diff --git a/docs/data/openapi/api-explorer.md b/docs/data/openapi/api-explorer.md index 0bd74a5624..3063adff42 100644 --- a/docs/data/openapi/api-explorer.md +++ b/docs/data/openapi/api-explorer.md @@ -320,13 +320,15 @@ A request body leaves out `readOnly` fields, because only the server sets them, A request or response body that declares more than one content type shows a **Content type** select at the top of its body, with the content types that have a schema first. Choosing one swaps the body below it. A response row shows the content type in view as a tag, and the select updates it. A content type with no schema, such as one the spec lists only to attach examples, shows "No schema is declared for this content type." +A bar of quick links sits above the parameter, body and response sections of an operation page: **Path**, **Query**, **Headers**, **Body** and **Responses**, for the sections the operation has. **Required privileges** comes before the bar and is not one of its links. The bar stays under the page header while you scroll, a link jumps to its section, and the link of the section in view is underlined. + Each section is a collapsible heading, expanded when the page loads. A reader can fold it and open it again. Each status code under **Responses** works the same way, and opening one leaves the others as they are. The **hide properties** toggle on an object field is open on load too, so nested fields show without a click; select it to fold them. Its summary punctuation says what the names are: | Summary | Meaning | |---|---| | `{ a, b }` | The fields of a JSON request body | | `A \| B` | Alternatives, such as the variants of a `oneOf` or the credentials of **Authentication** | -| `A + B` | Needed together, such as the privileges in **Required authorization** | +| `A + B` | Needed together, such as the privileges in **Required privileges** | | `a, b` | Anything else, such as parameters or the fields of a form | The names in a summary aren't searchable text, so find-in-page (Ctrl+F) skips them and finds the same field inside the section, which opens if a reader has folded it. Sections start expanded, so find-in-page reaches them on first load. Reaching a section a reader has folded relies on `hidden="until-found"`: in a browser without it, a folded section can't be searched. @@ -420,9 +422,9 @@ The paths come from either of two sources, and both produce the same rows: - The `**All methods and paths for this operation:**` (or `**Spaces method and path for this operation:**`) HTML block that bump.sh-flavoured specs put in the operation description. The block is removed from the rendered description. When it lists a `/s/{space_id}/…` route, the page also lists an optional `space_id` path parameter, described as the space identifier, defaulting to the default space, followed by the `Refer to [Spaces](…) for more information.` sentence that follows the block. That sentence is removed from the operation description. - Separate spec operations that share `x-namespace` and `x-api-name` (see below). -### Required authorization [x-req-auth] +### Required privileges [x-req-auth] -The **Required authorization** section lists the privileges an operation needs. It follows the description and also appears in the on-page table of contents. Like the other sections it is expanded by default. Its header names the privileges: `a + b` when they are needed together and `a | b` when they are alternatives. +The **Required privileges** section lists the privileges an operation needs. It follows the description and also appears in the on-page table of contents. Like the other sections it is expanded by default. Its header names the privileges: `a + b` when they are needed together and `a | b` when they are alternatives. Add the operation-level `x-req-auth` extension to list those privileges. It is a JSON array of strings. Each non-empty string becomes one row, with leading and trailing whitespace trimmed. @@ -447,13 +449,13 @@ Specs that do not use `x-req-auth` still work. The API Explorer reads two patter A line that mixes `OR` and `AND`, or a bullet with extra words around the values, stays in the description as written. When `x-req-auth` is present, it wins over the description. When neither source has privileges, the section is omitted. Malformed `x-req-auth` values are skipped and the build may log a warning. -`x-req-auth` is independent of OpenAPI `security` and `securitySchemes`. Privileges go in **Required authorization**. The credentials to send go in **Authentication**. +`x-req-auth` is independent of OpenAPI `security` and `securitySchemes`. Privileges go in **Required privileges**. The credentials to send go in **Authentication**. ### Authentication The **Authentication** section shows how to send a credential. It is expanded by default, and a reader who already has auth set up can fold it. The header shows the scheme names, for example `Api key | Basic | Bearer`. -The API Explorer reads the document-level `security`, or one alternative per scheme in `components.securitySchemes` when the document has none. The full section lives on the product's `/authentication` page. An endpoint page shows its own section, below **Required authorization** and in the on-page table of contents, only when the operation sets a `security` that names other alternatives than the document's. An empty operation `security: []` is an explicit override to none and shows no section. +The API Explorer reads the document-level `security`, or one alternative per scheme in `components.securitySchemes` when the document has none. The full section lives on the product's `/authentication` page. An endpoint page shows its own section, below **Required privileges** and in the on-page table of contents, only when the operation sets a `security` that names other alternatives than the document's. An empty operation `security: []` is an explicit override to none and shows no section. Every endpoint with authentication has an `Auth` badge in the row under its path, before the availability badge. The badges in that row, `Auth` and `Deprecated`, share the outlined look of the availability badge. `Deprecated` keeps a red outline and text, and a badge that links is underlined: diff --git a/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs b/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs index 2870c55f45..64fb1be2ef 100644 --- a/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs +++ b/src/Elastic.ApiExplorer/Infrastructure/SectionHeader.cs @@ -48,7 +48,7 @@ public record ApiPostSection(string Heading, string Anchor, HtmlString BodyHtml, internal static readonly FrozenSet OperationReservedAnchors = FrozenSet.ToFrozenSet( [ "paths", - "required-authorization", + "required-privileges", "authentication", "description", "path-params", diff --git a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs index 55350e46e1..8e8a68e18b 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationCommonMark.cs @@ -119,7 +119,7 @@ private static void WriteAuthorization(StringBuilder markdown, OperationPageMode { if (page.RequiredPrivileges.Count > 0) { - ApiCommonMark.Heading(markdown, 2, "Required authorization"); + ApiCommonMark.Heading(markdown, 2, "Required privileges"); foreach (var row in page.RequiredPrivileges) { var values = string.Join(row.Join is null ? ", " : $" {row.Join} ", row.Values.Select(ApiCommonMark.CodeSpan)); diff --git a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml index 4c334fd059..c5867484f0 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationView.cshtml +++ b/src/Elastic.ApiExplorer/Operations/OperationView.cshtml @@ -78,6 +78,11 @@ } @(await RenderPartialAsync<_AuthorizationSection, AuthorizationSectionModel>(Model.Authorization)) + @* The design-system stylesheet has no styles for the bar yet, so the flagged page leaves it out. *@ + @if (!Model.BuildContext.Configuration.Features.NavigationPreviewEnabled && Model.SectionLinks is { Count: > 1 } sectionLinks) + { + @(await RenderPartialAsync<_SectionLinks, IReadOnlyList>(sectionLinks)) + } @foreach (var section in pageModel.ParameterSections) { @(await RenderPartialAsync<_ParameterListSection, ParameterListSection>(section)) diff --git a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs index d01ecd397f..44e82340aa 100644 --- a/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs +++ b/src/Elastic.ApiExplorer/Operations/OperationViewModel.cs @@ -20,7 +20,7 @@ public record AuthenticationSectionModel( bool AlwaysTabs = false ); -/// Required privileges and credentials for the Required authorization and Authentication sections; renders descriptions. +/// Required privileges and credentials for the Required privileges and Authentication sections; renders descriptions. public record AuthorizationSectionModel( IReadOnlyList Requirements, IReadOnlyList Privileges, @@ -38,6 +38,9 @@ public record AuthorizationSectionModel( : Privileges.Any(static r => r.Join == "and") ? SummaryStyle.All : SummaryStyle.List; } +/// One quick link of the bar above an operation's sections. +public record SectionLink(string Label, string Anchor); + public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context) { public required ApiOperation Operation { get; init; } @@ -53,6 +56,27 @@ public class OperationViewModel(ApiRenderContext context) : ApiViewModel(context Page.AuthBadge?.Kind == AuthBadgeKind.Optional ); + /// + /// The sections under the quick links bar, in page order. Required privileges is not one of them: it comes before the bar. + /// + public IReadOnlyList SectionLinks + { + get + { + var links = new List( + Page.ParameterSections.Select(static s => new SectionLink(ParameterLabel(s.Location), s.Header.Anchor)) + ); + if (Operation.Operation.RequestBody is not null) + links.Add(new SectionLink("Body", "request-body")); + if (Page.Responses.Count > 0) + links.Add(new SectionLink("Responses", "responses")); + return links; + } + } + + private static string ParameterLabel(ParameterLocation location) => + location == ParameterLocation.Header ? "Headers" : location.ToString(); + /// Where the auth badge links: the Authentication page, or the endpoint's own section. public string? AuthBadgeHref => Page.AuthBadge?.Target switch { @@ -77,7 +101,7 @@ protected override IReadOnlyList GetTocItems() var tocItems = new List { new("Paths", "paths", 2) }; if (Page.RequiredPrivileges.Count > 0) - tocItems.Add(new TocItem("Required authorization", "required-authorization", 2)); + tocItems.Add(new TocItem("Required privileges", "required-privileges", 2)); if (Page.AuthRequirements.Count > 0) tocItems.Add(new TocItem("Authentication", "authentication", 2)); diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml index 50071ebe1c..4f8b8a0685 100644 --- a/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml +++ b/src/Elastic.ApiExplorer/Operations/_Partials/_AuthorizationSection.cshtml @@ -8,8 +8,8 @@ @if (Model.Privileges.Count > 0) {
- @(await RenderPartialAsync<_ParamSectionHeader, ParamSectionHeader>(new ParamSectionHeader("Required authorization", "required-authorization", Model.PrivilegeNames, Model.PrivilegeStyle))) -
+ @(await RenderPartialAsync<_ParamSectionHeader, ParamSectionHeader>(new ParamSectionHeader("Required privileges", "required-privileges", Model.PrivilegeNames, Model.PrivilegeStyle))) +
@foreach (var row in Model.Privileges) { diff --git a/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml new file mode 100644 index 0000000000..8b07094975 --- /dev/null +++ b/src/Elastic.ApiExplorer/Operations/_Partials/_SectionLinks.cshtml @@ -0,0 +1,8 @@ +@inherits RazorSlice> +@* Quick links to the sections below. The scroll-spy of the "On this page" outline (ds/toc.ts) marks the link of the section in view. *@ + diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.css b/src/Elastic.Documentation.Site/Assets/api-docs.css index 10701284ef..a63cf6b500 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.css +++ b/src/Elastic.Documentation.Site/Assets/api-docs.css @@ -2944,6 +2944,19 @@ button.example-response-tab:focus-visible { min-width: 0; padding-top: 16px; padding-bottom: 48px; + /* Stays in view while the sections scroll, however long the page is. The rail itself does not clip, so the + Copy page menu can open past its edge; the examples below it shrink to the window and scroll. */ + position: sticky; + top: var(--offset-top, 0px); + align-self: start; + max-height: calc(100vh - var(--offset-top, 0px)); + box-sizing: border-box; + } + + .api-page-rail > :not(.api-page-actions) { + flex: 0 1 auto; + min-height: 0; + overflow: hidden auto; } .api-page-rail > .api-page-actions { @@ -2956,7 +2969,7 @@ button.example-response-tab:focus-visible { margin-bottom: 0; } - /* The examples are normal page flow: no sticky, no scrolling box. Long examples make the page long. */ + /* The code blocks are normal page flow and cap at a fixed number of lines; the rail around them is sticky (above). */ #api-examples-panel .api-examples-panel-nav { display: flex; flex-direction: column; @@ -4210,3 +4223,44 @@ html:has(.api-page-shell) { } } } + +/* Quick links to the sections of an endpoint: one bar that stays under the header while the sections scroll. */ +.api-section-links { + position: sticky; + top: var(--offset-top, 0px); + z-index: 5; + display: flex; + flex-wrap: nowrap; + gap: 24px; + margin: 24px 0 0; + overflow-x: auto; + background: #fff; + border-bottom: 1px solid #e3e8f2; +} + +.api-section-links a { + display: flex; + align-items: center; + gap: 8px; + height: 48px; + border-bottom: 2px solid transparent; + color: #516381; + font-weight: 600; + text-decoration: none; + white-space: nowrap; +} + +.api-section-links a:hover { + color: #1d2a3e; +} + +.api-section-links a[aria-current] { + border-bottom-color: #0b64dd; + color: #111c2c; +} + +/* A section reached from the bar stops below the bar instead of under it. */ +#elastic-api-v3 .api-param-section-header, +#elastic-api-v3 .responses-header { + scroll-margin-top: calc(var(--offset-top, 0px) + 64px); +} diff --git a/src/Elastic.Documentation.Site/Assets/api-docs.ts b/src/Elastic.Documentation.Site/Assets/api-docs.ts index 17c5e6cda7..bfcf3dc564 100644 --- a/src/Elastic.Documentation.Site/Assets/api-docs.ts +++ b/src/Elastic.Documentation.Site/Assets/api-docs.ts @@ -8,6 +8,7 @@ import { initApiExamples, syncDescriptions } from './api-examples-carousel' import { fitParamSummaries } from './api-param-summary' import { initUnionChips, revealVariantsAround } from './api-union-chips' import { iconCheckEui, iconCopyEui } from './copybutton' +import { initToc } from './ds/toc' import { prefersReducedMotion } from './motion' import { flashTooltip, initTooltips } from './tooltip' @@ -312,6 +313,7 @@ function updateExamplesButtonVisibility(): void { } function initOperationView(): void { + initToc() // Bound once: htmx swaps the section, so the handler looks the elements up each time if (!examplesScrollBound) { examplesScrollBound = true diff --git a/src/Elastic.Documentation.Site/Assets/api-section-links.test.ts b/src/Elastic.Documentation.Site/Assets/api-section-links.test.ts new file mode 100644 index 0000000000..ebb0ffae74 --- /dev/null +++ b/src/Elastic.Documentation.Site/Assets/api-section-links.test.ts @@ -0,0 +1,33 @@ +import { initApiDocs } from './api-docs' +import { beforeAll, describe, expect, it } from 'vitest' + +describe('section links', () => { + beforeAll(() => initApiDocs()) + + it('marks the first link of the bar on an operation page, through the outline scroll-spy', () => { + document.body.innerHTML = ` +
+ +

+

+
` + // jsdom has no layout: a page taller than the window, scrolled to the top + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 5000, + configurable: true, + }) + // jsdom has no layout: the first section is in view, the second is below the fold + const top = { 'query-params': -100, responses: 900 } + for (const [id, y] of Object.entries(top)) + document.getElementById(id)!.getBoundingClientRect = () => + ({ top: y }) as DOMRect + initApiDocs() + + const current = document.querySelectorAll('[aria-current]') + expect(current).toHaveLength(1) + expect(current[0].getAttribute('href')).toBe('#query-params') + }) +}) diff --git a/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts b/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts index 340dba4417..19afedeaff 100644 --- a/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts +++ b/src/Elastic.Documentation.Site/Assets/ds/toc.test.ts @@ -14,6 +14,10 @@ function setup() { describe('initToc', () => { beforeEach(() => { + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 5000, + configurable: true, + }) Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true, @@ -71,4 +75,164 @@ describe('initToc', () => { .getAttribute('aria-current') ).toBe('location') }) + + it('marks the last heading at the bottom of the page, though it never reaches the line', () => { + setup() + document.getElementById('two')!.getBoundingClientRect = () => + ({ top: 700 }) as DOMRect + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 800, + configurable: true, + }) + initToc() + + expect( + document + .querySelector('[href="#two"]')! + .getAttribute('aria-current') + ).toBe('location') + expect( + document + .querySelector('[href="#one"]')! + .hasAttribute('aria-current') + ).toBe(false) + }) + + it('stops tracking an outline that left the page', () => { + setup() + const toc = document.querySelector('[data-toc]')! + const oldHeading = document.getElementById('two')! + initToc() + // a new page arrives and its outline is bound too + setup() + document.getElementById('two')!.getBoundingClientRect = () => + ({ top: 100 }) as DOMRect + initToc() + // the old heading now crosses the line, so a spy still tracking the old page would mark its link + oldHeading.getBoundingClientRect = () => ({ top: 100 }) as DOMRect + window.dispatchEvent(new Event('scroll')) + + expect( + toc.querySelector('[href="#two"]')!.hasAttribute('aria-current') + ).toBe(false) + expect( + document + .querySelector('[href="#two"]')! + .getAttribute('aria-current') + ).toBe('location') + }) + + it('marks the clicked section of a sticky outline, though the next heading is above the 25% line', () => { + document.body.innerHTML = ` + +

One

Two

` + const nav = document.querySelector('[data-toc]')! + nav.getBoundingClientRect = () => ({ bottom: 97 }) as DOMRect + // the section a click just landed under the bar, and a short one that follows it inside the 25% line (200px) + document.getElementById('one')!.getBoundingClientRect = () => + ({ top: 112 }) as DOMRect + document.getElementById('two')!.getBoundingClientRect = () => + ({ top: 190 }) as DOMRect + initToc() + + expect( + document + .querySelector('[href="#one"]')! + .getAttribute('aria-current') + ).toBe('location') + expect( + document + .querySelector('[href="#two"]')! + .hasAttribute('aria-current') + ).toBe(false) + }) + + it('marks the section the address names at the bottom of a page too short to scroll', () => { + setup() + document.getElementById('one')!.getBoundingClientRect = () => + ({ top: 100 }) as DOMRect + document.getElementById('two')!.getBoundingClientRect = () => + ({ top: 500 }) as DOMRect + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 800, + configurable: true, + }) + window.location.hash = '#one' + initToc() + + expect( + document + .querySelector('[href="#one"]')! + .getAttribute('aria-current') + ).toBe('location') + expect( + document + .querySelector('[href="#two"]')! + .hasAttribute('aria-current') + ).toBe(false) + + window.location.hash = '' + }) + + it('follows the address when it changes without a scroll', () => { + setup() + document.getElementById('one')!.getBoundingClientRect = () => + ({ top: 100 }) as DOMRect + document.getElementById('two')!.getBoundingClientRect = () => + ({ top: 500 }) as DOMRect + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 800, + configurable: true, + }) + initToc() + window.location.hash = '#one' + window.dispatchEvent(new HashChangeEvent('hashchange')) + + expect( + document + .querySelector('[href="#one"]')! + .getAttribute('aria-current') + ).toBe('location') + + window.location.hash = '' + }) + + it('follows a link click that pushes the address without a hashchange or a scroll', () => { + setup() + document.getElementById('one')!.getBoundingClientRect = () => + ({ top: 100 }) as DOMRect + document.getElementById('two')!.getBoundingClientRect = () => + ({ top: 500 }) as DOMRect + Object.defineProperty(document.documentElement, 'scrollHeight', { + value: 800, + configurable: true, + }) + initToc() + // at the bottom of a page that cannot scroll, the last section is current + expect( + document + .querySelector('[href="#two"]')! + .getAttribute('aria-current') + ).toBe('location') + + // what the in-page link handler does: pushState, no hashchange, no scroll + const one = document.querySelector('[href="#one"]')! + one.addEventListener('click', (e) => { + e.preventDefault() + history.pushState(null, '', '#one') + }) + one.click() + + expect(one.getAttribute('aria-current')).toBe('location') + expect( + document + .querySelector('[href="#two"]')! + .hasAttribute('aria-current') + ).toBe(false) + + history.pushState(null, '', window.location.pathname) + }) }) diff --git a/src/Elastic.Documentation.Site/Assets/ds/toc.ts b/src/Elastic.Documentation.Site/Assets/ds/toc.ts index 910188894c..bfa983068e 100644 --- a/src/Elastic.Documentation.Site/Assets/ds/toc.ts +++ b/src/Elastic.Documentation.Site/Assets/ds/toc.ts @@ -1,6 +1,15 @@ // Scroll-spy for the "On this page" outline: marks the link of the heading in view with aria-current="location". // The outline works as plain anchor links without this. const activationOffset = 0.25 +// Within this many pixels of the page bottom the last heading is current: a short last section can never reach the line. +const bottomSlack = 4 +// A sticky outline marks a heading once it is this far below the outline's own bottom edge, which is where a click lands it. +const stickySlack = 32 + +function isInView(element: Element): boolean { + const top = element.getBoundingClientRect().top + return top >= 0 && top < window.innerHeight +} export function initToc(scope: ParentNode = document) { scope @@ -17,10 +26,18 @@ export function initToc(scope: ParentNode = document) { ), })) + const listeners = new AbortController() let queued = false const update = () => { queued = false - const line = window.innerHeight * activationOffset + // An htmx swap replaced the page: stop tracking its outline + if (!toc.isConnected) { + listeners.abort() + return + } + const line = toc.hasAttribute('data-toc-sticky') + ? toc.getBoundingClientRect().bottom + stickySlack + : window.innerHeight * activationOffset let active: HTMLAnchorElement | null = null for (const { link, heading } of targets) { if ( @@ -30,6 +47,22 @@ export function initToc(scope: ParentNode = document) { active = link } } + const atBottom = + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - bottomSlack + if (atBottom) { + // A page too short to scroll cannot bring a clicked section to the line: the section the address + // names, if it is in view, is the one the reader chose; otherwise the last one is. + const chosen = targets.find( + ({ link, heading }) => + heading && + link.hash === window.location.hash && + isInView(heading) + ) + for (const { link, heading } of targets) { + if (heading) active = chosen?.link ?? link + } + } active ??= targets[0]?.link ?? null for (const { link } of targets) { if (link === active) { @@ -45,8 +78,24 @@ export function initToc(scope: ParentNode = document) { window.requestAnimationFrame(update) } - window.addEventListener('scroll', schedule, { passive: true }) - window.addEventListener('resize', schedule) + window.addEventListener('scroll', schedule, { + passive: true, + signal: listeners.signal, + }) + window.addEventListener('resize', schedule, { + signal: listeners.signal, + }) + window.addEventListener('hashchange', schedule, { + signal: listeners.signal, + }) + // A link click that pushes the address emits no hashchange, and a page too short to scroll emits no scroll + // either. The update waits a frame, so the click handlers have set the address by then. + toc.addEventListener('click', schedule, { + signal: listeners.signal, + }) + window.addEventListener('popstate', schedule, { + signal: listeners.signal, + }) update() }) } diff --git a/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs b/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs index 3d058a29d9..c9947ecfc9 100644 --- a/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/AuthorizationSectionRenderingTests.cs @@ -81,14 +81,14 @@ public async Task Render_PrivilegesNeededTogether_AreSummarisedAsSeparateNamesJo var html = await RenderPrivileges(new PrivilegeRow("Privileges", ["integrations-all", "fleet-agent-policies-all"], "and")); html.Should().Contain("data-param-section"); - html.Should().Contain("id=\"required-authorization\""); + html.Should().Contain("id=\"required-privileges\""); html.Should().Contain(""); html.Should().Contain(""); html.Should().Contain("api-param-summary is-all"); - html.Should().Contain("id=\"required-authorization-list\""); - html.Should().Contain("id=\"required-authorization-list\">", "the body has no hidden attribute, so it shows on load"); - html.Should().NotContain("id=\"required-authorization-list\" hidden"); - html.Should().MatchRegex("aria-expanded=\"true\"\\s+aria-controls=\"required-authorization-list\""); + html.Should().Contain("id=\"required-privileges-list\""); + html.Should().Contain("id=\"required-privileges-list\">", "the body has no hidden attribute, so it shows on load"); + html.Should().NotContain("id=\"required-privileges-list\" hidden"); + html.Should().MatchRegex("aria-expanded=\"true\"\\s+aria-controls=\"required-privileges-list\""); html.Should().Contain("integrations-all", "the rows are still listed in the body"); } diff --git a/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs b/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs index 88f4a93ca5..e32b122028 100644 --- a/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/OperationHeadingLevelTests.cs @@ -28,7 +28,7 @@ public async Task Render_SearchOperation_UsesOneH1ThenH2SectionsAndH3Nested() .Equal("h1", "h2", "h2", "h2", "h2", "h2", "h2", "h3"); html.Should().Contain("

Run a search

"); html.Should().Contain( - "

" + "

" ); html.Should().Contain( "

" diff --git a/tests/Elastic.ApiExplorer.Tests/ParameterRowsTests.cs b/tests/Elastic.ApiExplorer.Tests/ParameterRowsTests.cs index 6fba4256c9..17d81a4d76 100644 --- a/tests/Elastic.ApiExplorer.Tests/ParameterRowsTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/ParameterRowsTests.cs @@ -86,6 +86,8 @@ public async Task Render_HeaderParameters_ShowSectionAndTocEntry() html.Should().Contain("id=\"header-params-list\""); html.Should().Contain("
"); html.Should().Contain("href=\"#header-params\""); + html.Should().Contain("href=\"#header-params\" data-toc-link>Headers", "the quick link names the section in the plural"); + html.Should().Contain("data-toc-link>Path").And.Contain("data-toc-link>Query"); } [Test] diff --git a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs index 51f1b44a30..f9277fb0df 100644 --- a/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs +++ b/tests/Elastic.ApiExplorer.Tests/Supplemental/ApiSupplementalRenderTests.cs @@ -185,6 +185,29 @@ public async Task Operation_NestedProperties_UseShowPropertiesDisclosure() html.Should().NotContain("toggle-label\">Hide "); } + [Test] + public async Task Operation_SectionLinks_ListEachSectionInPageOrder() + { + var nav = SearchOperation(); + var html = await RenderAsync(nav.Model, nav); + + html.Should().Contain("class=\"api-section-links\" aria-label=\"Sections of this endpoint\" data-toc data-toc-sticky>"); + html.Should().Contain("href=\"#query-params\" data-toc-link>Query", "a link is its label alone, with no count"); + html.Should().Contain("href=\"#request-body\" data-toc-link>Body", "a link is its label alone, with no count"); + html.Should().Contain("href=\"#responses\" data-toc-link>Responses", "a link is its label alone, with no count"); + var bar = html[html.IndexOf("