refactor(frontend): extract dialog cluster from EditorLayout (#898)

Extract the two remaining inline ConfirmModal blocks at the bottom of
EditorLayout into their own modules under components/EditorLayout/,
matching the pattern established by CreateStackDialog (B4-2):

- DeleteStackDialog: takes open/onOpenChange/stackName/onConfirm; owns
  the prune-volumes checkbox state internally and resets it on close.
  EditorLayout's deleteStack handler now accepts pruneVolumes as a
  parameter instead of reading parent state.
- UnsavedChangesDialog: takes open/onCancel/onConfirm. The discard
  body is hoisted to a named handler in EditorLayout
  (discardAndLoadPending) so the dialog stays a thin presentational
  wrapper.

Also removes a dead label-bulk-action surface that had no live entry
point: bulkActionLabel and bulkAction were declared without setters,
and setBulkActionOpen(true) was never called from anywhere. The
multi-select bulk-stack-actions live elsewhere via useBulkStackActions
and SidebarBulkBar; this removed code was unrelated and unreachable.
Drops the BulkActionResult interface, four useState lines, the
bulkAffected useMemo, the runLabelBulkAction handler, and the inline
ConfirmModal.

EditorLayout.tsx: 2,852 -> 2,747 LOC (-105). useState count: 66 -> 61.
This commit is contained in:
Anso
2026-05-03 14:56:55 -04:00
committed by GitHub
parent d39de9acad
commit b3382d07a1
3 changed files with 104 additions and 131 deletions
+26 -131
View File
@@ -9,7 +9,6 @@ import type { NotificationItem } from './dashboard/types';
import BashExecModal from './BashExecModal';
import LazyBoundary from './LazyBoundary';
import { Button } from './ui/button';
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';
@@ -21,7 +20,6 @@ import { NotificationPanel } from './NotificationPanel';
import { apiFetch, fetchForNode } from '@/lib/api';
import { copyToClipboard } from '@/lib/clipboard';
import { toast } from '@/components/ui/toast-store';
import { Checkbox } from './ui/checkbox';
import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
@@ -30,6 +28,8 @@ import { cn } from '@/lib/utils';
import type { SectionId } from './settings/types';
import { ViewRouter } from './EditorLayout/ViewRouter';
import { CreateStackDialog } from './EditorLayout/CreateStackDialog';
import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog';
import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog';
import { StackAlertSheet } from './StackAlertSheet';
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
import { GitSourcePanel } from './stack/GitSourcePanel';
@@ -98,13 +98,6 @@ interface StackStatusInfo {
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
interface BulkActionResult {
stackName: string;
success: boolean;
error?: string;
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
@@ -254,7 +247,6 @@ export default function EditorLayout() {
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
const [pruneVolumesOnDelete, setPruneVolumesOnDelete] = useState(false);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -329,12 +321,6 @@ export default function EditorLayout() {
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
const [labels, setLabels] = useState<StackLabel[]>([]);
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
// Bulk-action dialog is retained as a safety fallback; the label pill entry
// point that drove the setters was removed alongside the sidebar rewrite.
const [bulkActionLabel] = useState<StackLabel | null>(null);
const [bulkAction] = useState<string>('');
const [bulkActionOpen, setBulkActionOpen] = useState(false);
const [bulkActionRunning, setBulkActionRunning] = useState(false);
// Bash exec modal state
const [bashModalOpen, setBashModalOpen] = useState(false);
@@ -1572,14 +1558,14 @@ export default function EditorLayout() {
}
};
const deleteStack = async () => {
const deleteStack = async (pruneVolumes: boolean) => {
if (!stackToDelete) return;
// Find matching file entry for per-stack tracking
const deleteKey = files.find(f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete) ?? stackToDelete;
if (isStackBusy(deleteKey)) return;
setStackAction(deleteKey, 'delete');
try {
const url = pruneVolumesOnDelete
const url = pruneVolumes
? `/stacks/${stackToDelete}?pruneVolumes=true`
: `/stacks/${stackToDelete}`;
const response = await apiFetch(url, {
@@ -1592,7 +1578,6 @@ export default function EditorLayout() {
toast.success('Stack deleted successfully!');
setDeleteDialogOpen(false);
setStackToDelete(null);
setPruneVolumesOnDelete(false);
if (selectedFile === stackToDelete) {
setSelectedFile(null);
setContent('');
@@ -1612,40 +1597,21 @@ 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 cancelPendingUnsavedLoad = () => {
setPendingUnsavedLoad(null);
setPendingUnsavedNode(null);
};
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);
const discardAndLoadPending = () => {
const target = pendingUnsavedLoad;
const targetNode = pendingUnsavedNode;
setContent(originalContent);
setEnvContent(originalEnvContent);
setPendingUnsavedLoad(null);
setPendingUnsavedNode(null);
if (target) {
if (targetNode) loadFileOnNode(targetNode, target);
else loadFile(target);
}
};
@@ -1893,7 +1859,7 @@ export default function EditorLayout() {
stop: () => executeStackActionByFile(file, 'stop', 'stop'),
restart: () => executeStackActionByFile(file, 'restart', 'restart'),
update: () => executeStackActionByFile(file, 'update', 'update'),
remove: () => { setStackToDelete(stackName); setPruneVolumesOnDelete(false); setDeleteDialogOpen(true); },
remove: () => { setStackToDelete(stackName); setDeleteDialogOpen(true); },
pin: () => pin(file),
unpin: () => unpin(file),
setAutoUpdateEnabled: async (enabled: boolean) => {
@@ -2248,7 +2214,6 @@ export default function EditorLayout() {
disabled={loadingAction !== null}
onClick={() => {
setStackToDelete(selectedFile);
setPruneVolumesOnDelete(false);
setDeleteDialogOpen(true);
}}
>
@@ -2654,88 +2619,18 @@ export default function EditorLayout() {
</div>
</div>
<ConfirmModal
<DeleteStackDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
variant="destructive"
kicker={`${(stackToDelete ?? 'STACK').toUpperCase()} · REMOVE · IRREVERSIBLE`}
title={
stackToDelete ? (
<>
Delete <em className="font-display italic text-destructive">{stackToDelete}</em>?
</>
) : (
'Delete stack?'
)
}
description={`Confirm deletion of ${stackToDelete ?? 'stack'}.`}
hint={pruneVolumesOnDelete ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'}
confirmLabel="Delete"
stackName={stackToDelete}
onConfirm={deleteStack}
>
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
<div className="flex items-center gap-2">
<Checkbox
id="prune-volumes"
checked={pruneVolumesOnDelete}
onCheckedChange={(v) => setPruneVolumesOnDelete(v === true)}
/>
<label htmlFor="prune-volumes" className="text-sm text-muted-foreground cursor-pointer select-none">
Also remove associated volumes
</label>
</div>
</ConfirmModal>
/>
<ConfirmModal
<UnsavedChangesDialog
open={!!pendingUnsavedLoad}
onOpenChange={(open) => { 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);
}
}}
>
<p className="text-sm text-muted-foreground">
You have unsaved changes. Switching stacks will discard them.
</p>
</ConfirmModal>
<ConfirmModal
open={bulkActionOpen}
onOpenChange={setBulkActionOpen}
kicker={`LABEL · ${(bulkAction || 'ACTION').toUpperCase()} ALL`}
title={
<>
{bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all{' '}
<em className="font-display italic">&ldquo;{bulkActionLabel?.name ?? ''}&rdquo;</em> 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}
>
<p className="text-sm text-muted-foreground">
This will {bulkAction || 'apply'} all stacks labeled &ldquo;{bulkActionLabel?.name ?? ''}&rdquo;.
</p>
{bulkAffected.length > 0 && (
<p className="font-mono text-xs text-stat-subtitle">
Affected: {bulkAffected.join(', ')}
</p>
)}
</ConfirmModal>
onCancel={cancelPendingUnsavedLoad}
onConfirm={discardAndLoadPending}
/>
{/* Bash Exec Modal */}
{selectedContainer && (
@@ -0,0 +1,53 @@
import { useState } from 'react';
import { ConfirmModal } from '../ui/modal';
import { Checkbox } from '../ui/checkbox';
export interface DeleteStackDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
stackName: string | null;
onConfirm: (pruneVolumes: boolean) => void | Promise<void>;
}
export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }: DeleteStackDialogProps) {
const [pruneVolumes, setPruneVolumes] = useState(false);
const handleOpenChange = (next: boolean) => {
if (!next) setPruneVolumes(false);
onOpenChange(next);
};
return (
<ConfirmModal
open={open}
onOpenChange={handleOpenChange}
variant="destructive"
kicker={`${(stackName ?? 'STACK').toUpperCase()} · REMOVE · IRREVERSIBLE`}
title={
stackName ? (
<>
Delete <em className="font-display italic text-destructive">{stackName}</em>?
</>
) : (
'Delete stack?'
)
}
description={`Confirm deletion of ${stackName ?? 'stack'}.`}
hint={pruneVolumes ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'}
confirmLabel="Delete"
onConfirm={() => onConfirm(pruneVolumes)}
>
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
<div className="flex items-center gap-2">
<Checkbox
id="prune-volumes"
checked={pruneVolumes}
onCheckedChange={(v) => setPruneVolumes(v === true)}
/>
<label htmlFor="prune-volumes" className="text-sm text-muted-foreground cursor-pointer select-none">
Also remove associated volumes
</label>
</div>
</ConfirmModal>
);
}
@@ -0,0 +1,25 @@
import { ConfirmModal } from '../ui/modal';
export interface UnsavedChangesDialogProps {
open: boolean;
onCancel: () => void;
onConfirm: () => void;
}
export function UnsavedChangesDialog({ open, onCancel, onConfirm }: UnsavedChangesDialogProps) {
return (
<ConfirmModal
open={open}
onOpenChange={(next) => { if (!next) onCancel(); }}
kicker="EDITOR · UNSAVED CHANGES"
title="Discard unsaved changes?"
description="You have unsaved changes. Switching stacks will discard them."
confirmLabel="Discard changes"
onConfirm={onConfirm}
>
<p className="text-sm text-muted-foreground">
You have unsaved changes. Switching stacks will discard them.
</p>
</ConfirmModal>
);
}