From 991764b0c8c1cede79514c850e0da19c6f0be4be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 20:20:03 +0200 Subject: [PATCH] Give settings cards one action row at the foot The TOTP enable, the source-priority reset, the biomarker and record add actions, and the about-me, profile-facts, emergency and condition saves move into SettingsCardActions, right-aligned and last in the card. The emergency Save waits for a change like its neighbours, an empty list leaves its add action to the empty state, and the source ladder keeps its up/down buttons side by side on a phone. --- .../biomarker-manager-actions.test.tsx | 82 +++++++++++ src/components/labs/biomarker-manager.tsx | 23 ++-- src/components/labs/labs-settings.tsx | 1 + .../__tests__/emergency-profile-save.test.tsx | 45 ++++++ .../records/about-me-note-manager.tsx | 78 +++++------ .../records/ai-profile-inclusion-manager.tsx | 5 +- .../records/allergy-free-text-note.tsx | 5 +- src/components/records/allergy-manager.tsx | 30 ++-- src/components/records/conditions-manager.tsx | 5 +- .../records/emergency-profile-manager.tsx | 16 ++- .../records/family-history-manager.tsx | 25 ++-- .../records/health-profile-facts-manager.tsx | 130 ++++++++++-------- .../settings/security-section/totp-card.tsx | 40 +++--- src/components/settings/sources-section.tsx | 98 ++++++------- 14 files changed, 374 insertions(+), 209 deletions(-) create mode 100644 src/components/labs/__tests__/biomarker-manager-actions.test.tsx create mode 100644 src/components/records/__tests__/emergency-profile-save.test.tsx diff --git a/src/components/labs/__tests__/biomarker-manager-actions.test.tsx b/src/components/labs/__tests__/biomarker-manager-actions.test.tsx new file mode 100644 index 000000000..ae1836687 --- /dev/null +++ b/src/components/labs/__tests__/biomarker-manager-actions.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// The biomarker catalog card offers its add action once: the empty state's +// own button while the catalog is empty, otherwise one action row at the +// foot of the card (design standards §12). It used to carry a "Define" +// button above the list AND the empty state's "Define your first biomarker". + +const mocks = vi.hoisted(() => ({ + biomarkers: [] as Array>, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { biomarkers: mocks.biomarkers }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), + apiPut: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: () => null, +})); +vi.mock("@/components/data-list", () => ({ + DeleteButton: (props: { title: string }) => ( + + ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { BiomarkerManager } from "../biomarker-manager"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + mocks.biomarkers = []; +}); + +describe(" action grammar", () => { + it("leaves the add action to the empty state while the catalog is empty", () => { + const html = render(); + expect(html.match(/Define your first biomarker/g)).toHaveLength(1); + expect(html).not.toContain('data-slot="settings-card-actions"'); + }); + + it("closes a populated catalog with one add action below the list", () => { + mocks.biomarkers = [ + { + id: "b1", + name: "LDL", + unit: "mg/dL", + lowerBound: null, + upperBound: 115, + panel: null, + hasContext: false, + context: null, + hidden: false, + createdAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + }, + ]; + const html = render(); + expect(html).not.toContain("Define your first biomarker"); + const row = html.indexOf('data-slot="settings-card-actions"'); + expect(row).toBeGreaterThan(html.indexOf("LDL")); + expect(html.slice(row).match(/>Define<\/button>/g)).toHaveLength(1); + }); +}); diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx index 56c883b77..18cc9f5a5 100644 --- a/src/components/labs/biomarker-manager.tsx +++ b/src/components/labs/biomarker-manager.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -166,16 +167,6 @@ export function BiomarkerManager() { return (
-
-

- {t("labs.biomarker.managerDescription")} -

