merge: ui/keyboard-acks-remits — j/k row nav + ? cheatsheet on Acks and Remittances
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Mirror the IS_REACT_ACT_ENVIRONMENT setup from `useDrawerKeyboard.test.ts`
|
||||
// so React doesn't log act() warnings about the createRoot 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, beforeEach } from "vitest";
|
||||
import { useRowKeyboard } from "./useRowKeyboard";
|
||||
|
||||
/**
|
||||
* Minimal renderHook shim — same shape as the project's other hook tests.
|
||||
* `useRowKeyboard` returns `void` (its side effect is the window listener),
|
||||
* so the shim doesn't surface a `result`; we only need `unmount` for
|
||||
* cleanup verification.
|
||||
*/
|
||||
function renderHook(setup: () => void): { unmount: () => void } {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
function Probe() {
|
||||
setup();
|
||||
return null;
|
||||
}
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
|
||||
return {
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a synthetic keydown. By default the target is `window`
|
||||
* (which is what a real keypress bubbles to). Pass an explicit target
|
||||
* for the "input has focus" test. `bubbles: true` + `cancelable: true`
|
||||
* so the event behaves like a real keydown — the handler calls
|
||||
* `preventDefault` on matched keys.
|
||||
*/
|
||||
function pressKey(
|
||||
key: string,
|
||||
target: EventTarget = window,
|
||||
init: KeyboardEventInit = {}
|
||||
): void {
|
||||
const event = new KeyboardEvent("keydown", {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
describe("useRowKeyboard", () => {
|
||||
// vi.fn() with no args in vitest 4 is typed `Mock<Procedure |
|
||||
// Constructable>`, which carries a constructor signature and won't
|
||||
// assign to `() => void`. Constraining the generic to `vi.fn<() => void>()`
|
||||
// gives a plain function-typed mock that flows directly into the hook's
|
||||
// callback params.
|
||||
let onNext: () => void;
|
||||
let onPrev: () => void;
|
||||
let onClose: () => void;
|
||||
let onToggleHelp: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
onNext = vi.fn<() => void>();
|
||||
onPrev = vi.fn<() => void>();
|
||||
onClose = vi.fn<() => void>();
|
||||
onToggleHelp = vi.fn<() => void>();
|
||||
});
|
||||
|
||||
it("fires onNext for j and ArrowDown", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
pressKey("j");
|
||||
expect(onNext).toHaveBeenCalledTimes(1);
|
||||
expect(onPrev).not.toHaveBeenCalled();
|
||||
|
||||
pressKey("ArrowDown");
|
||||
expect(onNext).toHaveBeenCalledTimes(2);
|
||||
expect(onPrev).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("fires onPrev for k and ArrowUp", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
pressKey("k");
|
||||
expect(onPrev).toHaveBeenCalledTimes(1);
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
|
||||
pressKey("ArrowUp");
|
||||
expect(onPrev).toHaveBeenCalledTimes(2);
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("fires onClose when Escape is pressed", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
pressKey("Escape");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
expect(onPrev).not.toHaveBeenCalled();
|
||||
expect(onToggleHelp).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('fires onToggleHelp when "?" is pressed', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
pressKey("?");
|
||||
expect(onToggleHelp).toHaveBeenCalledTimes(1);
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
expect(onPrev).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("ignores keys when an input has focus", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
// Mount a real input and focus it. A real keystroke while the input
|
||||
// is focused would have `event.target === input` and bubble to
|
||||
// window — we simulate that by dispatching the keydown on the input
|
||||
// itself with `bubbles: true`.
|
||||
const input = document.createElement("input");
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
expect(document.activeElement).toBe(input);
|
||||
|
||||
pressKey("j", input);
|
||||
pressKey("Escape", input);
|
||||
pressKey("?", input);
|
||||
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onToggleHelp).not.toHaveBeenCalled();
|
||||
|
||||
input.remove();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("ignores keys when a textarea has focus", () => {
|
||||
// Same protection as <input>: rich text fields must not be hijacked.
|
||||
// Mirrors the contenteditable note in the hook's JSDoc.
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
|
||||
pressKey("j", textarea);
|
||||
pressKey("k", textarea);
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
expect(onPrev).not.toHaveBeenCalled();
|
||||
|
||||
textarea.remove();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("does nothing when enabled is false", () => {
|
||||
// The `enabled` flag is the recommended way to yield to an open
|
||||
// overlay (e.g. the cheatsheet). When false, the listener must not
|
||||
// attach at all — no keydown should reach our handlers.
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: false, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
pressKey("j");
|
||||
pressKey("Escape");
|
||||
pressKey("?");
|
||||
|
||||
expect(onNext).not.toHaveBeenCalled();
|
||||
expect(onPrev).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onToggleHelp).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("cleans up the keydown listener on unmount", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useRowKeyboard({ enabled: true, onNext, onPrev, onClose, onToggleHelp })
|
||||
);
|
||||
|
||||
pressKey("j");
|
||||
expect(onNext).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
|
||||
// After unmount the effect's cleanup has removed the listener, so a
|
||||
// follow-up keydown should be a no-op. We dispatch a few different
|
||||
// keys to be thorough.
|
||||
pressKey("j");
|
||||
pressKey("Escape");
|
||||
pressKey("?");
|
||||
|
||||
expect(onNext).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(onToggleHelp).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/** Tag names whose descendants can receive free-form text input. */
|
||||
const INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
|
||||
|
||||
/**
|
||||
* Returns true when the keyboard event originated inside a free-form
|
||||
* text field, in which case we should NOT consume j/k/escape/`?` —
|
||||
* the user is typing, not navigating the table.
|
||||
*
|
||||
* - `INPUT` / `TEXTAREA` / `SELECT` are the obvious cases.
|
||||
* - `contenteditable` elements (e.g. rich text editors) are the
|
||||
* non-obvious one: `target.isContentEditable` is the standard check.
|
||||
*
|
||||
* Lifted from `useDrawerKeyboard` so both hooks agree on the
|
||||
* "is the user typing?" predicate. Keeping it in lock-step matters:
|
||||
* if the predicates drift, a future search box might accidentally
|
||||
* become a navigation surface in one component but not another.
|
||||
*/
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (INPUT_TAGS.has(target.tagName)) return true;
|
||||
return target.isContentEditable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires keyboard shortcuts for a paginated/filterable table whose rows
|
||||
* are keyboard-navigable (j/k to move, ? for help, Esc to close the
|
||||
* help overlay). When `enabled` is true, listens for `keydown` on
|
||||
* `window` and dispatches:
|
||||
*
|
||||
* j / ArrowDown → onNext
|
||||
* k / ArrowUp → onPrev
|
||||
* Escape → onClose
|
||||
* ? → onToggleHelp
|
||||
*
|
||||
* Semantically a sibling of `useDrawerKeyboard`, but tuned for
|
||||
* list-row navigation rather than a modal drawer:
|
||||
*
|
||||
* - `onNext` / `onPrev` advance/decrement a row index in the
|
||||
* *list* (the caller decides whether to wrap or clamp; this hook
|
||||
* doesn't enforce either policy — it just signals "the user wants
|
||||
* to move").
|
||||
* - `onClose` closes whatever overlay is currently open on the
|
||||
* page — typically just the keyboard-cheatsheet. The hook itself
|
||||
* doesn't own the overlay; it just forwards the key.
|
||||
* - `onToggleHelp` flips the cheatsheet open/closed. The hook does
|
||||
* NOT preempt this on `?` — see KeyboardCheatsheet.tsx for the
|
||||
* matching "skip `?` in the dismiss listener" rule.
|
||||
*
|
||||
* Keys are ignored when an `<input>`, `<textarea>`, `<select>`, or
|
||||
* `contenteditable` element has focus — typing "j" into a search box
|
||||
* must not jump rows. `preventDefault` is called for each matched
|
||||
* key to suppress the browser's default (arrow keys scrolling the
|
||||
* page, Escape closing modals/pausing video, etc.).
|
||||
*
|
||||
* Recommended pairing: pass `enabled: !helpOpen` so j/k nav yields
|
||||
* to the cheatsheet while it's open (the cheatsheet's own dismiss
|
||||
* listener still fires for any keypress, including j/k).
|
||||
*/
|
||||
export function useRowKeyboard(opts: {
|
||||
enabled: boolean;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
onClose: () => void;
|
||||
onToggleHelp: () => void;
|
||||
}): void {
|
||||
const { enabled, onNext, onPrev, onClose, onToggleHelp } = opts;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e.target)) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "j":
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
onNext();
|
||||
break;
|
||||
case "k":
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
onPrev();
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
case "?":
|
||||
e.preventDefault();
|
||||
onToggleHelp();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [enabled, onNext, onPrev, onClose, onToggleHelp]);
|
||||
}
|
||||
+362
-1
@@ -51,6 +51,57 @@ async function waitForText(text: string, timeoutMs = 2000): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a predicate until it holds or we time out. react-query's fetch
|
||||
* resolves asynchronously, and Radix portals content into
|
||||
* `document.body`, so the DOM doesn't reflect new state synchronously
|
||||
* after a keypress — we have to await a tick before asserting.
|
||||
*/
|
||||
async function settle(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 2000
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("settle: predicate did not hold within timeout");
|
||||
}
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a synthetic keydown on `window`. Matches the dispatching
|
||||
* pattern from `useDrawerKeyboard.test.ts` and `Claims.test.tsx` —
|
||||
* `bubbles: true` so the event propagates like a real keypress, and
|
||||
* `cancelable: true` so the hook's `preventDefault` has something to
|
||||
* act on.
|
||||
*/
|
||||
function pressKey(key: string): void {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The row at `idx` (per `data-row-index={idx}` on the row). */
|
||||
function rowAt(idx: number): HTMLTableRowElement | null {
|
||||
return document.querySelector(
|
||||
`tbody tr[data-row-index="${idx}"]`
|
||||
) as HTMLTableRowElement | null;
|
||||
}
|
||||
|
||||
/** True iff exactly one row carries `data-state="selected"`. */
|
||||
function hasExactlyOneSelectedRow(): boolean {
|
||||
const selected = document.querySelectorAll(
|
||||
'tbody tr[data-state="selected"]'
|
||||
);
|
||||
return selected.length === 1;
|
||||
}
|
||||
|
||||
describe("Acks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -87,4 +138,314 @@ describe("Acks", () => {
|
||||
expect(document.body.textContent).toContain("b-uuid-1");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("test_initial_render_shows_no_selection_highlight", async () => {
|
||||
// Three rows in the fixture so there's plenty to select; the test
|
||||
// asserts the *initial* state has no row marked selected.
|
||||
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
sourceBatchId: "b-1",
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sourceBatchId: "b-2",
|
||||
acceptedCount: 2,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 2,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:01:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sourceBatchId: "b-3",
|
||||
acceptedCount: 3,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 3,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:02:00Z",
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
returned: 3,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||
await waitForText("b-3");
|
||||
|
||||
// None of the three rows should be marked selected — the spec
|
||||
// requires the initial render shows no selection highlight.
|
||||
expect(
|
||||
document.querySelectorAll('tbody tr[data-state="selected"]').length
|
||||
).toBe(0);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_pressing_j_increments_selection", async () => {
|
||||
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
sourceBatchId: "b-1",
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sourceBatchId: "b-2",
|
||||
acceptedCount: 2,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 2,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:01:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sourceBatchId: "b-3",
|
||||
acceptedCount: 3,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 3,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:02:00Z",
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
returned: 3,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||
await waitForText("b-3");
|
||||
|
||||
// From null, j lands on row 0.
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
expect(rowAt(1)?.getAttribute("data-state")).not.toBe("selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");
|
||||
|
||||
// j again → row 1.
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
|
||||
expect(rowAt(0)?.getAttribute("data-state")).not.toBe("selected");
|
||||
|
||||
// j again → row 2.
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
// j from last row wraps to row 0 (per the wrap-or-clamp policy
|
||||
// documented in Acks.tsx).
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_pressing_k_decrements_selection_with_wrap", async () => {
|
||||
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
sourceBatchId: "b-1",
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sourceBatchId: "b-2",
|
||||
acceptedCount: 2,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 2,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:01:00Z",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sourceBatchId: "b-3",
|
||||
acceptedCount: 3,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 3,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:02:00Z",
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
returned: 3,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||
await waitForText("b-3");
|
||||
|
||||
// From null, k jumps to the last row — a one-keystroke "bottom
|
||||
// of list" gesture.
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
// k again → row 1.
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).not.toBe("selected");
|
||||
|
||||
// k twice more lands on row 0.
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
// k from row 0 wraps to row 2 (last).
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => {
|
||||
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
sourceBatchId: "b-1",
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
returned: 1,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||
await waitForText("b-1");
|
||||
|
||||
// No cheatsheet initially.
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).toBeNull();
|
||||
|
||||
// `?` opens it (Radix portals into document.body — query there).
|
||||
pressKey("?");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).not.toBeNull();
|
||||
|
||||
// Escape closes it. The page wires onClose via useRowKeyboard
|
||||
// (Escape → onClose → setHelpOpen(false)).
|
||||
pressKey("Escape");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).toBeNull();
|
||||
|
||||
// `?` again re-opens (proving the toggle works in both directions).
|
||||
pressKey("?");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => {
|
||||
// `enabled: !helpOpen` is the recommended pairing with
|
||||
// useRowKeyboard: j/k nav yields to the cheatsheet while it's
|
||||
// open. The cheatsheet's own dismiss listener still closes it on
|
||||
// any non-`?` keypress (per KeyboardCheatsheet.tsx), so pressing
|
||||
// j will close the cheatsheet but the row should NOT have moved.
|
||||
(api.listAcks as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
sourceBatchId: "b-1",
|
||||
acceptedCount: 1,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 1,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sourceBatchId: "b-2",
|
||||
acceptedCount: 2,
|
||||
rejectedCount: 0,
|
||||
receivedCount: 2,
|
||||
ackCode: "A",
|
||||
parsedAt: "2026-06-20T12:01:00Z",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
returned: 2,
|
||||
has_more: false,
|
||||
});
|
||||
|
||||
const { unmount } = renderIntoContainer(React.createElement(Acks));
|
||||
await waitForText("b-2");
|
||||
|
||||
// Move selection to row 0 first (so we can later verify it didn't
|
||||
// jump to row 1 while the cheatsheet is open).
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
|
||||
// Open cheatsheet.
|
||||
pressKey("?");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
|
||||
null
|
||||
);
|
||||
|
||||
// Press j while cheatsheet is open. Cheatsheet closes (its own
|
||||
// listener handles any non-? key), but selection should NOT have
|
||||
// moved — row 0 is still selected.
|
||||
pressKey("j");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).toBeNull();
|
||||
// Row 0 is still the selected row.
|
||||
expect(hasExactlyOneSelectedRow()).toBe(true);
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
+158
-89
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { CheckCircle2, Download } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||
import { useAcks } from "@/hooks/useAcks";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -107,98 +109,165 @@ export function Acks() {
|
||||
const { data, isLoading, isError, error, refetch } = useAcks({ limit: 100 });
|
||||
const items = data?.items ?? [];
|
||||
|
||||
// Row navigation state (SP4-style j/k). `selectedIndex === null`
|
||||
// means "no row focused yet" — the initial render shows no
|
||||
// highlight, and the first j press lands on row 0 (and the first k
|
||||
// press lands on the last row, so the user can quickly jump to
|
||||
// either end of the list).
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const moveNext = useCallback(() => {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
if (i === null) return 0;
|
||||
return (i + 1) % items.length;
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
const movePrev = useCallback(() => {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
if (i === null) return items.length - 1;
|
||||
// Add items.length before the modulo so the negative index
|
||||
// (first row → wrap to last) wraps correctly.
|
||||
return (i - 1 + items.length) % items.length;
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
// `enabled: !helpOpen` so j/k navigation yields to the cheatsheet
|
||||
// while it's open. The cheatsheet's own dismiss listener still fires
|
||||
// for any non-`?` keypress (including j/k), so pressing j will close
|
||||
// the cheatsheet but won't move the row — exactly the behavior the
|
||||
// user expects: "the cheatsheet is in the way, get it out of my
|
||||
// way; I'll start navigating on the next keypress".
|
||||
useRowKeyboard({
|
||||
enabled: !helpOpen && items.length > 0,
|
||||
onNext: moveNext,
|
||||
onPrev: movePrev,
|
||||
onClose: () => setHelpOpen(false),
|
||||
onToggleHelp: () => setHelpOpen((v) => !v),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
999 ACKs
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
999 Implementation Acknowledgments
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
999 ACK transaction sets — generated automatically in response to
|
||||
837P ingests, or parsed from inbound 999 uploads.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load ACKs from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
<>
|
||||
<KeyboardCheatsheet
|
||||
open={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
/>
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
999 ACKs
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="999 ACKs · awaiting first 837 with ack=true"
|
||||
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">
|
||||
999 Implementation Acknowledgments
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
999 ACK transaction sets — generated automatically in response to
|
||||
837P ingests, or parsed from inbound 999 uploads.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load ACKs from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12" aria-label="Status" />
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Source Batch</TableHead>
|
||||
<TableHead className="text-right">Accepted</TableHead>
|
||||
<TableHead className="text-right">Rejected</TableHead>
|
||||
<TableHead className="text-right">Received</TableHead>
|
||||
<TableHead>ACK Code</TableHead>
|
||||
<TableHead>Parsed</TableHead>
|
||||
<TableHead className="w-20" aria-label="Download" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
a.ackCode === "A" ? "text-emerald-400" : a.ackCode === "R" ? "text-red-400" : "text-amber-400",
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{a.id}</TableCell>
|
||||
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
|
||||
{a.sourceBatchId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-emerald-400">
|
||||
{a.acceptedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-red-400">
|
||||
{a.rejectedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{a.receivedCount}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AckCodeBadge code={a.ackCode} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[12.5px]">
|
||||
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DownloadButton id={a.id} sourceBatchId={a.sourceBatchId} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="999 ACKs · awaiting first 837 with ack=true"
|
||||
message="Upload an 837P with ?ack=true, or parse a 999 file directly to populate this list."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12" aria-label="Status" />
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Source Batch</TableHead>
|
||||
<TableHead className="text-right">Accepted</TableHead>
|
||||
<TableHead className="text-right">Rejected</TableHead>
|
||||
<TableHead className="text-right">Received</TableHead>
|
||||
<TableHead>ACK Code</TableHead>
|
||||
<TableHead>Parsed</TableHead>
|
||||
<TableHead className="w-20" aria-label="Download" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((a, idx) => {
|
||||
const isSelected = selectedIndex === idx;
|
||||
return (
|
||||
<TableRow
|
||||
key={a.id}
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
isSelected && [
|
||||
// Accent-tinted bg + ring + left strip make the
|
||||
// selected row clearly stand out from the
|
||||
// default `hover:bg-muted/30` and the
|
||||
// `data-[state=selected]:bg-muted` that the
|
||||
// TableRow primitive applies. Tailwind's
|
||||
// `accent` token is the same accent used
|
||||
// elsewhere in the app (nav-active indicator,
|
||||
// row-flash keyframe, focus ring).
|
||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||
],
|
||||
)}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<CheckCircle2
|
||||
className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
a.ackCode === "A" ? "text-emerald-400" : a.ackCode === "R" ? "text-red-400" : "text-amber-400",
|
||||
)}
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{a.id}</TableCell>
|
||||
<TableCell className="font-mono text-[12px] text-muted-foreground truncate max-w-[180px]">
|
||||
{a.sourceBatchId}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-emerald-400">
|
||||
{a.acceptedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-red-400">
|
||||
{a.rejectedCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{a.receivedCount}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AckCodeBadge code={a.ackCode} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[12.5px]">
|
||||
{a.parsedAt ? fmt.dateShort(a.parsedAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DownloadButton id={a.id} sourceBatchId={a.sourceBatchId} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+276
-33
@@ -75,48 +75,120 @@ async function waitForText(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a predicate until it holds or we time out. react-query's fetch
|
||||
* resolves asynchronously, and Radix portals content into
|
||||
* `document.body`, so the DOM doesn't reflect new state synchronously
|
||||
* after a keypress — we have to await a tick before asserting.
|
||||
*/
|
||||
async function settle(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 2000
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("settle: predicate did not hold within timeout");
|
||||
}
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a synthetic keydown on `window`. Same pattern used in
|
||||
* `Claims.test.tsx` and `useDrawerKeyboard.test.ts`.
|
||||
*/
|
||||
function pressKey(key: string): void {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true })
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The data row at `idx` (per `data-row-index={idx}`). */
|
||||
function rowAt(idx: number): HTMLTableRowElement | null {
|
||||
return document.querySelector(
|
||||
`tbody tr[data-row-index="${idx}"]`
|
||||
) as HTMLTableRowElement | null;
|
||||
}
|
||||
|
||||
/** True iff exactly one row carries `data-state="selected"`. */
|
||||
function hasExactlyOneSelectedRow(): boolean {
|
||||
const selected = document.querySelectorAll(
|
||||
'tbody tr[data-state="selected"]'
|
||||
);
|
||||
return selected.length === 1;
|
||||
}
|
||||
|
||||
/** Fixture used by every test below so the page has rows to navigate. */
|
||||
const SAMPLE_REMITS = [
|
||||
{
|
||||
id: "PCN-1",
|
||||
claimId: "CLM-1",
|
||||
payerName: "Colorado Medicaid",
|
||||
paidAmount: 100,
|
||||
adjustmentAmount: 60,
|
||||
receivedDate: "2026-06-19T12:00:00Z",
|
||||
checkNumber: "",
|
||||
status: "received",
|
||||
adjustments: [
|
||||
{
|
||||
group: "CO",
|
||||
reason: "45",
|
||||
label: "Charge exceeds fee schedule/maximum allowable",
|
||||
amount: 50,
|
||||
quantity: null,
|
||||
},
|
||||
{
|
||||
group: "PR",
|
||||
reason: "1",
|
||||
label: "Deductible amount",
|
||||
amount: 10,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "PCN-2",
|
||||
claimId: "CLM-2",
|
||||
payerName: "Aetna",
|
||||
paidAmount: 200,
|
||||
adjustmentAmount: 0,
|
||||
receivedDate: "2026-06-18T12:00:00Z",
|
||||
checkNumber: "",
|
||||
status: "received",
|
||||
adjustments: [],
|
||||
},
|
||||
{
|
||||
id: "PCN-3",
|
||||
claimId: "CLM-3",
|
||||
payerName: "United",
|
||||
paidAmount: 300,
|
||||
adjustmentAmount: 0,
|
||||
receivedDate: "2026-06-17T12:00:00Z",
|
||||
checkNumber: "",
|
||||
status: "received",
|
||||
adjustments: [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
describe("Remittances", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders a CAS adjustment label inside the expanded detail row", async () => {
|
||||
(
|
||||
api.listRemittances as unknown as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: "PCN-1",
|
||||
claimId: "CLM-1",
|
||||
payerName: "Colorado Medicaid",
|
||||
paidAmount: 100,
|
||||
adjustmentAmount: 60,
|
||||
receivedDate: "2026-06-19T12:00:00Z",
|
||||
checkNumber: "",
|
||||
status: "received",
|
||||
adjustments: [
|
||||
{
|
||||
group: "CO",
|
||||
reason: "45",
|
||||
label: "Charge exceeds fee schedule/maximum allowable",
|
||||
amount: 50,
|
||||
quantity: null,
|
||||
},
|
||||
{
|
||||
group: "PR",
|
||||
reason: "1",
|
||||
label: "Deductible amount",
|
||||
amount: 10,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
returned: 1,
|
||||
items: SAMPLE_REMITS,
|
||||
total: SAMPLE_REMITS.length,
|
||||
returned: SAMPLE_REMITS.length,
|
||||
has_more: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a CAS adjustment label inside the expanded detail row", async () => {
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-1");
|
||||
|
||||
@@ -146,4 +218,175 @@ describe("Remittances", () => {
|
||||
expect(document.body.textContent).toContain("PR-1");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_initial_render_shows_no_selection_highlight", async () => {
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-3");
|
||||
|
||||
// The spec requires the initial render shows no selection
|
||||
// highlight — none of the three data rows should be marked
|
||||
// selected.
|
||||
expect(
|
||||
document.querySelectorAll('tbody tr[data-state="selected"]').length
|
||||
).toBe(0);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_pressing_j_increments_selection_with_wrap", async () => {
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-3");
|
||||
|
||||
// From null, j lands on row 0.
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
// j again → row 1, then row 2.
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
// j from the last row wraps to row 0 (wrap policy documented in
|
||||
// Remittances.tsx — matches Claims' drawer behavior).
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
expect(hasExactlyOneSelectedRow()).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_pressing_k_decrements_selection_with_wrap", async () => {
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-3");
|
||||
|
||||
// From null, k jumps to the last row (one-keystroke "bottom of
|
||||
// list" gesture).
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
// k → row 1, then row 0.
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(1)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
// k from row 0 wraps to row 2 (last).
|
||||
pressKey("k");
|
||||
await settle(() => rowAt(2)?.getAttribute("data-state") === "selected");
|
||||
expect(rowAt(2)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_j_does_not_trigger_expand_collapse", async () => {
|
||||
// j/k selection is a focus indicator, NOT a substitute for the
|
||||
// click-to-expand gesture. Pressing j through a row that has
|
||||
// adjustments must NOT open the detail row.
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-3");
|
||||
|
||||
// Row 0 has adjustments — moving selection onto it should leave
|
||||
// the row collapsed (the user can still click to expand).
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
expect(document.body.textContent).not.toContain("Adjustments (2)");
|
||||
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(1)?.getAttribute("data-state") === "selected");
|
||||
expect(document.body.textContent).not.toContain("Adjustments (2)");
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_question_mark_toggles_cheatsheet_and_escape_closes", async () => {
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-3");
|
||||
|
||||
// No cheatsheet initially.
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).toBeNull();
|
||||
|
||||
// `?` opens it.
|
||||
pressKey("?");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).not.toBeNull();
|
||||
|
||||
// Escape closes it.
|
||||
pressKey("Escape");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).toBeNull();
|
||||
|
||||
// `?` again re-opens (toggle works in both directions).
|
||||
pressKey("?");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_j_does_not_navigate_while_cheatsheet_is_open", async () => {
|
||||
// `enabled: !helpOpen` is the recommended pairing with
|
||||
// useRowKeyboard: j/k nav yields to the cheatsheet while it's
|
||||
// open. Pressing j while the cheatsheet is open closes the
|
||||
// cheatsheet (its own dismiss listener handles any non-`?`
|
||||
// keypress) but the row should NOT have moved.
|
||||
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||
await waitForText("PCN-3");
|
||||
|
||||
// Move selection to row 0 first.
|
||||
pressKey("j");
|
||||
await settle(() => rowAt(0)?.getAttribute("data-state") === "selected");
|
||||
|
||||
// Open cheatsheet.
|
||||
pressKey("?");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') !==
|
||||
null
|
||||
);
|
||||
|
||||
// j while cheatsheet open: closes cheatsheet, row stays at 0.
|
||||
pressKey("j");
|
||||
await settle(
|
||||
() =>
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]') ===
|
||||
null
|
||||
);
|
||||
expect(
|
||||
document.body.querySelector('[data-testid="keyboard-cheatsheet"]')
|
||||
).toBeNull();
|
||||
expect(hasExactlyOneSelectedRow()).toBe(true);
|
||||
expect(rowAt(0)?.getAttribute("data-state")).toBe("selected");
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
+224
-167
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Fragment, useCallback, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -14,7 +14,9 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ErrorState } from "@/components/ui/error-state";
|
||||
import { FilterChips, type FilterChipOption } from "@/components/ui/filter-chips";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||
import { useRemittances } from "@/hooks/useRemittances";
|
||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CasAdjustment, RemittanceStatus } from "@/types";
|
||||
@@ -57,6 +59,11 @@ export function Remittances() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
// Row navigation (j/k) lives in parallel with `expanded`; selection
|
||||
// is purely a focus-state indicator and does NOT auto-expand the
|
||||
// row (so j/k doesn't fight the existing click-to-expand gesture).
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
||||
sort: "receivedDate",
|
||||
@@ -83,181 +90,231 @@ export function Remittances() {
|
||||
});
|
||||
};
|
||||
|
||||
const moveNext = useCallback(() => {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
if (i === null) return 0;
|
||||
return (i + 1) % items.length;
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
const movePrev = useCallback(() => {
|
||||
setSelectedIndex((i) => {
|
||||
if (items.length === 0) return null;
|
||||
if (i === null) return items.length - 1;
|
||||
// Add items.length before the modulo so the negative index
|
||||
// (first row → wrap to last) wraps correctly.
|
||||
return (i - 1 + items.length) % items.length;
|
||||
});
|
||||
}, [items.length]);
|
||||
|
||||
// `enabled: !helpOpen` so j/k yields to the cheatsheet while it's
|
||||
// open (the cheatsheet's own dismiss listener still closes it on
|
||||
// any non-`?` keypress). See Acks.tsx for the full rationale.
|
||||
useRowKeyboard({
|
||||
enabled: !helpOpen && items.length > 0,
|
||||
onNext: moveNext,
|
||||
onPrev: movePrev,
|
||||
onClose: () => setHelpOpen(false),
|
||||
onToggleHelp: () => setHelpOpen((v) => !v),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Remittances
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
Electronic remittance advice from payers, matched to submitted claims.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load remittances from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Remits
|
||||
<>
|
||||
<KeyboardCheatsheet
|
||||
open={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
/>
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<header>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
||||
<span className="inline-block h-px w-6 bg-border" />
|
||||
Remittances
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.num(data?.total ?? 0)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Total paid
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Adjustments
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-[22px] font-semibold tracking-tight">835 remittances</h1>
|
||||
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
||||
Electronic remittance advice from payers, matched to submitted claims.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="surface rounded-xl p-4">
|
||||
<FilterChips
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as RemittanceStatus | null) ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
eyebrow="Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="Remittances · awaiting first 835"
|
||||
message="Drop an 835 file on the Upload page to populate this list."
|
||||
{isError ? (
|
||||
<ErrorState
|
||||
message="Couldn't load remittances from the backend."
|
||||
detail={error instanceof Error ? error.message : String(error)}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" aria-label="Expand" />
|
||||
<TableHead>Remit</TableHead>
|
||||
<TableHead>Claim</TableHead>
|
||||
<TableHead>Payer</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Adjustment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Received</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r) => {
|
||||
const isOpen = expanded.has(r.id);
|
||||
const hasAdjustments =
|
||||
!!r.adjustments && r.adjustments.length > 0;
|
||||
return (
|
||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
||||
<TableRow
|
||||
className={cn("animate-row-flash")}
|
||||
onClick={() =>
|
||||
hasAdjustments ? toggleExpand(r.id) : undefined
|
||||
}
|
||||
aria-expanded={hasAdjustments ? isOpen : undefined}
|
||||
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
||||
>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{hasAdjustments ? (
|
||||
isOpen ? (
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||||
<TableCell className="display num text-[13px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display num">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[13px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isOpen && hasAdjustments ? (
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3">
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Remits
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.num(data?.total ?? 0)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Total paid
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.paid)}</div>
|
||||
</div>
|
||||
<div className="surface rounded-xl p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Adjustments
|
||||
</div>
|
||||
<div className="display text-[28px] leading-none mt-3">{fmt.usd(total.adjustments)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl p-4">
|
||||
<FilterChips
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus((v as RemittanceStatus | null) ?? null);
|
||||
setPage(1);
|
||||
}}
|
||||
eyebrow="Status"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-4 space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="row" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState
|
||||
eyebrow="Remittances · awaiting first 835"
|
||||
message="Drop an 835 file on the Upload page to populate this list."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" aria-label="Expand" />
|
||||
<TableHead>Remit</TableHead>
|
||||
<TableHead>Claim</TableHead>
|
||||
<TableHead>Payer</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Adjustment</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Received</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((r, idx) => {
|
||||
const isOpen = expanded.has(r.id);
|
||||
const hasAdjustments =
|
||||
!!r.adjustments && r.adjustments.length > 0;
|
||||
const isSelected = selectedIndex === idx;
|
||||
return (
|
||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
||||
<TableRow
|
||||
key={`${r.id}-${dataUpdatedAt}-detail`}
|
||||
className="bg-muted/30 hover:bg-muted/30"
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"animate-row-flash",
|
||||
isSelected && [
|
||||
// Same accent-tinted highlight as Acks:
|
||||
// background tint + inset ring + left
|
||||
// accent strip, layered on top of the
|
||||
// TableRow primitive's
|
||||
// `data-[state=selected]:bg-muted` to
|
||||
// win visually.
|
||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||
],
|
||||
)}
|
||||
onClick={() =>
|
||||
hasAdjustments ? toggleExpand(r.id) : undefined
|
||||
}
|
||||
aria-expanded={hasAdjustments ? isOpen : undefined}
|
||||
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
||||
>
|
||||
<TableCell />
|
||||
<TableCell colSpan={7} className="py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Receipt
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Adjustments ({r.adjustments!.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5">
|
||||
{r.adjustments!.map((adj, i) => (
|
||||
<AdjustmentRow
|
||||
key={`${adj.group}-${adj.reason}-${i}`}
|
||||
adj={adj}
|
||||
<TableCell className="text-muted-foreground">
|
||||
{hasAdjustments ? (
|
||||
isOpen ? (
|
||||
<ChevronDown
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className="h-3.5 w-3.5"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="display num text-[13px]">{r.id}</TableCell>
|
||||
<TableCell className="display num text-[13px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display num">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display num text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground num text-[13px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="px-4 pb-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={data?.total ?? 0}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isOpen && hasAdjustments ? (
|
||||
<TableRow
|
||||
key={`${r.id}-${dataUpdatedAt}-detail`}
|
||||
className="bg-muted/30 hover:bg-muted/30"
|
||||
>
|
||||
<TableCell />
|
||||
<TableCell colSpan={7} className="py-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Receipt
|
||||
className="h-3.5 w-3.5 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Adjustments ({r.adjustments!.length})
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-5">
|
||||
{r.adjustments!.map((adj, i) => (
|
||||
<AdjustmentRow
|
||||
key={`${adj.group}-${adj.reason}-${i}`}
|
||||
adj={adj}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="px-4 pb-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
total={data?.total ?? 0}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user