mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +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:
@@ -1,8 +1,8 @@
|
||||
import { Suspense } from 'react';
|
||||
import { DiffEditor } from '@/lib/monacoLoader';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BusyButton } from '@/components/ui/busy-button';
|
||||
import type { ComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel';
|
||||
|
||||
export interface ComposeDiffPreviewDialogProps {
|
||||
@@ -75,16 +75,13 @@ export function ComposeDiffPreviewDialog({
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<Button
|
||||
<BusyButton
|
||||
size="sm"
|
||||
pending={confirming}
|
||||
onClick={() => onConfirm()}
|
||||
disabled={confirming}
|
||||
>
|
||||
{confirming && (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
)}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</BusyButton>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -1056,6 +1056,8 @@ export default function EditorLayout() {
|
||||
<ShellOverlays
|
||||
overlayState={overlayState}
|
||||
stackActions={stackActions}
|
||||
stackActionMap={stackActionMap}
|
||||
stackFiles={files}
|
||||
isDarkMode={isDarkMode}
|
||||
isAdmin={isAdmin}
|
||||
can={can}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Modal, ModalDestructiveHeader, ModalBody, ModalFooter } from '@/components/ui/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { BusyButton } from '@/components/ui/busy-button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
@@ -69,6 +70,7 @@ export function DeleteFileConfirm({
|
||||
const protectedOk = !isProtected || confirmInput === entryName;
|
||||
|
||||
const deleteLabel = notEmpty ? 'Delete all' : 'Delete';
|
||||
const busyDeleteLabel = notEmpty ? 'Deleting all...' : 'Deleting...';
|
||||
|
||||
const titleNode = entryName ? (
|
||||
<>
|
||||
@@ -130,18 +132,17 @@ export function DeleteFileConfirm({
|
||||
</Button>
|
||||
}
|
||||
primary={
|
||||
<Button
|
||||
<BusyButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
data-testid="delete-confirm-btn"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting || !protectedOk}
|
||||
pending={deleting}
|
||||
busyLabel={busyDeleteLabel}
|
||||
disabled={!protectedOk}
|
||||
>
|
||||
{deleting && (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
)}
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
</BusyButton>
|
||||
}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, act } from '@testing-library/react';
|
||||
import { BusyButton } from '../busy-button';
|
||||
import { DURATION_BASE_MS } from '@/hooks/useVisualBusy';
|
||||
|
||||
describe('BusyButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('disables immediately when pending without showing spinner yet', () => {
|
||||
render(
|
||||
<BusyButton pending busyLabel="Saving...">
|
||||
Save
|
||||
</BusyButton>,
|
||||
);
|
||||
const btn = screen.getByRole('button', { name: /Save/i });
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveAttribute('aria-busy', 'true');
|
||||
expect(btn.querySelector('.animate-spin')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows spinner and progressive label after the visual delay', () => {
|
||||
render(
|
||||
<BusyButton pending busyLabel="Saving...">
|
||||
Save
|
||||
</BusyButton>,
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Saving/i })).toBeDisabled();
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('signals busy via aria-busy and label without requiring spin animation', () => {
|
||||
render(
|
||||
<BusyButton pending busyLabel="Deleting...">
|
||||
Delete
|
||||
</BusyButton>,
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
const btn = screen.getByRole('button', { name: /Deleting/i });
|
||||
expect(btn).toHaveAttribute('aria-busy', 'true');
|
||||
expect(btn).toHaveTextContent('Deleting...');
|
||||
// Spinner is enhancement only; the accessible name and aria-busy are primary.
|
||||
expect(btn.getAttribute('aria-busy')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { ConfirmModal } from '../modal';
|
||||
import { DURATION_BASE_MS } from '@/hooks/useVisualBusy';
|
||||
|
||||
function renderConfirm(props: Partial<ComponentProps<typeof ConfirmModal>> = {}) {
|
||||
const onOpenChange = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
kicker="TEST"
|
||||
title="Confirm?"
|
||||
confirmLabel="Delete"
|
||||
busyConfirmLabel="Deleting..."
|
||||
onConfirm={onConfirm}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onOpenChange, onConfirm };
|
||||
}
|
||||
|
||||
describe('ConfirmModal busy behaviour', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the dialog open for async onConfirm', async () => {
|
||||
let resolveConfirm!: () => void;
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
resolveConfirm = resolve;
|
||||
});
|
||||
const onConfirm = vi.fn(() => pending);
|
||||
const onOpenChange = vi.fn();
|
||||
|
||||
render(
|
||||
<ConfirmModal
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
kicker="TEST"
|
||||
title="Confirm?"
|
||||
confirmLabel="Delete"
|
||||
onConfirm={onConfirm}
|
||||
confirming={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
// Radix would otherwise close; preventDefault keeps it open so parent can set confirming.
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
resolveConfirm();
|
||||
await act(async () => {
|
||||
await pending;
|
||||
});
|
||||
});
|
||||
|
||||
it('disables only Confirm when confirmDisabled is set without confirming', () => {
|
||||
renderConfirm({ confirmDisabled: true, confirming: false });
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables both Confirm and Cancel when confirming', () => {
|
||||
renderConfirm({ confirming: true });
|
||||
expect(screen.getByRole('button', { name: /Delete/i })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not call onOpenChange(false) on Escape while confirming', async () => {
|
||||
const { onOpenChange } = renderConfirm({ confirming: true });
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('shows progressive busy label after the delay when confirming', async () => {
|
||||
renderConfirm({ confirming: true });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Deleting/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -93,10 +93,12 @@ AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayNam
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
>(({ className, asChild, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
asChild={asChild}
|
||||
// When asChild, child (e.g. BusyButton) owns buttonVariants; do not stack defaults.
|
||||
className={cn(asChild ? undefined : buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Button, type ButtonProps } from '@/components/ui/button';
|
||||
import { useVisualBusy } from '@/hooks/useVisualBusy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type BusyButtonProps = ButtonProps & {
|
||||
/** Immediate interaction lock. Disables the control while true. */
|
||||
pending: boolean;
|
||||
/**
|
||||
* Progressive label once delayed visual busy is shown. When omitted, idle
|
||||
* children stay visible and only the spinner (if shown) appears.
|
||||
*/
|
||||
busyLabel?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Composition over {@link Button} for async click surfaces.
|
||||
* Never use `asChild` on this component: busy chrome is multi-child content.
|
||||
*/
|
||||
export const BusyButton = React.forwardRef<HTMLButtonElement, BusyButtonProps>(
|
||||
({ pending, busyLabel, children, disabled, className, type = 'button', ...props }, ref) => {
|
||||
const { showBusy } = useVisualBusy(pending);
|
||||
const useTwinLabels =
|
||||
typeof children === 'string' &&
|
||||
typeof busyLabel === 'string';
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
type={type}
|
||||
disabled={disabled || pending}
|
||||
aria-busy={pending || undefined}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{pending ? (
|
||||
<span
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
{showBusy ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.5} />
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{useTwinLabels ? (
|
||||
<span className="relative inline-grid justify-items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'col-start-1 row-start-1',
|
||||
showBusy && busyLabel != null && 'invisible',
|
||||
)}
|
||||
aria-hidden={showBusy && busyLabel != null ? true : undefined}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'col-start-1 row-start-1',
|
||||
!(showBusy && busyLabel != null) && 'invisible',
|
||||
)}
|
||||
aria-hidden={!(showBusy && busyLabel != null) ? true : undefined}
|
||||
>
|
||||
{busyLabel}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<>{showBusy && busyLabel != null ? busyLabel : children}</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
);
|
||||
BusyButton.displayName = 'BusyButton';
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { BusyButton } from '@/components/ui/busy-button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -206,6 +207,12 @@ interface ConfirmModalProps {
|
||||
description?: string;
|
||||
hint?: React.ReactNode;
|
||||
confirmLabel: React.ReactNode;
|
||||
/**
|
||||
* Progressive confirm copy shown only after delayed visual busy
|
||||
* (see BusyButton / useVisualBusy). Prefer static confirmLabel + this over
|
||||
* swapping confirmLabel when confirming flips true.
|
||||
*/
|
||||
busyConfirmLabel?: React.ReactNode;
|
||||
cancelLabel?: React.ReactNode;
|
||||
confirming?: boolean;
|
||||
/** When true, the confirm action stays disabled (e.g. plan still loading). */
|
||||
@@ -225,6 +232,7 @@ export function ConfirmModal({
|
||||
description,
|
||||
hint,
|
||||
confirmLabel,
|
||||
busyConfirmLabel,
|
||||
cancelLabel = 'Cancel',
|
||||
confirming = false,
|
||||
confirmDisabled = false,
|
||||
@@ -234,14 +242,17 @@ export function ConfirmModal({
|
||||
}: ConfirmModalProps) {
|
||||
const Header = variant === 'destructive' ? ConfirmDestructiveHeader : ConfirmHeader;
|
||||
const cancelClass = buttonVariants({ variant: 'outline', size: 'sm' });
|
||||
const actionClass = buttonVariants({
|
||||
variant: variant === 'destructive' ? 'destructive' : 'default',
|
||||
size: 'sm',
|
||||
});
|
||||
const actionVariant = variant === 'destructive' ? 'destructive' : 'default';
|
||||
const actionDisabled = confirming || confirmDisabled;
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
// Block Esc / overlay dismiss while the confirm action owns the request.
|
||||
if (!next && confirming) return;
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<AlertDialogContent className={cn('flex max-h-[85dvh] flex-col p-0 gap-0 overflow-hidden border-card-border/60', SIZE_CLASS[size])}>
|
||||
<Header kicker={kicker} title={title} description={description} />
|
||||
{children !== undefined && <ModalBody fill>{children}</ModalBody>}
|
||||
@@ -257,21 +268,28 @@ export function ConfirmModal({
|
||||
</AlertDialogCancel>
|
||||
}
|
||||
primary={
|
||||
<AlertDialogAction
|
||||
className={actionClass}
|
||||
disabled={actionDisabled}
|
||||
onClick={(e) => {
|
||||
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}
|
||||
// BusyButton never uses asChild; Radix owns asChild on the Action
|
||||
// shell so the multi-child busy chrome stays on the real button.
|
||||
<AlertDialogAction asChild disabled={actionDisabled}>
|
||||
<BusyButton
|
||||
variant={actionVariant}
|
||||
size="sm"
|
||||
pending={confirming}
|
||||
busyLabel={busyConfirmLabel}
|
||||
disabled={actionDisabled}
|
||||
onClick={(e) => {
|
||||
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}
|
||||
</BusyButton>
|
||||
</AlertDialogAction>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { DURATION_BASE_MS, useVisualBusy } from '../useVisualBusy';
|
||||
|
||||
describe('useVisualBusy', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not show busy while not pending', () => {
|
||||
const { result } = renderHook(() => useVisualBusy(false));
|
||||
expect(result.current.locked).toBe(false);
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('never shows busy if pending ends before the delay', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pending }) => useVisualBusy(pending),
|
||||
{ initialProps: { pending: true } },
|
||||
);
|
||||
expect(result.current.locked).toBe(true);
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS - 1);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
rerender({ pending: false });
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
expect(result.current.locked).toBe(false);
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('shows busy after continuous pending through the delay', () => {
|
||||
const { result } = renderHook(() => useVisualBusy(true));
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
expect(result.current.locked).toBe(true);
|
||||
expect(result.current.showBusy).toBe(true);
|
||||
});
|
||||
|
||||
it('clears showBusy when pending becomes false', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pending }) => useVisualBusy(pending),
|
||||
{ initialProps: { pending: true } },
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(true);
|
||||
|
||||
rerender({ pending: false });
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
});
|
||||
|
||||
it('does not update state after unmount mid-delay', () => {
|
||||
const { unmount } = renderHook(() => useVisualBusy(true));
|
||||
unmount();
|
||||
expect(() => {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS);
|
||||
});
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('restarts the delay on rapid pending toggles', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ pending }) => useVisualBusy(pending),
|
||||
{ initialProps: { pending: true } },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS - 50);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
rerender({ pending: false });
|
||||
rerender({ pending: true });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(DURATION_BASE_MS - 50);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(false);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(50);
|
||||
});
|
||||
expect(result.current.showBusy).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Keep in sync with `--duration-base` in `frontend/src/index.css`.
|
||||
* Default gate before showing delayed busy chrome (spinner / progressive label).
|
||||
*/
|
||||
export const DURATION_BASE_MS = 220;
|
||||
|
||||
/**
|
||||
* Split interaction lock from delayed visual busy so fast ops never flash chrome.
|
||||
*
|
||||
* - `locked` tracks the raw `pending` flag (caller uses this to disable controls).
|
||||
* - `showBusy` becomes true only after `pending` stays true for `delayMs`.
|
||||
*/
|
||||
export function useVisualBusy(
|
||||
pending: boolean,
|
||||
delayMs: number = DURATION_BASE_MS,
|
||||
): { locked: boolean; showBusy: boolean } {
|
||||
const [showBusy, setShowBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
setShowBusy(false);
|
||||
return;
|
||||
}
|
||||
setShowBusy(false);
|
||||
const id = window.setTimeout(() => {
|
||||
setShowBusy(true);
|
||||
}, delayMs);
|
||||
return () => {
|
||||
window.clearTimeout(id);
|
||||
};
|
||||
}, [pending, delayMs]);
|
||||
|
||||
return { locked: pending, showBusy };
|
||||
}
|
||||
Reference in New Issue
Block a user