Files
mimic/frontend/src/components/SimulationList.tsx
Knacky 33a0ca30bb 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>
2026-05-28 07:02:34 +02:00

227 lines
7.6 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ChevronDown, Plus } from 'lucide-react';
import { extractApiError } from '@/api/client';
import type { SimulationTemplate } from '@/api/types';
import { useAuth } from '@/hooks/useAuth';
import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
import { useToast } from '@/hooks/useToast';
import { LoadingState } from './LoadingState';
import { ErrorState } from './ErrorState';
import { EmptyState } from './EmptyState';
import { SimulationStatusBadge } from './SimulationStatusBadge';
import { TemplatePickerModal } from './TemplatePickerModal';
interface SimulationListProps {
engagementId: number;
}
function formatDate(value: string | null): string {
if (!value) return '—';
return value.replace('T', ' ').slice(0, 16);
}
function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element {
const navigate = useNavigate();
const { push } = useToast();
const [open, setOpen] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const ref = useRef<HTMLDivElement>(null);
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 = () => {
setOpen(false);
navigate(`/engagements/${engagementId}/simulations/new`);
};
const handleFromTemplate = () => {
setOpen(false);
setShowPicker(true);
};
const handleSelectTemplate = async (template: SimulationTemplate) => {
try {
const sim = await createMutation.mutateAsync({ name: template.name, template_id: template.id });
setShowPicker(false);
push(`Created "${sim.name}" from template`, 'success');
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`);
} catch (err) {
push(extractApiError(err, 'Could not create simulation from template'), 'error');
}
};
return (
<div className="relative" ref={ref}>
<div className="inline-flex">
<button
type="button"
className="btn-primary rounded-r-none border-r border-primary-deep"
onClick={handleBlank}
data-testid="new-simulation-btn"
>
<Plus size={14} aria-hidden /> New
</button>
<button
type="button"
aria-label="More options"
aria-expanded={open}
className="btn-primary rounded-l-none px-sm"
onClick={() => setOpen((v) => !v)}
data-testid="new-simulation-dropdown-toggle"
>
<ChevronDown size={14} aria-hidden />
</button>
</div>
{open ? (
<div
className="absolute right-0 top-full mt-xxs bg-canvas border border-hairline rounded-md shadow-floating dark:shadow-floating-dark z-20 min-w-[180px]"
role="menu"
>
<button
type="button"
role="menuitem"
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog"
onClick={handleBlank}
>
Blank
</button>
<button
type="button"
role="menuitem"
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog"
onClick={handleFromTemplate}
data-testid="from-template-btn"
>
From template
</button>
</div>
) : null}
{showPicker ? (
<TemplatePickerModal
engagementId={engagementId}
onClose={() => setShowPicker(false)}
onInstantiated={(simId) => {
setShowPicker(false);
navigate(`/engagements/${engagementId}/simulations/${simId}/edit`);
}}
onSelectTemplate={handleSelectTemplate}
isPending={createMutation.isPending}
/>
) : null}
</div>
);
}
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
const { canEditEngagements } = useAuth();
const navigate = useNavigate();
if (isLoading) return <LoadingState label="Loading simulations…" />;
if (isError) {
return (
<ErrorState
message={extractApiError(error, 'Could not load simulations')}
onRetry={() => refetch()}
/>
);
}
if (!data || data.length === 0) {
return (
<EmptyState
title="No simulations yet"
description="Create the first simulation to start tracking red team tests."
action={
canEditEngagements ? (
<NewSimulationDropdown engagementId={engagementId} />
) : undefined
}
/>
);
}
return (
<div className="flex flex-col gap-md">
<div className="flex items-center justify-between">
<h2 className="text-[24px] font-medium text-ink">Simulations</h2>
{canEditEngagements ? (
<NewSimulationDropdown engagementId={engagementId} />
) : null}
</div>
<div className="card-product overflow-hidden p-0">
<table className="w-full text-left">
<thead className="bg-cloud border-b border-hairline">
<tr className="text-[12px] uppercase tracking-[0.5px] text-graphite">
<th className="px-xl py-md">Name</th>
<th className="px-xl py-md">MITRE</th>
<th className="px-xl py-md">Status</th>
<th className="px-xl py-md">Executed at</th>
</tr>
</thead>
<tbody>
{data.map((sim) => (
<tr
key={sim.id}
className="border-b border-hairline last:border-0 hover:bg-cloud cursor-pointer"
onClick={() =>
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
}
>
<td className="px-xl py-md">
<Link
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
className="text-ink font-medium hover:underline"
onClick={(e) => e.stopPropagation()}
>
{sim.name}
</Link>
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{(() => {
const items = [
...(sim.tactics ?? []).map((t) => t.id),
...sim.techniques.map((t) => t.id),
];
if (items.length === 0) return '—';
if (items.length === 1) return items[0];
return `${items[0]} +${items.length - 1}`;
})()}
</td>
<td className="px-xl py-md">
<SimulationStatusBadge status={sim.status} />
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{formatDate(sim.executed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}