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>
2026-05-27 04:04:23 +02:00
|
|
|
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
2026-05-26 11:13:14 +02:00
|
|
|
import { extractApiError } from '@/api/client';
|
|
|
|
|
import type { MitreTechnique } from '@/api/types';
|
|
|
|
|
import { useMitreSearch } from '@/hooks/useMitre';
|
|
|
|
|
|
|
|
|
|
interface MitreTechniquePickerProps {
|
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>
2026-05-27 04:04:23 +02:00
|
|
|
onSelect: (technique: MitreTechnique) => void;
|
2026-05-26 11:13:14 +02:00
|
|
|
disabled?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const DEBOUNCE_MS = 200;
|
|
|
|
|
|
|
|
|
|
export function MitreTechniquePicker({
|
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>
2026-05-27 04:04:23 +02:00
|
|
|
onSelect,
|
2026-05-26 11:13:14 +02:00
|
|
|
disabled = false,
|
|
|
|
|
}: MitreTechniquePickerProps): JSX.Element {
|
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>
2026-05-27 04:04:23 +02:00
|
|
|
const [inputValue, setInputValue] = useState('');
|
2026-05-26 11:13:14 +02:00
|
|
|
const [query, setQuery] = useState('');
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
|
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const listRef = useRef<HTMLUListElement>(null);
|
|
|
|
|
|
|
|
|
|
const { data: results, isFetching, isError, error } = useMitreSearch(query, open);
|
|
|
|
|
|
|
|
|
|
const items = results ?? [];
|
|
|
|
|
|
|
|
|
|
const handleInputChange = (value: string) => {
|
|
|
|
|
setInputValue(value);
|
|
|
|
|
setOpen(true);
|
|
|
|
|
setActiveIndex(-1);
|
|
|
|
|
|
|
|
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
|
|
|
debounceRef.current = setTimeout(() => {
|
|
|
|
|
setQuery(value);
|
|
|
|
|
}, DEBOUNCE_MS);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const selectItem = (item: MitreTechnique) => {
|
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>
2026-05-27 04:04:23 +02:00
|
|
|
onSelect(item);
|
|
|
|
|
// Reset to empty after selection — parent handles append + dedup
|
|
|
|
|
setInputValue('');
|
|
|
|
|
setQuery('');
|
2026-05-26 11:13:14 +02:00
|
|
|
setOpen(false);
|
|
|
|
|
setActiveIndex(-1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
|
|
|
if (!open || items.length === 0) {
|
|
|
|
|
if (e.key === 'Escape') setOpen(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setActiveIndex((i) => Math.min(i + 1, items.length - 1));
|
|
|
|
|
} else if (e.key === 'ArrowUp') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setActiveIndex((i) => Math.max(i - 1, 0));
|
|
|
|
|
} else if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (activeIndex >= 0 && items[activeIndex]) {
|
|
|
|
|
selectItem(items[activeIndex]);
|
|
|
|
|
}
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
setActiveIndex(-1);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (activeIndex >= 0 && listRef.current) {
|
|
|
|
|
const el = listRef.current.children[activeIndex] as HTMLElement | undefined;
|
|
|
|
|
el?.scrollIntoView?.({ block: 'nearest' });
|
|
|
|
|
}
|
|
|
|
|
}, [activeIndex]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const onPointerDown = (e: PointerEvent) => {
|
|
|
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('pointerdown', onPointerDown);
|
|
|
|
|
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const listboxId = 'mitre-picker-listbox';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div ref={containerRef} className="relative">
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
role="combobox"
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
aria-controls={listboxId}
|
|
|
|
|
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
|
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>
2026-05-27 04:04:23 +02:00
|
|
|
aria-label="Search MITRE technique"
|
2026-05-26 11:13:14 +02:00
|
|
|
className="text-input"
|
|
|
|
|
value={inputValue}
|
|
|
|
|
onChange={(e) => handleInputChange(e.target.value)}
|
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>
2026-05-27 04:04:23 +02:00
|
|
|
onFocus={() => setOpen(true)}
|
2026-05-26 11:13:14 +02:00
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
placeholder="Search by ID or name (e.g. T1059)"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{open && (
|
2026-06-11 11:12:07 +02:00
|
|
|
<div className="absolute z-20 w-full mt-xxs bg-paper border border-steel rounded-none overflow-hidden">
|
2026-05-26 11:13:14 +02:00
|
|
|
{isFetching && (
|
|
|
|
|
<div className="px-md py-sm text-[14px] text-graphite">Searching…</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isError && !isFetching && (
|
|
|
|
|
<div className="px-md py-sm text-[14px] text-bloom-deep" role="alert">
|
|
|
|
|
{extractApiError(error, 'MITRE search unavailable')}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!isFetching && !isError && items.length === 0 && query.trim().length > 0 && (
|
|
|
|
|
<div className="px-md py-sm text-[14px] text-graphite">No results</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!isFetching && items.length > 0 && (
|
|
|
|
|
<ul
|
|
|
|
|
id={listboxId}
|
|
|
|
|
ref={listRef}
|
|
|
|
|
role="listbox"
|
|
|
|
|
aria-label="MITRE techniques"
|
|
|
|
|
className="max-h-64 overflow-y-auto"
|
|
|
|
|
>
|
|
|
|
|
{items.map((item, i) => (
|
|
|
|
|
<li
|
|
|
|
|
key={item.id}
|
|
|
|
|
id={`mitre-option-${i}`}
|
|
|
|
|
role="option"
|
|
|
|
|
aria-selected={i === activeIndex}
|
|
|
|
|
className={`px-md py-sm text-[14px] cursor-pointer select-none ${
|
|
|
|
|
i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud'
|
|
|
|
|
}`}
|
|
|
|
|
onPointerDown={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
selectItem(item);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-09 18:42:26 +02:00
|
|
|
<span className="font-mono font-medium">{item.id}</span>
|
2026-05-26 11:13:14 +02:00
|
|
|
<span className="text-charcoal"> — {item.name}</span>
|
|
|
|
|
{item.tactics.length > 0 && (
|
2026-06-09 18:42:26 +02:00
|
|
|
<span className="font-mono text-graphite"> ({item.tactics[0]})</span>
|
2026-05-26 11:13:14 +02:00
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|