feat(frontend): sprint 4 — dark mode + matrix overhaul + tactic selection + done read-only + UI polish
US-17: fix duplicate "Create engagement" button, icon conventions (Save/RotateCcw/Grid2x2), UsersAdminPage form baseline alignment US-18: done status fully read-only + Reopen button (done → review_required) for all roles US-19: invalidate engagement queries on simulation PATCH/transition for auto-status propagation US-20: MitreMatrixModal rewritten — CSS grid 12-column layout, no horizontal scroll, attack.mitre.org compact look US-21: tactic header clickable in matrix, tactic chips (MitreTacticTag) in field, single atomic PATCH with technique_ids + tactic_ids US-22: MitreTechniquesField chips-only area + inline search input + matrix icon button; chips show ID-only (name in title=) US-23: useTheme hook — 3-state light/dark/system, CSS variables, Tailwind darkMode class, localStorage persistence 92/92 tests passing, typecheck and lint clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,8 @@ const SELECTION: MitreTechnique[] = [
|
||||
{ id: 'T1078', name: 'Valid Accounts', tactics: ['initial-access'] },
|
||||
];
|
||||
|
||||
const NO_TACTICS: never[] = [];
|
||||
|
||||
describe('MitreMatrixModal', () => {
|
||||
let mock: MockAdapter;
|
||||
|
||||
@@ -58,14 +60,26 @@ describe('MitreMatrixModal', () => {
|
||||
|
||||
it('returns null when isOpen=false', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<MitreMatrixModal isOpen={false} initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen={false}
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders dialog with tactic columns when open', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Initial Access')).toBeInTheDocument();
|
||||
@@ -75,7 +89,13 @@ describe('MitreMatrixModal', () => {
|
||||
|
||||
it('renders techniques for each tactic', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('T1078')).toBeInTheDocument();
|
||||
@@ -88,12 +108,17 @@ describe('MitreMatrixModal', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={onApply}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// Click the label button for T1078 to select it
|
||||
const t1078Btn = screen.getAllByRole('button').find(
|
||||
(btn) => btn.textContent?.includes('T1078') && !btn.getAttribute('aria-label'),
|
||||
);
|
||||
@@ -102,7 +127,9 @@ describe('MitreMatrixModal', () => {
|
||||
await user.click(screen.getByRole('button', { name: /Apply/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
|
||||
expect.objectContaining({
|
||||
techniques: expect.arrayContaining([expect.objectContaining({ id: 'T1078' })]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -112,7 +139,13 @@ describe('MitreMatrixModal', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={onApply} onCancel={onCancel} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={onApply}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Cancel/i }));
|
||||
@@ -126,7 +159,13 @@ describe('MitreMatrixModal', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
@@ -134,21 +173,31 @@ describe('MitreMatrixModal', () => {
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows initial selection as selected', async () => {
|
||||
it('shows initial technique selection as selected (count in header)', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={SELECTION}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// T1078 should show selected count in tactic header
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 sel\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('search filter narrows visible techniques', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
@@ -156,7 +205,6 @@ describe('MitreMatrixModal', () => {
|
||||
const searchInput = screen.getByPlaceholderText(/Filter techniques/i);
|
||||
await user.type(searchInput, 'T1059');
|
||||
|
||||
// T1059 column should be visible, T1078 should not
|
||||
expect(screen.queryByText('T1078')).toBeNull();
|
||||
expect(screen.getByText('T1059')).toBeInTheDocument();
|
||||
});
|
||||
@@ -164,64 +212,84 @@ describe('MitreMatrixModal', () => {
|
||||
it('chevron expands subtechniques', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => screen.getByText('T1078'));
|
||||
|
||||
// Subtechniques should not be visible initially
|
||||
expect(screen.queryByText(/Default Accounts/)).toBeNull();
|
||||
|
||||
// Click the expand chevron for T1078
|
||||
const expandBtn = screen.getByRole('button', { name: /Expand T1078/i });
|
||||
await user.click(expandBtn);
|
||||
|
||||
expect(screen.getByText(/Default Accounts/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Apply button shows technique count', async () => {
|
||||
it('Apply button shows item count when techniques selected', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={SELECTION}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Apply 1 technique/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Apply 1 item/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Apply button is disabled when no techniques selected and no initial selection', async () => {
|
||||
it('Apply button is disabled when nothing selected and no initial selection', async () => {
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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 () => {
|
||||
it('Apply button shows "Clear all" and is enabled when initial selection is deselected', async () => {
|
||||
const onApply = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={SELECTION} onApply={onApply} onCancel={vi.fn()} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={SELECTION}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={onApply}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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([]);
|
||||
expect(onApply).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ techniques: [], tactics: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('backdrop click calls onCancel', async () => {
|
||||
@@ -229,10 +297,15 @@ describe('MitreMatrixModal', () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(
|
||||
<MitreMatrixModal isOpen initialSelection={[]} onApply={vi.fn()} onCancel={onCancel} />,
|
||||
<MitreMatrixModal
|
||||
isOpen
|
||||
initialTechniques={[]}
|
||||
initialTactics={NO_TACTICS}
|
||||
onApply={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click the backdrop (the fixed inset div behind the modal)
|
||||
const backdrop = document.querySelector('.bg-ink\\/60') as HTMLElement;
|
||||
if (backdrop) await user.click(backdrop);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user