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, ): 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>( () => new Map(initialSelection.map((t) => [t.id, { id: t.id, name: t.name }])), ); const [expandedTechniques, setExpandedTechniques] = useState>(new Set()); const [search, setSearch] = useState(''); const containerRef = useRef(null); const searchInputRef = useRef(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( '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(); 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 (
{/* Backdrop */} ); }