import { useEffect, useId, useState, type ReactNode, type RefObject } from "react"; import { BoxIcon, CalendarClockIcon, ChevronRightIcon, FolderIcon, KeyRoundIcon, MessageSquareIcon, UserIcon, XIcon, type LucideIcon, } from "lucide-react"; import { toast } from "../../lib/toast"; import { Button } from "../../../ui/button"; import { destructiveActionFocusTarget } from "../../../ui/confirm-dialog"; import { Field, TextInput } from "../../../ui/field"; import { FormDialog, FormFrame, type FormFrameProps } from "../../../ui/form-dialog"; import { inAppClick } from "../../../ui/in-app-click"; import { cn } from "../../lib/utils"; /* ---------------------------------------------------------------------------- Destructive confirm (brief 7.16): make permanent actions explicit and blocked actions explainable. Builds on the FormDialog anatomy; the older ConfirmDialog stays for existing call sites. - "consequences" (default): the real name in the title, 2-4 bullets of what happens, what depends on it with links, and a destructive primary. - "type-to-confirm": the same, plus typing the name into an empty field. Only for permanent, wide-impact actions (a workspace, an org member). - "blocked": the action can't run. Says what blocks it, lists each dependency with a link, and offers only Close. Reversible actions skip the dialog: run them, then `showUndoToast`. -------------------------------------------------------------------------- */ export type DestructiveConfirmVariant = "consequences" | "type-to-confirm" | "blocked"; export type DependencyKind = | "schedule" | "chat" | "environment" | "workspace" | "variable-set" | "api-key" | "person"; // Resolved at render, not in a module-level table: the icons can live in a // chunk that loads after this one, and a table would capture undefined. function dependencyIcon(kind: DependencyKind): LucideIcon { switch (kind) { case "schedule": return CalendarClockIcon; case "chat": return MessageSquareIcon; case "environment": return BoxIcon; case "workspace": return FolderIcon; case "variable-set": case "api-key": return KeyRoundIcon; case "person": return UserIcon; } } export interface ConfirmDependency { id: string; kind: DependencyKind; /** "Schedule", "Chat", "Sandbox environment default". */ kindLabel: string; name: string; /** One quiet fact, for example "Every weekday at 08:00 · Oslo". */ detail?: string | undefined; /** Where to fix it. Rendered as a link. */ href?: string | undefined; } export interface DestructiveConfirmContentProps { variant?: DestructiveConfirmVariant | undefined; /** Names the real object: "Delete AWS production?" or, when blocked, "AWS production is in use". */ title: ReactNode; /** One optional sentence under the title. */ description?: ReactNode | undefined; /** 2-4 short bullets of what happens. End with "This can't be undone." when true. */ consequences?: ReactNode[] | undefined; /** What depends on the object, with links. Required for "blocked". */ dependencies?: ConfirmDependency[] | undefined; /** Heading over the dependencies. Default "Used by". */ dependenciesTitle?: ReactNode | undefined; /** * Opens a dependency's page in the app (the router). Without it a plain * click on a dependency link is a full page load. */ onOpenDependency?: ((dependency: ConfirmDependency & { href: string }) => void) | undefined; /** The exact name to type ("type-to-confirm"). */ confirmText?: string | undefined; /** Neutral placeholder for the typed name; never the name itself. */ confirmPlaceholder?: string | undefined; /** Verb + object: "Delete variable set". */ confirmLabel?: string | undefined; /** "Deleting…". */ pendingLabel?: string | undefined; /** The only button when blocked. Default "Close". */ closeLabel?: string | undefined; /** May be async. Throw (with a user-facing message) to show the error inside. */ onConfirm?: (() => void | boolean | Promise) | undefined; /** A server error, shown inside the dialog. */ error?: ReactNode | undefined; /** Controlled typed text. */ typedValue?: string | undefined; /** Initial typed text, for previews. The dialog always starts empty. */ defaultTypedValue?: string | undefined; onTypedValueChange?: ((value: string) => void) | undefined; /** Controlled submitting state, for previews. */ pending?: boolean | undefined; } /** True when the typed name matches exactly, ignoring surrounding spaces. */ export function confirmTextMatches(typed: string, expected: string): boolean { return expected.trim().length > 0 && typed.trim() === expected.trim(); } function DependencyList({ title, dependencies, onOpenDependency, }: { title: ReactNode; dependencies: ConfirmDependency[]; onOpenDependency?: DestructiveConfirmContentProps["onOpenDependency"] | undefined; }) { const headingId = useId(); return (

