feat(frontend): kind + since filters on ActivityLog page

This commit is contained in:
Tyler
2026-06-20 16:53:19 -06:00
parent b980e77cf2
commit ff4906d0c1
3 changed files with 744 additions and 5 deletions
+449
View File
@@ -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<void> {
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<void> {
// 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length >= 1);
const callsBefore = (api.listActivity as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length >
callsBefore,
);
const lastCall = (api.listActivity as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length >= 1,
);
const callsBefore = (api.listActivity as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length >
callsBefore,
);
const calls = (api.listActivity as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length >= 1,
);
const initialCall = (api.listActivity as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length >
callsBeforeClear,
);
const calls = (api.listActivity as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mock.calls.length >= 1,
);
const lastCall = (api.listActivity as ReturnType<typeof vi.fn>).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();
});
});
+130 -5
View File
@@ -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<ActivityKind[]>(
() =>
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 (
<div className="space-y-8 animate-fade-in">
@@ -22,6 +133,16 @@ export function ActivityLog() {
</p>
</header>
<div className="surface rounded-xl p-4">
<ActivityFilters
selectedKinds={selectedKinds}
onKindsChange={setSelectedKinds}
since={since}
onSinceChange={setSince}
onClear={clearFilters}
/>
</div>
{isError ? (
<ErrorState
message="Couldn't load activity from the backend."
@@ -39,8 +160,12 @@ export function ActivityLog() {
</div>
) : items.length === 0 ? (
<EmptyState
eyebrow="Activity · log idle"
message="Activity will appear here after the first parse."
eyebrow={hasFilters ? "Activity · no matches" : "Activity · log idle"}
message={
hasFilters
? "No activity matches the current filters."
: "Activity will appear here after the first parse."
}
/>
) : (
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
@@ -48,4 +173,4 @@ export function ActivityLog() {
</div>
</div>
);
}
}