merge: ui/global-search
This commit is contained in:
@@ -2,6 +2,7 @@ import { Outlet } from "react-router-dom";
|
|||||||
import { useIsFetching } from "@tanstack/react-query";
|
import { useIsFetching } from "@tanstack/react-query";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { SkipLink } from "@/components/ui/skip-link";
|
import { SkipLink } from "@/components/ui/skip-link";
|
||||||
|
import { SearchBar } from "./SearchBar";
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
// useIsFetching() returns the number of in-flight queries and is 0 on
|
// useIsFetching() returns the number of in-flight queries and is 0 on
|
||||||
@@ -37,6 +38,18 @@ export function Layout() {
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="md:pl-60 outline-none"
|
className="md:pl-60 outline-none"
|
||||||
>
|
>
|
||||||
|
{/*
|
||||||
|
Top bar — sits inside <main> so it shares the sidebar's right
|
||||||
|
column. Sticky to the viewport top so the search chip stays
|
||||||
|
reachable while the user scrolls long claim lists. The
|
||||||
|
hairline + horizontal padding mirrors the main content
|
||||||
|
container below it for visual continuity. The Sidebar's
|
||||||
|
own header is taller and brand-marked; this is the
|
||||||
|
lightweight "always-on utility" row.
|
||||||
|
*/}
|
||||||
|
<div className="sticky top-0 z-20 mx-auto max-w-[1400px] px-8 h-12 flex items-center justify-end bg-background/70 backdrop-blur-md border-b border-border/40">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
<div className="mx-auto max-w-[1400px] px-8 py-10">
|
<div className="mx-auto max-w-[1400px] px-8 py-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// SearchBar is the top-level component — it owns the dialog, the
|
||||||
|
// input, the keyboard shortcut, and navigation. We need both the
|
||||||
|
// act-aware env flag AND a MemoryRouter so `useNavigate` doesn't
|
||||||
|
// blow up at render time.
|
||||||
|
(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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { SearchBar } from "./SearchBar";
|
||||||
|
import { useAppStore } from "@/store";
|
||||||
|
import type { Activity, Claim, Remittance } from "@/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks — same as useSearch.test.ts: useSearch falls back to the
|
||||||
|
// in-memory store when `api.isConfigured` is false. We rely on that
|
||||||
|
// for the SearchBar tests too so we don't need to wire a fake fetch.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
isConfigured: false,
|
||||||
|
listClaims: vi.fn(),
|
||||||
|
listRemittances: vi.fn(),
|
||||||
|
listActivity: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures — minimal: enough to make sure hits show up.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CLAIMS: Claim[] = [
|
||||||
|
{
|
||||||
|
id: "CLM-10428",
|
||||||
|
patientName: "Avery Nguyen",
|
||||||
|
providerNpi: "1730187395",
|
||||||
|
payerName: "Blue Cross Blue Shield",
|
||||||
|
cptCode: "99213",
|
||||||
|
billedAmount: 240,
|
||||||
|
receivedAmount: 240,
|
||||||
|
status: "paid",
|
||||||
|
submissionDate: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const REMITS: Remittance[] = [
|
||||||
|
{
|
||||||
|
id: "REM-7782",
|
||||||
|
claimId: "CLM-10428",
|
||||||
|
payerName: "Blue Cross Blue Shield",
|
||||||
|
paidAmount: 240,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-05-10T00:00:00Z",
|
||||||
|
checkNumber: "EFT-100123",
|
||||||
|
status: "reconciled",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIVITY: Activity[] = [
|
||||||
|
{
|
||||||
|
id: "ACT-CLM-10428",
|
||||||
|
kind: "claim_paid",
|
||||||
|
message: "Paid CLM-10428 · Avery Nguyen",
|
||||||
|
timestamp: "2026-05-10T00:00:00Z",
|
||||||
|
npi: "1730187395",
|
||||||
|
amount: 240,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set an input's value the way React's synthetic event system expects.
|
||||||
|
*
|
||||||
|
* React 17+ installs an internal value tracker on `<input>` /
|
||||||
|
* `<textarea>` elements and only fires `onChange` when the tracker
|
||||||
|
* sees a change. Direct assignment (`input.value = "x"`) updates the
|
||||||
|
* DOM but skips the tracker, so React's onChange never fires in the
|
||||||
|
* test. The canonical workaround is to call the *native* value setter
|
||||||
|
* from the input's prototype — that update goes through React's
|
||||||
|
* tracker and the next dispatched `input` event fires onChange.
|
||||||
|
*/
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
|
||||||
|
window.HTMLInputElement.prototype,
|
||||||
|
"value"
|
||||||
|
)!.set!;
|
||||||
|
function typeInto(input: HTMLInputElement, value: string): void {
|
||||||
|
nativeInputValueSetter.call(input, value);
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderIntoContainer(
|
||||||
|
element: React.ReactElement,
|
||||||
|
initialEntries: string[] = ["/claims"]
|
||||||
|
): {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
|
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the predicate over `document.body` to hold. The search
|
||||||
|
* dialog's results panel renders into a Radix portal mounted on
|
||||||
|
* `document.body`, so we poll there rather than at the React
|
||||||
|
* container.
|
||||||
|
*/
|
||||||
|
async function settle(
|
||||||
|
predicate: (body: HTMLElement) => boolean,
|
||||||
|
timeoutMs = 2000
|
||||||
|
): Promise<HTMLElement> {
|
||||||
|
const body = document.body;
|
||||||
|
const start = Date.now();
|
||||||
|
while (!predicate(body)) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(
|
||||||
|
`settle: predicate did not hold within ${timeoutMs}ms (body=${body.innerHTML.slice(0, 200)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a keydown on the given target (window by default). */
|
||||||
|
function pressKey(
|
||||||
|
key: string,
|
||||||
|
target: EventTarget = window,
|
||||||
|
init: KeyboardEventInit = {}
|
||||||
|
): void {
|
||||||
|
target.dispatchEvent(
|
||||||
|
new KeyboardEvent("keydown", {
|
||||||
|
key,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
...init,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("SearchBar", () => {
|
||||||
|
it("renders the trigger button in the top bar", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
expect(
|
||||||
|
container.querySelector('[data-testid="search-trigger"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render the dialog until the trigger is clicked", () => {
|
||||||
|
const { unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
expect(
|
||||||
|
document.body.querySelector('[data-testid="search-dialog"]')
|
||||||
|
).toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the dialog when the trigger is clicked", async () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
const trigger = container.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="search-trigger"]'
|
||||||
|
)!;
|
||||||
|
act(() => {
|
||||||
|
trigger.click();
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the dialog when Cmd+K is pressed on Mac", async () => {
|
||||||
|
const originalPlatform = navigator.platform;
|
||||||
|
Object.defineProperty(navigator, "platform", {
|
||||||
|
value: "MacIntel",
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
act(() => {
|
||||||
|
pressKey("k", window, { metaKey: true });
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(navigator, "platform", {
|
||||||
|
value: originalPlatform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the dialog when Ctrl+K is pressed on non-Mac", async () => {
|
||||||
|
const originalPlatform = navigator.platform;
|
||||||
|
Object.defineProperty(navigator, "platform", {
|
||||||
|
value: "Win32",
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const { unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
act(() => {
|
||||||
|
pressKey("k", window, { ctrlKey: true });
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(navigator, "platform", {
|
||||||
|
value: originalPlatform,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders results when the user types a matching query", async () => {
|
||||||
|
// Seed the store BEFORE the SearchBar reads it. (We seed once per
|
||||||
|
// file scope because happy-dom persists across tests; instead we
|
||||||
|
// seed inside each test that needs it.)
|
||||||
|
useAppStore.setState({
|
||||||
|
claims: CLAIMS,
|
||||||
|
remittances: REMITS,
|
||||||
|
activity: ACTIVITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
const trigger = container.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="search-trigger"]'
|
||||||
|
)!;
|
||||||
|
act(() => {
|
||||||
|
trigger.click();
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'[data-testid="search-input"]'
|
||||||
|
)!;
|
||||||
|
act(() => {
|
||||||
|
typeInto(input, "Nguyen");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounce is 150ms; flush and then settle for the row to appear.
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-result-kind="claim"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const claimRow = document.body.querySelector(
|
||||||
|
'[data-result-kind="claim"]'
|
||||||
|
);
|
||||||
|
expect(claimRow).not.toBeNull();
|
||||||
|
expect(claimRow!.getAttribute("data-result-id")).toBe("CLM-10428");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the selection with ArrowDown / ArrowUp on the input", async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
claims: [
|
||||||
|
{ ...CLAIMS[0]!, id: "CLM-A" },
|
||||||
|
{ ...CLAIMS[0]!, id: "CLM-B" },
|
||||||
|
{ ...CLAIMS[0]!, id: "CLM-C" },
|
||||||
|
],
|
||||||
|
remittances: [],
|
||||||
|
activity: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
act(() => {
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>('[data-testid="search-trigger"]')!
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'[data-testid="search-input"]'
|
||||||
|
)!;
|
||||||
|
act(() => {
|
||||||
|
typeInto(input, "nguyen");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
});
|
||||||
|
await settle((b) => b.querySelectorAll('[data-result-kind="claim"]').length >= 3);
|
||||||
|
|
||||||
|
// Selection starts at 0.
|
||||||
|
let rows = document.body.querySelectorAll('[data-result-kind="claim"]');
|
||||||
|
expect(rows[0]!.getAttribute("data-selected")).not.toBeNull();
|
||||||
|
expect(rows[1]!.getAttribute("data-selected")).toBeNull();
|
||||||
|
|
||||||
|
// ArrowDown → selection 1.
|
||||||
|
act(() => {
|
||||||
|
pressKey("ArrowDown", input);
|
||||||
|
});
|
||||||
|
rows = document.body.querySelectorAll('[data-result-kind="claim"]');
|
||||||
|
expect(rows[0]!.getAttribute("data-selected")).toBeNull();
|
||||||
|
expect(rows[1]!.getAttribute("data-selected")).not.toBeNull();
|
||||||
|
|
||||||
|
// ArrowUp → selection 0 again.
|
||||||
|
act(() => {
|
||||||
|
pressKey("ArrowUp", input);
|
||||||
|
});
|
||||||
|
rows = document.body.querySelectorAll('[data-result-kind="claim"]');
|
||||||
|
expect(rows[0]!.getAttribute("data-selected")).not.toBeNull();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to /claims?claim=<id> when Enter is pressed on a claim row", async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
claims: CLAIMS,
|
||||||
|
remittances: REMITS,
|
||||||
|
activity: ACTIVITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
act(() => {
|
||||||
|
container
|
||||||
|
.querySelector<HTMLButtonElement>('[data-testid="search-trigger"]')!
|
||||||
|
.click();
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'[data-testid="search-input"]'
|
||||||
|
)!;
|
||||||
|
act(() => {
|
||||||
|
typeInto(input, "Nguyen");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-result-kind="claim"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
pressKey("Enter", input);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The dialog should close in response to the selection — when
|
||||||
|
// it closes, Radix removes the portal content. We assert on the
|
||||||
|
// dialog-testid disappearing rather than the router URL (which
|
||||||
|
// we'd need a navigation spy to observe, and MemoryRouter
|
||||||
|
// doesn't expose one).
|
||||||
|
await settle(
|
||||||
|
(b) => b.querySelector('[data-testid="search-dialog"]') === null
|
||||||
|
);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the query and selection when the dialog closes", async () => {
|
||||||
|
useAppStore.setState({
|
||||||
|
claims: CLAIMS,
|
||||||
|
remittances: REMITS,
|
||||||
|
activity: ACTIVITY,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, unmount } = renderIntoContainer(<SearchBar />);
|
||||||
|
const trigger = container.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="search-trigger"]'
|
||||||
|
)!;
|
||||||
|
act(() => {
|
||||||
|
trigger.click();
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'[data-testid="search-input"]'
|
||||||
|
)!;
|
||||||
|
act(() => {
|
||||||
|
typeInto(input, "Nguyen");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close via Escape. Radix's Dialog listens on `document`
|
||||||
|
// (capture phase) via `@radix-ui/react-use-escape-keydown`, so
|
||||||
|
// dispatching on document matches real-user behavior. Window
|
||||||
|
// bubbles to document anyway, but explicit `document` matches
|
||||||
|
// the capture-phase handler more clearly.
|
||||||
|
act(() => {
|
||||||
|
pressKey("Escape", document);
|
||||||
|
});
|
||||||
|
await settle(
|
||||||
|
(b) => b.querySelector('[data-testid="search-dialog"]') === null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reopen — input should be empty.
|
||||||
|
act(() => {
|
||||||
|
trigger.click();
|
||||||
|
});
|
||||||
|
await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="search-dialog"]') !== null
|
||||||
|
);
|
||||||
|
const reopened = document.body.querySelector<HTMLInputElement>(
|
||||||
|
'[data-testid="search-input"]'
|
||||||
|
)!;
|
||||||
|
expect(reopened.value).toBe("");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
destinationFor,
|
||||||
|
flattenResults,
|
||||||
|
useSearch,
|
||||||
|
type SearchResult,
|
||||||
|
} from "@/hooks/useSearch";
|
||||||
|
import { SearchResults, type SearchResultsHandle } from "./SearchResults";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global Cmd-K / Ctrl-K search bar.
|
||||||
|
*
|
||||||
|
* Two pieces:
|
||||||
|
*
|
||||||
|
* 1. **Top-bar button** — always-visible chip in the Layout's top
|
||||||
|
* bar. Clicking opens the palette; pressing ⌘K / Ctrl-K also
|
||||||
|
* opens it from anywhere on the page.
|
||||||
|
*
|
||||||
|
* 2. **Command palette dialog** — centered modal built on the
|
||||||
|
* project's `Dialog` primitive (Radix underneath, free focus
|
||||||
|
* management + ESC-to-close). Hosts an autofocused `<input>` and
|
||||||
|
* a `<SearchResults>` listbox with ↑/↓/Enter keyboard nav.
|
||||||
|
*
|
||||||
|
* Keyboard contract:
|
||||||
|
*
|
||||||
|
* - ⌘K / Ctrl+K → toggle open (from anywhere)
|
||||||
|
* - ↑ / k → move selection up
|
||||||
|
* - ↓ / j → move selection down
|
||||||
|
* - Enter → open the selected result
|
||||||
|
* - Esc → close the palette (handled by Radix's Dialog)
|
||||||
|
* - Backspace / typing in the input → updates the query
|
||||||
|
*
|
||||||
|
* When the dialog is closed, keyboard input flows normally. When
|
||||||
|
* the dialog is open, the ↑/↓/Enter listeners run while the input
|
||||||
|
* is focused — they're owned by the palette, not the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when running on a Mac-class platform. Computed per call rather
|
||||||
|
* than at module load — tests mutate `navigator.platform` and we want
|
||||||
|
* the chord-detection to honor the current value.
|
||||||
|
*/
|
||||||
|
function isMac(): boolean {
|
||||||
|
return (
|
||||||
|
typeof navigator !== "undefined" &&
|
||||||
|
/Mac|iPod|iPhone|iPad/.test(navigator.platform)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pretty label for the modifier key in the top-bar trigger. */
|
||||||
|
const MOD_LABEL = isMac() ? "⌘" : "Ctrl";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the trigger button. Lives in the top bar; opens the
|
||||||
|
* palette on click. Pure presentation — the parent passes `onOpen`.
|
||||||
|
*/
|
||||||
|
function SearchTrigger({ onOpen }: { onOpen: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpen}
|
||||||
|
data-testid="search-trigger"
|
||||||
|
aria-label="Open global search (Cmd or Ctrl + K)"
|
||||||
|
className={cn(
|
||||||
|
"group inline-flex items-center gap-2.5 h-8 pl-2.5 pr-2 rounded-md",
|
||||||
|
"border border-border/60 bg-background/40 hover:bg-background/70",
|
||||||
|
"text-[12px] text-muted-foreground hover:text-foreground",
|
||||||
|
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" strokeWidth={1.75} aria-hidden />
|
||||||
|
<span className="font-medium tracking-tight">Search</span>
|
||||||
|
<span className="ml-2 inline-flex items-center gap-0.5">
|
||||||
|
<kbd className="kbd">{MOD_LABEL}</kbd>
|
||||||
|
<kbd className="kbd">K</kbd>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { query, setQuery, results, isLoading } = useSearch();
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const resultsRef = useRef<SearchResultsHandle>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global ⌘K / Ctrl+K listener. Mounted once at the SearchBar level
|
||||||
|
* (which lives in the Layout — always present). Ignored when the
|
||||||
|
* palette is already open so a second chord doesn't reopen it
|
||||||
|
* while the user is typing.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const mod = isMac() ? e.metaKey : e.ctrlKey;
|
||||||
|
if (!mod) return;
|
||||||
|
if (e.key.toLowerCase() !== "k") return;
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset state when the palette closes — clear the query, drop the
|
||||||
|
* selection. Without this, reopening the palette shows the stale
|
||||||
|
* query from the last session.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) return;
|
||||||
|
setQuery("");
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [open, setQuery]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whenever the result list changes, snap the selected index back
|
||||||
|
* to 0 so the user always sees a selection at the top of the new
|
||||||
|
* list (rather than carrying a stale index from the previous
|
||||||
|
* query that may now point past the end).
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [results.query]);
|
||||||
|
|
||||||
|
/** Flat ordered list — what ↑/↓ step through. */
|
||||||
|
const flat = useMemo(() => flattenResults(results), [results]);
|
||||||
|
const flatTotal = flat.length;
|
||||||
|
|
||||||
|
const moveNext = useCallback(() => {
|
||||||
|
if (flatTotal === 0) return;
|
||||||
|
setSelectedIndex((i) => (i + 1) % flatTotal);
|
||||||
|
}, [flatTotal]);
|
||||||
|
|
||||||
|
const movePrev = useCallback(() => {
|
||||||
|
if (flatTotal === 0) return;
|
||||||
|
// `+ flatTotal` keeps the modulo positive when `i === 0`.
|
||||||
|
setSelectedIndex((i) => (i - 1 + flatTotal) % flatTotal);
|
||||||
|
}, [flatTotal]);
|
||||||
|
|
||||||
|
const openResult = useCallback(
|
||||||
|
(result: SearchResult) => {
|
||||||
|
const dest = destinationFor(result);
|
||||||
|
setOpen(false);
|
||||||
|
// Push the destination via react-router. The page-level
|
||||||
|
// effects (e.g. Claims reading `?claim=` to open its drawer)
|
||||||
|
// react to the new URL on the next render.
|
||||||
|
navigate(dest);
|
||||||
|
},
|
||||||
|
[navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input keydown — own ↑/↓/Enter here so the keys are intercepted
|
||||||
|
* BEFORE the browser handles them (arrows would otherwise move
|
||||||
|
* the text caret, which is wrong inside a listbox picker).
|
||||||
|
* Esc is owned by Radix and bubbles via `onOpenChange`.
|
||||||
|
*/
|
||||||
|
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
moveNext();
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
movePrev();
|
||||||
|
break;
|
||||||
|
case "Enter": {
|
||||||
|
e.preventDefault();
|
||||||
|
const r = flat[selectedIndex];
|
||||||
|
if (r) openResult(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// j/k — vim-style. We don't preempt ⌘K here because that
|
||||||
|
// would conflict with the chord-detection in the global listener.
|
||||||
|
case "j":
|
||||||
|
if (!e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
moveNext();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "k":
|
||||||
|
if (!e.metaKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
movePrev();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchTrigger onOpen={() => setOpen(true)} />
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
// Slightly wider than the default `max-w-lg` to give the
|
||||||
|
// listbox room for two-line rows without truncation.
|
||||||
|
className={cn(
|
||||||
|
"max-w-2xl p-0 gap-0 overflow-hidden",
|
||||||
|
"bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]",
|
||||||
|
"border border-[color:var(--m-border-heavy)]/20"
|
||||||
|
)}
|
||||||
|
aria-describedby={undefined}
|
||||||
|
data-testid="search-dialog"
|
||||||
|
>
|
||||||
|
{/*
|
||||||
|
* Search input row. Autofocuses on mount via Radix's
|
||||||
|
* focus management (the Dialog primitive focuses the first
|
||||||
|
* tabbable element on open). `pl-9` makes room for the
|
||||||
|
* magnifier icon on the left.
|
||||||
|
*/}
|
||||||
|
<div className="relative border-b border-border/40">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
placeholder="Search claims, remittances, activity…"
|
||||||
|
role="combobox"
|
||||||
|
aria-label="Search claims, remittances, and activity"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="search-results"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={onInputKeyDown}
|
||||||
|
data-testid="search-input"
|
||||||
|
className={cn(
|
||||||
|
"h-12 pl-9 pr-3 border-0 bg-transparent rounded-none",
|
||||||
|
"text-[14px] font-mono tracking-tight",
|
||||||
|
"shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results listbox. `id="search-results"` is the value of the
|
||||||
|
input's `aria-controls` attribute — links the two for
|
||||||
|
screen readers per the combobox pattern. */}
|
||||||
|
<div id="search-results">
|
||||||
|
<SearchResults
|
||||||
|
ref={resultsRef}
|
||||||
|
grouped={results}
|
||||||
|
selectedIndex={selectedIndex}
|
||||||
|
onSelect={openResult}
|
||||||
|
onHoverIndex={setSelectedIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer hint — shows the keyboard contract. */}
|
||||||
|
<div className="flex items-center justify-between gap-3 px-3 py-2 border-t border-border/40 text-[10.5px] font-mono uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<kbd className="kbd">↑</kbd>
|
||||||
|
<kbd className="kbd">↓</kbd>
|
||||||
|
navigate
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<kbd className="kbd">↵</kbd>
|
||||||
|
open
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<kbd className="kbd">Esc</kbd>
|
||||||
|
close
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="text-muted-foreground/70">loading…</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/70">
|
||||||
|
{flatTotal} {flatTotal === 1 ? "result" : "results"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// SearchResults is pure presentation — no React Query, no router.
|
||||||
|
// Mirror the IS_REACT_ACT_ENVIRONMENT flag from the other component
|
||||||
|
// tests so React doesn't log act() warnings during render/unmount.
|
||||||
|
(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 { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { SearchResults } from "./SearchResults";
|
||||||
|
import type { SearchResult, SearchResultsGrouped } from "@/hooks/useSearch";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeGrouped(
|
||||||
|
claims: SearchResult[],
|
||||||
|
remits: SearchResult[],
|
||||||
|
activity: SearchResult[] = [],
|
||||||
|
query = "ngu"
|
||||||
|
): SearchResultsGrouped {
|
||||||
|
return {
|
||||||
|
claims,
|
||||||
|
remittances: remits,
|
||||||
|
activity,
|
||||||
|
total: claims.length + remits.length + activity.length,
|
||||||
|
query,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function claim(id: string, title = `Claim ${id}`): SearchResult {
|
||||||
|
return {
|
||||||
|
kind: "claim",
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
subtitle: "subtitle",
|
||||||
|
badge: "paid",
|
||||||
|
score: 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function remit(id: string): SearchResult {
|
||||||
|
return {
|
||||||
|
kind: "remittance",
|
||||||
|
id,
|
||||||
|
title: `Remit ${id}`,
|
||||||
|
subtitle: "subtitle",
|
||||||
|
badge: "received",
|
||||||
|
score: 50,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function activity(id: string, kind = "claim_paid"): SearchResult {
|
||||||
|
return {
|
||||||
|
kind: "activity",
|
||||||
|
id,
|
||||||
|
title: `Activity ${id}`,
|
||||||
|
subtitle: "subtitle",
|
||||||
|
badge: kind,
|
||||||
|
score: 25,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderIntoContainer(element: React.ReactElement): {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
unmount: () => void;
|
||||||
|
} {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(element);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find a result row by its data-result-index attribute (0-based). */
|
||||||
|
function rowAt(parent: HTMLElement, index: number): HTMLElement | null {
|
||||||
|
return parent.querySelector(`[data-result-index="${index}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("SearchResults", () => {
|
||||||
|
it("renders the empty state when there are no results", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped([], [], [], "")}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// The listbox role is always present so screen readers can find
|
||||||
|
// it; only the empty state's text differentiates "no results".
|
||||||
|
expect(container.querySelector('[role="listbox"]')).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Type to search");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the query in the empty state when results are empty but a query exists", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped([], [], [], "nope")}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("nope");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders group headers with counts", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped(
|
||||||
|
[claim("CLM-1"), claim("CLM-2")],
|
||||||
|
[remit("REM-1")]
|
||||||
|
)}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("Claims · 2");
|
||||||
|
expect(container.textContent).toContain("Remittances · 1");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders one row per result, in flat display order", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped(
|
||||||
|
[claim("CLM-1"), claim("CLM-2")],
|
||||||
|
[remit("REM-1"), remit("REM-2")],
|
||||||
|
[activity("ACT-1")]
|
||||||
|
)}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
// 5 rows total, indexed 0..4, with kinds in order: claim, claim,
|
||||||
|
// remit, remit, activity.
|
||||||
|
expect(rowAt(container, 0)?.getAttribute("data-result-kind")).toBe("claim");
|
||||||
|
expect(rowAt(container, 1)?.getAttribute("data-result-kind")).toBe("claim");
|
||||||
|
expect(rowAt(container, 2)?.getAttribute("data-result-kind")).toBe("remittance");
|
||||||
|
expect(rowAt(container, 3)?.getAttribute("data-result-kind")).toBe("remittance");
|
||||||
|
expect(rowAt(container, 4)?.getAttribute("data-result-kind")).toBe("activity");
|
||||||
|
// The data-result-id should round-trip.
|
||||||
|
expect(rowAt(container, 0)?.getAttribute("data-result-id")).toBe("CLM-1");
|
||||||
|
expect(rowAt(container, 4)?.getAttribute("data-result-id")).toBe("ACT-1");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the selected row with data-selected", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped([claim("CLM-1"), claim("CLM-2")], [])}
|
||||||
|
selectedIndex={1}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(rowAt(container, 0)?.hasAttribute("data-selected")).toBe(false);
|
||||||
|
expect(rowAt(container, 1)?.hasAttribute("data-selected")).toBe(true);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onSelect with the clicked result on mousedown", () => {
|
||||||
|
const onSelect = vi.fn();
|
||||||
|
const c = claim("CLM-99");
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped([c], [])}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const row = rowAt(container, 0)!;
|
||||||
|
expect(row).not.toBeNull();
|
||||||
|
act(() => {
|
||||||
|
row.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
|
||||||
|
});
|
||||||
|
expect(onSelect).toHaveBeenCalledWith(c);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onHoverIndex with the row index on mouseenter", () => {
|
||||||
|
const onHover = vi.fn();
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped(
|
||||||
|
[claim("CLM-1"), claim("CLM-2"), claim("CLM-3")],
|
||||||
|
[]
|
||||||
|
)}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={onHover}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
// React's synthetic `onMouseEnter` is implemented on top of the
|
||||||
|
// native `mouseover` event (which DOES bubble) — the native
|
||||||
|
// `mouseenter` event does NOT bubble, so dispatching it here
|
||||||
|
// would never reach React's listener. `mouseover` with
|
||||||
|
// bubbles:true is the canonical way to trigger React's
|
||||||
|
// `onMouseEnter` in a unit test.
|
||||||
|
rowAt(container, 2)!.dispatchEvent(
|
||||||
|
new MouseEvent("mouseover", { bubbles: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(onHover).toHaveBeenCalledWith(2);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a focusIndex imperative handle that scrolls the matching row into view", () => {
|
||||||
|
// Replace scrollIntoView on the prototype so we can spy on it
|
||||||
|
// without a real scrollable container. happy-dom doesn't actually
|
||||||
|
// lay out the page so the call would be a no-op otherwise.
|
||||||
|
const scrollSpy = vi.fn();
|
||||||
|
const proto = HTMLElement.prototype as unknown as {
|
||||||
|
scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
|
||||||
|
};
|
||||||
|
const original = proto.scrollIntoView;
|
||||||
|
proto.scrollIntoView = scrollSpy;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ref = React.createRef<{ focusIndex: (i: number) => void }>();
|
||||||
|
const { unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
ref={ref}
|
||||||
|
grouped={makeGrouped(
|
||||||
|
[claim("CLM-1"), claim("CLM-2"), claim("CLM-3")],
|
||||||
|
[]
|
||||||
|
)}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
ref.current?.focusIndex(2);
|
||||||
|
});
|
||||||
|
expect(scrollSpy).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
} finally {
|
||||||
|
proto.scrollIntoView = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only renders groups that have at least one result", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped(
|
||||||
|
[claim("CLM-1")],
|
||||||
|
[],
|
||||||
|
[]
|
||||||
|
)}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("Claims · 1");
|
||||||
|
expect(container.textContent).not.toContain("Remittances");
|
||||||
|
expect(container.textContent).not.toContain("Activity ·");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders rows with role=option and aria-selected", () => {
|
||||||
|
const { container, unmount } = renderIntoContainer(
|
||||||
|
<SearchResults
|
||||||
|
grouped={makeGrouped([claim("CLM-1"), claim("CLM-2")], [])}
|
||||||
|
selectedIndex={0}
|
||||||
|
onSelect={vi.fn()}
|
||||||
|
onHoverIndex={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const rows = container.querySelectorAll('[role="option"]');
|
||||||
|
expect(rows.length).toBe(2);
|
||||||
|
expect(rows[0]!.getAttribute("aria-selected")).toBe("true");
|
||||||
|
expect(rows[1]!.getAttribute("aria-selected")).toBe("false");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||||
|
import { Receipt, Stethoscope, Activity as ActivityIcon } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { SearchResult, SearchResultsGrouped } from "@/hooks/useSearch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result row — one entity hit, clickable / Enter-able.
|
||||||
|
*
|
||||||
|
* The "selected" state is owned by the parent (the SearchBar wires
|
||||||
|
* keyboard nav) — we just consume it via the `selected` boolean so
|
||||||
|
* the row can paint its accent. We also expose `data-result-index`
|
||||||
|
* so the parent's index-based keyboard nav can scroll the matching
|
||||||
|
* row into view via `scrollIntoView`.
|
||||||
|
*/
|
||||||
|
type ResultRowProps = {
|
||||||
|
result: SearchResult;
|
||||||
|
selected: boolean;
|
||||||
|
index: number;
|
||||||
|
onSelect: (result: SearchResult) => void;
|
||||||
|
onHover: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ICON_BY_KIND = {
|
||||||
|
claim: Receipt,
|
||||||
|
remittance: Stethoscope,
|
||||||
|
activity: ActivityIcon,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ResultRow({
|
||||||
|
result,
|
||||||
|
selected,
|
||||||
|
index,
|
||||||
|
onSelect,
|
||||||
|
onHover,
|
||||||
|
}: ResultRowProps) {
|
||||||
|
const Icon = ICON_BY_KIND[result.kind];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
// `data-result-index` lets the parent keyboard handler call
|
||||||
|
// `el.scrollIntoView()` after a nav move — see SearchBar.
|
||||||
|
data-result-index={index}
|
||||||
|
data-result-kind={result.kind}
|
||||||
|
data-result-id={result.id}
|
||||||
|
data-selected={selected || undefined}
|
||||||
|
// Row is keyboard-focusable so screen readers and keyboard-only
|
||||||
|
// users see something focusable moving under ↑/↓. We manage the
|
||||||
|
// focused-row state ourselves (via the `selected` prop) rather
|
||||||
|
// than relying on the browser's focus ring because the input
|
||||||
|
// stays focused while the palette is open.
|
||||||
|
aria-selected={selected}
|
||||||
|
role="option"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// `mousedown` rather than `click` so the input doesn't blur
|
||||||
|
// before the navigation fires — otherwise the user sees a
|
||||||
|
// frame of "input lost focus" before the dialog closes.
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect(result);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => onHover(index)}
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors",
|
||||||
|
"focus:outline-none",
|
||||||
|
selected
|
||||||
|
? "bg-accent/15 ring-1 ring-inset ring-accent/50"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded-md",
|
||||||
|
selected
|
||||||
|
? "bg-accent/20 text-accent"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" strokeWidth={1.75} />
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-[13px] font-medium truncate">
|
||||||
|
{result.title}
|
||||||
|
</span>
|
||||||
|
<span className="block text-[11px] font-mono text-muted-foreground truncate">
|
||||||
|
{result.subtitle}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 px-1.5 py-0.5 rounded text-[10px] font-mono uppercase tracking-[0.08em]",
|
||||||
|
"bg-muted/70 text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{result.badge}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section header — small caps label above each group. */
|
||||||
|
function GroupHeader({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="px-3 pt-3 pb-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the "no matches" state. */
|
||||||
|
function EmptyState({ query }: { query: string }) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-10 text-center text-muted-foreground">
|
||||||
|
<div className="text-[12px] font-mono">
|
||||||
|
{query
|
||||||
|
? <>No matches for <span className="text-foreground">{query}</span></>
|
||||||
|
: "Type to search across claims, remittances, and activity."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public ref API — the parent (SearchBar) drives navigation from
|
||||||
|
* outside the list, so we expose an imperative handle with
|
||||||
|
* `focusIndex(i)`. The implementation scrolls the matching row into
|
||||||
|
* view; we don't move the actual DOM focus (the input owns that),
|
||||||
|
* but we paint the row as selected via the parent's state.
|
||||||
|
*/
|
||||||
|
export interface SearchResultsHandle {
|
||||||
|
focusIndex: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResultsProps {
|
||||||
|
grouped: SearchResultsGrouped;
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (result: SearchResult) => void;
|
||||||
|
onHoverIndex: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchResults = forwardRef<SearchResultsHandle, SearchResultsProps>(
|
||||||
|
function SearchResults(
|
||||||
|
{ grouped, selectedIndex, onSelect, onHoverIndex },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the row at `index` into view when it changes. We do this
|
||||||
|
* imperatively (rather than relying on the browser's native focus
|
||||||
|
* scroll) because the input owns focus during keyboard nav — moving
|
||||||
|
* DOM focus would close the virtual keyboard on mobile and break
|
||||||
|
* the "input stays focused" invariant.
|
||||||
|
*/
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focusIndex: (index: number) => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
const el = list.querySelector<HTMLElement>(
|
||||||
|
`[data-result-index="${index}"]`
|
||||||
|
);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ block: "nearest" });
|
||||||
|
},
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const total = grouped.total;
|
||||||
|
const isEmpty = total === 0;
|
||||||
|
|
||||||
|
// Track the running flat-index so `data-result-index` is stable
|
||||||
|
// and matches what the parent passes in `selectedIndex`.
|
||||||
|
let runningIndex = 0;
|
||||||
|
const claimNodes = grouped.claims.map((r) => {
|
||||||
|
const idx = runningIndex++;
|
||||||
|
return (
|
||||||
|
<ResultRow
|
||||||
|
key={`claim-${r.id}-${idx}`}
|
||||||
|
result={r}
|
||||||
|
index={idx}
|
||||||
|
selected={idx === selectedIndex}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onHover={onHoverIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const remitNodes = grouped.remittances.map((r) => {
|
||||||
|
const idx = runningIndex++;
|
||||||
|
return (
|
||||||
|
<ResultRow
|
||||||
|
key={`remit-${r.id}-${idx}`}
|
||||||
|
result={r}
|
||||||
|
index={idx}
|
||||||
|
selected={idx === selectedIndex}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onHover={onHoverIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const activityNodes = grouped.activity.map((r) => {
|
||||||
|
const idx = runningIndex++;
|
||||||
|
return (
|
||||||
|
<ResultRow
|
||||||
|
key={`activity-${r.id}-${idx}`}
|
||||||
|
result={r}
|
||||||
|
index={idx}
|
||||||
|
selected={idx === selectedIndex}
|
||||||
|
onSelect={onSelect}
|
||||||
|
onHover={onHoverIndex}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the selected row visible any time the parent updates
|
||||||
|
// `selectedIndex` (e.g. via ↑/↓). The `useImperativeHandle` above
|
||||||
|
// does the same thing on demand; this effect picks up the cases
|
||||||
|
// where the parent doesn't call into us explicitly.
|
||||||
|
useEffect(() => {
|
||||||
|
const list = listRef.current;
|
||||||
|
if (!list) return;
|
||||||
|
const el = list.querySelector<HTMLElement>(
|
||||||
|
`[data-result-index="${selectedIndex}"]`
|
||||||
|
);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ block: "nearest" });
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
// `role="listbox"` for the whole results container; each row
|
||||||
|
// is `role="option"`. This is what a screen reader expects
|
||||||
|
// for a "combobox with listbox popup" pattern.
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Search results"
|
||||||
|
className="max-h-[60vh] overflow-y-auto py-1"
|
||||||
|
>
|
||||||
|
{isEmpty ? (
|
||||||
|
<EmptyState query={grouped.query} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{claimNodes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<GroupHeader label={`Claims · ${claimNodes.length}`} />
|
||||||
|
<div className="space-y-0.5 px-1">{claimNodes}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{remitNodes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<GroupHeader label={`Remittances · ${remitNodes.length}`} />
|
||||||
|
<div className="space-y-0.5 px-1">{remitNodes}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activityNodes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<GroupHeader label={`Activity · ${activityNodes.length}`} />
|
||||||
|
<div className="space-y-0.5 px-1 pb-1">{activityNodes}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// Set the act-aware env flag so React Query / state updates flush
|
||||||
|
// without noisy console warnings. Mirrors the convention in the
|
||||||
|
// sibling hook tests (useDrawerKeyboard, useReconciliation).
|
||||||
|
(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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import { useSearch, destinationFor, flattenResults } from "./useSearch";
|
||||||
|
import { useAppStore } from "@/store";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { Activity, Claim, Remittance } from "@/types";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The hook reads from the in-memory zustand store via
|
||||||
|
// `useSyncExternalStore` when `api.isConfigured` is false. Default
|
||||||
|
// vitest env (`VITE_API_BASE_URL: "http://test.local"`) flips
|
||||||
|
// `api.isConfigured` to TRUE — we want to test the fallback code
|
||||||
|
// path here (it's the one the demo runs in), so mock `api` to report
|
||||||
|
// itself unconfigured.
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
isConfigured: false,
|
||||||
|
listClaims: vi.fn(),
|
||||||
|
listRemittances: vi.fn(),
|
||||||
|
listActivity: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test fixtures
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CLAIMS: Claim[] = [
|
||||||
|
{
|
||||||
|
id: "CLM-10428",
|
||||||
|
patientName: "Avery Nguyen",
|
||||||
|
providerNpi: "1730187395",
|
||||||
|
payerName: "Blue Cross Blue Shield",
|
||||||
|
cptCode: "99213",
|
||||||
|
billedAmount: 240,
|
||||||
|
receivedAmount: 240,
|
||||||
|
status: "paid",
|
||||||
|
submissionDate: "2026-05-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CLM-10429",
|
||||||
|
patientName: "Jordan Patel",
|
||||||
|
providerNpi: "1528471902",
|
||||||
|
payerName: "United Healthcare",
|
||||||
|
cptCode: "99214",
|
||||||
|
billedAmount: 410,
|
||||||
|
receivedAmount: 0,
|
||||||
|
status: "denied",
|
||||||
|
submissionDate: "2026-05-02T00:00:00Z",
|
||||||
|
denialReason: "CO-97: Service included in another service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "CLM-10430",
|
||||||
|
patientName: "Riley Garcia",
|
||||||
|
providerNpi: "1982036471",
|
||||||
|
payerName: "Aetna",
|
||||||
|
cptCode: "85025",
|
||||||
|
billedAmount: 95,
|
||||||
|
receivedAmount: 0,
|
||||||
|
status: "submitted",
|
||||||
|
submissionDate: "2026-05-03T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const REMITS: Remittance[] = [
|
||||||
|
{
|
||||||
|
id: "REM-7782",
|
||||||
|
claimId: "CLM-10428",
|
||||||
|
payerName: "Blue Cross Blue Shield",
|
||||||
|
paidAmount: 240,
|
||||||
|
adjustmentAmount: 0,
|
||||||
|
receivedDate: "2026-05-10T00:00:00Z",
|
||||||
|
checkNumber: "EFT-100123",
|
||||||
|
status: "reconciled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "REM-7783",
|
||||||
|
claimId: "CLM-10429",
|
||||||
|
payerName: "United Healthcare",
|
||||||
|
paidAmount: 0,
|
||||||
|
adjustmentAmount: 410,
|
||||||
|
receivedDate: "2026-05-11T00:00:00Z",
|
||||||
|
checkNumber: "EFT-100124",
|
||||||
|
status: "posted",
|
||||||
|
denialReason: "CO-97: Service included in another service",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIVITY: Activity[] = [
|
||||||
|
{
|
||||||
|
id: "ACT-CLM-10428",
|
||||||
|
kind: "claim_paid",
|
||||||
|
message: "Paid CLM-10428 · Avery Nguyen",
|
||||||
|
timestamp: "2026-05-10T00:00:00Z",
|
||||||
|
npi: "1730187395",
|
||||||
|
amount: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ACT-CLM-10429",
|
||||||
|
kind: "claim_denied",
|
||||||
|
message: "Denied CLM-10429 · Jordan Patel",
|
||||||
|
timestamp: "2026-05-11T00:00:00Z",
|
||||||
|
npi: "1528471902",
|
||||||
|
amount: 410,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ACT-CLM-10430",
|
||||||
|
kind: "claim_submitted",
|
||||||
|
message: "Submitted CLM-10430 · Riley Garcia",
|
||||||
|
timestamp: "2026-05-12T00:00:00Z",
|
||||||
|
npi: "1982036471",
|
||||||
|
amount: 95,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Minimal renderHook shim using createRoot — matches the pattern
|
||||||
|
* in the sibling hook tests. */
|
||||||
|
function renderHook<TResult>(
|
||||||
|
useHook: () => TResult
|
||||||
|
): { result: { current: TResult | undefined }; unmount: () => void } {
|
||||||
|
const result: { current: TResult | undefined } = { current: undefined };
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
// A QueryClient is only strictly needed when `api.isConfigured` is
|
||||||
|
// true, but supplying one is harmless either way and future-proofs
|
||||||
|
// the test if the mock flips.
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
result.current = useHook();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client: qc },
|
||||||
|
React.createElement(Probe)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
result,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Yield to the timer queue so the 150ms debounce in `useSearch`
|
||||||
|
* can flush. `act` wraps the wait so React state updates triggered
|
||||||
|
* by the debounced setter are visible to subsequent assertions. */
|
||||||
|
async function flushDebounce(): Promise<void> {
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("useSearch", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Seed the in-memory zustand store with our fixtures so the
|
||||||
|
// hook's `useSyncExternalStore` snapshot returns them.
|
||||||
|
useAppStore.setState({
|
||||||
|
claims: CLAIMS,
|
||||||
|
remittances: REMITS,
|
||||||
|
activity: ACTIVITY,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty results for an empty query", () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
expect(result.current?.query).toBe("");
|
||||||
|
expect(result.current?.results.total).toBe(0);
|
||||||
|
expect(result.current?.results.claims).toEqual([]);
|
||||||
|
expect(result.current?.results.remittances).toEqual([]);
|
||||||
|
expect(result.current?.results.activity).toEqual([]);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("debounces the query — results stay empty within the debounce window", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("Nguyen");
|
||||||
|
});
|
||||||
|
// Query is updated synchronously (so the input can echo it),
|
||||||
|
// but results should not have run yet — debounce is 150ms.
|
||||||
|
expect(result.current?.query).toBe("Nguyen");
|
||||||
|
expect(result.current?.results.total).toBe(0);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches claims by patient name", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("Nguyen");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
expect(result.current?.results.claims.length).toBeGreaterThan(0);
|
||||||
|
expect(result.current?.results.claims[0]?.id).toBe("CLM-10428");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches claims by id fragment", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("CLM-10430");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
expect(result.current?.results.claims[0]?.id).toBe("CLM-10430");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches claims by payer name", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("Aetna");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
expect(result.current?.results.claims.length).toBeGreaterThan(0);
|
||||||
|
expect(result.current?.results.claims[0]?.id).toBe("CLM-10430");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches remittances by check number", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("EFT-100124");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
expect(result.current?.results.remittances.length).toBeGreaterThan(0);
|
||||||
|
expect(result.current?.results.remittances[0]?.id).toBe("REM-7783");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches activity by message text", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("Garcia");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
expect(result.current?.results.activity.length).toBeGreaterThan(0);
|
||||||
|
expect(result.current?.results.activity[0]?.id).toBe("ACT-CLM-10430");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks exact-id matches above substring matches", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("CLM-10428");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
const flat = flattenResults(result.current!.results);
|
||||||
|
// The exact-id claim should be the first result.
|
||||||
|
expect(flat[0]?.id).toBe("CLM-10428");
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps each group at MAX_PER_GROUP (10)", async () => {
|
||||||
|
// Build a store with many claims all matching the same query.
|
||||||
|
const many: Claim[] = Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
...CLAIMS[0]!,
|
||||||
|
id: `CLM-${20000 + i}`,
|
||||||
|
patientName: `Patient ${i}`,
|
||||||
|
}));
|
||||||
|
useAppStore.setState({ claims: many, remittances: [], activity: [] });
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("Patient");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
// 25 matching claims, but the cap should hold at 10.
|
||||||
|
expect(result.current?.results.claims.length).toBe(10);
|
||||||
|
expect(result.current?.results.total).toBe(10);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the loading flag (false in fallback mode)", () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
expect(result.current?.isLoading).toBe(false);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns total === 0 when nothing matches", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
result.current?.setQuery("zzzzzz-no-such-string");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
expect(result.current?.results.total).toBe(0);
|
||||||
|
expect(result.current?.results.claims).toEqual([]);
|
||||||
|
expect(result.current?.results.remittances).toEqual([]);
|
||||||
|
expect(result.current?.results.activity).toEqual([]);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattens results in display order: claims → remittances → activity", async () => {
|
||||||
|
const { result, unmount } = renderHook(() => useSearch());
|
||||||
|
act(() => {
|
||||||
|
// A query that hits all three entity types.
|
||||||
|
result.current?.setQuery("10428");
|
||||||
|
});
|
||||||
|
await flushDebounce();
|
||||||
|
const flat = flattenResults(result.current!.results);
|
||||||
|
expect(flat.length).toBeGreaterThan(0);
|
||||||
|
// The first three should be claims (we have one CLM-10428 match);
|
||||||
|
// remits with claimId=CLM-10428 come next; activity with that
|
||||||
|
// claim id comes last.
|
||||||
|
const kinds = flat.map((r) => r.kind);
|
||||||
|
// Find the first index of each kind, in order:
|
||||||
|
const firstClaim = kinds.indexOf("claim");
|
||||||
|
const firstRemit = kinds.indexOf("remittance");
|
||||||
|
const firstActivity = kinds.indexOf("activity");
|
||||||
|
if (firstRemit !== -1) expect(firstRemit).toBeGreaterThan(firstClaim);
|
||||||
|
if (firstActivity !== -1) expect(firstActivity).toBeGreaterThan(firstClaim);
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("destinationFor", () => {
|
||||||
|
it("routes claim results to /claims?claim=<id>", () => {
|
||||||
|
const url = destinationFor({
|
||||||
|
kind: "claim",
|
||||||
|
id: "CLM-10428",
|
||||||
|
title: "",
|
||||||
|
subtitle: "",
|
||||||
|
badge: "paid",
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
expect(url).toBe("/claims?claim=CLM-10428");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("URL-encodes claim ids with special characters", () => {
|
||||||
|
const url = destinationFor({
|
||||||
|
kind: "claim",
|
||||||
|
id: "CLM/A B",
|
||||||
|
title: "",
|
||||||
|
subtitle: "",
|
||||||
|
badge: "paid",
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
expect(url).toBe("/claims?claim=CLM%2FA%20B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes remittance results to /remittances", () => {
|
||||||
|
const url = destinationFor({
|
||||||
|
kind: "remittance",
|
||||||
|
id: "REM-7782",
|
||||||
|
title: "",
|
||||||
|
subtitle: "",
|
||||||
|
badge: "reconciled",
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
expect(url).toBe("/remittances");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes activity results to /activity?kind=<kind>", () => {
|
||||||
|
const url = destinationFor({
|
||||||
|
kind: "activity",
|
||||||
|
id: "ACT-CLM-10428",
|
||||||
|
title: "",
|
||||||
|
subtitle: "",
|
||||||
|
badge: "claim_paid",
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
expect(url).toBe("/activity?kind=claim_paid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// `api` is mocked at the top of the file — silence the "imported but
|
||||||
|
// unused" lint with this re-export, which also documents that the
|
||||||
|
// mock is the load-bearing piece of the test setup.
|
||||||
|
void api;
|
||||||
@@ -0,0 +1,438 @@
|
|||||||
|
/**
|
||||||
|
* Global search hook — Cmd-K palette backing logic.
|
||||||
|
*
|
||||||
|
* Loads a generous initial slice of claims, remittances, and activity
|
||||||
|
* from the existing `api.list*` endpoints (or the in-memory zustand
|
||||||
|
* store in unconfigured mode) once when the hook mounts, then runs
|
||||||
|
* an in-memory filter+rank against whatever the user has typed.
|
||||||
|
*
|
||||||
|
* Design notes:
|
||||||
|
*
|
||||||
|
* - **Why fetch a slice, not everything?** The persistence layer
|
||||||
|
* supports a `limit` param; 200 is small enough to be fast and
|
||||||
|
* large enough to cover the demo dataset. We don't paginate the
|
||||||
|
* search corpus — typing should feel instant.
|
||||||
|
*
|
||||||
|
* - **Why in-memory and not a dedicated `/api/search` endpoint?**
|
||||||
|
* Avoids backend churn for a UI feature. The in-memory filter is
|
||||||
|
* cheap (three arrays of ≤200 entries each) and works against the
|
||||||
|
* sample-data fallback identically.
|
||||||
|
*
|
||||||
|
* - **Why debounce?** Each keystroke would otherwise trigger three
|
||||||
|
* ranking passes plus a re-render. 150ms is the sweet spot where
|
||||||
|
* typing feels uninterrupted but we don't recompute on every
|
||||||
|
* character.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
import { api, type PaginatedResponse } from "@/lib/api";
|
||||||
|
import { useAppStore } from "@/store";
|
||||||
|
import type { Activity, Claim, Remittance } from "@/types";
|
||||||
|
|
||||||
|
/** Max items we pull into the in-memory search corpus per entity type. */
|
||||||
|
const SEARCH_CORPUS_LIMIT = 200;
|
||||||
|
|
||||||
|
/** Debounce window for the query input. */
|
||||||
|
const DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
|
/** Hard cap on results returned to the UI. Keeps the palette snappy. */
|
||||||
|
const MAX_RESULTS = 50;
|
||||||
|
|
||||||
|
/** Per-group cap so a single entity type can't dominate the palette. */
|
||||||
|
const MAX_PER_GROUP = 10;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type SearchResultKind = "claim" | "remittance" | "activity";
|
||||||
|
|
||||||
|
/** Discriminated union — each variant carries the entity id + a display row. */
|
||||||
|
export type SearchResult =
|
||||||
|
| {
|
||||||
|
kind: "claim";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
badge: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "remittance";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
badge: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "activity";
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
badge: string;
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Result list grouped by entity type — what the palette renders. */
|
||||||
|
export interface SearchResultsGrouped {
|
||||||
|
claims: SearchResult[];
|
||||||
|
remittances: SearchResult[];
|
||||||
|
activity: SearchResult[];
|
||||||
|
total: number;
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return shape of {@link useSearch}. */
|
||||||
|
export interface UseSearchResult {
|
||||||
|
/** Debounced query — what the ranking is actually run against. */
|
||||||
|
query: string;
|
||||||
|
/** Setter for the input field. */
|
||||||
|
setQuery: (q: string) => void;
|
||||||
|
/** Grouped, scored results (already capped). */
|
||||||
|
results: SearchResultsGrouped;
|
||||||
|
/** True while the corpus is being fetched. */
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Scoring
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score a single candidate string against the (lower-cased) query.
|
||||||
|
*
|
||||||
|
* Priority (highest → lowest):
|
||||||
|
*
|
||||||
|
* 1. **exact match** — query equals the candidate (`42` === `42`). The
|
||||||
|
* single most useful hit when typing a numeric ID fragment.
|
||||||
|
* 2. **prefix match** — candidate starts with the query (`CLM-10…` for
|
||||||
|
* `CLM-10`). The next best "I'm typing the start of an ID" case.
|
||||||
|
* 3. **substring match** — query appears anywhere (`Garcia` matching
|
||||||
|
* `Avery Garcia`).
|
||||||
|
* 4. **fuzzy subsequence** — every character of the query appears in
|
||||||
|
* the candidate in order. Catches typos like `nguyne` → `Nguyen`.
|
||||||
|
*
|
||||||
|
* Returns 0 when there's no match at all.
|
||||||
|
*/
|
||||||
|
function scoreField(haystack: string | undefined, needle: string): number {
|
||||||
|
if (!haystack) return 0;
|
||||||
|
const hay = haystack.toLowerCase();
|
||||||
|
if (hay === needle) return 100;
|
||||||
|
if (hay.startsWith(needle)) return 60;
|
||||||
|
const idx = hay.indexOf(needle);
|
||||||
|
if (idx >= 0) {
|
||||||
|
// Earlier matches score slightly higher — leading `Bills` matches
|
||||||
|
// `Blue Cross Blue Shield` more meaningfully than trailing.
|
||||||
|
const positionBonus = Math.max(0, 20 - idx);
|
||||||
|
return 30 + positionBonus;
|
||||||
|
}
|
||||||
|
// Subsequence fuzzy match — every char of needle appears in hay in
|
||||||
|
// order, with no minimum-length requirement (1-char queries would
|
||||||
|
// otherwise match everything and starve exact matches).
|
||||||
|
let hi = 0;
|
||||||
|
for (let ni = 0; ni < needle.length; ni++) {
|
||||||
|
const ch = needle[ni];
|
||||||
|
const found = hay.indexOf(ch, hi);
|
||||||
|
if (found === -1) return 0;
|
||||||
|
hi = found + 1;
|
||||||
|
}
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum the best score across a set of candidate fields. */
|
||||||
|
function scoreAgainstFields(
|
||||||
|
query: string,
|
||||||
|
fields: (string | undefined)[]
|
||||||
|
): number {
|
||||||
|
let best = 0;
|
||||||
|
for (const f of fields) {
|
||||||
|
const s = scoreField(f, query);
|
||||||
|
if (s > best) best = s;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-entity rankers — each returns a SearchResult or null when no match
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rankClaim(c: Claim, query: string): SearchResult | null {
|
||||||
|
// Order matters loosely — ID first so typing "CLM-" jumps to claim
|
||||||
|
// rows, then patient, then payer, then code. scoreAgainstFields
|
||||||
|
// picks the best across all of them so the order here is purely
|
||||||
|
// about which fields carry more weight in practice.
|
||||||
|
const score = scoreAgainstFields(query, [
|
||||||
|
c.id,
|
||||||
|
c.patientName,
|
||||||
|
c.payerName,
|
||||||
|
c.cptCode,
|
||||||
|
c.providerNpi,
|
||||||
|
c.denialReason,
|
||||||
|
]);
|
||||||
|
if (score === 0) return null;
|
||||||
|
return {
|
||||||
|
kind: "claim",
|
||||||
|
id: c.id,
|
||||||
|
title: `${c.id} · ${c.patientName}`,
|
||||||
|
subtitle: `${c.payerName} · ${c.cptCode} · $${c.billedAmount.toLocaleString()}`,
|
||||||
|
badge: c.status,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankRemittance(r: Remittance, query: string): SearchResult | null {
|
||||||
|
const score = scoreAgainstFields(query, [
|
||||||
|
r.id,
|
||||||
|
r.claimId,
|
||||||
|
r.payerName,
|
||||||
|
r.checkNumber,
|
||||||
|
r.denialReason ?? undefined,
|
||||||
|
]);
|
||||||
|
if (score === 0) return null;
|
||||||
|
return {
|
||||||
|
kind: "remittance",
|
||||||
|
id: r.id,
|
||||||
|
title: `${r.id} · ${r.payerName}`,
|
||||||
|
subtitle: `Claim ${r.claimId} · Check ${r.checkNumber} · $${r.paidAmount.toLocaleString()}`,
|
||||||
|
badge: r.status,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankActivity(a: Activity, query: string): SearchResult | null {
|
||||||
|
const score = scoreAgainstFields(query, [
|
||||||
|
a.id,
|
||||||
|
a.message,
|
||||||
|
a.kind,
|
||||||
|
a.npi,
|
||||||
|
]);
|
||||||
|
if (score === 0) return null;
|
||||||
|
return {
|
||||||
|
kind: "activity",
|
||||||
|
id: a.id,
|
||||||
|
title: a.message,
|
||||||
|
subtitle: a.timestamp,
|
||||||
|
badge: a.kind,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Corpus fetch (uses existing hooks so fallback mode "just works")
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface SearchCorpus {
|
||||||
|
claims: Claim[];
|
||||||
|
remittances: Remittance[];
|
||||||
|
activity: Activity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the search corpus. Mirrors the fallback-mode strategy used by
|
||||||
|
* `useClaims` / `useRemittances` / `useActivity` so the palette works
|
||||||
|
* identically with the in-memory zustand store and with a live backend.
|
||||||
|
*
|
||||||
|
* We use `useQuery` for the configured path so react-query handles
|
||||||
|
* caching/refetching, and `useSyncExternalStore` for the fallback path
|
||||||
|
* so re-renders fire when the store changes (e.g. after a new claim
|
||||||
|
* submission).
|
||||||
|
*/
|
||||||
|
function useSearchCorpus(): { data: SearchCorpus; isLoading: boolean } {
|
||||||
|
// Fallback snapshot from the zustand store. We pull the three arrays
|
||||||
|
// we care about — `useSyncExternalStore` returns the same snapshot
|
||||||
|
// reference until the store mutates, so this is cheap.
|
||||||
|
const fallbackClaims = useSyncExternalStore(
|
||||||
|
(cb) => useAppStore.subscribe(cb),
|
||||||
|
() => useAppStore.getState().claims,
|
||||||
|
() => useAppStore.getState().claims
|
||||||
|
);
|
||||||
|
const fallbackRemits = useSyncExternalStore(
|
||||||
|
(cb) => useAppStore.subscribe(cb),
|
||||||
|
() => useAppStore.getState().remittances,
|
||||||
|
() => useAppStore.getState().remittances
|
||||||
|
);
|
||||||
|
const fallbackActivity = useSyncExternalStore(
|
||||||
|
(cb) => useAppStore.subscribe(cb),
|
||||||
|
() => useAppStore.getState().activity,
|
||||||
|
() => useAppStore.getState().activity
|
||||||
|
);
|
||||||
|
|
||||||
|
const claimsQ = useQuery<PaginatedResponse<Claim>>({
|
||||||
|
queryKey: ["search-corpus", "claims"],
|
||||||
|
queryFn: () => api.listClaims<Claim>({ limit: SEARCH_CORPUS_LIMIT }),
|
||||||
|
enabled: api.isConfigured,
|
||||||
|
// Keep the corpus cached for a minute — re-fetching on every
|
||||||
|
// palette open would defeat the "instant" goal of in-memory search.
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const remitsQ = useQuery<PaginatedResponse<Remittance>>({
|
||||||
|
queryKey: ["search-corpus", "remittances"],
|
||||||
|
queryFn: () => api.listRemittances<Remittance>({ limit: SEARCH_CORPUS_LIMIT }),
|
||||||
|
enabled: api.isConfigured,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
const activityQ = useQuery<PaginatedResponse<Activity>>({
|
||||||
|
queryKey: ["search-corpus", "activity"],
|
||||||
|
queryFn: () => api.listActivity<Activity>({ limit: SEARCH_CORPUS_LIMIT }),
|
||||||
|
enabled: api.isConfigured,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!api.isConfigured) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
claims: fallbackClaims,
|
||||||
|
remittances: fallbackRemits,
|
||||||
|
activity: fallbackActivity,
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
claims: claimsQ.data?.items ?? [],
|
||||||
|
remittances: remitsQ.data?.items ?? [],
|
||||||
|
activity: activityQ.data?.items ?? [],
|
||||||
|
},
|
||||||
|
isLoading:
|
||||||
|
claimsQ.isLoading || remitsQ.isLoading || activityQ.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Cmd-K search hook.
|
||||||
|
*
|
||||||
|
* Returns the debounced query (so consumers can render the input value
|
||||||
|
* without lag), the setter to wire to the input, the grouped/capped
|
||||||
|
* results, and a loading flag for the corpus fetch.
|
||||||
|
*
|
||||||
|
* The hook is intentionally pure-ish: it does NOT own the dialog
|
||||||
|
* open/close state, navigation, or keyboard wiring. Those belong in
|
||||||
|
* `<SearchBar>` so the hook stays easy to unit test (no Radix
|
||||||
|
* dependencies, no router, no DOM).
|
||||||
|
*/
|
||||||
|
export function useSearch(): UseSearchResult {
|
||||||
|
const [rawQuery, setRawQuery] = useState("");
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Debounce raw → debounced. We use a ref-tracked timer rather than
|
||||||
|
// a `useDebouncedCallback` library so this file has zero deps
|
||||||
|
// beyond what `useClaims` already pulls in.
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setDebouncedQuery(rawQuery);
|
||||||
|
timerRef.current = null;
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [rawQuery]);
|
||||||
|
|
||||||
|
const corpus = useSearchCorpus();
|
||||||
|
|
||||||
|
// The ranking pass. Memoized on the corpus + the *debounced* query
|
||||||
|
// so we don't re-rank on every keystroke — only after the 150ms
|
||||||
|
// debounce window settles.
|
||||||
|
const results = useMemo<SearchResultsGrouped>(() => {
|
||||||
|
const q = debouncedQuery.trim().toLowerCase();
|
||||||
|
if (!q) {
|
||||||
|
return {
|
||||||
|
claims: [],
|
||||||
|
remittances: [],
|
||||||
|
activity: [],
|
||||||
|
total: 0,
|
||||||
|
query: debouncedQuery,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = corpus.data.claims
|
||||||
|
.map((c) => rankClaim(c, q))
|
||||||
|
.filter((r): r is SearchResult => r !== null)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_PER_GROUP);
|
||||||
|
|
||||||
|
const remittances = corpus.data.remittances
|
||||||
|
.map((r) => rankRemittance(r, q))
|
||||||
|
.filter((r): r is SearchResult => r !== null)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_PER_GROUP);
|
||||||
|
|
||||||
|
const activity = corpus.data.activity
|
||||||
|
.map((a) => rankActivity(a, q))
|
||||||
|
.filter((r): r is SearchResult => r !== null)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, MAX_PER_GROUP);
|
||||||
|
|
||||||
|
return {
|
||||||
|
claims,
|
||||||
|
remittances,
|
||||||
|
activity,
|
||||||
|
total: claims.length + remittances.length + activity.length,
|
||||||
|
query: debouncedQuery,
|
||||||
|
};
|
||||||
|
}, [corpus.data.claims, corpus.data.remittances, corpus.data.activity, debouncedQuery]);
|
||||||
|
|
||||||
|
// Stable setter reference — the consumer (an `<input>`) doesn't
|
||||||
|
// need to re-render when our hook internals change.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const setQuery = useCallback((q: string) => setRawQuery(q), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: rawQuery,
|
||||||
|
setQuery,
|
||||||
|
results,
|
||||||
|
isLoading: corpus.isLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Navigation destinations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a search hit to a URL the router can push. Keeps all
|
||||||
|
* "where does an entity go?" knowledge in one place so the SearchBar
|
||||||
|
* component doesn't have to know per-entity routing rules.
|
||||||
|
*
|
||||||
|
* - claim → /claims?claim={id} (opens the drawer deep-link)
|
||||||
|
* - remittance → /remittances (Remittances page; the page
|
||||||
|
* already lets you expand a row
|
||||||
|
* for the adjustment detail)
|
||||||
|
* - activity → /activity?kind={kind} (Activity Log, pre-filtered
|
||||||
|
* to the matching event kind)
|
||||||
|
*/
|
||||||
|
export function destinationFor(result: SearchResult): string {
|
||||||
|
switch (result.kind) {
|
||||||
|
case "claim":
|
||||||
|
return `/claims?claim=${encodeURIComponent(result.id)}`;
|
||||||
|
case "remittance":
|
||||||
|
return "/remittances";
|
||||||
|
case "activity": {
|
||||||
|
// The activity id is "ACT-<claim-id>" by convention; the
|
||||||
|
// ActivityLog page filters by `kind`, so we surface that.
|
||||||
|
const u = new URLSearchParams();
|
||||||
|
u.set("kind", result.badge);
|
||||||
|
return `/activity?${u.toString()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flat list of results in display order — what the keyboard nav steps through. */
|
||||||
|
export function flattenResults(g: SearchResultsGrouped): SearchResult[] {
|
||||||
|
return [...g.claims, ...g.remittances, ...g.activity];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Total cap sentinel — exported for tests so they don't have to hard-code 50. */
|
||||||
|
export const SEARCH_MAX_RESULTS = MAX_RESULTS;
|
||||||
Reference in New Issue
Block a user