mirror of
https://github.com/mgeeky/decode-spam-headers.git
synced 2026-02-22 05:23:31 +01:00
MAESTRO: add test selector component
This commit is contained in:
@@ -23,7 +23,7 @@ This phase implements the test selection panel and analysis configuration contro
|
|||||||
- [x] T020 [US2] Write failing test (TDD Red) in `backend/tests/api/test_tests_router.py` — verify `GET /api/tests` returns 200 with all registered test IDs and names. Use httpx AsyncClient against the FastAPI test app
|
- [x] T020 [US2] Write failing test (TDD Red) in `backend/tests/api/test_tests_router.py` — verify `GET /api/tests` returns 200 with all registered test IDs and names. Use httpx AsyncClient against the FastAPI test app
|
||||||
- [x] T021 [US2] Create `backend/app/schemas/tests.py` (response schema) and `backend/app/routers/tests.py` — FastAPI router with `GET /api/tests` returning all available tests (id, name, category) from scanner registry. Register router in `backend/app/main.py`. Verify `test_tests_router.py` passes (TDD Green)
|
- [x] T021 [US2] Create `backend/app/schemas/tests.py` (response schema) and `backend/app/routers/tests.py` — FastAPI router with `GET /api/tests` returning all available tests (id, name, category) from scanner registry. Register router in `backend/app/main.py`. Verify `test_tests_router.py` passes (TDD Green)
|
||||||
- [x] T022 [US2] Write failing tests (TDD Red) in `frontend/src/__tests__/TestSelector.test.tsx` (render with mocked list, select/deselect all, search filtering) and `frontend/src/__tests__/AnalysisControls.test.tsx` (render, toggle states, keyboard accessibility)
|
- [x] T022 [US2] Write failing tests (TDD Red) in `frontend/src/__tests__/TestSelector.test.tsx` (render with mocked list, select/deselect all, search filtering) and `frontend/src/__tests__/AnalysisControls.test.tsx` (render, toggle states, keyboard accessibility)
|
||||||
- [ ] T023 [P] [US2] Create `frontend/src/components/TestSelector.tsx` — dropdown with checkboxes per test, fetches list from `GET /api/tests`, "Select All"/"Deselect All" buttons (FR-04), text search/filter (FR-23), grouped by vendor/category (FR-24), FontAwesome icons. Verify `TestSelector.test.tsx` passes (TDD Green)
|
- [x] T023 [P] [US2] Create `frontend/src/components/TestSelector.tsx` — dropdown with checkboxes per test, fetches list from `GET /api/tests`, "Select All"/"Deselect All" buttons (FR-04), text search/filter (FR-23), grouped by vendor/category (FR-24), FontAwesome icons. Verify `TestSelector.test.tsx` passes (TDD Green)
|
||||||
- [ ] T024 [P] [US2] Create `frontend/src/components/AnalysisControls.tsx` — panel containing TestSelector, DNS resolution toggle (off by default, FR-18), decode-all toggle (FR-19), FontAwesome toggle icons, keyboard accessible (NFR-02). Verify `AnalysisControls.test.tsx` passes (TDD Green)
|
- [ ] T024 [P] [US2] Create `frontend/src/components/AnalysisControls.tsx` — panel containing TestSelector, DNS resolution toggle (off by default, FR-18), decode-all toggle (FR-19), FontAwesome toggle icons, keyboard accessible (NFR-02). Verify `AnalysisControls.test.tsx` passes (TDD Green)
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
|
|||||||
223
frontend/src/components/TestSelector.tsx
Normal file
223
frontend/src/components/TestSelector.tsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import {
|
||||||
|
faCheck,
|
||||||
|
faChevronDown,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faXmark,
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
|
||||||
|
import { createApiClient } from "../lib/api-client";
|
||||||
|
|
||||||
|
type TestInfo = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestsResponse = {
|
||||||
|
tests: TestInfo[];
|
||||||
|
totalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestSelectorProps = {
|
||||||
|
selectedTestIds: number[];
|
||||||
|
onSelectionChange: (next: number[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TestGroup = {
|
||||||
|
category: string;
|
||||||
|
tests: TestInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildGroups = (tests: TestInfo[]): TestGroup[] => {
|
||||||
|
const groups = new Map<string, TestInfo[]>();
|
||||||
|
for (const test of tests) {
|
||||||
|
const current = groups.get(test.category) ?? [];
|
||||||
|
current.push(test);
|
||||||
|
groups.set(test.category, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(groups.entries()).map(([category, groupedTests]) => ({
|
||||||
|
category,
|
||||||
|
tests: groupedTests,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TestSelector({ selectedTestIds, onSelectionChange }: TestSelectorProps) {
|
||||||
|
const [tests, setTests] = useState<TestInfo[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const client = useMemo(
|
||||||
|
() =>
|
||||||
|
createApiClient({
|
||||||
|
fetcher: (...args) => globalThis.fetch(...args),
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isActive = true;
|
||||||
|
|
||||||
|
const loadTests = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await client.get<TestsResponse>("/api/tests");
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTests(response.tests ?? []);
|
||||||
|
} catch {
|
||||||
|
if (!isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("Unable to load analysis tests.");
|
||||||
|
} finally {
|
||||||
|
if (isActive) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadTests();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isActive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const normalizedSearch = searchText.trim().toLowerCase();
|
||||||
|
const filteredTests = useMemo(() => {
|
||||||
|
if (!normalizedSearch) {
|
||||||
|
return tests;
|
||||||
|
}
|
||||||
|
return tests.filter((test) => test.name.toLowerCase().includes(normalizedSearch));
|
||||||
|
}, [normalizedSearch, tests]);
|
||||||
|
|
||||||
|
const groupedTests = useMemo(() => buildGroups(filteredTests), [filteredTests]);
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedTestIds), [selectedTestIds]);
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
onSelectionChange(tests.map((test) => test.id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeselectAll = () => {
|
||||||
|
onSelectionChange([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleTest = (id: number) => {
|
||||||
|
if (selectedSet.has(id)) {
|
||||||
|
onSelectionChange(selectedTestIds.filter((testId) => testId !== id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectionChange([...selectedTestIds, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCount = tests.length;
|
||||||
|
const visibleCount = filteredTests.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="rounded-2xl border border-info/10 bg-surface p-6 shadow-[0_0_40px_rgba(15,23,42,0.25)]"
|
||||||
|
data-testid="test-selector"
|
||||||
|
>
|
||||||
|
<details open className="group">
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between text-xs uppercase tracking-[0.2em] text-info/90">
|
||||||
|
<span>Test Selection</span>
|
||||||
|
<span className="flex items-center gap-2 font-mono text-[10px] text-text/50">
|
||||||
|
{visibleCount} / {totalCount}
|
||||||
|
<FontAwesomeIcon icon={faChevronDown} className="text-[10px] text-text/40" />
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-4 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label className="flex flex-1 items-center gap-2 rounded-full border border-info/20 bg-background/40 px-3 py-2 text-sm text-text/70 focus-within:border-info/40">
|
||||||
|
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-xs text-text/40" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchText}
|
||||||
|
onInput={(event) => setSearchText(event.currentTarget.value)}
|
||||||
|
className="flex-1 bg-transparent text-xs text-text/80 outline-none"
|
||||||
|
placeholder="Search tests"
|
||||||
|
data-testid="test-search-input"
|
||||||
|
aria-label="Search analysis tests"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-info/20 bg-background/40 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-text/70 transition hover:border-info/40 hover:text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
data-testid="select-all-tests"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCheck} className="text-xs" />
|
||||||
|
Select All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-info/20 bg-background/40 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-text/70 transition hover:border-info/40 hover:text-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
|
||||||
|
onClick={handleDeselectAll}
|
||||||
|
data-testid="deselect-all-tests"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="text-xs" />
|
||||||
|
Deselect All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-xs text-text/60">Loading tests...</p>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-xs text-spam">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{groupedTests.map((group) => (
|
||||||
|
<div
|
||||||
|
key={group.category}
|
||||||
|
className="rounded-xl border border-info/10 bg-background/40 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between text-xs uppercase tracking-[0.2em] text-info/80">
|
||||||
|
<span>{group.category}</span>
|
||||||
|
<span className="font-mono text-[10px] text-text/50">
|
||||||
|
{group.tests.length} tests
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
|
{group.tests.map((test) => (
|
||||||
|
<label
|
||||||
|
key={test.id}
|
||||||
|
className="flex items-start gap-3 rounded-lg border border-info/10 bg-surface/40 p-3 text-sm text-text/80"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-info/40 text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info"
|
||||||
|
checked={selectedSet.has(test.id)}
|
||||||
|
onChange={() => handleToggleTest(test.id)}
|
||||||
|
data-testid={`test-checkbox-${test.id}`}
|
||||||
|
aria-label={test.name}
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<span className="font-medium">{test.name}</span>
|
||||||
|
<span className="font-mono text-[10px] text-text/50">ID {test.id}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{groupedTests.length === 0 && !isLoading ? (
|
||||||
|
<p className="text-xs text-text/60">No tests match the current search.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user