diff --git a/crates/hts-ui/templates/pages/capability-statement.html b/crates/hts-ui/templates/pages/capability-statement.html index 82ee50ff2..c7160e943 100644 --- a/crates/hts-ui/templates/pages/capability-statement.html +++ b/crates/hts-ui/templates/pages/capability-statement.html @@ -40,6 +40,8 @@ -#} {% extends "layouts/base.html" %} +{% block content_class %} content--capability{% endblock %} + {% block title %}{{ chrome.i18n.t("cap-title") }} · {{ chrome.i18n.t("hts-app-title") }}{% endblock %} {% block content %} diff --git a/crates/ui-chrome/templates/partials/capability-raw-card.html b/crates/ui-chrome/templates/partials/capability-raw-card.html index 2da86c856..d01767ec6 100644 --- a/crates/ui-chrome/templates/partials/capability-raw-card.html +++ b/crates/ui-chrome/templates/partials/capability-raw-card.html @@ -24,9 +24,16 @@

{{ i18n.t("cap-raw-toggle") }}

- {% include "icons/chevron-down.svg" %} +
+
+ + +
+ {% include "icons/chevron-down.svg" %} +
-
summary .icon { overflow-x: auto; } +.content--capability .table-wrap { + padding-inline: 20px; +} + .data-table { width: 100%; border-collapse: collapse; @@ -3070,6 +3086,23 @@ details.json-fold[open] > summary .icon { font-weight: 600; } +.content--capability .tag { + margin-inline: 0 8px; +} + +.content--capability { + display: flex; + flex-direction: column; + gap: 20px; +} + +.content--capability > .page-head { + margin-bottom: 0px; +} + +.content--capability > .card ~ .card { + margin-top: 0px; +} .tag--type { background: var(--accent-soft); color: var(--text); @@ -4374,6 +4407,12 @@ details.json-fold[open] > summary .icon { gap: 6px; } +.card-head__actions { + display: flex; + align-items: center; + gap: 10px; +} + .editor-json__act { padding: 3px 8px; font: inherit; diff --git a/crates/ui/assets/capability-json.js b/crates/ui/assets/capability-json.js index 031afaee5..8cf053626 100644 --- a/crates/ui/assets/capability-json.js +++ b/crates/ui/assets/capability-json.js @@ -61,13 +61,8 @@ if (body) reset(body, false); } - function closeOtherBranches(details) { - var root = details.closest("#capability-json-fold"); - if (!root || details === root) return; - root.querySelectorAll("details[data-capability-json-node][open]").forEach(function (open) { - if (open === details || open === root || open.contains(details)) return; - collapse(open); - }); + function rootFor(details) { + return details.closest("#capability-json-fold"); } function triggerLoad(body) { @@ -92,6 +87,39 @@ } } + function expandNext(root) { + if (!root || root.dataset.capabilityJsonExpandAll !== "true") return; + var nodes = root.querySelectorAll("details[data-capability-json-node]"); + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + if (node.open) continue; + node.open = true; + var body = bodyFor(node); + if (body) { + triggerLoad(body); + return; + } + } + } + + function setAll(details, expand) { + var root = rootFor(details); + if (!root) return; + root.dataset.capabilityJsonExpandAll = expand ? "true" : "false"; + if (expand) { + root.open = true; + var rootBody = bodyFor(root); + if (rootBody) { + triggerLoad(rootBody); + expandNext(root); + } + return; + } + root.querySelectorAll("details[data-capability-json-node]").forEach(function (node) { + collapse(node); + }); + } + document.addEventListener( "toggle", function (event) { @@ -101,17 +129,27 @@ if (!body) return; if (details.open) { - closeOtherBranches(details); triggerLoad(body); } else { // Removing the swapped subtree is the DOM budget: reopening issues a // fresh, bounded request rather than retaining descendants invisibly. + if (details === rootFor(details)) details.dataset.capabilityJsonExpandAll = "false"; reset(body, false); } }, true, ); + document.addEventListener("click", function (event) { + var control = event.target.closest && event.target.closest("[data-capability-json-fold]"); + if (!control) return; + event.preventDefault(); + event.stopPropagation(); + var details = control.closest("#capability-json-fold"); + if (!details) return; + setAll(details, control.dataset.capabilityJsonFold === "none"); + }); + document.addEventListener("htmx:beforeRequest", function (event) { var target = event.detail && event.detail.target; if (!target || !target.hasAttribute("data-capability-json-body")) return; @@ -152,6 +190,7 @@ var control = preferred || fallback; if (control) control.focus({ preventScroll: true }); } + expandNext(rootFor(target)); }); document.addEventListener("htmx:afterRequest", function (event) { @@ -162,6 +201,8 @@ if (!(status >= 200 && status < 400)) { var details = target.closest("details[data-capability-json-node]"); reset(target, !!(details && details.open)); + var root = rootFor(target); + if (root) root.dataset.capabilityJsonExpandAll = "false"; } }); })(); diff --git a/crates/ui/e2e/pages/capability-statement.ts b/crates/ui/e2e/pages/capability-statement.ts index 535b0eaf6..cd0fa8b67 100644 --- a/crates/ui/e2e/pages/capability-statement.ts +++ b/crates/ui/e2e/pages/capability-statement.ts @@ -61,6 +61,14 @@ export class CapabilityStatementPage { return this.rawBody.locator("#capability-json-loading"); } + get collapseAllJson(): Locator { + return this.rawDisclosure.locator('[data-capability-json-fold="all"]'); + } + + get expandAllJson(): Locator { + return this.rawDisclosure.locator('[data-capability-json-fold="none"]'); + } + get jsonView(): Locator { return this.rawBody.locator("#capability-json"); } diff --git a/crates/ui/e2e/tests/capability-statement.spec.ts b/crates/ui/e2e/tests/capability-statement.spec.ts index dea8fa025..08ad5c742 100644 --- a/crates/ui/e2e/tests/capability-statement.spec.ts +++ b/crates/ui/e2e/tests/capability-statement.spec.ts @@ -97,9 +97,19 @@ test("Capability Statement JSON expands in bounded reloadable fragments", async await expect(capabilityStatement.jsonOutline).toBeVisible(); await expect(capabilityStatement.jsonOutline).toHaveAttribute("data-item-count", /\d+/); + await expect(capabilityStatement.collapseAllJson).toBeVisible(); + await expect(capabilityStatement.expandAllJson).toBeVisible(); + await expect(capabilityStatement.rawDisclosure).toHaveAttribute("open", ""); + await expect(capabilityStatement.rawBody).toHaveAttribute("tabindex", "0"); const format = capabilityStatement.jsonNode(capabilityStatement.jsonOutline, "format"); const formatBody = capabilityStatement.nodeBody(format); + await capabilityStatement.expandAllJson.click(); + await expect(format).toHaveAttribute("open", ""); + await expect(formatBody.locator("[data-capability-json-page], .json-view").first()).toBeVisible(); + await capabilityStatement.collapseAllJson.click(); + await expect(format).not.toHaveAttribute("open", ""); + await expect(capabilityStatement.rawDisclosure).toHaveAttribute("open", ""); await format.locator(":scope > summary").click(); await expect(formatBody.locator("[data-capability-json-page], .json-view").first()).toBeVisible(); await expect(capabilityStatement.rawBody).toHaveCSS("overflow-y", "auto"); @@ -109,10 +119,9 @@ test("Capability Statement JSON expands in bounded reloadable fragments", async const rest = capabilityStatement.jsonNode(capabilityStatement.jsonOutline, "rest"); const restBody = capabilityStatement.nodeBody(rest); // An in-flight request is aborted and its busy state cleared when the node - // closes. Opening this sibling also closes and unloads the format branch. + // closes. Opening this sibling leaves the format branch available. await rest.locator(":scope > summary").click(); - await expect(format).not.toHaveAttribute("open", ""); - await expect(formatBody.locator("[data-capability-json-page], .json-view")).toHaveCount(0); + await expect(format).toHaveAttribute("open", ""); await expect(restBody).toHaveAttribute("aria-busy", "true"); await rest.locator(":scope > summary").click(); await expect(restBody).not.toHaveAttribute("aria-busy", "true"); @@ -171,6 +180,12 @@ test("Capability Statement JSON expands in bounded reloadable fragments", async "data-item-count", "100", ); + await expect + .poll(() => capabilityStatement.rawBody.evaluate((body) => body.scrollHeight > body.clientHeight)) + .toBe(true); + await capabilityStatement.rawBody.focus(); + await capabilityStatement.rawBody.press("PageDown"); + await expect.poll(() => capabilityStatement.rawBody.evaluate((body) => body.scrollTop)).toBeGreaterThan(0); const next = capabilityStatement.pageControl(resourceBody, "next"); await expect(next).toBeEnabled(); await next.click(); @@ -191,6 +206,11 @@ test("Capability Statement JSON expands in bounded reloadable fragments", async expect(secondPageCount).toBeLessThanOrEqual(100); expect(fragmentRequests.get("/rest/0/resource")).toBe(2); + await capabilityStatement.collapseAllJson.click(); + await expect(format).not.toHaveAttribute("open", ""); + await expect(rest).not.toHaveAttribute("open", ""); + await expect(capabilityStatement.rawDisclosure).toHaveAttribute("open", ""); + // Closing the top-level disclosure unloads the entire incremental tree. await capabilityStatement.rawSummary.click(); await expect(capabilityStatement.rawDisclosure).not.toHaveAttribute("open", ""); @@ -200,7 +220,6 @@ test("Capability Statement JSON expands in bounded reloadable fragments", async await expect(capabilityStatement.jsonOutline).toBeVisible(); expect(fragmentRequests.get("")).toBe(2); }); - test("typing filters resource capabilities live", async ({ capabilityStatement }) => { await capabilityStatement.goto(); await expect(capabilityStatement.resourceRow("Patient")).toBeVisible(); diff --git a/crates/ui/templates/pages/capability-statement.html b/crates/ui/templates/pages/capability-statement.html index b8d375670..309f69547 100644 --- a/crates/ui/templates/pages/capability-statement.html +++ b/crates/ui/templates/pages/capability-statement.html @@ -17,6 +17,8 @@ -#} {% extends "layouts/base.html" %} +{% block content_class %} content--capability{% endblock %} + {% block title %}{{ i18n.t("cap-title") }} — {{ i18n.t("app-title") }}{% endblock %} {% block content %}