Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions src/Elastic.ApiExplorer/Infrastructure/ApiHubSwitcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,26 @@ namespace Elastic.ApiExplorer.Infrastructure;

public static class ApiHubSwitcher
{
public static IReadOnlyList<ApiContextProduct> Build(IReadOnlyList<ApiCatalogEntry> entries, string? currentApiKey)
/// <summary>The catalog's groups, so the menu and the landing page sort the APIs into the same bands.</summary>
public static IReadOnlyList<ApiContextProductGroup> Build(IReadOnlyList<ApiCatalogEntry> 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))
]
)
)
];
}
}
8 changes: 5 additions & 3 deletions src/Elastic.ApiExplorer/Infrastructure/ApiViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 11 additions & 0 deletions src/Elastic.Documentation.Site/Assets/api-context-bar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 27 additions & 12 deletions src/Elastic.Documentation.Site/Layout/_ApiProductSwitcher.cshtml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@using Microsoft.AspNetCore.Html
@inherits RazorSlice<Elastic.Documentation.Site.ApiContextBar>
@* The menu is a popover (top layer), positioned by api-menu.ts. *@
@if (Model.Products.Count > 0)
@if (Model.ProductGroups.Count > 0)
{
<div class="api-context-product" id="api-hub-switcher">
<button type="button"
Expand All @@ -18,20 +18,35 @@
</span>
</button>
<div id="api-hub-menu" class="api-menu" popover data-placement="bottom-start" aria-label="API product">
@foreach (var product in Model.Products)
@for (var g = 0; g < Model.ProductGroups.Count; g++)
{
var selectedClass = product.Selected ? " secondary-nav-dropdown-link--selected" : "";
<a class="secondary-nav-dropdown-link api-context-product-link@(selectedClass)"
href="@product.Url"
aria-current="@(product.Selected ? "true" : null)"
preload="mousedown"
hx-boost="false">
@if (product.IconSvg is not null)
var group = Model.ProductGroups[g];
var labelId = "api-hub-group-" + g;
if (g > 0)
{
<div class="api-context-menu-sep" role="separator"></div>
}
<div role="group" aria-labelledby="@(group.Title is null ? null : labelId)">
@if (group.Title is not null)
{
<span class="api-context-logo api-context-logo--sm" aria-hidden="true">@(new HtmlString(product.IconSvg))</span>
<div id="@labelId" class="api-context-menu-group-label">@group.Title</div>
}
<span>@product.Label</span>
</a>
@foreach (var product in group.Products)
{
var selectedClass = product.Selected ? " secondary-nav-dropdown-link--selected" : "";
<a class="secondary-nav-dropdown-link api-context-product-link@(selectedClass)"
href="@product.Url"
aria-current="@(product.Selected ? "true" : null)"
preload="mousedown"
hx-boost="false">
@if (product.IconSvg is not null)
{
<span class="api-context-logo api-context-logo--sm" aria-hidden="true">@(new HtmlString(product.IconSvg))</span>
}
<span>@product.Label</span>
</a>
}
</div>
}
@if (Model.HubUrl is not null)
{
Expand Down
5 changes: 4 additions & 1 deletion src/Elastic.Documentation.Site/_ViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,14 @@ public sealed record ApiSpecDownload(string JsonUrl, string YamlUrl);
/// <summary>One API product in the context bar's product menu.</summary>
public sealed record ApiContextProduct(string Label, string Url, bool Selected, string? IconSvg);

/// <summary>A labelled band of the product menu. <see cref="Title"/> is null for a group that needs no label.</summary>
public sealed record ApiContextProductGroup(string? Title, IReadOnlyList<ApiContextProduct> Products);

/// <summary>The identity of the current API: product, its logo, the other products, the versions and the spec downloads.</summary>
public sealed record ApiContextBar(
string Title,
string? IconSvg,
IReadOnlyList<ApiContextProduct> Products,
IReadOnlyList<ApiContextProductGroup> ProductGroups,
string? HubUrl,
IReadOnlyList<NavigationSelectOption> Versions,
ApiSpecDownload Spec
Expand Down
41 changes: 21 additions & 20 deletions tests/Elastic.ApiExplorer.Tests/ApiHubSwitcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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]
Expand All @@ -78,16 +79,16 @@ 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]
public void Build_ResolvesTheProductIcon()
{
var items = ApiHubSwitcher.Build([Entry("elasticsearch", "Elasticsearch")], currentApiKey: "elasticsearch");

items.Should().ContainSingle().Which.IconSvg.Should().Contain("<svg");
items.SelectMany(g => g.Products).Should().ContainSingle().Which.IconSvg.Should().Contain("<svg");
}

private static ApiCatalogEntry Entry(string key, string title) => new(key, title, $"/api/doc/{key}/");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,13 @@ private static ApiContextBar CreateApiContext(IReadOnlyList<NavigationSelectOpti
"Elasticsearch API",
IconSvg: null,
[
new("Elasticsearch", "/api/doc/elasticsearch/", Selected: true, IconSvg: null),
new("Kibana", "/api/doc/kibana/", Selected: false, IconSvg: null)
new(
Title: null,
[
new("Elasticsearch", "/api/doc/elasticsearch/", Selected: true, IconSvg: null),
new("Kibana", "/api/doc/kibana/", Selected: false, IconSvg: null)
]
)
],
HubUrl: "/api/",
versions ?? [],
Expand Down
17 changes: 15 additions & 2 deletions tests/Navigation.Tests/Rendering/SecondaryNavRenderingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,14 @@ public async Task ApiContextBarShowsTheApiAndItsSpecDownload()
"Elasticsearch API",
"<svg></svg>",
[
new ApiContextProduct("Elasticsearch API", "/docs/api/doc/elasticsearch/", true, "<svg></svg>"),
new ApiContextProduct("Kibana APIs", "/docs/api/doc/kibana/", false, null)
new ApiContextProductGroup(
"Elastic Stack",
[
new ApiContextProduct("Elasticsearch API", "/docs/api/doc/elasticsearch/", true, "<svg></svg>"),
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/",
[
Expand All @@ -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</div>").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");
Expand Down
Loading