From 64a935bb6303de5fcc5382b91afdd8f40388ecd3 Mon Sep 17 00:00:00 2001 From: Gordex2014 Date: Mon, 21 Sep 2026 17:11:36 -0400 Subject: [PATCH] fix(ui): group result counts by locale (#1426) --- crates/ui/assets/saved-queries.js | 15 ++++- crates/ui/e2e/tests/resources.spec.ts | 90 +++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/crates/ui/assets/saved-queries.js b/crates/ui/assets/saved-queries.js index 3195a5f7b..5ee3bf2e8 100644 --- a/crates/ui/assets/saved-queries.js +++ b/crates/ui/assets/saved-queries.js @@ -61,6 +61,17 @@ var etag = null; var lang = document.documentElement.lang || undefined; + /* Result-header counts follow the page's own locale (#1426), the same way + * `whenText` localizes dates: `lang` is the negotiated ``, and + * an absent attribute leaves the choice to the platform. Only the rendered + * text is grouped — the wire query, `Bundle.total`, paging, and the numbers + * the script keeps for itself are untouched. */ + function formatCount(value) { + var count = Number(value); + if (!Number.isFinite(count)) return String(value); + return count.toLocaleString(lang); + } + function fetchDocument() { return fetch(SETTINGS, { headers: { Accept: "application/json" }, @@ -2589,11 +2600,11 @@ !hasTotal && hasNext ? results.card.dataset.msgTotalPartial : results.card.dataset.msgTotal - ).replace("{count}", total); + ).replace("{count}", formatCount(total)); if (included > 0) meta += " · " + - results.card.dataset.msgIncluded.replace("{count}", included); + results.card.dataset.msgIncluded.replace("{count}", formatCount(included)); var columns = elementColumns(context.query); /* No _elements: one column per attribute the server actually returned, so diff --git a/crates/ui/e2e/tests/resources.spec.ts b/crates/ui/e2e/tests/resources.spec.ts index a8ca4f12e..c3c7c6c57 100644 --- a/crates/ui/e2e/tests/resources.spec.ts +++ b/crates/ui/e2e/tests/resources.spec.ts @@ -141,6 +141,96 @@ test("the results heading sits on the same row as its count", async ({ resources expect(geometry.headingMarginBottom).toBe("0px"); }); +// #1426: the results header groups digits for the active UI locale — English +// 12345 reads "12,345 results", Spanish "12.345 resultados", German "12.345 +// Ergebnisse" — while the typed search still goes out exactly as before. The +// Bundles are intercepted: five digits and a grouped include count would +// otherwise mean seeding thousands of real resources. +test("the results header groups counts in the active locale", async ({ page, resources }) => { + const patient = (id: string) => ({ resource: { resourceType: "Patient", id } }); + const searchset = ( + total: number | null, + entry: Array<{ resource: { resourceType: string; id: string } }>, + next = false, + ) => ({ + resourceType: "Bundle", + type: "searchset", + ...(total === null ? {} : { total }), + ...(next ? { link: [{ relation: "next", url: "/Patient?_id=more&page=2" }] } : {}), + entry, + }); + + // One route serves every case: the run's own `_id` picks its Bundle, and + // anything else (the page's default Patient listing) answers empty. + const bundles: Record = { + // Five digits is the smallest exact count *all* the locales below group: + // Spanish CLDR leaves four digits ungrouped ("1234 resultados"). + "count-en": searchset(12345, [patient("count-en")]), + "count-es": searchset(12345, [patient("count-es")]), + "count-de": searchset(12345, [patient("count-de")]), + // One page whose total matches its entries: the include count runs + // through the same formatter. + "count-included": searchset(1235, [ + patient("count-included"), + ...Array.from({ length: 1234 }, (_, index) => ({ + resource: { resourceType: "Organization", id: `org-${index}` }, + })), + ]), + // No `Bundle.total` with a next page: the page count is grouped and keeps + // its `+` wording (#1003). + "count-partial": searchset( + null, + Array.from({ length: 1234 }, (_, index) => patient(`p-${index}`)), + true, + ), + "count-small": searchset(42, [patient("count-small")]), + "count-zero": searchset(0, []), + }; + const wire: string[] = []; + await page.route( + (url) => url.pathname.endsWith("/Patient") && url.search !== "", + async (route) => { + const url = new URL(route.request().url()); + const marker = url.searchParams.get("_id") || ""; + if (marker) wire.push(url.search); + await route.fulfill({ + status: 200, + contentType: "application/fhir+json", + body: JSON.stringify(bundles[marker] ?? searchset(0, [])), + }); + }, + ); + + const scenarios = [ + { lang: "en", marker: "count-en", expected: "12,345 results" }, + { lang: "es", marker: "count-es", expected: "12.345 resultados" }, + { lang: "de", marker: "count-de", expected: "12.345 Ergebnisse" }, + { lang: "en", marker: "count-included", expected: "1,235 results · 1,234 included" }, + { lang: "en", marker: "count-partial", expected: "1,234+ results" }, + { lang: "en", marker: "count-small", expected: "42 results" }, + { lang: "en", marker: "count-zero", expected: "0 results" }, + ] as const; + for (const scenario of scenarios) { + // `?lang=` is the page's own language switch (the fixture starts from a + // clean cookie jar), so each case states the locale it asserts. + await page.goto(`/ui/resources?type=Patient&lang=${scenario.lang}`, { + waitUntil: "networkidle", + }); + await expect(page.locator("html")).toHaveAttribute("lang", scenario.lang); + await switchToBuilderMode(resources); + await resources.results.waitShown(); + + await resources.builder.run(`Patient?_id=${scenario.marker}`); + await expect(resources.results.meta).toHaveText(scenario.expected); + } + + // Grouping is a rendering concern only: every run still asked for exactly + // the query the user typed, plus the existing `_total=accurate` (#1003). + await expect + .poll(() => wire) + .toEqual(scenarios.map((scenario) => `?_id=${scenario.marker}&_total=accurate`)); +}); + test("selecting a type updates the Create label and the URL", async ({ resources, page }) => { await resources.goto("Patient"); await expect(resources.createLabel).toHaveText("Create new Patient");