diff --git a/job-tracker-ui/src/admin-audit-page.test.tsx b/job-tracker-ui/src/admin-audit-page.test.tsx index e0d7cd3a..c1f75d0c 100644 --- a/job-tracker-ui/src/admin-audit-page.test.tsx +++ b/job-tracker-ui/src/admin-audit-page.test.tsx @@ -33,6 +33,29 @@ beforeEach(() => { const row = { id: 7, type: "Deleted", at: "2026-10-20T12:00:00Z", jobApplicationId: 42, note: "Owner authored text" }; function deferred() { let resolve!: (value: T) => void; const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } +test("searches loaded audit metadata without querying the API or matching notes", async () => { + mockedApi.get.mockResolvedValue({ data: [ + { ...row, jobTitle: "Teacher", ownerEmail: "alice@example.test", ownerUserName: "al", ownerUserId: "user-a" }, + { ...row, id: 8, jobApplicationId: 420, type: "StatusChanged", jobTitle: "Nurse", companyName: "Clinic", note: "Sensitive phrase" }, + ] } as any); + renderPage(); + await screen.findByText("Teacher"); + const search = screen.getByRole("searchbox", { name: "Search recent audit events" }); + expect(search).toHaveAccessibleDescription("Searches up to 200 loaded events by user, event type, application ID, title or company."); + + for (const query of [" ALICE ", "al", "user-a", "Deleted", "42", "teacher"]) { + fireEvent.change(search, { target: { value: query } }); + expect(screen.getByText("Teacher")).toBeInTheDocument(); + expect(screen.queryByText("Clinic - Nurse")).not.toBeInTheDocument(); + } + fireEvent.change(search, { target: { value: "sensitive phrase" } }); + expect(screen.getByText("No loaded events match your search.")).toBeInTheDocument(); + fireEvent.change(search, { target: { value: "" } }); + expect(screen.getByText("Clinic - Nurse")).toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenCalledTimes(1); + expect(mockedApi.post).not.toHaveBeenCalled(); +}); + test("[P7-10 audit] retains shared heading hierarchy and a named keyboard scroll region", async () => { mockedApi.get.mockResolvedValue({ data: [row] } as any); renderPage(); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index cc98a4fa..9c2af0e2 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -2622,6 +2622,9 @@ export const translations = { adminAuditDetails: "Details", adminAuditActions: "Actions", adminAuditNoEvents: "No events.", + adminAuditSearch: "Search recent audit events", + adminAuditSearchScope: "Searches up to 200 loaded events by user, event type, application ID, title or company.", + adminAuditNoMatches: "No loaded events match your search.", adminSystemEnvironment: "Environment", adminSystemStatusTab: "Status", adminSystemSettingsTab: "Settings", @@ -5852,6 +5855,9 @@ export const translations = { adminAuditDetails: "Detaljer", adminAuditActions: "Handlinger", adminAuditNoEvents: "Ingen hendelser.", + adminAuditSearch: "Søk i nylige revisjonshendelser", + adminAuditSearchScope: "Søker i opptil 200 innlastede hendelser etter bruker, hendelsestype, søknads-ID, stillingstittel eller virksomhet.", + adminAuditNoMatches: "Ingen innlastede hendelser samsvarer med søket.", adminSystemEnvironment: "Miljø", adminSystemStatusTab: "Status", adminSystemSettingsTab: "Innstillinger", diff --git a/job-tracker-ui/src/views/AdminAuditPage.tsx b/job-tracker-ui/src/views/AdminAuditPage.tsx index ba2a9a4f..6898127f 100644 --- a/job-tracker-ui/src/views/AdminAuditPage.tsx +++ b/job-tracker-ui/src/views/AdminAuditPage.tsx @@ -10,6 +10,7 @@ import { TableContainer, TableHead, TableRow, + TextField, Typography, } from "@mui/material"; @@ -44,6 +45,7 @@ export default function AdminAuditPage() { const { toast } = useToast(); const { language, t } = useI18n(); const [busyId, setBusyId] = useState(null); + const [search, setSearch] = useState(""); const body=useRef(null); const owner=useRef({ mounted: true, generation: 0, busy: false }); const currentTranslation=useRef(t); @@ -81,12 +83,28 @@ export default function AdminAuditPage() { } }; - const rows = auditResource.data; + const query = search.trim().toLowerCase(); + const rows = query ? auditResource.data.filter(e => + String(e.jobApplicationId) === query || + [e.type, e.jobTitle, e.companyName, e.ownerUserId, e.ownerEmail, e.ownerUserName] + .some(value => value?.toLowerCase().includes(query)) + ) : auditResource.data; const error=auditResource.error?.message===fallback.current ? { ...auditResource.error, message: t("adminAuditLoadFailed") } : auditResource.error; return ( + {!auditResource.error && auditResource.data.length > 0 && ( + setSearch(event.target.value)} + sx={{ width: "100%", maxWidth: 480, mb: 2 }} + /> + )} { body.current?.focus(); await auditResource.reload(); }} /> - {!auditResource.error ? + {!auditResource.error && query && auditResource.data.length > 0 && rows.length === 0 ? ( + {t("adminAuditNoMatches")} + ) : !auditResource.error ?