mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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.
This commit is contained in:
1 parent
97e4dd4c05
commit
991764b0c8
14 files changed
+374
-209
No files matched your search
@@ -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<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
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 }) => (
|
||||
<button aria-label={props.title}>Delete</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BiomarkerManager } from "../biomarker-manager";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BiomarkerManager />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.biomarkers = [];
|
||||
});
|
||||
|
||||
describe("<BiomarkerManager> 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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("labs.biomarker.managerDescription")}
|
||||
</p>
|
||||
<Button size="sm" onClick={openNew} className="shrink-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.biomarker.define")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
@@ -217,6 +208,18 @@ export function BiomarkerManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<SettingsCardActions>
|
||||
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("labs.biomarker.define")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : null}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
|
||||
@@ -154,6 +154,7 @@ export function LabsSettings() {
|
||||
<SettingsCardHeader
|
||||
icon={FlaskConical}
|
||||
title={t("labs.biomarker.manageTitle")}
|
||||
description={t("labs.biomarker.managerDescription")}
|
||||
/>
|
||||
<BiomarkerManager />
|
||||
</SettingsCard>
|
||||
|
||||
@@ -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("<EmergencyProfileManager> Save", () => {
|
||||
it("stays disabled until a field differs from the stored profile", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<EmergencyProfileManager />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const row = html.slice(html.indexOf('data-slot="settings-card-actions"'));
|
||||
expect(row).toMatch(/<button[^>]*disabled=""[^>]*>[^]*?Save<\/button>/);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p
|
||||
data-testid="settings-about-me-count"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
@@ -149,39 +147,11 @@ export function AboutMeNoteManager() {
|
||||
max: maxChars,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasNote && (
|
||||
<ConfirmButton
|
||||
slot="settings-about-me-clear"
|
||||
label={t("settings.ai.aboutMe.clear")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
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: "" })}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="settings-about-me-save"
|
||||
disabled={save.isPending || !dirty}
|
||||
onClick={() => save.mutate({ aboutMe: value })}
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("settings.ai.aboutMe.save")}
|
||||
</Button>
|
||||
</div>
|
||||
{/* What the note is for, under the field it describes; the action row
|
||||
stays the last thing in the card (design standards §12). */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{pendingQuestions.length > 0 && (
|
||||
@@ -218,9 +188,39 @@ export function AboutMeNoteManager() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
|
||||
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
|
||||
</p>
|
||||
<SettingsCardActions>
|
||||
{hasNote && (
|
||||
<ConfirmButton
|
||||
slot="settings-about-me-clear"
|
||||
label={t("settings.ai.aboutMe.clear")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
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: "" })}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="settings-about-me-save"
|
||||
disabled={save.isPending || !dirty}
|
||||
onClick={() => save.mutate({ aboutMe: value })}
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("settings.ai.aboutMe.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -152,7 +153,7 @@ export function AiProfileInclusionManager() {
|
||||
)}
|
||||
{t("records.aiInclusion.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -142,7 +143,7 @@ export function AllergyFreeTextNote() {
|
||||
)}
|
||||
{t("settings.ai.aboutMe.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.allergies.description")}
|
||||
</p>
|
||||
{rows.length > 0 ? (
|
||||
<Button size="sm" onClick={openNew} className="shrink-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("records.allergies.add")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
@@ -156,6 +145,23 @@ export function AllergyManager() {
|
||||
<ul className="space-y-2">{rows.map(renderRow)}</ul>
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={openNew}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("records.allergies.add")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : null}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
|
||||
@@ -26,6 +26,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 { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -137,7 +138,7 @@ export function ConditionsManager() {
|
||||
t("records.conditions.focusLabel"),
|
||||
t("settings.ai.aboutMe.focusPlaceholder"),
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -154,7 +155,7 @@ export function ConditionsManager() {
|
||||
)}
|
||||
{t("settings.ai.aboutMe.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<Draft>) =>
|
||||
setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch }));
|
||||
|
||||
@@ -113,7 +121,7 @@ export function EmergencyProfileManager() {
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => set({ [key]: next } as Partial<Draft>)}
|
||||
>
|
||||
<SelectTrigger id={`emergency-${key}`}>
|
||||
<SelectTrigger id={`emergency-${key}`} className="w-full">
|
||||
<SelectValue placeholder={t("records.emergency.notRecorded")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -170,12 +178,12 @@ export function EmergencyProfileManager() {
|
||||
{t("records.emergency.reportHint")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled || current === null}
|
||||
disabled={disabled || !dirty}
|
||||
onClick={() => current && save.mutate(current)}
|
||||
>
|
||||
{save.isPending && (
|
||||
@@ -186,7 +194,7 @@ export function EmergencyProfileManager() {
|
||||
)}
|
||||
{t("records.emergency.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("records.family.description")}
|
||||
</p>
|
||||
{rows.length > 0 ? (
|
||||
<Button size="sm" onClick={openNew} className="shrink-0">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("records.family.add")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }, (_, i) => (
|
||||
@@ -141,6 +130,18 @@ export function FamilyHistoryManager() {
|
||||
<ul className="space-y-2">{rows.map(renderRow)}</ul>
|
||||
)}
|
||||
|
||||
{/* 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 ? (
|
||||
<SettingsCardActions>
|
||||
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("records.family.add")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
) : null}
|
||||
|
||||
<ResponsiveSheet
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Loader2, Trash2 } 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 { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -289,7 +290,10 @@ export function HealthProfileFactsManager() {
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => editDraft(kind, next)}
|
||||
>
|
||||
<SelectTrigger id={`health-profile-fact-${kind}`}>
|
||||
<SelectTrigger
|
||||
id={`health-profile-fact-${kind}`}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
current?.unreadable
|
||||
@@ -311,71 +315,53 @@ export function HealthProfileFactsManager() {
|
||||
three fields no longer each stack their own grey Save slab.
|
||||
When a shared Save partially fails, the kind that didn't land
|
||||
says so here rather than behind the toast. */}
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
{failed ? (
|
||||
<span
|
||||
className="text-destructive text-xs"
|
||||
data-slot={`health-profile-fact-error-${kind}`}
|
||||
>
|
||||
{t("records.profileFacts.saveKindError")}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
{current ? (
|
||||
<ConfirmButton
|
||||
// Short label inside the row; the full phrase stays the
|
||||
// accessible name, because "Remove" on its own is
|
||||
// ambiguous once three of them are on screen.
|
||||
label={t("records.profileFacts.removeShort")}
|
||||
ariaLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
title={t("records.profileFacts.removeConfirmTitle", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
body={t("records.profileFacts.removeConfirmDescription")}
|
||||
confirmLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
onConfirm={() => 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={<Trash2 className="size-4" aria-hidden />}
|
||||
slot={`health-profile-fact-remove-${kind}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{failed || current ? (
|
||||
<div className="flex items-center justify-between gap-2 pt-1">
|
||||
{failed ? (
|
||||
<span
|
||||
className="text-destructive text-xs"
|
||||
data-slot={`health-profile-fact-error-${kind}`}
|
||||
>
|
||||
{t("records.profileFacts.saveKindError")}
|
||||
</span>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
{current ? (
|
||||
<ConfirmButton
|
||||
// Short label inside the row; the full phrase stays the
|
||||
// accessible name, because "Remove" on its own is
|
||||
// ambiguous once three of them are on screen.
|
||||
label={t("records.profileFacts.removeShort")}
|
||||
ariaLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
title={t("records.profileFacts.removeConfirmTitle", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
body={t("records.profileFacts.removeConfirmDescription")}
|
||||
confirmLabel={t("records.profileFacts.remove", {
|
||||
kind: t(KIND_LABEL[kind]),
|
||||
})}
|
||||
onConfirm={() => 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={<Trash2 className="size-4" aria-hidden />}
|
||||
slot={`health-profile-fact-remove-${kind}`}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled || dirtyCount === 0}
|
||||
onClick={() => save.mutate(collectDirtyInputs())}
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.profileFacts.save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{closedHistory.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
@@ -412,6 +398,28 @@ export function HealthProfileFactsManager() {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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). */}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
disabled={disabled || dirtyCount === 0}
|
||||
onClick={() => save.mutate(collectDirtyInputs())}
|
||||
>
|
||||
{save.isPending && (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
{t("records.profileFacts.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -265,21 +265,25 @@ export function TotpCard({
|
||||
|
||||
<div>
|
||||
{/* ── 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 && (
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="totp-setup-start"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => beginSetup.mutate(undefined)}
|
||||
disabled={beginSetup.isPending}
|
||||
>
|
||||
{beginSetup.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.security.totp.setUp")}
|
||||
</Button>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="totp-setup-start"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => beginSetup.mutate(undefined)}
|
||||
disabled={beginSetup.isPending}
|
||||
>
|
||||
{beginSetup.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<KeyRound className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.security.totp.setUp")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
<ExistingFactorReauthDialog
|
||||
open={reauthOpen}
|
||||
@@ -418,7 +422,7 @@ export function TotpCard({
|
||||
{ count: recoveryCodesRemaining },
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SettingsCardActions>
|
||||
<AlertDialog
|
||||
open={regenerateOpen}
|
||||
onOpenChange={setRegenerateOpen}
|
||||
@@ -470,8 +474,8 @@ export function TotpCard({
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
variant="destructive"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("settings.security.totp.disable")}
|
||||
</Button>
|
||||
@@ -544,7 +548,7 @@ export function TotpCard({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -297,21 +297,6 @@ export function SourcesSection() {
|
||||
icon={Layers}
|
||||
title={t("settings.sections.sources.cardTitle")}
|
||||
description={t("settings.sections.sources.help")}
|
||||
status={
|
||||
<ConfirmButton
|
||||
slot="settings-sources-reset"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
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()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="text-sm">{t("settings.sections.sources.helpDetail")}</p>
|
||||
@@ -346,12 +331,12 @@ export function SourcesSection() {
|
||||
<span className="flex-1 text-sm">
|
||||
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
|
||||
</span>
|
||||
{/* 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. */}
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
|
||||
{/* 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. */}
|
||||
<div className="flex shrink-0 gap-1 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -564,32 +549,51 @@ export function SourcesSection() {
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{dirty && priority && (
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate(priority)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<SettingsCardActions>
|
||||
<ConfirmButton
|
||||
slot="settings-sources-reset"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
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 ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setDraft(null)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => saveMutation.mutate(priority)}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
|
||||
{/* v1.28 shipped `<NutrientIntakeCard>` here; the 2026-07-17 UX/IA
|
||||
|
||||
Reference in new issue
Block a user