From d492594189640c67c85c3b2d341bf03edf5c430f Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 3 May 2026 14:23:26 -0400 Subject: [PATCH] feat(ui): add ConfirmModal, migrate EditorLayout inline confirms (#897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce ConfirmModal, an AlertDialog-rooted variant of the §10 modal chrome for Yes-No confirmations. Reuses the cyan or destructive rail, mono kicker, italic serif title, and footer hint via a parameterized HeaderShell that injects Title and Description components so the same helper renders Dialog or AlertDialog primitives correctly. Replace the three inline AlertDialog blocks in EditorLayout (delete stack, unsaved-load, label bulk action) with ConfirmModal. Hoist the label bulk-action handler out of inline JSX and memoize the affected stack list. Async confirms (returning a Promise from onConfirm) keep the dialog open so callers can render running state and close via onOpenChange; sync confirms let Radix auto-close. --- frontend/src/components/EditorLayout.tsx | 227 ++++++++++++----------- frontend/src/components/ui/modal.tsx | 156 ++++++++++++++-- 2 files changed, 261 insertions(+), 122 deletions(-) diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 50b43ebf..1fd43290 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -9,7 +9,7 @@ import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import LazyBoundary from './LazyBoundary'; import { Button } from './ui/button'; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; +import { ConfirmModal } from './ui/modal'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; @@ -1612,6 +1612,43 @@ export default function EditorLayout() { } }; + const bulkAffected = useMemo(() => { + if (!bulkActionLabel) return []; + return Object.entries(stackLabelMap) + .filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id)) + .map(([name]) => name); + }, [stackLabelMap, bulkActionLabel]); + + const runLabelBulkAction = async () => { + if (!bulkActionLabel) return; + setBulkActionRunning(true); + try { + const res = await apiFetch(`/labels/${bulkActionLabel.id}/action`, { + method: 'POST', + body: JSON.stringify({ action: bulkAction }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || `Bulk ${bulkAction} failed.`); + } + const data = await res.json(); + const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success); + if (failed.length > 0) { + const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', '); + toast.error(`Failed to ${bulkAction}: ${failedNames}`); + } else { + const successVerb = bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'; + toast.success(`All stacks ${successVerb} successfully.`); + } + setBulkActionOpen(false); + refreshStacks(true); + } catch (err: unknown) { + toast.error((err as Error)?.message || 'Something went wrong.'); + } finally { + setBulkActionRunning(false); + } + }; + // Context-menu-friendly stack actions (accept file name directly) const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => { if (isStackBusy(stackFile)) return; @@ -2617,116 +2654,88 @@ export default function EditorLayout() { - {/* Delete Confirmation Dialog */} - - - - Delete Stack - - Are you sure you want to delete {stackToDelete}? This action cannot be undone. - - -
- setPruneVolumesOnDelete(v === true)} - /> - -
- - setDeleteDialogOpen(false)}>Cancel - Delete - -
-
+ + Delete {stackToDelete}? + + ) : ( + 'Delete stack?' + ) + } + description={`Confirm deletion of ${stackToDelete ?? 'stack'}.`} + hint={pruneVolumesOnDelete ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'} + confirmLabel="Delete" + onConfirm={deleteStack} + > +

This action cannot be undone.

+
+ setPruneVolumesOnDelete(v === true)} + /> + +
+
- { if (!open) { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); } }}> - - - Unsaved Changes - - You have unsaved changes. Switching stacks will discard them. Continue? - - - - { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); }}>Cancel - { - const target = pendingUnsavedLoad; - const targetNode = pendingUnsavedNode; - // Reset content to original so the guard doesn't re-trigger - setContent(originalContent); - setEnvContent(originalEnvContent); - setPendingUnsavedLoad(null); - setPendingUnsavedNode(null); - if (target) { - if (targetNode) loadFileOnNode(targetNode, target); - else loadFile(target); - } - }}>Discard Changes - - - + { if (!open) { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); } }} + kicker="EDITOR · UNSAVED CHANGES" + title="Discard unsaved changes?" + description="You have unsaved changes. Switching stacks will discard them." + confirmLabel="Discard changes" + onConfirm={() => { + const target = pendingUnsavedLoad; + const targetNode = pendingUnsavedNode; + setContent(originalContent); + setEnvContent(originalEnvContent); + setPendingUnsavedLoad(null); + setPendingUnsavedNode(null); + if (target) { + if (targetNode) loadFileOnNode(targetNode, target); + else loadFile(target); + } + }} + > +

+ You have unsaved changes. Switching stacks will discard them. +

+
- - - - - {bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all “{bulkActionLabel?.name}” stacks? - - - This will {bulkAction} all stacks labeled “{bulkActionLabel?.name}”. - {stackLabelMap && bulkActionLabel && ( - - Affected: {Object.entries(stackLabelMap) - .filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id)) - .map(([name]) => name) - .join(', ') || 'none'} - - )} - - - - Cancel - { - e.preventDefault(); - if (!bulkActionLabel) return; - setBulkActionRunning(true); - try { - const res = await apiFetch(`/labels/${bulkActionLabel.id}/action`, { - method: 'POST', - body: JSON.stringify({ action: bulkAction }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data?.error || `Bulk ${bulkAction} failed.`); - } - const data = await res.json(); - const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success); - if (failed.length > 0) { - const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', '); - toast.error(`Failed to ${bulkAction}: ${failedNames}`); - } else { - toast.success(`All stacks ${bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'} successfully.`); - } - setBulkActionOpen(false); - refreshStacks(true); - } catch (err: unknown) { - toast.error((err as Error)?.message || 'Something went wrong.'); - } finally { - setBulkActionRunning(false); - } - }} - > - {bulkActionRunning ? 'Running...' : `${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} All`} - - - - + + {bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all{' '} + “{bulkActionLabel?.name ?? ''}” stacks? + + } + description={`${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all stacks labeled "${bulkActionLabel?.name ?? ''}".`} + hint={`${bulkAffected.length} AFFECTED`} + confirmLabel={bulkActionRunning ? 'Running...' : `${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all`} + confirming={bulkActionRunning} + onConfirm={runLabelBulkAction} + > +

