feat(frontend): sprint 3 — multi-technique MITRE selection + matrix modal
- types: replace mitre_technique_id/name scalars with techniques:MitreTechnique[] on Simulation; add MitreTactic/MitreMatrixTechnique/MitreMatrixSubtechnique; SimulationPatchInput now uses technique_ids:string[] - api/mitre.ts: add getMitreMatrix() → GET /api/mitre/matrix - hooks/useMitre: add useMitreMatrix(enabled) with staleTime:Infinity - MitreTechniquePicker: clean rewrite — onSelect(technique) one-shot, resets input after selection, no incoming value props - MitreTechniqueTag: chip component with id+name and × remove button - MitreMatrixModal: tactic columns (220px fixed), expand/collapse subtechniques, search filter (auto-expands parent on sub match), selection state, focus trap (Tab wrap, Escape, search autofocus), backdrop click cancel, Apply N techniques - MitreTechniquesField: orchestrates tags+picker+matrix with auto-save PATCH on every add/remove/Apply, dedup guard, disabled read-only mode for SOC - SimulationFormPage: swap MitreTechniquePicker for MitreTechniquesField; remove technique state from RT form (techniques have independent auto-save cycle) - SimulationList: MITRE column → T1059 +2 counter format, — when empty - Tests: 84 passing (13 test files); new suites for Tag, Field, Modal; MitreTechniquePicker + SimulationFormPage + SimulationList adapted to new API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
344
frontend/src/components/MitreMatrixModal.tsx
Normal file
344
frontend/src/components/MitreMatrixModal.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useMitreMatrix } from '@/hooks/useMitre';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
|
||||
interface MitreMatrixModalProps {
|
||||
isOpen: boolean;
|
||||
initialSelection: MitreTechnique[];
|
||||
onApply: (selection: MitreTechnique[]) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function techniqueInTactic(
|
||||
tacticTechniques: { id: string; subtechniques: { id: string }[] }[],
|
||||
selection: Set<string>,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (const t of tacticTechniques) {
|
||||
if (selection.has(t.id)) count++;
|
||||
for (const s of t.subtechniques) {
|
||||
if (selection.has(s.id)) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function MitreMatrixModal({
|
||||
isOpen,
|
||||
initialSelection,
|
||||
onApply,
|
||||
onCancel,
|
||||
}: MitreMatrixModalProps): JSX.Element | null {
|
||||
const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen);
|
||||
|
||||
// Selected IDs → Map id → {id, name} for reconstruct
|
||||
const [selectedMap, setSelectedMap] = useState<Map<string, { id: string; name: string }>>(
|
||||
() => new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])),
|
||||
);
|
||||
const [expandedTechniques, setExpandedTechniques] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Reset local state when modal opens with new initialSelection
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedMap(new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])));
|
||||
setExpandedTechniques(new Set());
|
||||
setSearch('');
|
||||
}
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Focus search input on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Small delay lets the DOM render before focus
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Escape closes modal
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [isOpen, onCancel]);
|
||||
|
||||
const getFocusableElements = () => {
|
||||
if (!containerRef.current) return [];
|
||||
return Array.from(
|
||||
containerRef.current.querySelectorAll<HTMLElement>(
|
||||
'a, button, input, [tabindex]:not([tabindex="-1"])',
|
||||
),
|
||||
).filter((el) => !(el as HTMLButtonElement | HTMLInputElement).disabled && !el.hidden && el.tabIndex !== -1);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
const focusables = getFocusableElements();
|
||||
if (focusables.length === 0) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggleTechnique = (id: string, name: string) => {
|
||||
setSelectedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.set(id, { id, name });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedTechniques((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const searchLower = search.toLowerCase().trim();
|
||||
|
||||
// Figure out which technique IDs should be auto-expanded due to a sub-technique match
|
||||
const autoExpanded = new Set<string>();
|
||||
if (searchLower && matrix) {
|
||||
for (const tactic of matrix) {
|
||||
for (const tech of tactic.techniques) {
|
||||
const subMatch = tech.subtechniques.some(
|
||||
(s) => s.id.toLowerCase().includes(searchLower) || s.name.toLowerCase().includes(searchLower),
|
||||
);
|
||||
if (subMatch) autoExpanded.add(tech.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
// Reconstruct MitreTechnique[] from selected IDs.
|
||||
// tactics are not available here; parent will use what it has or send []
|
||||
const selection: MitreTechnique[] = Array.from(selectedMap.values()).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
tactics: [],
|
||||
}));
|
||||
onApply(selection);
|
||||
};
|
||||
|
||||
const totalSelected = selectedMap.size;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/60"
|
||||
onClick={onCancel}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Modal container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="matrix-modal-title"
|
||||
className="relative bg-canvas rounded-xl shadow-elevated max-w-[95vw] max-h-[85vh] overflow-hidden flex flex-col"
|
||||
style={{ width: '1200px' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-xl py-md border-b border-hairline flex-shrink-0">
|
||||
<h2 id="matrix-modal-title" className="text-[20px] font-medium text-ink">
|
||||
MITRE ATT&CK Matrix
|
||||
</h2>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Filter techniques…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="text-input w-64"
|
||||
aria-label="Filter techniques"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-auto px-xl py-md">
|
||||
{isLoading && <LoadingState label="Loading MITRE matrix…" />}
|
||||
{isError && (
|
||||
<ErrorState
|
||||
message={extractApiError(error, 'Could not load MITRE matrix')}
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !isError && matrix && (
|
||||
<div className="flex gap-sm" style={{ minWidth: 'max-content' }}>
|
||||
{matrix.map((tactic) => {
|
||||
const selectedCount = techniqueInTactic(tactic.techniques, new Set(selectedMap.keys()));
|
||||
|
||||
// Filter techniques for this tactic
|
||||
const visibleTechniques = tactic.techniques.filter((tech) => {
|
||||
if (!searchLower) return true;
|
||||
const techMatch =
|
||||
tech.id.toLowerCase().includes(searchLower) ||
|
||||
tech.name.toLowerCase().includes(searchLower);
|
||||
const subMatch = tech.subtechniques.some(
|
||||
(s) =>
|
||||
s.id.toLowerCase().includes(searchLower) ||
|
||||
s.name.toLowerCase().includes(searchLower),
|
||||
);
|
||||
return techMatch || subMatch;
|
||||
});
|
||||
|
||||
if (visibleTechniques.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tactic.tactic_id}
|
||||
className="flex-shrink-0"
|
||||
style={{ width: '220px' }}
|
||||
>
|
||||
{/* Tactic header */}
|
||||
<div className="bg-cloud rounded-t-md px-sm py-xs border border-hairline border-b-0">
|
||||
<div className="text-[11px] uppercase tracking-[0.5px] text-graphite font-medium leading-none">
|
||||
{tactic.tactic_name}
|
||||
</div>
|
||||
{selectedCount > 0 && (
|
||||
<div className="text-[11px] text-primary-deep font-medium mt-xxs">
|
||||
{selectedCount} selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Techniques */}
|
||||
<div className="border border-hairline rounded-b-md overflow-hidden">
|
||||
{visibleTechniques.map((tech, techIdx) => {
|
||||
const isSelected = selectedMap.has(tech.id);
|
||||
const isExpanded = expandedTechniques.has(tech.id) || autoExpanded.has(tech.id);
|
||||
const hasSubtechniques = tech.subtechniques.length > 0;
|
||||
const isLast = techIdx === visibleTechniques.length - 1;
|
||||
|
||||
// Filter subtechniques when searching
|
||||
const visibleSubs = searchLower
|
||||
? tech.subtechniques.filter(
|
||||
(s) =>
|
||||
s.id.toLowerCase().includes(searchLower) ||
|
||||
s.name.toLowerCase().includes(searchLower),
|
||||
)
|
||||
: tech.subtechniques;
|
||||
|
||||
return (
|
||||
<div key={tech.id} className={!isLast ? 'border-b border-hairline' : ''}>
|
||||
{/* Technique row */}
|
||||
<div
|
||||
className={`flex items-start px-sm py-xs text-[13px] ${
|
||||
isSelected ? 'bg-primary text-canvas' : 'bg-canvas text-ink hover:bg-cloud'
|
||||
}`}
|
||||
>
|
||||
{/* Chevron — expand/collapse, does NOT toggle selection */}
|
||||
{hasSubtechniques ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isExpanded ? `Collapse ${tech.id}` : `Expand ${tech.id}`}
|
||||
onClick={() => toggleExpand(tech.id)}
|
||||
className={`mr-xxs flex-shrink-0 text-[11px] w-4 leading-none mt-[1px] ${
|
||||
isSelected ? 'text-canvas' : 'text-graphite'
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? '▾' : '▸'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="mr-xxs w-4 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Label — click toggles selection */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTechnique(tech.id, tech.name)}
|
||||
className={`text-left leading-snug flex-1 min-w-0 ${
|
||||
isSelected ? 'text-canvas' : 'text-ink'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{tech.id}</span>
|
||||
<br />
|
||||
<span className={isSelected ? 'text-canvas/80' : 'text-charcoal'}>
|
||||
{tech.name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtechniques — shown when expanded */}
|
||||
{isExpanded &&
|
||||
visibleSubs.map((sub) => {
|
||||
const isSubSelected = selectedMap.has(sub.id);
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
onClick={() => toggleTechnique(sub.id, sub.name)}
|
||||
className={`w-full text-left pl-md pr-sm py-xxs text-[12px] border-t border-hairline leading-snug ${
|
||||
isSubSelected
|
||||
? 'bg-primary-soft text-primary-deep'
|
||||
: 'bg-cloud text-charcoal hover:bg-fog'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium">{sub.id}</span>
|
||||
{' — '}
|
||||
{sub.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-md px-xl py-md border-t border-hairline flex-shrink-0">
|
||||
<button type="button" className="btn-outline-ink" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleApply}
|
||||
disabled={isLoading || isError}
|
||||
>
|
||||
Apply {totalSelected > 0 ? `${totalSelected} technique${totalSelected !== 1 ? 's' : ''}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user