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:
Knacky
2026-05-27 04:04:23 +02:00
parent 673b25e0b0
commit 771483f3b0
15 changed files with 973 additions and 181 deletions

View File

@@ -1,5 +1,5 @@
import { apiClient } from './client';
import type { MitreTechnique } from './types';
import type { MitreTactic, MitreTechnique } from './types';
export async function searchMitreTechniques(query: string): Promise<MitreTechnique[]> {
const { data } = await apiClient.get<MitreTechnique[]>('/mitre/techniques', {
@@ -7,3 +7,8 @@ export async function searchMitreTechniques(query: string): Promise<MitreTechniq
});
return data;
}
export async function getMitreMatrix(): Promise<MitreTactic[]> {
const { data } = await apiClient.get<MitreTactic[]>('/mitre/matrix');
return data;
}

View File

@@ -61,12 +61,28 @@ export interface MitreTechnique {
tactics: string[];
}
export interface MitreMatrixSubtechnique {
id: string;
name: string;
}
export interface MitreMatrixTechnique {
id: string;
name: string;
subtechniques: MitreMatrixSubtechnique[];
}
export interface MitreTactic {
tactic_id: string;
tactic_name: string;
techniques: MitreMatrixTechnique[];
}
export interface Simulation {
id: number;
engagement_id: number;
name: string;
mitre_technique_id: string | null;
mitre_technique_name: string | null;
techniques: MitreTechnique[];
description: string | null;
commands: string | null;
prerequisites: string | null;
@@ -88,8 +104,7 @@ export interface SimulationCreateInput {
export interface SimulationPatchInput {
name?: string;
mitre_technique_id?: string | null;
mitre_technique_name?: string | null;
technique_ids?: string[];
description?: string | null;
commands?: string | null;
prerequisites?: string | null;

View 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&amp;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>
);
}

View File

@@ -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);
}}

View File

@@ -0,0 +1,33 @@
import type { MitreTechnique } from '@/api/types';
interface MitreTechniqueTagProps {
technique: MitreTechnique;
onRemove: () => void;
disabled?: boolean;
}
export function MitreTechniqueTag({
technique,
onRemove,
disabled = false,
}: MitreTechniqueTagProps): JSX.Element {
return (
<span
data-testid="mitre-technique-tag"
className="inline-flex items-center gap-xxs bg-primary-soft text-primary-deep rounded-full px-md py-xxs text-[14px]"
>
<span className="font-medium">{technique.id}</span>
<span className="text-primary-deep opacity-75"> {technique.name}</span>
{!disabled && (
<button
type="button"
aria-label={`Remove ${technique.id}`}
onClick={onRemove}
className="ml-xxs text-primary-deep opacity-60 hover:opacity-100 leading-none"
>
×
</button>
)}
</span>
);
}

View File

@@ -0,0 +1,135 @@
import { useState } from 'react';
import { extractApiError } from '@/api/client';
import type { MitreTechnique } from '@/api/types';
import { useUpdateSimulation } from '@/hooks/useSimulations';
import { useToast } from '@/hooks/useToast';
import { MitreTechniqueTag } from './MitreTechniqueTag';
import { MitreTechniquePicker } from './MitreTechniquePicker';
import { MitreMatrixModal } from './MitreMatrixModal';
interface MitreTechniquesFieldProps {
value: MitreTechnique[];
simulationId: number;
engagementId: number;
disabled?: boolean;
}
export function MitreTechniquesField({
value,
simulationId,
engagementId,
disabled = false,
}: MitreTechniquesFieldProps): JSX.Element {
const [showMatrix, setShowMatrix] = useState(false);
const [showPicker, setShowPicker] = useState(false);
const { push } = useToast();
const updateMutation = useUpdateSimulation(simulationId, engagementId);
const save = async (techniques: MitreTechnique[]) => {
try {
await updateMutation.mutateAsync({
technique_ids: techniques.map((t) => t.id),
});
push('Techniques updated', 'success');
} catch (err) {
push(extractApiError(err, 'Could not update techniques'), 'error');
}
};
const handleRemove = (id: string) => {
const next = value.filter((t) => t.id !== id);
void save(next);
};
const handleSelect = (technique: MitreTechnique) => {
// Dedup: no-op if already present
if (value.some((t) => t.id === technique.id)) return;
const next = [...value, technique];
void save(next);
};
const handleMatrixApply = (selection: MitreTechnique[]) => {
setShowMatrix(false);
// Merge: preserve existing tactics on items already in value, fill from selection otherwise.
// The backend re-enriches tactics at serialize time, so the exact tactics here don't matter.
const merged = selection.map((s) => {
const existing = value.find((v) => v.id === s.id);
return existing ?? s;
});
void save(merged);
};
const isPending = updateMutation.isPending;
return (
<div className="flex flex-col gap-sm">
{/* Tag list */}
{value.length === 0 ? (
<p className="text-[14px] text-graphite">
No techniques selected use the matrix or the quick search to add.
</p>
) : (
<div className="flex flex-wrap gap-sm" data-testid="techniques-tag-list">
{value.map((t) => (
<MitreTechniqueTag
key={t.id}
technique={t}
onRemove={() => handleRemove(t.id)}
disabled={disabled || isPending}
/>
))}
</div>
)}
{/* Action buttons — hidden in read-only mode */}
{!disabled && (
<div className="flex items-center gap-sm">
<button
type="button"
className="btn-outline"
onClick={() => {
setShowPicker(false);
setShowMatrix(true);
}}
disabled={isPending}
>
Add technique
</button>
<button
type="button"
className="btn-outline-ink"
onClick={() => setShowPicker((v) => !v)}
disabled={isPending}
>
Quick search
</button>
{isPending && (
<span className="text-[13px] text-graphite">Saving</span>
)}
</div>
)}
{/* Inline Quick Search picker */}
{showPicker && !disabled && (
<div className="max-w-md">
<MitreTechniquePicker
onSelect={(technique) => {
handleSelect(technique);
setShowPicker(false);
}}
disabled={isPending}
/>
</div>
)}
{/* Matrix modal */}
<MitreMatrixModal
isOpen={showMatrix}
initialSelection={value}
onApply={handleMatrixApply}
onCancel={() => setShowMatrix(false)}
/>
</div>
);
}

