mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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
@@ -7,6 +7,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -170,14 +171,16 @@ export function AiServerKeySection() {
|
||||
{t("admin.aiServerKey.save")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="admin-ai-server-key-remove"
|
||||
variant="outline"
|
||||
onClick={handleRemove}
|
||||
disabled={save.isPending}
|
||||
data-slot="admin-ai-server-key-remove"
|
||||
>
|
||||
{t("admin.aiServerKey.remove")}
|
||||
</Button>
|
||||
label={t("admin.aiServerKey.remove")}
|
||||
title={t("admin.aiServerKey.removeTitle")}
|
||||
body={t("admin.aiServerKey.removeBody")}
|
||||
confirmLabel={t("admin.aiServerKey.removeConfirm")}
|
||||
pending={save.isPending}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -180,21 +181,19 @@ export function CentralCodexSection() {
|
||||
)}
|
||||
|
||||
{isConnected ? (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="admin-central-codex-disconnect"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
data-slot="admin-central-codex-disconnect"
|
||||
>
|
||||
{disconnecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("admin.centralCodex.disconnectButton")}
|
||||
</Button>
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label={t("admin.centralCodex.disconnectButton")}
|
||||
title={t("admin.centralCodex.disconnectTitle")}
|
||||
body={t("admin.centralCodex.disconnectBody")}
|
||||
confirmLabel={t("admin.centralCodex.disconnectConfirm")}
|
||||
pending={disconnecting}
|
||||
onConfirm={handleDisconnect}
|
||||
/>
|
||||
) : deviceCode ? (
|
||||
<div className="border-primary bg-primary/5 space-y-3 rounded-lg border-l-4 p-4">
|
||||
<p className="text-sm font-medium">
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
@@ -687,20 +688,20 @@ export function LogDaySheet({
|
||||
footer={
|
||||
<>
|
||||
{rowId ? (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="cycle-day-delete"
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={busy}
|
||||
className="text-destructive hover:text-destructive mr-auto"
|
||||
aria-label={t("cycle.sheet.delete")}
|
||||
>
|
||||
{deleteDay.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("cycle.sheet.delete")}
|
||||
</Button>
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label={t("cycle.sheet.delete")}
|
||||
ariaLabel={t("cycle.sheet.delete")}
|
||||
title={t("cycle.sheet.deleteTitle")}
|
||||
body={t("cycle.sheet.deleteBody")}
|
||||
confirmLabel={t("cycle.sheet.deleteConfirm")}
|
||||
disabled={busy && !deleteDay.isPending}
|
||||
pending={deleteDay.isPending}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1199,6 +1200,7 @@ function SymptomChip({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const deleteCustom = useDeleteCustomSymptom();
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
async function handleRemove() {
|
||||
if (!custom) return;
|
||||
@@ -1222,7 +1224,7 @@ function SymptomChip({
|
||||
{custom ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemove}
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
disabled={deleteCustom.isPending}
|
||||
aria-label={t("cycle.symptom.custom.remove", { label })}
|
||||
className="border-border bg-background text-muted-foreground hover:text-destructive hover:border-destructive focus-visible:ring-ring/50 relative -ml-1.5 grid size-4 shrink-0 place-items-center rounded-full border transition-colors before:absolute before:-inset-2 before:content-[''] focus-visible:ring-2 focus-visible:outline-none"
|
||||
@@ -1230,6 +1232,18 @@ function SymptomChip({
|
||||
<X className="size-2.5" aria-hidden="true" />
|
||||
</button>
|
||||
) : null}
|
||||
{custom ? (
|
||||
<ConfirmDialog
|
||||
slot="cycle-custom-symptom-remove"
|
||||
open={confirmRemove}
|
||||
onOpenChange={setConfirmRemove}
|
||||
title={t("cycle.symptom.custom.removeTitle")}
|
||||
body={t("cycle.symptom.custom.removeBody", { label })}
|
||||
confirmLabel={t("cycle.symptom.custom.removeConfirm")}
|
||||
pending={deleteCustom.isPending}
|
||||
onConfirm={handleRemove}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{active ? (
|
||||
<div
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -230,20 +231,19 @@ export function InsightsEditMode({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-end sm:self-auto">
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="insights-edit-reset"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
data-slot="insights-edit-reset"
|
||||
>
|
||||
{resetMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.editMode.reset")}
|
||||
</Button>
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("insights.editMode.reset")}
|
||||
title={t("insights.editMode.resetTitle")}
|
||||
body={t("insights.editMode.resetBody")}
|
||||
confirmLabel={t("insights.editMode.resetConfirm")}
|
||||
disabled={busy && !resetMutation.isPending}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
|
||||
@@ -122,11 +122,13 @@ describe("Settings — CoachMemorySection", () => {
|
||||
|
||||
it("renders a per-fact forget control and the bulk forget-all action", () => {
|
||||
const html = render(buildFacts());
|
||||
expect(html).toContain('data-testid="settings-coach-memory-forget"');
|
||||
// The per-row control routes through `ConfirmButton`, which identifies
|
||||
// itself with `data-slot`; the bulk one is still a local dialog.
|
||||
expect(html).toContain('data-slot="settings-coach-memory-forget"');
|
||||
expect(html).toContain('data-testid="settings-coach-memory-forget-all"');
|
||||
// One forget button per fact (3 seeded).
|
||||
const forgetButtons = html.match(
|
||||
/data-testid="settings-coach-memory-forget"/g,
|
||||
/data-slot="settings-coach-memory-forget"/g,
|
||||
);
|
||||
expect(forgetButtons).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -26,6 +26,16 @@
|
||||
*
|
||||
* Plain text only — every value renders exclusively through inputs and
|
||||
* React text children; no markdown anywhere.
|
||||
*
|
||||
* The "Clear" control writes `aboutMe: ""` and nothing else. It used to send
|
||||
* `allergies: ""` alongside, so one untargeted tap wiped the allergy line as
|
||||
* well — while the toast it raised said "About-me note cleared", which is what
|
||||
* the control looks like it does and what a person reading it expects. The
|
||||
* allergy line is the entry a clinician reads first, so the control is scoped
|
||||
* rather than merely gated: the field is omitted from the payload, and the PUT
|
||||
* leaves an omitted field untouched by contract. Clearing allergies is done in
|
||||
* the allergies field itself, which is a deliberate act on the thing being
|
||||
* emptied. The confirmation on top of that guards the note.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -40,6 +50,7 @@ import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -103,7 +114,9 @@ export function AboutMeSection({
|
||||
};
|
||||
const dirty =
|
||||
value.aboutMe !== saved.aboutMe || value.allergies !== saved.allergies;
|
||||
const hasAnyContent = saved.aboutMe.length > 0 || saved.allergies.length > 0;
|
||||
// The clear control only ever touches the note, so it only appears when
|
||||
// there is a note to clear.
|
||||
const hasNote = saved.aboutMe.length > 0;
|
||||
|
||||
// Quiet completeness meter: one segment per answered field. Reads
|
||||
// the SAVED state (not the draft) so it only moves on persistence.
|
||||
@@ -118,14 +131,16 @@ export function AboutMeSection({
|
||||
// v1.32.21 (R5a) — echo the base token this edit was based on so a
|
||||
// concurrent write to the same self-context (another tab, or Coach adopt)
|
||||
// 409s instead of silently overwriting the newer state.
|
||||
mutationFn: async (input: { aboutMe: string; allergies: string }) => {
|
||||
// `allergies` is optional on purpose: the clear control omits it so the
|
||||
// stored allergy line survives, per the PUT's omitted-field contract.
|
||||
mutationFn: async (input: { aboutMe: string; allergies?: string }) => {
|
||||
return apiPut<AboutMeData>(
|
||||
"/api/coach/about-me",
|
||||
withBaseToken(input, query.data?.updatedAt ?? undefined),
|
||||
);
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
const cleared = !next.aboutMe && !next.allergies;
|
||||
const cleared = !next.aboutMe;
|
||||
toast.success(
|
||||
cleared
|
||||
? t("settings.ai.aboutMe.clearedToast")
|
||||
@@ -251,19 +266,21 @@ export function AboutMeSection({
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasAnyContent && (
|
||||
<Button
|
||||
type="button"
|
||||
{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"
|
||||
data-testid="settings-about-me-clear"
|
||||
disabled={!isAuthenticated || save.isPending}
|
||||
onClick={() => save.mutate({ aboutMe: "", allergies: "" })}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
{t("settings.ai.aboutMe.clear")}
|
||||
</Button>
|
||||
title={t("settings.ai.aboutMe.clearTitle")}
|
||||
body={t("settings.ai.aboutMe.clearBody")}
|
||||
confirmLabel={t("settings.ai.aboutMe.clearConfirm")}
|
||||
disabled={!isAuthenticated}
|
||||
pending={save.isPending}
|
||||
onConfirm={() => save.mutate({ aboutMe: "" })}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ImageUp, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -146,23 +147,23 @@ export function AvatarSection() {
|
||||
: t("settings.avatar.upload")}
|
||||
</Button>
|
||||
{avatarUrl && (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="settings-avatar-remove"
|
||||
variant="ghost"
|
||||
className="text-destructive min-h-11 sm:min-h-9"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label={t("settings.avatar.remove")}
|
||||
title={t("settings.avatar.removeTitle")}
|
||||
body={t("settings.avatar.removeBody")}
|
||||
confirmLabel={t("settings.avatar.removeConfirm")}
|
||||
disabled={busy && !remove.isPending}
|
||||
pending={remove.isPending}
|
||||
onConfirm={() => {
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
remove.mutate();
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.avatar.remove")}
|
||||
</Button>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Loader2, Sparkles, Trash2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -205,20 +206,19 @@ export function CodexProviderForm({
|
||||
{t("settings.ai.oauthNotConfigured")}
|
||||
</p>
|
||||
) : isConnected ? (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="settings-codex-disconnect"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11 shrink-0 sm:min-h-9"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnecting}
|
||||
>
|
||||
{disconnecting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.ai.codex.disconnectButton")}
|
||||
</Button>
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
label={t("settings.ai.codex.disconnectButton")}
|
||||
title={t("settings.ai.codex.disconnectTitle")}
|
||||
body={t("settings.ai.codex.disconnectBody")}
|
||||
confirmLabel={t("settings.ai.codex.disconnectConfirm")}
|
||||
pending={disconnecting}
|
||||
onConfirm={handleDisconnect}
|
||||
/>
|
||||
) : deviceCode ? (
|
||||
<div className="border-primary bg-primary/5 space-y-3 rounded-lg border-l-4 p-4">
|
||||
<p className="text-sm font-medium">
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -243,35 +244,28 @@ export function CoachMemorySection({
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
<ConfirmButton
|
||||
slot="settings-coach-memory-forget"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
data-testid="settings-coach-memory-forget"
|
||||
aria-label={t("settings.ai.coachMemory.forgetAria")}
|
||||
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
|
||||
icon={<Trash2 className="size-4" aria-hidden />}
|
||||
label={t("settings.ai.coachMemory.forget")}
|
||||
title={t("settings.ai.coachMemory.forgetTitle")}
|
||||
body={t("settings.ai.coachMemory.forgetBody")}
|
||||
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
|
||||
disabled={!isAuthenticated}
|
||||
// v1.11.2 — per-id pending: only the row being
|
||||
// deleted disables/spins. `forgetOne.variables` holds
|
||||
// the id passed to the in-flight `mutate()`, so a
|
||||
// single shared mutation no longer greys out every
|
||||
// other row's forget button during one delete.
|
||||
disabled={
|
||||
!isAuthenticated ||
|
||||
(forgetOne.isPending && forgetOne.variables === fact.id)
|
||||
pending={
|
||||
forgetOne.isPending && forgetOne.variables === fact.id
|
||||
}
|
||||
onClick={() => forgetOne.mutate(fact.id)}
|
||||
>
|
||||
{forgetOne.isPending &&
|
||||
forgetOne.variables === fact.id ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
)}
|
||||
{t("settings.ai.coachMemory.forget")}
|
||||
</Button>
|
||||
onConfirm={() => forgetOne.mutate(fact.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { formatDateOrRelative } from "@/lib/format";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useCoachReminders,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -607,16 +608,19 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
icon={LayoutDashboard}
|
||||
title={t("dashboard.customizeTitle")}
|
||||
status={
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="settings-dashboard-layout-reset"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending}
|
||||
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("dashboard.layoutReset")}
|
||||
</Button>
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("dashboard.layoutReset")}
|
||||
title={t("dashboard.layoutResetTitle")}
|
||||
body={t("dashboard.layoutResetBody")}
|
||||
confirmLabel={t("dashboard.layoutResetConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/*
|
||||
|
||||
@@ -27,7 +27,7 @@ import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
@@ -297,15 +298,19 @@ export function SourcesSection() {
|
||||
title={t("settings.sections.sources.cardTitle")}
|
||||
description={t("settings.sections.sources.help")}
|
||||
status={
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="settings-sources-reset"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={resetMutation.isPending || saveMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("settings.sections.sources.resetDefaults")}
|
||||
</Button>
|
||||
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()}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -130,15 +131,18 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{data && Object.keys(data.overrides).length > 0 && (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="settings-thresholds-reset-all"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate(null)}
|
||||
disabled={resetMutation.isPending}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("thresholds.resetAllAction")}
|
||||
</Button>
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("thresholds.resetAllAction")}
|
||||
title={t("thresholds.resetAllTitle")}
|
||||
body={t("thresholds.resetAllBody")}
|
||||
confirmLabel={t("thresholds.resetAllConfirm")}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -241,6 +245,7 @@ function MetricRow({
|
||||
|
||||
const hasOverride = override !== null;
|
||||
const [overrideMode, setOverrideMode] = useState(hasOverride);
|
||||
const [confirmSwitchOff, setConfirmSwitchOff] = useState(false);
|
||||
const [minStr, setMinStr] = useState(
|
||||
seedString(override?.min ?? effective?.default?.greenMin, bounds.min),
|
||||
);
|
||||
@@ -310,15 +315,35 @@ function MetricRow({
|
||||
<Label htmlFor={`override-${metric}`} className="text-xs">
|
||||
{t("thresholds.overrideToggleLabel")}
|
||||
</Label>
|
||||
{/* Flipping this off DELETES the stored override — the same
|
||||
irreversible write the Reset control performs, reached by a
|
||||
control that reads as a display toggle. It gets the same
|
||||
confirmation, and a dismissal leaves the switch on. */}
|
||||
<Switch
|
||||
id={`override-${metric}`}
|
||||
checked={overrideMode}
|
||||
onCheckedChange={(next) => {
|
||||
if (!next && hasOverride) {
|
||||
setConfirmSwitchOff(true);
|
||||
return;
|
||||
}
|
||||
setOverrideMode(next);
|
||||
if (!next && hasOverride) onReset();
|
||||
}}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
slot={`settings-thresholds-override-off-${metric}`}
|
||||
open={confirmSwitchOff}
|
||||
onOpenChange={setConfirmSwitchOff}
|
||||
title={t("thresholds.resetTitle")}
|
||||
body={t("thresholds.resetBody")}
|
||||
confirmLabel={t("thresholds.resetConfirm")}
|
||||
pending={busy}
|
||||
onConfirm={() => {
|
||||
setOverrideMode(false);
|
||||
onReset();
|
||||
}}
|
||||
/>
|
||||
{hasOverride && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("thresholds.sourceOverride")}
|
||||
@@ -381,15 +406,18 @@ function MetricRow({
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
{hasOverride && (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot={`settings-thresholds-reset-${metric}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onReset}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("thresholds.resetAction")}
|
||||
</Button>
|
||||
label={t("thresholds.resetAction")}
|
||||
title={t("thresholds.resetTitle")}
|
||||
body={t("thresholds.resetBody")}
|
||||
confirmLabel={t("thresholds.resetConfirm")}
|
||||
pending={busy}
|
||||
onConfirm={onReset}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { ConfirmButton } from "@/components/settings/confirm-button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RotateCcw } from "lucide-react";
|
||||
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -422,16 +423,23 @@ function TargetEditSheetBody({
|
||||
footer={
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
{hasOverride ? (
|
||||
<Button
|
||||
<ConfirmButton
|
||||
slot="target-edit-reset"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
data-slot="target-edit-reset"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{t("targets.edit.resetToDefault")}
|
||||
</Button>
|
||||
icon={<RotateCcw className="h-3.5 w-3.5" />}
|
||||
label={t("targets.edit.resetToDefault")}
|
||||
title={t("targets.edit.resetTitle")}
|
||||
body={
|
||||
isBp
|
||||
? t("targets.edit.resetBodyBp")
|
||||
: t("targets.edit.resetBody")
|
||||
}
|
||||
confirmLabel={t("targets.edit.resetConfirm")}
|
||||
disabled={busy && !resetMutation.isPending}
|
||||
pending={resetMutation.isPending}
|
||||
onConfirm={() => resetMutation.mutate()}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* A destructive settings control that asks first.
|
||||
* 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 —
|
||||
@@ -13,6 +13,13 @@
|
||||
* 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
|
||||
@@ -36,6 +43,51 @@ import {
|
||||
} 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,
|
||||
@@ -50,15 +102,9 @@ export function ConfirmButton({
|
||||
icon,
|
||||
ariaLabel,
|
||||
slot,
|
||||
}: {
|
||||
}: ConfirmCopy & {
|
||||
/** The control's own label, before the dialog opens. */
|
||||
label: string;
|
||||
title: string;
|
||||
body: string;
|
||||
/** The dialog's action label. Names the act, not "OK". */
|
||||
confirmLabel: string;
|
||||
onConfirm: () => void;
|
||||
pending?: boolean;
|
||||
disabled?: boolean;
|
||||
size?: "sm" | "icon";
|
||||
variant?: "outline" | "ghost";
|
||||
@@ -66,11 +112,7 @@ export function ConfirmButton({
|
||||
/** For an icon-only control, where `label` renders as an icon. */
|
||||
ariaLabel?: string;
|
||||
icon?: ReactNode;
|
||||
/** `data-slot` for tests and the design guards. */
|
||||
slot?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -94,26 +136,35 @@ export function ConfirmButton({
|
||||
{label}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<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>
|
||||
<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