Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions containers/ecr-viewer/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions containers/ecr-viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"postcss-preset-env": "^10.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-xml-viewer": "^3.0.5",
"sanitize-html": "^2.17.0",
"server-only": "^0.0.1",
"sharp": "^0.34.3",
Expand Down
2 changes: 1 addition & 1 deletion containers/ecr-viewer/src/app/api/process-ecr/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ export const zipAndSaveXml = async (body: RequestBody, ecrId: string) => {
zip.file(`${ecrId}-CDA_eICR.xml`, body.ecr);

// add RR if exists and is string
if (body.rr === "string") {
if (typeof body.rr === "string") {
zip.file(`${ecrId}-CDA_RR.xml`, body.rr);
}

Expand Down
45 changes: 45 additions & 0 deletions containers/ecr-viewer/src/app/api/view-xml/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";

import { S3_SOURCE } from "@/app/data/blobStorage/utils";
import {
getEcrXmls,
XmlNotFoundError,
} from "@/app/view-data/services/xmlService";

export async function GET(request: NextRequest): Promise<NextResponse> {
if (process.env.SAVE_XML !== "true") {
return NextResponse.json(
{ message: "XML storage is not enabled" },
{ status: 404 },
);
}

const id = request.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json(
{ message: "Missing id parameter" },
{ status: 400 },
);
}

if (process.env.SOURCE !== S3_SOURCE) {
return NextResponse.json(
{ message: "XML viewing is only supported for S3 storage" },
{ status: 501 },
);
}

try {
const xmls = await getEcrXmls(id);
return NextResponse.json(xmls);
} catch (error) {
if (error instanceof XmlNotFoundError) {
return NextResponse.json({ message: error.message }, { status: 404 });
}
console.error({ message: "Failed to retrieve XML", error, id });
return NextResponse.json(
{ message: "Failed to retrieve XML" },
{ status: 500 },
);
}
}
160 changes: 160 additions & 0 deletions containers/ecr-viewer/src/app/view-data/components/XmlViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"use client";

import { useState } from "react";
import XMLViewer from "react-xml-viewer";

import { EcrXmls } from "@/app/view-data/services/xmlService";

type TabId = "ecr" | "rr";

const tabConfig: { id: TabId; label: string }[] = [
{ id: "ecr", label: "eICR XML" },
{ id: "rr", label: "RR XML" },
];

interface XmlViewerProps {
children: React.ReactNode;
sideNav: React.ReactNode;
ecrId?: string;
}

const XmlViewer = ({ children, sideNav, ecrId }: XmlViewerProps) => {
const xmlApiUrl = ecrId
? `${process.env.BASE_PATH}/api/view-xml?id=${ecrId}`
: undefined;
const [showXml, setShowXml] = useState(false);
const [xmls, setXmls] = useState<EcrXmls | null>(null);
const [activeTab, setActiveTab] = useState<TabId>("ecr");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);

const openXml = async () => {
if (!xmls) {
setLoading(true);
setError(false);
try {
const res = await fetch(xmlApiUrl!);
if (!res.ok) {
const body = await res.text().catch(() => "");
console.error("view-xml failed", res.status, body);
throw new Error();
}
const data: EcrXmls = await res.json();
setXmls(data);
setActiveTab(data.ecrXml ? "ecr" : "rr");
} catch {
setError(true);
setLoading(false);
return;
}
setLoading(false);
}
setShowXml(true);
};

const xmlMap: Record<TabId, string | null> = {
ecr: xmls?.ecrXml ?? null,
rr: xmls?.rrXml ?? null,
};
const availableTabs = xmls
? tabConfig.filter(({ id }) => xmlMap[id] !== null)
: [];

return (
<>
{!showXml && sideNav}
<div className="ecr-viewer-container">
{!showXml && (
<>
<div className="margin-bottom-3">
<div className="display-flex flex-align-center margin-top-3">
<h2
className="margin-bottom-05 margin-top-0 margin-right-auto"
id="ecr-summary"
>
eCR Summary
</h2>
{xmlApiUrl && (
<button
onClick={openXml}
disabled={loading}
className="usa-button usa-button--outline usa-button--small text-primary"
>
{loading ? "Loading..." : "View XML"}
</button>
)}
</div>
<div className="text-base-darker line-height-sans-5">
Provides key info upfront to help you understand the eCR at a
glance
</div>
</div>
{children}
</>
)}

{showXml && (
<>
<div className="display-flex flex-align-center margin-top-3 margin-bottom-2">
<button
className="usa-button--unstyled text-primary font-sans-sm"
onClick={() => setShowXml(false)}
>
← Back to eCR
</button>
</div>

{availableTabs.length > 1 && (
<div
role="tablist"
className="display-flex border-bottom border-base-light"
>
{availableTabs.map(({ id, label }) => (
<button
key={id}
role="tab"
aria-selected={activeTab === id}
aria-controls={`panel-${id}`}
onClick={() => setActiveTab(id)}
className={`usa-button--unstyled padding-x-3 padding-y-105 font-sans-sm text-no-underline border-bottom-05 ${
activeTab === id
? "border-primary text-primary"
: "border-transparent text-base"
}`}
>
{label}
</button>
))}
</div>
)}

{availableTabs.map(({ id }) => (
<div
key={id}
id={`panel-${id}`}
role="tabpanel"
hidden={activeTab !== id}
className="overflow-auto margin-top-2"
style={{ maxHeight: "calc(100vh - 160px)" }}
>
<XMLViewer xml={xmlMap[id] ?? ""} collapsible showLineNumbers />
</div>
))}
</>
)}

{error && !showXml && (
<div className="usa-alert usa-alert--error margin-top-2">
<div className="usa-alert__body">
<p className="usa-alert__text">
Failed to load XML. Please try again.
</p>
</div>
</div>
)}
</div>
</>
);
};

