mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
fix(ui): a mis-tap must not be able to destroy an allergy list
Settings → AI "About me" had one Clear control wired to
`save.mutate({ aboutMe: "", allergies: "" })`. One tap, no dialog, and
the allergy line went with the note — while the toast it raised said
"About-me note cleared", which is what the control looks like it does.
The allergy line is the first thing a clinician reads and the column is
overwritten in place, so there is nothing to restore afterwards.
Scope the control rather than only gating it: it now writes `aboutMe`
alone and omits `allergies`, which the PUT leaves untouched by contract,
so the payload cannot carry the allergy line away even if the dialog is
ever dropped. Clearing allergies stays possible in the allergies field
itself, which is a deliberate act on the thing being emptied. The
confirmation on top guards the note.
The same one-tap shape ran through thirteen other controls that
overwrite a column or drop a credential with nothing to restore from.
They route through the shared confirmation now: both Codex disconnects
(personal and the instance-wide shared account), the AI server key, the
profile photo, the threshold reset — all, per metric, and the override
switch, whose off position performed the same irreversible delete from a
control that reads as a display toggle — the source-priority order, the
dashboard layout, the Insights overview, a single target range, a
remembered Coach fact, a past Coach plan, a logged cycle day and a custom
cycle symptom.
`ConfirmButton` moves to `components/ui`, since it is no longer a
settings-local helper, and gains `ConfirmDialog`: the same dialog without
a trigger, for destructive controls that are not buttons. A switch has to
stay on when the dialog its off position raised is cancelled, and a
control that could not reach for the primitive reached for nothing —
which is a fair part of why those were the ones that shipped unguarded.
Copy lands in all six locales.
This commit is contained in:
1 parent
0afd217f9f
commit
48ac8f7e2d
25 files changed
+587
-182
No files matched your search
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The destructive-control confirmation, in the two shapes the tree needs.
|
||||
*
|
||||
* The security card's "sign out everywhere" and its per-row revoke, and the
|
||||
* same pair on trusted devices, fired on a single tap with no confirmation —
|
||||
* while every other destructive surface in the app confirms. Signing every
|
||||
* other device out is not recoverable by an undo; the other devices simply
|
||||
* have to log in again, and on a shared screen a mis-tap is a support call.
|
||||
*
|
||||
* Wrapping the pattern rather than inlining a fourth dialog is the point: the
|
||||
* next destructive control gets the confirmation by using this, instead of by
|
||||
* someone remembering to add one.
|
||||
*
|
||||
* `ConfirmButton` covers the common case — a button that opens its own dialog.
|
||||
* `ConfirmDialog` is the same dialog without the trigger, for the destructive
|
||||
* controls that are not buttons: a switch whose off position deletes what it
|
||||
* was holding, a menu item, a key binding. Those had no shared path at all,
|
||||
* which is a fair part of why they were the ones that shipped unguarded — a
|
||||
* control that cannot reach for the primitive reaches for nothing.
|
||||
*
|
||||
* The copy contract matches the rest of the app after the delete-confirmation
|
||||
* sweep — say what happens, and claim no more finality than is true. Callers
|
||||
* pass their own body text so each control can be specific about its own
|
||||
* consequence.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface ConfirmCopy {
|
||||
title: string;
|
||||
body: string;
|
||||
/** The dialog's action label. Names the act, not "OK". */
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
pending?: boolean;
|
||||
/** `data-slot` for tests and the design guards. */
|
||||
slot?: string;
|
||||
}
|
||||
|
||||
function ConfirmBody({
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
pending = false,
|
||||
slot,
|
||||
}: ConfirmCopy) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{body}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-slot={slot ? `${slot}-confirm` : undefined}
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{pending && (
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmButton({
|
||||
label,
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
onConfirm,
|
||||
pending = false,
|
||||
disabled = false,
|
||||
size,
|
||||
variant = "outline",
|
||||
className,
|
||||
icon,
|
||||
ariaLabel,
|
||||
slot,
|
||||
}: ConfirmCopy & {
|
||||
/** The control's own label, before the dialog opens. */
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "icon";
|
||||
variant?: "outline" | "ghost";
|
||||
className?: string;
|
||||
/** For an icon-only control, where `label` renders as an icon. */
|
||||
ariaLabel?: string;
|
||||
icon?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={className}
|
||||
aria-label={ariaLabel}
|
||||
data-slot={slot}
|
||||
disabled={disabled || pending}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<ConfirmBody
|
||||
title={title}
|
||||
body={body}
|
||||
confirmLabel={confirmLabel}
|
||||
onConfirm={onConfirm}
|
||||
pending={pending}
|
||||
slot={slot}
|
||||
/>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The trigger-less half. The caller owns `open` and decides what a dismissal
|
||||
* means for its own control — a switch, for instance, has to stay ON when the
|
||||
* dialog its OFF position raised is cancelled, which is a state a
|
||||
* self-triggering dialog cannot express.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
...copy
|
||||
}: ConfirmCopy & {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<ConfirmBody {...copy} />
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user