mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user