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:
@@ -1,55 +1,26 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { MitreTechnique } from '@/api/types';
|
||||
import { useMitreSearch } from '@/hooks/useMitre';
|
||||
|
||||
interface MitreTechniquePickerProps {
|
||||
techniqueId: string | null;
|
||||
techniqueName: string | null;
|
||||
onChange: (id: string | null, name: string | null) => void;
|
||||
onSelect: (technique: MitreTechnique) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function formatOption(t: MitreTechnique): string {
|
||||
const tacticList = t.tactics.length > 0 ? ` (${t.tactics[0]})` : '';
|
||||
return `${t.id} — ${t.name}${tacticList}`;
|
||||
}
|
||||
|
||||
const DEBOUNCE_MS = 200;
|
||||
|
||||
export function MitreTechniquePicker({
|
||||
techniqueId,
|
||||
techniqueName,
|
||||
onChange,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
}: MitreTechniquePickerProps): JSX.Element {
|
||||
const [inputValue, setInputValue] = useState(
|
||||
techniqueId && techniqueName ? `${techniqueId} — ${techniqueName}` : '',
|
||||
);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
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);
|
||||
// True once we've synced the first real techniqueId from props (parent/API load).
|
||||
// After that we stop reacting to null, so keystrokes that emit onChange(null,null)
|
||||
// don't propagate back and wipe the input mid-stroke.
|
||||
const hasHydratedFromProps = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (techniqueId && techniqueName) {
|
||||
setInputValue(`${techniqueId} — ${techniqueName}`);
|
||||
hasHydratedFromProps.current = true;
|
||||
} else if (!techniqueId && !hasHydratedFromProps.current) {
|
||||
setInputValue('');
|
||||
}
|
||||
}, [techniqueId, techniqueName]);
|
||||
|
||||
const { data: results, isFetching, isError, error } = useMitreSearch(query, open);
|
||||
|
||||
@@ -57,8 +28,6 @@ export function MitreTechniquePicker({
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setInputValue(value);
|
||||
// Clear the selection when user starts typing
|
||||
onChange(null, null);
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
|
||||
@@ -69,11 +38,12 @@ export function MitreTechniquePicker({
|
||||
};
|
||||
|
||||
const selectItem = (item: MitreTechnique) => {
|
||||
setInputValue(formatOption(item));
|
||||
onChange(item.id, item.name);
|
||||
onSelect(item);
|
||||
// Reset to empty after selection — parent handles append + dedup
|
||||
setInputValue('');
|
||||
setQuery('');
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
@@ -98,7 +68,6 @@ export function MitreTechniquePicker({
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll active item into view
|
||||
useEffect(() => {
|
||||
if (activeIndex >= 0 && listRef.current) {
|
||||
const el = listRef.current.children[activeIndex] as HTMLElement | undefined;
|
||||
@@ -106,7 +75,6 @@ export function MitreTechniquePicker({
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
@@ -127,13 +95,11 @@ export function MitreTechniquePicker({
|
||||
aria-expanded={open}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? `mitre-option-${activeIndex}` : undefined}
|
||||
aria-label="MITRE technique"
|
||||
aria-label="Search MITRE technique"
|
||||
className="text-input"
|
||||
value={inputValue}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
if (!techniqueId) setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
placeholder="Search by ID or name (e.g. T1059)"
|
||||
@@ -174,7 +140,6 @@ export function MitreTechniquePicker({
|
||||
i === activeIndex ? 'bg-primary-soft text-ink' : 'text-ink hover:bg-cloud'
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
// Prevent input blur before we handle the click
|
||||
e.preventDefault();
|
||||
selectItem(item);
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user