- -
- {isLoading ? (
{Array.from({ length: 3 }, (_, i) => ( @@ -217,6 +208,18 @@ export function BiomarkerManager() {
)} + {/* The add action is the card's one action row, last in the card + (design standards §12). An empty catalog leaves it to the empty + state's own button, so the card never offers the same action twice. */} + {!isLoading && !isError && markers.length > 0 ? ( + + + + ) : null} + diff --git a/src/components/records/__tests__/emergency-profile-save.test.tsx b/src/components/records/__tests__/emergency-profile-save.test.tsx new file mode 100644 index 000000000..6640644ce --- /dev/null +++ b/src/components/records/__tests__/emergency-profile-save.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +// The emergency card's Save stayed enabled on an untouched, stored profile +// while every other Save on the medical-history page waits for a change. +// It now follows them: nothing edited, nothing to save. + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + bloodType: "A_POS", + organDonor: null, + advanceDirective: null, + contacts: "A contact", + implants: null, + note: null, + updatedAt: "2026-07-28T10:00:00.000Z", + }, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPatch: vi.fn() })); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { I18nProvider } from "@/lib/i18n/context"; +import { EmergencyProfileManager } from "../emergency-profile-manager"; + +describe(" Save", () => { + it("stays disabled until a field differs from the stored profile", () => { + const html = renderToStaticMarkup( + + + , + ); + const row = html.slice(html.indexOf('data-slot="settings-card-actions"')); + expect(row).toMatch(/]*disabled=""[^>]*>[^]*?Save<\/button>/); + }); +}); diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx index 66def03fc..2fa1059bf 100644 --- a/src/components/records/about-me-note-manager.tsx +++ b/src/components/records/about-me-note-manager.tsx @@ -32,6 +32,7 @@ import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { Label } from "@/components/ui/label"; @@ -137,9 +138,6 @@ export function AboutMeNoteManager() { disabled={disabled} onChange={(e) => setDraft(e.target.value)} /> -
- -

-

- {hasNote && ( - } - variant="outline" - size="sm" - className="min-h-11 sm:min-h-9" - title={t("settings.ai.aboutMe.clearTitle")} - body={t("settings.ai.aboutMe.clearBody")} - confirmLabel={t("settings.ai.aboutMe.clearConfirm")} - pending={save.isPending} - onConfirm={() => save.mutate({ aboutMe: "" })} - /> - )} - -
+ {/* What the note is for, under the field it describes; the action row + stays the last thing in the card (design standards §12). */} +

+ {t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")} +

{pendingQuestions.length > 0 && ( @@ -218,9 +188,39 @@ export function AboutMeNoteManager() { /> )} -

- {t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")} -

+ + {hasNote && ( + } + variant="outline" + size="sm" + className="min-h-11 sm:min-h-9" + title={t("settings.ai.aboutMe.clearTitle")} + body={t("settings.ai.aboutMe.clearBody")} + confirmLabel={t("settings.ai.aboutMe.clearConfirm")} + pending={save.isPending} + onConfirm={() => save.mutate({ aboutMe: "" })} + /> + )} + + ); } diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx index 1b8fadee4..8f0c3e88b 100644 --- a/src/components/records/ai-profile-inclusion-manager.tsx +++ b/src/components/records/ai-profile-inclusion-manager.tsx @@ -6,6 +6,7 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Switch } from "@/components/ui/switch"; @@ -136,7 +137,7 @@ export function AiProfileInclusionManager() { ); })} -
+
+ ); } diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx index 11fd89acd..1e0d63416 100644 --- a/src/components/records/allergy-free-text-note.tsx +++ b/src/components/records/allergy-free-text-note.tsx @@ -27,6 +27,7 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Label } from "@/components/ui/label"; import { useTranslations } from "@/lib/i18n/context"; @@ -125,7 +126,7 @@ export function AllergyFreeTextNote() { onRetry={() => query.refetch()} /> ) : ( -
+
+ )} ); diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index deef991e8..31de01954 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -120,18 +121,6 @@ export function AllergyManager() { return (
-
-

- {t("records.allergies.description")} -

- {rows.length > 0 ? ( - - ) : null} -
- {isLoading ? (
{Array.from({ length: 2 }, (_, i) => ( @@ -156,6 +145,23 @@ export function AllergyManager() {
    {rows.map(renderRow)}
)} + {/* The add action closes the list (design standards §12); an empty + list leaves it to the empty state's own button. Outline here, + because the card's primary is the free-text note's Save below. */} + {!isLoading && !isError && rows.length > 0 ? ( + + + + ) : null} + +
+ )}
diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx index ef27daa44..7bf147763 100644 --- a/src/components/records/emergency-profile-manager.tsx +++ b/src/components/records/emergency-profile-manager.tsx @@ -6,6 +6,7 @@ import { Loader2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { Label } from "@/components/ui/label"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Textarea } from "@/components/ui/textarea"; @@ -96,6 +97,13 @@ export function EmergencyProfileManager() { } const disabled = query.isLoading || save.isPending || current === null; + // Save stays off until a field differs from the stored profile, like every + // other Save on this page; a fresh draft that matches the record is not a + // change. + const dirty = + draft !== null && + query.data != null && + JSON.stringify(draft) !== JSON.stringify(draftFromDto(query.data)); const set = (patch: Partial) => setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch })); @@ -113,7 +121,7 @@ export function EmergencyProfileManager() { disabled={disabled} onValueChange={(next) => set({ [key]: next } as Partial)} > - + @@ -170,12 +178,12 @@ export function EmergencyProfileManager() { {t("records.emergency.reportHint")}

-
+ -
+ ); } diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index 1b865bc8a..2f84fff10 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { DeleteButton } from "@/components/data-list"; import { Button } from "@/components/ui/button"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; @@ -105,18 +106,6 @@ export function FamilyHistoryManager() { return (
-
-

- {t("records.family.description")} -

- {rows.length > 0 ? ( - - ) : null} -
- {isLoading ? (
{Array.from({ length: 2 }, (_, i) => ( @@ -141,6 +130,18 @@ export function FamilyHistoryManager() {
    {rows.map(renderRow)}
)} + {/* The add action closes the list as the card's action row (design + standards §12); an empty list leaves it to the empty state's own + button. */} + {!isLoading && !isError && rows.length > 0 ? ( + + + + ) : null} + editDraft(kind, next)} > - + - {failed ? ( - - {t("records.profileFacts.saveKindError")} - - ) : ( - - )} - {current ? ( - remove.mutate(current)} - pending={ - remove.isPending && remove.variables?.id === current.id - } - disabled={disabled} - variant="ghost" - size="sm" - className="text-destructive min-h-11 sm:min-h-9" - icon={} - slot={`health-profile-fact-remove-${kind}`} - /> - ) : null} -
+ {failed || current ? ( +
+ {failed ? ( + + {t("records.profileFacts.saveKindError")} + + ) : ( + + )} + {current ? ( + remove.mutate(current)} + pending={ + remove.isPending && remove.variables?.id === current.id + } + disabled={disabled} + variant="ghost" + size="sm" + className="text-destructive min-h-11 sm:min-h-9" + icon={} + slot={`health-profile-fact-remove-${kind}`} + /> + ) : null} +
+ ) : null}
); })} - {/* One Save for the whole tile. It writes only the dirty kinds and stays - disabled until at least one field differs from its stored value. */} -
- -
- {closedHistory.length > 0 && (

@@ -412,6 +398,28 @@ export function HealthProfileFactsManager() {

)} + + {/* One Save for the whole tile. It writes only the dirty kinds and stays + disabled until at least one field differs from its stored value. It + is the last thing in the card, below the history (design standards + §12). */} + + + ); } diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx index 7495a9c93..e8346359a 100644 --- a/src/components/settings/security-section/totp-card.tsx +++ b/src/components/settings/security-section/totp-card.tsx @@ -265,21 +265,25 @@ export function TotpCard({
{/* ── Not enabled, no wizard: the enable CTA ── */} + {/* The enable CTA is the card's primary, right-aligned in the one + action row like every other Settings card (design standards §12). */} {!enabled && !setup && !freshCodes && ( - + + + )} -
+ @@ -544,7 +548,7 @@ export function TotpCard({ -
+
)} diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 254db3663..8e88cd081 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -297,21 +297,6 @@ export function SourcesSection() { icon={Layers} title={t("settings.sections.sources.cardTitle")} description={t("settings.sections.sources.help")} - status={ - } - label={t("settings.sections.sources.resetDefaults")} - title={t("settings.sections.sources.resetDefaultsTitle")} - body={t("settings.sections.sources.resetDefaultsBody")} - confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")} - disabled={saveMutation.isPending} - pending={resetMutation.isPending} - onConfirm={() => resetMutation.mutate()} - /> - } />

{t("settings.sections.sources.helpDetail")}

@@ -346,12 +331,12 @@ export function SourcesSection() { {t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])} - {/* v1.4.27 R3d MB2 — stack up/down vertically on - narrow viewports so each button keeps the - 44 px floor without crowding the row. From - `sm:` up the two buttons sit side-by-side - again to preserve the desktop layout. */} -
+ {/* Up/down sit side by side at every width: two + 44 px buttons leave a 390 px row ~170 px for the + source name. Stacking them on phones (v1.4.27 + MB2) doubled every row and pushed this page past + twelve thousand pixels. */} +
- - - )} + {/* One action row, last in the card (design standards §12). The + reset used to ride the header's status slot, which carries + status, not actions; Cancel + Save join it while a draft is open. */} + + } + label={t("settings.sections.sources.resetDefaults")} + title={t("settings.sections.sources.resetDefaultsTitle")} + body={t("settings.sections.sources.resetDefaultsBody")} + confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")} + disabled={saveMutation.isPending} + pending={resetMutation.isPending} + onConfirm={() => resetMutation.mutate()} + /> + {dirty && priority ? ( + <> + + + + ) : null} + {/* v1.28 shipped `` here; the 2026-07-17 UX/IA