diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs index 4d5176e195..d7d66f8c74 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs @@ -9,17 +9,26 @@ namespace Elastic.ApiExplorer.Infrastructure; public static class ApiHubSwitcher { - public static IReadOnlyList Build(IReadOnlyList entries, string? currentApiKey) + /// The catalog's groups, so the menu and the landing page sort the APIs into the same bands. + public static IReadOnlyList Build(IReadOnlyList entries, string? currentApiKey) { if (entries.Count == 0 || currentApiKey is null) return []; return [ - .. entries - .OrderBy(e => ApiCatalogOrder.Rank(e.Key)) - .ThenBy(e => e.Title, StringComparer.OrdinalIgnoreCase) - .ThenBy(e => e.Key, StringComparer.Ordinal) - .Select(e => new ApiContextProduct(e.Title, e.Url, e.Key == currentApiKey, e.IconSvg)) + .. ApiCatalogViewModel.Group(entries).Select( + g => new ApiContextProductGroup( + g.Title, + [ + .. g + .Entries + .OrderBy(e => ApiCatalogOrder.Rank(e.Key)) + .ThenBy(e => e.Title, StringComparer.OrdinalIgnoreCase) + .ThenBy(e => e.Key, StringComparer.Ordinal) + .Select(e => new ApiContextProduct(e.Title, e.Url, e.Key == currentApiKey, e.IconSvg)) + ] + ) + ) ]; } } diff --git a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs index b47d80f541..80a97b2cf0 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs @@ -142,13 +142,15 @@ private ApiContextBar BuildApiContext(string docTitle, string catalogUrl, string { var productId = RenderContext.Product?.Id ?? RenderContext.CurrentApiKey; var versions = RenderContext.VersionSwitcherItems; - var products = ApiHubSwitcher.Build(RenderContext.CatalogEntries, RenderContext.CurrentApiKey); + var productGroups = ApiHubSwitcher.Build(RenderContext.CatalogEntries, RenderContext.CurrentApiKey); // Same text as the menu row, so the bar and the menu name the API alike. - var title = products.FirstOrDefault(static p => p.Selected)?.Label ?? RenderContext.Product?.DisplayName ?? docTitle; + var title = productGroups.SelectMany(static g => g.Products).FirstOrDefault(static p => p.Selected)?.Label + ?? RenderContext.Product?.DisplayName + ?? docTitle; return new ApiContextBar( title, ProductIcons.Get(productId), - products, + productGroups, catalogUrl, [.. versions.Select(static v => new NavigationSelectOption(v.Label, v.Url, v.Selected))], new ApiSpecDownload(ApiOutputPaths.JsonUrl(specRootUrl), ApiOutputPaths.YamlUrl(specRootUrl)) diff --git a/src/Elastic.Documentation.Site/Assets/api-context-bar.css b/src/Elastic.Documentation.Site/Assets/api-context-bar.css index 86c5f7612c..c297b31e0e 100644 --- a/src/Elastic.Documentation.Site/Assets/api-context-bar.css +++ b/src/Elastic.Documentation.Site/Assets/api-context-bar.css @@ -127,6 +127,17 @@ gap: 8px; } + .api-context-menu-group-label { + padding: 4px 8px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + line-height: 16px; + text-transform: uppercase; + color: #637290; + user-select: none; + } + .api-context-menu-sep { height: 1px; margin: 6px 0; diff --git a/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml b/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml index db30eea6fc..7451d41433 100644 --- a/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml +++ b/src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml @@ -1,7 +1,7 @@ @using Microsoft.AspNetCore.Html @inherits RazorSlice @* The menu is a popover (top layer), positioned by api-menu.ts. *@ -@if (Model.Products.Count > 0) +@if (Model.ProductGroups.Count > 0) {
- @foreach (var product in Model.Products) + @for (var g = 0; g < Model.ProductGroups.Count; g++) { - var selectedClass = product.Selected ? " secondary-nav-dropdown-link--selected" : ""; - - @if (product.IconSvg is not null) + var group = Model.ProductGroups[g]; + var labelId = "api-hub-group-" + g; + if (g > 0) + { + + } +
+ @if (group.Title is not null) { - +
@group.Title
} - @product.Label -
+ @foreach (var product in group.Products) + { + var selectedClass = product.Selected ? " secondary-nav-dropdown-link--selected" : ""; + + @if (product.IconSvg is not null) + { + + } + @product.Label + + } +
} @if (Model.HubUrl is not null) { diff --git a/src/Elastic.Documentation.Site/_ViewModels.cs b/src/Elastic.Documentation.Site/_ViewModels.cs index f9a20d23c8..9b112ddaba 100644 --- a/src/Elastic.Documentation.Site/_ViewModels.cs +++ b/src/Elastic.Documentation.Site/_ViewModels.cs @@ -42,11 +42,14 @@ public sealed record ApiSpecDownload(string JsonUrl, string YamlUrl); /// One API product in the context bar's product menu. public sealed record ApiContextProduct(string Label, string Url, bool Selected, string? IconSvg); +/// A labelled band of the product menu. is null for a group that needs no label. +public sealed record ApiContextProductGroup(string? Title, IReadOnlyList Products); + /// The identity of the current API: product, its logo, the other products, the versions and the spec downloads. public sealed record ApiContextBar( string Title, string? IconSvg, - IReadOnlyList Products, + IReadOnlyList ProductGroups, string? HubUrl, IReadOnlyList Versions, ApiSpecDownload Spec diff --git a/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs index cd7eead28d..3fd32d8738 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs @@ -31,13 +31,21 @@ public void Build_OrdersEntriesAlphabeticallyByTitle() { var items = ApiHubSwitcher.Build([Entry("kibana", "Kibana"), Entry("elasticsearch", "Elasticsearch")], currentApiKey: "kibana"); - items.Select(i => i.Label).Should().Equal("Elasticsearch", "Kibana"); + items.SelectMany(g => g.Products).Select(i => i.Label).Should().Equal("Elasticsearch", "Kibana"); } [Test] - public void Build_ListsStackWithVariantsThenCloudThenTheRestByTitle() + public void Build_SingleGroupHasNoTitle() { - var items = ApiHubSwitcher.Build( + var groups = ApiHubSwitcher.Build([Entry("kibana", "Kibana"), Entry("elasticsearch", "Elasticsearch")], currentApiKey: "kibana"); + + groups.Should().ContainSingle().Which.Title.Should().BeNull(); + } + + [Test] + public void Build_GroupsMatchTheCatalogLanding() + { + var groups = ApiHubSwitcher.Build( [ Entry("zeta", "Zeta API"), Entry("kibana-serverless", "Kibana Serverless APIs"), @@ -53,21 +61,14 @@ public void Build_ListsStackWithVariantsThenCloudThenTheRestByTitle() currentApiKey: "kibana" ); - items - .Select(i => i.Label) + groups.Select(g => g.Title).Should().Equal("Elastic Stack", "Orchestration", null); + groups[0] + .Products + .Select(p => p.Label) .Should() - .Equal( - "Elasticsearch API", - "Elasticsearch Serverless API", - "Kibana APIs", - "Kibana Serverless APIs", - "Logstash APIs", - "Elastic Cloud API", - "Cloud Billing API", - "Elastic Cloud Serverless API", - "Alpha API", - "Zeta API" - ); + .Equal("Elasticsearch API", "Elasticsearch Serverless API", "Kibana APIs", "Kibana Serverless APIs", "Logstash APIs"); + groups[1].Products.Select(p => p.Label).Should().Equal("Elastic Cloud API", "Cloud Billing API", "Elastic Cloud Serverless API"); + groups[2].Products.Select(p => p.Label).Should().Equal("Alpha API", "Zeta API"); } [Test] @@ -78,8 +79,8 @@ public void Build_SelectedMatchesCurrentKey() currentApiKey: "elasticsearch" ); - items.Count(i => i.Selected).Should().Be(1); - items.Single(i => i.Selected).Label.Should().Be("Elasticsearch"); + items.SelectMany(g => g.Products).Count(i => i.Selected).Should().Be(1); + items.SelectMany(g => g.Products).Single(i => i.Selected).Label.Should().Be("Elasticsearch"); } [Test] @@ -87,7 +88,7 @@ public void Build_ResolvesTheProductIcon() { var items = ApiHubSwitcher.Build([Entry("elasticsearch", "Elasticsearch")], currentApiKey: "elasticsearch"); - items.Should().ContainSingle().Which.IconSvg.Should().Contain(" g.Products).Should().ContainSingle().Which.IconSvg.Should().Contain(" new(key, title, $"/api/doc/{key}/"); diff --git a/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs index aa230cb28c..0d40744a30 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiLayoutVersionDropdownTests.cs @@ -49,9 +49,9 @@ public void CreateGlobalLayoutModel_DescribesTheCurrentApiInTheContextBar() layout.ApiContext.Should().NotBeNull(); layout.ApiContext!.Title.Should().Be("Elasticsearch"); layout.ApiContext.HubUrl.Should().Be("/docs/api"); - layout.ApiContext.Products.Select(p => p.Label).Should().Equal("Elasticsearch", "Kibana"); - layout.ApiContext.Products.Should().ContainSingle(p => p.Selected && p.Label == "Elasticsearch"); - layout.ApiContext.Products.Should().OnlyContain(p => p.IconSvg != null); + layout.ApiContext.ProductGroups.SelectMany(g => g.Products).Select(p => p.Label).Should().Equal("Elasticsearch", "Kibana"); + layout.ApiContext.ProductGroups.SelectMany(g => g.Products).Should().ContainSingle(p => p.Selected && p.Label == "Elasticsearch"); + layout.ApiContext.ProductGroups.SelectMany(g => g.Products).Should().OnlyContain(p => p.IconSvg != null); layout.ApiContext.Versions.Should().HaveCount(3); layout.Breadcrumbs.Select(b => b.NavigationTitle).Should().Equal("Docs", "APIs"); layout.Breadcrumbs.Select(b => b.Url).Should().Equal("/docs", "/docs/api"); @@ -63,7 +63,7 @@ public void CreateGlobalLayoutModel_BuildsTheContextBarInIsolatedToo() var layout = CreateLayout(catalogEntries: Catalog); layout.ApiContext.Should().NotBeNull(); - layout.ApiContext!.Products.Should().HaveCount(2); + layout.ApiContext!.ProductGroups.SelectMany(g => g.Products).Should().HaveCount(2); } private static readonly ApiCatalogEntry[] Catalog = diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index 2dbd039a43..8efde958e3 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -291,8 +291,13 @@ private static ApiContextBar CreateApiContext(IReadOnlyList", [ - new ApiContextProduct("Elasticsearch API", "/docs/api/doc/elasticsearch/", true, ""), - new ApiContextProduct("Kibana APIs", "/docs/api/doc/kibana/", false, null) + new ApiContextProductGroup( + "Elastic Stack", + [ + new ApiContextProduct("Elasticsearch API", "/docs/api/doc/elasticsearch/", true, ""), + new ApiContextProduct("Kibana APIs", "/docs/api/doc/kibana/", false, null) + ] + ), + new ApiContextProductGroup(null, [new ApiContextProduct("Alpha API", "/docs/api/doc/alpha/", false, null)]) ], "/docs/api/", [ @@ -194,6 +200,13 @@ public async Task ApiContextBarShowsTheApiAndItsSpecDownload() // Product and version together on the left, the spec download alone on the right. html.Should().Contain("id=\"api-hub-switcher\"").And.Contain("popovertarget=\"api-hub-menu\""); + html.Should().Contain("api-context-menu-group-label\">Elastic Stack
").And.Contain("role=\"group\""); + // A group without a title gets no label, and the separator sits between the groups. + html.Should().NotContain("id=\"api-hub-group-1\""); + var groupOrder = new[] { "Kibana APIs", "api-context-menu-sep", "Alpha API" }.Select( + t => html.IndexOf(t, StringComparison.Ordinal) + ).ToArray(); + groupOrder.Should().OnlyContain(p => p >= 0).And.BeInAscendingOrder(); html.Should().Contain("id=\"api-version-switcher\"").And.Contain("popovertarget=\"api-version-menu\""); html.Should().Contain("Download OpenAPI spec").And.Contain("popovertarget=\"api-spec-menu\""); html.Should().Contain("href=\"/docs/api/doc/elasticsearch.json\"").And.Contain("download");