feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
import { useRef, useState } from 'react';
|
2026-05-26 11:22:05 +02:00
|
|
|
import { Link, useNavigate } from 'react-router-dom';
|
2026-05-28 06:50:19 +02:00
|
|
|
import { ChevronDown, Plus } from 'lucide-react';
|
2026-05-26 11:13:14 +02:00
|
|
|
import { extractApiError } from '@/api/client';
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
import type { SimulationTemplate } from '@/api/types';
|
2026-05-26 11:13:14 +02:00
|
|
|
import { useAuth } from '@/hooks/useAuth';
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
import { useEngagementSimulations, useCreateSimulation } from '@/hooks/useSimulations';
|
|
|
|
|
import { useToast } from '@/hooks/useToast';
|
2026-05-26 11:13:14 +02:00
|
|
|
import { LoadingState } from './LoadingState';
|
|
|
|
|
import { ErrorState } from './ErrorState';
|
|
|
|
|
import { EmptyState } from './EmptyState';
|
|
|
|
|
import { SimulationStatusBadge } from './SimulationStatusBadge';
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
import { TemplatePickerModal } from './TemplatePickerModal';
|
2026-05-26 11:13:14 +02:00
|
|
|
|
|
|
|
|
interface SimulationListProps {
|
|
|
|
|
engagementId: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(value: string | null): string {
|
|
|
|
|
if (!value) return '—';
|
|
|
|
|
return value.replace('T', ' ').slice(0, 16);
|
|
|
|
|
}
|
|
|
|
|
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
function NewSimulationDropdown({ engagementId }: { engagementId: number }): JSX.Element {
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const { push } = useToast();
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [showPicker, setShowPicker] = useState(false);
|
|
|
|
|
const btnRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const createMutation = useCreateSimulation(engagementId);
|
|
|
|
|
|
|
|
|
|
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={btnRef}>
|
|
|
|
|
<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"
|
|
|
|
|
>
|
2026-05-28 06:50:19 +02:00
|
|
|
<Plus size={14} aria-hidden /> New
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
</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
|
2026-05-28 06:50:19 +02:00
|
|
|
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]"
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
role="menu"
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
role="menuitem"
|
2026-05-28 06:50:19 +02:00
|
|
|
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog"
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
onClick={handleBlank}
|
|
|
|
|
>
|
|
|
|
|
Blank
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
role="menuitem"
|
2026-05-28 06:50:19 +02:00
|
|
|
className="w-full text-left px-md py-sm text-[14px] text-ink hover:bg-cloud dark:hover:bg-fog"
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 11:13:14 +02:00
|
|
|
export function SimulationList({ engagementId }: SimulationListProps): JSX.Element {
|
|
|
|
|
const { data, isLoading, isError, error, refetch } = useEngagementSimulations(engagementId);
|
|
|
|
|
const { canEditEngagements } = useAuth();
|
2026-05-26 11:22:05 +02:00
|
|
|
const navigate = useNavigate();
|
2026-05-26 11:13:14 +02:00
|
|
|
|
|
|
|
|
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 ? (
|
|
|
|
|
<Link
|
|
|
|
|
to={`/engagements/${engagementId}/simulations/new`}
|
|
|
|
|
className="btn-primary"
|
|
|
|
|
data-testid="new-simulation-btn"
|
|
|
|
|
>
|
2026-05-26 16:08:46 +02:00
|
|
|
New simulation
|
2026-05-26 11:13:14 +02:00
|
|
|
</Link>
|
|
|
|
|
) : 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 ? (
|
feat(frontend): sprint 5 — templates CRUD pages + nav + picker modal + dropdown
- types.ts: SimulationTemplate, SimulationTemplateCreateInput, SimulationTemplatePatchInput,
extend SimulationCreateInput with template_id
- api/templates.ts: listTemplates, getTemplate, createTemplate, updateTemplate, deleteTemplate
- hooks/useTemplates.ts: useTemplates, useTemplate, useCreateTemplate, useUpdateTemplate,
useDeleteTemplate (TanStack Query, invalidates ["templates"])
- TemplatesListPage: /admin/templates — table (name, MITRE count, created by, updated),
New/Edit/Delete actions, loading/error/empty states
- TemplateFormPage: /admin/templates/new + /admin/templates/:id/edit — controlled form
with inline MITRE field (picker + matrix modal), ConfirmDialog for delete
- TemplatePickerModal: reusable modal listing templates with empty state (AC-27.6)
- SimulationList: replace "New simulation" link with split-button dropdown
(Blank → /simulations/new | From template… → TemplatePickerModal + POST template_id)
- Layout: "Templates" nav link (admin | redteam, before "Users")
- App.tsx: /admin/templates routes gated roles=["admin","redteam"]
- 26 new Vitest tests (118 total, 92 original preserved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 06:36:10 +02:00
|
|
|
<NewSimulationDropdown engagementId={engagementId} />
|
2026-05-26 11:13:14 +02:00
|
|
|
) : 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={() =>
|
2026-05-26 11:22:05 +02:00
|
|
|
navigate(`/engagements/${engagementId}/simulations/${sim.id}/edit`)
|
2026-05-26 11:13:14 +02:00
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<td className="px-xl py-md">
|
|
|
|
|
<Link
|
|
|
|
|
to={`/engagements/${engagementId}/simulations/${sim.id}/edit`}
|
|
|
|
|
className="text-ink font-medium hover:underline"
|
2026-05-27 04:22:23 +02:00
|
|
|
onClick={(e) => e.stopPropagation()}
|
2026-05-26 11:13:14 +02:00
|
|
|
>
|
|
|
|
|
{sim.name}
|
|
|
|
|
</Link>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-xl py-md text-charcoal text-[14px]">
|
feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment
US-18: done status fully read-only + Reopen button (done → review_required) for all roles
US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation
US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look
US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids
US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=)
US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence
92/92 tests passing, typecheck and lint clean.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 20:06:01 +02:00
|
|
|
{(() => {
|
|
|
|
|
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}`;
|
|
|
|
|
})()}
|
2026-05-26 11:13:14 +02:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|