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