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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:46:03 +02:00
1 parent 59acbed5ab
commit bc1633db40
17 files changed
+353 -116

No files matched your search

+60 -6
View File
@@ -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);
+8 -3
View File
@@ -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<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
@@ -67,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
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();
+4 -4
View File
@@ -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}"
}
+4 -4
View File
@@ -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}"
}
+4 -4
View File
@@ -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}"
}
+4 -4
View File
@@ -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}"
}
+4 -4
View File
@@ -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}"
}
+4 -4
View File
@@ -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}"
}
@@ -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(
/<button[^>]*data-testid="health-record-generate"[^>]*>/,
)?.[0];
if (!button) throw new Error("the Generate button did not render");
return /\sdisabled=""/.test(button);
}
describe("<HealthRecordExportPanel> — 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("<HealthRecordExportPanel> — remembered practice name", () => {
});
describe("<HealthRecordExportPanel> — 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&#x27;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&#x27;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("<HealthRecordExportPanel> — 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("<HealthRecordExportPanel> — 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("<HealthRecordExportPanel> — a repeat run", () => {
@@ -217,6 +248,12 @@ describe("<HealthRecordExportPanel> — 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("<HealthRecordExportPanel> — a repeat run", () => {
expect(html).toContain('<option value="180" selected=""');
});
it("falls back to the template when the saved blob cannot be read", () => {
it("falls back to an empty scope when the saved blob cannot be read", () => {
const user = {
...buildUser(null),
reportSelection: { bp: true, weight: true },
} as AuthUser;
const html = renderPanel(user);
// A shape the server cannot read is not consent to anything, so the panel
// shows the named template rather than a partial scope.
expect(html).toContain("Standard doctor&#x27;s report");
expect(groupChip(html, "body")).toBe("2/12");
// treats it as a first run: nothing selected, Generate refused, the
// standard-report button one click away.
expect(groupChip(html, "body")).toBe("0/12");
expect(generateDisabled(html)).toBe(true);
expect(html).toContain('data-testid="report-apply-standard-export"');
});
});
@@ -12,6 +12,12 @@
* SSR-static render only (no jsdom), matching the sibling settings tests. The
* TanStack hooks are mocked; the QR (`qrcode`) only ever renders in an effect
* after a create, so it never runs under `renderToStaticMarkup`.
*
* Mutation checks (each verified red, then reverted):
* - seed `selectedLeaves` from `STANDARD_TEMPLATE_LEAVES` again → "starts a
* record link with an empty scope" goes red.
* - drop `selectedLeaves.size === 0` from the submit `disabled` expression →
* "refuses to mint a record link with an empty scope" goes red.
*/
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import { describe, expect, it, vi } from "vitest";
@@ -47,6 +53,15 @@ function render(
);
}
/** Whether the create control refuses the press. */
function createDisabled(html: string): boolean {
const button = html.match(
/<button[^>]*data-testid="share-create-submit"[^>]*>/,
)?.[0];
if (!button) throw new Error("the create button did not render");
return /\sdisabled=""/.test(button);
}
describe("<ShareLinkCreateForm> — shared create flow", () => {
it("renders the create form with the 90-day-capped expiry and attach affordance", () => {
const html = render();
@@ -87,6 +102,28 @@ describe("<ShareLinkCreateForm> — shared create flow", () => {
expect(html).toContain("Camera metadata");
});
it("starts a record link with an empty scope and the standard-report button", () => {
const html = render();
// A link is handed to another person; nothing rides on it that was not
// ticked. The template is reachable in one click and applied in none.
expect(html).toContain("Nothing selected yet");
expect(html).toContain('data-testid="report-apply-standard-share"');
expect(html).toContain("Apply Standard doctor&#x27;s report");
});
it("refuses to mint a record link with an empty scope, label or not", () => {
const html = render({ initialLabel: "Cardiology" });
expect(createDisabled(html)).toBe(true);
// The same form in documents-only mode mints happily on the same label:
// the refusal comes from the empty report scope, not from the label.
const docOnly = render({
documentOnly: true,
initialLabel: "Cardiology",
initialDocuments: [{ id: "doc-1", title: "Blood panel 2026" }],
});
expect(createDisabled(docOnly)).toBe(false);
});
it("document-only mode hides every record-scope control but keeps expiry + the doc", () => {
const html = render({
documentOnly: true,
@@ -64,12 +64,13 @@ describe("share-link create form — re-mint seeding", () => {
expect(html).toContain('data-testid="report-group-row-identity-share"');
// The server refuses the leaf with a 422; a control that cannot be
// honoured has no business rendering, so the identity group is one leaf
// wide here and two wide in the export panel.
// wide here and two wide in the export panel. Nothing in it is on: a new
// link starts with an empty scope on every surface.
const row = html.match(
/data-testid="report-group-row-identity-share"[\s\S]*?<\/div>/,
)?.[0];
expect(row).toBeDefined();
expect(row).toContain(">1/1<");
expect(row).toContain(">0/1<");
// The fenced tier renders its leaves unconditionally; none is checked.
expect(html).toContain('data-testid="report-leaf-MOOD-share"');
expect(html).not.toContain('data-testid="report-leaf-INSURANCE-share"');
@@ -5,6 +5,13 @@
* export panel and the share-link create form — mount this exact component, so
* two selection UIs cannot drift apart.
*
* Nothing is ever selected for the user. A picker with an empty selection
* stays empty until someone ticks something, and the one shipped template is
* reachable only through the button at the top that carries its name. One
* click still gets a complete doctor's report, so nothing got slower — but the
* click happened, and that is the whole difference between a chosen scope and
* a scope somebody merely failed to untick.
*
* Twelve group rows plus a fenced tier below a separator. The fenced tier has
* NO group checkbox: there is no control anywhere in this component that can
* turn on more than one sensitive leaf at a time, and there is no select-all.
@@ -21,6 +28,7 @@
*/
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "@/lib/i18n/context";
import {
@@ -35,6 +43,10 @@ import {
toggleGroup,
toggleLeaf,
} from "@/lib/report-selection/panel-state";
import {
STANDARD_TEMPLATE_LABEL_KEY,
standardTemplateFor,
} from "@/lib/report-selection/template";
import { GroupRow } from "./group-row";
import { LeafGrid } from "./leaf-grid";
@@ -93,8 +105,37 @@ export function ReportScopePicker({
onChange(toggleGroup(selected, group, leaves, remembered));
};
const hintId = `${idPrefix}-standard`;
return (
<div className="space-y-3" data-testid={`report-scope-picker-${surface}`}>
{/* The one-click way to a complete report. An inline action row
(UI-STANDARDS §11): the sentence wraps, the action stays beside it.
It is offered, never applied — and it stays offered so a person can
come back to the standard set after experimenting. */}
<div className="flex items-start justify-between gap-3">
<p
id={hintId}
className="text-muted-foreground min-w-0 flex-1 text-xs"
data-testid={`report-standard-hint-${surface}`}
>
{t("reportSelection.standardHint")}
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 shrink-0 sm:min-h-9"
aria-describedby={hintId}
data-testid={`report-apply-standard-${surface}`}
onClick={() => onChange(new Set(standardTemplateFor(hiddenLeaves)))}
>
{t("reportSelection.applyStandard", {
template: t(STANDARD_TEMPLATE_LABEL_KEY),
})}
</Button>
</div>
{plainGroups.map((group) => (
<GroupRow
key={group.id}
@@ -7,6 +7,11 @@
* restated by name rather than folded into the count, because a number cannot
* be checked at a glance and "incl. Mood, PHQ-9" can.
*
* On an empty scope it is also the reason the generate / create control is
* disabled, which is why it takes an `id`: the control points its
* `aria-describedby` here, so "why can I not press this" has an answer in the
* accessibility tree as well as on screen.
*
* Meta tier — one sentence about the form beside it, not content.
*
* One per picker, so the test id ends with the same surface its picker does.
@@ -18,10 +23,13 @@ import type { ReportScopeSurface } from "./surface";
export function ScopeSummary({
t,
id,
surface,
selected,
}: {
t: (key: string, vars?: Record<string, string | number>) => string;
/** Referenced by the control this line explains, via `aria-describedby`. */
id?: string;
surface: ReportScopeSurface;
selected: ReadonlySet<ReportLeafId>;
}) {
@@ -29,6 +37,7 @@ export function ScopeSummary({
if (summary.total === 0) {
return (
<p
id={id}
className="text-muted-foreground text-xs"
data-testid={`report-scope-summary-${surface}`}
>
@@ -48,6 +57,7 @@ export function ScopeSummary({
: null;
return (
<p
id={id}
className="text-muted-foreground text-xs"
data-testid={`report-scope-summary-${surface}`}
>
@@ -9,10 +9,11 @@
*
* Three states, by design:
*
* 1. First run (no saved selection): the picker opens EXPANDED with the
* named standard template applied and the fenced tier visibly empty, so
* the user presses Generate having seen twelve named groups. That is
* consent to a named bundle, not a server-chosen default.
* 1. First run (no saved selection): NOTHING is selected. The picker opens
* expanded so the standard-report button and the empty fenced tier are
* both in view, and Generate is disabled until something is ticked. One
* click on that button still produces a complete doctor's report, so the
* fast path costs one interaction, not a scope nobody chose.
* 2. Repeat run: the picker is collapsed behind its disclosure, the saved
* selection is loaded, and the scope line under the button says what will
* be in the document. Two interactions after navigation, and the reading
@@ -55,10 +56,6 @@ import {
parseSavedProfile,
SAVED_PROFILE_FALLBACK,
} from "@/lib/report-selection/profile-shape";
import {
STANDARD_TEMPLATE_LABEL_KEY,
STANDARD_TEMPLATE_LEAVES,
} from "@/lib/report-selection/template";
import { orderLeaves } from "@/lib/report-selection/selection";
import { ReportScopePicker } from "./report-scope-picker";
@@ -85,16 +82,19 @@ export function HealthRecordExportPanel() {
const [includeCharts, setIncludeCharts] = useState<boolean>(
SAVED_PROFILE_FALLBACK.includeCharts,
);
// Empty until a human ticks something. There is no default selection to get
// wrong, because there is no default selection.
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
() => new Set(STANDARD_TEMPLATE_LEAVES),
() => new Set<ReportLeafId>(),
);
const [seededUserId, setSeededUserId] = useState<string | null>(null);
// First run opens the picker so the template is seen, not merely applied.
// A first run opens the picker: with nothing selected, the standard-report
// button and the empty groups are the two things the person needs to see.
const [pickerOpen, setPickerOpen] = useState(true);
const [templateApplied, setTemplateApplied] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const pickerPanelId = useId();
const scopeSummaryId = useId();
if (user && user.id !== seededUserId) {
setSeededUserId(user.id);
@@ -106,7 +106,6 @@ export function HealthRecordExportPanel() {
setDays(saved.rangeDays);
setIncludeCharts(saved.includeCharts);
setPickerOpen(false);
setTemplateApplied(false);
}
}
@@ -120,10 +119,6 @@ export function HealthRecordExportPanel() {
});
async function handleGenerate() {
if (selected.size === 0) {
setError(t("settings.healthRecord.errorEmptySelection"));
return;
}
setBusy(true);
setError(null);
try {
@@ -171,7 +166,6 @@ export function HealthRecordExportPanel() {
a.click();
a.remove();
URL.revokeObjectURL(url);
setTemplateApplied(false);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
@@ -332,16 +326,6 @@ export function HealthRecordExportPanel() {
className="animate-insight-in space-y-3"
style={{ animationDuration: "200ms" }}
>
{templateApplied ? (
<p
className="text-muted-foreground text-xs"
data-testid="report-template-note"
>
{t("reportSelection.templateApplied", {
template: t(STANDARD_TEMPLATE_LABEL_KEY),
})}
</p>
) : null}
<ReportScopePicker
surface="export"
selected={selected}
@@ -365,17 +349,28 @@ export function HealthRecordExportPanel() {
{/* The FHIR note and the generate action share the footer row. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<ScopeSummary t={t} surface="export" selected={selected} />
<ScopeSummary
t={t}
id={scopeSummaryId}
surface="export"
selected={selected}
/>
{isFhirLike ? (
<p className="text-muted-foreground max-w-md text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
) : null}
</div>
{/* An empty scope cannot be generated: there is no document to make.
The scope line beside the button carries the reason and the way
out of it, and describes the button so the disabled state is not
silent for a screen reader. */}
<Button
type="button"
onClick={handleGenerate}
disabled={busy}
disabled={busy || selected.size === 0}
aria-describedby={scopeSummaryId}
data-testid="health-record-generate"
className="ml-auto min-h-11 shrink-0 sm:min-h-9"
>
{busy ? (
@@ -21,7 +21,7 @@
* document-launched flow seeds the document the user was looking at) and
* `initialLabel` to pre-fill the label.
*/
import { useEffect, useState } from "react";
import { useEffect, useId, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react";
@@ -36,7 +36,6 @@ import { ReportScopePicker } from "@/components/settings/report-selection/report
import { ScopeSummary } from "@/components/settings/report-selection/scope-summary";
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
import { orderLeaves } from "@/lib/report-selection/selection";
import { STANDARD_TEMPLATE_LEAVES } from "@/lib/report-selection/template";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
@@ -205,23 +204,18 @@ export function ShareLinkCreateForm({
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>(
initialDocuments ?? [],
);
// The named standard template is the starting point for a record share, the
// same one the export panel opens with, minus the leaf a link may not carry.
// A new link starts with an empty scope. The picker's standard-report button
// fills it in one click, minus the leaf a link may not carry — but a link is
// handed to another person, so nothing rides on it that was not ticked.
const [selectedLeaves, setSelectedLeaves] = useState<
ReadonlySet<ReportLeafId>
>(
() =>
new Set(
STANDARD_TEMPLATE_LEAVES.filter(
(leaf) => !SHARE_HIDDEN_LEAVES.includes(leaf),
),
),
);
>(() => new Set<ReportLeafId>());
const [pickerOpen, setPickerOpen] = useState(false);
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const scopeSummaryId = useId();
// Render the QR from the `#k=` deep link once a link is created. The fragment
// carries the passphrase, so the QR alone opens the record — it is shown
@@ -382,7 +376,12 @@ export function ShareLinkCreateForm({
onChange={setSelectedLeaves}
hiddenLeaves={SHARE_HIDDEN_LEAVES}
/>
<ScopeSummary t={t} surface="share" selected={selectedLeaves} />
<ScopeSummary
t={t}
id={scopeSummaryId}
surface="share"
selected={selectedLeaves}
/>
</div>
) : null}
@@ -458,6 +457,10 @@ export function ShareLinkCreateForm({
</p>
)}
{/* A record link with an empty scope cannot be minted: it would serve
a page with nothing on it. The scope line above says so and says
what to do about it, and describes the button so the disabled state
is not silent for a screen reader. */}
<Button
type="submit"
disabled={
@@ -465,6 +468,8 @@ export function ShareLinkCreateForm({
!label.trim() ||
(!documentOnly && selectedLeaves.size === 0)
}
aria-describedby={documentOnly ? undefined : scopeSummaryId}
data-testid="share-create-submit"
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
@@ -29,7 +29,7 @@ import {
STRUCTURED_LEAF_IDS,
leafLabelKey,
} from "../catalogue";
import { STANDARD_TEMPLATE_LEAVES } from "../template";
import { STANDARD_TEMPLATE_LEAVES, standardTemplateFor } from "../template";
const EN = JSON.parse(
readFileSync(
@@ -112,3 +112,37 @@ describe("template purity", () => {
);
});
});
/**
* The set behind the one-click action, checked as a set.
*
* The button is the only thing that applies the template now, so what it
* applies has to be exactly the shipped list and nothing beside it — a leaf
* that crept in here would land in a document the person believes they chose
* from a named list.
*
* Mutation check (verified red, then reverted): make `standardTemplateFor`
* ignore its argument → "drops only what the surface hides" goes red.
*/
describe("the standard-report action", () => {
it("applies exactly the shipped set and nothing outside it", () => {
expect(standardTemplateFor()).toEqual([...STANDARD_TEMPLATE_LEAVES]);
const outside = standardTemplateFor().filter(
(leaf) => !STANDARD_TEMPLATE_LEAVES.includes(leaf),
);
expect(outside).toEqual([]);
// Nothing fenced, on any surface: the fence is not a matter of which
// button you press.
expect(
standardTemplateFor().filter((leaf) => SENSITIVE_LEAF_IDS.includes(leaf)),
).toEqual([]);
});
it("drops only what the surface hides", () => {
const onShare = standardTemplateFor(["INSURANCE"]);
expect(onShare).not.toContain("INSURANCE");
expect(onShare).toEqual(
STANDARD_TEMPLATE_LEAVES.filter((leaf) => leaf !== "INSURANCE"),
);
});
});
+23 -7
View File
@@ -1,14 +1,15 @@
/**
* The one shipped template: what a general doctor's visit usually needs.
*
* It is applied on the FIRST run only, with the panel open, so the user
* presses Generate having seen twelve named groups and an empty fenced tier.
* That is consent to a named bundle, not a server-chosen default — nothing
* here is applied to a stored selection, to a share link, or to any surface
* that cannot ask a human.
* Nothing applies it on its own. It reaches a selection only when a person
* presses the button that carries its name, on a panel they are looking at —
* never on load, never on a stored selection, never on a share link, never on
* a surface that cannot ask a human. A set that arrives without being asked
* for is a preselection whatever it is called, and the one thing this release
* refuses is material in a report nobody ticked.
*
* The template intersects the fenced tier at zero leaves, and
* `report-template-purity.test.ts` fails the build if that stops being true.
* `catalogue-guard.test.ts` fails the build if that stops being true.
*/
import { SENSITIVE_LEAF_IDS, type ReportLeafId } from "./catalogue";
@@ -39,8 +40,23 @@ export const STANDARD_TEMPLATE_LEAVES: readonly ReportLeafId[] = [
"ILLNESS_EPISODES",
];
/** The template's i18n label — it is named in the panel, never anonymous. */
/** The template's i18n label — it is named on the button, never anonymous. */
export const STANDARD_TEMPLATE_LABEL_KEY = "reportSelection.templateStandard";
/**
* The template as one surface can offer it: the shipped set minus the leaves
* that surface may not carry (the share-link form hides the insurance leaf,
* which the server refuses anyway).
*
* Pure, so "one click applies exactly the standard set and nothing outside it"
* is provable without a DOM.
*/
export function standardTemplateFor(
hiddenLeaves: readonly ReportLeafId[] = [],
): ReportLeafId[] {
const hidden = new Set(hiddenLeaves);
return STANDARD_TEMPLATE_LEAVES.filter((leaf) => !hidden.has(leaf));
}
/** Exported so the purity guard reads the same set the panel does. */
export const SENSITIVE_LEAVES = SENSITIVE_LEAF_IDS;