mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(export): make each scope-picker mount addressable on its own
Settings → Gesundheitsakte mounts the one scope picker twice: the export panel at the top, the share-link create form under `#sharing`. Sharing the component is deliberate, but it also meant every test id inside it resolved to two elements on that page, so nothing in the picker could be addressed without guessing which one you got. The picker now takes a required `surface` and every id under it ends with that surface — `report-group-row-vitals-export` and `report-group-row-vitals-share` are two controls and read as two controls. The summary line beside each picker follows the same rule. A third mount picks a value and stays addressable by construction.
This commit is contained in:
1 parent
2f92c0577b
commit
fcd3dd2d07
9 files changed
+80
-24
No files matched your search
@@ -95,7 +95,7 @@ function withSavedSelection(user: AuthUser, leaves: string[]): AuthUser {
|
||||
*/
|
||||
function leafChecked(html: string, leaf: string): boolean {
|
||||
const row = html.match(
|
||||
new RegExp(`data-testid="report-leaf-${leaf}"[\\s\\S]*?</label>`),
|
||||
new RegExp(`data-testid="report-leaf-${leaf}-export"[\\s\\S]*?</label>`),
|
||||
)?.[0];
|
||||
if (!row) return false;
|
||||
return /data-state="checked"/.test(row);
|
||||
@@ -104,7 +104,9 @@ function leafChecked(html: string, leaf: string): boolean {
|
||||
/** The "n/total" chip a collapsed group row carries. */
|
||||
function groupChip(html: string, group: string): string | null {
|
||||
const row = html.match(
|
||||
new RegExp(`data-testid="report-group-row-${group}"[\\s\\S]*?</div>`),
|
||||
new RegExp(
|
||||
`data-testid="report-group-row-${group}-export"[\\s\\S]*?</div>`,
|
||||
),
|
||||
)?.[0];
|
||||
return row?.match(/data-slot="tag-chip"[^>]*>([^<]*)</)?.[1] ?? null;
|
||||
}
|
||||
@@ -145,7 +147,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
it("opens the picker expanded and names the template it applied", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
expect(html).toContain('data-testid="health-record-included-data-panel"');
|
||||
expect(html).toContain('data-testid="report-scope-picker"');
|
||||
expect(html).toContain('data-testid="report-scope-picker-export"');
|
||||
expect(html).toContain("Standard doctor's report");
|
||||
});
|
||||
|
||||
@@ -169,7 +171,7 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
|
||||
it("leaves every fenced leaf unchecked and renders the tier", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
expect(html).toContain('data-testid="report-sensitive-tier"');
|
||||
expect(html).toContain('data-testid="report-sensitive-tier-export"');
|
||||
for (const leaf of [
|
||||
"MOOD",
|
||||
"CYCLE",
|
||||
@@ -185,13 +187,15 @@ describe("<HealthRecordExportPanel> — the first run", () => {
|
||||
|
||||
it("gives the fenced tier no group checkbox", () => {
|
||||
const html = renderPanel(buildUser(null));
|
||||
expect(html).toContain('data-testid="report-group-check-vitals"');
|
||||
expect(html).not.toContain('data-testid="report-group-check-sensitive"');
|
||||
expect(html).toContain('data-testid="report-group-check-vitals-export"');
|
||||
expect(html).not.toContain(
|
||||
'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"');
|
||||
expect(html).toContain('data-testid="report-scope-summary-export"');
|
||||
expect(html).toContain("14 entries from 7 areas");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,23 +60,23 @@ describe("share-link create form — re-mint seeding", () => {
|
||||
|
||||
it("offers the scope picker, with the insurance leaf absent from it", () => {
|
||||
const html = render(<ShareLinkCreateForm />);
|
||||
expect(html).toContain('data-testid="report-scope-picker"');
|
||||
expect(html).toContain('data-testid="report-group-row-identity"');
|
||||
expect(html).toContain('data-testid="report-scope-picker-share"');
|
||||
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.
|
||||
const row = html.match(
|
||||
/data-testid="report-group-row-identity"[\s\S]*?<\/div>/,
|
||||
/data-testid="report-group-row-identity-share"[\s\S]*?<\/div>/,
|
||||
)?.[0];
|
||||
expect(row).toBeDefined();
|
||||
expect(row).toContain(">1/1<");
|
||||
// The fenced tier renders its leaves unconditionally; none is checked.
|
||||
expect(html).toContain('data-testid="report-leaf-MOOD"');
|
||||
expect(html).not.toContain('data-testid="report-leaf-INSURANCE"');
|
||||
expect(html).toContain('data-testid="report-leaf-MOOD-share"');
|
||||
expect(html).not.toContain('data-testid="report-leaf-INSURANCE-share"');
|
||||
});
|
||||
|
||||
it("hides the scope picker entirely for a documents-only share", () => {
|
||||
const html = render(<ShareLinkCreateForm documentOnly />);
|
||||
expect(html).not.toContain('data-testid="report-scope-picker"');
|
||||
expect(html).not.toContain('data-testid="report-scope-picker-share"');
|
||||
});
|
||||
});
|
||||
@@ -25,9 +25,11 @@ import type {
|
||||
import type { GroupCheckState } from "@/lib/report-selection/panel-state";
|
||||
|
||||
import { LeafGrid } from "./leaf-grid";
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function GroupRow({
|
||||
t,
|
||||
surface,
|
||||
id,
|
||||
labelKey,
|
||||
leaves,
|
||||
@@ -41,6 +43,7 @@ export function GroupRow({
|
||||
onToggleOpen,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
surface: ReportScopeSurface;
|
||||
id: ReportGroupId;
|
||||
labelKey: string;
|
||||
leaves: readonly ReportLeafId[];
|
||||
@@ -55,7 +58,10 @@ export function GroupRow({
|
||||
}) {
|
||||
const label = t(labelKey);
|
||||
return (
|
||||
<ListRow className="space-y-2" data-testid={`report-group-row-${id}`}>
|
||||
<ListRow
|
||||
className="space-y-2"
|
||||
data-testid={`report-group-row-${id}-${surface}`}
|
||||
>
|
||||
<div className="flex min-h-11 items-center gap-3 sm:min-h-9">
|
||||
<Checkbox
|
||||
checked={
|
||||
@@ -67,7 +73,7 @@ export function GroupRow({
|
||||
}
|
||||
onCheckedChange={onToggleGroup}
|
||||
aria-label={label}
|
||||
data-testid={`report-group-check-${id}`}
|
||||
data-testid={`report-group-check-${id}-${surface}`}
|
||||
/>
|
||||
<span className="text-foreground min-w-0 flex-1 text-sm">{label}</span>
|
||||
<TagChip>
|
||||
@@ -94,6 +100,7 @@ export function GroupRow({
|
||||
<div id={panelId}>
|
||||
<LeafGrid
|
||||
t={t}
|
||||
surface={surface}
|
||||
leaves={leaves}
|
||||
selected={selected}
|
||||
onToggle={onToggleLeaf}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* A dense read-out, not a field grid: `gap-2` on the enforced scale, two
|
||||
* columns from the `sm` breakpoint. Each row clears the tap floor and carries
|
||||
* a stable test id so an end-to-end assertion binds to the leaf, not to text
|
||||
* that a breakpoint may hide.
|
||||
* that a breakpoint may hide. The id ends with the mounting surface, because
|
||||
* one page carries two pickers.
|
||||
*/
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MEASUREMENT_TYPE_ICONS } from "@/components/measurements/measurement-list-meta";
|
||||
@@ -15,13 +16,17 @@ import {
|
||||
type ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function LeafGrid({
|
||||
t,
|
||||
surface,
|
||||
leaves,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
surface: ReportScopeSurface;
|
||||
leaves: readonly ReportLeafId[];
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
onToggle: (leaf: ReportLeafId) => void;
|
||||
@@ -33,7 +38,7 @@ export function LeafGrid({
|
||||
return (
|
||||
<label
|
||||
key={leaf}
|
||||
data-testid={`report-leaf-${leaf}`}
|
||||
data-testid={`report-leaf-${leaf}-${surface}`}
|
||||
className="flex min-h-11 items-center gap-3 text-sm sm:min-h-9"
|
||||
>
|
||||
<Checkbox
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
*
|
||||
* The component is controlled: it owns no selection state, only which rows are
|
||||
* expanded and what each group looked like before it was switched off.
|
||||
*
|
||||
* `surface` is required because Settings → Gesundheitsakte mounts the picker
|
||||
* twice on one page. Every test id under the picker ends with it, so each mount
|
||||
* stays uniquely addressable no matter how many surfaces adopt the component.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
|
||||
@@ -34,12 +38,19 @@ import {
|
||||
|
||||
import { GroupRow } from "./group-row";
|
||||
import { LeafGrid } from "./leaf-grid";
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function ReportScopePicker({
|
||||
surface,
|
||||
selected,
|
||||
onChange,
|
||||
hiddenLeaves,
|
||||
}: {
|
||||
/**
|
||||
* The mount this picker belongs to. Names every test id under it, so the two
|
||||
* pickers that share the Gesundheitsakte page never collide.
|
||||
*/
|
||||
surface: ReportScopeSurface;
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
onChange: (next: Set<ReportLeafId>) => void;
|
||||
/**
|
||||
@@ -83,11 +94,12 @@ export function ReportScopePicker({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="report-scope-picker">
|
||||
<div className="space-y-3" data-testid={`report-scope-picker-${surface}`}>
|
||||
{plainGroups.map((group) => (
|
||||
<GroupRow
|
||||
key={group.id}
|
||||
t={t}
|
||||
surface={surface}
|
||||
id={group.id}
|
||||
labelKey={group.labelKey}
|
||||
leaves={group.leaves}
|
||||
@@ -110,7 +122,7 @@ export function ReportScopePicker({
|
||||
))}
|
||||
|
||||
{sensitiveGroup ? (
|
||||
<div data-testid="report-sensitive-tier">
|
||||
<div data-testid={`report-sensitive-tier-${surface}`}>
|
||||
<Separator className="my-4" />
|
||||
<p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
||||
{t(sensitiveGroup.labelKey)}
|
||||
@@ -120,6 +132,7 @@ export function ReportScopePicker({
|
||||
</p>
|
||||
<LeafGrid
|
||||
t={t}
|
||||
surface={surface}
|
||||
leaves={sensitiveGroup.leaves}
|
||||
selected={selected}
|
||||
onToggle={handleLeaf}
|
||||
|
||||
@@ -8,15 +8,21 @@
|
||||
* be checked at a glance and "incl. Mood, PHQ-9" can.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import { scopeSummary } from "@/lib/report-selection/panel-state";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
|
||||
import type { ReportScopeSurface } from "./surface";
|
||||
|
||||
export function ScopeSummary({
|
||||
t,
|
||||
surface,
|
||||
selected,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
surface: ReportScopeSurface;
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
}) {
|
||||
const summary = scopeSummary(selected);
|
||||
@@ -24,7 +30,7 @@ export function ScopeSummary({
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="report-scope-summary"
|
||||
data-testid={`report-scope-summary-${surface}`}
|
||||
>
|
||||
{t("reportSelection.scopeNone")}
|
||||
</p>
|
||||
@@ -43,7 +49,7 @@ export function ScopeSummary({
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="report-scope-summary"
|
||||
data-testid={`report-scope-summary-${surface}`}
|
||||
>
|
||||
{sensitive ? `${base} · ${sensitive}` : base}
|
||||
</p>
|
||||
|
||||
@@ -342,7 +342,11 @@ export function HealthRecordExportPanel() {
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<ReportScopePicker selected={selected} onChange={setSelected} />
|
||||
<ReportScopePicker
|
||||
surface="export"
|
||||
selected={selected}
|
||||
onChange={setSelected}
|
||||
/>
|
||||
{isPdfLike ? (
|
||||
<label className="flex min-h-11 items-center justify-between gap-3 text-sm sm:min-h-9">
|
||||
<span className="text-foreground">
|
||||
@@ -361,7 +365,7 @@ 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} selected={selected} />
|
||||
<ScopeSummary t={t} surface="export" selected={selected} />
|
||||
{isFhirLike ? (
|
||||
<p className="text-muted-foreground max-w-md text-xs">
|
||||
{t("settings.healthRecord.fhirNote")}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Which surface a scope picker is mounted on.
|
||||
*
|
||||
* Settings → Gesundheitsakte carries BOTH mounts on one page: the export panel
|
||||
* at the top and the share-link create form under `#sharing`. They are the same
|
||||
* component by design — two selection UIs must not drift apart — but that made
|
||||
* every test id inside the picker resolve twice on that page, so nothing in it
|
||||
* was addressable.
|
||||
*
|
||||
* The surface is therefore required, not optional, and every test id under the
|
||||
* picker ends with it: `report-group-row-vitals-export` and
|
||||
* `report-group-row-vitals-share` are two different controls and read as two
|
||||
* different controls. A third mount adds a value here and stays addressable by
|
||||
* construction.
|
||||
*/
|
||||
export type ReportScopeSurface = "export" | "share";
|
||||
@@ -377,11 +377,12 @@ export function ShareLinkCreateForm({
|
||||
</p>
|
||||
</div>
|
||||
<ReportScopePicker
|
||||
surface="share"
|
||||
selected={selectedLeaves}
|
||||
onChange={setSelectedLeaves}
|
||||
hiddenLeaves={SHARE_HIDDEN_LEAVES}
|
||||
/>
|
||||
<ScopeSummary t={t} selected={selectedLeaves} />
|
||||
<ScopeSummary t={t} surface="share" selected={selectedLeaves} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in new issue
Block a user