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, MitreTacticRef } from '@/api/types'; export interface MatrixSelection { techniques: MitreTechnique[]; tactics: MitreTacticRef[]; } interface MitreMatrixModalProps { isOpen: boolean; initialTechniques: MitreTechnique[]; initialTactics: MitreTacticRef[]; onApply: (selection: MatrixSelection) => void; onCancel: () => void; } function countSelected( techniques: { id: string; subtechniques: { id: string }[] }[], techMap: Set, tacticId: string, tacticMap: Set, ): number { let count = tacticMap.has(tacticId) ? 1 : 0; for (const t of techniques) { if (techMap.has(t.id)) count++; for (const s of t.subtechniques) { if (techMap.has(s.id)) count++; } } return count; } export function MitreMatrixModal({ isOpen, initialTechniques, initialTactics, onApply, onCancel, }: MitreMatrixModalProps): JSX.Element | null { const { data: matrix, isLoading, isError, error } = useMitreMatrix(isOpen); const [selectedTechMap, setSelectedTechMap] = useState>( () => new Map(initialTechniques.map((t) => [t.id, { id: t.id, name: t.name }])), ); const [selectedTacticSet, setSelectedTacticSet] = useState>( () => new Set(initialTactics.map((t) => t.id)), ); const [expandedTechniques, setExpandedTechniques] = useState>(new Set()); const [search, setSearch] = useState(''); const containerRef = useRef(null); const searchInputRef = useRef(null); useEffect(() => { if (isOpen) { setSelectedTechMap(new Map(initialTechniques.map((t) => [t.id, { id: t.id, name: t.name }]))); setSelectedTacticSet(new Set(initialTactics.map((t) => t.id))); setExpandedTechniques(new Set()); setSearch(''); } }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (isOpen) { setTimeout(() => searchInputRef.current?.focus(), 0); } }, [isOpen]); 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) => { setSelectedTechMap((prev) => { const next = new Map(prev); if (next.has(id)) next.delete(id); else next.set(id, { id, name }); return next; }); }; const toggleTactic = (tacticId: string) => { setSelectedTacticSet((prev) => { const next = new Set(prev); if (next.has(tacticId)) next.delete(tacticId); else next.add(tacticId); 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(); 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 = () => { const techniques: MitreTechnique[] = Array.from(selectedTechMap.values()).map((t) => ({ id: t.id, name: t.name, tactics: [], })); // Reconstruct tactic refs from matrix data const tactics: MitreTacticRef[] = matrix ? matrix .filter((t) => selectedTacticSet.has(t.tactic_id)) .map((t) => ({ id: t.tactic_id, name: t.tactic_name })) : Array.from(selectedTacticSet).map((id) => ({ id, name: id })); onApply({ techniques, tactics }); }; const totalTechSelected = selectedTechMap.size; const totalTacticSelected = selectedTacticSet.size; const totalSelected = totalTechSelected + totalTacticSelected; const hasInitial = initialTechniques.length + initialTactics.length > 0; return (
); }