From 67653f0da81c87f659108c71c342ce4eb0e7fb4a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 20 Jun 2026 11:09:41 -0600 Subject: [PATCH] feat(frontend): ClaimDrawer skeleton + error states --- .../ClaimDrawer/ClaimDrawerError.test.tsx | 157 ++++++++++++++++++ .../ClaimDrawer/ClaimDrawerError.tsx | 62 +++++++ .../ClaimDrawer/ClaimDrawerSkeleton.test.tsx | 120 +++++++++++++ .../ClaimDrawer/ClaimDrawerSkeleton.tsx | 46 +++++ src/components/ui/skeleton.tsx | 5 +- 5 files changed, 388 insertions(+), 2 deletions(-) create mode 100644 src/components/ClaimDrawer/ClaimDrawerError.test.tsx create mode 100644 src/components/ClaimDrawer/ClaimDrawerError.tsx create mode 100644 src/components/ClaimDrawer/ClaimDrawerSkeleton.test.tsx create mode 100644 src/components/ClaimDrawer/ClaimDrawerSkeleton.tsx diff --git a/src/components/ClaimDrawer/ClaimDrawerError.test.tsx b/src/components/ClaimDrawer/ClaimDrawerError.test.tsx new file mode 100644 index 0000000..34d509a --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawerError.test.tsx @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom +// React Query's internal state updates need an `act`-aware environment or +// it logs noisy warnings. Match the convention from useClaimDetail.test.ts. +(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 { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ClaimDrawerError } from "./ClaimDrawerError"; + +function renderIntoContainer(element: React.ReactElement): { + 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( + React.createElement(QueryClientProvider, { client: qc }, element) + ); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("ClaimDrawerError", () => { + it("renders_not_found_message_and_close_button", () => { + // The container surface marks itself with role=alert; that gives AT + // a single announcement region to listen for. + const onClose = vi.fn(); + const { container, unmount } = renderIntoContainer( + + ); + + const root = container.firstElementChild as HTMLElement; + expect(root.getAttribute("role")).toBe("alert"); + expect( + root.getAttribute("data-testid")?.includes("not_found") + ).toBe(true); + + // Message body — case-insensitive substring match against "not found". + const text = (container.textContent ?? "").toLowerCase(); + expect(text).toContain("not found"); + + // Close button is wired up. + const closeBtn = container.querySelector( + '[data-testid="error-close"]' + ) as HTMLButtonElement | null; + expect(closeBtn).not.toBeNull(); + expect(closeBtn?.textContent?.toLowerCase()).toContain("close"); + expect(onClose).not.toHaveBeenCalled(); + act(() => { + closeBtn?.click(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("renders_network_message_and_retry_button", () => { + const onRetry = vi.fn(); + const onClose = vi.fn(); + const { container, unmount } = renderIntoContainer( + + ); + + // Message body — at least one of the three hints ("network", + // "connection", "try again") should appear, case-insensitive. + const text = (container.textContent ?? "").toLowerCase(); + expect( + text.includes("network") || + text.includes("connection") || + text.includes("try again") + ).toBe(true); + + // Retry button fires onRetry. + const retryBtn = container.querySelector( + '[data-testid="error-retry"]' + ) as HTMLButtonElement | null; + expect(retryBtn).not.toBeNull(); + act(() => { + retryBtn?.click(); + }); + expect(onRetry).toHaveBeenCalledTimes(1); + + // Close still fires onClose. + const closeBtn = container.querySelector( + '[data-testid="error-close"]' + ) as HTMLButtonElement | null; + expect(closeBtn).not.toBeNull(); + act(() => { + closeBtn?.click(); + }); + expect(onClose).toHaveBeenCalledTimes(1); + + unmount(); + }); + + it("omits_retry_button_when_onRetry_not_provided", () => { + const onClose = vi.fn(); + const { container, unmount } = renderIntoContainer( + + ); + + // No retry button when handler not supplied — no point promising + // a retry the caller can't fulfill. + const retryBtn = container.querySelector('[data-testid="error-retry"]'); + expect(retryBtn).toBeNull(); + + unmount(); + }); + + it("close_button_always_present", () => { + // not_found branch. + const onCloseNF = vi.fn(); + const { container: c1, unmount: u1 } = renderIntoContainer( + + ); + expect( + c1.querySelector('[data-testid="error-close"]') + ).not.toBeNull(); + u1(); + + // network branch (with retry). + const onRetry = vi.fn(); + const onCloseNet = vi.fn(); + const { container: c2, unmount: u2 } = renderIntoContainer( + + ); + expect( + c2.querySelector('[data-testid="error-close"]') + ).not.toBeNull(); + u2(); + + // network branch (no retry) — close still required. + const onCloseNet2 = vi.fn(); + const { container: c3, unmount: u3 } = renderIntoContainer( + + ); + expect( + c3.querySelector('[data-testid="error-close"]') + ).not.toBeNull(); + u3(); + }); +}); \ No newline at end of file diff --git a/src/components/ClaimDrawer/ClaimDrawerError.tsx b/src/components/ClaimDrawer/ClaimDrawerError.tsx new file mode 100644 index 0000000..7e33fab --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawerError.tsx @@ -0,0 +1,62 @@ +import { AlertCircle, WifiOff } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +type ClaimDrawerErrorProps = { + kind: "not_found" | "network"; + onRetry?: () => void; + onClose: () => void; +}; + +const COPY = { + not_found: { + eyebrow: "NOT FOUND", + message: "This claim doesn't exist or has been removed.", + }, + network: { + eyebrow: "CONNECTION", + message: "Couldn't reach the server. Check your connection and try again.", + }, +} as const; + +/** + * Error state for the claim detail drawer (SP4). + * + * Two shapes — `not_found` (the claim id in the URL doesn't resolve on + * the server, e.g. a stale deep link) and `network` (the request + * failed). The not_found variant has no retry affordance (retrying won't + * help), the network variant does when `onRetry` is supplied. + */ +export function ClaimDrawerError({ kind, onRetry, onClose }: ClaimDrawerErrorProps) { + const { eyebrow, message } = COPY[kind]; + const Icon = kind === "network" ? WifiOff : AlertCircle; + + return ( +
+
+ + + {eyebrow} + +
+

