fix(frontend): sprint 5 post-code-review — dropdown close-on-outside + empty-state dropdown

- useEffect pointerdown + Escape listeners when dropdown open (NIT 1)
- empty state now renders NewSimulationDropdown instead of plain Link (NIT 2)
- 3 new Vitest: close-on-outside, close-on-Escape, empty-state has dropdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-28 07:02:34 +02:00
parent 20783118ee
commit 33a0ca30bb
2 changed files with 60 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { ChevronDown, Plus } from 'lucide-react'; import { ChevronDown, Plus } from 'lucide-react';
import { extractApiError } from '@/api/client'; import { extractApiError } from '@/api/client';
@@ -26,9 +26,27 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
const { push } = useToast(); const { push } = useToast();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const btnRef = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const createMutation = useCreateSimulation(engagementId); const createMutation = useCreateSimulation(engagementId);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
const handleBlank = () => { const handleBlank = () => {
setOpen(false); setOpen(false);
navigate(`/engagements/${engagementId}/simulations/new`); navigate(`/engagements/${engagementId}/simulations/new`);
@@ -51,7 +69,7 @@ function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.
}; };
return ( return (
<div className="relative" ref={btnRef}> <div className="relative" ref={ref}>
<div className="inline-flex"> <div className="inline-flex">
<button <button
type="button" type="button"
@@ -137,13 +155,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
description="Create the first simulation to start tracking red team tests." description="Create the first simulation to start tracking red team tests."
action={ action={
canEditEngagements ? ( canEditEngagements ? (
<Link <NewSimulationDropdown engagementId={engagementId} />
to={`/engagements/${engagementId}/simulations/new`}
className="btn-primary"
data-testid="new-simulation-btn"
>
New simulation
</Link>
) : undefined ) : undefined
} }
/> />

View File

@@ -142,6 +142,44 @@ describe('SimulationList — admin/redteam', () => {
}); });
}); });
it('closes dropdown on click outside', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const user = userEvent.setup();
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
expect(screen.getByTestId('from-template-btn')).toBeInTheDocument();
// Click outside the dropdown
await user.click(document.body);
expect(screen.queryByTestId('from-template-btn')).toBeNull();
});
it('closes dropdown on Escape key', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const user = userEvent.setup();
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByText('Lateral movement test')).toBeInTheDocument();
});
await user.click(screen.getByTestId('new-simulation-dropdown-toggle'));
expect(screen.getByTestId('from-template-btn')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByTestId('from-template-btn')).toBeNull();
});
it('shows dropdown in empty state (not a plain link)', async () => {
mock.onGet('/engagements/42/simulations').reply(200, []);
renderWithProviders(<SimulationList engagementId={42} />);
await waitFor(() => {
expect(screen.getByTestId('empty-state')).toBeInTheDocument();
});
// Must have the split-button dropdown, not a plain link
expect(screen.getByTestId('new-simulation-btn')).toBeInTheDocument();
expect(screen.getByTestId('new-simulation-dropdown-toggle')).toBeInTheDocument();
});
it('clicking a row uses SPA navigation and does not trigger window.location change', async () => { it('clicking a row uses SPA navigation and does not trigger window.location change', async () => {
mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS); mock.onGet('/engagements/42/simulations').reply(200, SIMULATIONS);
const originalHref = window.location.href; const originalHref = window.location.href;