From bc1633db4009620235f560eed4fd0feb48c18bcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 21:59:47 +0200 Subject: [PATCH] fix(export): make the standard report a click, not a preselection The scope picker arrived with the standard doctor's report already ticked, so someone who opened the panel and pressed Generate sent material they never chose. Nothing is selected now until a person selects it. The template keeps its definition and loses its automatic application: it sits on a button at the top of the picker, named, on both surfaces that mount the picker, and it stays there so the standard set can be restored after experimenting. One click still gets a complete doctor's report. Generate and the share-link create refuse an empty scope, with the scope line beside them saying what to do rather than reporting a failure after the press; the line also describes the control, so the disabled state is not silent for a screen reader. --- e2e/settings-export.spec.ts | 66 ++++++++++-- e2e/share-documents.spec.ts | 11 +- messages/de.json | 8 +- messages/en.json | 8 +- messages/es.json | 8 +- messages/fr.json | 8 +- messages/it.json | 8 +- messages/pl.json | 8 +- .../__tests__/report-selection-panel.test.tsx | 101 ++++++++++++------ .../__tests__/share-link-create-form.test.tsx | 37 +++++++ .../__tests__/sharing-reselect.test.tsx | 5 +- .../report-selection/report-scope-picker.tsx | 41 +++++++ .../report-selection/scope-summary.tsx | 10 ++ .../report-selection/selection-panel.tsx | 53 +++++---- .../settings/share-link-create-form.tsx | 31 +++--- .../__tests__/catalogue-guard.test.ts | 36 ++++++- src/lib/report-selection/template.ts | 30 ++++-- 17 files changed, 353 insertions(+), 116 deletions(-) diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index dbe14a4d8..a6ef1db5c 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -101,12 +101,11 @@ test.describe("Settings → Export & Import", () => { test("opens the scope picker expanded on a first run, fenced tier and all", async ({ page, }) => { - // The three panel states are the release's whole point, and the first one - // is the only place a template is applied. It has to be VISIBLE — a - // template behind a collapsed disclosure is opt-out wearing a hat — so the - // picker, its twelve group rows and the fenced tier all render without a - // click. Asserted against the export mount; the share form runs its own - // picker with its own defaults. + // The three panel states are the release's whole point, and on the first + // one nothing is selected at all. The picker still opens without a click, + // because with an empty scope the standard-report button and the named + // groups are exactly what the person needs in front of them. Asserted + // against the export mount; the share form runs its own picker. await openGesundheitsakte(page); await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({ timeout: 10_000, @@ -128,6 +127,61 @@ test.describe("Settings → Export & Import", () => { await expect(page.getByTestId("report-scope-summary-export")).toBeVisible(); }); + test("first run selects nothing and refuses to generate until it does", async ({ + page, + }) => { + // The rule the whole surface exists for: nothing reaches the report that + // was not ticked. So a first run starts empty, Generate refuses the press + // and says why, and the one-click action is what makes that cost nothing — + // it fills the standard set and the control comes alive. + await openGesundheitsakte(page); + const generate = page.getByTestId("health-record-generate"); + await expect(generate).toBeVisible({ timeout: 10_000 }); + await expect(generate).toBeDisabled(); + await expect( + page.getByTestId("report-group-row-vitals-export"), + ).toContainText("0/7"); + await expect(page.getByTestId("report-scope-summary-export")).toContainText( + "Nothing selected yet", + ); + + await page.getByTestId("report-apply-standard-export").click(); + + await expect(generate).toBeEnabled(); + // Exactly the shipped set: the three classic vitals, weight and BMI, and + // nothing at all from the groups the template leaves alone. + await expect( + page.getByTestId("report-group-row-vitals-export"), + ).toContainText("3/7"); + await expect( + page.getByTestId("report-group-row-body-export"), + ).toContainText("2/12"); + await expect( + page.getByTestId("report-group-row-cardio-export"), + ).toContainText("0/14"); + // The fenced tier is not part of any one-click set. + await expect( + page.getByTestId("report-scope-summary-export"), + ).not.toContainText("incl."); + }); + + test("a share link cannot be minted on an empty scope either", async ({ + page, + }) => { + await openGesundheitsakte(page); + const create = page.getByTestId("share-create-submit"); + await expect(create).toBeVisible({ timeout: 10_000 }); + await page.locator("#share-label").fill("Cardiology"); + // A label alone is not a scope: the link would serve a page with nothing + // on it, so the control stays shut until something is chosen. + await expect(create).toBeDisabled(); + await expect(page.getByTestId("report-scope-summary-share")).toContainText( + "Nothing selected yet", + ); + await page.getByTestId("report-apply-standard-share").click(); + await expect(create).toBeEnabled(); + }); + test("clears the tap floor on the group rows at 390 px", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await openGesundheitsakte(page); diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index 28d00a7d6..862f4a426 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -51,9 +51,10 @@ interface CreatedShare { } /** - * Drive the owner create flow: open the picker, isolate the share-fixture trio - * by search, select all three, create the link, and read the one-time token + - * passphrase off the reveal card. Also asserts the QR promotion. + * Drive the owner create flow: name the link, choose a record scope, open the + * picker, isolate the share-fixture trio by search, select all three, create + * the link, and read the one-time token + passphrase off the reveal card. Also + * asserts the QR promotion. */ async function createShareWithDocs(page: Page): Promise { const label = `${SHARE_DOC_PREFIX} ${Date.now()}`; @@ -67,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise { await page.goto("/settings/sharing"); await sharingDataReady; await page.locator("#share-label").fill(label); + // A record link starts with an empty scope and cannot be minted on one, so + // the scope is chosen here the way an owner chooses it: one click on the + // named standard set. + await page.getByTestId("report-apply-standard-share").click(); // Open the document picker and isolate the seeded trio. await page.getByTestId("share-attach-open").click(); diff --git a/messages/de.json b/messages/de.json index 334d94127..4728f85d6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5317,8 +5317,7 @@ "rangeCustomEnd": "Bis", "errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.", "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.", - "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.", - "errorEmptySelection": "Wählen Sie mindestens einen Eintrag aus." + "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet." }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.", @@ -8888,11 +8887,12 @@ "leafMood": "Stimmung", "leafCycle": "Menstruationszyklus", "templateStandard": "Standard-Arztbericht", - "templateApplied": "{template} ist vorausgewählt. Prüfen Sie ihn vor dem Erstellen.", + "applyStandard": "{template} übernehmen", + "standardHint": "Ein Klick füllt aus, was ein normaler Arztbesuch meist braucht. Jeder Eintrag bleibt änderbar.", "groupCount": "{on}/{total}", "groupDisclosure": "Einträge in {group} anzeigen", "sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.", - "scopeNone": "Nichts ausgewählt — das Dokument wäre leer.", + "scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.", "scopeCount": "{count} Einträge aus {groups} Bereichen", "scopeSensitive": "inkl. {names}" } diff --git a/messages/en.json b/messages/en.json index 564f393b2..4191253be 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5317,8 +5317,7 @@ "rangeCustomEnd": "To", "errorGeneric": "The export could not be generated. Please try again.", "errorRateLimit": "You have reached the export limit for this hour.", - "errorModuleDisabled": "The health record export is switched off for this account.", - "errorEmptySelection": "Pick at least one entry to include." + "errorModuleDisabled": "The health record export is switched off for this account." }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.", @@ -8888,11 +8887,12 @@ "leafMood": "Mood", "leafCycle": "Menstrual cycle", "templateStandard": "Standard doctor's report", - "templateApplied": "{template} is preselected. Check it before you generate.", + "applyStandard": "Apply {template}", + "standardHint": "One click fills in what a general visit usually needs. Every entry stays editable.", "groupCount": "{on}/{total}", "groupDisclosure": "Show the entries in {group}", "sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.", - "scopeNone": "Nothing selected — the document would be empty.", + "scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.", "scopeCount": "{count} entries from {groups} areas", "scopeSensitive": "incl. {names}" } diff --git a/messages/es.json b/messages/es.json index 7f14c9594..d046fbd97 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5317,8 +5317,7 @@ "rangeCustomEnd": "Hasta", "errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.", "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.", - "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.", - "errorEmptySelection": "Seleccione al menos una entrada." + "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta." }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -8888,11 +8887,12 @@ "leafMood": "Estado de ánimo", "leafCycle": "Ciclo menstrual", "templateStandard": "Informe médico estándar", - "templateApplied": "{template} está preseleccionado. Revíselo antes de generar.", + "applyStandard": "Aplicar {template}", + "standardHint": "Un clic rellena lo que suele necesitar una consulta general. Cada entrada sigue siendo editable.", "groupCount": "{on}/{total}", "groupDisclosure": "Mostrar las entradas de {group}", "sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.", - "scopeNone": "Nada seleccionado: el documento estaría vacío.", + "scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.", "scopeCount": "{count} entradas de {groups} áreas", "scopeSensitive": "incl. {names}" } diff --git a/messages/fr.json b/messages/fr.json index 8aa90fa95..2eda57ea9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5317,8 +5317,7 @@ "rangeCustomEnd": "Au", "errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.", "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.", - "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.", - "errorEmptySelection": "Sélectionnez au moins un élément." + "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte." }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.", @@ -8888,11 +8887,12 @@ "leafMood": "Humeur", "leafCycle": "Cycle menstruel", "templateStandard": "Compte rendu médical standard", - "templateApplied": "{template} est présélectionné. Vérifiez-le avant de générer.", + "applyStandard": "Appliquer {template}", + "standardHint": "Un clic remplit ce dont une consultation générale a généralement besoin. Chaque élément reste modifiable.", "groupCount": "{on}/{total}", "groupDisclosure": "Afficher les éléments de {group}", "sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.", - "scopeNone": "Rien de sélectionné — le document serait vide.", + "scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.", "scopeCount": "{count} éléments issus de {groups} domaines", "scopeSensitive": "dont {names}" } diff --git a/messages/it.json b/messages/it.json index 198f067a2..f772e54ed 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5317,8 +5317,7 @@ "rangeCustomEnd": "Al", "errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.", "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.", - "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.", - "errorEmptySelection": "Seleziona almeno una voce." + "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account." }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.", @@ -8888,11 +8887,12 @@ "leafMood": "Umore", "leafCycle": "Ciclo mestruale", "templateStandard": "Referto medico standard", - "templateApplied": "{template} è preselezionato. Controllalo prima di generare.", + "applyStandard": "Applica {template}", + "standardHint": "Un clic compila ciò che serve di solito a una visita generale. Ogni voce resta modificabile.", "groupCount": "{on}/{total}", "groupDisclosure": "Mostra le voci di {group}", "sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.", - "scopeNone": "Niente selezionato: il documento sarebbe vuoto.", + "scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.", "scopeCount": "{count} voci da {groups} aree", "scopeSensitive": "incl. {names}" } diff --git a/messages/pl.json b/messages/pl.json index 563f4fc1f..45354f0dc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5317,8 +5317,7 @@ "rangeCustomEnd": "Do", "errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.", "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.", - "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.", - "errorEmptySelection": "Wybierz co najmniej jedną pozycję." + "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta." }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.", @@ -8888,11 +8887,12 @@ "leafMood": "Nastrój", "leafCycle": "Cykl menstruacyjny", "templateStandard": "Standardowy raport lekarski", - "templateApplied": "{template} jest wstępnie wybrany. Sprawdź go przed wygenerowaniem.", + "applyStandard": "Zastosuj {template}", + "standardHint": "Jedno kliknięcie wypełnia to, czego zwykle wymaga wizyta u lekarza rodzinnego. Każdą pozycję można potem zmienić.", "groupCount": "{on}/{total}", "groupDisclosure": "Pokaż pozycje w {group}", "sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.", - "scopeNone": "Nic nie wybrano — dokument byłby pusty.", + "scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.", "scopeCount": "{count} pozycji z {groups} obszarów", "scopeSensitive": "w tym {names}" } diff --git a/src/components/settings/__tests__/report-selection-panel.test.tsx b/src/components/settings/__tests__/report-selection-panel.test.tsx index 3484046ff..7d0a34249 100644 --- a/src/components/settings/__tests__/report-selection-panel.test.tsx +++ b/src/components/settings/__tests__/report-selection-panel.test.tsx @@ -7,22 +7,26 @@ * stick, and it is gated on the user id so a late re-resolve never clobbers * edits in flight. * - * The three panel states are the load-bearing part. First run must open the - * picker EXPANDED with the named template applied and the fenced tier visibly - * empty, so the user presses Generate having seen twelve named groups; a - * repeat run must open collapsed carrying the owner's own last scope. A - * template silently applied behind a collapsed disclosure would be opt-out - * wearing a hat. + * The three panel states are the load-bearing part. A first run must select + * NOTHING — no template, no server default, no group half on — and must leave + * Generate disabled until a person ticks something, with the standard-report + * button one click away. A repeat run must open collapsed carrying the owner's + * own last scope. A set that arrives without being asked for is a + * preselection whatever it is called, and material nobody ticked is exactly + * what this surface must never send. * * SSR-only, per the project convention for this surface. * - * Mutation checks: + * Mutation checks (each verified red, then reverted): * - remove the seeding block → "pre-fills the input" and "restores the saved * selection" go red. + * - seed `selected` from `STANDARD_TEMPLATE_LEAVES` → "selects nothing on a + * first run", "counts nothing in any group" and "disables Generate" go + * red. * - start `pickerOpen` at `false` → "opens expanded on the first run" goes * red. - * - add a fenced leaf to `STANDARD_TEMPLATE_LEAVES` → "checks no fenced leaf - * on the first run" goes red. + * - drop `selected.size === 0` from the Generate `disabled` expression → + * "disables Generate while the scope is empty" goes red. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -126,6 +130,15 @@ function practiceInputValue(html: string): string | null { return input.match(/value="([^"]*)"/)?.[1] ?? ""; } +/** Whether the Generate control refuses the press. */ +function generateDisabled(html: string): boolean { + const button = html.match( + /]*data-testid="health-record-generate"[^>]*>/, + )?.[0]; + if (!button) throw new Error("the Generate button did not render"); + return /\sdisabled=""/.test(button); +} + describe(" — remembered practice name", () => { it("pre-fills the input with the last-used practice name", () => { const html = renderPanel(buildUser("Sample Practice")); @@ -144,24 +157,38 @@ describe(" — remembered practice name", () => { }); describe(" — the first run", () => { - it("opens the picker expanded and names the template it applied", () => { + it("opens the picker expanded with the standard-report button in view", () => { const html = renderPanel(buildUser(null)); expect(html).toContain('data-testid="health-record-included-data-panel"'); expect(html).toContain('data-testid="report-scope-picker-export"'); - expect(html).toContain("Standard doctor's report"); + // The template is offered by name on a button, not applied behind the + // user's back. One click is still one click; it just has to happen. + expect(html).toContain('data-testid="report-apply-standard-export"'); + expect(html).toContain("Apply Standard doctor's report"); }); - it("counts the template's leaves per group, and no others", () => { + it("selects nothing at all — no template, no default", () => { const html = renderPanel(buildUser(null)); - // Identity: both leaves. Vitals: the three classic ones out of seven. - // Body: weight and BMI out of twelve. Nothing at all from the groups the - // template leaves alone. - expect(groupChip(html, "identity")).toBe("2/2"); - expect(groupChip(html, "vitals")).toBe("3/7"); - expect(groupChip(html, "body")).toBe("2/12"); - expect(groupChip(html, "labs")).toBe("1/1"); - expect(groupChip(html, "medications")).toBe("2/4"); - expect(groupChip(html, "history")).toBe("2/2"); + expect(html).toContain('data-testid="report-scope-summary-export"'); + expect(html).toContain("Nothing selected yet"); + // Every leaf the panel renders at all is off. Only the fenced tier renders + // leaf rows while every group is collapsed, and none of them is checked. + for (const leaf of ["MOOD", "CYCLE", "FAMILY_HISTORY", "PHQ9_SCORE"]) { + expect(leafChecked(html, leaf), leaf).toBe(false); + } + }); + + it("counts nothing in any group, the template's own groups included", () => { + const html = renderPanel(buildUser(null)); + // These six carry the standard template's leaves. On a first run they read + // 0/n like every other group: the template is a button, not a starting + // state. + expect(groupChip(html, "identity")).toBe("0/2"); + expect(groupChip(html, "vitals")).toBe("0/7"); + expect(groupChip(html, "body")).toBe("0/12"); + expect(groupChip(html, "labs")).toBe("0/1"); + expect(groupChip(html, "medications")).toBe("0/4"); + expect(groupChip(html, "history")).toBe("0/2"); expect(groupChip(html, "cardio")).toBe("0/14"); expect(groupChip(html, "activity")).toBe("0/8"); expect(groupChip(html, "sleepRecovery")).toBe("0/16"); @@ -169,6 +196,16 @@ describe(" — the first run", () => { expect(groupChip(html, "environment")).toBe("0/4"); }); + it("disables Generate while the scope is empty, and says what to do", () => { + const html = renderPanel(buildUser(null)); + expect(generateDisabled(html)).toBe(true); + // The reason sits beside the control and points at the way out of it, + // rather than telling the person off after they press. + expect(html).toContain( + "Nothing selected yet. Choose what to include, or apply the standard report.", + ); + }); + it("leaves every fenced leaf unchecked and renders the tier", () => { const html = renderPanel(buildUser(null)); expect(html).toContain('data-testid="report-sensitive-tier-export"'); @@ -192,12 +229,6 @@ describe(" — the first run", () => { 'data-testid="report-group-check-sensitive-export"', ); }); - - it("states the scope in one line", () => { - const html = renderPanel(buildUser(null)); - expect(html).toContain('data-testid="report-scope-summary-export"'); - expect(html).toContain("14 entries from 7 areas"); - }); }); describe(" — a repeat run", () => { @@ -217,6 +248,12 @@ describe(" — a repeat run", () => { expect(html).toContain("incl. Mood"); }); + it("enables Generate once a scope has been chosen", () => { + const html = renderPanel(withSavedSelection(buildUser(null), ["WEIGHT"])); + expect(generateDisabled(html)).toBe(false); + expect(html).not.toContain("Nothing selected yet"); + }); + it("restores the saved format and range", () => { const html = renderPanel(withSavedSelection(buildUser(null), ["WEIGHT"])); // FHIR was saved, so the practice-name line (PDF only) is not rendered. @@ -224,15 +261,17 @@ describe(" — a repeat run", () => { expect(html).toContain('