View File

@@ -95,7 +95,11 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
</Link>
</td>
<td className="px-xl py-md text-charcoal text-[14px]">
{sim.mitre_technique_id ?? '—'}
{sim.techniques.length === 0
? '—'
: sim.techniques.length === 1
? sim.techniques[0].id
: `${sim.techniques[0].id} +${sim.techniques.length - 1}`}
</td>
<td className="px-xl py-md">
<SimulationStatusBadge status={sim.status} />

View File

@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { searchMitreTechniques } from '@/api/mitre';
import { getMitreMatrix, searchMitreTechniques } from '@/api/mitre';
export function useMitreSearch(query: string, enabled: boolean) {
return useQuery({
@@ -9,3 +9,12 @@ export function useMitreSearch(query: string, enabled: boolean) {
staleTime: 5 * 60 * 1000,
});
}
export function useMitreMatrix(enabled: boolean) {
return useQuery({
queryKey: ['mitre', 'matrix'],
queryFn: getMitreMatrix,
enabled,
staleTime: Infinity,
});
}

View File

@@ -16,12 +16,10 @@ import { LoadingState } from '@/components/LoadingState';
import { ErrorState } from '@/components/ErrorState';
import { SimulationStatusBadge } from '@/components/SimulationStatusBadge';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { MitreTechniquePicker } from '@/components/MitreTechniquePicker';
import { MitreTechniquesField } from '@/components/MitreTechniquesField';
interface RedteamFormState {
name: string;
mitre_technique_id: string | null;
mitre_technique_name: string | null;
description: string;
commands: string;
prerequisites: string;
@@ -38,8 +36,6 @@ interface SocFormState {
const EMPTY_RT: RedteamFormState = {
name: '',
mitre_technique_id: null,
mitre_technique_name: null,
description: '',
commands: '',
prerequisites: '',
@@ -81,8 +77,6 @@ export function SimulationFormPage(): JSX.Element {
const s = detail.data;
setRt({
name: s.name,
mitre_technique_id: s.mitre_technique_id,
mitre_technique_name: s.mitre_technique_name,
description: s.description ?? '',
commands: s.commands ?? '',
prerequisites: s.prerequisites ?? '',
@@ -154,8 +148,6 @@ export function SimulationFormPage(): JSX.Element {
}
const patch: SimulationPatchInput = {
name: rt.name.trim(),
mitre_technique_id: rt.mitre_technique_id ?? null,
mitre_technique_name: rt.mitre_technique_name ?? null,
description: rt.description.trim() || null,
commands: rt.commands.trim() || null,
prerequisites: rt.prerequisites.trim() || null,
@@ -314,16 +306,15 @@ export function SimulationFormPage(): JSX.Element {
/>
</FormField>
<FormField label="MITRE Technique" htmlFor="sim-mitre">
<MitreTechniquePicker
techniqueId={rt.mitre_technique_id}
techniqueName={rt.mitre_technique_name}
onChange={(id, name) =>
setRt({ ...rt, mitre_technique_id: id, mitre_technique_name: name })
}
<div className="flex flex-col gap-xs">
<span className="text-[14px] font-medium text-ink">MITRE Techniques</span>
<MitreTechniquesField
value={simulation?.techniques ?? []}
simulationId={simulationId as number}
engagementId={engagementId as number}
disabled={rtDisabled}
/>
</FormField>
</div>
<FormField label="Description" htmlFor="sim-description">
<TextArea