feat(frontend): kind + since filters on ActivityLog page
This commit is contained in:
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
data-testid="activity-filters"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="mr-1 text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Kind
|
||||||
|
</span>
|
||||||
|
{KIND_OPTIONS.map((opt) => {
|
||||||
|
const isActive = selectedKinds.includes(opt.value);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={isActive}
|
||||||
|
data-testid={`kind-chip-${opt.value}`}
|
||||||
|
onClick={() => toggleKind(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"h-7 rounded-full px-3 text-xs font-medium transition-colors",
|
||||||
|
"ring-1 ring-inset focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
isActive
|
||||||
|
? "bg-accent/15 text-accent ring-accent/40 hover:bg-accent/20"
|
||||||
|
: "bg-transparent text-muted-foreground ring-border/60 hover:text-foreground hover:ring-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Since
|
||||||
|
</span>
|
||||||
|
<Select
|
||||||
|
value={since}
|
||||||
|
onValueChange={(v) => onSinceChange(v as SinceValue)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]" aria-label="Since">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SINCE_OPTIONS.map((opt) => (
|
||||||
|
<SelectItem
|
||||||
|
key={opt.value}
|
||||||
|
value={opt.value}
|
||||||
|
data-testid={`since-option-${opt.value}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{hasFilters ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClear}
|
||||||
|
data-testid="clear-filters"
|
||||||
|
aria-label="Clear filters"
|
||||||
|
className={cn(
|
||||||
|
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
"text-muted-foreground hover:text-foreground",
|
||||||
|
"ring-1 ring-inset ring-border/40 hover:ring-border/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" strokeWidth={1.75} aria-hidden />
|
||||||
|
Clear filters
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,12 +1,123 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useActivity } from "@/hooks/useActivity";
|
import { useActivity } from "@/hooks/useActivity";
|
||||||
import { ActivityFeed } from "@/components/ActivityFeed";
|
import { ActivityFeed } from "@/components/ActivityFeed";
|
||||||
|
import { ActivityFilters, type SinceValue } from "@/components/ActivityFilters";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { ErrorState } from "@/components/ui/error-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() {
|
export function ActivityLog() {
|
||||||
const { data, isLoading, isError, error, refetch } = useActivity({ limit: 200 });
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const items = data?.items ?? [];
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="space-y-8 animate-fade-in">
|
<div className="space-y-8 animate-fade-in">
|
||||||
@@ -22,6 +133,16 @@ export function ActivityLog() {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="surface rounded-xl p-4">
|
||||||
|
<ActivityFilters
|
||||||
|
selectedKinds={selectedKinds}
|
||||||
|
onKindsChange={setSelectedKinds}
|
||||||
|
since={since}
|
||||||
|
onSinceChange={setSince}
|
||||||
|
onClear={clearFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
message="Couldn't load activity from the backend."
|
message="Couldn't load activity from the backend."
|
||||||
@@ -39,8 +160,12 @@ export function ActivityLog() {
|
|||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
eyebrow="Activity · log idle"
|
eyebrow={hasFilters ? "Activity · no matches" : "Activity · log idle"}
|
||||||
message="Activity will appear here after the first parse."
|
message={
|
||||||
|
hasFilters
|
||||||
|
? "No activity matches the current filters."
|
||||||
|
: "Activity will appear here after the first parse."
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
|
<ActivityFeed items={items} emptyMessage="No activity recorded yet." />
|
||||||
@@ -48,4 +173,4 @@ export function ActivityLog() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user