diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 446d9441df..d9f99178fa 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -18,7 +18,7 @@ The API title comes from `info.title` in the spec, so fix naming and description API search matches parameter and field names, including prefixes and qualified paths. The docs-wide search matches a field only by its exact name, and only for a word that contains `_` or `.`, so a plain word keeps ranking pages first. Direct operation and endpoint matches rank above field matches. An operation also matches the words of its operation id and path, split on dots, hyphens, underscores and case changes, and the name of its group, so `ml flush`, `flush ML job` and `_ml _flush` all find `POST /_ml/anomaly_detectors/{job_id}/_flush`. Combine an endpoint and a field, such as `_search knn`, to find the operation and its matching fields. The order does not matter: `knn /_search`, `/_search knn`, `knn search` and `search knn` read the same. A word with a leading slash, or an HTTP method word right before one, is always the endpoint; on its own a word such as `options` or `delete` may name a field. With two plain words the index tries both orders and keeps the one whose endpoint exists, so both halves must match. With three or more plain words the last word is the field. Each matching operation lists up to three matching fields beneath it, with their paths, types, and locations. Child rows match the field's own name; ancestor names and descriptions provide context without adding unrelated children. Select a field to open its anchor, including fields inside collapsed sections. Arrow keys move through operations and fields in order; Enter opens the selected row. -When a query names a path, such as `_search`, the result shows one route in place of the description: the method the page leads with and the path with the match highlighted. The route whose whole path is the query word wins, so `/_search` shows `/_search`; otherwise the operation's primary route shows when it contains the word, and an alternate route only when the primary does not. Every result uses the same bordered box, with matching fields grouped beneath the operation when present. Matches use the same yellow tint as the destination property's flash. Navigating to a field reveals it and flashes its name, including when search replaces the current page without a full reload. Each result's breadcrumb ends with the operation's group, the same group the page shows. +When a query names a path, such as `_search`, the result shows one route in place of the description: the method the page leads with and the path with the match highlighted. The route whose whole path is the query word wins, so `/_search` shows `/_search`; otherwise the operation's primary route shows when it contains the word, and an alternate route only when the primary does not. Every result uses the same bordered box, with matching fields grouped beneath the operation when present. Matches use the same yellow tint as the destination property's flash. Navigating to a field reveals it and flashes its name, including when search replaces the current page without a full reload. Each result's breadcrumb ends with the operation's group, the same group the page shows. A deprecated operation is struck through and dimmed in the results, its method chip turns grey as in the sidebar, and the same outlined `Deprecated` badge as on its page sits beside the title at the method chip's height. The assembler indexes fields from the same property trees used to render operation pages. Fields of a repeated shape appear under its first listing. On the existing semantic index, the indexer adds the nested `api.properties` mapping before incremental synchronization. Property changes participate in the document hash, so a field update is synchronized even when the operation description stays the same. @@ -466,6 +466,8 @@ Every endpoint with authentication has an `Auth` badge in the row under its path A spec that declares no `security` shows no badge. +The sidebar marks an operation that the spec sets `deprecated: true` on, in both the legacy and the design-system look. Its title is struck through and dimmed, and its method chip turns grey. Screen readers hear the word "Deprecated" after the title. The row keeps its width, so long titles wrap as before. + Each listed scheme is resolved against `components.securitySchemes`, in the order the spec lists them: | OpenAPI scheme | Label | diff --git a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs index 8160ae88b3..36bdca8bc9 100644 --- a/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs +++ b/src/Elastic.ApiExplorer/Export/OpenApiDocumentExporter.cs @@ -167,7 +167,7 @@ public IEnumerable ConvertToDocuments(OpenApiDocument ope Method = method, Path = path.Key, OperationId = operationId, - Deprecated = operation.Value.Deprecated + Deprecated = apiOperation.Deprecated }; yield return new DocumentationDocument diff --git a/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs b/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs index b4d6671893..b31729e0ed 100644 --- a/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs +++ b/src/Elastic.ApiExplorer/Landing/ApiOverviewRow.cs @@ -237,7 +237,7 @@ bool designSystem showTooltip: false, designSystem: designSystem ), - Deprecated = operation.Model.Operation.Deprecated, + Deprecated = operation.Model.Deprecated, DesignSystem = designSystem }; } diff --git a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs index 55350e46e1..0fcffbd3ef 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs @@ -19,7 +19,7 @@ public static string Write(ApiOperation apiOperation, OperationPageModel page, A var operation = apiOperation.Operation; var title = OpenApiExtensionReader.GetSummary(operation) ?? apiOperation.DisplayName; ApiCommonMark.Heading(markdown, 1, title); - WriteBadges(markdown, operation, page); + WriteBadges(markdown, apiOperation.Deprecated, page); WriteServers(markdown, page); WritePaths(markdown, page); WritePathParameters(markdown, page, apiBaseUrl); @@ -40,9 +40,9 @@ public static string Write(ApiOperation apiOperation, OperationPageModel page, A return markdown.ToString(); } - private static void WriteBadges(StringBuilder markdown, OpenApiOperation operation, OperationPageModel page) + private static void WriteBadges(StringBuilder markdown, bool deprecated, OperationPageModel page) { - if (operation.Deprecated) + if (deprecated) ApiCommonMark.Paragraph(markdown, "deprecated"); if (page.AuthBadge is { } auth) ApiCommonMark.Paragraph(markdown, $"Authentication: {auth.Summary}"); diff --git a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs index 76e7463e35..f745629997 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationNavigationItem.cs @@ -19,11 +19,17 @@ public record ApiOperation( string Route, IOpenApiPathItem Path, string ApiName -) : IApiModel, IHttpMethodNavigationModel +) : IApiModel, IApiOperationNavigationModel { // The page header leads with the dominant method, which can come from the description's method listing // rather than the spec; the sidebar badge must agree with it. - string IHttpMethodNavigationModel.HttpMethod => OperationEndpoint.Build(this, Operation.Description, []).Rows[0].Method; + string IApiOperationNavigationModel.HttpMethod => OperationEndpoint.Build(this, Operation.Description, []).Rows[0].Method; + + /// + /// The one deprecation fact the page title badge, the overview rows, the sidebar row and the search index all read. + /// Collapsed siblings always agree on it, because operations that differ in lifecycle are never collapsed onto one page. + /// + public bool Deprecated => Operation.Deprecated; /// The title shown for the operation; an operationId used as the grouping key is shown as readable words. public string DisplayName => diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml index 4c334fd059..994abf3bb8 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml +++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml @@ -53,7 +53,7 @@ } - @await RenderPartialAsync(_TitleBadges.Create(new TitleBadgesModel(pageModel.Availability, operation.Deprecated, Model.BuildContext.Configuration.Features.NavigationPreviewEnabled, pageModel.AuthBadge?.Text, Model.AuthBadgeHref))) + @await RenderPartialAsync(_TitleBadges.Create(new TitleBadgesModel(pageModel.Availability, Model.Operation.Deprecated, Model.BuildContext.Configuration.Features.NavigationPreviewEnabled, pageModel.AuthBadge?.Text, Model.AuthBadgeHref)))
@if (!string.IsNullOrWhiteSpace(pageModel.DescriptionMarkdown) || pageModel.ExternalDocs is not null) diff --git a/src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs b/src/Elastic.Documentation.Navigation/IApiOperationNavigationModel.cs similarity index 68% rename from src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs rename to src/Elastic.Documentation.Navigation/IApiOperationNavigationModel.cs index 897982a3e8..439e812162 100644 --- a/src/Elastic.Documentation.Navigation/IHttpMethodNavigationModel.cs +++ b/src/Elastic.Documentation.Navigation/IApiOperationNavigationModel.cs @@ -5,10 +5,13 @@ namespace Elastic.Documentation.Navigation; /// -/// Navigation model for an API operation leaf that exposes its HTTP method for sidebar chrome. +/// Navigation model for an API operation leaf that exposes its HTTP method and deprecation for sidebar chrome. /// -public interface IHttpMethodNavigationModel : INavigationModel +public interface IApiOperationNavigationModel : INavigationModel { /// Lowercase HTTP method name (e.g. get, post). string HttpMethod { get; } + + /// True when the spec marks the operation deprecated. + bool Deprecated { get; } } diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 10701284ef..150632936e 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -994,6 +994,25 @@ border-color: var(--color-grey-100); } +#pages-nav a.nav-link--deprecated > span:first-child { + text-decoration: line-through; + opacity: 0.7; +} + +#pages-nav a.nav-link--deprecated .nav-v2-api-method { + background-color: var(--color-grey-20); + border-color: var(--color-grey-30); + color: var(--color-grey-100); +} + +#pages-nav a.nav-link--deprecated:hover .nav-v2-api-method { + border-color: var(--color-grey-60); +} + +#pages-nav a.nav-link--deprecated.current .nav-v2-api-method { + border-color: var(--color-grey-100); +} + .api-url { margin-left: calc(var(--spacing) * 2); min-width: 0; diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx index 06fa40e9c2..1a721d48fa 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.test.tsx @@ -277,3 +277,25 @@ describe('property rows', () => { expect(navigationSearchStore.getState().selectedIndex).toBe(3) }) }) + +describe('deprecated operations', () => { + beforeEach(() => { + navigationSearchStore.getState().actions.setSearchTerm('search') + }) + + it('shows a Deprecated badge beside the title of a deprecated operation', () => { + const { container } = renderList({ + groups: groupSearchResults([{ ...operation, deprecated: true }]), + }) + const badges = container.querySelectorAll( + '.title-text .api-title-badges .applicable-info.api-badge--danger' + ) + expect(badges).toHaveLength(1) + expect(badges[0]).toHaveTextContent('Deprecated') + }) + + it('shows no badge for an operation that is not deprecated', () => { + const { container } = renderList() + expect(container.querySelector('.api-badge--danger')).toBeNull() + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx index 84c3148797..c06c397b39 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/SearchResultsList.tsx @@ -312,6 +312,12 @@ const SearchResultRow = ({ } } } + + ${ + result.deprecated + ? `.route-line .api-method { background-color: var(--color-grey-20); border-color: var(--color-grey-30); color: var(--color-grey-100); }` + : '' + } `} >
- + <Title text={result.title} deprecated={result.deprecated} /> {route ? ( <RouteLine route={route} tokens={tokens} /> ) : ( @@ -415,7 +421,13 @@ const Breadcrumb = ({ items }: { items: string[] }) => { ) } -const Title = ({ text }: { text: string }) => { +const Title = ({ + text, + deprecated, +}: { + text: string + deprecated?: boolean +}) => { const { euiTheme } = useEuiTheme() return ( @@ -433,9 +445,54 @@ const Title = ({ text }: { text: string }) => { font-weight: ${euiTheme.font.weight.bold}; color: ${euiTheme.colors.textParagraph}; } + + ${ + deprecated + ? `& > span:first-child { text-decoration: line-through; opacity: 0.7; }` + : '' + } `} > <SanitizedHtmlContent htmlContent={text} /> + {deprecated && ( + <span + className="api-title-badges" + css={css` + /* An atomic inline, so the row's hover underline stops at the title text. Middle alignment lands 1px low beside the bold title. */ + display: inline-flex; + align-items: center; + position: relative; + top: -1px; + margin-left: ${euiTheme.size.s}; + vertical-align: middle; + line-height: 1; + font-weight: ${euiTheme.font.weight.regular}; + .applies { + display: inline-flex; + margin-right: 0; + } + .applicable-info { + display: inline-flex; + align-items: center; + height: calc( + ${euiTheme.size.base} + ${euiTheme.size.xxs} + ); + padding: 0 ${euiTheme.size.s}; + line-height: 1; + } + .applicable-name { + font-size: 10px; + line-height: 1; + } + `} + > + <span className="applies applies-inline"> + <span className="applicable-info api-badge--danger"> + <span className="applicable-name">Deprecated</span> + </span> + </span> + </span> + )} </div> ) } diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts index d65732b222..f93dffafc2 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.test.ts @@ -20,6 +20,7 @@ describe('SearchResponse', () => { title: 'Bulk API', description: 'Index multiple documents', score: 0.9, + deprecated: true, parents: [ { url: '/docs/api', title: 'API Reference' }, { @@ -36,5 +37,9 @@ describe('SearchResponse', () => { }) expect(parsed.results.map((item) => item.type)).toEqual(['docs', 'api']) + expect(parsed.results.map((item) => item.deprecated)).toEqual([ + undefined, + true, + ]) }) }) diff --git a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts index e12f2a3e93..f27013b59f 100644 --- a/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts +++ b/src/Elastic.Documentation.Site/Assets/web-components/NavigationSearch/useNavigationSearchQuery.ts @@ -45,6 +45,7 @@ const SearchResultItem = z.object({ parents: z.array(SearchResultItemParent), properties: z.array(ApiPropertyResult).optional(), routes: z.array(ApiRoute).optional(), + deprecated: z.boolean().optional(), }) export type SearchResultItem = z.infer<typeof SearchResultItem> diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs b/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs index 05d5939373..531c8515bb 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavSide/NavSideExamples.cs @@ -52,6 +52,7 @@ private static NavigationRenderNode Group(string id, string title, string url, p "#", Leaf("Search documents", "#", method: "post"), Leaf("Get document", "#", method: "get"), + Leaf("Get document source", "#", method: "get") with { Deprecated = true }, Leaf("Delete document", "#", method: "delete") ), Leaf("Query DSL", "#", badge: "ns"), diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml index f2e1895664..a42ce194c5 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeItems.cshtml @@ -18,7 +18,7 @@ } else { - <a class="nav-tree__link" href="@item.Url"@(item.Url.Contains('#') ? new HtmlString(" hx-boost=\"false\"") : HtmlString.Empty)> + <a class="nav-tree__link@(item.Deprecated ? " nav-tree__link--deprecated" : "")" href="@item.Url"@(item.Url.Contains('#') ? new HtmlString(" hx-boost=\"false\"") : HtmlString.Empty)> @await RenderPartialAsync(_NavTreeLabel.Create(item)) </a> } diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml index 395a660a80..f305349b87 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/_NavTreeLabel.cshtml @@ -1,6 +1,10 @@ @inherits RazorSlice<Elastic.Documentation.Site.Navigation.NavigationRenderNode> @using Elastic.Documentation.Site.Components.Navigation.NavTree <span class="nav-tree__text">@Model.NavigationTitle</span> +@if (Model.Deprecated) +{ + <span class="sr-only">Deprecated</span> +} @if (Model.Badge is { } badge) { @await RenderPartialAsync(_Badge.Create(new BadgeModel { Text = badge, Tone = Tone.Info })) diff --git a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css index dcd9b83f2a..f301410b6b 100644 --- a/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css +++ b/src/Elastic.Documentation.Site/Components/Navigation/NavTree/nav-tree.css @@ -176,6 +176,16 @@ color: var(--color-text-muted); } +.nav-tree__link--deprecated .nav-tree__text { + text-decoration: line-through; + opacity: 0.7; +} + +.nav-tree__link--deprecated .method { + --badge-tone: var(--color-text-muted); + --badge-surface: var(--color-surface-sunken); +} + .nav-tree__separator { height: var(--border-width); margin: var(--space-2) var(--space-3); diff --git a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs index b49a4748d1..d1073fe01e 100644 --- a/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs +++ b/src/Elastic.Documentation.Site/Navigation/NavigationRenderModel.cs @@ -32,7 +32,8 @@ public sealed record NavigationRenderNode public string? Badge { get; init; } /// <summary>Lowercase HTTP method for API operation leaves; drives the sidebar method glyph.</summary> public string? HttpMethod { get; init; } - /// <summary>True when the row groups several HTTP operations under one endpoint.</summary> + /// <summary>True for an API operation leaf the spec marks deprecated.</summary> + public bool Deprecated { get; init; } /// <summary>Only projected for nodes, where it drives the expand/collapse checkbox and its persisted state.</summary> public string? Id { get; init; } public bool ShowToggle { get; init; } @@ -396,16 +397,15 @@ private static NavigationRenderNode CreateNode(INodeNavigationItem<INavigationMo private static NavigationRenderNode CreateLeaf(INavigationItem item, bool isTopLevel) { var (badge, navigationTitle) = ParseNavTitle(item.NavigationTitle); - var httpMethod = item is ILeafNavigationItem<INavigationModel> { Model: IHttpMethodNavigationModel method } - ? method.HttpMethod - : null; + var operation = item is ILeafNavigationItem<INavigationModel> { Model: IApiOperationNavigationModel op } ? op : null; return new NavigationRenderNode { Kind = NavigationRenderNodeKind.Leaf, IsTopLevel = isTopLevel, NavigationTitle = navigationTitle, Badge = badge, - HttpMethod = httpMethod, + HttpMethod = operation?.HttpMethod, + Deprecated = operation?.Deprecated ?? false, Url = item.Url }; } @@ -469,9 +469,11 @@ private static void AppendNode(IncrementalHash hash, NavigationRenderNode node) Append(hash, node.Url); Append(hash, node.Id ?? string.Empty); AppendInt(hash, node.ShowToggle ? 1 : 0); - // Hashed only when set, so trees without an expanded group keep the hash, and so the tree id, they had before. + // Flags are hashed only when set, each with its own marker, so trees without them keep the hash, and so the tree id, they had before. if (node.Expanded) AppendInt(hash, 1); + if (node.Deprecated) + AppendInt(hash, 2); AppendNodes(hash, node.NavigationItems); } diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml index b9f88c0306..0d1e05a599 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNav.cshtml @@ -9,8 +9,9 @@ else if (item.Kind == NavigationRenderNodeKind.Leaf) { <li class="flex group/li"> - <a href="@item.Url" class="sidebar-link nav-link nav-v2-link"@(item.Url.Contains('#') ? new Microsoft.AspNetCore.Html.HtmlString(" hx-boost=\"false\"") : Microsoft.AspNetCore.Html.HtmlString.Empty)> + <a href="@item.Url" class="sidebar-link nav-link nav-v2-link@(item.Deprecated ? " nav-link--deprecated" : "")"@(item.Url.Contains('#') ? new Microsoft.AspNetCore.Html.HtmlString(" hx-boost=\"false\"") : Microsoft.AspNetCore.Html.HtmlString.Empty)> <span class="nav-v2-nav-text">@item.NavigationTitle</span> + @if (item.Deprecated) { <span class="sr-only">Deprecated</span> } @if (item.Badge is not null) { <span class="nav-badge nav-badge-@item.Badge">@item.Badge</span> } @await RenderPartialAsync(_NavHttpMethodGlyph.Create(item)) </a> diff --git a/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml b/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml index 985fb487f0..6005206501 100644 --- a/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml +++ b/src/Elastic.Documentation.Site/Navigation/_TocTreeNavLegacy.cshtml @@ -9,8 +9,9 @@ else if (item.Kind == NavigationRenderNodeKind.Leaf) { <li class="flex group/li pr-8 @(item.IsTopLevel ? "font-semibold mt-6" : "mt-4")"> - <a href="@item.Url" class="sidebar-link nav-link"@(item.Url.Contains('#') ? new Microsoft.AspNetCore.Html.HtmlString(" hx-boost=\"false\"") : Microsoft.AspNetCore.Html.HtmlString.Empty)> + <a href="@item.Url" class="sidebar-link nav-link@(item.Deprecated ? " nav-link--deprecated" : "")"@(item.Url.Contains('#') ? new Microsoft.AspNetCore.Html.HtmlString(" hx-boost=\"false\"") : Microsoft.AspNetCore.Html.HtmlString.Empty)> <span>@item.NavigationTitle</span> + @if (item.Deprecated) { <span class="sr-only">Deprecated</span> } @if (item.Badge is not null) { <span class="nav-badge nav-badge-@item.Badge">@item.Badge</span> } @await RenderPartialAsync(_NavHttpMethodGlyph.Create(item)) </a> diff --git a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs index 578c14c05c..5c0eee9f8e 100644 --- a/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/DefaultSearchService.cs @@ -46,7 +46,9 @@ public partial class DefaultSearchService<TDocument>( "api.method", "api.path", "api.operation_id", - "api.routes" + "api.routes", + // optional, so the required-member test cannot catch its absence; without it every hit reads as current + "api.deprecated" ]; private static readonly string[] SearchSourceIncludes = diff --git a/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs b/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs index 0a31b72d48..8936d4c18d 100644 --- a/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/INavigationSearchService.cs @@ -53,6 +53,9 @@ public record NavigationSearchResultItem /// <summary>The operation's routes with their methods; empty for a docs page.</summary> public ApiRouteEntry[] Routes { get; init; } = []; + + /// <summary>True when the result is an API operation the spec marks deprecated; false for a docs page.</summary> + public bool Deprecated { get; init; } public required string Type { get; init; } public required string Url { get; init; } public required string Title { get; init; } diff --git a/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs b/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs index 1c6b405f69..8c376f8263 100644 --- a/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs +++ b/src/services/search/Elastic.Documentation.Search/NavigationSearchService.cs @@ -53,6 +53,7 @@ public async Task<NavigationSearchResponse> NavigationSearchAsync(NavigationSear Title = item.Title, Description = item.Description, Routes = item.Document.Api?.Routes ?? [], + Deprecated = item.Document.Api?.Deprecated ?? false, Properties = item .Properties .Select( diff --git a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs index 10369a9210..9156d5e1c7 100644 --- a/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ApiPagesNavRenderingTests.cs @@ -238,6 +238,39 @@ public async Task Render_OperationRow_ShowsVerbTextAfterOperationName(string met link.Should().NotContain("aria-label"); link.Should().NotContain("icon-api-arrow"); link.Should().NotContain("icon-api-x"); + link.Should().NotContain("deprecated"); + } + + [Test] + public async Task Render_DeprecatedOperationRow_MarksTheLinkAndNamesItForScreenReaders() + { + NavigationRenderNode[] nodes = + [ + new() + { + Kind = NavigationRenderNodeKind.Leaf, + IsTopLevel = false, + NavigationTitle = "Get a document source", + Url = "/api/doc/elasticsearch/operation/operation-get-source", + HttpMethod = "get", + Deprecated = true + } + ]; + var ct = TestContext.Current!.Execution.CancellationToken; + + foreach (var html in new[] + { + await _TocTreeNav.Create(nodes).RenderAsync(cancellationToken: ct), + await _TocTreeNavLegacy.Create(nodes).RenderAsync(cancellationToken: ct) + }) + { + var link = OperationLink(html); + link.Should().MatchRegex("class=\"sidebar-link nav-link( nav-v2-link)? nav-link--deprecated\""); + var hidden = link.IndexOf("<span class=\"sr-only\">Deprecated</span>", StringComparison.Ordinal); + hidden.Should().BeGreaterThan(link.IndexOf("Get a document source", StringComparison.Ordinal)); + hidden.Should().BeLessThan(link.IndexOf(">GET<", StringComparison.Ordinal)); + link.Should().NotContain("aria-label"); + } } [Test] diff --git a/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs new file mode 100644 index 0000000000..d668f53e63 --- /dev/null +++ b/tests/Elastic.ApiExplorer.Tests/OperationDeprecatedProjectionTests.cs @@ -0,0 +1,108 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.ApiExplorer.Export; +using Elastic.ApiExplorer.Operations; +using Elastic.Documentation; +using Elastic.Documentation.Configuration.Versions; +using Elastic.Documentation.Site.Navigation; +using Elastic.Documentation.Versions; + +namespace Elastic.ApiExplorer.Tests; + +[ClassDataSource<ApiExplorerFixture>(Shared = SharedType.PerClass)] +public class OperationDeprecatedProjectionTests(ApiExplorerFixture fixture) +{ + private const string SpecTemplate = + """ + { + "openapi":"3.0.3", "info":{"title":"Lifecycle","version":"1"}, + "paths":{"/thing":{"get":{"operationId":"thing-get","summary":"Get a thing",DEPRECATED"responses":{"200":{"description":"OK"}}}}} + } + """; + + [Test] + public void Deprecated_SpecFlag_ReadsThroughApiOperation() + { + Operation("docs-get-source").Model.Deprecated.Should().BeTrue(); + Operation("docs-get").Model.Deprecated.Should().BeFalse(); + } + + [Test] + public void CreateNavigation_CollapsedSiblings_AgreeOnDeprecated() + { + foreach (var navigation in fixture.Walk().OfType<OperationNavigationItem>()) + navigation.Siblings.Should().OnlyContain(s => s.Deprecated == navigation.Model.Deprecated); + } + + [Test] + public void Create_DeprecatedOperation_ProjectsDeprecatedOntoTheLeaf() + { + var model = NavigationRenderModel.Create( + fixture.Navigation, + [fixture.Navigation], + isUsingNavigationDropdown: false, + isPrimaryNavEnabled: false, + isGlobalAssemblyBuild: false + ); + var leaves = Flatten(model.Tree).ToArray(); + + var deprecated = leaves.Single(n => n.Url == Operation("docs-get-source").Url); + deprecated.Deprecated.Should().BeTrue(); + deprecated.HttpMethod.Should().Be("get"); + leaves.Single(n => n.Url == Operation("docs-get").Url).Deprecated.Should().BeFalse(); + } + + [Test] + public async Task Create_FlippingDeprecated_ChangesTheContentHash() + { + var current = await HashAsync(SpecTemplate.Replace("DEPRECATED", "", StringComparison.Ordinal)); + var currentAgain = await HashAsync(SpecTemplate.Replace("DEPRECATED", "", StringComparison.Ordinal)); + var deprecated = await HashAsync(SpecTemplate.Replace("DEPRECATED", "\"deprecated\":true,", StringComparison.Ordinal)); + + currentAgain.Should().Be(current); + deprecated.Should().NotBe(current); + } + + [Test] + public void ConvertToDocuments_DeprecatedOperation_SetsApiDeprecated() + { + var versions = new VersionsConfiguration + { + VersioningSystems = new Dictionary<VersioningSystemId, VersioningSystem> + { + [VersioningSystemId.Stack] = new VersioningSystem + { + Id = VersioningSystemId.Stack, + Base = new SemVersion(8, 0, 0), + Current = new SemVersion(99, 0, 0) + } + } + }; + var documents = new OpenApiDocumentExporter(versions).ConvertToDocuments(fixture.Document, "fixture").ToArray(); + + documents.Single(d => d.Api!.OperationId == "docs-get-source").Api!.Deprecated.Should().BeTrue(); + documents.Single(d => d.Api!.OperationId == "docs-get").Api!.Deprecated.Should().BeFalse(); + } + + private OperationNavigationItem Operation(string operationId) => + fixture.Walk().OfType<OperationNavigationItem>().Single(n => n.Model.Operation.OperationId == operationId); + + private static IEnumerable<NavigationRenderNode> Flatten(IEnumerable<NavigationRenderNode> nodes) => + nodes.SelectMany(n => Flatten(n.NavigationItems).Prepend(n)); + + private static async Task<string> HashAsync(string json) + { + var (generator, document) = await TestSpecs.CreateGeneratorWithSpecAsync(json); + var navigation = generator.CreateNavigation("lifecycle", document); + return NavigationRenderModel.Create( + navigation, + [navigation], + isUsingNavigationDropdown: false, + isPrimaryNavEnabled: false, + isGlobalAssemblyBuild: false + ).ContentHash; + } +} diff --git a/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs index 19a6df2c62..3b8906305f 100644 --- a/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OperationEndpointTests.cs @@ -24,8 +24,8 @@ public void HttpMethod_ListingAddsDominantMethod_UsesDominant() }; var withoutListing = Op(HttpMethod.Get, "/_cluster/health"); - ((IHttpMethodNavigationModel)withListing).HttpMethod.Should().Be("post"); - ((IHttpMethodNavigationModel)withoutListing).HttpMethod.Should().Be("get"); + ((IApiOperationNavigationModel)withListing).HttpMethod.Should().Be("post"); + ((IApiOperationNavigationModel)withoutListing).HttpMethod.Should().Be("get"); } private static ApiOperation Op(HttpMethod method, string route, string? operationId = null, params string[] queryParameters) => diff --git a/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs b/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs index 3a324ee236..eab77f0f57 100644 --- a/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/AutocompleteSourceIncludesTests.cs @@ -30,4 +30,8 @@ public void AutocompleteSourceIncludes_CoverEveryRequiredApiMember() DefaultSearchService<DocumentationDocument>.AutocompleteSourceIncludes.Should().Contain(required); DefaultSearchService<DocumentationDocument>.AutocompleteSourceIncludes.Should().Contain("api.routes"); } + + [Test] + public void AutocompleteSourceIncludes_ContainTheDeprecatedFlag() => + DefaultSearchService<DocumentationDocument>.AutocompleteSourceIncludes.Should().Contain("api.deprecated"); } diff --git a/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs b/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs index 55d0648edf..ec1ced310b 100644 --- a/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs +++ b/tests/Elastic.Documentation.Api.Tests/NavigationSearchServiceTests.cs @@ -18,7 +18,6 @@ public class NavigationSearchServiceTests [Test] public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperationPage() { - var inner = A.Fake<ISearchService<DocumentationDocument>>(); var document = new DocumentationDocument { Title = "Search", @@ -46,6 +45,48 @@ public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperati Anchor = "req-knn", Description = "Approximate kNN" }; + + var result = await SearchAsync(document, [property]); + + result.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search"); + var linked = result.Properties.Single(); + linked.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search#req-knn"); + linked.Anchor.Should().Be("req-knn"); + linked.Location.Should().Be("request"); + result + .Routes + .Select(r => (r.Path, string.Join(",", r.Methods))) + .Should() + .Equal(("/{index}/_search", "POST,GET"), ("/_search", "POST,GET")); + result.Deprecated.Should().BeFalse(); + } + + [Test] + public async Task NavigationSearchAsync_DeprecatedOperation_FlagsTheResult() + { + var document = new DocumentationDocument + { + Title = "Get a document source", + SearchTitle = "Get a document source", + Path = "/docs/api/doc/elasticsearch/operation/operation-get-source", + Api = new ApiOperationMetadata + { + Endpoints = ["_source"], + Method = "GET", + Path = "/{index}/_source/{id}", + OperationId = "get-source", + Deprecated = true + } + }; + + var result = await SearchAsync(document, []); + + result.Deprecated.Should().BeTrue(); + } + + private static async Task<NavigationSearchResultItem> SearchAsync(DocumentationDocument document, ApiPropertyEntry[] properties) + { + var inner = A.Fake<ISearchService<DocumentationDocument>>(); _ = A.CallTo( () => inner.AutocompleteAsync(A<AutocompleteRequest>._, A<CancellationToken>._) ).Returns(new AutocompleteResponse<DocumentationDocument> @@ -55,10 +96,10 @@ public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperati new SearchResultItem<DocumentationDocument> { Document = document, - Title = "Search", + Title = document.Title, Description = "Run a search", Score = 5, - Properties = [property] + Properties = properties } ], TotalResults = 1, @@ -75,18 +116,7 @@ public async Task NavigationSearchAsync_PropertyHit_LinksToTheAnchorOnTheOperati ); using var service = new NavigationSearchService(inner, accessor, NullLogger<NavigationSearchService>.Instance); - var response = await service.NavigationSearchAsync(new NavigationSearchRequest { Query = "knn", TypeFilter = "api" }); - - var result = response.Results.Single(); - result.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search"); - var linked = result.Properties.Single(); - linked.Url.Should().Be("/docs/api/doc/elasticsearch/operation/operation-search#req-knn"); - linked.Anchor.Should().Be("req-knn"); - linked.Location.Should().Be("request"); - result - .Routes - .Select(r => (r.Path, string.Join(",", r.Methods))) - .Should() - .Equal(("/{index}/_search", "POST,GET"), ("/_search", "POST,GET")); + var response = await service.NavigationSearchAsync(new NavigationSearchRequest { Query = "q", TypeFilter = "api" }); + return response.Results.Single(); } } diff --git a/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs b/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs index 276b790158..87ec6f2c80 100644 --- a/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs +++ b/tests/Navigation.Tests/DesignSystem/NavSideRenderingTests.cs @@ -49,4 +49,38 @@ public async Task Tree_MarksAbsoluteLinksAsExternal() html.Should().Contain("nav-tree__link--external").And.Contain("target=\"_blank\"").And.Contain("rel=\"noopener\""); } + + [Test] + public async Task Tree_DeprecatedOperation_DimsTheRowAndNamesItForScreenReaders() + { + var html = await RenderAsync(NavSideExamples.Navigation with { Tree = [Operation(deprecated: true)] }); + + html.Should().Contain("class=\"nav-tree__link nav-tree__link--deprecated\""); + var text = html.IndexOf("class=\"nav-tree__text\"", StringComparison.Ordinal); + var hidden = html.IndexOf("<span class=\"sr-only\">Deprecated</span>", StringComparison.Ordinal); + var method = html.IndexOf("method method--sm", StringComparison.Ordinal); + hidden.Should().BeGreaterThan(text); + hidden.Should().BeLessThan(method); + html.Should().Contain("class=\"badge badge--info method method--sm\""); + } + + [Test] + public async Task Tree_OperationNotDeprecated_HasNoDeprecatedModifier() + { + var html = await RenderAsync(NavSideExamples.Navigation with { Tree = [Operation(deprecated: false)] }); + + html.Should().Contain("class=\"nav-tree__link\""); + html.Should().NotContain("deprecated").And.NotContain("Deprecated"); + } + + private static NavigationRenderNode Operation(bool deprecated) => + new() + { + Kind = NavigationRenderNodeKind.Leaf, + IsTopLevel = false, + NavigationTitle = "Get document source", + Url = "/api/doc/elasticsearch/operation/operation-get-source", + HttpMethod = "get", + Deprecated = deprecated + }; }