fix(ui): show delayed busy feedback on confirm actions (#1763)

Wire ConfirmModal and BusyButton so async confirms lock immediately, show
spinner and progressive labels after duration-base, and block dismiss mid-flight.
Connect stack delete and take-down to the existing stackAction map so the dialog
is not idle until the toast.
This commit is contained in:
Anso
2026-08-03 22:14:24 -04:00
committed by GitHub
parent 0ba09ebdee
commit 5d89a10754
18 changed files with 509 additions and 46 deletions
@@ -7,9 +7,17 @@ export interface DeleteStackDialogProps {
onOpenChange: (open: boolean) => void;
stackName: string | null;
onConfirm: (pruneVolumes: boolean) => void | Promise<void>;
/** True while the stack delete request owns the flow (from stackActionMap). */
confirming?: boolean;
}
export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }: DeleteStackDialogProps) {
export function DeleteStackDialog({
open,
onOpenChange,
stackName,
onConfirm,
confirming = false,
}: DeleteStackDialogProps) {
const [pruneVolumes, setPruneVolumes] = useState(false);
const handleOpenChange = (next: boolean) => {
@@ -42,6 +50,8 @@ export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }:
description={`Confirm deletion of ${stackName ?? 'stack'}.`}
hint={pruneVolumes ? 'VOLUMES PRUNED' : 'VOLUMES KEPT'}
confirmLabel="Delete"
busyConfirmLabel="Deleting..."
confirming={confirming}
onConfirm={() => onConfirm(pruneVolumes)}
>
<p className="text-sm text-muted-foreground">This action cannot be undone.</p>
@@ -49,6 +59,7 @@ export function DeleteStackDialog({ open, onOpenChange, stackName, onConfirm }:
<Checkbox
id="prune-volumes"
checked={pruneVolumes}
disabled={confirming}
onCheckedChange={(v) => setPruneVolumes(v === true)}
/>
<label htmlFor="prune-volumes" className="text-sm text-muted-foreground cursor-pointer select-none">
@@ -19,10 +19,16 @@ import type { OverlayState } from './hooks/useOverlayState';
import type { StackActionsHook } from './hooks/useStackActions';
import type { PermissionAction } from '@/context/AuthContext';
import type { useComposeReapplyAction } from '../FleetView/hooks/useComposeReapplyAction';
import type { StackAction } from './EditorView';
import { resolveStackFileKey } from './hooks/resolveStackFileKey';
interface ShellOverlaysProps {
overlayState: OverlayState;
stackActions: StackActionsHook;
/** Filename-keyed busy map from stack list state (not the stackActions hook). */
stackActionMap: Record<string, StackAction>;
/** Stack filenames for resolveStackFileKey (overlay holds bare names). */
stackFiles: string[];
isDarkMode: boolean;
isAdmin: boolean;
can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean;
@@ -41,6 +47,8 @@ interface ShellOverlaysProps {
export function ShellOverlays({
overlayState,
stackActions,
stackActionMap,
stackFiles,
isDarkMode,
isAdmin,
can,
@@ -72,6 +80,13 @@ export function ShellOverlays({
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
} = overlayState;
const isDeleteConfirming =
stackToDelete != null &&
stackActionMap[resolveStackFileKey(stackFiles, stackToDelete)] === 'delete';
const isTakeDownConfirming =
stackToTakeDown != null &&
stackActionMap[resolveStackFileKey(stackFiles, stackToTakeDown)] === 'down';
return (
<>
<DeleteStackDialog
@@ -79,6 +94,7 @@ export function ShellOverlays({
onOpenChange={(open) => { if (!open) closeDeleteDialog(); }}
stackName={stackToDelete}
onConfirm={stackActions.deleteStack}
confirming={isDeleteConfirming}
/>
<TakeDownStackDialog
@@ -87,6 +103,7 @@ export function ShellOverlays({
stackName={stackToTakeDown}
showVolumeOption={canOfferVolumeRemoval}
onConfirm={stackActions.takeDownStack}
confirming={isTakeDownConfirming}
/>
<SelfStackProtectedDialog
@@ -8,6 +8,8 @@ export interface TakeDownStackDialogProps {
stackName: string | null;
showVolumeOption: boolean;
onConfirm: (removeVolumes: boolean) => void | Promise<void>;
/** True while the take-down request owns the flow (from stackActionMap). */
confirming?: boolean;
}
export function TakeDownStackDialog({
@@ -16,6 +18,7 @@ export function TakeDownStackDialog({
stackName,
showVolumeOption,
onConfirm,
confirming = false,
}: TakeDownStackDialogProps) {
const [removeVolumes, setRemoveVolumes] = useState(false);
@@ -30,7 +33,6 @@ export function TakeDownStackDialog({
open={open}
onOpenChange={onOpenChange}
variant="destructive"
data-testid="take-down-dialog"
kicker={`${(stackName ?? 'STACK').toUpperCase()} · TAKE DOWN${removeVolumes ? '' : ' · REVERSIBLE'}`}
title={
stackName ? (
@@ -44,6 +46,8 @@ export function TakeDownStackDialog({
description="This removes running containers and compose-created networks. The stack configuration stays on disk so you can deploy again later."
hint={removeVolumes ? 'VOLUMES REMOVED' : 'VOLUMES KEPT'}
confirmLabel="Take down"
busyConfirmLabel="Taking down..."
confirming={confirming}
onConfirm={() => onConfirm(removeVolumes)}
>
{showVolumeOption && (
@@ -52,6 +56,7 @@ export function TakeDownStackDialog({
id="take-down-remove-volumes"
data-testid="take-down-remove-volumes"
checked={removeVolumes}
disabled={confirming}
onCheckedChange={(v) => setRemoveVolumes(v === true)}
/>
<label
@@ -28,4 +28,20 @@ describe('DeleteStackDialog', () => {
expect(screen.getByTitle(LONG_STACK_NAME)).toHaveTextContent(LONG_STACK_NAME);
});
it('disables Delete, Cancel, and volume checkbox while confirming', () => {
render(
<DeleteStackDialog
open
onOpenChange={vi.fn()}
stackName="web"
onConfirm={vi.fn()}
confirming
/>,
);
expect(screen.getByRole('button', { name: /Delete/i })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
expect(screen.getByRole('checkbox')).toBeDisabled();
});
});
@@ -88,4 +88,11 @@ describe('TakeDownStackDialog', () => {
await user.click(screen.getByRole('button', { name: 'Take down' }));
expect(onConfirm).toHaveBeenCalledWith(false);
});
it('disables Take down, Cancel, and volume checkbox while confirming', () => {
renderDialog(true, { confirming: true });
expect(screen.getByRole('button', { name: /Take down/i })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
expect(screen.getByTestId('take-down-remove-volumes')).toBeDisabled();
});
});
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { resolveStackFileKey } from '../resolveStackFileKey';
describe('resolveStackFileKey', () => {
const files = ['web.yml', 'api.yaml', 'plain'];
it('returns an exact filename match', () => {
expect(resolveStackFileKey(files, 'web.yml')).toBe('web.yml');
});
it('matches a bare name against a .yml file', () => {
expect(resolveStackFileKey(files, 'web')).toBe('web.yml');
});
it('matches a bare name against a .yaml file', () => {
expect(resolveStackFileKey(files, 'api')).toBe('api.yaml');
});
it('passes through when no match exists', () => {
expect(resolveStackFileKey(files, 'missing')).toBe('missing');
});
});
@@ -0,0 +1,12 @@
/**
* Map an overlay stack name (bare name or filename) to the key used in
* `stackActionMap` / stack file lists. The map is filename-keyed (e.g. `web.yml`);
* overlay state often holds the bare name (`web`).
*/
export function resolveStackFileKey(files: string[], name: string): string {
return (
files.find(
(f) => f === name || f.replace(/\.(yml|yaml)$/, '') === name,
) ?? name
);
}
@@ -30,6 +30,7 @@ import type {
MissingExternalNetworksPayload,
} from '../../stack/MissingExternalNetworksDialog';
import type { PreDeployScanImage } from '@/types/security';
import { resolveStackFileKey } from './resolveStackFileKey';
interface RunResult {
ok: boolean;
@@ -1948,10 +1949,7 @@ export function useStackActions(options: UseStackActionsOptions) {
const deleteStack = async (pruneVolumes: boolean) => {
const stackToDelete = overlayState.stackToDelete;
if (!stackToDelete) return;
const deleteKey =
stackListState.files.find(
f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete,
) ?? stackToDelete;
const deleteKey = resolveStackFileKey(stackListState.files, stackToDelete);
const canonicalName = deleteKey.replace(/\.(yml|yaml)$/, '');
if (stackListState.isStackBusy(deleteKey)) return;
stackListState.setStackAction(deleteKey, 'delete');
@@ -2010,10 +2008,7 @@ export function useStackActions(options: UseStackActionsOptions) {
overlayState.closeTakeDownDialog();
return;
}
const stackFile =
stackListState.files.find(
f => f === stackToTakeDown || f.replace(/\.(yml|yaml)$/, '') === stackToTakeDown,
) ?? stackToTakeDown;
const stackFile = resolveStackFileKey(stackListState.files, stackToTakeDown);
if (stackListState.isStackBusy(stackFile)) return;
if (openSelfStackProtectedIfNeeded(stackFile)) return;