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:
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user