From 6bdfc227585ee6b5418347b06607d898f62f9c81 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 8 Oct 2026 12:21:47 -0300 Subject: [PATCH 1/4] And a dropdown to easily upgrade schemas in the UI Signed-off-by: Juan Cruz Viotti --- enterprise/e2e/html/hurl/schemas-as.all.hurl | 44 ++++++++ .../e2e/html/playwright/convert.spec.js | 104 ++++++++++++++++++ .../e2e/html/playwright/playwright.config.js | 16 +++ src/web/CMakeLists.txt | 1 + src/web/helpers.h | 19 ++-- src/web/pages/schema.cc | 31 ++++++ src/web/scripts/dropdown.js | 30 +++++ src/web/scripts/main.js | 1 + src/web/style.scss | 10 +- test/e2e/html/hurl/html.all.hurl | 17 +++ test/e2e/html/hurl/schemas-as.community.hurl | 16 +++ 11 files changed, 280 insertions(+), 9 deletions(-) create mode 100644 enterprise/e2e/html/playwright/convert.spec.js create mode 100644 enterprise/e2e/html/playwright/playwright.config.js create mode 100644 src/web/scripts/dropdown.js diff --git a/enterprise/e2e/html/hurl/schemas-as.all.hurl b/enterprise/e2e/html/hurl/schemas-as.all.hurl index 5e6530035..94d3bc699 100644 --- a/enterprise/e2e/html/hurl/schemas-as.all.hurl +++ b/enterprise/e2e/html/hurl/schemas-as.all.hurl @@ -719,3 +719,47 @@ header "Vary" == "Accept, Accept-Encoding" header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ body contains "Draft3" body contains "https://www.learnjsonschema.com/draft3" + +# The web page offers every conversion the metadata advertises, oldest first +GET {{base}}/test/draft3/string +Accept: text/html +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: text/html; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: frame-ancestors 'none' +X-Frame-Options: DENY +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ +xpath "string(//button[@data-sourcemeta-ui-dropdown-toggle])" == "Convert" +xpath "string(//button[@data-sourcemeta-ui-dropdown-toggle]/@aria-expanded)" == "false" +xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a)" == 5 +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a)" == "Draft4" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a/@href)" == "/test/draft3/string.json?as=draft4" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a)" == "Draft6" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a/@href)" == "/test/draft3/string.json?as=draft6" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[3]/a)" == "Draft7" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[3]/a/@href)" == "/test/draft3/string.json?as=draft7" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[4]/a)" == "2019-09" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[4]/a/@href)" == "/test/draft3/string.json?as=2019-09" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[5]/a)" == "2020-12" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[5]/a/@href)" == "/test/draft3/string.json?as=2020-12" + +# A schema on the newest dialect is still offered it, as what it references +# may be older +GET {{base}}/test/object +Accept: text/html +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: text/html; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: frame-ancestors 'none' +X-Frame-Options: DENY +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ +xpath "string(//button[@data-sourcemeta-ui-dropdown-toggle])" == "Convert" +xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a)" == 1 +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a)" == "2020-12" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a/@href)" == "/test/object.json?as=2020-12" diff --git a/enterprise/e2e/html/playwright/convert.spec.js b/enterprise/e2e/html/playwright/convert.spec.js new file mode 100644 index 000000000..4023a53f1 --- /dev/null +++ b/enterprise/e2e/html/playwright/convert.spec.js @@ -0,0 +1,104 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Schema Convert Dropdown', () => { + test('starts collapsed and lists every dialect the schema can be asked for', + async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + + // The menu is closed until asked for + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).not.toBeVisible(); + + await toggle.click(); + + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(menu).toBeVisible(); + + // A Draft 3 schema can be asked for in every newer official dialect + const items = menu.locator('a'); + await expect(items).toHaveText([ + 'Draft4', 'Draft6', 'Draft7', '2019-09', '2020-12' + ]); + await expect(items.nth(0)).toHaveAttribute( + 'href', '/test/draft3/string.json?as=draft4'); + await expect(items.nth(1)).toHaveAttribute( + 'href', '/test/draft3/string.json?as=draft6'); + await expect(items.nth(2)).toHaveAttribute( + 'href', '/test/draft3/string.json?as=draft7'); + await expect(items.nth(3)).toHaveAttribute( + 'href', '/test/draft3/string.json?as=2019-09'); + await expect(items.nth(4)).toHaveAttribute( + 'href', '/test/draft3/string.json?as=2020-12'); + }); + + test('offers the dialect the schema already declares', async ({ page }) => { + await page.goto('/test/object'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + + const items = menu.locator('a'); + await expect(items).toHaveText([ '2020-12' ]); + await expect(items.nth(0)).toHaveAttribute( + 'href', '/test/object.json?as=2020-12'); + }); + + test('the menu sits below the button it belongs to', async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + + const toggleBox = await toggle.boundingBox(); + const menuBox = await menu.boundingBox(); + expect(menuBox.y).toBeGreaterThanOrEqual(toggleBox.y + toggleBox.height); + expect(menuBox.x).toBe(toggleBox.x); + }); + + test('clicking outside the dropdown closes it', async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + await expect(menu).toBeVisible(); + + await page.locator('footer').click(); + + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).not.toBeVisible(); + }); + + test('pressing escape closes it', async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + await expect(menu).toBeVisible(); + + await page.keyboard.press('Escape'); + + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).not.toBeVisible(); + }); + + test('clicking the button again closes it', async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + await expect(menu).toBeVisible(); + + await toggle.click(); + + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).not.toBeVisible(); + }); +}); diff --git a/enterprise/e2e/html/playwright/playwright.config.js b/enterprise/e2e/html/playwright/playwright.config.js new file mode 100644 index 000000000..566a8c968 --- /dev/null +++ b/enterprise/e2e/html/playwright/playwright.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from '@playwright/test'; + +// See https://playwright.dev/docs/test-configuration +export default defineConfig({ + testDir: '.', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + outputDir: '../../../../build/test-results', + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL, + trace: 'on-first-retry' + } +}); diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index 2538054fb..398080437 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -22,6 +22,7 @@ sourcemeta_esbuild_bundle( DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/scripts/main.js" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/copy.js" + "${CMAKE_CURRENT_SOURCE_DIR}/scripts/dropdown.js" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/search.js" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/tabs.js" "${CMAKE_CURRENT_SOURCE_DIR}/scripts/editor.js" diff --git a/src/web/helpers.h b/src/web/helpers.h index ec430f5f8..3285b9b84 100644 --- a/src/web/helpers.h +++ b/src/web/helpers.h @@ -95,6 +95,16 @@ make_schema_health_progress_bar(sourcemeta::core::HTMLWriter &writer, writer.close(); } +inline auto dialect_display_name(const std::string_view name) -> std::string { + std::string result{name}; + if (!result.empty()) { + result.front() = static_cast( + std::toupper(static_cast(result.front()))); + } + + return result; +} + inline auto make_dialect_badge(sourcemeta::core::HTMLWriter &writer, const sourcemeta::core::JSON::String &base_dialect_uri) @@ -136,13 +146,6 @@ make_dialect_badge(sourcemeta::core::HTMLWriter &writer, return {"unknown", false}; }(); - // Capitalize first character - std::string display_name = short_name; - if (!display_name.empty()) { - display_name[0] = static_cast( - std::toupper(static_cast(display_name[0]))); - } - writer.a() .attribute("href", "https://www.learnjsonschema.com/" + short_name) .attribute("target", "_blank"); @@ -150,7 +153,7 @@ make_dialect_badge(sourcemeta::core::HTMLWriter &writer, "class", "align-middle badge " + std::string(is_current ? "text-bg-primary" : "text-bg-danger")); - writer.text(display_name); + writer.text(dialect_display_name(short_name)); writer.close(); writer.close(); } diff --git a/src/web/pages/schema.cc b/src/web/pages/schema.cc index 977dc0b05..1de6766a0 100644 --- a/src/web/pages/schema.cc +++ b/src/web/pages/schema.cc @@ -81,6 +81,37 @@ auto GenerateWebSchema::handler( .attribute("role", "button"); body.text("Bundle"); body.close(); + + // Conversions are advertised only where they are offered, so a schema + // nothing can be converted into grows no control at all + if (meta.defines("conversions") && !meta.at("conversions").empty()) { + body.div() + .attribute("class", "btn-group ms-2") + .attribute("data-sourcemeta-ui-dropdown", ""); + body.button() + .attribute("class", "btn btn-secondary dropdown-toggle") + .attribute("type", "button") + .attribute("aria-expanded", "false") + .attribute("data-sourcemeta-ui-dropdown-toggle", ""); + body.text("Convert"); + body.close(); + body.ul() + .attribute("class", "dropdown-menu") + .attribute("data-sourcemeta-ui-dropdown-menu", ""); + for (const auto &conversion : meta.at("conversions").as_object()) { + body.li(); + body.a() + .attribute("class", "dropdown-item") + .attribute("href", meta.at("path").to_string() + + ".json?as=" + conversion.first); + body.text(html::dialect_display_name(conversion.first)); + body.close(); + body.close(); + } + body.close(); + body.close(); + } + body.close(); // Integration snippets diff --git a/src/web/scripts/dropdown.js b/src/web/scripts/dropdown.js new file mode 100644 index 000000000..5311b5a0c --- /dev/null +++ b/src/web/scripts/dropdown.js @@ -0,0 +1,30 @@ +document.querySelectorAll("[data-sourcemeta-ui-dropdown]").forEach((dropdown) => { + const toggle = dropdown.querySelector("[data-sourcemeta-ui-dropdown-toggle]"); + const menu = dropdown.querySelector("[data-sourcemeta-ui-dropdown-menu]"); + + function close() { + menu.classList.remove("show"); + toggle.setAttribute("aria-expanded", "false"); + } + + toggle.addEventListener("click", () => { + if (toggle.getAttribute("aria-expanded") === "true") { + close(); + } else { + menu.classList.add("show"); + toggle.setAttribute("aria-expanded", "true"); + } + }); + + document.addEventListener("click", (event) => { + if (!dropdown.contains(event.target)) { + close(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + close(); + } + }); +}); diff --git a/src/web/scripts/main.js b/src/web/scripts/main.js index 17d78a1d0..1dc8b2536 100644 --- a/src/web/scripts/main.js +++ b/src/web/scripts/main.js @@ -1,5 +1,6 @@ import "./tabs.js"; import "./copy.js"; +import "./dropdown.js"; import "./search.js"; import "./dependencies.js"; diff --git a/src/web/style.scss b/src/web/style.scss index 59b29a19b..e648bf1c9 100644 --- a/src/web/style.scss +++ b/src/web/style.scss @@ -27,7 +27,7 @@ $breadcrumb-font-size: 15px; @import "../../vendor/bootstrap/scss/forms"; @import "../../vendor/bootstrap/scss/buttons"; // @import "../../vendor/bootstrap/scss/transitions"; -// @import "../../vendor/bootstrap/scss/dropdown"; +@import "../../vendor/bootstrap/scss/dropdown"; @import "../../vendor/bootstrap/scss/button-group"; @import "../../vendor/bootstrap/scss/nav"; @import "../../vendor/bootstrap/scss/navbar"; @@ -57,6 +57,14 @@ $bootstrap-icons-font: bootstrap-icons; $bootstrap-icons-font-dir: "."; @import "../../vendor/bootstrap-icons/font/bootstrap-icons.scss"; +// Bootstrap only places `.dropdown-menu` when Popper has marked it, and this +// UI drives its dropdowns without Popper +.dropdown-menu { + top: 100%; + left: 0; + margin-top: $dropdown-spacer; +} + // Bootstrap doesn't seem to provide breakpoint-based modifiers to `.w-*` @include media-breakpoint-up(md) { .w-md-auto { diff --git a/test/e2e/html/hurl/html.all.hurl b/test/e2e/html/hurl/html.all.hurl index 6672032c2..dc558461b 100644 --- a/test/e2e/html/hurl/html.all.hurl +++ b/test/e2e/html/hurl/html.all.hurl @@ -607,3 +607,20 @@ Cache-Control: no-store [Asserts] header "Vary" not exists jsonpath "$.valid" == true + +# A schema nothing can be converted into offers no conversion control, which +# is what every schema looks like where conversions are not offered at all +GET {{base}}/test/schemas/custom-meta-draft7 +Accept: text/html +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: text/html; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: frame-ancestors 'none' +X-Frame-Options: DENY +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ +xpath "count(//button[@data-sourcemeta-ui-dropdown-toggle])" == 0 +xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu])" == 0 +xpath "boolean(//a[@href='/test/schemas/custom-meta-draft7.json?bundle=1'])" == true diff --git a/test/e2e/html/hurl/schemas-as.community.hurl b/test/e2e/html/hurl/schemas-as.community.hurl index f52c5864c..f8e9eef37 100644 --- a/test/e2e/html/hurl/schemas-as.community.hurl +++ b/test/e2e/html/hurl/schemas-as.community.hurl @@ -170,3 +170,19 @@ header "Content-Security-Policy" not exists header "X-Frame-Options" not exists header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ jsonpath "$.valid" == true + +# With nothing to convert into, the web page offers no conversion control +GET {{base}}/test/v2.0/schema +Accept: text/html +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: text/html; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: frame-ancestors 'none' +X-Frame-Options: DENY +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ +xpath "count(//button[@data-sourcemeta-ui-dropdown-toggle])" == 0 +xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu])" == 0 +xpath "boolean(//a[@href='/test/v2.0/schema.json?bundle=1'])" == true From a58565f223d5466662b6b8bd33d2c36c64db9538 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 8 Oct 2026 14:15:52 -0300 Subject: [PATCH 2/4] Fix Signed-off-by: Juan Cruz Viotti --- .../e2e/html/playwright/convert.spec.js | 57 +++++++++++++++++++ src/web/scripts/dropdown.js | 12 ++++ test/e2e/html/hurl/html.all.hurl | 4 +- test/e2e/html/hurl/schemas-as.community.hurl | 3 +- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/enterprise/e2e/html/playwright/convert.spec.js b/enterprise/e2e/html/playwright/convert.spec.js index 4023a53f1..624b2e201 100644 --- a/enterprise/e2e/html/playwright/convert.spec.js +++ b/enterprise/e2e/html/playwright/convert.spec.js @@ -74,6 +74,63 @@ test.describe('Schema Convert Dropdown', () => { await expect(menu).not.toBeVisible(); }); + test('pressing escape from inside the menu hands focus back to the button', + async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + await page.keyboard.press('Tab'); + await expect(menu.locator('a').first()).toBeFocused(); + + await page.keyboard.press('Escape'); + + await expect(menu).not.toBeVisible(); + await expect(toggle).toBeFocused(); + }); + + test('tabbing past the last entry closes it', async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + + // Walk out of the menu through its five entries + for (let index = 0; index < 6; index++) { + await page.keyboard.press('Tab'); + } + + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).not.toBeVisible(); + }); + + test('closing by clicking elsewhere leaves focus where it was put', + async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const bundle = page.locator('a[href="/test/draft3/string.json?bundle=1"]'); + await toggle.click(); + + await bundle.focus(); + + await expect(toggle).not.toBeFocused(); + await expect(bundle).toBeFocused(); + }); + + test('an entry navigates to the converted schema', async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + await menu.locator('a', { hasText: '2020-12' }).click(); + + await expect(page).toHaveURL('/test/draft3/string.json?as=2020-12'); + }); + test('pressing escape closes it', async ({ page }) => { await page.goto('/test/draft3/string'); diff --git a/src/web/scripts/dropdown.js b/src/web/scripts/dropdown.js index 5311b5a0c..32e181589 100644 --- a/src/web/scripts/dropdown.js +++ b/src/web/scripts/dropdown.js @@ -3,8 +3,14 @@ document.querySelectorAll("[data-sourcemeta-ui-dropdown]").forEach((dropdown) => const menu = dropdown.querySelector("[data-sourcemeta-ui-dropdown-menu]"); function close() { + // Whoever was reading the menu would be left standing on something that + // is no longer there, with no way back to the control that opened it + const wasInside = dropdown.contains(document.activeElement); menu.classList.remove("show"); toggle.setAttribute("aria-expanded", "false"); + if (wasInside) { + toggle.focus(); + } } toggle.addEventListener("click", () => { @@ -22,6 +28,12 @@ document.querySelectorAll("[data-sourcemeta-ui-dropdown]").forEach((dropdown) => } }); + dropdown.addEventListener("focusout", (event) => { + if (!dropdown.contains(event.relatedTarget)) { + close(); + } + }); + document.addEventListener("keydown", (event) => { if (event.key === "Escape") { close(); diff --git a/test/e2e/html/hurl/html.all.hurl b/test/e2e/html/hurl/html.all.hurl index dc558461b..cc8badf73 100644 --- a/test/e2e/html/hurl/html.all.hurl +++ b/test/e2e/html/hurl/html.all.hurl @@ -608,8 +608,8 @@ Cache-Control: no-store header "Vary" not exists jsonpath "$.valid" == true -# A schema nothing can be converted into offers no conversion control, which -# is what every schema looks like where conversions are not offered at all +# A schema with no conversions on offer grows no conversion control. This +# schema is a meta-schema, which is pinned where its metadata is asserted GET {{base}}/test/schemas/custom-meta-draft7 Accept: text/html HTTP 200 diff --git a/test/e2e/html/hurl/schemas-as.community.hurl b/test/e2e/html/hurl/schemas-as.community.hurl index f8e9eef37..693b88926 100644 --- a/test/e2e/html/hurl/schemas-as.community.hurl +++ b/test/e2e/html/hurl/schemas-as.community.hurl @@ -171,7 +171,8 @@ header "X-Frame-Options" not exists header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ jsonpath "$.valid" == true -# With nothing to convert into, the web page offers no conversion control +# With no conversions on offer at all, the web page grows no conversion +# control, however convertible the schema itself looks GET {{base}}/test/v2.0/schema Accept: text/html HTTP 200 From def5cc8c8b36a1bc84f8eb7023788687750b9c7f Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 8 Oct 2026 14:38:10 -0300 Subject: [PATCH 3/4] Fix Signed-off-by: Juan Cruz Viotti --- .../e2e/html/playwright/convert.spec.js | 32 +++++++++++++++++-- src/web/scripts/dropdown.js | 16 +++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/enterprise/e2e/html/playwright/convert.spec.js b/enterprise/e2e/html/playwright/convert.spec.js index 624b2e201..c6e080c7a 100644 --- a/enterprise/e2e/html/playwright/convert.spec.js +++ b/enterprise/e2e/html/playwright/convert.spec.js @@ -72,6 +72,7 @@ test.describe('Schema Convert Dropdown', () => { await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await expect(menu).not.toBeVisible(); + await expect(toggle).not.toBeFocused(); }); test('pressing escape from inside the menu hands focus back to the button', @@ -90,7 +91,8 @@ test.describe('Schema Convert Dropdown', () => { await expect(toggle).toBeFocused(); }); - test('tabbing past the last entry closes it', async ({ page }) => { + test('tabbing past the last entry closes it and keeps going', + async ({ page }) => { await page.goto('/test/draft3/string'); const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); @@ -104,20 +106,44 @@ test.describe('Schema Convert Dropdown', () => { await expect(toggle).toHaveAttribute('aria-expanded', 'false'); await expect(menu).not.toBeVisible(); + + // Whatever follows the menu is where the walk was headed + await expect(page.locator('[data-sourcemeta-ui-tab-target="usage-cli"]')) + .toBeFocused(); + await expect(toggle).not.toBeFocused(); }); - test('closing by clicking elsewhere leaves focus where it was put', + test('moving focus out of an entry lands where it was aimed', async ({ page }) => { await page.goto('/test/draft3/string'); const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); const bundle = page.locator('a[href="/test/draft3/string.json?bundle=1"]'); await toggle.click(); + await page.keyboard.press('Tab'); + await expect(menu.locator('a').first()).toBeFocused(); await bundle.focus(); - await expect(toggle).not.toBeFocused(); + await expect(menu).not.toBeVisible(); await expect(bundle).toBeFocused(); + await expect(toggle).not.toBeFocused(); + }); + + test('clicking the empty part of the menu leaves it open', + async ({ page }) => { + await page.goto('/test/draft3/string'); + + const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); + const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); + await toggle.click(); + + const box = await menu.boundingBox(); + await page.mouse.click(box.x + box.width - 2, box.y + box.height - 2); + + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await expect(menu).toBeVisible(); }); test('an entry navigates to the converted schema', async ({ page }) => { diff --git a/src/web/scripts/dropdown.js b/src/web/scripts/dropdown.js index 32e181589..26b3f3d23 100644 --- a/src/web/scripts/dropdown.js +++ b/src/web/scripts/dropdown.js @@ -3,11 +3,15 @@ document.querySelectorAll("[data-sourcemeta-ui-dropdown]").forEach((dropdown) => const menu = dropdown.querySelector("[data-sourcemeta-ui-dropdown-menu]"); function close() { - // Whoever was reading the menu would be left standing on something that - // is no longer there, with no way back to the control that opened it - const wasInside = dropdown.contains(document.activeElement); menu.classList.remove("show"); toggle.setAttribute("aria-expanded", "false"); + } + + // Whoever was reading the menu would be left standing on something that is + // no longer there, with no way back to the control that opened it + function dismiss() { + const wasInside = dropdown.contains(document.activeElement); + close(); if (wasInside) { toggle.focus(); } @@ -28,15 +32,17 @@ document.querySelectorAll("[data-sourcemeta-ui-dropdown]").forEach((dropdown) => } }); + // Focus landing nowhere is focus that never went anywhere, such as a click + // on a part of the menu that cannot hold it dropdown.addEventListener("focusout", (event) => { - if (!dropdown.contains(event.relatedTarget)) { + if (event.relatedTarget && !dropdown.contains(event.relatedTarget)) { close(); } }); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { - close(); + dismiss(); } }); }); From b6e54642d62e0c90cfe1af98c9f7651544c42bbc Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Thu, 8 Oct 2026 15:19:09 -0300 Subject: [PATCH 4/4] Simpler Signed-off-by: Juan Cruz Viotti --- enterprise/e2e/html/hurl/schemas-as.all.hurl | 38 ++++++++-- .../e2e/html/playwright/convert.spec.js | 21 +++++- src/web/helpers.h | 75 ++++++++++--------- src/web/pages/schema.cc | 20 ++++- 4 files changed, 106 insertions(+), 48 deletions(-) diff --git a/enterprise/e2e/html/hurl/schemas-as.all.hurl b/enterprise/e2e/html/hurl/schemas-as.all.hurl index 94d3bc699..43af1c9a7 100644 --- a/enterprise/e2e/html/hurl/schemas-as.all.hurl +++ b/enterprise/e2e/html/hurl/schemas-as.all.hurl @@ -746,9 +746,9 @@ xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[4]/a/@href)" == "/test/ xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[5]/a)" == "2020-12" xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[5]/a/@href)" == "/test/draft3/string.json?as=2020-12" -# A schema on the newest dialect is still offered it, as what it references -# may be older -GET {{base}}/test/object +# The dialect the schema already declares is left out, as a schema is +# already written in it +GET {{base}}/test/jsonld/draft7 Accept: text/html HTTP 200 Cache-Control: public, max-age=0, must-revalidate @@ -760,6 +760,32 @@ X-Frame-Options: DENY header "Vary" == "Accept, Accept-Encoding" header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ xpath "string(//button[@data-sourcemeta-ui-dropdown-toggle])" == "Convert" -xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a)" == 1 -xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a)" == "2020-12" -xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a/@href)" == "/test/object.json?as=2020-12" +xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a)" == 2 +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a)" == "2019-09" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[1]/a/@href)" == "/test/jsonld/draft7.json?as=2019-09" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a)" == "2020-12" +xpath "string(//ul[@data-sourcemeta-ui-dropdown-menu]/li[2]/a/@href)" == "/test/jsonld/draft7.json?as=2020-12" +xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu]/li/a[text()='Draft7'])" == 0 + +# A schema on the newest dialect has nothing left to be offered, even though +# the API still serves it in that dialect +GET {{base}}/test/object +Accept: text/html +HTTP 200 +Cache-Control: public, max-age=0, must-revalidate +Content-Type: text/html; charset=utf-8 +Referrer-Policy: strict-origin-when-cross-origin +Content-Security-Policy: frame-ancestors 'none' +X-Frame-Options: DENY +[Asserts] +header "Vary" == "Accept, Accept-Encoding" +header "Date" matches /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (0[1-9]|[12][0-9]|3[01]) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [0-9]{4} ([01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9] GMT$/ +xpath "count(//button[@data-sourcemeta-ui-dropdown-toggle])" == 0 +xpath "count(//ul[@data-sourcemeta-ui-dropdown-menu])" == 0 +xpath "boolean(//a[@href='/test/object.json?bundle=1'])" == true + +GET {{base}}/test/object.json?as=2020-12 +HTTP 200 +Content-Type: application/schema+json +[Asserts] +header "ETag" exists diff --git a/enterprise/e2e/html/playwright/convert.spec.js b/enterprise/e2e/html/playwright/convert.spec.js index c6e080c7a..ee670fa31 100644 --- a/enterprise/e2e/html/playwright/convert.spec.js +++ b/enterprise/e2e/html/playwright/convert.spec.js @@ -34,17 +34,30 @@ test.describe('Schema Convert Dropdown', () => { 'href', '/test/draft3/string.json?as=2020-12'); }); - test('offers the dialect the schema already declares', async ({ page }) => { - await page.goto('/test/object'); + test('leaves out the dialect the schema already declares', + async ({ page }) => { + await page.goto('/test/jsonld/draft7'); const toggle = page.locator('[data-sourcemeta-ui-dropdown-toggle]'); const menu = page.locator('[data-sourcemeta-ui-dropdown-menu]'); await toggle.click(); const items = menu.locator('a'); - await expect(items).toHaveText([ '2020-12' ]); + await expect(items).toHaveText([ '2019-09', '2020-12' ]); await expect(items.nth(0)).toHaveAttribute( - 'href', '/test/object.json?as=2020-12'); + 'href', '/test/jsonld/draft7.json?as=2019-09'); + await expect(items.nth(1)).toHaveAttribute( + 'href', '/test/jsonld/draft7.json?as=2020-12'); + }); + + test('a schema on the newest dialect gets no control at all', + async ({ page }) => { + await page.goto('/test/object'); + + await expect(page.locator('[data-sourcemeta-ui-dropdown-toggle]')) + .toHaveCount(0); + await expect(page.locator('a[href="/test/object.json?bundle=1"]')) + .toBeVisible(); }); test('the menu sits below the button it belongs to', async ({ page }) => { diff --git a/src/web/helpers.h b/src/web/helpers.h index 3285b9b84..f4a4159fe 100644 --- a/src/web/helpers.h +++ b/src/web/helpers.h @@ -105,49 +105,56 @@ inline auto dialect_display_name(const std::string_view name) -> std::string { return result; } +// The short name a dialect goes by, which is also the name a conversion into +// it is asked for by inline auto -make_dialect_badge(sourcemeta::core::HTMLWriter &writer, - const sourcemeta::core::JSON::String &base_dialect_uri) - -> void { - const auto [short_name, is_current] = - [&base_dialect_uri]() -> std::pair { - if (base_dialect_uri == "https://json-schema.org/draft/2020-12/schema" || - base_dialect_uri == - "https://json-schema.org/draft/2020-12/hyper-schema") { - return {"2020-12", true}; - } +dialect_short_name(const sourcemeta::core::JSON::String &base_dialect_uri) + -> std::string_view { + if (base_dialect_uri == "https://json-schema.org/draft/2020-12/schema" || + base_dialect_uri == + "https://json-schema.org/draft/2020-12/hyper-schema") { + return "2020-12"; + } - if (base_dialect_uri == "https://json-schema.org/draft/2019-09/schema" || - base_dialect_uri == - "https://json-schema.org/draft/2019-09/hyper-schema") { - return {"2019-09", false}; - } + if (base_dialect_uri == "https://json-schema.org/draft/2019-09/schema" || + base_dialect_uri == + "https://json-schema.org/draft/2019-09/hyper-schema") { + return "2019-09"; + } - if (base_dialect_uri == "http://json-schema.org/draft-07/schema#" || - base_dialect_uri == "http://json-schema.org/draft-07/hyper-schema#") { - return {"draft7", false}; - } + if (base_dialect_uri == "http://json-schema.org/draft-07/schema#" || + base_dialect_uri == "http://json-schema.org/draft-07/hyper-schema#") { + return "draft7"; + } - if (base_dialect_uri == "http://json-schema.org/draft-06/schema#" || - base_dialect_uri == "http://json-schema.org/draft-06/hyper-schema#") { - return {"draft6", false}; - } + if (base_dialect_uri == "http://json-schema.org/draft-06/schema#" || + base_dialect_uri == "http://json-schema.org/draft-06/hyper-schema#") { + return "draft6"; + } - if (base_dialect_uri == "http://json-schema.org/draft-04/schema#" || - base_dialect_uri == "http://json-schema.org/draft-04/hyper-schema#") { - return {"draft4", false}; - } + if (base_dialect_uri == "http://json-schema.org/draft-04/schema#" || + base_dialect_uri == "http://json-schema.org/draft-04/hyper-schema#") { + return "draft4"; + } - if (base_dialect_uri == "http://json-schema.org/draft-03/schema#" || - base_dialect_uri == "http://json-schema.org/draft-03/hyper-schema#") { - return {"draft3", false}; - } + if (base_dialect_uri == "http://json-schema.org/draft-03/schema#" || + base_dialect_uri == "http://json-schema.org/draft-03/hyper-schema#") { + return "draft3"; + } - return {"unknown", false}; - }(); + return "unknown"; +} + +inline auto +make_dialect_badge(sourcemeta::core::HTMLWriter &writer, + const sourcemeta::core::JSON::String &base_dialect_uri) + -> void { + const auto short_name{dialect_short_name(base_dialect_uri)}; + const auto is_current{short_name == "2020-12"}; writer.a() - .attribute("href", "https://www.learnjsonschema.com/" + short_name) + .attribute("href", + "https://www.learnjsonschema.com/" + std::string{short_name}) .attribute("target", "_blank"); writer.span().attribute( "class", diff --git a/src/web/pages/schema.cc b/src/web/pages/schema.cc index 1de6766a0..e99732227 100644 --- a/src/web/pages/schema.cc +++ b/src/web/pages/schema.cc @@ -8,6 +8,7 @@ #include #include +#include // std::ranges::any_of #include // assert #include // std::chrono #include // std::filesystem @@ -82,9 +83,16 @@ auto GenerateWebSchema::handler( body.text("Bundle"); body.close(); - // Conversions are advertised only where they are offered, so a schema - // nothing can be converted into grows no control at all - if (meta.defines("conversions") && !meta.at("conversions").empty()) { + // A schema is already written in the dialect it declares, so offering + // to convert it into that one reads as a choice where there is none + const auto *const conversions{meta.try_at("conversions")}; + const auto declared{ + html::dialect_short_name(meta.at("baseDialect").to_string())}; + if (conversions != nullptr && + std::ranges::any_of(conversions->as_object(), + [&declared](const auto &conversion) { + return conversion.first != declared; + })) { body.div() .attribute("class", "btn-group ms-2") .attribute("data-sourcemeta-ui-dropdown", ""); @@ -98,7 +106,11 @@ auto GenerateWebSchema::handler( body.ul() .attribute("class", "dropdown-menu") .attribute("data-sourcemeta-ui-dropdown-menu", ""); - for (const auto &conversion : meta.at("conversions").as_object()) { + for (const auto &conversion : conversions->as_object()) { + if (conversion.first == declared) { + continue; + } + body.li(); body.a() .attribute("class", "dropdown-item")