From a34ab607cf2c04067f549eca064294f8bf31b629 Mon Sep 17 00:00:00 2001 From: mforce <> Date: Wed, 12 Aug 2026 18:52:42 -0700 Subject: [PATCH 1/2] feat(web): cascading record-type filter on the Audit page (#520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second "Record type" dropdown narrows the existing action dropdown to only actions actually recorded against that entity type, instead of one flat ~48-item list. The action->entity-type map is read off each audit.WriteAsync(...) call site (not derived from the action's own "Entity.Verb" prefix, which is wrong for the four Account.*Logo/Banner actions — recorded against FarmLogo, a shared row). Client-side narrowing only; the /api/v1/audit query still filters on `action` alone. --- web/src/i18n/en.ts | 2 + web/src/i18n/enums.ts | 60 +++++++++++++++++++++++ web/src/i18n/es.ts | 2 + web/src/i18n/tl.ts | 2 + web/src/routes/AuditPage.test.tsx | 80 +++++++++++++++++++++++++++++-- web/src/routes/AuditPage.tsx | 44 ++++++++++++++++- 6 files changed, 184 insertions(+), 6 deletions(-) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index af5f905d..529a00fd 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1846,6 +1846,8 @@ export const en = { // loaded, or if the entity has zero audit events — deliberately not // distinguishing those two cases (Gate 3). scopedHeadingFallback: "Record history", + entityTypeFilterLabel: "Record type", + allEntityTypesOption: "All types", actionFilterLabel: "Action", allActionsOption: "All actions", whenHeader: "When (UTC)", diff --git a/web/src/i18n/enums.ts b/web/src/i18n/enums.ts index 8f87e179..d040b22b 100644 --- a/web/src/i18n/enums.ts +++ b/web/src/i18n/enums.ts @@ -353,6 +353,66 @@ export function auditActionLabel(value: AuditActionValue | (string & {})): strin return key ? i18n.t(key) : String(value); } +// Which EntityType each action is actually recorded against server-side — +// read off every audit.WriteAsync(...) call site, not derived from the +// action's own "Entity.Verb" prefix, which is misleading for the four +// Account.Set/RemoveLogo/Banner actions (prefixed "Account", recorded +// against "FarmLogo" — a shared row FarmLogo.cs documents as covering both +// logo and banner). Powers AuditPage's entity-type dropdown, which narrows +// the action dropdown to only actions that can occur on the selected type. +export const AUDIT_ACTION_ENTITY_TYPE = { + "DailyEntry.Adjust": "DailyEntry", + "DailyEntry.Void": "DailyEntry", + "SalesOrder.Void": "SalesOrder", + "Payment.Void": "Payment", + "Expense.Adjust": "Expense", + "ExpenseCategory.Update": "ExpenseCategory", + "InventoryItem.Adjust": "InventoryItem", + "WaterUsage.Correct": "WaterUsage", + "Flock.BirdMovement": "Flock", + "Flock.Update": "Flock", + "Flock.Deplete": "Flock", + "Flock.Archive": "Flock", + "Flock.Reactivate": "Flock", + "EggGrade.Update": "EggGrade", + "EggGrade.Activate": "EggGrade", + "EggGrade.Deactivate": "EggGrade", + "User.Create": "User", + "User.Update": "User", + "User.PasswordSet": "User", + "User.PasswordChanged": "User", + "User.BreakGlassReset": "User", + "User.RoleChanged": "User", + "User.Disabled": "User", + "User.Enabled": "User", + "User.FlockAssign": "User", + "User.FlockUnassign": "User", + "Account.Export": "Account", + "Account.SetLogo": "FarmLogo", + "Account.RemoveLogo": "FarmLogo", + "Account.SetBanner": "FarmLogo", + "Account.RemoveBanner": "FarmLogo", + "Account.UpdateSettings": "Account", + "Product.Create": "Product", + "Product.Update": "Product", + "Product.Activate": "Product", + "Product.Deactivate": "Product", + "EggUnitConversion.Update": "EggUnitConversion", + "EggLot.Movement": "EggLot", + "Flock.Create": "Flock", + "DailyEntry.Create": "DailyEntry", + "DailyEntry.Update": "DailyEntry", + "DailyEntry.Submit": "DailyEntry", + "SalesOrder.Create": "SalesOrder", + "SalesOrder.Confirm": "SalesOrder", + "SalesOrder.Cancel": "SalesOrder", + "SalesOrder.AddItem": "SalesOrder", + "SalesOrder.UpdateItem": "SalesOrder", + "SalesOrder.RemoveItem": "SalesOrder", + "Expense.Create": "Expense", + "EggGrade.Create": "EggGrade", +} as const satisfies Record; + // --------------------------------------------------------------------------- // entity type (AuditPage table entity cell) — AuditEvent.entityType. // --------------------------------------------------------------------------- diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index d48abb39..f8e9594b 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -1442,6 +1442,8 @@ export const es = { + "mismo y nunca se editan.", scopedHeading: "Historial de {{entityType}}", scopedHeadingFallback: "Historial del registro", + entityTypeFilterLabel: "Tipo de registro", + allEntityTypesOption: "Todos los tipos", actionFilterLabel: "Acción", allActionsOption: "Todas las acciones", whenHeader: "Cuándo (UTC)", diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 1f2cb63a..5c677a91 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -1495,6 +1495,8 @@ export const tl = { + "pagbabago mismo at hindi na ito ine-edit pa.", scopedHeading: "Kasaysayan ng {{entityType}}", scopedHeadingFallback: "Kasaysayan ng record", + entityTypeFilterLabel: "Uri ng record", + allEntityTypesOption: "Lahat ng uri", actionFilterLabel: "Aksyon", allActionsOption: "Lahat ng aksyon", whenHeader: "Kailan (UTC)", diff --git a/web/src/routes/AuditPage.test.tsx b/web/src/routes/AuditPage.test.tsx index 9fec25f8..0831318f 100644 --- a/web/src/routes/AuditPage.test.tsx +++ b/web/src/routes/AuditPage.test.tsx @@ -193,7 +193,7 @@ describe("AuditPage filter", () => { await screen.findByText("No audit events yet."); // let the mount load settle first await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); // The ARGUMENT is the behavior: the chosen action must reach the seam. @@ -201,6 +201,78 @@ describe("AuditPage filter", () => { expect.objectContaining({ action: "Flock.Deplete", offset: 0 }), ); }); + + it("narrows the action dropdown to only actions recorded against the chosen record type", async () => { + renderAudit(); + await screen.findByText("No audit events yet."); + + const actionSelect = screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement; + const optionValues = () => Array.from(actionSelect.options).map((o) => o.value); + expect(optionValues()).toContain("User.Create"); // unfiltered: every action listed + expect(optionValues()).toContain("Flock.Deplete"); + + fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), { + target: { value: "Flock" }, + }); + + // Only Flock-recorded actions remain, plus the "All actions" blank option. + expect(optionValues()).toEqual( + expect.arrayContaining(["", "Flock.BirdMovement", "Flock.Update", "Flock.Deplete", "Flock.Archive", "Flock.Reactivate", "Flock.Create"]), + ); + expect(optionValues()).not.toContain("User.Create"); + expect(optionValues()).not.toContain("SalesOrder.Void"); + }); + + it("resets a selected action that no longer matches once the record type changes", async () => { + mockListAuditEvents.mockResolvedValue([]); + renderAudit(); + await screen.findByText("No audit events yet."); + + await act(async () => { + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { + target: { value: "User.Create" }, + }); + }); + expect(mockListAuditEvents).toHaveBeenLastCalledWith( + expect.objectContaining({ action: "User.Create" }), + ); + + // "User.Create" isn't a Flock action — picking Flock as the record type + // must not leave a hidden, mismatched action filter still in effect. + await act(async () => { + fireEvent.change(screen.getByRole("combobox", { name: "Record type" }), { + target: { value: "Flock" }, + }); + }); + + expect(mockListAuditEvents).toHaveBeenLastCalledWith( + expect.objectContaining({ action: undefined }), + ); + expect((screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).value).toBe(""); + }); + + it("carries the record-type filter in the URL, independent of entityId scoping", async () => { + renderAudit("/audit?entityType=Flock"); + await screen.findByText("No audit events yet."); + + expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe( + "Flock", + ); + }); + + it("falls back to the unfiltered action list for a garbage entityType query value", async () => { + renderAudit("/audit?entityType=NotARealType"); + await screen.findByText("No audit events yet."); + + expect((screen.getByRole("combobox", { name: "Record type" }) as HTMLSelectElement).value).toBe( + "", + ); + expect( + Array.from( + (screen.getByRole("combobox", { name: "Action" }) as HTMLSelectElement).options, + ).map((o) => o.value), + ).toContain("User.Create"); // unfiltered — the bogus value is ignored, not applied + }); }); describe("AuditPage paging", () => { @@ -486,7 +558,7 @@ describe("AuditPage entity-scoped mode (#493)", () => { await screen.findByText("No audit events for this record yet."); await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); expect(mockListAuditEvents).toHaveBeenLastCalledWith( @@ -500,7 +572,7 @@ describe("AuditPage entity-scoped mode (#493)", () => { await screen.findByText("No audit events yet."); await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); expect(mockListAuditEvents).toHaveBeenLastCalledWith( @@ -524,7 +596,7 @@ describe("AuditPage entity-scoped mode (#493)", () => { mockListAuditEvents.mockReturnValueOnce(new Promise((r) => (resolveReload = r))); await act(async () => { - fireEvent.change(screen.getByRole("combobox"), { target: { value: "Flock.Deplete" } }); + fireEvent.change(screen.getByRole("combobox", { name: "Action" }), { target: { value: "Flock.Deplete" } }); }); // The reload is in flight: the heading must NOT still say "Flock diff --git a/web/src/routes/AuditPage.tsx b/web/src/routes/AuditPage.tsx index 476f9fdd..f29e49ac 100644 --- a/web/src/routes/AuditPage.tsx +++ b/web/src/routes/AuditPage.tsx @@ -3,7 +3,14 @@ import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router"; import { listAuditEvents } from "../api/cluckwork"; import { usePagedList } from "../components/usePagedList"; -import { AUDIT_ACTION_VALUES, auditActionLabel, entityTypeLabel } from "../i18n/enums"; +import { + AUDIT_ACTION_ENTITY_TYPE, + AUDIT_ACTION_VALUES, + auditActionLabel, + ENTITY_TYPE_VALUES, + entityTypeLabel, + type EntityTypeValue, +} from "../i18n/enums"; const PAGE = 100; @@ -75,6 +82,31 @@ export function AuditPage() { setSearchParams(next); }, [searchParams, setSearchParams]); + // Entity-type filter narrows the action dropdown's OPTION LIST only — it is + // never sent to the server (the /api/v1/audit query still filters on + // `action` alone, matching what the backend supports). Changing it drops + // any selected `action`: AUDIT_ACTION_ENTITY_TYPE is a many-to-one map, so + // a previously chosen action can fall outside the new type's option list, + // and leaving it selected-but-hidden would silently keep querying against + // a type the visible dropdown no longer shows. + const rawEntityTypeFilter = searchParams.get("entityType"); + const entityTypeFilter: EntityTypeValue | "" = + rawEntityTypeFilter && (ENTITY_TYPE_VALUES as readonly string[]).includes(rawEntityTypeFilter) + ? (rawEntityTypeFilter as EntityTypeValue) + : ""; + + const updateEntityTypeFilter = useCallback((type: string) => { + const next = new URLSearchParams(searchParams); + if (type) next.set("entityType", type); + else next.delete("entityType"); + next.delete("action"); + setSearchParams(next); + }, [searchParams, setSearchParams]); + + const availableActions = entityTypeFilter + ? AUDIT_ACTION_VALUES.filter((a) => AUDIT_ACTION_ENTITY_TYPE[a] === entityTypeFilter) + : AUDIT_ACTION_VALUES; + // #469 — the ticket/dedupe/busy-ownership discipline this screen grew for // itself (codex review of #94) now lives in usePagedList, shared with every // other paged screen. The filter is expressed as the fetcher's identity, so @@ -169,10 +201,18 @@ export function AuditPage() {

{t("intro")}

+