diff --git a/frontend/src/components/MitreMatrixModal.tsx b/frontend/src/components/MitreMatrixModal.tsx
index 6f170f7..f7aae3a 100644
--- a/frontend/src/components/MitreMatrixModal.tsx
+++ b/frontend/src/components/MitreMatrixModal.tsx
@@ -333,9 +333,11 @@ export function MitreMatrixModal({
type="button"
className="btn-primary"
onClick={handleApply}
- disabled={isLoading || isError}
+ disabled={isLoading || isError || (totalSelected === 0 && initialSelection.length === 0)}
>
- Apply {totalSelected > 0 ? `${totalSelected} technique${totalSelected !== 1 ? 's' : ''}` : ''}
+ {totalSelected === 0
+ ? 'Clear all'
+ : `Apply ${totalSelected} technique${totalSelected !== 1 ? 's' : ''}`}
diff --git a/frontend/src/components/SimulationList.tsx b/frontend/src/components/SimulationList.tsx
index ee8618c..94ded36 100644
--- a/frontend/src/components/SimulationList.tsx
+++ b/frontend/src/components/SimulationList.tsx
@@ -90,6 +90,7 @@ export function SimulationList({ engagementId }: SimulationListProps): JSX.Eleme
e.stopPropagation()}
>
{sim.name}
diff --git a/frontend/tests/MitreMatrixModal.test.tsx b/frontend/tests/MitreMatrixModal.test.tsx
index 3f344ad..8758b62 100644
--- a/frontend/tests/MitreMatrixModal.test.tsx
+++ b/frontend/tests/MitreMatrixModal.test.tsx
@@ -189,6 +189,41 @@ describe('MitreMatrixModal', () => {
});
});
+ it('Apply button is disabled when no techniques selected and no initial selection', async () => {
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => screen.getByText('T1078'));
+
+ // Label is "Clear all" when totalSelected === 0, but it's disabled when initialSelection is also empty
+ const applyBtn = screen.getByRole('button', { name: /Clear all/i });
+ expect(applyBtn).toBeDisabled();
+ });
+
+ it('Apply button shows "Clear all" and stays enabled when initial selection is deselected', async () => {
+ const onApply = vi.fn();
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => screen.getByText('T1078'));
+
+ // Deselect T1078 (it was pre-selected)
+ const t1078Btn = screen.getAllByRole('button').find(
+ (btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
+ );
+ await user.click(t1078Btn!);
+
+ // Button should show "Clear all" and be enabled (user explicitly clearing the list)
+ const applyBtn = screen.getByRole('button', { name: /Clear all/i });
+ expect(applyBtn).not.toBeDisabled();
+ await user.click(applyBtn);
+ expect(onApply).toHaveBeenCalledWith([]);
+ });
+
it('backdrop click calls onCancel', async () => {
const onCancel = vi.fn();
const user = userEvent.setup();
diff --git a/frontend/tests/MitreTechniquesField.test.tsx b/frontend/tests/MitreTechniquesField.test.tsx
index efa650d..0b0fede 100644
--- a/frontend/tests/MitreTechniquesField.test.tsx
+++ b/frontend/tests/MitreTechniquesField.test.tsx
@@ -110,16 +110,29 @@ describe('MitreTechniquesField', () => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
- it('dedup: adding an already-present technique does not PATCH', async () => {
+ it('dedup: selecting an already-present technique does not PATCH', async () => {
mock.onGet('/mitre/techniques').reply(200, [T1059]);
const user = userEvent.setup();
renderWithProviders(
,
);
- // open picker
+
+ // Open the quick-search picker
await user.click(screen.getByRole('button', { name: /Quick search/i }));
- // Picker shows; but we can't easily select the same item without triggering real debounce in this test.
- // Instead just verify no PATCH happened yet — dedup is the key invariant.
+ const combobox = screen.getByRole('combobox');
+ expect(combobox).toBeInTheDocument();
+
+ // Type to trigger the search (debounce is 200ms but fake timers not needed — mock responds immediately)
+ await user.type(combobox, 'T1059');
+
+ // Wait for the option to appear in the listbox
+ const option = await screen.findByRole('option', { name: /T1059/i });
+ expect(option).toBeInTheDocument();
+
+ // Select it via pointerDown (mirrors the component's onPointerDown handler)
+ await user.pointer({ target: option, keys: '[MouseLeft>]' });
+
+ // Dedup guard should have fired — no PATCH should have been sent
expect(mock.history.patch.length).toBe(0);
});