feat(admin): search recent audit metadata

This commit is contained in:
cesnimda
2026-11-10 13:11:29 +01:00
parent 2033aea31b
commit 1a8a97a578
3 changed files with 51 additions and 2 deletions
@@ -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<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(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();
+6
View File
@@ -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",
+22 -2
View File
@@ -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<number | null>(null);
const [search, setSearch] = useState("");
const body=useRef<HTMLDivElement>(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 (
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
<PageSection title={t("adminAuditEvents")}>
{!auditResource.error && auditResource.data.length > 0 && (
<TextField
type="search"
size="small"
label={t("adminAuditSearch")}
helperText={t("adminAuditSearchScope")}
value={search}
onChange={event => setSearch(event.target.value)}
sx={{ width: "100%", maxWidth: 480, mb: 2 }}
/>
)}
<Box ref={body} role="region" aria-label={t("adminAuditTitle")} tabIndex={0} aria-busy={busyId!==null || auditResource.loading || auditResource.refreshing} sx={{ minWidth: 0, overflow: "auto" }}>
<ViewStateNotice
@@ -97,7 +115,9 @@ export default function AdminAuditPage() {
onRetry={async () => { body.current?.focus(); await auditResource.reload(); }}
/>
{!auditResource.error ? <TableContainer sx={{ overflow: "visible" }}>
{!auditResource.error && query && auditResource.data.length > 0 && rows.length === 0 ? (
<Typography role="status" sx={{ color: "text.secondary" }}>{t("adminAuditNoMatches")}</Typography>
) : !auditResource.error ? <TableContainer sx={{ overflow: "visible" }}>
<Table size="small" aria-label={t("adminAuditTitle")} sx={{ minWidth: 1200, "& th, & td": { overflowWrap: "anywhere" }, "& button": { whiteSpace: "normal", "@media (pointer: coarse)": { minHeight: 44 } } }}>
<TableHead>
<TableRow>