{title}

); } /** Builds the frame props shared by the dialog and its static panel. */ function useDestructiveFrame({ variant = "consequences", title, description, consequences = [], dependencies = [], dependenciesTitle = "Used by", onOpenDependency, confirmText = "", confirmPlaceholder = "Type the name", confirmLabel = "Delete", pendingLabel = "Deleting…", closeLabel = "Close", onConfirm, error, typedValue, defaultTypedValue = "", onTypedValueChange, pending, open, }: DestructiveConfirmContentProps & { open?: boolean | undefined }): Omit< FormFrameProps, "variant" > { const [ownTyped, setOwnTyped] = useState(defaultTypedValue); const typed = typedValue ?? ownTyped; const setTyped = (value: string) => { setOwnTyped(value); onTypedValueChange?.(value); }; // The typed name always starts empty when the dialog opens again. useEffect(() => { if (open) setOwnTyped(""); }, [open]); const blocked = variant === "blocked"; const needsTyping = variant === "type-to-confirm"; const matches = !needsTyping || confirmTextMatches(typed, confirmText); const body = (
{!blocked && consequences.length > 0 ? ( ) : null} {dependencies.length > 0 ? ( ) : null} {needsTyping ? ( Type {confirmText} to confirm } > setTyped(event.target.value)} placeholder={confirmPlaceholder} autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false} data-1p-ignore data-lpignore="true" /> ) : null}
); if (blocked) { return { title, description, children: body, submitLabel: closeLabel, cancelLabel: null, showClose: false, footer: undefined, }; } return { title, description, children: body, submitLabel: confirmLabel, pendingLabel, tone: "destructive", onSubmit: onConfirm, error, pending, submitDisabled: !matches, showClose: false, initialFocus: needsTyping ? "field" : "cancel", }; } /** The destructive confirm in a modal. Focus starts on Cancel unless a name must be typed. */ export function DestructiveConfirm({ open, onOpenChange, trigger, restoreFocusRef, restoreFocusFallbackRef, ...content }: DestructiveConfirmContentProps & { open: boolean; onOpenChange: (open: boolean) => void; trigger?: ReactNode | undefined; /** Where focus goes after closing when the trigger is gone (a deleted row). */ restoreFocusRef?: RefObject | undefined; restoreFocusFallbackRef?: RefObject | undefined; }) { const frame = useDestructiveFrame({ ...content, open }); const blocked = content.variant === "blocked"; return ( onOpenChange(false)} footer={ blocked ? ( ) : undefined } onCloseAutoFocus={ restoreFocusRef || restoreFocusFallbackRef ? (event) => { const destination = destructiveActionFocusTarget( restoreFocusRef?.current ?? null, restoreFocusFallbackRef?.current ?? null, ); if (!destination) return; event.preventDefault(); destination.focus(); } : undefined } /> ); } /** * The same content as a static panel, for previews and documentation. It is * exactly what the modal renders, without the overlay. */ export function DestructiveConfirmPanel({ onClose, className, ...content }: DestructiveConfirmContentProps & { onClose?: (() => void) | undefined; className?: string | undefined; }) { const frame = useDestructiveFrame(content); const blocked = content.variant === "blocked"; return ( {content.closeLabel ?? "Close"} ) : undefined } className={cn("w-full max-w-[480px]", className)} /> ); } /* ---------------------------------------------------------------------------- Undo instead of a dialog, for reversible actions (archive, remove from a list). -------------------------------------------------------------------------- */ export function UndoToast({ title, description, undoLabel = "Undo", onUndo, onDismiss, icon, className, }: { /** What just happened, past tense: "Archived 14 Sep checkout outage". */ title: ReactNode; description?: ReactNode | undefined; undoLabel?: string | undefined; onUndo: () => void; onDismiss?: (() => void) | undefined; icon?: ReactNode | undefined; className?: string | undefined; }) { return (
{icon ? {icon} : null}

{title}

{description ? (

{description}

) : null}
{onDismiss ? ( ) : null}
); } /** * Runs nothing itself: call it right after a reversible action. Undo calls * `onUndo` and closes the toast. Stays 8 seconds, longer than a normal toast. */ export function showUndoToast({ title, description, undoLabel, onUndo, icon, duration = 8000, }: { title: ReactNode; description?: ReactNode | undefined; undoLabel?: string | undefined; onUndo: () => void; icon?: ReactNode | undefined; duration?: number | undefined; }): string | number { return toast.custom( (id) => ( { onUndo(); toast.dismiss(id); }} onDismiss={() => toast.dismiss(id)} /> ), { duration }, ); }