{message}

+
+ {kind === "network" && onRetry ? ( + + ) : null} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/ClaimDrawer/ClaimDrawerSkeleton.test.tsx b/src/components/ClaimDrawer/ClaimDrawerSkeleton.test.tsx new file mode 100644 index 0000000..6e95aa1 --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawerSkeleton.test.tsx @@ -0,0 +1,120 @@ +// @vitest-environment happy-dom +// React Query's internal state updates need an `act`-aware environment or +// it logs noisy warnings. Match the convention from useClaimDetail.test.ts. +(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 } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton"; + +function renderIntoContainer(element: React.ReactElement): { + 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( + React.createElement(QueryClientProvider, { client: qc }, element) + ); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("ClaimDrawerSkeleton", () => { + it("renders_eight_to_twelve_skeleton_primitives", () => { + // The Skeleton primitive marks itself aria-busy="true" for AT. + // Counting aria-busy elements tells us how many primitives rendered, + // independent of layout or copy. + const { unmount } = renderIntoContainer(); + + const busyCount = document.querySelectorAll('[aria-busy="true"]').length; + expect(busyCount).toBeGreaterThanOrEqual(8); + expect(busyCount).toBeLessThanOrEqual(12); + + unmount(); + }); + + it("renders_one_primitives_per_drawer_section", () => { + // The skeleton must mirror the drawer's section structure so the + // layout doesn't jump when real data arrives. Each section gets a + // labelled testid primitive. + const { container, unmount } = renderIntoContainer(); + + const header = container.querySelector('[data-testid="skeleton-header"]'); + const validation = container.querySelector( + '[data-testid="skeleton-validation"]' + ); + const lines1 = container.querySelector('[data-testid="skeleton-lines-1"]'); + const lines2 = container.querySelector('[data-testid="skeleton-lines-2"]'); + const lines3 = container.querySelector('[data-testid="skeleton-lines-3"]'); + const dx1 = container.querySelector('[data-testid="skeleton-dx-1"]'); + const dx2 = container.querySelector('[data-testid="skeleton-dx-2"]'); + const parties1 = container.querySelector( + '[data-testid="skeleton-parties-1"]' + ); + const parties2 = container.querySelector( + '[data-testid="skeleton-parties-2"]' + ); + const history = container.querySelector('[data-testid="skeleton-history"]'); + + // Every section primitive is present. + expect(header).not.toBeNull(); + expect(validation).not.toBeNull(); + expect(lines1).not.toBeNull(); + expect(lines2).not.toBeNull(); + expect(lines3).not.toBeNull(); + expect(dx1).not.toBeNull(); + expect(dx2).not.toBeNull(); + expect(parties1).not.toBeNull(); + expect(parties2).not.toBeNull(); + expect(history).not.toBeNull(); + + // Header band should be visibly taller than the validation row — it + // hosts the claim id, status, billed amount etc. + // happy-dom doesn't do layout, so we read the inline styles the + // Skeleton primitive writes from `height`/`width` props. + const headerHeight = Number( + (header as HTMLElement).style.height.replace("px", "") + ); + const validationHeight = Number( + (validation as HTMLElement).style.height.replace("px", "") || "36" + ); + expect(headerHeight).toBeGreaterThan(validationHeight); + + // Header should be wider than the dx rows (60% / 45%). + const headerWidth = (header as HTMLElement).style.width || "100%"; + const dx1Width = (dx1 as HTMLElement).style.width; + // "100%" vs "60%" — compare as percentage strings. + const headerPct = Number(headerWidth.replace("%", "")); + const dx1Pct = Number(dx1Width.replace("%", "")); + expect(headerPct).toBeGreaterThan(dx1Pct); + + // History block is taller than the validation row. + const historyHeight = Number( + (history as HTMLElement).style.height.replace("px", "") + ); + expect(historyHeight).toBeGreaterThan(validationHeight); + + unmount(); + }); + + it("no_props_required", () => { + // The contract: "No props." Calling the component with no props + // must not throw and must render something. + expect(() => { + const { unmount } = renderIntoContainer(); + unmount(); + }).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/components/ClaimDrawer/ClaimDrawerSkeleton.tsx b/src/components/ClaimDrawer/ClaimDrawerSkeleton.tsx new file mode 100644 index 0000000..6a72432 --- /dev/null +++ b/src/components/ClaimDrawer/ClaimDrawerSkeleton.tsx @@ -0,0 +1,46 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** + * Loading state for the claim detail drawer (SP4). + * + * Mirrors the drawer's section heights so the layout doesn't jump when + * data arrives. 10 primitives across 6 sections: header (1), validation + * (1), service lines (3 rows), diagnoses (2), parties (2), history (1). + */ +export function ClaimDrawerSkeleton() { + return ( +
+ {/* Header band — taller, prominent */} + + + {/* Validation panel — short row */} + + + {/* Service lines — three stacked rows */} +
+ + + +
+ + {/* Diagnoses — two short text rows */} +
+ + +
+ + {/* Parties — two rows */} +
+ + +
+ + {/* History — tall block */} + +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx index 37cf573..7ef9ba7 100644 --- a/src/components/ui/skeleton.tsx +++ b/src/components/ui/skeleton.tsx @@ -3,8 +3,7 @@ import { cn } from "@/lib/utils"; type SkeletonVariant = "default" | "text" | "circle" | "row"; -type SkeletonProps = { - className?: string; +type SkeletonProps = React.HTMLAttributes & { variant?: SkeletonVariant; width?: string | number; height?: string | number; @@ -28,6 +27,7 @@ export function Skeleton({ variant = "default", width, height, + ...props }: SkeletonProps) { const style: React.CSSProperties = { ...(width !== undefined ? { width } : null), @@ -45,6 +45,7 @@ export function Skeleton({ style={style} aria-busy="true" aria-live="polite" + {...props} >