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:
Tyler
2026-06-20 20:44:39 -06:00
parent 2893676c0b
commit 1764df0cd5
6 changed files with 336 additions and 9 deletions
+42
View File
@@ -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);
});
});
+37
View File
@@ -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,
+97
View File
@@ -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;
}
});
});
+31
View File
@@ -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));
}