diff --git a/crates/ui/assets/app.css b/crates/ui/assets/app.css index cbe14deaf..c27a268f9 100644 --- a/crates/ui/assets/app.css +++ b/crates/ui/assets/app.css @@ -4700,6 +4700,9 @@ details.json-fold[open] > summary .icon { } .batch-tab { + display: inline-flex; + align-items: center; + gap: 8px; padding: 8px 14px; border: 1px solid var(--divider); border-radius: 999px; @@ -4847,6 +4850,27 @@ details.json-fold[open] > summary .icon { color: var(--muted); } +/* The card head's right-hand cluster (#729): created count beside the + status badge. */ +.card-head__meta { + display: flex; + align-items: center; + gap: 10px; +} + +.batch-created { + color: var(--muted); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +/* The stage takes programmatic focus when revealed (#679); it is not + keyboard-reachable, so the browser's default ring around the whole + section is noise (#726). */ +#batch-preflight:focus { + outline: none; +} + .batch-json { margin: 0; diff --git a/crates/ui/assets/batch.js b/crates/ui/assets/batch.js index 54cb35112..cd762e68d 100644 --- a/crates/ui/assets/batch.js +++ b/crates/ui/assets/batch.js @@ -43,10 +43,9 @@ var outcomes = document.getElementById("batch-outcomes"); var overall = document.getElementById("batch-overall"); var summary = document.getElementById("batch-summary"); - var executeBtn = document.getElementById("batch-execute"); var executeTopBtn = document.getElementById("batch-execute-top"); - var cancelBtn = document.getElementById("batch-cancel"); var cancelTopBtn = document.getElementById("batch-cancel-top"); + var createdBadge = document.getElementById("batch-created"); var doneBtn = document.getElementById("batch-done"); var busyRegion = document.getElementById("batch-busy"); @@ -194,7 +193,9 @@ revealed stage, not its primary action. */ var active = document.activeElement; if (active === document.body || stages.upload.contains(active)) { - stages.preflight.focus(); + /* preventScroll (#732): on a plan taller than the viewport the + default scroll-into-view pinned the Execute row to the top. */ + stages.preflight.focus({ preventScroll: true }); } } finally { busy.done(); @@ -301,6 +302,11 @@ bundle = null; fileInput.value = ""; clearPreflight(); + /* Errors are page-level elements, not stage content: hiding a stage + leaves them set, so an abandoned attempt's error would greet the next + one (#731). */ + uploadError.hidden = true; + executeError.hidden = true; show("upload"); /* Done/Cancel hid the stage that held focus; land on the one action the upload stage offers (#679). Containment, not body: the focus @@ -314,7 +320,6 @@ drop.focus(); } } - cancelBtn.addEventListener("click", reset); cancelTopBtn.addEventListener("click", reset); /* ---- stage 3: execute and report ------------------------------------ */ @@ -331,7 +336,7 @@ crashed the settling renderResponse. Busy holds until the outcome is rendered, not merely until response headers arrive. */ hfsBusy.during( - [executeBtn, executeTopBtn], + [executeTopBtn], function () { return fetch("/", { method: "POST", @@ -350,10 +355,9 @@ executeError.hidden = false; }); }, - { alsoDisable: [cancelBtn, cancelTopBtn], region: busyRegion, label: messages.msgExecuting } + { alsoDisable: [cancelTopBtn], region: busyRegion, label: messages.msgExecuting } ); } - executeBtn.addEventListener("click", execute); executeTopBtn.addEventListener("click", execute); /* The run already happened: Done lands back on a clean upload stage rather than offering to re-run a mutation that succeeded (#675). */ @@ -416,12 +420,16 @@ outcomes.appendChild(li); }); + /* The created count reads in the card head next to the status badge + (#729); the rest of the tally keeps the summary line, failures above + all. */ + createdBadge.textContent = created ? created + " " + messages.msgCreated : ""; var parts = []; - if (created) parts.push(created + " " + messages.msgCreated); if (updated) parts.push(updated + " " + messages.msgUpdated); if (other) parts.push(other + " " + messages.msgOther); if (failed) parts.push(failed + " " + messages.msgFailed); summary.textContent = parts.join(" · "); + summary.hidden = !parts.length; show("response"); /* The disabled trigger was hidden with its stage; land on the one diff --git a/crates/ui/e2e/tests/batch.spec.ts b/crates/ui/e2e/tests/batch.spec.ts index 52a0528f3..67bb3f2c9 100644 --- a/crates/ui/e2e/tests/batch.spec.ts +++ b/crates/ui/e2e/tests/batch.spec.ts @@ -72,16 +72,19 @@ test("a transaction bundle uploads, previews, executes, and reports", async ({ p await expect(page.locator("#batch-json .json-view")).toBeVisible(); await page.locator("#batch-tab-actions").click(); - // The execute error slot lives inside the footer, next to its button (#676). - await expect(page.locator(".batch-footer #batch-execute-error")).toHaveCount(1); + // The execute error slot sits above the plan card (#730), and the bottom + // footer is gone — Cancel/Execute exist once, at the top. + await expect(page.locator("#batch-execute-error + .card")).toHaveCount(1); + await expect(page.locator("#batch-preflight .batch-footer")).toHaveCount(1); // Execute: outcomes per entry plus the aggregate summary. - await page.locator("#batch-execute").click(); + await page.locator("#batch-execute-top").click(); await expect(page.locator("#batch-response")).toBeVisible(); await expect(page.locator("#batch-outcomes .batch-row")).toHaveCount(2); await expect(page.locator("#batch-outcomes .batch-badge").first()).toContainText("201"); - await expect(page.locator("#batch-summary")).toContainText("2"); - await expect(page.locator("#batch-summary")).toContainText(/created/i); + // The created count reads in the card head, beside the status badge (#729). + await expect(page.locator("#batch-created")).toContainText("2"); + await expect(page.locator("#batch-created")).toContainText(/created/i); await expect(page.locator("#batch-overall")).toHaveClass(/--ok/); // Done is the one way out (#675): back to a clean upload stage. Done hid @@ -213,7 +216,7 @@ test("an invalid replacement clears the old preview and cannot execute stale or // Exercise the defensive guard even though the hidden control cannot be // reached by a user while the upload stage is visible. - await page.locator("#batch-execute").evaluate((button: HTMLButtonElement) => button.click()); + await page.locator("#batch-execute-top").evaluate((button: HTMLButtonElement) => button.click()); expect(executeRequests).toBe(0); await expect(page.locator("#batch-upload-error")).toBeVisible(); }); @@ -320,7 +323,7 @@ test("a non-bundle file is rejected with a message, not a crash", async ({ page // The transient states are made deterministically observable: FileReader // delivery and the execute POST are both parked behind manual releases. -const FOOTER_CONTROLS = ["#batch-execute", "#batch-execute-top", "#batch-cancel", "#batch-cancel-top"]; +const FOOTER_CONTROLS = ["#batch-execute-top", "#batch-cancel-top"]; test("picking a file shows the busy region before any file bytes arrive", async ({ page }) => { await page.goto("/ui/batch", { waitUntil: "networkidle" }); @@ -373,12 +376,12 @@ test("execute busies the whole footer, ignores re-entrant clicks, and lands focu await page.goto("/ui/batch", { waitUntil: "networkidle" }); await page.locator("#batch-file").setInputFiles(bundleFile("batch")); await expect(page.locator("#batch-preflight")).toBeVisible(); - await page.locator("#batch-execute").click(); + await page.locator("#batch-execute-top").click(); // Both Execute copies spin, and the Cancels go inert with them: a // mid-flight Cancel raced the settling response and crashed on the nulled // bundle before #679. - await expect(page.locator("#batch-execute")).toHaveAttribute("aria-busy", "true"); + await expect(page.locator("#batch-execute-top")).toHaveAttribute("aria-busy", "true"); await expect(page.locator("#batch-execute-top")).toHaveAttribute("aria-busy", "true"); for (const control of FOOTER_CONTROLS) await expect(page.locator(control)).toBeDisabled(); await expect(page.locator("#batch-busy")).toBeVisible(); @@ -387,7 +390,7 @@ test("execute busies the whole footer, ignores re-entrant clicks, and lands focu // The default-motion busy button: the label yields to the animated ring, // and the filled primary gets the explicit white ring (accent-on-accent // vanishes in dark theme). - const busyStyle = await page.locator("#batch-execute").evaluate((button) => ({ + const busyStyle = await page.locator("#batch-execute-top").evaluate((button) => ({ color: getComputedStyle(button).color, content: getComputedStyle(button, "::after").content, animation: getComputedStyle(button, "::after").animationName, @@ -401,7 +404,7 @@ test("execute busies the whole footer, ignores re-entrant clicks, and lands focu // Re-entrant activation cannot double-POST: a real click on a disabled // button dispatches nothing, and a synthetic event that does reach the // handler is ignored by the busy guard. - await page.locator("#batch-execute").evaluate((button: HTMLButtonElement) => button.click()); + await page.locator("#batch-execute-top").evaluate((button: HTMLButtonElement) => button.click()); await page .locator("#batch-execute-top") .evaluate((button) => button.dispatchEvent(new MouseEvent("click", { bubbles: true }))); @@ -444,10 +447,10 @@ test("a whole-bundle failure clears the busy state and re-enables the footer", a await page.goto("/ui/batch", { waitUntil: "networkidle" }); await page.locator("#batch-file").setInputFiles(bundleFile("batch")); - await page.locator("#batch-execute").click(); + await page.locator("#batch-execute-top").click(); // The busy state is genuinely entered before the failure lands… - await expect(page.locator("#batch-execute")).toHaveAttribute("aria-busy", "true"); + await expect(page.locator("#batch-execute-top")).toHaveAttribute("aria-busy", "true"); await expect(page.locator("#batch-busy")).toBeVisible(); release(); @@ -456,10 +459,44 @@ test("a whole-bundle failure clears the busy state and re-enables the footer", a await expect(page.locator("#batch-execute-error")).toBeVisible(); await expect(page.locator("#batch-preflight")).toBeVisible(); for (const control of FOOTER_CONTROLS) await expect(page.locator(control)).toBeEnabled(); - await expect(page.locator("#batch-execute")).not.toHaveAttribute("aria-busy", "true"); + await expect(page.locator("#batch-execute-top")).not.toHaveAttribute("aria-busy", "true"); await expect(page.locator("#batch-busy")).toBeHidden(); // Focus returns to the trigger, which sits next to the inline error (#676). - await expect(page.locator("#batch-execute")).toBeFocused(); + await expect(page.locator("#batch-execute-top")).toBeFocused(); + + // Cancel wipes the attempt completely: dropping the next file must not + // resurface the abandoned attempt's error (#731). + await page.locator("#batch-cancel-top").click(); + await expect(page.locator("#batch-upload-error")).toBeHidden(); + await page.locator("#batch-file").setInputFiles(bundleFile("batch")); + await expect(page.locator("#batch-preflight")).toBeVisible(); + await expect(page.locator("#batch-execute-error")).toBeHidden(); +}); + +test("revealing a tall preflight neither scrolls the page nor paints a focus ring", async ({ + page, +}) => { + await page.goto("/ui/batch", { waitUntil: "networkidle" }); + // Enough entries that the rendered plan is taller than the viewport — + // the size precondition of the focus() scroll (#732). + const entries = Array.from({ length: 60 }, (_, i) => ({ + request: { method: "POST", url: "Patient" }, + resource: { resourceType: "Patient", name: [{ family: `Tall${i}` }] }, + })); + const file = writeRawFile( + JSON.stringify({ resourceType: "Bundle", type: "batch", entry: entries }), + "tall", + ); + await page.locator("#batch-file").setInputFiles(file); + await expect(page.locator("#batch-preflight")).toBeVisible(); + await expect(page.locator("#batch-rows .batch-row")).toHaveCount(60); + expect(await page.evaluate(() => window.scrollY)).toBe(0); + // The stage holds programmatic focus without the browser's default ring + // tracing its outline (#726). + await expect(page.locator("#batch-preflight")).toBeFocused(); + expect( + await page.locator("#batch-preflight").evaluate((el) => getComputedStyle(el).outlineStyle), + ).toBe("none"); }); test("reduced-motion users get a static ring, not an animated one", async ({ page }) => { @@ -474,12 +511,12 @@ test("reduced-motion users get a static ring, not an animated one", async ({ pag await page.goto("/ui/batch", { waitUntil: "networkidle" }); await page.locator("#batch-file").setInputFiles(bundleFile("batch")); - await page.locator("#batch-execute").click(); - await expect(page.locator("#batch-execute")).toHaveAttribute("aria-busy", "true"); + await page.locator("#batch-execute-top").click(); + await expect(page.locator("#batch-execute-top")).toHaveAttribute("aria-busy", "true"); // The static form: the ring glyph is present but does not animate. A // label-only dimmed button would read as "disabled", not "working". - const after = await page.locator("#batch-execute").evaluate((button) => ({ + const after = await page.locator("#batch-execute-top").evaluate((button) => ({ content: getComputedStyle(button, "::after").content, animation: getComputedStyle(button, "::after").animationName, })); @@ -512,7 +549,7 @@ test("a synchronously-failing operation cannot leave a stale region label", asyn // region keeps the stale label and announces it on its next reveal. const state = await page.evaluate(() => { const region = document.getElementById("batch-busy") as HTMLElement; - const button = document.getElementById("batch-execute") as HTMLButtonElement; + const button = document.getElementById("batch-execute-top") as HTMLButtonElement; const busyApi = (window as { hfsBusy?: { during: Function } }).hfsBusy!; busyApi.during( [button], diff --git a/crates/ui/templates/pages/batch.html b/crates/ui/templates/pages/batch.html index d5688d7c1..01d04cf37 100644 --- a/crates/ui/templates/pages/batch.html +++ b/crates/ui/templates/pages/batch.html @@ -67,25 +67,22 @@

{% include "icons/export.svg" %} {{ i18n.t("batch- server will treat failures — all-or-nothing vs. entry-by-entry. -->

+ + +

{% include "icons/layers.svg" %} {{ i18n.t("batch-plan-heading") }}

- - + +
    - - @@ -93,10 +90,13 @@

    {% include "icons/layers.svg" %} {{ i18n.t("batch-

    {% include "icons/check.svg" %} {{ i18n.t("batch-response-heading") }}

    - + +
    + + +
    -