export default XmlViewer;
17 changes: 6 additions & 11 deletions containers/ecr-viewer/src/app/view-data/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import EcrDocument from "./components/EcrDocument";
import { getEcrDocumentAccordionItems } from "./components/EcrDocument/accordion-items";
import EcrSummary from "./components/EcrSummary";
import SideNav from "./components/SideNav";
import XmlViewer from "./components/XmlViewer";
import {
evaluatePatientDOB,
evaluatePatientName,
Expand Down Expand Up @@ -76,16 +77,10 @@ const ECRViewerPage = async ({

return (
<ECRViewerLayout patientName={patientName} patientDOB={patientDOB}>
<SideNav ecrDocumentNavConfig={ecrDocumentNavConfig} />
<div className="ecr-viewer-container">
<div className="margin-bottom-3">
<h2 className="margin-bottom-05 margin-top-3" id="ecr-summary">
eCR Summary
</h2>
<div className="text-base-darker line-height-sans-5">
Provides key info upfront to help you understand the eCR at a glance
</div>
</div>
<XmlViewer
sideNav={<SideNav ecrDocumentNavConfig={ecrDocumentNavConfig} />}
ecrId={process.env.SAVE_XML === "true" ? fhirId : undefined}
>
<EcrSummary
patientDetails={
evaluateEcrSummaryPatientDetails(fhirBundle, fhirIndex)
Expand All @@ -102,7 +97,7 @@ const ECRViewerPage = async ({
snomed={snomedCode}
/>
<EcrDocument initialAccordionItems={accordionItems} />
</div>
</XmlViewer>
</ECRViewerLayout>
);
};
Expand Down
40 changes: 40 additions & 0 deletions containers/ecr-viewer/src/app/view-data/services/xmlService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { GetObjectCommand } from "@aws-sdk/client-s3";
import JSZip from "jszip";

import { s3Client } from "@/app/data/blobStorage/s3Client";

export class XmlNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "XmlNotFoundError";
}
}

export interface EcrXmls {
ecrXml: string | null;
rrXml: string | null;
}

export const getEcrXmls = async (id: string): Promise<EcrXmls> => {
const command = new GetObjectCommand({
Bucket: process.env.ECR_BUCKET_NAME,
Key: `${id}.zip`,
});

const { Body } = await s3Client.send(command);
if (!Body) throw new XmlNotFoundError("XML archive not found");

const buffer = Buffer.from(await Body.transformToByteArray());
const zip = await JSZip.loadAsync(buffer);

const ecrFile = zip.file(`${id}-CDA_eICR.xml`);
const rrFile = zip.file(`${id}-CDA_RR.xml`);

if (!ecrFile && !rrFile)
throw new XmlNotFoundError("No XML files found in archive");

return {
ecrXml: ecrFile ? await ecrFile.async("string") : null,
rrXml: rrFile ? await rrFile.async("string") : null,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,21 @@ describe("orchestrationRequest", () => {
expect(Object.keys(zip.files)).toContain(`${ecrId}-CDA_eICR.xml`);
});

it("zipAndSaveXml should include RR xml when ecr and rr are both strings", async () => {
const body = {
ecr: "<ClinicalDocument>eICR</ClinicalDocument>",
rr: "<ReportabilityResponse>RR</ReportabilityResponse>",
};

await zipAndSaveXml(body, ecrId);

const [zipBuffer] = (saveToStorage as jest.Mock).mock.calls[0];
const zip = await JSZip.loadAsync(zipBuffer);
const files = Object.keys(zip.files);
expect(files).toContain(`${ecrId}-CDA_eICR.xml`);
expect(files).toContain(`${ecrId}-CDA_RR.xml`);
});

it("zipAndSaveXml should take in a zip then call saveToStorage with a zipBuffer", async () => {
const fakeZipBuffer = createFakeZip("<xml/>");
const mockZip = new File([fakeZipBuffer as BlobPart], "test.zip", {
Expand Down
Loading
Loading