feat(dashboard): Recent activity events route to entity by kind
SP21 Task 2.5 — the Dashboard's 'Recent activity' card now routes clicks
to the matching entity drawer / page by event kind:
- claim_* → /claims?claim=<id> (drawer in Phase 5)
- provider_added → /providers?provider=<npi> (ProviderDrawer)
- remit_received → toast 'coming in a later phase' (RemitDrawer in Phase 4)
- anything else → toast (manual_match, unknown kinds)
Implementation:
- New src/lib/event-routing.ts with the eventKindToUrl() helper,
plus a unit test covering all 6 + default branches.
- src/components/ActivityFeed.tsx gains an optional onItemClick
prop; when set, each row gets role='button', tabIndex=0, the
drillable hover affordance (chevron + tint), and an Enter/Space
keybinding. e.stopPropagation() is called before the handler so a
parent row click can't double-fire (same fix as Task 2.4).
- src/pages/Dashboard.tsx wires the handler on the 'Recent activity'
card via eventKindToUrl + sonner toast for unhandled kinds.
- Backend: CycloneStore.recent_activity() now exposes claimId and
remittanceId on each row (read from ActivityEvent.claim_id /
remittance_id) so the routing helper has the entity ids it needs.
- The frontend Activity interface gains optional claimId /
remittanceId fields; the in-memory sample data and the
addClaim store action populate them so the dashboard works in
both API-configured and sample-data modes.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { eventKindToUrl, type RoutableEvent } from "./event-routing";
|
||||
|
||||
// A minimal event factory keeps the assertions short and keeps the
|
||||
// focus on what the helper actually inspects (kind + the relevant
|
||||
// entity-id field for that kind).
|
||||
function evt(overrides: Partial<RoutableEvent> & { kind: RoutableEvent["kind"] }): RoutableEvent {
|
||||
return {
|
||||
claimId: null,
|
||||
remittanceId: null,
|
||||
npi: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("eventKindToUrl", () => {
|
||||
// ----- claim_* → /claims?claim=ID -------------------------------
|
||||
it("routes claim_submitted to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_submitted", claimId: "CLM-1" })),
|
||||
).toBe("/claims?claim=CLM-1");
|
||||
});
|
||||
|
||||
it("routes claim_paid to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM-2" })),
|
||||
).toBe("/claims?claim=CLM-2");
|
||||
});
|
||||
|
||||
it("routes claim_denied to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_denied", claimId: "CLM-3" })),
|
||||
).toBe("/claims?claim=CLM-3");
|
||||
});
|
||||
|
||||
it("routes claim_accepted to /claims?claim=ID", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_accepted", claimId: "CLM-4" })),
|
||||
).toBe("/claims?claim=CLM-4");
|
||||
});
|
||||
|
||||
it("percent-encodes the claim id when it contains special chars", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: "CLM/1+2" })),
|
||||
).toBe("/claims?claim=CLM%2F1%2B2");
|
||||
});
|
||||
|
||||
it("returns null for claim_* when claimId is missing", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: null })),
|
||||
).toBeNull();
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "claim_paid", claimId: undefined })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ----- remit_received → null (Phase 4) --------------------------
|
||||
it("returns null for remit_received (Phase 4 drawer not built yet)", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "remit_received", remittanceId: "REM-1" })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ----- provider_added → /providers?provider=NPI -----------------
|
||||
it("routes provider_added to /providers?provider=NPI", () => {
|
||||
expect(
|
||||
eventKindToUrl(evt({ kind: "provider_added", npi: "1730187395" })),
|
||||
).toBe("/providers?provider=1730187395");
|
||||
});
|
||||
|
||||
it("returns null for provider_added when npi is missing", () => {
|
||||
expect(eventKindToUrl(evt({ kind: "provider_added", npi: undefined }))).toBeNull();
|
||||
});
|
||||
|
||||
// ----- default branch ------------------------------------------
|
||||
it("returns null for unhandled kinds (manual_match)", () => {
|
||||
expect(eventKindToUrl(evt({ kind: "manual_match" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown kinds (defensive default)", () => {
|
||||
// Cast through unknown so the type-checker doesn't widen the
|
||||
// literal away from the exhaustive union.
|
||||
const unknown = { kind: "future_kind" } as unknown as RoutableEvent;
|
||||
expect(eventKindToUrl(unknown)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Activity } from "@/types";
|
||||
|
||||
/**
|
||||
* The minimum an activity event must carry to be routable. Centralizing
|
||||
* this here means callers can pass an `Activity` (full feed row) or a
|
||||
* narrower shape (e.g. a future "activity event" peek payload) — both
|
||||
* satisfy the kind + entity-id fields the helper inspects.
|
||||
*
|
||||
* - `claimId` / `remittanceId` come from the backend `ActivityEvent`
|
||||
* ORM columns (SP21 Task 2.5). `claimId` is set on `claim_*` events;
|
||||
* `remittanceId` is set on `remit_received`.
|
||||
* - `npi` is the existing `Activity.npi` field. For `provider_added`
|
||||
* events the provider NPI IS the entity id (the URL target).
|
||||
*/
|
||||
export type RoutableEvent = Pick<
|
||||
Activity,
|
||||
"kind" | "claimId" | "remittanceId" | "npi"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Maps an activity event to the URL the operator should land on when
|
||||
* clicking the event. Used by:
|
||||
*
|
||||
* - Dashboard "Recent activity" card (this PR, Task 2.5).
|
||||
* - `/activity` log page (Task 2.5 wired here; full integration is
|
||||
* a later task).
|
||||
*
|
||||
* Returns `null` for kinds that don't have a drill target yet, or
|
||||
* when the entity id field is missing on the event. Callers are
|
||||
* expected to surface a "coming soon" toast in that case so the click
|
||||
* still gives feedback.
|
||||
*/
|
||||
export function eventKindToUrl(event: RoutableEvent): string | null {
|
||||
switch (event.kind) {
|
||||
case "claim_submitted":
|
||||
case "claim_paid":
|
||||
case "claim_denied":
|
||||
case "claim_accepted": {
|
||||
if (!event.claimId) return null;
|
||||
return `/claims?claim=${encodeURIComponent(event.claimId)}`;
|
||||
}
|
||||
case "remit_received":
|
||||
// Phase 4 — the `RemitDrawer` isn't built yet; the helper stays
|
||||
// honest and returns null so the caller's "coming soon" toast
|
||||
// fires. When the drawer lands, the route becomes
|
||||
// `/remittances?remit=${encodeURIComponent(event.remittanceId ?? "")}`.
|
||||
return null;
|
||||
case "provider_added": {
|
||||
if (!event.npi) return null;
|
||||
return `/providers?provider=${encodeURIComponent(event.npi)}`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user