+ This will {bulkAction || 'apply'} all stacks labeled “{bulkActionLabel?.name ?? ''}”. +

+ {bulkAffected.length > 0 && ( +

+ Affected: {bulkAffected.join(', ')} +

+ )} +
{/* Bash Exec Modal */} {selectedContainer && ( diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx index b6f41aee..c0d50a68 100644 --- a/frontend/src/components/ui/modal.tsx +++ b/frontend/src/components/ui/modal.tsx @@ -1,11 +1,20 @@ import * as React from 'react'; import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogTitle, } from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; const KICKER_CLASS = 'font-mono text-[10px] uppercase tracking-[0.22em]'; @@ -48,25 +57,40 @@ interface ModalHeaderBaseProps { description?: string; } +type HeaderVariant = 'default' | 'destructive'; + +const HEADER_VARIANT: Record = { + default: { rail: 'bg-brand', kicker: 'text-stat-subtitle' }, + destructive: { rail: 'bg-destructive', kicker: 'text-destructive' }, +}; + +interface HeaderShellProps extends ModalHeaderBaseProps { + variant: HeaderVariant; + TitleComponent: React.ElementType; + DescriptionComponent: React.ElementType; +} + function HeaderShell({ kicker, title, description, - railClassName, - kickerClassName, -}: ModalHeaderBaseProps & { railClassName: string; kickerClassName: string }) { + variant, + TitleComponent, + DescriptionComponent, +}: HeaderShellProps) { + const v = HEADER_VARIANT[variant]; return (
- -
+ +
{kicker}
- + {title} - - + + {description ?? (typeof title === 'string' ? title : kicker)} - +
); } @@ -75,8 +99,9 @@ export function ModalHeader(props: ModalHeaderBaseProps) { return ( ); } @@ -85,8 +110,31 @@ export function ModalDestructiveHeader(props: ModalHeaderBaseProps) { return ( + ); +} + +function ConfirmHeader(props: ModalHeaderBaseProps) { + return ( + + ); +} + +function ConfirmDestructiveHeader(props: ModalHeaderBaseProps) { + return ( + ); } @@ -120,3 +168,85 @@ export function ModalFooter({ primary, secondary, hint, hintAccent }: ModalFoote
); } + +type ConfirmSize = 'sm' | 'md'; + +interface ConfirmModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + variant?: 'default' | 'destructive'; + size?: ConfirmSize; + kicker: string; + title: React.ReactNode; + description?: string; + hint?: React.ReactNode; + confirmLabel: React.ReactNode; + cancelLabel?: React.ReactNode; + confirming?: boolean; + onConfirm: () => void | Promise; + onCancel?: () => void; + children?: React.ReactNode; +} + +export function ConfirmModal({ + open, + onOpenChange, + variant = 'default', + size = 'sm', + kicker, + title, + description, + hint, + confirmLabel, + cancelLabel = 'Cancel', + confirming = false, + onConfirm, + onCancel, + children, +}: ConfirmModalProps) { + const Header = variant === 'destructive' ? ConfirmDestructiveHeader : ConfirmHeader; + const cancelClass = buttonVariants({ variant: 'outline', size: 'sm' }); + const actionClass = buttonVariants({ + variant: variant === 'destructive' ? 'destructive' : 'default', + size: 'sm', + }); + + return ( + + +
+ {children !== undefined && {children}} + + {cancelLabel} + + } + primary={ + { + const result = onConfirm(); + // Async confirms keep the dialog open so the caller can render + // `confirming` state and close via onOpenChange when work completes. + // Sync confirms let Radix auto-close. + if (result instanceof Promise) { + e.preventDefault(); + void result; + } + }} + > + {confirmLabel} + + } + /> + + + ); +}