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('