diff --git a/src/components/ActivityFilters.tsx b/src/components/ActivityFilters.tsx new file mode 100644 index 0000000..2155778 --- /dev/null +++ b/src/components/ActivityFilters.tsx @@ -0,0 +1,165 @@ +import { X } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; +import type { ActivityKind } from "@/types"; + +/** + * URL-friendly relative time filters. The page converts each value into an + * ISO timestamp before passing it to `useActivity` so the backend's + * lexicographic `timestamp >= since` comparison works against the stored + * ISO strings. + * + * `all` is the implicit default and is rendered as "All time". + */ +export type SinceValue = "1h" | "24h" | "7d" | "all"; + +const KIND_OPTIONS: { value: ActivityKind; label: string }[] = [ + { value: "claim_submitted", label: "Submitted" }, + { value: "claim_accepted", label: "Accepted" }, + { value: "claim_paid", label: "Paid" }, + { value: "claim_denied", label: "Denied" }, + { value: "remit_received", label: "Remit received" }, + { value: "provider_added", label: "Provider added" }, +]; + +const SINCE_OPTIONS: { value: SinceValue; label: string }[] = [ + { value: "all", label: "All time" }, + { value: "1h", label: "Last 1 hour" }, + { value: "24h", label: "Last 24 hours" }, + { value: "7d", label: "Last 7 days" }, +]; + +type ActivityFiltersProps = { + /** Currently selected activity kinds (multi-select). Empty array = no filter. */ + selectedKinds: ActivityKind[]; + /** Toggle a kind in or out of `selectedKinds`. */ + onKindsChange: (kinds: ActivityKind[]) => void; + /** Active "since" window; `"all"` means no time filter. */ + since: SinceValue; + /** Update the "since" window. */ + onSinceChange: (since: SinceValue) => void; + /** Reset both filters (and any other future filters). */ + onClear: () => void; +}; + +/** + * Filter strip for the Activity Log page. + * + * - "Kind": a row of toggleable chips (multi-select). Each chip uses + * `role="checkbox"` + `aria-checked` so screen readers announce the + * toggle state correctly. Selected chips lift to the electric blue + * accent, matching the single-select `FilterChips` visual language. + * + * - "Since": a Radix Select dropdown with the four relative windows. + * `SelectValue` renders the current label so the user always sees + * what's applied (rather than a bare `1h` slug). + * + * - "Clear filters": a small X-pill appears once any filter is active. + * Mirrors the single-select `FilterChips` "Clear" affordance so the + * operator's mental model is consistent across pages. + * + * The component is purely presentational — it doesn't read or write URL + * state itself. `ActivityLog` owns URL sync via `useSearchParams` so the + * component is trivially testable in isolation if needed. + */ +export function ActivityFilters({ + selectedKinds, + onKindsChange, + since, + onSinceChange, + onClear, +}: ActivityFiltersProps) { + const hasFilters = selectedKinds.length > 0 || since !== "all"; + + const toggleKind = (value: ActivityKind) => { + if (selectedKinds.includes(value)) { + onKindsChange(selectedKinds.filter((k) => k !== value)); + } else { + onKindsChange([...selectedKinds, value]); + } + }; + + return ( +
+
+ + Kind + + {KIND_OPTIONS.map((opt) => { + const isActive = selectedKinds.includes(opt.value); + return ( + + ); + })} +
+ +
+ + Since + + + + {hasFilters ? ( + + ) : null} +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/ActivityLog.test.tsx b/src/pages/ActivityLog.test.tsx new file mode 100644 index 0000000..1575fe1 --- /dev/null +++ b/src/pages/ActivityLog.test.tsx @@ -0,0 +1,449 @@ +// @vitest-environment happy-dom +// Tell React this is an `act`-aware test environment so react-query's +// internal state updates flush through without noisy console warnings. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = + true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ActivityLog } from "./ActivityLog"; +import { api } from "@/lib/api"; +import type { Activity } from "@/types"; + +// Module-level mock — vitest hoists vi.mock above imports. We only stub +// the API method this page touches (`listActivity`). `isConfigured: true` +// is critical: without it, `useActivity` falls back to the empty zustand +// store instead of calling our mocked API (see useActivity.ts). +vi.mock("@/lib/api", () => ({ + api: { + isConfigured: true, + listActivity: vi.fn(), + }, +})); + +/** + * Empty activity set by default — individual tests override the resolved + * value with whatever shape they need to assert on. + */ +const EMPTY: { items: Activity[]; total: number; returned: number; has_more: boolean } = { + items: [], + total: 0, + returned: 0, + has_more: false, +}; + +const SAMPLE: Activity[] = [ + { + id: "A-1", + kind: "claim_submitted", + message: "Claim CLM-1 submitted", + timestamp: "2026-06-20T12:00:00Z", + }, + { + id: "A-2", + kind: "claim_paid", + message: "Claim CLM-2 paid", + timestamp: "2026-06-20T11:00:00Z", + npi: "1234567890", + amount: 100, + }, + { + id: "A-3", + kind: "remit_received", + message: "Remit PCN-1 received", + timestamp: "2026-06-20T10:00:00Z", + }, +]; + +type RenderOpts = { initialEntries?: string[] }; + +/** + * Render the page wrapped in QueryClient + MemoryRouter. We use + * `MemoryRouter` rather than the default `BrowserRouter` because + * happy-dom's URL state has to be driven explicitly per test — a memory + * router accepts `initialEntries` so each test gets a clean URL without + * the `setLocation` dance that `useDrawerUrlState.test.ts` uses. + */ +function renderActivity({ initialEntries = ["/activity"] }: RenderOpts = {}): { + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, + }); + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement( + QueryClientProvider, + { client: qc }, + React.createElement( + MemoryRouter, + { initialEntries }, + React.createElement(ActivityLog), + ), + ), + ); + }); + return { + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Flush microtasks + react state updates until `predicate` holds. Radix + * portals render asynchronously after a Select opens, so polling the + * DOM is the only way to avoid flakiness without `@testing-library/dom`. + */ +async function settle( + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error("settle: predicate did not hold within timeout"); + } + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +/** + * Open the Radix "Since" Select and click the option whose label + * contains `label`. The trigger has `aria-label="Since"` and the + * options portal into `document.body`. + */ +async function pickSinceOption(label: string): Promise { + // Find the trigger. Radix Select renders a button with role="combobox". + const triggers = Array.from( + document.body.querySelectorAll('button[role="combobox"]'), + ); + const trigger = triggers.find( + (t) => t.getAttribute("aria-label") === "Since", + ) as HTMLButtonElement | undefined; + expect(trigger).toBeDefined(); + await act(async () => { + trigger!.click(); + }); + await settle( + () => + Array.from(document.body.querySelectorAll('[role="option"]')).some( + (o) => o.textContent?.includes(label) ?? false, + ), + ); + const option = Array.from( + document.body.querySelectorAll('[role="option"]'), + ).find((o) => o.textContent?.includes(label)) as HTMLElement | undefined; + expect(option).toBeDefined(); + await act(async () => { + option!.click(); + }); + // Wait for the portal to close so subsequent assertions see the new + // query state, not the still-open menu. + await settle(() => document.body.querySelector('[role="option"]') === null); +} + +describe("ActivityLog page filters", () => { + beforeEach(() => { + vi.clearAllMocks(); + (api.listActivity as unknown as ReturnType).mockResolvedValue( + EMPTY, + ); + }); + + it("test_renders_filter_controls_when_mounted", async () => { + const { unmount } = renderActivity(); + // ActivityFilters mounts synchronously with the page; no async wait. + expect( + document.body.querySelector('[data-testid="activity-filters"]'), + ).not.toBeNull(); + + // All six kind chips render (one per ActivityKind enum value). + const chips = document.body.querySelectorAll( + '[data-testid^="kind-chip-"]', + ); + expect(chips.length).toBe(6); + + // "Since" trigger is present. The label "All time" is the default. + expect(document.body.textContent).toContain("All time"); + + // Clear button is NOT present when no filter is active. + expect( + document.body.querySelector('[data-testid="clear-filters"]'), + ).toBeNull(); + + unmount(); + }); + + it("test_selecting_a_kind_updates_url_and_triggers_refetch", async () => { + (api.listActivity as unknown as ReturnType).mockResolvedValue({ + items: [SAMPLE[0]], + total: 1, + returned: 1, + has_more: false, + }); + + const { unmount } = renderActivity({ initialEntries: ["/activity"] }); + + // Wait for the initial (empty) fetch to settle so we can compare + // the pre-click and post-click call args cleanly. + await settle(() => (api.listActivity as ReturnType).mock.calls.length >= 1); + const callsBefore = (api.listActivity as ReturnType).mock.calls.length; + + // Click the "Submitted" kind chip. + const chip = document.body.querySelector( + '[data-testid="kind-chip-claim_submitted"]', + ) as HTMLButtonElement | null; + expect(chip).not.toBeNull(); + await act(async () => { + chip!.click(); + }); + + // URL must reflect the selection. We're inside a MemoryRouter, so + // `window.location` doesn't update — instead the hook's setSearchParams + // pushes a new history entry inside the router. The assertion below + // reaches into React Router's `useLocation` by reading the rendered + // search input — but the simpler invariant is that `api.listActivity` + // is called again with `{ kind: "claim_submitted" }`. + await settle( + () => + (api.listActivity as ReturnType).mock.calls.length > + callsBefore, + ); + const lastCall = (api.listActivity as ReturnType).mock.calls.at( + -1, + )![0] as { kind?: string }; + expect(lastCall.kind).toBe("claim_submitted"); + + unmount(); + }); + + it("test_since_select_changes_the_api_call_param", async () => { + const { unmount } = renderActivity({ initialEntries: ["/activity"] }); + + // Wait for the initial fetch (no `since`) so we can compare. + await settle( + () => (api.listActivity as ReturnType).mock.calls.length >= 1, + ); + const callsBefore = (api.listActivity as ReturnType).mock.calls.length; + + // Pick "Last 1 hour" from the dropdown. + await pickSinceOption("Last 1 hour"); + + // A new fetch should have fired with a non-empty `since` ISO string. + await settle( + () => + (api.listActivity as ReturnType).mock.calls.length > + callsBefore, + ); + const calls = (api.listActivity as ReturnType).mock.calls; + const lastCall = calls.at(-1)![0] as { since?: string }; + expect(lastCall.since).toBeDefined(); + // The backend compares against ISO timestamps lexicographically, so + // the page must convert "1h" → ISO before sending. We don't pin the + // exact value (clock drifts), but it must be parseable as a recent + // timestamp and lie within the last hour from now. + const sinceMs = Date.parse(lastCall.since!); + expect(Number.isFinite(sinceMs)).toBe(true); + expect(Date.now() - sinceMs).toBeGreaterThanOrEqual(0); + expect(Date.now() - sinceMs).toBeLessThanOrEqual(60 * 60 * 1000 + 5_000); + + unmount(); + }); + + it("test_clear_filters_resets_url_and_refetches", async () => { + // Mount with both filters already set in the URL — Clear should drop + // them and trigger an unfiltered refetch. + (api.listActivity as unknown as ReturnType).mockResolvedValue({ + items: SAMPLE, + total: SAMPLE.length, + returned: SAMPLE.length, + has_more: false, + }); + + const { unmount } = renderActivity({ + initialEntries: ["/activity?kind=claim_paid&since=24h"], + }); + + // Wait for the initial filtered fetch. + await settle( + () => (api.listActivity as ReturnType).mock.calls.length >= 1, + ); + const initialCall = (api.listActivity as ReturnType).mock.calls[0]![0] as { + kind?: string; + since?: string; + }; + expect(initialCall.kind).toBe("claim_paid"); + expect(initialCall.since).toBeDefined(); + + // Sanity: the "Submitted" chip is NOT active (only "Paid" is selected). + const paidChip = document.body.querySelector( + '[data-testid="kind-chip-claim_paid"]', + ) as HTMLButtonElement | null; + expect(paidChip?.getAttribute("aria-checked")).toBe("true"); + const submittedChip = document.body.querySelector( + '[data-testid="kind-chip-claim_submitted"]', + ) as HTMLButtonElement | null; + expect(submittedChip?.getAttribute("aria-checked")).toBe("false"); + + // Clear button must be visible while filters are active. + const clearBtn = document.body.querySelector( + '[data-testid="clear-filters"]', + ) as HTMLButtonElement | null; + expect(clearBtn).not.toBeNull(); + const callsBeforeClear = (api.listActivity as ReturnType).mock.calls.length; + + await act(async () => { + clearBtn!.click(); + }); + + // All kind chips should now report unchecked. + await settle(() => { + const checked = document.body.querySelectorAll( + '[role="checkbox"][aria-checked="true"]', + ); + return checked.length === 0; + }); + expect( + document.body.querySelectorAll('[role="checkbox"][aria-checked="true"]') + .length, + ).toBe(0); + + // Clear button disappears once filters are inactive. + await settle( + () => document.body.querySelector('[data-testid="clear-filters"]') === null, + ); + expect( + document.body.querySelector('[data-testid="clear-filters"]'), + ).toBeNull(); + + // A new fetch should have fired with no kind/since params. + await settle( + () => + (api.listActivity as ReturnType).mock.calls.length > + callsBeforeClear, + ); + const calls = (api.listActivity as ReturnType).mock.calls; + const lastCall = calls.at(-1)![0] as { kind?: string; since?: string }; + expect(lastCall.kind).toBeUndefined(); + expect(lastCall.since).toBeUndefined(); + + unmount(); + }); + + it("test_url_params_on_initial_load_populate_the_filter_ui", async () => { + (api.listActivity as unknown as ReturnType).mockResolvedValue({ + items: [SAMPLE[1]], + total: 1, + returned: 1, + has_more: false, + }); + + // Deep link with two kinds + a since window — the most aggressive + // initial-load case. + const { unmount } = renderActivity({ + initialEntries: [ + "/activity?kind=claim_paid&kind=remit_received&since=7d", + ], + }); + + // Wait for the initial fetch. + await settle( + () => (api.listActivity as ReturnType).mock.calls.length >= 1, + ); + + // Both selected chips must report aria-checked=true. + await settle(() => { + const paid = document.body.querySelector( + '[data-testid="kind-chip-claim_paid"]', + ); + const remit = document.body.querySelector( + '[data-testid="kind-chip-remit_received"]', + ); + return ( + paid?.getAttribute("aria-checked") === "true" && + remit?.getAttribute("aria-checked") === "true" + ); + }); + expect( + document.body + .querySelector('[data-testid="kind-chip-claim_paid"]') + ?.getAttribute("aria-checked"), + ).toBe("true"); + expect( + document.body + .querySelector('[data-testid="kind-chip-remit_received"]') + ?.getAttribute("aria-checked"), + ).toBe("true"); + + // Other chips must report aria-checked=false. + expect( + document.body + .querySelector('[data-testid="kind-chip-claim_submitted"]') + ?.getAttribute("aria-checked"), + ).toBe("false"); + + // "Since" Select must reflect "Last 7 days". Radix Select renders the + // current value inside the trigger's `SelectValue` slot. + expect(document.body.textContent).toContain("Last 7 days"); + + // API call: with TWO kinds selected, the page drops `kind` from the + // backend params (it only handles single-kind server-side filtering) + // and the since ISO timestamp is sent instead of the "7d" slug. + const lastCall = (api.listActivity as ReturnType).mock.calls.at( + -1, + )![0] as { kind?: string; since?: string }; + expect(lastCall.kind).toBeUndefined(); + expect(lastCall.since).toBeDefined(); + expect(Date.parse(lastCall.since!)).toBeGreaterThan( + Date.now() - 8 * 24 * 60 * 60 * 1000, + ); + + unmount(); + }); + + it("test_unknown_url_params_are_ignored", async () => { + // Garbage in the URL (unknown kind, unknown since value) must not + // throw, must not pass garbage to the API, and must leave the UI in + // the "no filters" state. + const { unmount } = renderActivity({ + initialEntries: ["/activity?kind=not_a_kind&since=nope"], + }); + + await settle( + () => (api.listActivity as ReturnType).mock.calls.length >= 1, + ); + + const lastCall = (api.listActivity as ReturnType).mock.calls.at( + -1, + )![0] as { kind?: string; since?: string }; + expect(lastCall.kind).toBeUndefined(); + expect(lastCall.since).toBeUndefined(); + + // No chip is checked. + expect( + document.body.querySelectorAll( + '[role="checkbox"][aria-checked="true"]', + ).length, + ).toBe(0); + + // "Since" trigger shows the default ("All time"). + expect(document.body.textContent).toContain("All time"); + + // Clear button hidden. + expect( + document.body.querySelector('[data-testid="clear-filters"]'), + ).toBeNull(); + + unmount(); + }); +}); \ No newline at end of file diff --git a/src/pages/ActivityLog.tsx b/src/pages/ActivityLog.tsx index fedfd99..5eaca51 100644 --- a/src/pages/ActivityLog.tsx +++ b/src/pages/ActivityLog.tsx @@ -1,12 +1,123 @@ +import { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; import { useActivity } from "@/hooks/useActivity"; import { ActivityFeed } from "@/components/ActivityFeed"; +import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters"; import { Skeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; +import type { ActivityKind } from "@/types"; + +const VALID_KINDS: readonly ActivityKind[] = [ + "claim_submitted", + "claim_paid", + "claim_denied", + "claim_accepted", + "remit_received", + "provider_added", +]; + +const VALID_SINCE: readonly SinceValue[] = ["1h", "24h", "7d", "all"]; + +/** + * Convert a relative "since" window into an ISO timestamp string for the + * backend's lexicographic `timestamp >= since` comparison. Memoized on + * `since` so the queryKey stays stable across re-renders (otherwise the + * refetch would fire on every Date.now() tick). + */ +function useSinceIso(since: SinceValue): string | undefined { + return useMemo(() => { + const now = Date.now(); + if (since === "1h") return new Date(now - 60 * 60 * 1000).toISOString(); + if (since === "24h") return new Date(now - 24 * 60 * 60 * 1000).toISOString(); + if (since === "7d") return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString(); + return undefined; + }, [since]); +} export function ActivityLog() { - const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 }); - const items = data?.items ?? []; + const [searchParams, setSearchParams] = useSearchParams(); + + // URL → filter state. `searchParams` is stable as long as the URL is, + // so these are computed cheaply with useMemo. + const selectedKinds = useMemo( + () => + searchParams + .getAll("kind") + .filter((k): k is ActivityKind => + (VALID_KINDS as readonly string[]).includes(k), + ), + [searchParams], + ); + + const sinceRaw = searchParams.get("since") ?? "all"; + const since: SinceValue = (VALID_SINCE as readonly string[]).includes(sinceRaw) + ? (sinceRaw as SinceValue) + : "all"; + + // Backend's `/api/activity` only accepts a single `kind` query param, so + // for multi-select we fetch the unfiltered set and filter client-side. + // Single-kind selections pass through to the backend (cheaper + benefits + // from any future server-side filtering). + const apiKind = selectedKinds.length === 1 ? selectedKinds[0] : undefined; + const sinceIso = useSinceIso(since); + + const { data, isLoading, isError, error, refetch } = useActivity({ + kind: apiKind, + since: sinceIso, + limit: 200, + }); + + const allItems = data?.items ?? []; + const items = useMemo(() => { + // Server already returned a single-kind slice when `apiKind` is set; + // otherwise apply the (multi-)kind filter locally. + if (selectedKinds.length <= 1) return allItems; + return allItems.filter((a) => selectedKinds.includes(a.kind)); + }, [allItems, selectedKinds]); + + const writeParams = useCallback( + (mutate: (next: URLSearchParams) => void) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + mutate(next); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams], + ); + + const setSelectedKinds = useCallback( + (kinds: ActivityKind[]) => { + writeParams((next) => { + next.delete("kind"); + for (const k of kinds) next.append("kind", k); + }); + }, + [writeParams], + ); + + const setSince = useCallback( + (value: SinceValue) => { + writeParams((next) => { + if (value === "all") next.delete("since"); + else next.set("since", value); + }); + }, + [writeParams], + ); + + const clearFilters = useCallback(() => { + writeParams((next) => { + next.delete("kind"); + next.delete("since"); + }); + }, [writeParams]); + + const hasFilters = selectedKinds.length > 0 || since !== "all"; return (
@@ -22,6 +133,16 @@ export function ActivityLog() {

+
+ +
+ {isError ? ( ) : items.length === 0 ? ( ) : ( @@ -48,4 +173,4 @@ export function ActivityLog() {
); -} +} \ No newline at end of file