feat(frontend): kind + since filters on ActivityLog page
This commit is contained in:
@@ -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 { 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user