Skip to content

Commit c3b08dd

Browse files
CoderCococlaude
andauthored
feat(web): confirmation flow for destructive actions (#120)
Closes #67 ## Summary - New `ConfirmDialog` component wrapping shadcn `AlertDialog` with two optional modes: type-to-confirm input (blocks Confirm until text matches) and "don't ask again" session checkbox - New `lib/confirm-skip.ts` session store: `isSuppressed(key)` / `suppress(key)` backed by a module-level `Set` — resets on hard reload, persists across SPA navigation - **Stop server** (`GameCard`): simple confirm with "Don't ask again for this session"; suppressed path fires `api.stop()` directly - **Remove allowed guild** (`GuildsSection`): type-to-confirm — user must type the guild ID exactly to enable Confirm; no "don't ask again" - **Clear per-game permissions** (`PermissionRow`): simple confirm with "don't ask again" - **Remove admin user/role ID** (`AdminsSection`): simple confirm with "don't ask again"; added `onRemoveChip` prop to `SnowflakeChipsInput` so Backspace-to-pop also routes through confirmation - 20 new tests across `ConfirmDialog.test.tsx`, `GameCard.test.tsx`, and `DiscordPage.test.tsx`; 335 total passing ## Implementation notes - The "don't ask again" state is intentionally in-memory (module-level `Set`) — not `localStorage` — so it resets on hard reload per the spec. SPA navigation leaves the module loaded, so suppression persists across routes. - ESC, focus-trap, and `prefers-reduced-motion` are handled automatically by the Radix `AlertDialog` primitive — no custom code needed for AC#4. - Admin removal confirms before local-state update, not before the API call (the API call is deferred to "Save admins" per the existing `AdminsSection` draft-editor design). The dialog description reflects this. - **Merge note**: if `issue-114` (file naming convention) lands after this PR, the new files (`confirm-skip.ts`, `ConfirmDialog.tsx`, `GameCard.test.tsx`) will need to be renamed to the kebab-case + type-suffix convention. ## Test plan - [ ] `npm run app:test` — 335 tests pass - [ ] `npm run app:lint` — exits clean - [ ] Manual: click Stop on a running server → dialog appears; check "Don't ask again" and confirm → subsequent clicks skip dialog until page reload - [ ] Manual: Remove an allowed guild → Confirm disabled until guild ID is typed exactly; confirm → guild removed - [ ] Manual: Clear per-game permissions → confirm → permissions cleared; cancel → no change - [ ] Manual: X a chip in Admin User/Role IDs → dialog appears; confirm → chip removed (API call deferred to Save admins) --- _Generated with [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e53bc37 commit c3b08dd

7 files changed

Lines changed: 679 additions & 184 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { ConfirmDialog } from './confirm-dialog.component.js';
5+
import { isSuppressed } from '../lib/confirm-skip.utils.js';
6+
7+
const mockStore = new Set<string>();
8+
9+
vi.mock('../lib/confirm-skip.utils.js', () => ({
10+
isSuppressed: (key: string) => mockStore.has(key),
11+
suppress: (key: string) => mockStore.add(key),
12+
}));
13+
14+
function open(overrides: Partial<Parameters<typeof ConfirmDialog>[0]> = {}) {
15+
const onConfirm = vi.fn();
16+
const onOpenChange = vi.fn();
17+
render(
18+
<ConfirmDialog
19+
open
20+
onOpenChange={onOpenChange}
21+
title="Delete this?"
22+
description="This cannot be undone."
23+
onConfirm={onConfirm}
24+
{...overrides}
25+
/>,
26+
);
27+
return { onConfirm, onOpenChange };
28+
}
29+
30+
describe('ConfirmDialog', () => {
31+
beforeEach(() => {
32+
mockStore.clear();
33+
});
34+
35+
it('should render the title and description', () => {
36+
open();
37+
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
38+
expect(screen.getByText('Delete this?')).toBeInTheDocument();
39+
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument();
40+
});
41+
42+
it('should call onConfirm when the confirm button is clicked', async () => {
43+
const { onConfirm } = open();
44+
await userEvent.click(screen.getByRole('button', { name: /confirm/i }));
45+
expect(onConfirm).toHaveBeenCalledOnce();
46+
});
47+
48+
it('should not call onConfirm when cancel is clicked', async () => {
49+
const { onConfirm, onOpenChange } = open();
50+
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
51+
expect(onConfirm).not.toHaveBeenCalled();
52+
expect(onOpenChange).toHaveBeenCalledWith(false);
53+
});
54+
55+
it('should disable Confirm until the typed value matches typeToConfirm', async () => {
56+
open({ typeToConfirm: 'abc123' });
57+
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
58+
expect(confirmBtn).toBeDisabled();
59+
60+
await userEvent.type(screen.getByRole('textbox'), 'abc123');
61+
expect(confirmBtn).not.toBeDisabled();
62+
});
63+
64+
it('should suppress the key and call onConfirm when "don\'t ask again" is checked', async () => {
65+
const { onConfirm } = open({ confirmKey: 'test-key' });
66+
await userEvent.click(screen.getByRole('checkbox'));
67+
await userEvent.click(screen.getByRole('button', { name: /confirm/i }));
68+
expect(onConfirm).toHaveBeenCalledOnce();
69+
expect(isSuppressed('test-key')).toBe(true);
70+
});
71+
72+
it('should not suppress the key when "don\'t ask again" is NOT checked', async () => {
73+
open({ confirmKey: 'other-key' });
74+
await userEvent.click(screen.getByRole('button', { name: /confirm/i }));
75+
expect(isSuppressed('other-key')).toBe(false);
76+
});
77+
78+
it('should use a custom confirmLabel when provided', () => {
79+
open({ confirmLabel: 'Yes, stop it' });
80+
expect(screen.getByRole('button', { name: 'Yes, stop it' })).toBeInTheDocument();
81+
});
82+
});
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { useState, useEffect } from 'react';
2+
import {
3+
AlertDialog,
4+
AlertDialogContent,
5+
AlertDialogHeader,
6+
AlertDialogTitle,
7+
AlertDialogDescription,
8+
AlertDialogFooter,
9+
AlertDialogCancel,
10+
AlertDialogAction,
11+
} from '@/components/ui/alert-dialog.component';
12+
import { Input } from '@/components/ui/input.component';
13+
import { suppress } from '../lib/confirm-skip.utils.js';
14+
15+
interface ConfirmDialogProps {
16+
open: boolean;
17+
onOpenChange: (open: boolean) => void;
18+
title: string;
19+
description: string;
20+
onConfirm: () => void;
21+
/** Label for the confirm button. Defaults to "Confirm". */
22+
confirmLabel?: string;
23+
/**
24+
* When provided, shows a "Don't ask again for this session" checkbox.
25+
* The key is stored in the module-level session store so the caller can
26+
* bypass opening the dialog on future clicks via `isSuppressed(confirmKey)`.
27+
*/
28+
confirmKey?: string;
29+
/**
30+
* When provided, shows a text input; the confirm button stays disabled
31+
* until the typed value exactly matches this string (e.g. a guild ID).
32+
*/
33+
typeToConfirm?: string;
34+
}
35+
36+
/**
37+
* Generic confirmation dialog for destructive actions. Wraps shadcn AlertDialog
38+
* with optional type-to-confirm input and "don't ask again" session suppression.
39+
* ESC, focus-trap, and reduce-motion are handled by Radix automatically.
40+
*/
41+
export function ConfirmDialog({
42+
open,
43+
onOpenChange,
44+
title,
45+
description,
46+
onConfirm,
47+
confirmLabel = 'Confirm',
48+
confirmKey,
49+
typeToConfirm,
50+
}: ConfirmDialogProps) {
51+
const [typed, setTyped] = useState('');
52+
const [skipSession, setSkipSession] = useState(false);
53+
54+
useEffect(() => {
55+
if (!open) {
56+
setTyped('');
57+
setSkipSession(false);
58+
}
59+
}, [open]);
60+
61+
const confirmDisabled = typeToConfirm !== undefined && typed !== typeToConfirm;
62+
63+
function handleConfirm() {
64+
if (confirmKey && skipSession) suppress(confirmKey);
65+
onConfirm();
66+
}
67+
68+
return (
69+
<AlertDialog open={open} onOpenChange={onOpenChange}>
70+
<AlertDialogContent>
71+
<AlertDialogHeader>
72+
<AlertDialogTitle>{title}</AlertDialogTitle>
73+
<AlertDialogDescription>{description}</AlertDialogDescription>
74+
</AlertDialogHeader>
75+
76+
{typeToConfirm !== undefined && (
77+
<Input
78+
value={typed}
79+
onChange={(e) => setTyped(e.target.value)}
80+
placeholder={typeToConfirm}
81+
className="font-[var(--font-mono)]"
82+
aria-label="Type to confirm"
83+
/>
84+
)}
85+
86+
{confirmKey && (
87+
<label className="flex items-center gap-2 text-sm text-[var(--color-muted-foreground)] cursor-pointer">
88+
<input
89+
type="checkbox"
90+
checked={skipSession}
91+
onChange={(e) => setSkipSession(e.target.checked)}
92+
className="size-3.5 rounded"
93+
/>
94+
Don&apos;t ask again for this session
95+
</label>
96+
)}
97+
98+
<AlertDialogFooter>
99+
<AlertDialogCancel>Cancel</AlertDialogCancel>
100+
<AlertDialogAction onClick={handleConfirm} disabled={confirmDisabled}>
101+
{confirmLabel}
102+
</AlertDialogAction>
103+
</AlertDialogFooter>
104+
</AlertDialogContent>
105+
</AlertDialog>
106+
);
107+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { MemoryRouter } from 'react-router-dom';
5+
import { GameCard } from './game-card.component.js';
6+
7+
const apiMock = vi.hoisted(() => ({
8+
stop: vi.fn().mockResolvedValue(undefined),
9+
start: vi.fn().mockResolvedValue(undefined),
10+
}));
11+
vi.mock('../api.service.js', () => ({ api: apiMock }));
12+
13+
const mockIsSuppressed = vi.hoisted(() => vi.fn().mockReturnValue(false));
14+
vi.mock('../lib/confirm-skip.utils.js', () => ({
15+
isSuppressed: mockIsSuppressed,
16+
suppress: vi.fn(),
17+
}));
18+
19+
/** A minimal running-server status fixture. */
20+
const runningStatus = {
21+
game: 'minecraft',
22+
state: 'running' as const,
23+
};
24+
25+
function renderCard() {
26+
return render(
27+
<MemoryRouter>
28+
<GameCard
29+
status={runningStatus}
30+
onRefresh={vi.fn()}
31+
onOpenFiles={vi.fn()}
32+
/>
33+
</MemoryRouter>,
34+
);
35+
}
36+
37+
describe('GameCard — Stop confirmation', () => {
38+
it('should show the confirmation dialog when Stop is clicked and the action is not suppressed', async () => {
39+
mockIsSuppressed.mockReturnValue(false);
40+
renderCard();
41+
42+
await userEvent.click(screen.getByRole('button', { name: /stop/i }));
43+
44+
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
45+
expect(screen.getByText('Stop minecraft?')).toBeInTheDocument();
46+
});
47+
48+
it('should call api.stop directly without showing the dialog when the action is suppressed', async () => {
49+
mockIsSuppressed.mockReturnValue(true);
50+
renderCard();
51+
52+
await userEvent.click(screen.getByRole('button', { name: /stop/i }));
53+
54+
expect(apiMock.stop).toHaveBeenCalledWith('minecraft');
55+
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
56+
});
57+
58+
it('should call api.stop after the user confirms the dialog', async () => {
59+
mockIsSuppressed.mockReturnValue(false);
60+
renderCard();
61+
62+
await userEvent.click(screen.getByRole('button', { name: /stop/i }));
63+
await userEvent.click(screen.getByRole('button', { name: /stop server/i }));
64+
65+
expect(apiMock.stop).toHaveBeenCalledWith('minecraft');
66+
});
67+
68+
it('should not call api.stop when the user cancels the dialog', async () => {
69+
mockIsSuppressed.mockReturnValue(false);
70+
renderCard();
71+
72+
await userEvent.click(screen.getByRole('button', { name: /stop/i }));
73+
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
74+
75+
expect(apiMock.stop).not.toHaveBeenCalled();
76+
});
77+
});

0 commit comments

Comments
 (0)