diff --git a/package-lock.json b/package-lock.json index 5af4c1620..7e1b2c5c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "graphql-tag": "^2.12.6", "jest-axe": "^8.0.0", "jest-fail-on-console": "^3.3.0", + "jspdf": "^2.5.2", "lodash": "^4.17.21", "notistack": "^3.0.1", "papaparse": "^5.4.1", @@ -9985,8 +9986,9 @@ } }, "node_modules/dompurify": { - "version": "2.4.7", - "license": "(MPL-2.0 OR Apache-2.0)", + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.6.tgz", + "integrity": "sha512-zUTaUBO8pY4+iJMPE1B9XlO2tXVYIcEA4SNGtvDELzTSCQO7RzH+j7S180BmhmJId78lqGU2z19vgVx2Sxs/PQ==", "optional": true }, "node_modules/domutils": { @@ -11209,8 +11211,9 @@ } }, "node_modules/fflate": { - "version": "0.4.8", - "license": "MIT" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" }, "node_modules/file-entry-cache": { "version": "6.0.1", @@ -14282,18 +14285,19 @@ } }, "node_modules/jspdf": { - "version": "2.5.1", - "license": "MIT", + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", "dependencies": { - "@babel/runtime": "^7.14.0", + "@babel/runtime": "^7.23.2", "atob": "^2.1.2", "btoa": "^1.2.1", - "fflate": "^0.4.8" + "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.6", "core-js": "^3.6.0", - "dompurify": "^2.2.0", + "dompurify": "^2.5.4", "html2canvas": "^1.0.0-rc.5" } }, diff --git a/package.json b/package.json index 03f126fdf..6d7702e69 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "graphql-tag": "^2.12.6", "jest-axe": "^8.0.0", "jest-fail-on-console": "^3.3.0", + "jspdf": "^2.5.2", "lodash": "^4.17.21", "notistack": "^3.0.1", "papaparse": "^5.4.1", diff --git a/src/assets/icons/download_icon.svg b/src/assets/icons/download_icon.svg index 3d1c48d64..596596553 100644 --- a/src/assets/icons/download_icon.svg +++ b/src/assets/icons/download_icon.svg @@ -1,8 +1,8 @@ + fill="currentColor" /> + fill="currentColor" /> diff --git a/src/components/Contexts/SubmissionContext.test.tsx b/src/components/Contexts/SubmissionContext.test.tsx index a888127be..c5cec710b 100644 --- a/src/components/Contexts/SubmissionContext.test.tsx +++ b/src/components/Contexts/SubmissionContext.test.tsx @@ -49,6 +49,7 @@ const baseSubmission: Submission = { intention: "New/Update", dataType: "Metadata Only", otherSubmissions: "", + nodeCount: 0, createdAt: "", updatedAt: "", studyID: "", diff --git a/src/components/DataSubmissions/CrossValidationButton.test.tsx b/src/components/DataSubmissions/CrossValidationButton.test.tsx index ca52fbf0e..925e5694f 100644 --- a/src/components/DataSubmissions/CrossValidationButton.test.tsx +++ b/src/components/DataSubmissions/CrossValidationButton.test.tsx @@ -53,6 +53,7 @@ const baseSubmission: Omit< validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; const baseAuthCtx: AuthCtxState = { diff --git a/src/components/DataSubmissions/CrossValidationFilters.test.tsx b/src/components/DataSubmissions/CrossValidationFilters.test.tsx index 415e3de1f..64829e381 100644 --- a/src/components/DataSubmissions/CrossValidationFilters.test.tsx +++ b/src/components/DataSubmissions/CrossValidationFilters.test.tsx @@ -49,6 +49,7 @@ const baseSubmission: Submission = { intention: "New/Update", dataType: "Metadata Only", otherSubmissions: "", + nodeCount: 0, createdAt: "", updatedAt: "", }; diff --git a/src/components/DataSubmissions/DataUpload.test.tsx b/src/components/DataSubmissions/DataUpload.test.tsx index 6cf4325f7..4f37c5f49 100644 --- a/src/components/DataSubmissions/DataUpload.test.tsx +++ b/src/components/DataSubmissions/DataUpload.test.tsx @@ -55,6 +55,7 @@ const baseSubmission: Omit = { validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; const baseUser: User = { diff --git a/src/components/DataSubmissions/DeleteAllOrphanFilesButton.test.tsx b/src/components/DataSubmissions/DeleteAllOrphanFilesButton.test.tsx index 5c49c6746..5764481c1 100644 --- a/src/components/DataSubmissions/DeleteAllOrphanFilesButton.test.tsx +++ b/src/components/DataSubmissions/DeleteAllOrphanFilesButton.test.tsx @@ -53,6 +53,7 @@ const baseSubmission: Submission = { validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; const baseContext: ContextState = { diff --git a/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx b/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx index 07783b8d8..53c9375de 100644 --- a/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx +++ b/src/components/DataSubmissions/DeleteNodeDataButton.test.tsx @@ -49,6 +49,7 @@ const BaseSubmission: Submission = { validationScope: "New", validationType: [], deletingData: false, + nodeCount: 0, }; const baseAuthCtx: AuthContextState = { diff --git a/src/components/DataSubmissions/DeleteOrphanFileChip.test.tsx b/src/components/DataSubmissions/DeleteOrphanFileChip.test.tsx index a1f55f518..8c2f0950d 100644 --- a/src/components/DataSubmissions/DeleteOrphanFileChip.test.tsx +++ b/src/components/DataSubmissions/DeleteOrphanFileChip.test.tsx @@ -53,6 +53,7 @@ const baseSubmission: Submission = { validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; const baseContext: ContextState = { diff --git a/src/components/DataSubmissions/ExportCrossValidationButton.test.tsx b/src/components/DataSubmissions/ExportCrossValidationButton.test.tsx index c42d9543a..9916b4f9c 100644 --- a/src/components/DataSubmissions/ExportCrossValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportCrossValidationButton.test.tsx @@ -48,6 +48,7 @@ const baseSubmission: Submission = { crossSubmissionStatus: null, studyID: "", deletingData: false, + nodeCount: 0, }; const baseCrossValidationResult: CrossValidationResult = { diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index 5120f43c2..b846895ab 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -57,6 +57,7 @@ describe("ExportValidationButton cases", () => { validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; const baseQCResult: Omit = { diff --git a/src/components/DataSubmissions/MetadataUpload.test.tsx b/src/components/DataSubmissions/MetadataUpload.test.tsx index 7140bdea5..a139d5226 100644 --- a/src/components/DataSubmissions/MetadataUpload.test.tsx +++ b/src/components/DataSubmissions/MetadataUpload.test.tsx @@ -40,6 +40,7 @@ const baseSubmission: Omit< validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; const baseContext: ContextState = { diff --git a/src/components/DataSubmissions/ValidationControls.test.tsx b/src/components/DataSubmissions/ValidationControls.test.tsx index aabdd5427..3e705c842 100644 --- a/src/components/DataSubmissions/ValidationControls.test.tsx +++ b/src/components/DataSubmissions/ValidationControls.test.tsx @@ -53,6 +53,7 @@ const baseSubmission: Omit< validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; const baseAuthCtx: AuthCtxState = { diff --git a/src/components/DataSubmissions/ValidationStatistics.test.tsx b/src/components/DataSubmissions/ValidationStatistics.test.tsx index 8fa74523d..47d32cfc5 100644 --- a/src/components/DataSubmissions/ValidationStatistics.test.tsx +++ b/src/components/DataSubmissions/ValidationStatistics.test.tsx @@ -33,6 +33,7 @@ const baseSubmission: Omit = { validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; describe("Accessibility", () => { diff --git a/src/components/DataSubmissions/ValidationStatus.test.tsx b/src/components/DataSubmissions/ValidationStatus.test.tsx index de78a3ead..561a1f56a 100644 --- a/src/components/DataSubmissions/ValidationStatus.test.tsx +++ b/src/components/DataSubmissions/ValidationStatus.test.tsx @@ -40,6 +40,7 @@ const BaseSubmission: Omit< updatedAt: "", studyID: "", deletingData: false, + nodeCount: 0, }; type TestParentProps = { diff --git a/src/components/ExportRequestButton/index.test.tsx b/src/components/ExportRequestButton/index.test.tsx new file mode 100644 index 000000000..3ef130c22 --- /dev/null +++ b/src/components/ExportRequestButton/index.test.tsx @@ -0,0 +1,363 @@ +import { FC, useMemo } from "react"; +import { render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { axe } from "jest-axe"; +import ExportRequestButton from "./index"; +import { + ContextState as FormContextState, + Context as FormContext, + Status as FormStatus, +} from "../Contexts/FormContext"; +import { ContextState, Context as AuthCtx, Status as AuthStatus } from "../Contexts/AuthContext"; +import { InitialApplication, InitialQuestionnaire } from "../../config/InitialValues"; + +const mockGenerate = jest.fn(); +jest.mock("./pdf/Generate", () => ({ + GenerateDocument: (...args) => mockGenerate(...args), +})); + +const mockDownloadBlob = jest.fn(); +jest.mock("../../utils", () => ({ + ...jest.requireActual("../../utils"), + downloadBlob: (...args) => mockDownloadBlob(...args), +})); + +type TestParentProps = { + /** + * The status of the form context. + */ + formStatus?: FormStatus; + /** + * The form data to provide to the form context. + */ + formData?: Partial; + /** + * The role of the "current user" viewing the element + */ + userRole?: UserRole; + /** + * The element to use as the print region for the PDF. + */ + printRegion?: React.ReactNode; + /** + * The children to render within the test parent. + */ + children: React.ReactNode; +}; + +const TestParent: FC = ({ + formStatus = FormStatus.LOADED, + formData = {}, + userRole = "User", + printRegion =
, + children, +}: TestParentProps) => { + const formValue = useMemo( + () => ({ + status: formStatus, + data: + formStatus === FormStatus.LOADED + ? { + ...InitialApplication, + ...formData, + questionnaireData: { ...InitialQuestionnaire, ...formData?.questionnaireData }, + } + : null, + }), + [formStatus, formData] + ); + + const authValue = useMemo( + () => ({ + status: AuthStatus.LOADED, + user: { role: userRole } as User, + isLoggedIn: true, + }), + [userRole] + ); + + return ( + + + {printRegion} + {children} + + + ); +}; + +describe("Accessibility", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should not have any violations", async () => { + const { container } = render(, { + wrapper: (p) => , + }); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should not have any violations (hover)", async () => { + const { container, getByTestId, getByRole } = render(, { + wrapper: (p) => , + }); + + userEvent.hover(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(getByRole("tooltip")).toBeInTheDocument(); + }); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should not have any violations (disabled)", async () => { + const { container } = render(, { + wrapper: (p) => , + }); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); + +describe("Basic Functionality", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it("should render without crashing", () => { + expect(() => + render(, { wrapper: (p) => }) + ).not.toThrow(); + }); + + it("should disable the button when building the document", async () => { + jest.useFakeTimers(); + mockGenerate.mockImplementation( + () => + new Promise((res) => { + setTimeout(() => { + res("mock pdf data"); // NOTE: This is a placeholder value. + }, 1000); + }) + ); + + const { getByTestId } = render(, { + wrapper: (p) => , + }); + + userEvent.click(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(getByTestId("export-submission-request-button")).toBeDisabled(); + }); + + expect(mockGenerate).toHaveBeenCalledTimes(1); + }); + + it("should disable the button when the disabled prop is passed", () => { + const { getByTestId } = render(, { + wrapper: (p) => , + }); + + expect(getByTestId("export-submission-request-button")).toBeDisabled(); + }); + + it("should disable the button when the FormContext is not loaded", () => { + const { getByTestId } = render(, { + wrapper: (p) => , + }); + + expect(getByTestId("export-submission-request-button")).toBeDisabled(); + }); + + it("should display an error message on failed export", async () => { + jest.spyOn(console, "error").mockImplementation(() => null); + + mockGenerate.mockRejectedValue(new Error("mock error")); + + const { getByTestId } = render(, { + wrapper: (p) => , + }); + + userEvent.click(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(global.mockEnqueue).toHaveBeenCalledWith( + "An error occurred while exporting the Submission Request to PDF.", + { variant: "error" } + ); + }); + }); + + it("should display an error message if no print region is found", async () => { + const { getByTestId } = render(, { + wrapper: (p) => , + }); + + userEvent.click(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(global.mockEnqueue).toHaveBeenCalledWith( + "An error occurred while exporting the Submission Request to PDF.", + { variant: "error" } + ); + }); + }); +}); + +describe("Implementation Requirements", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should have a tooltip on hover", async () => { + const { getByTestId, getByRole, queryByRole } = render(, { + wrapper: (p) => , + }); + + userEvent.hover(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(getByRole("tooltip")).toBeInTheDocument(); + }); + + expect(getByRole("tooltip")).toHaveTextContent( + "Click to export this Submission Request as a PDF." + ); + + userEvent.unhover(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(queryByRole("tooltip")).not.toBeInTheDocument(); + }); + }); + + // NOTE: This component does not currently implement any authorization, + // this is just future-proofing test coverage. + it.each([ + "User", + "Submitter", + "Organization Owner", + "Federal Lead", + "Admin", + "fake role" as UserRole, + ])("should be enabled for the user role %s if they can access the page", (role) => { + const { getByTestId } = render(, { + wrapper: (p) => , + }); + + expect(getByTestId("export-submission-request-button")).toBeEnabled(); + }); + + it("should format the PDF filename as 'CRDCSubmissionPortal-Request-{studyAbbr}-{submittedDate}.pdf'", async () => { + const mockFormObject: Partial = { + status: "Submitted", + updatedAt: "2024-09-30T09:10:00.000Z", + submittedDate: "2024-09-30T09:10:00.000Z", + questionnaireData: { + ...InitialQuestionnaire, + study: { + ...InitialQuestionnaire.study, + abbreviation: "TEST", + name: "Test Study", + }, + }, + history: [], + }; + + const { getByTestId } = render(, { + wrapper: (p) => ( + + ), + }); + + userEvent.click(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(mockDownloadBlob).toHaveBeenCalledTimes(1); + expect(mockDownloadBlob).toHaveBeenCalledWith( + undefined, + "CRDCSubmissionPortal-Request-TEST-2024-09-30.pdf", + "application/pdf" + ); + }); + }); + + it.each(["", null, undefined])( + "should fallback to the study name if the abbreviation is not provided", + async (abbreviation) => { + const mockFormObject: Partial = { + status: "Submitted", + updatedAt: "2024-09-30T09:10:00.000Z", + submittedDate: "2024-09-30T09:10:00.000Z", + questionnaireData: { + ...InitialQuestionnaire, + study: { + ...InitialQuestionnaire.study, + abbreviation, + name: "Test Study", + }, + }, + history: [], + }; + + const { getByTestId } = render(, { + wrapper: (p) => ( + + ), + }); + + userEvent.click(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(mockDownloadBlob).toHaveBeenCalledTimes(1); + expect(mockDownloadBlob).toHaveBeenCalledWith( + undefined, + "CRDCSubmissionPortal-Request-Test Study-2024-09-30.pdf", + "application/pdf" + ); + }); + } + ); + + it("should use the updatedAt date if the status is 'In Progress'", async () => { + const mockFormObject: Partial = { + status: "In Progress", + updatedAt: "2024-09-30T09:10:00.000Z", + submittedDate: "2024-10-22T14:10:00.000Z", + questionnaireData: { + ...InitialQuestionnaire, + study: { + ...InitialQuestionnaire.study, + abbreviation: "TEST", + name: "Test Study", + }, + }, + history: [], + }; + + const { getByTestId } = render(, { + wrapper: (p) => ( + + ), + }); + + userEvent.click(getByTestId("export-submission-request-button")); + + await waitFor(() => { + expect(mockDownloadBlob).toHaveBeenCalledTimes(1); + expect(mockDownloadBlob).toHaveBeenCalledWith( + undefined, + "CRDCSubmissionPortal-Request-TEST-2024-09-30.pdf", + "application/pdf" + ); + }); + }); +}); diff --git a/src/components/ExportRequestButton/index.tsx b/src/components/ExportRequestButton/index.tsx new file mode 100644 index 000000000..7f2c3d422 --- /dev/null +++ b/src/components/ExportRequestButton/index.tsx @@ -0,0 +1,94 @@ +import { forwardRef, memo, useState } from "react"; +import { styled, Button, ButtonProps } from "@mui/material"; +import { useSnackbar } from "notistack"; +import { ReactComponent as DownloadIcon } from "../../assets/icons/download_icon.svg"; +import StyledFormTooltip from "../StyledFormComponents/StyledTooltip"; +import { Status as FormStatus, useFormContext } from "../Contexts/FormContext"; +import { GenerateDocument } from "./pdf/Generate"; +import { downloadBlob, FormatDate, Logger } from "../../utils"; + +const StyledTooltip = styled(StyledFormTooltip)({ + marginLeft: "0 !important", + "& .MuiTooltip-tooltip": { + color: "#000000", + }, +}); + +const StyledDownloadIcon = styled(DownloadIcon)({ + color: "inherit", + marginLeft: "10px", +}); + +export type ExportRequestButtonProps = ButtonProps; + +/** + * Provides the button and supporting functionality to export a + * Submission Request to PDF format. + * + * @returns {React.FC} The export PDF button. + */ +const ExportRequestButton = forwardRef( + ({ disabled, ...buttonProps }, ref) => { + const { enqueueSnackbar } = useSnackbar(); + const { data, status } = useFormContext(); + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + setLoading(true); + + try { + const printRegion: HTMLElement = document.querySelector("[data-pdf-print-region]"); + if (!printRegion) { + throw new Error("Unable to locate the print region for the PDF."); + } + + const pdfBlob = await GenerateDocument(data, printRegion.cloneNode(true) as HTMLElement); + + const studyAbbr = + data?.questionnaireData?.study?.abbreviation || data?.questionnaireData?.study?.name; + const submittedDate = + data?.status === "In Progress" + ? FormatDate(data?.updatedAt, "YYYY-MM-DD") + : FormatDate(data?.submittedDate, "YYYY-MM-DD"); + const filename = `CRDCSubmissionPortal-Request-${studyAbbr}-${submittedDate}.pdf`; + + downloadBlob(pdfBlob, filename, "application/pdf"); + } catch (error) { + Logger.error("ExportRequestButton", error); + + enqueueSnackbar("An error occurred while exporting the Submission Request to PDF.", { + variant: "error", + }); + } + + setLoading(false); + }; + + return ( + + + + + + ); + } +); + +export default memo(ExportRequestButton); diff --git a/src/components/ExportRequestButton/pdf/Colors.ts b/src/components/ExportRequestButton/pdf/Colors.ts new file mode 100644 index 000000000..b452934b2 --- /dev/null +++ b/src/components/ExportRequestButton/pdf/Colors.ts @@ -0,0 +1,42 @@ +/** + * An array of numbers representing an RGB color code as [red, green, blue] + */ +export type RgbColor = [number, number, number]; + +/** + * Defines the base text color for the document (black). + * + * @note #000000 + */ +export const COLOR_BASE: RgbColor = [0, 0, 0]; + +/** + * Defines the hyperlink color for the document. + * + * @note #0B6CB1 + */ +export const COLOR_HYPERLINK: RgbColor = [11, 108, 177]; + +/** + * The text color of the field values + * + * @note #595959 + */ +export const COLOR_FIELD_BASE: RgbColor = [89, 89, 89]; + +/** + * Defines the color of the horizontal rule. + */ +export const COLOR_HR: RgbColor = [198, 198, 198]; + +/** + * The color of the email address. + */ +export const COLOR_EMAIL: RgbColor = [8, 58, 80]; + +/** + * The color for footer text + * + * @note #2E3030 + */ +export const COLOR_DOC_FOOTER: RgbColor = [46, 48, 48]; diff --git a/src/components/ExportRequestButton/pdf/Fonts.ts b/src/components/ExportRequestButton/pdf/Fonts.ts new file mode 100644 index 000000000..dc9874324 --- /dev/null +++ b/src/components/ExportRequestButton/pdf/Fonts.ts @@ -0,0 +1,88 @@ +/** + * Describes a font resource to be loaded for the PDF generation. + */ +export type FontResource = { + /** + * The full URL to the TTF font file. + */ + src: string; + /** + * The font family name. + * + * @example "Nunito" + */ + family: string; + /** + * The font style. + * + * @example "normal" + */ + style: string; + /** + * The font weight. + * + * @example 400 + */ + fontWeight: number; +}; + +/** + * Defines the fonts to be loaded for the PDF generation. + * + * @note jsPDF does NOT support anything other than ttf fonts. In order to get TTF fonts from Google Fonts, + * Open the typical Google Fonts CSS URL, then mock your user agent to an old device (e.g. blackberry) and reload the page. + */ +const fonts: Readonly = [ + // { + // family: "Nunito", + // style: "normal", + // name: "Nunito-Regular.ttf", + // src: "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDOUhRTM.ttf", + // fontWeight: 300, + // }, + { + family: "Nunito", + style: "normal", + src: "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDLshRTM.ttf", + fontWeight: 400, + }, + // { + // family: "Nunito", + // style: "normal", + // src: "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDIkhRTM.ttf", + // fontWeight: 500, + // }, + { + family: "Nunito", + style: "normal", + src: "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDGUmRTM.ttf", + fontWeight: 600, + }, + { + family: "Nunito", + style: "normal", + src: "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDFwmRTM.ttf", + fontWeight: 700, + }, + { + family: "Nunito", + style: "normal", + src: "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDDsmRTM.ttf", + fontWeight: 800, + }, + // { + // family: "Nunito", + // style: "normal", + // name: "Nunito-Regular.ttf", + // src: "https://fonts.gstatic.com/s/nunito/v26/XRXI3I6Li01BKofiOc5wtlZ2di8HDBImRTM.ttf", + // fontWeight: 900, + // }, + // { + // family: "Nunito Sans", + // style: "normal", + // src: "https://fonts.gstatic.com/s/nunitosans/v15/pe1mMImSLYBIv1o4X1M8ce2xCx3yop4tQpF_MeTm0lfGWVpNn64CL7U8upHZIbMV51Q42ptCp5F5bxqqtQ1yiU4GVi5ntA.ttf", + // fontWeight: 800, + // }, +]; + +export default fonts; diff --git a/src/components/ExportRequestButton/pdf/Generate.ts b/src/components/ExportRequestButton/pdf/Generate.ts new file mode 100644 index 000000000..fbd59020d --- /dev/null +++ b/src/components/ExportRequestButton/pdf/Generate.ts @@ -0,0 +1,236 @@ +import { jsPDF as JsPDF } from "jspdf"; +import { loadFont } from "./utils"; +import fonts from "./Fonts"; +import { FormatDate, formatFullStudyName } from "../../../utils"; +import env from "../../../env"; +import Logo from "../../../assets/modelNavigator/Logo.jpg"; +import { + COLOR_HR, + COLOR_FIELD_BASE, + COLOR_EMAIL, + COLOR_BASE, + COLOR_HYPERLINK, + COLOR_DOC_FOOTER, +} from "./Colors"; + +/** + * Describes the minimal padding from the edges of the document. + * + * @note No content should be placed from the document edge to this margin. + */ +const BASE_MARGIN = 20; + +/** + * Describes the margin around the content of the document. + * + * @note The design spec suggests it should be closer to +50p, + * but this is a compromise to ensure the content fits within the page. + * @note Any horizontal lines bypass this constraint. + */ +const CONTENT_MARGIN = BASE_MARGIN + 25; + +/** + * Describes the drawing width of horizontal lines. + */ +const HR_WIDTH = 0.8; + +/** + * Writes the PDF header to the document. + * + * @param doc The jsPDF instance to add the header to. + * @param request The submission request to generate the header from. + * @returns {number} The current Y position after writing the header including padding. + */ +const writeHeader = (doc: JsPDF, request: Application): number => { + const { applicant, questionnaireData, status, submittedDate } = request; + const { study } = questionnaireData || {}; + const formattedSubmittedDate = + status !== "In Progress" ? FormatDate(submittedDate, "YYYY-MM-DD") : "N/A"; + + const RIGHT_EDGE = doc.internal.pageSize.width - BASE_MARGIN; + let y = BASE_MARGIN; + + doc.addImage(Logo, "JPEG", BASE_MARGIN, y, 278, 56); + y += 63; + + // Submitter name + doc.setDrawColor(...COLOR_HR); + doc.setLineWidth(HR_WIDTH); + doc.line(BASE_MARGIN, (y += 15), RIGHT_EDGE, y); + doc.setFont("Nunito", "normal", 800); + doc.setTextColor(...COLOR_FIELD_BASE); + doc.setFontSize(10); + doc.text("SUBMITTER'S NAME", CONTENT_MARGIN, (y += 15)); + doc.setFont("Nunito", "normal", 700); + doc.setFontSize(14); + doc.setTextColor(...COLOR_EMAIL); + doc.text(applicant.applicantName, CONTENT_MARGIN + 85, (y += 1)); + doc.line(BASE_MARGIN, (y += 10), RIGHT_EDGE, y); + y += 24; + + // Study name and abbreviation + doc.setFont("Nunito", "normal", 600); + doc.setTextColor(...COLOR_BASE); + doc.setFontSize(10); + doc.text("STUDY NAME", CONTENT_MARGIN, y); + + const formattedName = formatFullStudyName(study?.name, study?.abbreviation); + const splitName = doc.splitTextToSize(formattedName, RIGHT_EDGE - CONTENT_MARGIN); + doc.setFont("Nunito", "normal", 400); + doc.setFontSize(12); + doc.setTextColor(...COLOR_FIELD_BASE); + doc.text(splitName, CONTENT_MARGIN + 85, y); + y += splitName.length * 8 + 10; + + // Submitted Date + doc.setFont("Nunito", "normal", 600); + doc.setTextColor(...COLOR_BASE); + doc.setFontSize(10); + doc.text("SUBMITTED DATE", CONTENT_MARGIN, y); + + doc.setFont("Nunito", "normal", 400); + doc.setFontSize(12); + doc.setTextColor(...COLOR_FIELD_BASE); + doc.text(formattedSubmittedDate, CONTENT_MARGIN + 85, y); + y += 18; + + // Status + doc.setFont("Nunito", "normal", 600); + doc.setTextColor(...COLOR_BASE); + doc.setFontSize(10); + doc.text("STATUS", CONTENT_MARGIN, y); + + doc.setFont("Nunito", "normal", 400); + doc.setFontSize(12); + doc.setTextColor(...COLOR_FIELD_BASE); + doc.text(status, CONTENT_MARGIN + 85, y); + y += 24; + + // Click to view (text) + doc.setFont("Nunito", "normal", 400); + doc.setFontSize(12); + doc.setTextColor(...COLOR_FIELD_BASE); + doc.text("Click here to view the Submission Request Form", CONTENT_MARGIN, y, { + align: "left", + }); + + // Click to view (overlapping link) + doc.setTextColor(...COLOR_HYPERLINK); + doc.textWithLink("here", CONTENT_MARGIN + 22, y, { + url: `${env.REACT_APP_NIH_REDIRECT_URL}/submission/${request._id}`, + align: "left", + }); + + doc.setDrawColor(...COLOR_HYPERLINK); + doc.setLineWidth(HR_WIDTH); + doc.line(CONTENT_MARGIN + 22, (y += 2), CONTENT_MARGIN + 40, y); + + doc.setDrawColor(...COLOR_HR); + doc.line(BASE_MARGIN, (y += 22), RIGHT_EDGE, y); + + return y + 30; +}; + +/** + * A function to write a footer across all pages in the PDF document. + * + * @param doc The jsPDF instance to add the footer to. + * @returns {void} + */ +const writeFooters = (doc: JsPDF): void => { + const pdfRightEdge = doc.internal.pageSize.width - BASE_MARGIN; + const bottomMargin = doc.internal.pageSize.height - BASE_MARGIN; + const pageCount: number = doc.internal.pages.filter((p) => p !== null).length; + + for (let pageNum = 0; pageNum < pageCount; pageNum += 1) { + doc.setPage(pageNum + 1); + + // Draw the horizontal line 20px from the bottom + doc.setDrawColor(...COLOR_HR); + doc.setLineWidth(HR_WIDTH); + doc.line( + BASE_MARGIN, + bottomMargin - 20, + doc.internal.pageSize.width - BASE_MARGIN, + bottomMargin - 20 + ); + + // Write the page number + doc.setFontSize(10); + doc.setTextColor(...COLOR_DOC_FOOTER); + doc.setFont("Nunito", "normal", 600); + doc.text(`Page ${pageNum + 1} of ${pageCount}`, pdfRightEdge, bottomMargin, { + align: "right", + }); + } +}; + +/** + * A function to generate a PDF document from a submission request. + * + * @note This function temporarily modifies the width of the print region to ensure the + * content fits within the page. + * @param request The submission request to generate a PDF from. + * @param printRegion The region to print to the PDF. + * @returns {Promise} A promise that resolves when the PDF is generated. + * @throws {Error} If the submission request is invalid. + */ +export const GenerateDocument = async ( + request: Application, + printRegion: HTMLElement +): Promise => { + if (!request || !request?._id) { + throw new Error("Invalid submission request provided."); + } + if (!printRegion || !(printRegion instanceof Element)) { + throw new Error("Invalid print region provided."); + } + + const doc = new JsPDF({ + orientation: "portrait", + unit: "px", + format: "letter", + }); + + doc.setProperties({ + title: `Submission Request ${request._id}`, + subject: `PDF Export of Submission Request ${request._id}`, + keywords: "CRDC, submission request, PDF export", + author: "CRDC Submission Portal", + creator: "crdc-datahub-ui", + }); + + await Promise.allSettled(fonts.map((font) => loadFont(doc, font))); + + return new Promise((resolve) => { + const y = writeHeader(doc, request); + + // NOTE: This fixes the width of the form to prevent the form from being too wide in the PDF + // html2canvas width/windowWidth props do not work as expected + printRegion.style.width = "794px"; + + doc.html(printRegion, { + callback: (doc) => { + printRegion.style.width = ""; + + writeFooters(doc); + resolve(doc.output("blob")); + }, + html2canvas: { + scale: 0.5, + ignoreElements: (element: HTMLElement) => { + if (element?.getAttribute("data-print") === "false") { + return true; + } + + return false; + }, + logging: false, + }, + autoPaging: "text", + x: 0, + y: y - 25, + margin: [BASE_MARGIN, BASE_MARGIN + 10, 50, BASE_MARGIN + 10], + }); + }); +}; diff --git a/src/components/ExportRequestButton/pdf/utils.test.ts b/src/components/ExportRequestButton/pdf/utils.test.ts new file mode 100644 index 000000000..cf58a3128 --- /dev/null +++ b/src/components/ExportRequestButton/pdf/utils.test.ts @@ -0,0 +1,194 @@ +import jsPDF from "jspdf"; +import { FontResource } from "./Fonts"; +import * as utils from "./utils"; + +const mockAddFileToVFS = jest.fn(); +const mockAddFont = jest.fn(); +const MockJsPDF = { + addFileToVFS: (...p) => mockAddFileToVFS(...p), + addFont: (...p) => mockAddFont(...p), +}; + +const baseFont: FontResource = { + src: "", + family: "mock-family", + style: "normal", + fontWeight: 500, +}; + +describe("loadFont", () => { + beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => {}); + jest.resetAllMocks(); + }); + + it("should add the font to the jsPDF instance if the fetch is successful", async () => { + jest.spyOn(window, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(1)), + } as unknown as Response); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + src: "http://example.com/font.ttf", + family: "test-mock-family", + fontWeight: 900, + }); + + expect(window.fetch).toHaveBeenCalledWith("http://example.com/font.ttf", { + cache: "force-cache", + }); + expect(mockAddFileToVFS).toHaveBeenCalledWith("test-mock-family", "AA=="); + expect(mockAddFont).toHaveBeenCalledWith("test-mock-family", "test-mock-family", "normal", 900); + }); + + it("should enforce heavy caching to avoid unnecessary network requests", async () => { + jest.spyOn(window, "fetch").mockResolvedValue(new Response(null)); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + src: "https://example-fonts.com/font-abc.ttf", + }); + + expect(window.fetch).toHaveBeenCalledWith("https://example-fonts.com/font-abc.ttf", { + cache: "force-cache", + }); + }); + + it("should not add the font to the jsPDF instance if an invalid font resource is provided (src)", async () => { + jest.spyOn(window, "fetch").mockResolvedValue(new Response(null)); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + src: "", + }); + + expect(window.fetch).not.toHaveBeenCalled(); + expect(mockAddFileToVFS).not.toHaveBeenCalled(); + expect(mockAddFont).not.toHaveBeenCalled(); + }); + + it("should not add the font to the jsPDF instance if an invalid font resource is provided (family)", async () => { + jest.spyOn(window, "fetch").mockResolvedValue(new Response(null)); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + family: "", + }); + + expect(window.fetch).not.toHaveBeenCalled(); + expect(mockAddFileToVFS).not.toHaveBeenCalled(); + expect(mockAddFont).not.toHaveBeenCalled(); + }); + + it("should not add the font to the jsPDF instance if an invalid font resource is provided (style)", async () => { + jest.spyOn(window, "fetch").mockResolvedValue(new Response(null)); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + style: "", + }); + + expect(window.fetch).not.toHaveBeenCalled(); + expect(mockAddFileToVFS).not.toHaveBeenCalled(); + expect(mockAddFont).not.toHaveBeenCalled(); + }); + + it("should not add the font to the jsPDF instance if an invalid font resource is provided (fontWeight)", async () => { + jest.spyOn(window, "fetch").mockResolvedValue(new Response(null)); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + fontWeight: 0, + }); + + expect(window.fetch).not.toHaveBeenCalled(); + expect(mockAddFileToVFS).not.toHaveBeenCalled(); + expect(mockAddFont).not.toHaveBeenCalled(); + }); + + it("should not add the font to the jsPDF instance if the fetch fails", async () => { + jest.spyOn(window, "fetch").mockResolvedValue(new Response(null, { status: 404 })); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + src: "http://example.com/font.ttf", + }); + + expect(window.fetch).toHaveBeenCalledWith("http://example.com/font.ttf", expect.any(Object)); + + expect(mockAddFileToVFS).not.toHaveBeenCalled(); + expect(mockAddFont).not.toHaveBeenCalled(); + }); + + it("should not add the font to the jsPDF instance if the fetch throws an exception", async () => { + jest.spyOn(window, "fetch").mockRejectedValue(new Error("Mock fetch error")); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + src: "http://example.com/font.ttf", + }); + + expect(window.fetch).toHaveBeenCalledWith("http://example.com/font.ttf", expect.any(Object)); + + expect(mockAddFileToVFS).not.toHaveBeenCalled(); + expect(mockAddFont).not.toHaveBeenCalled(); + }); + + it("should not add the font to the jsPDF instance if the arrayBuffer fails", async () => { + jest.spyOn(window, "fetch").mockResolvedValue({ + ok: true, + arrayBuffer: jest.fn().mockRejectedValue(new Error("Mock arrayBuffer error")), + } as unknown as Response); + + await utils.loadFont(MockJsPDF as unknown as jsPDF, { + ...baseFont, + src: "http://example.com/font.ttf", + }); + + expect(window.fetch).toHaveBeenCalledWith("http://example.com/font.ttf", expect.any(Object)); + + expect(mockAddFileToVFS).not.toHaveBeenCalled(); + expect(mockAddFont).not.toHaveBeenCalled(); + }); +}); + +describe("arrayBufferToBase64", () => { + beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should return a base64 string if the buffer is valid", () => { + const buffer = new ArrayBuffer(1); + const view = new Uint8Array(buffer); + view[0] = 1; + expect(utils.arrayBufferToBase64(buffer)).toBe("AQ=="); + }); + + it("should return an empty string if the buffer argument is invalid", () => { + const buffer = new ArrayBuffer(0); + expect(utils.arrayBufferToBase64(buffer)).toBe(""); + }); + + it.each(["", null, undefined, {}])( + "should return an empty string if the buffer argument is not an ArrayBuffer", + (arg) => { + expect(utils.arrayBufferToBase64(arg as unknown as ArrayBuffer)).toBe(""); + } + ); + + it("should return an empty string if btoa fails", () => { + jest.spyOn(window, "btoa").mockImplementation(() => { + throw new Error("Mock btoa error"); + }); + + const buffer = new ArrayBuffer(1); + const view = new Uint8Array(buffer); + view[0] = 1; + expect(utils.arrayBufferToBase64(buffer)).toBe(""); + }); +}); diff --git a/src/components/ExportRequestButton/pdf/utils.ts b/src/components/ExportRequestButton/pdf/utils.ts new file mode 100644 index 000000000..a503e0e33 --- /dev/null +++ b/src/components/ExportRequestButton/pdf/utils.ts @@ -0,0 +1,65 @@ +import type { jsPDF } from "jspdf"; +import type { FontResource } from "./Fonts"; +import { Logger } from "../../../utils"; + +/** + * Convert an ArrayBuffer to a base64 string + * + * @param buffer the ArrayBuffer to convert + * @returns the base64 string + */ +export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + if (!buffer || !(buffer instanceof ArrayBuffer)) { + Logger.error("arrayBufferToBase64: Invalid buffer received"); + return ""; + } + + let binary = ""; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + + for (let i = 0; i < len; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + + try { + return window.btoa(binary); + } catch (e) { + Logger.error("arrayBufferToBase64: Failed to convert buffer to base64", e); + } + + return ""; +}; + +/** + * Load a external font file and add it to the jsPDF instance + * + * @note This function enforces heavy caching to avoid unnecessary network requests. + * @param doc the jsPDF instance to add the font to + * @param font the font resource to load + * @return {Promise} a promise that resolves when the font is loaded + */ +export const loadFont = async ( + doc: jsPDF, + { src, family, style, fontWeight }: FontResource +): Promise => { + if (!src || !family || !style || !fontWeight) { + Logger.error("loadFont: Invalid font resource"); + return; + } + + const response = await fetch(src, { cache: "force-cache" }).catch(() => null); + if (!response || !response.ok) { + Logger.error(`loadFont: Failed to fetch font ${family}`); + return; + } + + const contentBuffer = await response.arrayBuffer().catch(() => null); + const contentString = arrayBufferToBase64(contentBuffer); + if (contentString) { + doc.addFileToVFS(family, contentString); + doc.addFont(family, family, style, fontWeight); + } else { + Logger.error(`loadFont: Failed to base64 font ${family}`); + } +}; diff --git a/src/components/Questionnaire/FormContainer.tsx b/src/components/Questionnaire/FormContainer.tsx index 500e13e4d..44db49505 100644 --- a/src/components/Questionnaire/FormContainer.tsx +++ b/src/components/Questionnaire/FormContainer.tsx @@ -91,12 +91,14 @@ const FormContainer = forwardRef( Return to all Submissions - - {description} - - e.preventDefault()}> - {children} - +
+ + {description} + + e.preventDefault()}> + {children} + +
); } diff --git a/src/components/Questionnaire/ReviewDataListing.tsx b/src/components/Questionnaire/ReviewDataListing.tsx index 22761b340..44220d7f4 100644 --- a/src/components/Questionnaire/ReviewDataListing.tsx +++ b/src/components/Questionnaire/ReviewDataListing.tsx @@ -34,7 +34,11 @@ const ReviewDataListing = ({ idPrefix, title, description, hideTitle, children } )} {description && ( - + {description} )} diff --git a/src/components/Questionnaire/ReviewFileTypeTable.tsx b/src/components/Questionnaire/ReviewFileTypeTable.tsx index b9bf9e0a2..cc78510d3 100644 --- a/src/components/Questionnaire/ReviewFileTypeTable.tsx +++ b/src/components/Questionnaire/ReviewFileTypeTable.tsx @@ -77,24 +77,6 @@ const StyledTableCell = styled(TableCell)(() => ({ }, })); -const StyledTableCellNumber = styled(TableCell)(() => ({ - "&.MuiTableCell-root": { - height: "100%", - color: "#346798", - fontFamily: "'Inter'", - fontSize: "15px", - fontStyle: "normal", - fontWeight: 400, - lineHeight: "normal", - padding: "11px 15px", - borderBottom: "0 !important", - borderRight: "1px solid #6B7294", - "&:last-child": { - borderRight: "none", - }, - }, -})); - type ReviewFileTypeTableProps = { files: FileInfo[]; }; @@ -127,15 +109,12 @@ const ReviewFileTypeTable: React.FC = ({ files }) => ( {file.extension} - + {file.count} - - + + {addSpace(file.amount)} - + ))} diff --git a/src/components/UploaderToolDialog/PackageTable.tsx b/src/components/UploaderToolDialog/PackageTable.tsx index e478aa2c4..811957387 100644 --- a/src/components/UploaderToolDialog/PackageTable.tsx +++ b/src/components/UploaderToolDialog/PackageTable.tsx @@ -63,6 +63,10 @@ const StyledDownload = styled(Link)({ marginRight: "8px", }); +const StyledDownloadIcon = styled(DownloadIcon)({ + color: "#365F71", +}); + const PackageTable = () => ( @@ -98,7 +102,7 @@ const PackageTable = () => ( aria-label={pkg.fileName} data-testid={`package-table-icon-download-${pkg.fileName}`} > - + )} diff --git a/src/content/dataSubmissions/CrossValidation.test.tsx b/src/content/dataSubmissions/CrossValidation.test.tsx index 40a99f261..d39f79b71 100644 --- a/src/content/dataSubmissions/CrossValidation.test.tsx +++ b/src/content/dataSubmissions/CrossValidation.test.tsx @@ -57,6 +57,7 @@ const baseSubmission: Submission = { crossSubmissionStatus: null, studyID: "", deletingData: false, + nodeCount: 0, }; const baseCrossValidationResult: CrossValidationResult = { diff --git a/src/content/dataSubmissions/DataActivity.test.tsx b/src/content/dataSubmissions/DataActivity.test.tsx index 86d8040d0..5192aa06c 100644 --- a/src/content/dataSubmissions/DataActivity.test.tsx +++ b/src/content/dataSubmissions/DataActivity.test.tsx @@ -42,6 +42,7 @@ const baseSubmission: Omit = { fileValidationStatus: "New", studyID: "", deletingData: false, + nodeCount: 0, }; type ParentProps = { diff --git a/src/content/dataSubmissions/DataSubmissionsListView.tsx b/src/content/dataSubmissions/DataSubmissionsListView.tsx index 402118228..34b63396b 100644 --- a/src/content/dataSubmissions/DataSubmissionsListView.tsx +++ b/src/content/dataSubmissions/DataSubmissionsListView.tsx @@ -79,7 +79,7 @@ const StyledTableCell = styled(TableCell)({ fontSize: "14px", color: "#083A50 !important", "&.MuiTableCell-root": { - padding: "14px 8px 12px", + padding: "14px 4px 12px", overflowWrap: "anywhere", whiteSpace: "nowrap", }, @@ -213,6 +213,12 @@ const columns: Column[] = [ renderValue: (a) => , field: "conciergeName", }, + { + label: "Node Count", + renderValue: (a) => + Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(a.nodeCount || 0), + field: "nodeCount", + }, { label: "Created Date", renderValue: (a) => diff --git a/src/content/dataSubmissions/QualityControl.test.tsx b/src/content/dataSubmissions/QualityControl.test.tsx index 0a352b2d0..adf4c7f09 100644 --- a/src/content/dataSubmissions/QualityControl.test.tsx +++ b/src/content/dataSubmissions/QualityControl.test.tsx @@ -55,6 +55,7 @@ const baseSubmission: Submission = { crossSubmissionStatus: null, studyID: "", deletingData: false, + nodeCount: 0, }; const baseQCResult: QCResult = { diff --git a/src/content/questionnaire/FormView.tsx b/src/content/questionnaire/FormView.tsx index 75741cd31..6770c708e 100644 --- a/src/content/questionnaire/FormView.tsx +++ b/src/content/questionnaire/FormView.tsx @@ -27,6 +27,7 @@ import PageBanner from "../../components/PageBanner"; import bannerPng from "../../assets/banner/submission_banner.png"; import { Status as AuthStatus, useAuthContext } from "../../components/Contexts/AuthContext"; import usePageTitle from "../../hooks/usePageTitle"; +import ExportRequestButton from "../../components/ExportRequestButton"; const StyledContainer = styled(Container)(() => ({ "&.MuiContainer-root": { @@ -173,13 +174,14 @@ const FormView: FC = ({ section }: Props) => { const hasReopenedFormRef = useRef(false); const hasUpdatedReviewStatusRef = useRef(false); - const refs = { + const refs: FormSectionProps["refs"] = { saveFormRef: createRef(), submitFormRef: createRef(), nextButtonRef: createRef(), approveFormRef: createRef(), inquireFormRef: createRef(), rejectFormRef: createRef(), + exportButtonRef: createRef(), getFormObjectRef: useRef<(() => FormObject) | null>(null), }; @@ -810,6 +812,7 @@ const FormView: FC = ({ section }: Props) => { > Next + diff --git a/src/content/questionnaire/sections/A.tsx b/src/content/questionnaire/sections/A.tsx index 722c01677..e7e11c69b 100644 --- a/src/content/questionnaire/sections/A.tsx +++ b/src/content/questionnaire/sections/A.tsx @@ -73,6 +73,7 @@ const FormSectionA: FC = ({ SectionOption, refs }: FormSection approveFormRef, inquireFormRef, rejectFormRef, + exportButtonRef, getFormObjectRef, } = refs; @@ -129,6 +130,7 @@ const FormSectionA: FC = ({ SectionOption, refs }: FormSection approveFormRef.current.style.display = "none"; inquireFormRef.current.style.display = "none"; rejectFormRef.current.style.display = "none"; + exportButtonRef.current.style.display = "none"; getFormObjectRef.current = getFormObject; }, [refs]); diff --git a/src/content/questionnaire/sections/B.tsx b/src/content/questionnaire/sections/B.tsx index 89c4d3a0a..827a2f7c4 100644 --- a/src/content/questionnaire/sections/B.tsx +++ b/src/content/questionnaire/sections/B.tsx @@ -94,6 +94,7 @@ const FormSectionB: FC = ({ SectionOption, refs }: FormSection approveFormRef, inquireFormRef, rejectFormRef, + exportButtonRef, getFormObjectRef, } = refs; @@ -310,6 +311,7 @@ const FormSectionB: FC = ({ SectionOption, refs }: FormSection approveFormRef.current.style.display = "none"; inquireFormRef.current.style.display = "none"; rejectFormRef.current.style.display = "none"; + exportButtonRef.current.style.display = "none"; getFormObjectRef.current = getFormObject; }, [refs]); diff --git a/src/content/questionnaire/sections/C.tsx b/src/content/questionnaire/sections/C.tsx index 1361e12c8..5fe5722dc 100644 --- a/src/content/questionnaire/sections/C.tsx +++ b/src/content/questionnaire/sections/C.tsx @@ -40,6 +40,7 @@ const FormSectionC: FC = ({ SectionOption, refs }: FormSection approveFormRef, inquireFormRef, rejectFormRef, + exportButtonRef, getFormObjectRef, } = refs; const { C: SectionCMetadata } = SectionMetadata; @@ -121,6 +122,7 @@ const FormSectionC: FC = ({ SectionOption, refs }: FormSection approveFormRef.current.style.display = "none"; inquireFormRef.current.style.display = "none"; rejectFormRef.current.style.display = "none"; + exportButtonRef.current.style.display = "none"; getFormObjectRef.current = getFormObject; }, [refs]); diff --git a/src/content/questionnaire/sections/D.tsx b/src/content/questionnaire/sections/D.tsx index b30b565f7..c1ebfef18 100644 --- a/src/content/questionnaire/sections/D.tsx +++ b/src/content/questionnaire/sections/D.tsx @@ -158,6 +158,7 @@ const FormSectionD: FC = ({ SectionOption, refs }: FormSection approveFormRef, inquireFormRef, rejectFormRef, + exportButtonRef, getFormObjectRef, } = refs; const [fileTypeData, setFileTypeData] = useState( @@ -267,6 +268,7 @@ const FormSectionD: FC = ({ SectionOption, refs }: FormSection approveFormRef.current.style.display = "none"; inquireFormRef.current.style.display = "none"; rejectFormRef.current.style.display = "none"; + exportButtonRef.current.style.display = "none"; getFormObjectRef.current = getFormObject; }, [refs]); diff --git a/src/content/questionnaire/sections/Review.tsx b/src/content/questionnaire/sections/Review.tsx index 50be5427e..1a2345c84 100644 --- a/src/content/questionnaire/sections/Review.tsx +++ b/src/content/questionnaire/sections/Review.tsx @@ -62,6 +62,7 @@ const FormSectionReview: FC = ({ SectionOption, refs }: FormSe approveFormRef, inquireFormRef, rejectFormRef, + exportButtonRef, getFormObjectRef, } = refs; @@ -106,6 +107,7 @@ const FormSectionReview: FC = ({ SectionOption, refs }: FormSe saveFormRef.current.style.display = "none"; nextButtonRef.current.style.display = "none"; + exportButtonRef.current.style.display = "flex"; if (formMode === "Review") { approveFormRef.current.style.display = "flex"; @@ -718,7 +720,7 @@ const FormSectionReview: FC = ({ SectionOption, refs }: FormSe - + ); }; diff --git a/src/graphql/getSubmission.ts b/src/graphql/getSubmission.ts index 05157ce2f..1559253d3 100644 --- a/src/graphql/getSubmission.ts +++ b/src/graphql/getSubmission.ts @@ -57,6 +57,7 @@ export const query = gql` intention dataType otherSubmissions + nodeCount createdAt updatedAt } diff --git a/src/graphql/listSubmissions.ts b/src/graphql/listSubmissions.ts index da42e1169..d5befc98c 100644 --- a/src/graphql/listSubmissions.ts +++ b/src/graphql/listSubmissions.ts @@ -33,6 +33,7 @@ export const query = gql` status archived conciergeName + nodeCount createdAt updatedAt intention diff --git a/src/types/Globals.d.ts b/src/types/Globals.d.ts index f34fb76fc..c8af36932 100644 --- a/src/types/Globals.d.ts +++ b/src/types/Globals.d.ts @@ -12,6 +12,7 @@ type FormSectionProps = { approveFormRef: React.RefObject; inquireFormRef: React.RefObject; rejectFormRef: React.RefObject; + exportButtonRef: React.RefObject; getFormObjectRef: React.MutableRefObject<(() => FormObject | null) | null>; }; SectionOption: SectionOption; diff --git a/src/types/Submissions.d.ts b/src/types/Submissions.d.ts index 4c4af1cc1..4f6f0e853 100644 --- a/src/types/Submissions.d.ts +++ b/src/types/Submissions.d.ts @@ -59,6 +59,10 @@ type Submission = { * @see OtherSubmissions */ otherSubmissions: string; + /** + * The total number of nodes in the Submission + */ + nodeCount: number; createdAt: string; // ISO 8601 date time format with UTC or offset e.g., 2023-05-01T09:23:30Z updatedAt: string; // ISO 8601 date time format with UTC or offset e.g., 2023-05-01T09:23:30Z }; diff --git a/src/utils/dataSubmissionUtils.test.ts b/src/utils/dataSubmissionUtils.test.ts index 441eea57d..919b5bda0 100644 --- a/src/utils/dataSubmissionUtils.test.ts +++ b/src/utils/dataSubmissionUtils.test.ts @@ -34,6 +34,7 @@ const baseSubmission: Submission = { validationType: ["metadata", "file"], studyID: "", deletingData: false, + nodeCount: 0, }; describe("General Submit", () => { diff --git a/src/utils/dataSubmissionUtils.ts b/src/utils/dataSubmissionUtils.ts index 80f9e770e..8f6db9172 100644 --- a/src/utils/dataSubmissionUtils.ts +++ b/src/utils/dataSubmissionUtils.ts @@ -159,7 +159,11 @@ export const unpackValidationSeverities = { +export const downloadBlob = ( + content: string | Blob, + filename: string, + contentType: string +): void => { const blob = new Blob([content], { type: contentType }); const url = URL.createObjectURL(blob); const link = document.createElement("a");