feat(frontend): ClaimDrawer skeleton + error states
This commit is contained in:
@@ -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(
|
||||
<ClaimDrawerError kind="not_found" onClose={onClose} />
|
||||
);
|
||||
|
||||
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(
|
||||
<ClaimDrawerError kind="network" onRetry={onRetry} onClose={onClose} />
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ClaimDrawerError kind="network" onClose={onClose} />
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ClaimDrawerError kind="not_found" onClose={onCloseNF} />
|
||||
);
|
||||
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(
|
||||
<ClaimDrawerError
|
||||
kind="network"
|
||||
onRetry={onRetry}
|
||||
onClose={onCloseNet}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<ClaimDrawerError kind="network" onClose={onCloseNet2} />
|
||||
);
|
||||
expect(
|
||||
c3.querySelector('[data-testid="error-close"]')
|
||||
).not.toBeNull();
|
||||
u3();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
|
||||
role="alert"
|
||||
data-testid={`claim-drawer-error-${kind}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="h-5 w-5 text-[color:var(--m-error)]"
|
||||
strokeWidth={1.75}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-error)]">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[color:var(--m-ink-secondary)]">{message}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{kind === "network" && onRetry ? (
|
||||
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
|
||||
Retry
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onClick={onClose} data-testid="error-close">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(<ClaimDrawerSkeleton />);
|
||||
|
||||
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(<ClaimDrawerSkeleton />);
|
||||
|
||||
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(<ClaimDrawerSkeleton />);
|
||||
unmount();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div
|
||||
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
|
||||
data-testid="claim-drawer-skeleton"
|
||||
aria-busy="true"
|
||||
>
|
||||
{/* Header band — taller, prominent */}
|
||||
<Skeleton data-testid="skeleton-header" variant="default" height={48} />
|
||||
|
||||
{/* Validation panel — short row */}
|
||||
<Skeleton data-testid="skeleton-validation" variant="row" />
|
||||
|
||||
{/* Service lines — three stacked rows */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton data-testid="skeleton-lines-1" variant="row" />
|
||||
<Skeleton data-testid="skeleton-lines-2" variant="row" />
|
||||
<Skeleton data-testid="skeleton-lines-3" variant="row" />
|
||||
</div>
|
||||
|
||||
{/* Diagnoses — two short text rows */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton data-testid="skeleton-dx-1" variant="text" width="60%" />
|
||||
<Skeleton data-testid="skeleton-dx-2" variant="text" width="45%" />
|
||||
</div>
|
||||
|
||||
{/* Parties — two rows */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton data-testid="skeleton-parties-1" variant="row" />
|
||||
<Skeleton data-testid="skeleton-parties-2" variant="row" />
|
||||
</div>
|
||||
|
||||
{/* History — tall block */}
|
||||
<Skeleton data-testid="skeleton-history" variant="default" height={96} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
type SkeletonVariant = "default" | "text" | "circle" | "row";
|
||||
|
||||
type SkeletonProps = {
|
||||
className?: string;
|
||||
type SkeletonProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
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}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
Reference in New Issue
Block a user