feat(sp8): Claim drawer Download 837 button
Three pieces:
- src/lib/download.ts: generic downloadTextFile(filename, mime, text)
helper. Mirrors csv.ts:downloadCsv but takes an explicit MIME type and
drops the BOM prepend (which would corrupt the ISA segment).
- src/lib/api.ts: serializeClaim837(id) → {text, filename}. Fetches
GET /api/claims/{id}/serialize-837, pulls the suggested filename from
Content-Disposition (falls back to claim-{id}.x12 if the header is
missing). Throws ApiError on non-2xx so callers can branch on .status.
- ClaimDrawerHeader: Download icon button between the amount and the
close button. Click → api.serializeClaim837 → downloadTextFile.
Disabled + 'Downloading 837 file' aria-label while the fetch is in
flight so the click feels responsive. Optional onError prop surfaces
fetch failures; defaults to a no-op so existing callers stay clean.
Tests: 3 download.test.ts, 3 api.test.ts, 2 header.test.ts (happy
path + error path). Frontend: 350 passing (+8 from 342).
This commit is contained in:
@@ -7,10 +7,19 @@
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
|
||||
import * as apiModule from "@/lib/api";
|
||||
import * as downloadModule from "@/lib/download";
|
||||
import type { ClaimDetail } from "@/types";
|
||||
|
||||
// Mock the api + download helpers so we can assert the wiring without
|
||||
// hitting the network or the real Blob/anchor machinery. The spies live
|
||||
// at module scope but `beforeEach` resets their call history so the
|
||||
// happy-path test doesn't leak into the error-path test.
|
||||
const serializeSpy = vi.spyOn(apiModule.api, "serializeClaim837");
|
||||
const downloadSpy = vi.spyOn(downloadModule, "downloadTextFile");
|
||||
|
||||
function renderIntoContainer(element: React.ReactElement): {
|
||||
container: HTMLDivElement;
|
||||
unmount: () => void;
|
||||
@@ -104,16 +113,21 @@ function makeDetail(overrides: Partial<ClaimDetail> = {}): ClaimDetail {
|
||||
|
||||
function renderHeader(
|
||||
claim: Partial<ClaimDetail>,
|
||||
onClose: () => void = vi.fn()
|
||||
): { container: HTMLDivElement; unmount: () => void; onClose: () => void } {
|
||||
onClose: () => void = vi.fn(),
|
||||
onError: (message: string) => void = vi.fn()
|
||||
): { container: HTMLDivElement; unmount: () => void; onClose: () => void; onError: (m: string) => void } {
|
||||
const element = (
|
||||
<ClaimDrawerHeader claim={makeDetail(claim)} onClose={onClose} />
|
||||
<ClaimDrawerHeader claim={makeDetail(claim)} onClose={onClose} onError={onError} />
|
||||
);
|
||||
const { container, unmount } = renderIntoContainer(element);
|
||||
return { container, unmount, onClose };
|
||||
return { container, unmount, onClose, onError };
|
||||
}
|
||||
|
||||
describe("ClaimDrawerHeader", () => {
|
||||
beforeEach(() => {
|
||||
serializeSpy.mockReset();
|
||||
downloadSpy.mockReset();
|
||||
});
|
||||
it("test_renders_claim_id_label_and_value", () => {
|
||||
const { container, unmount } = renderHeader({ id: "CLM-42" });
|
||||
|
||||
@@ -226,4 +240,64 @@ describe("ClaimDrawerHeader", () => {
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_download_button_fetches_and_saves_file_for_claim_id", async () => {
|
||||
// Happy path: click → api.serializeClaim837(claim.id) → downloadTextFile
|
||||
// with the returned text + filename. We don't actually need to wait for
|
||||
// the download side-effect — we just verify the wiring (right ids,
|
||||
// right payload, right mime).
|
||||
serializeSpy.mockResolvedValueOnce({
|
||||
text: "ISA*00*~IEA*0*1~",
|
||||
filename: "claim-CLM-1.x12",
|
||||
});
|
||||
|
||||
const { container, unmount } = renderHeader({ id: "CLM-1" });
|
||||
const btn = container.querySelector(
|
||||
'[data-testid="header-download-837"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(btn).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
btn?.click();
|
||||
// Flush the microtask queue so the awaited handler resolves.
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(serializeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(serializeSpy).toHaveBeenCalledWith("CLM-1");
|
||||
expect(downloadSpy).toHaveBeenCalledTimes(1);
|
||||
expect(downloadSpy).toHaveBeenCalledWith(
|
||||
"claim-CLM-1.x12",
|
||||
"text/x12",
|
||||
"ISA*00*~IEA*0*1~"
|
||||
);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("test_download_button_routes_api_error_through_onError", async () => {
|
||||
// When serializeClaim837 throws, the header swallows the error and
|
||||
// hands the message to the optional onError callback. The download
|
||||
// helper must NOT be called on the failure path — that's the
|
||||
// difference between "the file was corrupted in the DB" and "we just
|
||||
// gave the user a broken empty file".
|
||||
serializeSpy.mockRejectedValueOnce(new Error("404 Not found"));
|
||||
const onError = vi.fn();
|
||||
|
||||
const { container, unmount } = renderHeader({ id: "CLM-1" }, vi.fn(), onError);
|
||||
const btn = container.querySelector(
|
||||
'[data-testid="header-download-837"]'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
await act(async () => {
|
||||
btn?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(onError).toHaveBeenCalledWith("404 Not found");
|
||||
expect(downloadSpy).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Download, X } from "lucide-react";
|
||||
import { Badge, type BadgeProps } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadTextFile } from "@/lib/download";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ClaimDetail } from "@/types";
|
||||
@@ -8,6 +11,13 @@ import type { ClaimDetail } from "@/types";
|
||||
type ClaimDrawerHeaderProps = {
|
||||
claim: ClaimDetail;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Optional toast callback for surfacing download failures (network error,
|
||||
* 404 if the claim was deleted out from under us, 422 if the stored
|
||||
* payload can't be regenerated). Defaults to a no-op so consumers that
|
||||
* don't wire a toast still get the happy-path download.
|
||||
*/
|
||||
onError?: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -44,7 +54,32 @@ function badgeVariantFor(state: string): BadgeProps["variant"] {
|
||||
* badge color encodes state so the user can read the drawer's status
|
||||
* at a glance without scrolling.
|
||||
*/
|
||||
export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
|
||||
export function ClaimDrawerHeader({
|
||||
claim,
|
||||
onClose,
|
||||
onError,
|
||||
}: ClaimDrawerHeaderProps) {
|
||||
// Local "is downloading" flag so the button can show feedback while the
|
||||
// fetch is in flight. The header is rendered above the fold, so a stuck
|
||||
// spinner is the difference between "the click did something" and "did
|
||||
// my click even register?"
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
async function handleDownload() {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const { text, filename } = await api.serializeClaim837(claim.id);
|
||||
downloadTextFile(filename, "text/x12", text);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Failed to download 837 file.";
|
||||
onError?.(message);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
@@ -68,8 +103,8 @@ export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: state badge + total amount + close */}
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Right: state badge + total amount + download + close */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Badge
|
||||
variant={badgeVariantFor(claim.state)}
|
||||
@@ -86,6 +121,17 @@ export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
|
||||
{fmt.usdPrecise(claim.billedAmount)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
|
||||
title="Download 837 file"
|
||||
data-testid="header-download-837"
|
||||
>
|
||||
<Download className="h-4 w-4" strokeWidth={1.75} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -175,4 +175,46 @@ describe("api GET helpers", () => {
|
||||
expect(caught).toBeInstanceOf(ApiError);
|
||||
expect((caught as ApiError).status).toBe(404);
|
||||
});
|
||||
|
||||
it("serializeClaim837 returns the regenerated text + filename from Content-Disposition", async () => {
|
||||
const x12 = "ISA*00* *00* *ZZ*CYCLONE *ZZ*RECEIVER *260620*1200*^*00501*000000001*0*P*:~IEA*0*000000001~";
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(x12, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/x12",
|
||||
// Backend emits: attachment; filename="claim-{id}.x12"
|
||||
"content-disposition": 'attachment; filename="claim-CLM-1.x12"',
|
||||
},
|
||||
})
|
||||
);
|
||||
const out = await api.serializeClaim837("CLM-1");
|
||||
expect(out.text).toBe(x12);
|
||||
expect(out.filename).toBe("claim-CLM-1.x12");
|
||||
// URL must hit the new SP8 endpoint with the encoded id.
|
||||
const called = mockFetch.mock.calls[0][0] as string;
|
||||
expect(called).toContain("/api/claims/CLM-1/serialize-837");
|
||||
// No Accept header negotiation needed — backend always returns text/x12.
|
||||
const init = mockFetch.mock.calls[0][1] as RequestInit | undefined;
|
||||
const headers = (init?.headers ?? {}) as Record<string, string>;
|
||||
expect(headers.Accept).toBeUndefined();
|
||||
});
|
||||
|
||||
it("serializeClaim837 falls back to a default filename if Content-Disposition is missing", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response("ISA*00*~", { status: 200 })
|
||||
);
|
||||
const out = await api.serializeClaim837("CLM-2");
|
||||
expect(out.filename).toBe("claim-CLM-2.x12");
|
||||
});
|
||||
|
||||
it("serializeClaim837 throws ApiError on 404", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ error: "Not found", detail: "Claim ghost not found" }),
|
||||
{ status: 404, headers: { "content-type": "application/json" } }
|
||||
)
|
||||
);
|
||||
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -476,6 +476,42 @@ async function getClaimDetail(id: string): Promise<ClaimDetail> {
|
||||
return (await res.json()) as ClaimDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a claim back to an X12 837P file via the backend's outbound
|
||||
* serializer (SP8).
|
||||
*
|
||||
* Returns both the regenerated X12 text and the filename the backend
|
||||
* suggested (from the `Content-Disposition: attachment; filename=...`
|
||||
* header). Callers usually pipe both into `downloadTextFile` so the user
|
||||
* gets a byte-faithful file named after the claim id.
|
||||
*
|
||||
* Throws `ApiError` on non-2xx — 404 (claim missing) and 422 (stored
|
||||
* `raw_json` unparseable / serializer rejected the payload) are both
|
||||
* reachable, and the caller may want to branch on `.status` to surface a
|
||||
* useful message ("claim data is corrupted — open the batch file
|
||||
* manually" vs. "this claim no longer exists").
|
||||
*/
|
||||
async function serializeClaim837(
|
||||
id: string,
|
||||
): Promise<{ text: string; filename: string }> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`),
|
||||
);
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new ApiError(res.status, detail || res.statusText);
|
||||
}
|
||||
const text = await res.text();
|
||||
// Filename comes from Content-Disposition (set by the backend). Fall back
|
||||
// to a sensible default if the header is missing or malformed so the UI
|
||||
// always has something to hand to `downloadTextFile`.
|
||||
const cd = res.headers.get("content-disposition") ?? "";
|
||||
const match = /filename="?([^";]+)"?/i.exec(cd);
|
||||
const filename = match?.[1] ?? `claim-${id}.x12`;
|
||||
return { text, filename };
|
||||
}
|
||||
|
||||
async function listRemittances<T = unknown>(
|
||||
params: ListRemittancesParams
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
@@ -700,6 +736,7 @@ export const api = {
|
||||
getBatchDiff,
|
||||
listClaims,
|
||||
getClaimDetail,
|
||||
serializeClaim837,
|
||||
listRemittances,
|
||||
getRemittance,
|
||||
listProviders,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { downloadTextFile } from "./download";
|
||||
|
||||
// Capture the real appendChild before any spy replaces it, so the
|
||||
// happy-dom implementation is preserved when the appendChild spy calls
|
||||
// through (otherwise removeChild inside downloadTextFile can't find the
|
||||
// node we appended).
|
||||
const originalAppendChild = document.body.appendChild.bind(document.body);
|
||||
|
||||
/**
|
||||
* `downloadTextFile` drives a hidden `<a download>` and clicks it. The
|
||||
* observable side effects we care about:
|
||||
*
|
||||
* 1. An `<a>` element with the right `href` (object URL) and `download`
|
||||
* (filename) is appended to `document.body`.
|
||||
* 2. The element is removed from the DOM (cleanup).
|
||||
* 3. The object URL is revoked (memory hygiene).
|
||||
*
|
||||
* We don't try to assert on the browser's actual download behavior — that's
|
||||
* not observable from the test environment. We assert on the contract the
|
||||
* implementation honors.
|
||||
*/
|
||||
describe("downloadTextFile", () => {
|
||||
let createSpy: ReturnType<typeof vi.spyOn>;
|
||||
let revokeSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
createSpy = vi
|
||||
.spyOn(URL, "createObjectURL")
|
||||
.mockReturnValue("blob:test-url");
|
||||
revokeSpy = vi.spyOn(URL, "revokeObjectURL");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("test_appends_anchor_with_object_url_and_filename_then_cleans_up", async () => {
|
||||
// Spy on appendChild to capture the anchor that's about to be appended
|
||||
// (downloadTextFile removes it again synchronously, so lastElementChild
|
||||
// is back to null by the time the call returns).
|
||||
const captured: HTMLAnchorElement[] = [];
|
||||
const appendSpy = vi
|
||||
.spyOn(document.body, "appendChild")
|
||||
.mockImplementation((node) => {
|
||||
if (node instanceof HTMLAnchorElement) captured.push(node);
|
||||
// Preserve the real append side-effect so removeChild later in
|
||||
// downloadTextFile can find the node.
|
||||
return originalAppendChild.call(document.body, node);
|
||||
});
|
||||
|
||||
downloadTextFile("claim-CLM-1.x12", "text/x12", "ISA*00*~");
|
||||
|
||||
expect(captured).toHaveLength(1);
|
||||
const a = captured[0];
|
||||
expect(a.tagName).toBe("A");
|
||||
expect(a.getAttribute("download")).toBe("claim-CLM-1.x12");
|
||||
expect(a.getAttribute("href")).toBe("blob:test-url");
|
||||
expect(a.style.display).toBe("none");
|
||||
|
||||
// removeChild ran synchronously inside downloadTextFile — the anchor
|
||||
// is no longer a child of document.body.
|
||||
expect(document.body.contains(a)).toBe(false);
|
||||
|
||||
expect(appendSpy).toHaveBeenCalledTimes(1);
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
// The revoke is scheduled via queueMicrotask — flush the microtask
|
||||
// queue so the assertion sees it.
|
||||
await Promise.resolve();
|
||||
expect(revokeSpy).toHaveBeenCalledWith("blob:test-url");
|
||||
});
|
||||
|
||||
it("test_blob_carries_supplied_mime_type_and_text_bytes", () => {
|
||||
downloadTextFile("a.txt", "text/plain", "hello world");
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
const blob = createSpy.mock.calls[0][0] as Blob;
|
||||
expect(blob.type).toBe("text/plain;charset=utf-8");
|
||||
expect(blob.size).toBe("hello world".length);
|
||||
});
|
||||
|
||||
it("test_noop_when_document_undefined", () => {
|
||||
// SSR-style guard: with no `document`, the function returns silently
|
||||
// instead of throwing.
|
||||
const originalDocument = globalThis.document;
|
||||
// @ts-expect-error - intentionally strip document for this case
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
try {
|
||||
expect(() =>
|
||||
downloadTextFile("a.txt", "text/plain", "x"),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
(globalThis as { document?: Document }).document = originalDocument;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Generic browser-side download helper for plain-text files.
|
||||
*
|
||||
* Mirrors `downloadCsv` in `./csv.ts` but takes an explicit MIME type so the
|
||||
* same code path can serve X12 (text/x12), JSON (application/json), or any
|
||||
* other text payload without growing a one-off helper per format.
|
||||
*
|
||||
* The implementation is identical to `downloadCsv` minus the BOM prepending
|
||||
* (we want byte-faithful X12 — prepending a BOM corrupts the ISA segment).
|
||||
*
|
||||
* Safe to call from SSR/Node — guards against a missing `document`.
|
||||
*/
|
||||
export function downloadTextFile(
|
||||
filename: string,
|
||||
mimeType: string,
|
||||
text: string,
|
||||
): void {
|
||||
if (typeof document === "undefined" || typeof URL === "undefined") return;
|
||||
const blob = new Blob([text], { type: `${mimeType};charset=utf-8` });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
// Some browsers require the link to be in the DOM before .click() works.
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Defer revoke so the browser has the URL handle when the download kicks off.
|
||||
queueMicrotask(() => URL.revokeObjectURL(url));
|
||||
}
|
||||
Reference in New Issue
Block a user