From 356d499ee2dd003ed9897be9858218a30c692716 Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 5 Mar 2024 10:20:15 -0500 Subject: [PATCH 01/34] feat: Add typing to FE env variables --- src/config/globalFooterData.tsx | 3 --- src/content/login/Controller.tsx | 10 +++---- src/env.ts | 11 +++++--- src/types/AppEnv.d.ts | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 src/types/AppEnv.d.ts diff --git a/src/config/globalFooterData.tsx b/src/config/globalFooterData.tsx index 6850564c9..5a24abfd7 100644 --- a/src/config/globalFooterData.tsx +++ b/src/config/globalFooterData.tsx @@ -1,4 +1,3 @@ -import env from '../env'; import instagramIcon from '../assets/footer/Instgram_Logo.svg'; import twitterIcon from '../assets/footer/Twitter_Logo.svg'; import facebookIcon from '../assets/footer/Facebook_Logo.svg'; @@ -11,8 +10,6 @@ export default { footerLogoAltText: 'Footer Logo', footerLogoHyperlink: 'https://www.cancer.gov/', footerStaticText: 'NIH … Turning Discovery Into Health®', - version: env.REACT_APP_FE_VERSION, - BEversion: env.REACT_APP_BE_VERSION, // A maximum of 3 Subsections (link_sections) are allowed // A maximum of 4 Subsection Links ('items' under link_sections) are allowed // A maximum of 4 Anchor Links (global_footer_links) are allowed diff --git a/src/content/login/Controller.tsx b/src/content/login/Controller.tsx index e10a925ae..5b1c4e4bc 100644 --- a/src/content/login/Controller.tsx +++ b/src/content/login/Controller.tsx @@ -10,14 +10,11 @@ import usePageTitle from '../../hooks/usePageTitle'; const loginController = () => { usePageTitle("Login"); - const NIH_AUTHORIZE_URL = env.REACT_APP_NIH_AUTHORIZE_URL || env.NIH_AUTHORIZE_URL; - const NIH_CLIENT_ID = env.REACT_APP_NIH_CLIENT_ID || env.NIH_CLIENT_ID; - const NIH_REDIRECT_URL = env.REACT_APP_NIH_REDIRECT_URL || env.NIH_REDIRECT_URL; const { state } = useLocation(); const redirectURLOnLoginSuccess = state && state.redirectURLOnLoginSuccess ? state.redirectURLOnLoginSuccess : null; const urlParam = { - client_id: `${NIH_CLIENT_ID}`, - redirect_uri: `${NIH_REDIRECT_URL}`, + client_id: env.REACT_APP_NIH_CLIENT_ID, + redirect_uri: env.REACT_APP_NIH_REDIRECT_URL, response_type: 'code', scope: 'openid email profile', prompt: 'login', @@ -28,8 +25,7 @@ const loginController = () => { } const params = new URLSearchParams(urlParam).toString(); - const redirectUrl = `${NIH_AUTHORIZE_URL}?${params}`; - window.location.href = redirectUrl; + window.location.href = `${env.REACT_APP_NIH_AUTHORIZE_URL}?${params}`; return null; }; diff --git a/src/env.ts b/src/env.ts index fe0860298..9364e6612 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,8 +1,13 @@ +declare global { + interface Window { + injectedEnv: AppEnv; + } +} + const processEnv = process.env ?? {}; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const injectedEnv = (window as { injectedEnv?: any }).injectedEnv || {}; +const { injectedEnv } = window ?? {}; -const env = { +const env: AppEnv = { ...injectedEnv, ...processEnv, }; diff --git a/src/types/AppEnv.d.ts b/src/types/AppEnv.d.ts new file mode 100644 index 000000000..70ba855ec --- /dev/null +++ b/src/types/AppEnv.d.ts @@ -0,0 +1,45 @@ +type AppEnv = { + /** + * NIH SSO Url + */ + REACT_APP_NIH_AUTHORIZE_URL: string; + /** + * NIH SSO Client Id + */ + REACT_APP_NIH_CLIENT_ID: string; + /** + * NIH SSO Redirect Url + */ + REACT_APP_NIH_REDIRECT_URL: string; + /** + * Backend API URL + * + * @example "https://example.com/api/graphql" + */ + REACT_APP_BACKEND_API: string; + /** + * Current deployment tier + * + * @example DEV2 + * @example PROD + */ + REACT_APP_DEV_TIER: string; + /** + * Fully-qualified URL to the Uploader CLI zip download + * + * @example "https://github.com/CBIIT/crdc-datahub-cli-uploader/releases/download/1.0.0/crdc-datahub-cli-uploader.zip" + */ + REACT_APP_UPLOADER_CLI: string; + /** + * Google Analytic (GA4) Tracking ID + * + * @example "G-XXXXXXXXXX" + */ + REACT_APP_GA_TRACKING_ID: string; + /** + * Current frontend build tag/version + * + * @example "mvp-2.213" + */ + REACT_APP_FE_VERSION: string; +}; From 394e20238b4ed4e25781e45710b41e0261bc3876 Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 5 Mar 2024 11:24:06 -0500 Subject: [PATCH 02/34] fix: `REACT_APP_NIH_AUTHORIZE_URL` is not populated --- conf/inject.template.js | 2 +- public/injectEnv.js | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/conf/inject.template.js b/conf/inject.template.js index dfa0fd991..f7987397d 100644 --- a/conf/inject.template.js +++ b/conf/inject.template.js @@ -18,7 +18,7 @@ window.injectedEnv = { REACT_APP_USER_SERVICE_API: '${REACT_APP_USER_SERVICE_API}', REACT_APP_NIH_CLIENT_ID: '${REACT_APP_NIH_CLIENT_ID}', REACT_APP_NIH_AUTH_URL: '${REACT_APP_NIH_AUTH_URL}', - REACT_APP_NIH_AUTHORIZE_URL: '${REACT_APP_NIH_AUTHORIZE_URL}', + REACT_APP_NIH_AUTHORIZE_URL: '${NIH_AUTHORIZE_URL}', REACT_APP_NIH_REDIRECT_URL: '${REACT_APP_NIH_REDIRECT_URL}', REACT_APP_BACKEND_PUBLIC_API: '${REACT_APP_BACKEND_PUBLIC_API}', REACT_APP_AUTH: '${REACT_APP_AUTH}', diff --git a/public/injectEnv.js b/public/injectEnv.js index 124027f53..2b41ddc82 100644 --- a/public/injectEnv.js +++ b/public/injectEnv.js @@ -6,4 +6,5 @@ window.injectedEnv = { REACT_APP_UPLOADER_CLI: '', REACT_APP_GA_TRACKING_ID: '', REACT_APP_FE_VERSION: '', + REACT_APP_BACKEND_API: '', }; From 66247fea4afd27c2b2b7f00b780176783adce11f Mon Sep 17 00:00:00 2001 From: Alec M Date: Mon, 18 Mar 2024 11:09:07 -0400 Subject: [PATCH 03/34] feat: Validation Results CSV Exporting --- package-lock.json | 17 ++++- package.json | 2 + .../dataSubmissions/QualityControl.tsx | 62 +++++++++++++++++-- src/utils/dataSubmissionUtils.ts | 54 ++++++++++++++++ 4 files changed, 129 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa46308ca..5bd1c28bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "graphql": "^16.7.1", "lodash": "^4.17.21", "notistack": "^3.0.1", + "papaparse": "^5.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-ga4": "^2.1.0", @@ -44,6 +45,7 @@ "@types/jest-axe": "^3.5.9", "@types/lodash": "^4.14.198", "@types/node": "^20.4.0", + "@types/papaparse": "^5.3.14", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "@types/recharts": "^1.8.29", @@ -4944,6 +4946,15 @@ "version": "20.4.0", "license": "MIT" }, + "node_modules/@types/papaparse": { + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.14.tgz", + "integrity": "sha512-LxJ4iEFcpqc6METwp9f6BV6VVc43m6MfH0VqFosHvrUgfXiFe6ww7R3itkOQ+TCK6Y+Iv/+RnnvtRZnkc5Kc9g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "license": "MIT" @@ -13448,6 +13459,11 @@ "version": "1.0.11", "license": "(MIT AND Zlib)" }, + "node_modules/papaparse": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", + "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" + }, "node_modules/param-case": { "version": "3.0.4", "license": "MIT", @@ -17394,7 +17410,6 @@ }, "node_modules/typescript": { "version": "5.1.3", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 388d51bdc..c90e27636 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "graphql": "^16.7.1", "lodash": "^4.17.21", "notistack": "^3.0.1", + "papaparse": "^5.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-ga4": "^2.1.0", @@ -71,6 +72,7 @@ "@types/jest-axe": "^3.5.9", "@types/lodash": "^4.14.198", "@types/node": "^20.4.0", + "@types/papaparse": "^5.3.14", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "@types/recharts": "^1.8.29", diff --git a/src/content/dataSubmissions/QualityControl.tsx b/src/content/dataSubmissions/QualityControl.tsx index 4fa852ed6..468e10be0 100644 --- a/src/content/dataSubmissions/QualityControl.tsx +++ b/src/content/dataSubmissions/QualityControl.tsx @@ -2,11 +2,14 @@ import { FC, useEffect, useMemo, useRef, useState } from "react"; import { useLazyQuery, useQuery } from "@apollo/client"; import { useParams } from "react-router-dom"; import { isEqual } from "lodash"; +import { LoadingButton } from '@mui/lab'; import { Box, Button, FormControl, MenuItem, Select, styled } from "@mui/material"; import { Controller, useForm } from 'react-hook-form'; +import { useSnackbar } from 'notistack'; +import { unparse } from 'papaparse'; import { LIST_BATCHES, LIST_NODE_TYPES, ListBatchesResp, ListNodeTypesResp, SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from "../../graphql"; import GenericTable, { Column, FetchListing, TableMethods } from "../../components/DataSubmissions/GenericTable"; -import { FormatDate, capitalizeFirstLetter } from "../../utils"; +import { FormatDate, capitalizeFirstLetter, downloadBlob, unpackQCResultSeverities } from "../../utils"; import ErrorDialog from "./ErrorDialog"; import QCResultsContext from "./Contexts/QCResultsContext"; @@ -156,15 +159,15 @@ const columns: Column[] = [ const QualityControl: FC = () => { const { submissionId } = useParams(); const { watch, control } = useForm(); + const { enqueueSnackbar } = useSnackbar(); const [loading, setLoading] = useState(false); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [error, setError] = useState(null); const [data, setData] = useState([]); const [prevData, setPrevData] = useState>(null); const [totalData, setTotalData] = useState(0); const [openErrorDialog, setOpenErrorDialog] = useState(false); const [selectedRow, setSelectedRow] = useState(null); + const [buildingResults, setBuildingResults] = useState(false); const tableRef = useRef(null); const errorDescriptions = selectedRow?.errors?.map((error) => `(Error) ${error.description}`) ?? []; @@ -199,7 +202,7 @@ const QualityControl: FC = () => { const handleFetchQCResults = async (fetchListing: FetchListing, force: boolean) => { const { first, offset, sortDirection, orderBy } = fetchListing || {}; if (!submissionId) { - setError("Invalid submission ID provided."); + enqueueSnackbar("Invalid submission ID provided.", { variant: "error" }); return; } if (!force && data?.length > 0 && isEqual(fetchListing, prevData)) { @@ -234,7 +237,7 @@ const QualityControl: FC = () => { setData(d.submissionQCResults.results); setTotalData(d.submissionQCResults.total); } catch (err) { - setError(err?.toString()); + enqueueSnackbar(err?.toString(), { variant: "error" }); } finally { setLoading(false); } @@ -245,6 +248,46 @@ const QualityControl: FC = () => { setSelectedRow(data); }; + const downloadQCResults = async () => { + setBuildingResults(true); + + const { data: d, error } = await submissionQCResults({ + variables: { + // TODO: sorting and filters? + submissionID: submissionId, + first: 10000, // TODO: change to -1 + offset: 0, + }, + context: { clientName: 'backend' }, + fetchPolicy: 'no-cache' + }); + + if (error || !d?.submissionQCResults?.results) { + enqueueSnackbar("Unable to retrieve submission quality control results.", { variant: "error" }); + setBuildingResults(false); + return; + } + + try { + const unpacked = unpackQCResultSeverities(d.submissionQCResults.results); + const csvArray = unpacked.map((result) => ({ + "Batch ID": result.displayID, + "Node Type": result.type, + "Submitted Identifier": result.submittedID, + Severity: result.severity, + "Validated Date": result.validatedDate, // TODO: formatted? + Issues: result.errors?.length > 0 ? result.errors[0].description : result.warnings[0]?.description, + })); + + // TODO: File name? + downloadBlob(unparse(csvArray), "validation-results.csv", "text/csv"); + } catch (err) { + enqueueSnackbar("Unable to export validation results.", { variant: "error" }); + } + + setBuildingResults(false); + }; + const providerValue = useMemo(() => ({ handleOpenErrorDialog }), [handleOpenErrorDialog]); @@ -255,6 +298,15 @@ const QualityControl: FC = () => { return ( <> + {/* NOTE: This is just temporary */} + + Download QC Results + Node Type diff --git a/src/utils/dataSubmissionUtils.ts b/src/utils/dataSubmissionUtils.ts index bd2899c54..457026181 100644 --- a/src/utils/dataSubmissionUtils.ts +++ b/src/utils/dataSubmissionUtils.ts @@ -39,3 +39,57 @@ export const shouldDisableSubmit = ( return { disable, isAdminOverride }; }; + +/** + * Unpacks the Warning and Error severities from the original QCResult into duplicates of the original QCResult + * + * @example + * - Original QCResult: { severity: "error", errors: [error1, error2], warnings: [warning1, warning2] } + * - Unpacked QCResults: [{ severity: "error", errors: [error1] }, { severity: "error", errors: [error2] }, ... + * @param results - The QC results to unpack + * @returns A new array of QCResults + */ +export const unpackQCResultSeverities = (results: QCResult[]): QCResult[] => { + const unpackedResults: QCResult[] = []; + + // Iterate through each result and push the errors and warnings into separate results + results.forEach((result) => { + result.errors.slice(0).forEach((error) => { + unpackedResults.push({ + ...result, + severity: "Error", + errors: [error], + warnings: [], + }); + }); + result.warnings.slice(0).forEach((warning) => { + unpackedResults.push({ + ...result, + severity: "Warning", + errors: [], + warnings: [warning], + }); + }); + }); + + return unpackedResults; +}; + +/** + * Build a file with data and download it + * + * @param content file content + * @param filename file name + * @param contentType the content type + * @returns void + */ +export const downloadBlob = (content: string, filename: string, contentType: string): void => { + const blob = new Blob([content], { type: contentType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.setAttribute('download', filename); + link.click(); + link.remove(); +}; From 507e80c7252c355b33f20f36481c9f591004fcbb Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 19 Mar 2024 10:59:26 -0400 Subject: [PATCH 04/34] Add `unpackQCResultSeverities` test coverage --- src/utils/dataSubmissionUtils.test.ts | 106 ++++++++++++++++++++++++++ src/utils/dataSubmissionUtils.ts | 8 +- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/src/utils/dataSubmissionUtils.test.ts b/src/utils/dataSubmissionUtils.test.ts index d15997ba0..e3125a960 100644 --- a/src/utils/dataSubmissionUtils.test.ts +++ b/src/utils/dataSubmissionUtils.test.ts @@ -165,3 +165,109 @@ describe('Admin Submit', () => { expect(result.isAdminOverride).toBe(true); }); }); + +describe('unpackQCResultSeverities cases', () => { + // Base QCResult, unused props are empty + const baseResult: Omit = { + submissionID: "", + batchID: "", + type: '', + validationType: '' as QCResult["validationType"], + // NOTE: This is intentionally incorrect and should break the tests if used + severity: "SHOULD NOT BE USED" as QCResult["severity"], + displayID: 0, + submittedID: '', + uploadedDate: '', + validatedDate: '' + }; + + // Base ErrorMessage + const baseError: ErrorMessage = { + title: "", + description: "unused description", + }; + + it('should unpack errors and warnings into separate results', () => { + const errors: ErrorMessage[] = [ + { ...baseError, title: "error1" }, + { ...baseError, title: "error2" }, + ]; + const warnings: ErrorMessage[] = [ + { ...baseError, title: "warning1" }, + { ...baseError, title: "warning2" }, + ]; + const results: QCResult[] = [{ ...baseResult, errors, warnings }]; + + const unpackedResults = utils.unpackQCResultSeverities(results); + + expect(unpackedResults.length).toEqual(4); + expect(unpackedResults).toEqual([ + { ...baseResult, severity: "Error", errors: [errors[0]], warnings: [] }, + { ...baseResult, severity: "Error", errors: [errors[1]], warnings: [] }, + { ...baseResult, severity: "Warning", errors: [], warnings: [warnings[0]] }, + { ...baseResult, severity: "Warning", errors: [], warnings: [warnings[1]] }, + ]); + }); + + it('should return an array with the same length as errors.length + warnings.length', () => { + const errors: ErrorMessage[] = new Array(999).fill({ ...baseError, title: "error1" }); + const warnings: ErrorMessage[] = new Array(999).fill({ ...baseError, title: "warning1" }); + const results: QCResult[] = [{ ...baseResult, errors, warnings }]; + + expect(utils.unpackQCResultSeverities(results).length).toEqual(1998); + }); + + it('should unpack an array of only warnings', () => { + const warnings: ErrorMessage[] = [ + { ...baseError, title: "warning1" }, + { ...baseError, title: "warning2" }, + ]; + const results: QCResult[] = [{ ...baseResult, errors: [], warnings }]; + + const unpackedResults = utils.unpackQCResultSeverities(results); + + expect(unpackedResults.length).toEqual(2); + expect(unpackedResults).toEqual([ + { ...baseResult, severity: "Warning", errors: [], warnings: [warnings[0]] }, + { ...baseResult, severity: "Warning", errors: [], warnings: [warnings[1]] }, + ]); + }); + + it('should unpack an array of only errors', () => { + const errors: ErrorMessage[] = [ + { ...baseError, title: "error1" }, + { ...baseError, title: "error2" }, + ]; + const results: QCResult[] = [{ ...baseResult, errors, warnings: [] }]; + + const unpackedResults = utils.unpackQCResultSeverities(results); + + expect(unpackedResults.length).toEqual(2); + expect(unpackedResults).toEqual([ + { ...baseResult, severity: "Error", errors: [errors[0]], warnings: [] }, + { ...baseResult, severity: "Error", errors: [errors[1]], warnings: [] }, + ]); + }); + + it('should handle a large array of QCResults', () => { + const errors: ErrorMessage[] = new Array(10).fill({ ...baseError, title: "error1" }); + const warnings: ErrorMessage[] = new Array(5).fill({ ...baseError, title: "warning1" }); + const results: QCResult[] = new Array(10000).fill({ ...baseResult, errors, warnings }); + + const unpackedResults = utils.unpackQCResultSeverities(results); + + // 15 errors and 5 warnings per result, 150000 total + expect(unpackedResults.length).toEqual(150000); + expect(unpackedResults.filter((result) => result.severity === "Error").length).toEqual(100000); + expect(unpackedResults.filter((result) => result.severity === "Warning").length).toEqual(50000); + }); + + it('should return an empty array when given an empty array', () => { + expect(utils.unpackQCResultSeverities([])).toEqual([]); + }); + + it('returns an empty array when there are no errors or warnings', () => { + const results = [{ ...baseResult, errors: [], warnings: [] }]; + expect(utils.unpackQCResultSeverities(results)).toEqual([]); + }); +}); diff --git a/src/utils/dataSubmissionUtils.ts b/src/utils/dataSubmissionUtils.ts index 457026181..c6965f6d0 100644 --- a/src/utils/dataSubmissionUtils.ts +++ b/src/utils/dataSubmissionUtils.ts @@ -53,8 +53,8 @@ export const unpackQCResultSeverities = (results: QCResult[]): QCResult[] => { const unpackedResults: QCResult[] = []; // Iterate through each result and push the errors and warnings into separate results - results.forEach((result) => { - result.errors.slice(0).forEach((error) => { + results.forEach(({ errors, warnings, ...result }) => { + errors.slice(0).forEach((error) => { unpackedResults.push({ ...result, severity: "Error", @@ -62,7 +62,7 @@ export const unpackQCResultSeverities = (results: QCResult[]): QCResult[] => { warnings: [], }); }); - result.warnings.slice(0).forEach((warning) => { + warnings.slice(0).forEach((warning) => { unpackedResults.push({ ...result, severity: "Warning", @@ -89,7 +89,7 @@ export const downloadBlob = (content: string, filename: string, contentType: str const link = document.createElement('a'); link.href = url; - link.setAttribute('download', filename); + link.download = filename; link.click(); link.remove(); }; From dba10be8c3b77616e9e09510f9a55eacf686dc49 Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 19 Mar 2024 12:23:10 -0400 Subject: [PATCH 05/34] Add downloadBlob test coverage --- src/utils/dataSubmissionUtils.test.ts | 54 ++++++++++++++++++++++++++- src/utils/dataSubmissionUtils.ts | 4 +- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/utils/dataSubmissionUtils.test.ts b/src/utils/dataSubmissionUtils.test.ts index e3125a960..8d7be74eb 100644 --- a/src/utils/dataSubmissionUtils.test.ts +++ b/src/utils/dataSubmissionUtils.test.ts @@ -266,8 +266,60 @@ describe('unpackQCResultSeverities cases', () => { expect(utils.unpackQCResultSeverities([])).toEqual([]); }); - it('returns an empty array when there are no errors or warnings', () => { + it('should return an empty array when there are no errors or warnings', () => { const results = [{ ...baseResult, errors: [], warnings: [] }]; expect(utils.unpackQCResultSeverities(results)).toEqual([]); }); }); + +describe('downloadBlob cases', () => { + const mockSetAttribute = jest.fn(); + const mockClick = jest.fn(); + const mockRemove = jest.fn(); + + beforeEach(() => { + URL.createObjectURL = jest.fn().mockReturnValue('blob-url'); + + // Spy on document.createElement calls and override the return value + jest.spyOn(document, 'createElement').mockReturnValue({ + ...document.createElement('a'), + setAttribute: mockSetAttribute, + click: mockClick, + remove: mockRemove, + }) as jest.MockedFunction; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('should create a ObjectURL with the file content blob', () => { + const content = 'test,csv,content\n1,2,3'; + const contentType = 'text/csv'; + + utils.downloadBlob(content, "blob.csv", contentType); + + expect(URL.createObjectURL).toHaveBeenCalledWith(new Blob([content], { type: contentType })); + }); + + it('should create a anchor with the href and download properties', () => { + const filename = 'test.txt'; + + utils.downloadBlob("test content", filename, "text/plain"); + + expect(document.createElement).toHaveBeenCalledWith('a'); + expect(mockSetAttribute).toHaveBeenCalledWith('href', 'blob-url'); + expect(mockSetAttribute).toHaveBeenCalledWith('download', filename); + }); + + it('should open the download link and remove itself from the DOM', () => { + utils.downloadBlob("test,content,csv", "test-file.csv", "text/csv"); + + expect(mockClick).toHaveBeenCalled(); + expect(mockRemove).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/dataSubmissionUtils.ts b/src/utils/dataSubmissionUtils.ts index c6965f6d0..d532c6509 100644 --- a/src/utils/dataSubmissionUtils.ts +++ b/src/utils/dataSubmissionUtils.ts @@ -88,8 +88,8 @@ export const downloadBlob = (content: string, filename: string, contentType: str const url = URL.createObjectURL(blob); const link = document.createElement('a'); - link.href = url; - link.download = filename; + link.setAttribute('download', filename); + link.setAttribute('href', url); link.click(); link.remove(); }; From cdb7b7045f0f63056f0c9f8e338189277600c697 Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 19 Mar 2024 14:47:02 -0400 Subject: [PATCH 06/34] Migrate Export Button to testable component --- package-lock.json | 1 + .../ExportValidationButton.test.tsx | 216 ++++++++++++++++++ .../ExportValidationButton.tsx | 99 ++++++++ .../dataSubmissions/QualityControl.tsx | 66 ++---- 4 files changed, 329 insertions(+), 53 deletions(-) create mode 100644 src/components/DataSubmissions/ExportValidationButton.test.tsx create mode 100644 src/components/DataSubmissions/ExportValidationButton.tsx diff --git a/package-lock.json b/package-lock.json index 5bd1c28bd..b56b37811 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17410,6 +17410,7 @@ }, "node_modules/typescript": { "version": "5.1.3", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx new file mode 100644 index 000000000..e239a9718 --- /dev/null +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -0,0 +1,216 @@ +import 'jest-axe/extend-expect'; +import '@testing-library/jest-dom'; + +import { FC } from 'react'; +import { render, fireEvent, act, waitFor } from '@testing-library/react'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { GraphQLError } from 'graphql'; +import { axe } from 'jest-axe'; +import { ExportValidationButton } from './ExportValidationButton'; +import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; + +type ParentProps = { + mocks?: MockedResponse[]; + children: React.ReactNode; +}; + +// NOTE: Need to migrate to setupTests.ts to avoid duplication +const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + ...jest.requireActual('notistack'), + useSnackbar: () => ({ + enqueueSnackbar: mockEnqueue + }) +})); + +const TestParent: FC = ({ mocks, children } : ParentProps) => ( + + {children} + +); + +describe('ExportValidationButton cases', () => { + const baseQCResult: Omit = { + batchID: "", + type: '', + validationType: 'metadata', + severity: "Error", + displayID: 0, + submittedID: '', + uploadedDate: '', + validatedDate: '', + errors: [], + warnings: [], + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should not have accessibility violations', async () => { + const { container } = render( + + + + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it('should render without crashing', () => { + const { getByText } = render( + + + + ); + + expect(getByText('Download QC Results')).toBeInTheDocument(); + }); + + it('should execute the SUBMISSION_QC_RESULTS query onClick', async () => { + const submissionID = "example-sub-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + }, + result: { + data: { + submissionQCResults: { + total: 1, + results: [{ + ...baseQCResult, + submissionID + }] + }, + }, + }, + }]; + + const { getByText } = render( + + + + ); + + act(() => { + fireEvent.click(getByText('Download QC Results')); + }); + }); + + it('should handle network errors when fetching the QC Results without crashing', async () => { + const submissionID = "random-010101-sub-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + }, + error: new Error('Simulated network error'), + }]; + + const { getByText } = render( + + + + ); + + act(() => { + fireEvent.click(getByText('Download QC Results')); + }); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith("Unable to retrieve submission quality control results.", { variant: "error" }); + }); + }); + + it('should handle GraphQL errors when fetching the QC Results without crashing', async () => { + const submissionID = "example-GraphQL-level-errors-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + }, + result: { + errors: [new GraphQLError('Simulated GraphQL error')], + }, + }]; + + const { getByText } = render( + + + + ); + + act(() => { + fireEvent.click(getByText('Download QC Results')); + }); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith("Unable to retrieve submission quality control results.", { variant: "error" }); + }); + }); + + it('should handle invalid datasets without crashing', async () => { + const submissionID = "example-dataset-level-errors-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + }, + result: { + data: { + submissionQCResults: { + total: 1, + results: [ + { notReal: "true", } as unknown as QCResult, + { badData: "agreed", } as unknown as QCResult, + { 1: null, } as unknown as QCResult, + ] + }, + }, + }, + }]; + + const { getByText } = render( + + + + ); + + act(() => { + fireEvent.click(getByText('Download QC Results')); + }); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith("Unable to export validation results.", { variant: "error" }); + }); + }); +}); diff --git a/src/components/DataSubmissions/ExportValidationButton.tsx b/src/components/DataSubmissions/ExportValidationButton.tsx new file mode 100644 index 000000000..edb9a774f --- /dev/null +++ b/src/components/DataSubmissions/ExportValidationButton.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { useLazyQuery } from '@apollo/client'; +import { LoadingButton } from '@mui/lab'; +import { ButtonProps } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { unparse } from 'papaparse'; +import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; +import { downloadBlob, unpackQCResultSeverities } from '../../utils'; + +export type Props = { + /** + * The `_id` (uuid) of the submission to build the QC Results for + * + * @example 9f9c5d6b-5ddb-4a02-8bd6-7bf0c15a169a + */ + submissionId: Submission["_id"]; + /** + * The K:V pair of the fields that should be exported where + * `key` is the column header and `value` is a function + * that generates the exportable value + * + * @example { "Batch ID": (d) => d.displayID } + */ + fields: Record string | number>; +} & ButtonProps; + +/** + * Provides the button and supporting functionality to export the validation results of a submission. + * + * @param submissionId The ID of the submission to export validation results for. + * @returns {React.FC} The export validation button. + */ +export const ExportValidationButton: React.FC = ({ submissionId, fields, ...buttonProps }: Props) => { + const { enqueueSnackbar } = useSnackbar(); + const [loading, setLoading] = useState(false); + + const [submissionQCResults] = useLazyQuery(SUBMISSION_QC_RESULTS, { + variables: { id: submissionId }, + context: { clientName: 'backend' }, + fetchPolicy: 'cache-and-network', + }); + + const handleClick = async () => { + setLoading(true); + + const { data: d, error } = await submissionQCResults({ + variables: { + id: submissionId, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + context: { clientName: 'backend' }, + fetchPolicy: 'no-cache' + }); + + if (error || !d?.submissionQCResults?.results) { + enqueueSnackbar("Unable to retrieve submission quality control results.", { variant: "error" }); + setLoading(false); + return; + } + + try { + const unpacked = unpackQCResultSeverities(d.submissionQCResults.results); + const csvArray = []; + + unpacked.forEach((row) => { + const csvRow = {}; + const fieldset = Object.entries(fields); + + fieldset.forEach(([field, value]) => { + csvRow[field] = value(row) || ""; + }); + + csvArray.push(csvRow); + }); + + // TODO: File name? + downloadBlob(unparse(csvArray), "validation-results.csv", "text/csv"); + } catch (err) { + enqueueSnackbar("Unable to export validation results.", { variant: "error" }); + } + + setLoading(false); + }; + + return ( + + Download QC Results + + ); +}; diff --git a/src/content/dataSubmissions/QualityControl.tsx b/src/content/dataSubmissions/QualityControl.tsx index 468e10be0..e2521ae52 100644 --- a/src/content/dataSubmissions/QualityControl.tsx +++ b/src/content/dataSubmissions/QualityControl.tsx @@ -2,16 +2,15 @@ import { FC, useEffect, useMemo, useRef, useState } from "react"; import { useLazyQuery, useQuery } from "@apollo/client"; import { useParams } from "react-router-dom"; import { isEqual } from "lodash"; -import { LoadingButton } from '@mui/lab'; import { Box, Button, FormControl, MenuItem, Select, styled } from "@mui/material"; import { Controller, useForm } from 'react-hook-form'; import { useSnackbar } from 'notistack'; -import { unparse } from 'papaparse'; import { LIST_BATCHES, LIST_NODE_TYPES, ListBatchesResp, ListNodeTypesResp, SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from "../../graphql"; import GenericTable, { Column, FetchListing, TableMethods } from "../../components/DataSubmissions/GenericTable"; -import { FormatDate, capitalizeFirstLetter, downloadBlob, unpackQCResultSeverities } from "../../utils"; +import { FormatDate, capitalizeFirstLetter } from "../../utils"; import ErrorDialog from "./ErrorDialog"; import QCResultsContext from "./Contexts/QCResultsContext"; +import { ExportValidationButton } from '../../components/DataSubmissions/ExportValidationButton'; type FilterForm = { nodeType: string | "All"; @@ -156,6 +155,16 @@ const columns: Column[] = [ }, ]; +// TODO: Use `columns` instead of duplicating the fields here. +const csvColumns = { + "Batch ID": (d: QCResult) => d.displayID, + "Node Type": (d: QCResult) => d.type, + "Submitted Identifier": (d: QCResult) => d.submittedID, + Severity: (d: QCResult) => d.severity, + "Validated Date": (d: QCResult) => d.validatedDate, + Issues: (d: QCResult) => (d.errors?.length > 0 ? d.errors[0].description : d.warnings[0]?.description), +}; + const QualityControl: FC = () => { const { submissionId } = useParams(); const { watch, control } = useForm(); @@ -167,7 +176,6 @@ const QualityControl: FC = () => { const [totalData, setTotalData] = useState(0); const [openErrorDialog, setOpenErrorDialog] = useState(false); const [selectedRow, setSelectedRow] = useState(null); - const [buildingResults, setBuildingResults] = useState(false); const tableRef = useRef(null); const errorDescriptions = selectedRow?.errors?.map((error) => `(Error) ${error.description}`) ?? []; @@ -248,46 +256,6 @@ const QualityControl: FC = () => { setSelectedRow(data); }; - const downloadQCResults = async () => { - setBuildingResults(true); - - const { data: d, error } = await submissionQCResults({ - variables: { - // TODO: sorting and filters? - submissionID: submissionId, - first: 10000, // TODO: change to -1 - offset: 0, - }, - context: { clientName: 'backend' }, - fetchPolicy: 'no-cache' - }); - - if (error || !d?.submissionQCResults?.results) { - enqueueSnackbar("Unable to retrieve submission quality control results.", { variant: "error" }); - setBuildingResults(false); - return; - } - - try { - const unpacked = unpackQCResultSeverities(d.submissionQCResults.results); - const csvArray = unpacked.map((result) => ({ - "Batch ID": result.displayID, - "Node Type": result.type, - "Submitted Identifier": result.submittedID, - Severity: result.severity, - "Validated Date": result.validatedDate, // TODO: formatted? - Issues: result.errors?.length > 0 ? result.errors[0].description : result.warnings[0]?.description, - })); - - // TODO: File name? - downloadBlob(unparse(csvArray), "validation-results.csv", "text/csv"); - } catch (err) { - enqueueSnackbar("Unable to export validation results.", { variant: "error" }); - } - - setBuildingResults(false); - }; - const providerValue = useMemo(() => ({ handleOpenErrorDialog }), [handleOpenErrorDialog]); @@ -298,15 +266,7 @@ const QualityControl: FC = () => { return ( <> - {/* NOTE: This is just temporary */} - - Download QC Results - + Node Type From 8301f8adec704e383db7e1cfd83bc196b7c8e4f3 Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 19 Mar 2024 15:16:31 -0400 Subject: [PATCH 07/34] Add missing code coverage --- .../ExportValidationButton.test.tsx | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index e239a9718..55fbcc897 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -85,10 +85,7 @@ describe('ExportValidationButton cases', () => { data: { submissionQCResults: { total: 1, - results: [{ - ...baseQCResult, - submissionID - }] + results: [{ ...baseQCResult, submissionID }] }, }, }, @@ -105,6 +102,67 @@ describe('ExportValidationButton cases', () => { }); }); + it('should call the field value callback function for each field', async () => { + const submissionID = "formatter-callback-sub-id"; + + const qcErrors = [ + { title: "Error 01", description: "Error 01 description" }, + { title: "Error 02", description: "Error 02 description" }, + ]; + const qcWarnings = [ + { title: "Warning 01", description: "Warning 01 description" }, + { title: "Warning 02", description: "Warning 02 description" }, + ]; + + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + }, + result: { + data: { + submissionQCResults: { + total: 3, + results: [ + { ...baseQCResult, errors: qcErrors, warnings: qcWarnings, submissionID, displayID: 1 }, + { ...baseQCResult, errors: qcErrors, warnings: qcWarnings, submissionID, displayID: 2 }, + { ...baseQCResult, errors: qcErrors, warnings: qcWarnings, submissionID, displayID: 3 }, + ] + }, + }, + }, + }]; + + const fields = { + DisplayID: jest.fn().mockImplementation((result: QCResult) => result.displayID), + ValidationType: jest.fn().mockImplementation((result: QCResult) => result.validationType), + // Testing the fallback of falsy values + NullValueField: jest.fn().mockImplementation(() => null), + }; + + const { getByText } = render( + + + + ); + + act(() => { + fireEvent.click(getByText('Download QC Results')); + }); + + await waitFor(() => { + // NOTE: The results are unpacked, 3 QCResults with 2 errors and 2 warnings each = 12 calls + expect(fields.DisplayID).toHaveBeenCalledTimes(12); + expect(fields.ValidationType).toHaveBeenCalledTimes(12); + }); + }); + it('should handle network errors when fetching the QC Results without crashing', async () => { const submissionID = "random-010101-sub-id"; From e413cd3072d306db06e23c210a1f355b07834d2a Mon Sep 17 00:00:00 2001 From: Alec M Date: Wed, 20 Mar 2024 09:40:11 -0400 Subject: [PATCH 08/34] feat: Create `setupTests.ts` and remove dup. imports --- src/components/Contexts/AuthContext.test.tsx | 1 - .../Contexts/DataCommonContext.test.tsx | 1 - src/components/Contexts/FormContext.test.tsx | 1 - .../DataSubmissionSummary.test.tsx | 2 -- .../ExportValidationButton.test.tsx | 13 +------------ src/components/Footer/index.test.tsx | 2 -- src/components/Header/index.test.tsx | 2 -- src/components/NodeChart/index.test.tsx | 2 -- src/components/PageBanner/index.test.tsx | 2 -- src/components/ProgressBar/ProgressBar.test.tsx | 3 --- .../Shared/ReviewCommentsDialog.test.tsx | 3 --- src/components/StatusBar/StatusBar.test.tsx | 3 --- src/components/SuspenseLoader/index.test.tsx | 2 -- .../SystemUseWarningOverlay/index.test.tsx | 2 -- src/content/index.test.tsx | 2 -- src/content/status/Page404.test.tsx | 2 -- src/setupTests.ts | 15 +++++++++++++++ src/utils/dataSubmissionUtils.test.ts | 5 +++-- 18 files changed, 19 insertions(+), 44 deletions(-) create mode 100644 src/setupTests.ts diff --git a/src/components/Contexts/AuthContext.test.tsx b/src/components/Contexts/AuthContext.test.tsx index 581715880..fac036f39 100644 --- a/src/components/Contexts/AuthContext.test.tsx +++ b/src/components/Contexts/AuthContext.test.tsx @@ -1,5 +1,4 @@ import React, { FC } from 'react'; -import '@testing-library/jest-dom'; import { GraphQLError } from 'graphql'; import { render, waitFor } from '@testing-library/react'; import { MockedProvider, MockedResponse } from '@apollo/client/testing'; diff --git a/src/components/Contexts/DataCommonContext.test.tsx b/src/components/Contexts/DataCommonContext.test.tsx index 6cad091d0..71a88baad 100644 --- a/src/components/Contexts/DataCommonContext.test.tsx +++ b/src/components/Contexts/DataCommonContext.test.tsx @@ -1,5 +1,4 @@ import React, { FC } from 'react'; -import '@testing-library/jest-dom'; import { render, waitFor } from '@testing-library/react'; import { useDataCommonContext, Status as DCStatus, DataCommonProvider } from './DataCommonContext'; import { DataCommons } from '../../config/DataCommons'; diff --git a/src/components/Contexts/FormContext.test.tsx b/src/components/Contexts/FormContext.test.tsx index 05a4806b3..7fbf403f9 100644 --- a/src/components/Contexts/FormContext.test.tsx +++ b/src/components/Contexts/FormContext.test.tsx @@ -1,5 +1,4 @@ import React, { FC } from 'react'; -import '@testing-library/jest-dom'; import { render, waitFor } from '@testing-library/react'; import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { GraphQLError } from 'graphql'; diff --git a/src/components/DataSubmissions/DataSubmissionSummary.test.tsx b/src/components/DataSubmissions/DataSubmissionSummary.test.tsx index 927246aba..acca0cd23 100644 --- a/src/components/DataSubmissions/DataSubmissionSummary.test.tsx +++ b/src/components/DataSubmissions/DataSubmissionSummary.test.tsx @@ -1,5 +1,3 @@ -import "@testing-library/jest-dom"; -import "jest-axe/extend-expect"; import { render, fireEvent, waitFor } from "@testing-library/react"; import { act } from 'react-dom/test-utils'; import { BrowserRouter } from "react-router-dom"; diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index 55fbcc897..ec4da0a88 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -1,6 +1,3 @@ -import 'jest-axe/extend-expect'; -import '@testing-library/jest-dom'; - import { FC } from 'react'; import { render, fireEvent, act, waitFor } from '@testing-library/react'; import { MockedProvider, MockedResponse } from '@apollo/client/testing'; @@ -8,21 +5,13 @@ import { GraphQLError } from 'graphql'; import { axe } from 'jest-axe'; import { ExportValidationButton } from './ExportValidationButton'; import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; +import { mockEnqueue } from '../../setupTests'; type ParentProps = { mocks?: MockedResponse[]; children: React.ReactNode; }; -// NOTE: Need to migrate to setupTests.ts to avoid duplication -const mockEnqueue = jest.fn(); -jest.mock('notistack', () => ({ - ...jest.requireActual('notistack'), - useSnackbar: () => ({ - enqueueSnackbar: mockEnqueue - }) -})); - const TestParent: FC = ({ mocks, children } : ParentProps) => ( {children} diff --git a/src/components/Footer/index.test.tsx b/src/components/Footer/index.test.tsx index 4c0515f30..dcdb66362 100644 --- a/src/components/Footer/index.test.tsx +++ b/src/components/Footer/index.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { axe } from 'jest-axe'; import { render } from '@testing-library/react'; import Footer from './index'; diff --git a/src/components/Header/index.test.tsx b/src/components/Header/index.test.tsx index adfbfecf2..d5d28d131 100644 --- a/src/components/Header/index.test.tsx +++ b/src/components/Header/index.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { FC, useMemo } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { axe } from 'jest-axe'; diff --git a/src/components/NodeChart/index.test.tsx b/src/components/NodeChart/index.test.tsx index 05acaa1ae..c39b008a7 100644 --- a/src/components/NodeChart/index.test.tsx +++ b/src/components/NodeChart/index.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { axe } from 'jest-axe'; import { render } from '@testing-library/react'; import NodeChart from './index'; diff --git a/src/components/PageBanner/index.test.tsx b/src/components/PageBanner/index.test.tsx index a0135cf3e..2fad89817 100644 --- a/src/components/PageBanner/index.test.tsx +++ b/src/components/PageBanner/index.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { axe } from 'jest-axe'; import { render } from '@testing-library/react'; import { HelmetProvider } from 'react-helmet-async'; diff --git a/src/components/ProgressBar/ProgressBar.test.tsx b/src/components/ProgressBar/ProgressBar.test.tsx index 5abb53140..52d758026 100644 --- a/src/components/ProgressBar/ProgressBar.test.tsx +++ b/src/components/ProgressBar/ProgressBar.test.tsx @@ -1,6 +1,3 @@ -import '@testing-library/jest-dom'; -import 'jest-axe/extend-expect'; - import { FC, useMemo } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { render } from '@testing-library/react'; diff --git a/src/components/Shared/ReviewCommentsDialog.test.tsx b/src/components/Shared/ReviewCommentsDialog.test.tsx index 84e76dd50..4b5157b3b 100644 --- a/src/components/Shared/ReviewCommentsDialog.test.tsx +++ b/src/components/Shared/ReviewCommentsDialog.test.tsx @@ -1,6 +1,3 @@ -import "@testing-library/jest-dom"; -import "jest-axe/extend-expect"; - import { ThemeProvider } from "@mui/material"; import { CSSProperties } from "react"; import { BrowserRouter } from "react-router-dom"; diff --git a/src/components/StatusBar/StatusBar.test.tsx b/src/components/StatusBar/StatusBar.test.tsx index bfd0366d8..09dce0c6e 100644 --- a/src/components/StatusBar/StatusBar.test.tsx +++ b/src/components/StatusBar/StatusBar.test.tsx @@ -1,6 +1,3 @@ -import '@testing-library/jest-dom'; -import 'jest-axe/extend-expect'; - import { FC, useMemo } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { fireEvent, render, waitFor } from '@testing-library/react'; diff --git a/src/components/SuspenseLoader/index.test.tsx b/src/components/SuspenseLoader/index.test.tsx index e23261081..fe02de01c 100644 --- a/src/components/SuspenseLoader/index.test.tsx +++ b/src/components/SuspenseLoader/index.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { axe } from 'jest-axe'; import { render } from '@testing-library/react'; import Loader from './index'; diff --git a/src/components/SystemUseWarningOverlay/index.test.tsx b/src/components/SystemUseWarningOverlay/index.test.tsx index 34de8a38f..f2add2582 100644 --- a/src/components/SystemUseWarningOverlay/index.test.tsx +++ b/src/components/SystemUseWarningOverlay/index.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { axe } from 'jest-axe'; import { render, waitFor } from '@testing-library/react'; import OverlayWindow from './OverlayWindow'; diff --git a/src/content/index.test.tsx b/src/content/index.test.tsx index ae42d9ca8..00472c9bb 100644 --- a/src/content/index.test.tsx +++ b/src/content/index.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { FC, useMemo } from 'react'; import { BrowserRouter } from 'react-router-dom'; import { axe } from 'jest-axe'; diff --git a/src/content/status/Page404.test.tsx b/src/content/status/Page404.test.tsx index 204ada9c5..a0ac76b3a 100644 --- a/src/content/status/Page404.test.tsx +++ b/src/content/status/Page404.test.tsx @@ -1,5 +1,3 @@ -import 'jest-axe/extend-expect'; - import { axe } from 'jest-axe'; import { render } from '@testing-library/react'; import Page from './Page404'; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 000000000..7cb65d64e --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom'; +import 'jest-axe/extend-expect'; + +/** + * Mocks the enqueueSnackbar function from notistack for testing + * + * @note You must clear all mocks after each test to avoid unexpected behavior + * @example expect(mockEnqueue).toHaveBeenCalledWith('message', { variant: 'error' }); + * @see notistack documentation: https://notistack.com/getting-started + */ +export const mockEnqueue = jest.fn(); +jest.mock('notistack', () => ({ + ...jest.requireActual('notistack'), + useSnackbar: () => ({ enqueueSnackbar: mockEnqueue }) +})); diff --git a/src/utils/dataSubmissionUtils.test.ts b/src/utils/dataSubmissionUtils.test.ts index 8d7be74eb..aa882a80b 100644 --- a/src/utils/dataSubmissionUtils.test.ts +++ b/src/utils/dataSubmissionUtils.test.ts @@ -173,7 +173,8 @@ describe('unpackQCResultSeverities cases', () => { batchID: "", type: '', validationType: '' as QCResult["validationType"], - // NOTE: This is intentionally incorrect and should break the tests if used + // NOTE: This is intentionally invalid and should break the tests if used + // by the unpackQCResultSeverities function severity: "SHOULD NOT BE USED" as QCResult["severity"], displayID: 0, submittedID: '', @@ -256,7 +257,7 @@ describe('unpackQCResultSeverities cases', () => { const unpackedResults = utils.unpackQCResultSeverities(results); - // 15 errors and 5 warnings per result, 150000 total + // 10 errors and 5 warnings per result with 10K results, 150K total expect(unpackedResults.length).toEqual(150000); expect(unpackedResults.filter((result) => result.severity === "Error").length).toEqual(100000); expect(unpackedResults.filter((result) => result.severity === "Warning").length).toEqual(50000); From ae0429b7a92334155de14a4dcdaf572b4683cb2c Mon Sep 17 00:00:00 2001 From: Alec M Date: Wed, 20 Mar 2024 15:57:58 -0400 Subject: [PATCH 09/34] CRDCDH-881 Setup Data Content tab and base tests --- .../DataContentFilters.test.tsx | 115 +++++++++++ .../DataSubmissions/DataContentFilters.tsx | 113 +++++++++++ .../DataSubmissions/GenericTable.tsx | 6 +- .../dataSubmissions/DataContent.test.tsx | 190 ++++++++++++++++++ src/content/dataSubmissions/DataContent.tsx | 105 ++++++++++ .../dataSubmissions/DataSubmission.tsx | 21 +- src/graphql/getSubmissionNodes.ts | 41 ++++ src/graphql/index.ts | 3 + src/types/Submissions.d.ts | 22 ++ 9 files changed, 609 insertions(+), 7 deletions(-) create mode 100644 src/components/DataSubmissions/DataContentFilters.test.tsx create mode 100644 src/components/DataSubmissions/DataContentFilters.tsx create mode 100644 src/content/dataSubmissions/DataContent.test.tsx create mode 100644 src/content/dataSubmissions/DataContent.tsx create mode 100644 src/graphql/getSubmissionNodes.ts diff --git a/src/components/DataSubmissions/DataContentFilters.test.tsx b/src/components/DataSubmissions/DataContentFilters.test.tsx new file mode 100644 index 000000000..f74d8ab0e --- /dev/null +++ b/src/components/DataSubmissions/DataContentFilters.test.tsx @@ -0,0 +1,115 @@ +import { render, waitFor, within } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; +import { DataContentFilters } from './DataContentFilters'; + +describe("DataContentFilters cases", () => { + const baseStatistic: SubmissionStatistic = { + nodeName: "", + total: 0, + new: 0, + passed: 0, + warning: 0, + error: 0 + }; + + it("should not have accessibility violations", async () => { + const { container } = render( + + ); + + const results = await axe(container); + + expect(results).toHaveNoViolations(); + }); + + it("should handle an empty array of node types without errors", async () => { + expect(() => render()).not.toThrow(); + }); + + // NOTE: The sorting function `compareNodeStats` is already heavily tested, this is just a sanity check + it("should sort the node types by count in descending order", async () => { + const stats: SubmissionStatistic[] = [ + { ...baseStatistic, nodeName: "N-3", total: 1 }, + { ...baseStatistic, nodeName: "N-1", total: 3 }, + { ...baseStatistic, nodeName: "N-2", total: 2 }, + ]; + + const { getByTestId } = render(); + + const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); + + await waitFor(() => { + UserEvent.click(muiSelectBox); + + const muiSelectList = within(getByTestId("data-content-node-filter")).getByRole("listbox", { hidden: true }); + + // The order of the nodes should be N-1 < N-2 < N-3 + expect(muiSelectList).toBeInTheDocument(); + expect(muiSelectList.innerHTML.search("N-1")).toBeLessThan(muiSelectList.innerHTML.search("N-2")); + expect(muiSelectList.innerHTML.search("N-2")).toBeLessThan(muiSelectList.innerHTML.search("N-3")); + }); + }); + + it("should select the first sorted node type in the by default", async () => { + const stats: SubmissionStatistic[] = [ + { ...baseStatistic, nodeName: "SECOND", total: 3 }, + { ...baseStatistic, nodeName: "FIRST", total: 999 }, + { ...baseStatistic, nodeName: "THIRD", total: 1 }, + ]; + + const { getByTestId } = render(); + const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); + + expect(muiSelectBox).toHaveTextContent("FIRST"); + }); + + it("should update the empty selection when the node types are populated", async () => { + const stats: SubmissionStatistic[] = [ + { ...baseStatistic, nodeName: "FIRST-NODE", total: 999 }, + { ...baseStatistic, nodeName: "SECOND", total: 3 }, + { ...baseStatistic, nodeName: "THIRD", total: 1 }, + ]; + + const { getByTestId, rerender } = render(); + const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); + + rerender(); + + expect(muiSelectBox).toHaveTextContent("FIRST-NODE"); + }); + + it("should not change a NON-DEFAULT selection when the node types are updated", async () => { + const stats: SubmissionStatistic[] = [ + { ...baseStatistic, nodeName: "FIRST", total: 100 }, + { ...baseStatistic, nodeName: "SECOND", total: 2 }, + { ...baseStatistic, nodeName: "THIRD", total: 1 }, + ]; + + const { getByTestId, rerender } = render(); + const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); + + await waitFor(() => { + expect(muiSelectBox).toHaveTextContent("FIRST"); + }); + + // Open the dropdown + await waitFor(() => UserEvent.click(muiSelectBox)); + + // Select the 3rd option + const firstOption = getByTestId("nodeType-THIRD"); + await waitFor(() => UserEvent.click(firstOption)); + + const newStats: SubmissionStatistic[] = [ + ...stats, + { ...baseStatistic, nodeName: "NEW-FIRST", total: 999 }, + ]; + + rerender(); + + await waitFor(() => { + // Verify the 3rd option is still selected + expect(muiSelectBox).toHaveTextContent("THIRD"); + }); + }); +}); diff --git a/src/components/DataSubmissions/DataContentFilters.tsx b/src/components/DataSubmissions/DataContentFilters.tsx new file mode 100644 index 000000000..80ec3b0ca --- /dev/null +++ b/src/components/DataSubmissions/DataContentFilters.tsx @@ -0,0 +1,113 @@ +import { FC, useEffect, useMemo } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { cloneDeep } from 'lodash'; +import { Box, FormControl, MenuItem, Select, styled } from '@mui/material'; +import { compareNodeStats } from '../../utils'; + +export type DataContentFiltersProps = { + statistics: SubmissionStatistic[]; + onChange?: (data: FilterForm) => void; +}; + +export type FilterForm = { + nodeType: string; +}; + +const StyledContainer = styled(Box)({ + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + marginBottom: "19px", + paddingLeft: "24px", +}); + +const StyledFormControl = styled(FormControl)({ + margin: "10px", + marginRight: "15px", + minWidth: "250px", +}); + +const StyledInlineLabel = styled('label')({ + padding: "0 10px", + fontWeight: "700" +}); + +const baseTextFieldStyles = { + borderRadius: "8px", + "& .MuiInputBase-input": { + fontWeight: 400, + fontSize: "16px", + fontFamily: "'Nunito', 'Rubik', sans-serif", + padding: "10px", + height: "20px", + }, + "& .MuiOutlinedInput-notchedOutline": { + borderColor: "#6B7294", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + border: "1px solid #209D7D", + boxShadow: "2px 2px 4px 0px rgba(38, 184, 147, 0.10), -1px -1px 6px 0px rgba(38, 184, 147, 0.20)", + }, + "& .Mui-disabled": { + cursor: "not-allowed", + }, + "& .MuiList-root": { + padding: "0 !important", + }, + "& .MuiMenuItem-root.Mui-selected": { + background: "#3E7E6D !important", + color: "#FFFFFF !important", + }, + "& .MuiMenuItem-root:hover": { + background: "#D5EDE5", + }, +}; + +const StyledSelect = styled(Select)(baseTextFieldStyles); + +export const DataContentFilters: FC = ({ statistics, onChange }: DataContentFiltersProps) => { + const { watch, setValue, getValues, control } = useForm(); + + const nodeTypes = useMemo(() => cloneDeep(statistics) + ?.sort(compareNodeStats) + ?.reverse() + ?.map((stat) => stat.nodeName), [statistics]); + + useEffect(() => { + if (!!watch("nodeType") || !nodeTypes?.length) { + return; + } + + setValue("nodeType", nodeTypes?.[0] || ""); + }, [nodeTypes]); + + useEffect(() => { + onChange?.(getValues()); + }, [watch("nodeType")]); + + return ( + + Node Type + + ( + + {nodeTypes?.map((nodeType) => ( + {nodeType} + ))} + + )} + /> + + + ); +}; diff --git a/src/components/DataSubmissions/GenericTable.tsx b/src/components/DataSubmissions/GenericTable.tsx index 3508f8eda..1886e21f6 100644 --- a/src/components/DataSubmissions/GenericTable.tsx +++ b/src/components/DataSubmissions/GenericTable.tsx @@ -15,7 +15,6 @@ import { styled, } from "@mui/material"; import { CSSProperties, ElementType, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; -import { useAuthContext } from "../Contexts/AuthContext"; import PaginationActions from "./PaginationActions"; import SuspenseLoader from '../SuspenseLoader'; @@ -126,7 +125,7 @@ export type Order = "asc" | "desc"; export type Column = { label: string | React.ReactNode; - renderValue: (a: T, user: User) => string | boolean | number | React.ReactNode; + renderValue: (a: T) => string | boolean | number | React.ReactNode; field?: keyof T; default?: true; sortDisabled?: boolean; @@ -180,7 +179,6 @@ const GenericTable = ({ onOrderByChange, onPerPageChange, }: Props, ref: React.Ref) => { - const { user } = useAuthContext(); const [order, setOrder] = useState(defaultOrder); const [orderBy, setOrderBy] = useState>( columns.find((c) => c.default) || columns.find((c) => c.field) @@ -278,7 +276,7 @@ const GenericTable = ({ {columns.map((col: Column) => ( - {col.renderValue(d, user)} + {col.renderValue(d)} ))} diff --git a/src/content/dataSubmissions/DataContent.test.tsx b/src/content/dataSubmissions/DataContent.test.tsx new file mode 100644 index 000000000..42ed6ab66 --- /dev/null +++ b/src/content/dataSubmissions/DataContent.test.tsx @@ -0,0 +1,190 @@ +import { FC } from 'react'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { GraphQLError } from 'graphql'; +import { axe } from 'jest-axe'; +import { render, waitFor } from '@testing-library/react'; +import DataContent from './DataContent'; +import { mockEnqueue } from '../../setupTests'; +import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; + +type ParentProps = { + mocks?: MockedResponse[]; + children: React.ReactNode; +}; + +const TestParent: FC = ({ mocks, children } : ParentProps) => ( + + {children} + +); + +describe("DataContent > General", () => { + const baseSubmissionStatistic: SubmissionStatistic = { + nodeName: '', + total: 0, + new: 0, + passed: 0, + warning: 0, + error: 0 + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should not have any high level accessibility violations", async () => { + const { container } = render( + + + + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should show an error message when no submission ID is provided", async () => { + render( + + + + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith("Cannot fetch results. Submission ID is invalid or missing.", { variant: "error" }); + }); + }); + + it("should show an error message when the nodes cannot be fetched (network)", async () => { + const submissionID = "example-sub-id-1"; + + // TODO: Update to the real query + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "desc", + orderBy: "displayID", + first: 20, + offset: 0, + nodeTypes: ["example-node"], + }, + }, + error: new Error('Simulated network error'), + }]; + + const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + + render( + + + + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith("Unable to retrieve node data.", { variant: "error" }); + }); + }); + + it("should show an error message when the nodes cannot be fetched (GraphQL)", async () => { + const submissionID = "example-sub-id-2"; + + // TODO: Update to the real query + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "desc", + orderBy: "displayID", + first: 20, + offset: 0, + nodeTypes: ["example-node"], + }, + }, + result: { + errors: [new GraphQLError('Simulated GraphQL error')], + }, + }]; + + const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + + render( + + + + ); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith("Unable to retrieve node data.", { variant: "error" }); + }); + }); +}); + +describe("DataContent > Table", () => { + const baseSubmissionStatistic: SubmissionStatistic = { + nodeName: '', + total: 0, + new: 0, + passed: 0, + warning: 0, + error: 0 + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should render the placeholder text when no data is available", async () => { + const submissionID = "example-placeholder-test-id"; + + // TODO: Update to the real query + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "desc", + orderBy: "displayID", + first: 20, + offset: 0, + nodeTypes: ["example-node"], + }, + }, + result: { + data: { + submissionQCResults: { + total: 0, + results: [], + }, + }, + }, + }]; + + const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + + const { getByText } = render( + + + + ); + + await waitFor(() => { + expect(getByText("No existing data was found")).toBeInTheDocument(); + }); + }); + + it("should render dynamic columns based on the node type selected", async () => { + fail("Not implemented"); + }); + + it("should fetch the QC results when the component mounts", async () => { + fail("Not implemented"); + }); + + it("should have a default pagination count of 25 rows per page", async () => { + fail("Not implemented"); + }); + + // ... more tests ...? +}); diff --git a/src/content/dataSubmissions/DataContent.tsx b/src/content/dataSubmissions/DataContent.tsx new file mode 100644 index 000000000..ad42678c0 --- /dev/null +++ b/src/content/dataSubmissions/DataContent.tsx @@ -0,0 +1,105 @@ +import { FC, useRef, useState } from "react"; +import { useLazyQuery } from "@apollo/client"; +import { isEqual } from "lodash"; +import { useSnackbar } from 'notistack'; +import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from "../../graphql"; +import GenericTable, { Column, FetchListing, TableMethods } from "../../components/DataSubmissions/GenericTable"; +import { DataContentFilters, FilterForm } from '../../components/DataSubmissions/DataContentFilters'; + +type TODO = QCResult; // TODO: Type this when the real type is known + +type Props = { + submissionId: string; + statistics: SubmissionStatistic[]; +}; + +const columns: Column[] = [ + { + label: "TBD", + renderValue: () => "TBD", + field: "displayID", + default: true + }, +]; + +const DataContent: FC = ({ submissionId, statistics }) => { + const { enqueueSnackbar } = useSnackbar(); + + const tableRef = useRef(null); + const filterRef = useRef({ nodeType: "" }); + + const [loading, setLoading] = useState(true); + const [data, setData] = useState([]); + const [prevListing, setPrevListing] = useState>(null); + const [totalData, setTotalData] = useState(0); + const [submissionQCResults] = useLazyQuery(SUBMISSION_QC_RESULTS, { + variables: { id: submissionId }, + context: { clientName: 'backend' }, + fetchPolicy: 'cache-and-network', + }); + + const handleFetchData = async (fetchListing: FetchListing, force: boolean) => { + const { first, offset, sortDirection, orderBy } = fetchListing || {}; + if (!submissionId) { + enqueueSnackbar("Cannot fetch results. Submission ID is invalid or missing.", { variant: "error" }); + return; + } + if (!force && data?.length > 0 && isEqual(fetchListing, prevListing)) { + return; + } + if (!filterRef.current.nodeType) { + setData([]); + setTotalData(0); + return; + } + + setPrevListing(fetchListing); + setLoading(true); + + const { data: d, error } = await submissionQCResults({ + variables: { + first, + offset, + sortDirection, + orderBy, + nodeTypes: [filterRef.current.nodeType], + }, + context: { clientName: 'backend' }, + fetchPolicy: 'no-cache' + }); + + if (error || !d?.submissionQCResults) { + enqueueSnackbar("Unable to retrieve node data.", { variant: "error" }); + setLoading(false); + return; + } + + setData(d.submissionQCResults.results); + setTotalData(d.submissionQCResults.total); + setLoading(false); + }; + + const handleFilterChange = (filters: FilterForm) => { + filterRef.current = filters; + tableRef.current?.setPage(0, true); + }; + + return ( + <> + + `${idx}_${item.batchID}_${item.submittedID}`} + onFetchData={handleFetchData} + /> + + ); +}; + +export default DataContent; diff --git a/src/content/dataSubmissions/DataSubmission.tsx b/src/content/dataSubmissions/DataSubmission.tsx index 050f794cc..5c29d6952 100644 --- a/src/content/dataSubmissions/DataSubmission.tsx +++ b/src/content/dataSubmissions/DataSubmission.tsx @@ -44,6 +44,7 @@ import FileListDialog from "./FileListDialog"; import { shouldDisableSubmit } from "../../utils/dataSubmissionUtils"; import usePageTitle from '../../hooks/usePageTitle'; import BackButton from "../../components/DataSubmissions/BackButton"; +import DataContent from './DataContent'; const StyledBanner = styled("div")(({ bannerSrc }: { bannerSrc: string }) => ({ background: `url(${bannerSrc})`, @@ -323,7 +324,8 @@ const columns: Column[] = [ const URLTabs = { DATA_ACTIVITY: "data-activity", - VALIDATION_RESULTS: "validation-results" + VALIDATION_RESULTS: "validation-results", + DATA_CONTENT: "data-content", }; const submissionLockedStatuses: SubmissionStatus[] = ["Submitted", "Released", "Completed", "Canceled", "Archived"]; @@ -571,10 +573,17 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } to={`/data-submission/${submissionId}/${URLTabs.VALIDATION_RESULTS}`} selected={tab === URLTabs.VALIDATION_RESULTS} /> + - {tab === URLTabs.DATA_ACTIVITY ? ( + {/* Primary Tab Content */} + {tab === URLTabs.DATA_ACTIVITY && ( = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } containerProps={{ sx: { marginBottom: "8px" } }} /> - ) : } + )} + {tab === URLTabs.VALIDATION_RESULTS && ( + + )} + {tab === URLTabs.DATA_CONTENT && ( + + )} {/* Return to Data Submission List Button */} diff --git a/src/graphql/getSubmissionNodes.ts b/src/graphql/getSubmissionNodes.ts new file mode 100644 index 000000000..66e813f33 --- /dev/null +++ b/src/graphql/getSubmissionNodes.ts @@ -0,0 +1,41 @@ +import gql from "graphql-tag"; + +export const query = gql` + query getSubmissionNodes($_id: String!, $nodeType: String!, $first: Int, $offset: Int, $orderBy: String, $sortDirection: String) { + getSubmissionNodes(_id: $_id, nodeType: $nodeType, first: $first, offset: $offset, orderBy: $orderBy, sortDirection: $sortDirection) { + total + properties + nodes { + _id + nodeType + name + description + status + createdAt + updatedAt + createdBy + updatedBy + parentID + parentType + properties + } + } + } +`; + +export type Response = { + getSubmissionNodes: { + /** + * Total number of nodes in the submission. + */ + total: number; + /** + * The list of all node properties including parents + */ + properties: string[]; + /** + * An array of nodes matching the queried node type + */ + nodes: SubmissionNode[]; + }; +}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts index 87da8f3fd..8925be2d6 100644 --- a/src/graphql/index.ts +++ b/src/graphql/index.ts @@ -60,6 +60,9 @@ export type { Response as ValidateSubmissionResp } from "./validateSubmission"; export { query as LIST_NODE_TYPES } from "./listSubmissionNodeTypes"; export type { Response as ListNodeTypesResp } from "./listSubmissionNodeTypes"; +export { query as GET_SUBMISSION_NODES } from "./getSubmissionNodes"; +export type { Response as GetSubmissionNodesResp } from "./getSubmissionNodes"; + // User Profile export { query as GET_USER } from "./getUser"; export type { Response as GetUserResp } from "./getUser"; diff --git a/src/types/Submissions.d.ts b/src/types/Submissions.d.ts index 78f538ad5..5c5ecb68c 100644 --- a/src/types/Submissions.d.ts +++ b/src/types/Submissions.d.ts @@ -246,3 +246,25 @@ type ValidationType = "Metadata" | "Files" | "All"; * The target of Data Validation action. */ type ValidationTarget = "New" | "All"; + +/** + * Represents a node returned from the getSubmissionNodes API + * + * @note Not the same thing as `SubmissionStatistic` + */ +type SubmissionNode = { + submissionID: string; + nodeType: string; + nodeID: string; + status: string; + createdAt: string; + updatedAt: string; + validatedAt: string; + lineNumber: number; + /** + * The node properties as a JSON string. + * + * @see JSON.parse + */ + props: string; +}; From 60646c426959faf59729b3c479e5d438261d7009 Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 21 Mar 2024 13:58:04 -0400 Subject: [PATCH 10/34] CRDCDH-882 Change props to Submission --- .../ExportValidationButton.test.tsx | 85 +++++++++++++++++-- .../ExportValidationButton.tsx | 18 ++-- 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index ec4da0a88..c8d21ef8e 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -19,6 +19,29 @@ const TestParent: FC = ({ mocks, children } : ParentProps) => ( ); describe('ExportValidationButton cases', () => { + const baseSubmission: Submission = { + _id: '', + name: '', + submitterID: '', + submitterName: '', + organization: null, + dataCommons: '', + modelVersion: '', + studyAbbreviation: '', + dbGaPID: '', + bucketName: '', + rootPath: '', + status: 'New', + metadataValidationStatus: 'Error', + fileValidationStatus: 'Error', + fileErrors: [], + history: [], + conciergeName: '', + conciergeEmail: '', + createdAt: '', + updatedAt: '' + }; + const baseQCResult: Omit = { batchID: "", type: '', @@ -39,7 +62,7 @@ describe('ExportValidationButton cases', () => { it('should not have accessibility violations', async () => { const { container } = render( - + ); @@ -49,7 +72,7 @@ describe('ExportValidationButton cases', () => { it('should render without crashing', () => { const { getByText } = render( - + ); @@ -82,7 +105,7 @@ describe('ExportValidationButton cases', () => { const { getByText } = render( - + ); @@ -91,6 +114,54 @@ describe('ExportValidationButton cases', () => { }); }); + it.each([ + { ...baseSubmission, _id: "example-sub-id" }, + { ...baseSubmission, _id: "example-sub-id-2" }, + { ...baseSubmission, _id: "example-sub-id-3" }, + ])("should name the export file as [TODO]", async (submission) => { + // TODO: Implement per requirements + expect(false).toBeTruthy(); + }); + + it('should alert the user if there are no QC Results to export', async () => { + const submissionID = "example-no-results-to-export-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submissionID, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + }, + result: { + data: { + submissionQCResults: { + total: 0, + results: [] + }, + }, + }, + }]; + + const { getByText } = render( + + + + ); + + act(() => { + fireEvent.click(getByText('Download QC Results')); + }); + + await waitFor(() => { + expect(mockEnqueue).toHaveBeenCalledWith("There are no validation results to export.", { variant: "error" }); + }); + }); + it('should call the field value callback function for each field', async () => { const submissionID = "formatter-callback-sub-id"; @@ -137,7 +208,7 @@ describe('ExportValidationButton cases', () => { const { getByText } = render( - + ); @@ -171,7 +242,7 @@ describe('ExportValidationButton cases', () => { const { getByText } = render( - + ); @@ -205,7 +276,7 @@ describe('ExportValidationButton cases', () => { const { getByText } = render( - + ); @@ -248,7 +319,7 @@ describe('ExportValidationButton cases', () => { const { getByText } = render( - + ); diff --git a/src/components/DataSubmissions/ExportValidationButton.tsx b/src/components/DataSubmissions/ExportValidationButton.tsx index edb9a774f..62e0d4eea 100644 --- a/src/components/DataSubmissions/ExportValidationButton.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.tsx @@ -9,11 +9,9 @@ import { downloadBlob, unpackQCResultSeverities } from '../../utils'; export type Props = { /** - * The `_id` (uuid) of the submission to build the QC Results for - * - * @example 9f9c5d6b-5ddb-4a02-8bd6-7bf0c15a169a + * The full Data Submission object to export validation results for */ - submissionId: Submission["_id"]; + submission: Submission; /** * The K:V pair of the fields that should be exported where * `key` is the column header and `value` is a function @@ -27,15 +25,13 @@ export type Props = { /** * Provides the button and supporting functionality to export the validation results of a submission. * - * @param submissionId The ID of the submission to export validation results for. * @returns {React.FC} The export validation button. */ -export const ExportValidationButton: React.FC = ({ submissionId, fields, ...buttonProps }: Props) => { +export const ExportValidationButton: React.FC = ({ submission, fields, ...buttonProps }: Props) => { const { enqueueSnackbar } = useSnackbar(); const [loading, setLoading] = useState(false); const [submissionQCResults] = useLazyQuery(SUBMISSION_QC_RESULTS, { - variables: { id: submissionId }, context: { clientName: 'backend' }, fetchPolicy: 'cache-and-network', }); @@ -45,7 +41,7 @@ export const ExportValidationButton: React.FC = ({ submissionId, fields, const { data: d, error } = await submissionQCResults({ variables: { - id: submissionId, + id: submission?._id, sortDirection: "asc", orderBy: "displayID", first: 10000, // TODO: change to -1 @@ -61,6 +57,12 @@ export const ExportValidationButton: React.FC = ({ submissionId, fields, return; } + if (!d?.submissionQCResults?.results.length) { + enqueueSnackbar("There are no validation results to export.", { variant: "error" }); + setLoading(false); + return; + } + try { const unpacked = unpackQCResultSeverities(d.submissionQCResults.results); const csvArray = []; From 889c5aa6874f892e005796a41cff9f817fda5d8f Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 21 Mar 2024 14:18:29 -0400 Subject: [PATCH 11/34] Migrate QC Export button to DataSubmissionAction --- .../dataSubmissions/DataSubmission.tsx | 7 ++++++- .../dataSubmissions/DataSubmissionActions.tsx | 21 ++++++++++++++++++- .../dataSubmissions/QualityControl.tsx | 8 +++---- src/graphql/getSubmission.ts | 16 ++++++++++++++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/content/dataSubmissions/DataSubmission.tsx b/src/content/dataSubmissions/DataSubmission.tsx index b59b55d8c..f5ae474fa 100644 --- a/src/content/dataSubmissions/DataSubmission.tsx +++ b/src/content/dataSubmissions/DataSubmission.tsx @@ -33,7 +33,7 @@ import DataSubmissionSummary from "../../components/DataSubmissions/DataSubmissi import GenericTable, { Column, FetchListing, TableMethods } from "../../components/DataSubmissions/GenericTable"; import { FormatDate } from "../../utils"; import DataSubmissionActions from "./DataSubmissionActions"; -import QualityControl from "./QualityControl"; +import QualityControl, { csvColumns } from "./QualityControl"; import { ReactComponent as CopyIconSvg } from "../../assets/icons/copy_icon_2.svg"; import ErrorDialog from "./ErrorDialog"; import BatchTableContext from "./Contexts/BatchTableContext"; @@ -597,6 +597,11 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } disable: submitInfo?.disable, label: submitInfo?.isAdminOverride ? "Admin Submit" : "Submit", }} + exportActionButton={{ + fields: csvColumns, + disabled: !data?.totalQCResults?.total, + visible: tab === URLTabs.VALIDATION_RESULTS, + }} onError={(message: string) => enqueueSnackbar(message, { variant: "error" })} /> diff --git a/src/content/dataSubmissions/DataSubmissionActions.tsx b/src/content/dataSubmissions/DataSubmissionActions.tsx index a4c9b4f4b..231793201 100644 --- a/src/content/dataSubmissions/DataSubmissionActions.tsx +++ b/src/content/dataSubmissions/DataSubmissionActions.tsx @@ -5,6 +5,7 @@ import { Button, OutlinedInput, Stack, Typography, styled } from "@mui/material" import { useAuthContext } from "../../components/Contexts/AuthContext"; import CustomDialog from "../../components/Shared/Dialog"; import { EXPORT_SUBMISSION, ExportSubmissionResp } from "../../graphql"; +import { ExportValidationButton, Props as ExportButtonProps } from '../../components/DataSubmissions/ExportValidationButton'; const StyledActionWrapper = styled(Stack)(() => ({ justifyContent: "center", @@ -124,14 +125,23 @@ type SubmitActionButton = { disable: boolean; }; +type ExportActionButton = { + disabled: boolean; + visible: boolean; + fields: ExportButtonProps["fields"]; +}; + type Props = { submission: Submission; submitActionButton: SubmitActionButton; + exportActionButton: ExportActionButton; onAction: (action: SubmissionAction, reviewComment?: string) => Promise; onError: (message: string) => void; }; -const DataSubmissionActions = ({ submission, submitActionButton, onAction, onError }: Props) => { +const DataSubmissionActions = ({ + submission, submitActionButton, exportActionButton, onAction, onError, +}: Props) => { const { user } = useAuthContext(); const [currentDialog, setCurrentDialog] = useState(null); @@ -284,6 +294,15 @@ const DataSubmissionActions = ({ submission, submitActionButton, onAction, onErr ) : null} + {/* Validation Result Export Button */} + {exportActionButton.visible && ( + + )} + {/* Submit Dialog */} [] = [ }, ]; -// TODO: Use `columns` instead of duplicating the fields here. -const csvColumns = { +// CSV columns used for exporting table data +export const csvColumns = { "Batch ID": (d: QCResult) => d.displayID, "Node Type": (d: QCResult) => d.type, "Submitted Identifier": (d: QCResult) => d.submittedID, Severity: (d: QCResult) => d.severity, - "Validated Date": (d: QCResult) => d.validatedDate, + "Validated Date": (d: QCResult) => FormatDate(d?.validatedDate, "MM-DD-YYYY [at] hh:mm A", ""), Issues: (d: QCResult) => (d.errors?.length > 0 ? d.errors[0].description : d.warnings[0]?.description), }; @@ -266,7 +265,6 @@ const QualityControl: FC = () => { return ( <> - Node Type diff --git a/src/graphql/getSubmission.ts b/src/graphql/getSubmission.ts index ab9881905..c746f027f 100644 --- a/src/graphql/getSubmission.ts +++ b/src/graphql/getSubmission.ts @@ -61,12 +61,28 @@ export const query = gql` error } } + + totalQCResults: submissionQCResults(_id: $id, first: 1) { + total + } } `; export type Response = { + /** + * The submission object + */ getSubmission: Submission; + /** + * The node statistics for the submission + */ submissionStats: { stats: SubmissionStatistic[]; }; + /** + * The total number of QC results for the submission + */ + totalQCResults: { + total: number; + }; }; From 5ae73934fe67e91a355625cfb85e98545be6c242 Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 21 Mar 2024 15:02:33 -0400 Subject: [PATCH 12/34] Add base filename test cases, add note to setupTests.ts --- .../ExportValidationButton.test.tsx | 61 ++++++++++++++++--- src/setupTests.ts | 2 +- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index c8d21ef8e..a0e1d163a 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -18,6 +18,12 @@ const TestParent: FC = ({ mocks, children } : ParentProps) => ( ); +const mockDownloadBlob = jest.fn(); +jest.mock('../../utils', () => ({ + ...jest.requireActual('../../utils'), + downloadBlob: (...args) => mockDownloadBlob(...args), +})); + describe('ExportValidationButton cases', () => { const baseSubmission: Submission = { _id: '', @@ -56,7 +62,7 @@ describe('ExportValidationButton cases', () => { }; afterEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); }); it('should not have accessibility violations', async () => { @@ -115,12 +121,53 @@ describe('ExportValidationButton cases', () => { }); it.each([ - { ...baseSubmission, _id: "example-sub-id" }, - { ...baseSubmission, _id: "example-sub-id-2" }, - { ...baseSubmission, _id: "example-sub-id-3" }, - ])("should name the export file as [TODO]", async (submission) => { - // TODO: Implement per requirements - expect(false).toBeTruthy(); + { ...baseSubmission, _id: "1", name: "A B C 1 2 3" }, + { ...baseSubmission, _id: "2", name: "long name".repeat(100) }, + { ...baseSubmission, _id: "3", name: "" }, + ])("should name the CSV export file dynamically using submission name and export date", async (submission) => { + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_QC_RESULTS, + variables: { + id: submission._id, + sortDirection: "asc", + orderBy: "displayID", + first: 10000, // TODO: change to -1 + offset: 0, + }, + }, + result: { + data: { + submissionQCResults: { + total: 1, + results: [{ + ...baseQCResult, + submissionID: submission._id, + errors: [{ title: "Error 01", description: "Error 01 description" }], + }] + }, + }, + }, + }]; + + const fields = { + ID: jest.fn().mockImplementation((result: QCResult) => result.submissionID), + }; + + const { getByText } = render( + + + + ); + + act(() => { + fireEvent.click(getByText('Download QC Results')); + }); + + await waitFor(() => { + // TODO: Waiting for requirement to assert the file name + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.any(String), "validation-results.csv", expect.any(String)); + }); }); it('should alert the user if there are no QC Results to export', async () => { diff --git a/src/setupTests.ts b/src/setupTests.ts index 7cb65d64e..3ef9dd821 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -4,7 +4,7 @@ import 'jest-axe/extend-expect'; /** * Mocks the enqueueSnackbar function from notistack for testing * - * @note You must clear all mocks after each test to avoid unexpected behavior + * @note You must RESET all mocks after each test to avoid unexpected behavior * @example expect(mockEnqueue).toHaveBeenCalledWith('message', { variant: 'error' }); * @see notistack documentation: https://notistack.com/getting-started */ From c4a5a08f402c4228eaf6465518b99c7d76f454be Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 21 Mar 2024 16:41:54 -0400 Subject: [PATCH 13/34] Integrate getSubmissionNodes query --- .../DataSubmissions/GenericTable.tsx | 14 +- .../dataSubmissions/DataContent.test.tsx | 163 +++++++++++++++--- src/content/dataSubmissions/DataContent.tsx | 48 +++--- src/graphql/getSubmissionNodes.ts | 17 +- src/utils/index.ts | 1 + src/utils/jsonUtils.test.ts | 41 +++++ src/utils/jsonUtils.ts | 20 +++ 7 files changed, 244 insertions(+), 60 deletions(-) create mode 100644 src/utils/jsonUtils.test.ts create mode 100644 src/utils/jsonUtils.ts diff --git a/src/components/DataSubmissions/GenericTable.tsx b/src/components/DataSubmissions/GenericTable.tsx index 1886e21f6..58a9564d0 100644 --- a/src/components/DataSubmissions/GenericTable.tsx +++ b/src/components/DataSubmissions/GenericTable.tsx @@ -248,7 +248,11 @@ const GenericTable = ({ {columns.map((col: Column) => ( - + {col.field && !col.sortDisabled ? ( ({ || emptyRows > 0 || loading }} - SelectProps={{ inputProps: { "aria-label": "rows per page" }, native: true }} + SelectProps={{ + inputProps: { + "aria-label": "rows per page", + "data-testid": "generic-table-rows-per-page" + }, + native: true, + }} backIconButtonProps={{ disabled: page === 0 || loading }} ActionsComponent={PaginationActions} /> diff --git a/src/content/dataSubmissions/DataContent.test.tsx b/src/content/dataSubmissions/DataContent.test.tsx index 42ed6ab66..fd3a0c6eb 100644 --- a/src/content/dataSubmissions/DataContent.test.tsx +++ b/src/content/dataSubmissions/DataContent.test.tsx @@ -5,7 +5,7 @@ import { axe } from 'jest-axe'; import { render, waitFor } from '@testing-library/react'; import DataContent from './DataContent'; import { mockEnqueue } from '../../setupTests'; -import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; +import { GET_SUBMISSION_NODES, GetSubmissionNodesResp } from '../../graphql'; type ParentProps = { mocks?: MockedResponse[]; @@ -57,14 +57,12 @@ describe("DataContent > General", () => { it("should show an error message when the nodes cannot be fetched (network)", async () => { const submissionID = "example-sub-id-1"; - // TODO: Update to the real query - const mocks: MockedResponse[] = [{ + const mocks: MockedResponse[] = [{ request: { - query: SUBMISSION_QC_RESULTS, + query: GET_SUBMISSION_NODES, variables: { - id: submissionID, + _id: submissionID, sortDirection: "desc", - orderBy: "displayID", first: 20, offset: 0, nodeTypes: ["example-node"], @@ -89,14 +87,12 @@ describe("DataContent > General", () => { it("should show an error message when the nodes cannot be fetched (GraphQL)", async () => { const submissionID = "example-sub-id-2"; - // TODO: Update to the real query - const mocks: MockedResponse[] = [{ + const mocks: MockedResponse[] = [{ request: { - query: SUBMISSION_QC_RESULTS, + query: GET_SUBMISSION_NODES, variables: { - id: submissionID, + _id: submissionID, sortDirection: "desc", - orderBy: "displayID", first: 20, offset: 0, nodeTypes: ["example-node"], @@ -138,14 +134,12 @@ describe("DataContent > Table", () => { it("should render the placeholder text when no data is available", async () => { const submissionID = "example-placeholder-test-id"; - // TODO: Update to the real query - const mocks: MockedResponse[] = [{ + const mocks: MockedResponse[] = [{ request: { - query: SUBMISSION_QC_RESULTS, + query: GET_SUBMISSION_NODES, variables: { - id: submissionID, + _id: submissionID, sortDirection: "desc", - orderBy: "displayID", first: 20, offset: 0, nodeTypes: ["example-node"], @@ -153,9 +147,10 @@ describe("DataContent > Table", () => { }, result: { data: { - submissionQCResults: { + getSubmissionNodes: { total: 0, - results: [], + properties: [], + nodes: [], }, }, }, @@ -174,17 +169,135 @@ describe("DataContent > Table", () => { }); }); - it("should render dynamic columns based on the node type selected", async () => { - fail("Not implemented"); + it("should render dynamic columns based on the selected node properties", async () => { + const submissionID = "example-dynamic-columns-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeTypes: ["example-node"], + }, + }, + result: { + data: { + getSubmissionNodes: { + total: 2, + properties: ["col.1", "col.2", "col.3"], + nodes: [ + { + nodeType: "example-node", + nodeID: "example-node-id", + props: JSON.stringify({ "col.1": "value-1", "col.2": "value-2", "col.3": "value-3" }), + }, + ], + }, + }, + }, + }]; + + const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId("generic-table-header-col.1")).toBeInTheDocument(); + expect(getByTestId("generic-table-header-col.2")).toBeInTheDocument(); + expect(getByTestId("generic-table-header-col.3")).toBeInTheDocument(); + }); }); - it("should fetch the QC results when the component mounts", async () => { - fail("Not implemented"); + // NOTE: We're asserting that the columns ARE built using getSubmissionNodes.properties + // instead of the keys of nodes.[x].props JSON object + it("should NOT build the columns based off of the nodes.[X].props JSON object", async () => { + const submissionID = "example-using-properties-dynamic-columns-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeTypes: ["example-node"], + }, + }, + result: { + data: { + getSubmissionNodes: { + total: 2, + properties: ["good-col-1", "good-col-2"], + nodes: [ + { + nodeType: "example-node", + nodeID: "example-node-id", + props: JSON.stringify({ "good-col-1": "ok", "good-col-2": "ok", "bad-column": "bad" }), + }, + ], + }, + }, + }, + }]; + + const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(() => getByTestId("generic-table-header-bad-column")).toThrow(); + }); }); it("should have a default pagination count of 25 rows per page", async () => { - fail("Not implemented"); - }); + const submissionID = "example-pagination-default-test-id"; + + const mocks: MockedResponse[] = [{ + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeTypes: ["example-node"], + }, + }, + result: { + data: { + getSubmissionNodes: { + total: 0, + properties: [], + nodes: [], + }, + }, + }, + }]; - // ... more tests ...? + const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + // TODO: this matches current requirements but it should be 20 + // if the reqs of 25 is correct, need to update the test queries above + expect(getByTestId("generic-table-rows-per-page")).toHaveValue("25"); + }); + }); }); diff --git a/src/content/dataSubmissions/DataContent.tsx b/src/content/dataSubmissions/DataContent.tsx index ad42678c0..8fed81802 100644 --- a/src/content/dataSubmissions/DataContent.tsx +++ b/src/content/dataSubmissions/DataContent.tsx @@ -2,26 +2,20 @@ import { FC, useRef, useState } from "react"; import { useLazyQuery } from "@apollo/client"; import { isEqual } from "lodash"; import { useSnackbar } from 'notistack'; -import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from "../../graphql"; +import { GET_SUBMISSION_NODES, GetSubmissionNodesResp } from "../../graphql"; import GenericTable, { Column, FetchListing, TableMethods } from "../../components/DataSubmissions/GenericTable"; import { DataContentFilters, FilterForm } from '../../components/DataSubmissions/DataContentFilters'; +import { safeParse } from '../../utils'; -type TODO = QCResult; // TODO: Type this when the real type is known +type T = Pick & { + props: Record; +}; type Props = { submissionId: string; statistics: SubmissionStatistic[]; }; -const columns: Column[] = [ - { - label: "TBD", - renderValue: () => "TBD", - field: "displayID", - default: true - }, -]; - const DataContent: FC = ({ submissionId, statistics }) => { const { enqueueSnackbar } = useSnackbar(); @@ -29,16 +23,17 @@ const DataContent: FC = ({ submissionId, statistics }) => { const filterRef = useRef({ nodeType: "" }); const [loading, setLoading] = useState(true); - const [data, setData] = useState([]); - const [prevListing, setPrevListing] = useState>(null); + const [columns, setColumns] = useState[]>([]); + const [data, setData] = useState([]); + const [prevListing, setPrevListing] = useState>(null); const [totalData, setTotalData] = useState(0); - const [submissionQCResults] = useLazyQuery(SUBMISSION_QC_RESULTS, { - variables: { id: submissionId }, + + const [getSubmissionNodes] = useLazyQuery(GET_SUBMISSION_NODES, { context: { clientName: 'backend' }, fetchPolicy: 'cache-and-network', }); - const handleFetchData = async (fetchListing: FetchListing, force: boolean) => { + const handleFetchData = async (fetchListing: FetchListing, force: boolean) => { const { first, offset, sortDirection, orderBy } = fetchListing || {}; if (!submissionId) { enqueueSnackbar("Cannot fetch results. Submission ID is invalid or missing.", { variant: "error" }); @@ -56,8 +51,9 @@ const DataContent: FC = ({ submissionId, statistics }) => { setPrevListing(fetchListing); setLoading(true); - const { data: d, error } = await submissionQCResults({ + const { data: d, error } = await getSubmissionNodes({ variables: { + _id: submissionId, first, offset, sortDirection, @@ -68,14 +64,24 @@ const DataContent: FC = ({ submissionId, statistics }) => { fetchPolicy: 'no-cache' }); - if (error || !d?.submissionQCResults) { + if (error || !d?.getSubmissionNodes || !d?.getSubmissionNodes?.properties) { enqueueSnackbar("Unable to retrieve node data.", { variant: "error" }); setLoading(false); return; } - setData(d.submissionQCResults.results); - setTotalData(d.submissionQCResults.total); + setTotalData(d.getSubmissionNodes.total); + setData(d.getSubmissionNodes.nodes.map((node) => ({ + nodeType: node.nodeType, + nodeID: node.nodeID, + props: safeParse(node.props), + }))); + setColumns(d.getSubmissionNodes.properties.map((prop) => ({ + label: prop, + renderValue: (d) => d?.[prop] || "", + field: prop as keyof T, // TODO: fix this hack + default: true + }))); setLoading(false); }; @@ -95,7 +101,7 @@ const DataContent: FC = ({ submissionId, statistics }) => { loading={loading} defaultRowsPerPage={20} defaultOrder="desc" - setItemKey={(item, idx) => `${idx}_${item.batchID}_${item.submittedID}`} + setItemKey={(item, idx) => `${idx}_${item.nodeID}_${item.nodeID}`} onFetchData={handleFetchData} /> diff --git a/src/graphql/getSubmissionNodes.ts b/src/graphql/getSubmissionNodes.ts index 66e813f33..fda72baca 100644 --- a/src/graphql/getSubmissionNodes.ts +++ b/src/graphql/getSubmissionNodes.ts @@ -6,18 +6,9 @@ export const query = gql` total properties nodes { - _id nodeType - name - description - status - createdAt - updatedAt - createdBy - updatedBy - parentID - parentType - properties + nodeID + props } } } @@ -35,7 +26,9 @@ export type Response = { properties: string[]; /** * An array of nodes matching the queried node type + * + * @note Unused values are omitted from the query. See the type definition for additional fields. */ - nodes: SubmissionNode[]; + nodes: Pick[]; }; }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 7e01b752d..d0fe727f7 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,4 @@ export * from './dataValidationUtils'; export * from './dataSubmissionUtils'; export * from './tableUtils'; export * from './statisticUtils'; +export * from './jsonUtils'; diff --git a/src/utils/jsonUtils.test.ts b/src/utils/jsonUtils.test.ts new file mode 100644 index 000000000..ee3fab26c --- /dev/null +++ b/src/utils/jsonUtils.test.ts @@ -0,0 +1,41 @@ +import { safeParse } from './jsonUtils'; + +describe('safeParse cases', () => { + it('should parse valid JSON', () => { + const json = '{"key": "value"}'; + expect(safeParse(json)).toEqual({ key: 'value' }); + }); + + it('should return an empty object for invalid JSON', () => { + const json = '{key: value}'; + expect(safeParse(json)).toEqual({}); + }); + + it('should return the fallback value for invalid JSON', () => { + const json = '{key: value}'; + const fallback = { fallback: 'value' }; + expect(safeParse(json, fallback)).toEqual(fallback); + }); + + it('should parse valid JSON arrays', () => { + const json = '[1, 2, 3]'; + expect(safeParse(json)).toEqual([1, 2, 3]); + }); + + it('should return an empty object for invalid JSON arrays', () => { + const json = '[1, 2,'; + expect(safeParse(json)).toEqual({}); + }); + + it('should return the fallback value for undefined', () => { + const json = undefined; + const fallback = { fallback: 'value' }; + expect(safeParse(json, fallback)).toEqual(fallback); + }); + + it('should return the fallback value for null', () => { + const json = null; + const fallback = { fallback: 'value' }; + expect(safeParse(json, fallback)).toEqual(fallback); + }); +}); diff --git a/src/utils/jsonUtils.ts b/src/utils/jsonUtils.ts new file mode 100644 index 000000000..64cded9cb --- /dev/null +++ b/src/utils/jsonUtils.ts @@ -0,0 +1,20 @@ +/** + * Safely parse a JSON string into an object. + * + * @param unsafeJson The JSON string to parse. + * @param fallback The value to return if the JSON string is invalid. + * @returns The parsed JSON object or the fallback value if the JSON string is invalid. + */ +export const safeParse = (unsafeJson: string, fallback = {}) => { + try { + const result = JSON.parse(unsafeJson); + + if (result === null || result === undefined) { + return fallback; + } + + return result; + } catch (e) { + return fallback; + } +}; From a4710225156b671ba962aa3ad99ecaef5b85e9f3 Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 21 Mar 2024 17:10:42 -0400 Subject: [PATCH 14/34] Implement filename and test cases --- .../ExportValidationButton.test.tsx | 29 ++++++++++++------- .../ExportValidationButton.tsx | 7 +++-- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index a0e1d163a..066e94913 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -3,9 +3,11 @@ import { render, fireEvent, act, waitFor } from '@testing-library/react'; import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { GraphQLError } from 'graphql'; import { axe } from 'jest-axe'; +import dayjs from 'dayjs'; import { ExportValidationButton } from './ExportValidationButton'; import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; import { mockEnqueue } from '../../setupTests'; +import { filterAlphaNumeric } from '../../utils'; type ParentProps = { mocks?: MockedResponse[]; @@ -120,16 +122,21 @@ describe('ExportValidationButton cases', () => { }); }); - it.each([ - { ...baseSubmission, _id: "1", name: "A B C 1 2 3" }, - { ...baseSubmission, _id: "2", name: "long name".repeat(100) }, - { ...baseSubmission, _id: "3", name: "" }, - ])("should name the CSV export file dynamically using submission name and export date", async (submission) => { + it.each<{ original: string, expected: string }>([ + { original: "A B C 1 2 3", expected: "ABC123" }, + { original: "long name".repeat(100), expected: "longname".repeat(100) }, + { original: "", expected: "" }, + { original: `non $alpha name $@!819`, expected: "nonalphaname819" }, + { original: " ", expected: "" }, + { original: `_-"a-b+c=d`, expected: "abcd" }, + ])("should safely name the CSV export file dynamically using submission name and export date", async ({ original, expected }) => { + jest.useFakeTimers().setSystemTime(new Date('2021-01-01T00:00:00Z')); + const mocks: MockedResponse[] = [{ request: { query: SUBMISSION_QC_RESULTS, variables: { - id: submission._id, + id: "example-dynamic-filename-id", sortDirection: "asc", orderBy: "displayID", first: 10000, // TODO: change to -1 @@ -142,7 +149,7 @@ describe('ExportValidationButton cases', () => { total: 1, results: [{ ...baseQCResult, - submissionID: submission._id, + submissionID: "example-dynamic-filename-id", errors: [{ title: "Error 01", description: "Error 01 description" }], }] }, @@ -156,7 +163,7 @@ describe('ExportValidationButton cases', () => { const { getByText } = render( - + ); @@ -165,9 +172,11 @@ describe('ExportValidationButton cases', () => { }); await waitFor(() => { - // TODO: Waiting for requirement to assert the file name - expect(mockDownloadBlob).toHaveBeenCalledWith(expect.any(String), "validation-results.csv", expect.any(String)); + const filename = `${expected}-${dayjs().format("YYYY-MM-DDTHHmmss")}.csv`; + expect(mockDownloadBlob).toHaveBeenCalledWith(expect.any(String), filename, expect.any(String)); }); + + jest.useRealTimers(); }); it('should alert the user if there are no QC Results to export', async () => { diff --git a/src/components/DataSubmissions/ExportValidationButton.tsx b/src/components/DataSubmissions/ExportValidationButton.tsx index 62e0d4eea..935f36667 100644 --- a/src/components/DataSubmissions/ExportValidationButton.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.tsx @@ -3,9 +3,10 @@ import { useLazyQuery } from '@apollo/client'; import { LoadingButton } from '@mui/lab'; import { ButtonProps } from '@mui/material'; import { useSnackbar } from 'notistack'; +import dayjs from 'dayjs'; import { unparse } from 'papaparse'; import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; -import { downloadBlob, unpackQCResultSeverities } from '../../utils'; +import { downloadBlob, filterAlphaNumeric, unpackQCResultSeverities } from '../../utils'; export type Props = { /** @@ -64,6 +65,7 @@ export const ExportValidationButton: React.FC = ({ submission, fields, .. } try { + const filename = `${filterAlphaNumeric(submission.name)}-${dayjs().format("YYYY-MM-DDTHHmmss")}.csv`; const unpacked = unpackQCResultSeverities(d.submissionQCResults.results); const csvArray = []; @@ -78,8 +80,7 @@ export const ExportValidationButton: React.FC = ({ submission, fields, .. csvArray.push(csvRow); }); - // TODO: File name? - downloadBlob(unparse(csvArray), "validation-results.csv", "text/csv"); + downloadBlob(unparse(csvArray), filename, "text/csv"); } catch (err) { enqueueSnackbar("Unable to export validation results.", { variant: "error" }); } From 18645058be28e8491afcd685c7ff0665726f4614 Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 21 Mar 2024 17:13:17 -0400 Subject: [PATCH 15/34] fix: Unused import --- src/components/DataSubmissions/ExportValidationButton.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index 066e94913..eab39b48d 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -7,7 +7,6 @@ import dayjs from 'dayjs'; import { ExportValidationButton } from './ExportValidationButton'; import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; import { mockEnqueue } from '../../setupTests'; -import { filterAlphaNumeric } from '../../utils'; type ParentProps = { mocks?: MockedResponse[]; From fd00371794a351e41feb3ca8f3763685daf37a7f Mon Sep 17 00:00:00 2001 From: Alec M Date: Fri, 22 Mar 2024 09:31:57 -0400 Subject: [PATCH 16/34] fix: Column value is row.props.[prop] not row.[prop] Also update pagination to 20 instead of 25 --- src/content/dataSubmissions/DataContent.test.tsx | 14 ++++++++------ src/content/dataSubmissions/DataContent.tsx | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/content/dataSubmissions/DataContent.test.tsx b/src/content/dataSubmissions/DataContent.test.tsx index fd3a0c6eb..b9674e635 100644 --- a/src/content/dataSubmissions/DataContent.test.tsx +++ b/src/content/dataSubmissions/DataContent.test.tsx @@ -202,7 +202,7 @@ describe("DataContent > Table", () => { const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; - const { getByTestId } = render( + const { getByTestId, getByText } = render( @@ -212,6 +212,9 @@ describe("DataContent > Table", () => { expect(getByTestId("generic-table-header-col.1")).toBeInTheDocument(); expect(getByTestId("generic-table-header-col.2")).toBeInTheDocument(); expect(getByTestId("generic-table-header-col.3")).toBeInTheDocument(); + expect(getByText("value-1")).toBeInTheDocument(); + expect(getByText("value-2")).toBeInTheDocument(); + expect(getByText("value-3")).toBeInTheDocument(); }); }); @@ -250,7 +253,7 @@ describe("DataContent > Table", () => { const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; - const { getByTestId } = render( + const { getByTestId, getByText } = render( @@ -258,10 +261,11 @@ describe("DataContent > Table", () => { await waitFor(() => { expect(() => getByTestId("generic-table-header-bad-column")).toThrow(); + expect(() => getByText("bad-column")).toThrow(); }); }); - it("should have a default pagination count of 25 rows per page", async () => { + it("should have a default pagination count of 20 rows per page", async () => { const submissionID = "example-pagination-default-test-id"; const mocks: MockedResponse[] = [{ @@ -295,9 +299,7 @@ describe("DataContent > Table", () => { ); await waitFor(() => { - // TODO: this matches current requirements but it should be 20 - // if the reqs of 25 is correct, need to update the test queries above - expect(getByTestId("generic-table-rows-per-page")).toHaveValue("25"); + expect(getByTestId("generic-table-rows-per-page")).toHaveValue("20"); }); }); }); diff --git a/src/content/dataSubmissions/DataContent.tsx b/src/content/dataSubmissions/DataContent.tsx index 8fed81802..29fc2c91d 100644 --- a/src/content/dataSubmissions/DataContent.tsx +++ b/src/content/dataSubmissions/DataContent.tsx @@ -78,7 +78,7 @@ const DataContent: FC = ({ submissionId, statistics }) => { }))); setColumns(d.getSubmissionNodes.properties.map((prop) => ({ label: prop, - renderValue: (d) => d?.[prop] || "", + renderValue: (d) => d?.props?.[prop] || "", field: prop as keyof T, // TODO: fix this hack default: true }))); From cd794330a1adb9f098b35cc9e9ad590a863b4c86 Mon Sep 17 00:00:00 2001 From: Alec M Date: Fri, 22 Mar 2024 09:37:48 -0400 Subject: [PATCH 17/34] Replace `10000` placeholder query limit with `-1` --- .../ExportValidationButton.test.tsx | 14 +++++++------- .../DataSubmissions/ExportValidationButton.tsx | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index eab39b48d..97de64b4d 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -96,7 +96,7 @@ describe('ExportValidationButton cases', () => { id: submissionID, sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, }, @@ -138,7 +138,7 @@ describe('ExportValidationButton cases', () => { id: "example-dynamic-filename-id", sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, }, @@ -188,7 +188,7 @@ describe('ExportValidationButton cases', () => { id: submissionID, sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, }, @@ -236,7 +236,7 @@ describe('ExportValidationButton cases', () => { id: submissionID, sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, }, @@ -288,7 +288,7 @@ describe('ExportValidationButton cases', () => { id: submissionID, sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, }, @@ -320,7 +320,7 @@ describe('ExportValidationButton cases', () => { id: submissionID, sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, }, @@ -354,7 +354,7 @@ describe('ExportValidationButton cases', () => { id: submissionID, sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, }, diff --git a/src/components/DataSubmissions/ExportValidationButton.tsx b/src/components/DataSubmissions/ExportValidationButton.tsx index 935f36667..5beb441a3 100644 --- a/src/components/DataSubmissions/ExportValidationButton.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.tsx @@ -45,7 +45,7 @@ export const ExportValidationButton: React.FC = ({ submission, fields, .. id: submission?._id, sortDirection: "asc", orderBy: "displayID", - first: 10000, // TODO: change to -1 + first: -1, offset: 0, }, context: { clientName: 'backend' }, From f3dd1e7b3f5ccbabbbdbdbc2cda6ea5e18119bd4 Mon Sep 17 00:00:00 2001 From: Alec M Date: Fri, 22 Mar 2024 10:11:19 -0400 Subject: [PATCH 18/34] fix: Missing query execution assertion --- package-lock.json | 75 ++++++++++++++----- package.json | 2 +- .../ExportValidationButton.test.tsx | 24 ++++-- 3 files changed, 75 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index b56b37811..b23b2dd7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "crdc-datahub-ui", "version": "0.1.0", "dependencies": { - "@apollo/client": "^3.7.16", + "@apollo/client": "^3.9.8", "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@jalik/form-parser": "^3.1.0", @@ -104,18 +104,19 @@ } }, "node_modules/@apollo/client": { - "version": "3.8.5", - "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.8.5.tgz", - "integrity": "sha512-/ueWC3f1pFeH+tWbM1phz6pzUGGijyml6oQ+LKUcQzpXF6tVFPrb6oUIUQCbZpr6Xmv/dtNiFDohc39ra7Solg==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.9.8.tgz", + "integrity": "sha512-ausPftEb2xAUkZqz+VkSSIhNxKraShJXdV2/NJ7JbHAAciGsFlapGtZ++b7lF0/+3Jp/p34g/i6dvO8b4WjQig==", "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", - "@wry/context": "^0.7.3", + "@wry/caches": "^1.0.0", "@wry/equality": "^0.5.6", - "@wry/trie": "^0.4.3", + "@wry/trie": "^0.5.0", "graphql-tag": "^2.12.6", "hoist-non-react-statics": "^3.3.2", - "optimism": "^0.17.5", + "optimism": "^0.18.0", "prop-types": "^15.7.2", + "rehackt": "0.0.6", "response-iterator": "^0.2.6", "symbol-observable": "^4.0.0", "ts-invariant": "^0.10.3", @@ -123,7 +124,7 @@ "zen-observable-ts": "^1.2.5" }, "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", + "graphql": "^15.0.0 || ^16.0.0", "graphql-ws": "^5.5.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", @@ -5460,10 +5461,21 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@wry/context": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.3.tgz", - "integrity": "sha512-Nl8WTesHp89RF803Se9X3IiHjdmLBrIvPMaJkl+rKVJAYyPsz1TEUbu89943HpvujtSJgDUx9W4vZw3K1Mr3sA==", + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", "dependencies": { "tslib": "^2.3.0" }, @@ -5482,8 +5494,9 @@ } }, "node_modules/@wry/trie": { - "version": "0.4.3", - "license": "MIT", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", "dependencies": { "tslib": "^2.3.0" }, @@ -13387,15 +13400,27 @@ } }, "node_modules/optimism": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.17.5.tgz", - "integrity": "sha512-TEcp8ZwK1RczmvMnvktxHSF2tKgMWjJ71xEFGX5ApLh67VsMSTy1ZUlipJw8W+KaqgOmQ+4pqwkeivY89j+4Vw==", + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.0.tgz", + "integrity": "sha512-tGn8+REwLRNFnb9WmcY5IfpOqeX2kpaYJ1s6Ae3mn12AeydLkR3j+jSCmVQFoXqU8D41PAJ1RG1rCRNWmNZVmQ==", "dependencies": { + "@wry/caches": "^1.0.0", "@wry/context": "^0.7.0", "@wry/trie": "^0.4.3", "tslib": "^2.3.0" } }, + "node_modules/optimism/node_modules/@wry/trie": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", + "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/optionator": { "version": "0.9.1", "license": "MIT", @@ -15595,6 +15620,23 @@ "jsesc": "bin/jsesc" } }, + "node_modules/rehackt": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/rehackt/-/rehackt-0.0.6.tgz", + "integrity": "sha512-l3WEzkt4ntlEc/IB3/mF6SRgNHA6zfQR7BlGOgBTOmx7IJJXojDASav+NsgXHFjHn+6RmwqsGPFgZpabWpeOdw==", + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/relateurl": { "version": "0.2.7", "license": "MIT", @@ -17410,7 +17452,6 @@ }, "node_modules/typescript": { "version": "5.1.3", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index c15c06e0d..963dee96e 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@apollo/client": "^3.7.16", + "@apollo/client": "^3.9.8", "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@jalik/form-parser": "^3.1.0", diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index 97de64b4d..637b4fcd7 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -1,5 +1,6 @@ import { FC } from 'react'; import { render, fireEvent, act, waitFor } from '@testing-library/react'; +import UserEvent from '@testing-library/user-event'; import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { GraphQLError } from 'graphql'; import { axe } from 'jest-axe'; @@ -89,6 +90,7 @@ describe('ExportValidationButton cases', () => { it('should execute the SUBMISSION_QC_RESULTS query onClick', async () => { const submissionID = "example-sub-id"; + let called = false; const mocks: MockedResponse[] = [{ request: { query: SUBMISSION_QC_RESULTS, @@ -100,13 +102,17 @@ describe('ExportValidationButton cases', () => { offset: 0, }, }, - result: { - data: { - submissionQCResults: { - total: 1, - results: [{ ...baseQCResult, submissionID }] + result: () => { + called = true; + + return { + data: { + submissionQCResults: { + total: 1, + results: [{ ...baseQCResult, submissionID }] + }, }, - }, + }; }, }]; @@ -116,8 +122,10 @@ describe('ExportValidationButton cases', () => { ); - act(() => { - fireEvent.click(getByText('Download QC Results')); + expect(called).toBe(false); + await waitFor(() => { + UserEvent.click(getByText('Download QC Results')); + expect(called).toBe(true); }); }); From b68a9fc657b9fd401cd1d80e1123ffff564aa6db Mon Sep 17 00:00:00 2001 From: Alec M Date: Mon, 25 Mar 2024 10:11:26 -0400 Subject: [PATCH 19/34] Improve export performance and cleanup test cases --- .../ExportValidationButton.test.tsx | 22 ++++++------------- .../ExportValidationButton.tsx | 2 +- src/utils/dataSubmissionUtils.ts | 4 ++-- 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index 637b4fcd7..fa9501260 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -4,7 +4,6 @@ import UserEvent from '@testing-library/user-event'; import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { GraphQLError } from 'graphql'; import { axe } from 'jest-axe'; -import dayjs from 'dayjs'; import { ExportValidationButton } from './ExportValidationButton'; import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; import { mockEnqueue } from '../../setupTests'; @@ -77,18 +76,8 @@ describe('ExportValidationButton cases', () => { expect(await axe(container)).toHaveNoViolations(); }); - it('should render without crashing', () => { - const { getByText } = render( - - - - ); - - expect(getByText('Download QC Results')).toBeInTheDocument(); - }); - it('should execute the SUBMISSION_QC_RESULTS query onClick', async () => { - const submissionID = "example-sub-id"; + const submissionID = "example-execute-test-sub-id"; let called = false; const mocks: MockedResponse[] = [{ @@ -123,8 +112,10 @@ describe('ExportValidationButton cases', () => { ); expect(called).toBe(false); + + // NOTE: This must be separate from the expect below to ensure its not called multiple times + await waitFor(() => UserEvent.click(getByText('Download QC Results'))); await waitFor(() => { - UserEvent.click(getByText('Download QC Results')); expect(called).toBe(true); }); }); @@ -137,7 +128,7 @@ describe('ExportValidationButton cases', () => { { original: " ", expected: "" }, { original: `_-"a-b+c=d`, expected: "abcd" }, ])("should safely name the CSV export file dynamically using submission name and export date", async ({ original, expected }) => { - jest.useFakeTimers().setSystemTime(new Date('2021-01-01T00:00:00Z')); + jest.useFakeTimers().setSystemTime(new Date('2021-01-19T14:54:01Z')); const mocks: MockedResponse[] = [{ request: { @@ -179,10 +170,11 @@ describe('ExportValidationButton cases', () => { }); await waitFor(() => { - const filename = `${expected}-${dayjs().format("YYYY-MM-DDTHHmmss")}.csv`; + const filename = `${expected}-2021-01-19T145401.csv`; expect(mockDownloadBlob).toHaveBeenCalledWith(expect.any(String), filename, expect.any(String)); }); + jest.runOnlyPendingTimers(); jest.useRealTimers(); }); diff --git a/src/components/DataSubmissions/ExportValidationButton.tsx b/src/components/DataSubmissions/ExportValidationButton.tsx index 5beb441a3..df3d1abdc 100644 --- a/src/components/DataSubmissions/ExportValidationButton.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.tsx @@ -67,11 +67,11 @@ export const ExportValidationButton: React.FC = ({ submission, fields, .. try { const filename = `${filterAlphaNumeric(submission.name)}-${dayjs().format("YYYY-MM-DDTHHmmss")}.csv`; const unpacked = unpackQCResultSeverities(d.submissionQCResults.results); + const fieldset = Object.entries(fields); const csvArray = []; unpacked.forEach((row) => { const csvRow = {}; - const fieldset = Object.entries(fields); fieldset.forEach(([field, value]) => { csvRow[field] = value(row) || ""; diff --git a/src/utils/dataSubmissionUtils.ts b/src/utils/dataSubmissionUtils.ts index 7a9c063ea..111997352 100644 --- a/src/utils/dataSubmissionUtils.ts +++ b/src/utils/dataSubmissionUtils.ts @@ -56,7 +56,7 @@ export const unpackQCResultSeverities = (results: QCResult[]): QCResult[] => { // Iterate through each result and push the errors and warnings into separate results results.forEach(({ errors, warnings, ...result }) => { - errors.slice(0).forEach((error) => { + errors.forEach((error) => { unpackedResults.push({ ...result, severity: "Error", @@ -64,7 +64,7 @@ export const unpackQCResultSeverities = (results: QCResult[]): QCResult[] => { warnings: [], }); }); - warnings.slice(0).forEach((warning) => { + warnings.forEach((warning) => { unpackedResults.push({ ...result, severity: "Warning", From 711c650c2637ae4426791af0582fb21a8ad128c6 Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 26 Mar 2024 14:16:13 -0400 Subject: [PATCH 20/34] Rename Data Content to Submitted Data --- ...test.tsx => SubmittedDataFilters.test.tsx} | 20 ++++++++--------- ...ntFilters.tsx => SubmittedDataFilters.tsx} | 4 ++-- .../dataSubmissions/DataSubmission.tsx | 16 +++++++------- ...ontent.test.tsx => SubmittedData.test.tsx} | 22 +++++++++---------- .../{DataContent.tsx => SubmittedData.tsx} | 10 ++++----- 5 files changed, 36 insertions(+), 36 deletions(-) rename src/components/DataSubmissions/{DataContentFilters.test.tsx => SubmittedDataFilters.test.tsx} (83%) rename src/components/DataSubmissions/{DataContentFilters.tsx => SubmittedDataFilters.tsx} (94%) rename src/content/dataSubmissions/{DataContent.test.tsx => SubmittedData.test.tsx} (92%) rename src/content/dataSubmissions/{DataContent.tsx => SubmittedData.tsx} (90%) diff --git a/src/components/DataSubmissions/DataContentFilters.test.tsx b/src/components/DataSubmissions/SubmittedDataFilters.test.tsx similarity index 83% rename from src/components/DataSubmissions/DataContentFilters.test.tsx rename to src/components/DataSubmissions/SubmittedDataFilters.test.tsx index f74d8ab0e..684e7eae1 100644 --- a/src/components/DataSubmissions/DataContentFilters.test.tsx +++ b/src/components/DataSubmissions/SubmittedDataFilters.test.tsx @@ -1,9 +1,9 @@ import { render, waitFor, within } from '@testing-library/react'; import UserEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; -import { DataContentFilters } from './DataContentFilters'; +import { SubmittedDataFilters } from './SubmittedDataFilters'; -describe("DataContentFilters cases", () => { +describe("SubmittedDataFilters cases", () => { const baseStatistic: SubmissionStatistic = { nodeName: "", total: 0, @@ -15,7 +15,7 @@ describe("DataContentFilters cases", () => { it("should not have accessibility violations", async () => { const { container } = render( - + ); const results = await axe(container); @@ -24,7 +24,7 @@ describe("DataContentFilters cases", () => { }); it("should handle an empty array of node types without errors", async () => { - expect(() => render()).not.toThrow(); + expect(() => render()).not.toThrow(); }); // NOTE: The sorting function `compareNodeStats` is already heavily tested, this is just a sanity check @@ -35,7 +35,7 @@ describe("DataContentFilters cases", () => { { ...baseStatistic, nodeName: "N-2", total: 2 }, ]; - const { getByTestId } = render(); + const { getByTestId } = render(); const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); @@ -58,7 +58,7 @@ describe("DataContentFilters cases", () => { { ...baseStatistic, nodeName: "THIRD", total: 1 }, ]; - const { getByTestId } = render(); + const { getByTestId } = render(); const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); expect(muiSelectBox).toHaveTextContent("FIRST"); @@ -71,10 +71,10 @@ describe("DataContentFilters cases", () => { { ...baseStatistic, nodeName: "THIRD", total: 1 }, ]; - const { getByTestId, rerender } = render(); + const { getByTestId, rerender } = render(); const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); - rerender(); + rerender(); expect(muiSelectBox).toHaveTextContent("FIRST-NODE"); }); @@ -86,7 +86,7 @@ describe("DataContentFilters cases", () => { { ...baseStatistic, nodeName: "THIRD", total: 1 }, ]; - const { getByTestId, rerender } = render(); + const { getByTestId, rerender } = render(); const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); await waitFor(() => { @@ -105,7 +105,7 @@ describe("DataContentFilters cases", () => { { ...baseStatistic, nodeName: "NEW-FIRST", total: 999 }, ]; - rerender(); + rerender(); await waitFor(() => { // Verify the 3rd option is still selected diff --git a/src/components/DataSubmissions/DataContentFilters.tsx b/src/components/DataSubmissions/SubmittedDataFilters.tsx similarity index 94% rename from src/components/DataSubmissions/DataContentFilters.tsx rename to src/components/DataSubmissions/SubmittedDataFilters.tsx index 80ec3b0ca..e27020e64 100644 --- a/src/components/DataSubmissions/DataContentFilters.tsx +++ b/src/components/DataSubmissions/SubmittedDataFilters.tsx @@ -4,7 +4,7 @@ import { cloneDeep } from 'lodash'; import { Box, FormControl, MenuItem, Select, styled } from '@mui/material'; import { compareNodeStats } from '../../utils'; -export type DataContentFiltersProps = { +export type SubmittedDataFiltersProps = { statistics: SubmissionStatistic[]; onChange?: (data: FilterForm) => void; }; @@ -65,7 +65,7 @@ const baseTextFieldStyles = { const StyledSelect = styled(Select)(baseTextFieldStyles); -export const DataContentFilters: FC = ({ statistics, onChange }: DataContentFiltersProps) => { +export const SubmittedDataFilters: FC = ({ statistics, onChange }: SubmittedDataFiltersProps) => { const { watch, setValue, getValues, control } = useForm(); const nodeTypes = useMemo(() => cloneDeep(statistics) diff --git a/src/content/dataSubmissions/DataSubmission.tsx b/src/content/dataSubmissions/DataSubmission.tsx index 13cf50906..ac3cbf4e2 100644 --- a/src/content/dataSubmissions/DataSubmission.tsx +++ b/src/content/dataSubmissions/DataSubmission.tsx @@ -44,7 +44,7 @@ import FileListDialog from "./FileListDialog"; import { shouldDisableSubmit } from "../../utils/dataSubmissionUtils"; import usePageTitle from '../../hooks/usePageTitle'; import BackButton from "../../components/DataSubmissions/BackButton"; -import DataContent from './DataContent'; +import SubmittedData from './SubmittedData'; const StyledBanner = styled("div")(({ bannerSrc }: { bannerSrc: string }) => ({ background: `url(${bannerSrc})`, @@ -325,7 +325,7 @@ const columns: Column[] = [ const URLTabs = { DATA_ACTIVITY: "data-activity", VALIDATION_RESULTS: "validation-results", - DATA_CONTENT: "data-content", + SUBMITTED_DATA: "submitted-data", }; const submissionLockedStatuses: SubmissionStatus[] = ["Submitted", "Released", "Completed", "Canceled", "Archived"]; @@ -570,10 +570,10 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } selected={tab === URLTabs.VALIDATION_RESULTS} /> @@ -596,8 +596,8 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } {tab === URLTabs.VALIDATION_RESULTS && ( )} - {tab === URLTabs.DATA_CONTENT && ( - + {tab === URLTabs.SUBMITTED_DATA && ( + )} {/* Return to Data Submission List Button */} diff --git a/src/content/dataSubmissions/DataContent.test.tsx b/src/content/dataSubmissions/SubmittedData.test.tsx similarity index 92% rename from src/content/dataSubmissions/DataContent.test.tsx rename to src/content/dataSubmissions/SubmittedData.test.tsx index b9674e635..0f32eef64 100644 --- a/src/content/dataSubmissions/DataContent.test.tsx +++ b/src/content/dataSubmissions/SubmittedData.test.tsx @@ -3,7 +3,7 @@ import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { GraphQLError } from 'graphql'; import { axe } from 'jest-axe'; import { render, waitFor } from '@testing-library/react'; -import DataContent from './DataContent'; +import SubmittedData from './SubmittedData'; import { mockEnqueue } from '../../setupTests'; import { GET_SUBMISSION_NODES, GetSubmissionNodesResp } from '../../graphql'; @@ -18,7 +18,7 @@ const TestParent: FC = ({ mocks, children } : ParentProps) => ( ); -describe("DataContent > General", () => { +describe("SubmittedData > General", () => { const baseSubmissionStatistic: SubmissionStatistic = { nodeName: '', total: 0, @@ -35,7 +35,7 @@ describe("DataContent > General", () => { it("should not have any high level accessibility violations", async () => { const { container } = render( - + ); @@ -45,7 +45,7 @@ describe("DataContent > General", () => { it("should show an error message when no submission ID is provided", async () => { render( - + ); @@ -75,7 +75,7 @@ describe("DataContent > General", () => { render( - + ); @@ -107,7 +107,7 @@ describe("DataContent > General", () => { render( - + ); @@ -117,7 +117,7 @@ describe("DataContent > General", () => { }); }); -describe("DataContent > Table", () => { +describe("SubmittedData > Table", () => { const baseSubmissionStatistic: SubmissionStatistic = { nodeName: '', total: 0, @@ -160,7 +160,7 @@ describe("DataContent > Table", () => { const { getByText } = render( - + ); @@ -204,7 +204,7 @@ describe("DataContent > Table", () => { const { getByTestId, getByText } = render( - + ); @@ -255,7 +255,7 @@ describe("DataContent > Table", () => { const { getByTestId, getByText } = render( - + ); @@ -294,7 +294,7 @@ describe("DataContent > Table", () => { const { getByTestId } = render( - + ); diff --git a/src/content/dataSubmissions/DataContent.tsx b/src/content/dataSubmissions/SubmittedData.tsx similarity index 90% rename from src/content/dataSubmissions/DataContent.tsx rename to src/content/dataSubmissions/SubmittedData.tsx index 29fc2c91d..ee704696b 100644 --- a/src/content/dataSubmissions/DataContent.tsx +++ b/src/content/dataSubmissions/SubmittedData.tsx @@ -4,7 +4,7 @@ import { isEqual } from "lodash"; import { useSnackbar } from 'notistack'; import { GET_SUBMISSION_NODES, GetSubmissionNodesResp } from "../../graphql"; import GenericTable, { Column, FetchListing, TableMethods } from "../../components/DataSubmissions/GenericTable"; -import { DataContentFilters, FilterForm } from '../../components/DataSubmissions/DataContentFilters'; +import { SubmittedDataFilters, FilterForm } from '../../components/DataSubmissions/SubmittedDataFilters'; import { safeParse } from '../../utils'; type T = Pick & { @@ -16,13 +16,13 @@ type Props = { statistics: SubmissionStatistic[]; }; -const DataContent: FC = ({ submissionId, statistics }) => { +const SubmittedData: FC = ({ submissionId, statistics }) => { const { enqueueSnackbar } = useSnackbar(); const tableRef = useRef(null); const filterRef = useRef({ nodeType: "" }); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [columns, setColumns] = useState[]>([]); const [data, setData] = useState([]); const [prevListing, setPrevListing] = useState>(null); @@ -92,7 +92,7 @@ const DataContent: FC = ({ submissionId, statistics }) => { return ( <> - + = ({ submissionId, statistics }) => { ); }; -export default DataContent; +export default SubmittedData; From 76d839c34c2ba160f6e2067f7c678ebaff8fefc2 Mon Sep 17 00:00:00 2001 From: Alec M Date: Wed, 27 Mar 2024 10:24:44 -0400 Subject: [PATCH 21/34] fix: Node filter not updating on data upload Migrate submissionStats query to filters --- package-lock.json | 1 + .../SubmittedDataFilters.test.tsx | 186 +++++++---- .../DataSubmissions/SubmittedDataFilters.tsx | 42 ++- .../dataSubmissions/DataSubmission.tsx | 2 +- .../dataSubmissions/SubmittedData.test.tsx | 296 ++++++++++-------- src/content/dataSubmissions/SubmittedData.tsx | 7 +- src/graphql/getSubmission.ts | 19 -- src/graphql/index.ts | 3 + src/graphql/submissionStats.ts | 25 ++ 9 files changed, 359 insertions(+), 222 deletions(-) create mode 100644 src/graphql/submissionStats.ts diff --git a/package-lock.json b/package-lock.json index b23b2dd7e..5bacf7b04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17452,6 +17452,7 @@ }, "node_modules/typescript": { "version": "5.1.3", + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/src/components/DataSubmissions/SubmittedDataFilters.test.tsx b/src/components/DataSubmissions/SubmittedDataFilters.test.tsx index 684e7eae1..b134ded46 100644 --- a/src/components/DataSubmissions/SubmittedDataFilters.test.tsx +++ b/src/components/DataSubmissions/SubmittedDataFilters.test.tsx @@ -1,7 +1,21 @@ +import { FC } from 'react'; import { render, waitFor, within } from '@testing-library/react'; import UserEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; import { SubmittedDataFilters } from './SubmittedDataFilters'; +import { SUBMISSION_STATS, SubmissionStatsResp } from '../../graphql'; + +type ParentProps = { + mocks?: MockedResponse[]; + children: React.ReactNode; +}; + +const TestParent: FC = ({ mocks, children } : ParentProps) => ( + + {children} + +); describe("SubmittedDataFilters cases", () => { const baseStatistic: SubmissionStatistic = { @@ -15,27 +29,65 @@ describe("SubmittedDataFilters cases", () => { it("should not have accessibility violations", async () => { const { container } = render( - + + + ); - const results = await axe(container); - - expect(results).toHaveNoViolations(); + expect(await axe(container)).toHaveNoViolations(); }); it("should handle an empty array of node types without errors", async () => { - expect(() => render()).not.toThrow(); + const _id = "example-empty-results"; + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_STATS, + variables: { id: _id }, + }, + result: { + data: { + submissionStats: { + stats: [], + }, + }, + }, + }]; + + expect(() => render( + + + + )).not.toThrow(); }); // NOTE: The sorting function `compareNodeStats` is already heavily tested, this is just a sanity check it("should sort the node types by count in descending order", async () => { - const stats: SubmissionStatistic[] = [ - { ...baseStatistic, nodeName: "N-3", total: 1 }, - { ...baseStatistic, nodeName: "N-1", total: 3 }, - { ...baseStatistic, nodeName: "N-2", total: 2 }, - ]; - - const { getByTestId } = render(); + const _id = "example-sorting-by-count-id"; + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_STATS, + variables: { + id: _id + }, + }, + result: { + data: { + submissionStats: { + stats: [ + { ...baseStatistic, nodeName: "N-3", total: 1 }, + { ...baseStatistic, nodeName: "N-1", total: 3 }, + { ...baseStatistic, nodeName: "N-2", total: 2 }, + ], + }, + }, + }, + }]; + + const { getByTestId } = render( + + + + ); const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); @@ -52,64 +104,86 @@ describe("SubmittedDataFilters cases", () => { }); it("should select the first sorted node type in the by default", async () => { - const stats: SubmissionStatistic[] = [ - { ...baseStatistic, nodeName: "SECOND", total: 3 }, - { ...baseStatistic, nodeName: "FIRST", total: 999 }, - { ...baseStatistic, nodeName: "THIRD", total: 1 }, - ]; + const _id = "example-select-first-node-id"; + const mocks: MockedResponse[] = [{ + request: { + query: SUBMISSION_STATS, + variables: { + id: _id + }, + }, + result: { + data: { + submissionStats: { + stats: [ + { ...baseStatistic, nodeName: "SECOND", total: 3 }, + { ...baseStatistic, nodeName: "FIRST", total: 999 }, + { ...baseStatistic, nodeName: "THIRD", total: 1 }, + ], + }, + }, + }, + }]; + + const { getByTestId } = render( + + + + ); - const { getByTestId } = render(); const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); - expect(muiSelectBox).toHaveTextContent("FIRST"); + await waitFor(() => expect(muiSelectBox).toHaveTextContent("FIRST")); }); - it("should update the empty selection when the node types are populated", async () => { - const stats: SubmissionStatistic[] = [ - { ...baseStatistic, nodeName: "FIRST-NODE", total: 999 }, - { ...baseStatistic, nodeName: "SECOND", total: 3 }, - { ...baseStatistic, nodeName: "THIRD", total: 1 }, - ]; + // NOTE: This test no longer applies since the component fetches it's own data. + // it("should update the empty selection when the node types are populated", async () => { + // const stats: SubmissionStatistic[] = [ + // { ...baseStatistic, nodeName: "FIRST-NODE", total: 999 }, + // { ...baseStatistic, nodeName: "SECOND", total: 3 }, + // { ...baseStatistic, nodeName: "THIRD", total: 1 }, + // ]; - const { getByTestId, rerender } = render(); - const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); + // const { getByTestId, rerender } = render(); + // const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); - rerender(); + // rerender(); - expect(muiSelectBox).toHaveTextContent("FIRST-NODE"); - }); + // expect(muiSelectBox).toHaveTextContent("FIRST-NODE"); + // }); - it("should not change a NON-DEFAULT selection when the node types are updated", async () => { - const stats: SubmissionStatistic[] = [ - { ...baseStatistic, nodeName: "FIRST", total: 100 }, - { ...baseStatistic, nodeName: "SECOND", total: 2 }, - { ...baseStatistic, nodeName: "THIRD", total: 1 }, - ]; + // NOTE: This test no longer applies since the component fetches it's own data. + // it("should not change a NON-DEFAULT selection when the node types are updated", async () => { + // const stats: SubmissionStatistic[] = [ + // { ...baseStatistic, nodeName: "FIRST", total: 100 }, + // { ...baseStatistic, nodeName: "SECOND", total: 2 }, + // { ...baseStatistic, nodeName: "THIRD", total: 1 }, + // ]; - const { getByTestId, rerender } = render(); - const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); + // const { getByTestId, rerender } = render(); + // const muiSelectBox = within(getByTestId("data-content-node-filter")).getByRole("button"); - await waitFor(() => { - expect(muiSelectBox).toHaveTextContent("FIRST"); - }); + // await waitFor(() => { + // expect(muiSelectBox).toHaveTextContent("FIRST"); + // }); - // Open the dropdown - await waitFor(() => UserEvent.click(muiSelectBox)); + // // Open the dropdown + // await waitFor(() => UserEvent.click(muiSelectBox)); - // Select the 3rd option - const firstOption = getByTestId("nodeType-THIRD"); - await waitFor(() => UserEvent.click(firstOption)); + // // Select the 3rd option + // const firstOption = getByTestId("nodeType-THIRD"); + // await waitFor(() => UserEvent.click(firstOption)); - const newStats: SubmissionStatistic[] = [ - ...stats, - { ...baseStatistic, nodeName: "NEW-FIRST", total: 999 }, - ]; + // const newStats: SubmissionStatistic[] = [ + // ...stats, + // { ...baseStatistic, nodeName: "NEW-FIRST", total: 999 }, + // ]; - rerender(); + // rerender(); - await waitFor(() => { - // Verify the 3rd option is still selected - expect(muiSelectBox).toHaveTextContent("THIRD"); - }); - }); + // await waitFor(() => { + // // Verify the 3rd option is still selected + // expect(muiSelectBox).toHaveTextContent("THIRD"); + // }); + // }); }); diff --git a/src/components/DataSubmissions/SubmittedDataFilters.tsx b/src/components/DataSubmissions/SubmittedDataFilters.tsx index e27020e64..de7ea4dbf 100644 --- a/src/components/DataSubmissions/SubmittedDataFilters.tsx +++ b/src/components/DataSubmissions/SubmittedDataFilters.tsx @@ -1,11 +1,18 @@ -import { FC, useEffect, useMemo } from 'react'; -import { Controller, useForm } from 'react-hook-form'; -import { cloneDeep } from 'lodash'; -import { Box, FormControl, MenuItem, Select, styled } from '@mui/material'; -import { compareNodeStats } from '../../utils'; +import { FC, useEffect, useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { cloneDeep } from "lodash"; +import { Box, FormControl, MenuItem, Select, styled } from "@mui/material"; +import { useQuery } from "@apollo/client"; +import { compareNodeStats } from "../../utils"; +import { SUBMISSION_STATS, SubmissionStatsResp } from "../../graphql"; export type SubmittedDataFiltersProps = { - statistics: SubmissionStatistic[]; + /** + * The `_id` of the Data Submission + * + * @note The filters will not be fetched if this is not valid + */ + submissionId: string; onChange?: (data: FilterForm) => void; }; @@ -27,7 +34,7 @@ const StyledFormControl = styled(FormControl)({ minWidth: "250px", }); -const StyledInlineLabel = styled('label')({ +const StyledInlineLabel = styled("label")({ padding: "0 10px", fontWeight: "700" }); @@ -65,13 +72,20 @@ const baseTextFieldStyles = { const StyledSelect = styled(Select)(baseTextFieldStyles); -export const SubmittedDataFilters: FC = ({ statistics, onChange }: SubmittedDataFiltersProps) => { +export const SubmittedDataFilters: FC = ({ submissionId, onChange }: SubmittedDataFiltersProps) => { const { watch, setValue, getValues, control } = useForm(); - const nodeTypes = useMemo(() => cloneDeep(statistics) + const { data } = useQuery(SUBMISSION_STATS, { + variables: { id: submissionId, }, + context: { clientName: "backend" }, + skip: !submissionId, + fetchPolicy: "cache-and-network" + }); + + const nodeTypes = useMemo(() => cloneDeep(data?.submissionStats?.stats) ?.sort(compareNodeStats) ?.reverse() - ?.map((stat) => stat.nodeName), [statistics]); + ?.map((stat) => stat.nodeName), [data?.submissionStats?.stats]); useEffect(() => { if (!!watch("nodeType") || !nodeTypes?.length) { @@ -102,7 +116,13 @@ export const SubmittedDataFilters: FC = ({ statistics data-testid="data-content-node-filter" > {nodeTypes?.map((nodeType) => ( - {nodeType} + + {nodeType} + ))} )} diff --git a/src/content/dataSubmissions/DataSubmission.tsx b/src/content/dataSubmissions/DataSubmission.tsx index ac3cbf4e2..474ea316c 100644 --- a/src/content/dataSubmissions/DataSubmission.tsx +++ b/src/content/dataSubmissions/DataSubmission.tsx @@ -597,7 +597,7 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } )} {tab === URLTabs.SUBMITTED_DATA && ( - + )} {/* Return to Data Submission List Button */} diff --git a/src/content/dataSubmissions/SubmittedData.test.tsx b/src/content/dataSubmissions/SubmittedData.test.tsx index 0f32eef64..fc5b5efc1 100644 --- a/src/content/dataSubmissions/SubmittedData.test.tsx +++ b/src/content/dataSubmissions/SubmittedData.test.tsx @@ -5,7 +5,7 @@ import { axe } from 'jest-axe'; import { render, waitFor } from '@testing-library/react'; import SubmittedData from './SubmittedData'; import { mockEnqueue } from '../../setupTests'; -import { GET_SUBMISSION_NODES, GetSubmissionNodesResp } from '../../graphql'; +import { GET_SUBMISSION_NODES, SUBMISSION_STATS } from '../../graphql'; type ParentProps = { mocks?: MockedResponse[]; @@ -28,6 +28,20 @@ describe("SubmittedData > General", () => { error: 0 }; + const mockSubmissionQuery = { + request: { + query: SUBMISSION_STATS, + }, + variableMatcher: () => true, + result: { + data: { + submissionStats: { + stats: [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }], + }, + }, + }, + }; + afterEach(() => { jest.clearAllMocks(); }); @@ -35,7 +49,7 @@ describe("SubmittedData > General", () => { it("should not have any high level accessibility violations", async () => { const { container } = render( - + ); @@ -45,7 +59,7 @@ describe("SubmittedData > General", () => { it("should show an error message when no submission ID is provided", async () => { render( - + ); @@ -57,25 +71,26 @@ describe("SubmittedData > General", () => { it("should show an error message when the nodes cannot be fetched (network)", async () => { const submissionID = "example-sub-id-1"; - const mocks: MockedResponse[] = [{ - request: { - query: GET_SUBMISSION_NODES, - variables: { - _id: submissionID, - sortDirection: "desc", - first: 20, - offset: 0, - nodeTypes: ["example-node"], + const mocks: MockedResponse[] = [ + mockSubmissionQuery, + { + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeType: "example-node", + }, }, - }, - error: new Error('Simulated network error'), - }]; - - const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + error: new Error('Simulated network error'), + } + ]; render( - + ); @@ -87,27 +102,28 @@ describe("SubmittedData > General", () => { it("should show an error message when the nodes cannot be fetched (GraphQL)", async () => { const submissionID = "example-sub-id-2"; - const mocks: MockedResponse[] = [{ - request: { - query: GET_SUBMISSION_NODES, - variables: { - _id: submissionID, - sortDirection: "desc", - first: 20, - offset: 0, - nodeTypes: ["example-node"], + const mocks: MockedResponse[] = [ + mockSubmissionQuery, + { + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeType: "example-node", + }, }, - }, - result: { - errors: [new GraphQLError('Simulated GraphQL error')], - }, - }]; - - const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + result: { + errors: [new GraphQLError('Simulated GraphQL error')], + }, + } + ]; render( - + ); @@ -127,6 +143,20 @@ describe("SubmittedData > Table", () => { error: 0 }; + const mockSubmissionQuery = { + request: { + query: SUBMISSION_STATS, + }, + variableMatcher: () => true, + result: { + data: { + submissionStats: { + stats: [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }], + }, + }, + }, + }; + afterEach(() => { jest.clearAllMocks(); }); @@ -134,33 +164,34 @@ describe("SubmittedData > Table", () => { it("should render the placeholder text when no data is available", async () => { const submissionID = "example-placeholder-test-id"; - const mocks: MockedResponse[] = [{ - request: { - query: GET_SUBMISSION_NODES, - variables: { - _id: submissionID, - sortDirection: "desc", - first: 20, - offset: 0, - nodeTypes: ["example-node"], + const mocks: MockedResponse[] = [ + mockSubmissionQuery, + { + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeType: "example-node", + }, }, - }, - result: { - data: { - getSubmissionNodes: { - total: 0, - properties: [], - nodes: [], + result: { + data: { + getSubmissionNodes: { + total: 0, + properties: [], + nodes: [], + }, }, }, - }, - }]; - - const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + } + ]; const { getByText } = render( - + ); @@ -172,39 +203,40 @@ describe("SubmittedData > Table", () => { it("should render dynamic columns based on the selected node properties", async () => { const submissionID = "example-dynamic-columns-id"; - const mocks: MockedResponse[] = [{ - request: { - query: GET_SUBMISSION_NODES, - variables: { - _id: submissionID, - sortDirection: "desc", - first: 20, - offset: 0, - nodeTypes: ["example-node"], + const mocks: MockedResponse[] = [ + mockSubmissionQuery, + { + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeType: "example-node", + }, }, - }, - result: { - data: { - getSubmissionNodes: { - total: 2, - properties: ["col.1", "col.2", "col.3"], - nodes: [ - { - nodeType: "example-node", - nodeID: "example-node-id", - props: JSON.stringify({ "col.1": "value-1", "col.2": "value-2", "col.3": "value-3" }), - }, - ], + result: { + data: { + getSubmissionNodes: { + total: 2, + properties: ["col.1", "col.2", "col.3"], + nodes: [ + { + nodeType: "example-node", + nodeID: "example-node-id", + props: JSON.stringify({ "col.1": "value-1", "col.2": "value-2", "col.3": "value-3" }), + }, + ], + }, }, }, - }, - }]; - - const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + } + ]; const { getByTestId, getByText } = render( - + ); @@ -223,39 +255,40 @@ describe("SubmittedData > Table", () => { it("should NOT build the columns based off of the nodes.[X].props JSON object", async () => { const submissionID = "example-using-properties-dynamic-columns-id"; - const mocks: MockedResponse[] = [{ - request: { - query: GET_SUBMISSION_NODES, - variables: { - _id: submissionID, - sortDirection: "desc", - first: 20, - offset: 0, - nodeTypes: ["example-node"], + const mocks: MockedResponse[] = [ + mockSubmissionQuery, + { + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeType: "example-node", + }, }, - }, - result: { - data: { - getSubmissionNodes: { - total: 2, - properties: ["good-col-1", "good-col-2"], - nodes: [ - { - nodeType: "example-node", - nodeID: "example-node-id", - props: JSON.stringify({ "good-col-1": "ok", "good-col-2": "ok", "bad-column": "bad" }), - }, - ], + result: { + data: { + getSubmissionNodes: { + total: 2, + properties: ["good-col-1", "good-col-2"], + nodes: [ + { + nodeType: "example-node", + nodeID: "example-node-id", + props: JSON.stringify({ "good-col-1": "ok", "good-col-2": "ok", "bad-column": "bad" }), + }, + ], + }, }, }, - }, - }]; - - const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + } + ]; const { getByTestId, getByText } = render( - + ); @@ -268,33 +301,34 @@ describe("SubmittedData > Table", () => { it("should have a default pagination count of 20 rows per page", async () => { const submissionID = "example-pagination-default-test-id"; - const mocks: MockedResponse[] = [{ - request: { - query: GET_SUBMISSION_NODES, - variables: { - _id: submissionID, - sortDirection: "desc", - first: 20, - offset: 0, - nodeTypes: ["example-node"], + const mocks: MockedResponse[] = [ + mockSubmissionQuery, + { + request: { + query: GET_SUBMISSION_NODES, + variables: { + _id: submissionID, + sortDirection: "desc", + first: 20, + offset: 0, + nodeType: "example-node", + }, }, - }, - result: { - data: { - getSubmissionNodes: { - total: 0, - properties: [], - nodes: [], + result: { + data: { + getSubmissionNodes: { + total: 0, + properties: [], + nodes: [], + }, }, }, - }, - }]; - - const stats: SubmissionStatistic[] = [{ ...baseSubmissionStatistic, nodeName: "example-node", total: 1 }]; + } + ]; const { getByTestId } = render( - + ); diff --git a/src/content/dataSubmissions/SubmittedData.tsx b/src/content/dataSubmissions/SubmittedData.tsx index ee704696b..04737ef02 100644 --- a/src/content/dataSubmissions/SubmittedData.tsx +++ b/src/content/dataSubmissions/SubmittedData.tsx @@ -13,10 +13,9 @@ type T = Pick & { type Props = { submissionId: string; - statistics: SubmissionStatistic[]; }; -const SubmittedData: FC = ({ submissionId, statistics }) => { +const SubmittedData: FC = ({ submissionId }) => { const { enqueueSnackbar } = useSnackbar(); const tableRef = useRef(null); @@ -58,7 +57,7 @@ const SubmittedData: FC = ({ submissionId, statistics }) => { offset, sortDirection, orderBy, - nodeTypes: [filterRef.current.nodeType], + nodeType: filterRef.current.nodeType, }, context: { clientName: 'backend' }, fetchPolicy: 'no-cache' @@ -92,7 +91,7 @@ const SubmittedData: FC = ({ submissionId, statistics }) => { return ( <> - + Date: Wed, 27 Mar 2024 11:15:25 -0400 Subject: [PATCH 22/34] revert f8fefc2 --- src/graphql/getSubmission.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/graphql/getSubmission.ts b/src/graphql/getSubmission.ts index 586b2d8d6..c746f027f 100644 --- a/src/graphql/getSubmission.ts +++ b/src/graphql/getSubmission.ts @@ -20,6 +20,25 @@ export const query = gql` status metadataValidationStatus fileValidationStatus + fileErrors { + submissionID + type + validationType + batchID + displayID + submittedID + severity + uploadedDate + validatedDate + errors { + title + description + } + warnings { + title + description + } + } history { status reviewComment From 006150a851ec40942751982cfac40410aee99666 Mon Sep 17 00:00:00 2001 From: Alec M Date: Wed, 27 Mar 2024 14:51:26 -0400 Subject: [PATCH 23/34] fix: Sorting direction not persistent --- src/content/dataSubmissions/SubmittedData.tsx | 22 +++++++++++++------ src/graphql/getSubmissionNodes.ts | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/content/dataSubmissions/SubmittedData.tsx b/src/content/dataSubmissions/SubmittedData.tsx index 04737ef02..24020de0f 100644 --- a/src/content/dataSubmissions/SubmittedData.tsx +++ b/src/content/dataSubmissions/SubmittedData.tsx @@ -20,6 +20,7 @@ const SubmittedData: FC = ({ submissionId }) => { const tableRef = useRef(null); const filterRef = useRef({ nodeType: "" }); + const prevFilterRef = useRef({ nodeType: "" }); const [loading, setLoading] = useState(false); const [columns, setColumns] = useState[]>([]); @@ -69,18 +70,25 @@ const SubmittedData: FC = ({ submissionId }) => { return; } - setTotalData(d.getSubmissionNodes.total); + // Only update columns if the nodeType has changed + if (prevFilterRef.current.nodeType !== filterRef.current.nodeType) { + setTotalData(d.getSubmissionNodes.total); + setColumns(d.getSubmissionNodes.properties.map((prop: string, index: number) => ({ + label: prop, + renderValue: (d) => d?.props?.[prop] || "", + // NOTE: prop is not actually a keyof T, but it's a value of prop.props + field: prop as unknown as keyof T, + default: index === 0 ? true : undefined, + }))); + + prevFilterRef.current = filterRef.current; + } + setData(d.getSubmissionNodes.nodes.map((node) => ({ nodeType: node.nodeType, nodeID: node.nodeID, props: safeParse(node.props), }))); - setColumns(d.getSubmissionNodes.properties.map((prop) => ({ - label: prop, - renderValue: (d) => d?.props?.[prop] || "", - field: prop as keyof T, // TODO: fix this hack - default: true - }))); setLoading(false); }; diff --git a/src/graphql/getSubmissionNodes.ts b/src/graphql/getSubmissionNodes.ts index fda72baca..ecf7c677b 100644 --- a/src/graphql/getSubmissionNodes.ts +++ b/src/graphql/getSubmissionNodes.ts @@ -2,7 +2,7 @@ import gql from "graphql-tag"; export const query = gql` query getSubmissionNodes($_id: String!, $nodeType: String!, $first: Int, $offset: Int, $orderBy: String, $sortDirection: String) { - getSubmissionNodes(_id: $_id, nodeType: $nodeType, first: $first, offset: $offset, orderBy: $orderBy, sortDirection: $sortDirection) { + getSubmissionNodes(submissionID: $_id, nodeType: $nodeType, first: $first, offset: $offset, orderBy: $orderBy, sortDirection: $sortDirection) { total properties nodes { From 6e88875463c36fb9b274a26e2acc2a46145db6fb Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 28 Mar 2024 10:47:04 -0400 Subject: [PATCH 24/34] Migrate button to table element --- .../ExportValidationButton.test.tsx | 28 ++++++------- .../ExportValidationButton.tsx | 25 ++++++----- .../DataSubmissions/GenericTable.tsx | 5 ++- .../DataSubmissions/PaginationActions.tsx | 42 ++++++++++++------- .../dataSubmissions/DataSubmission.tsx | 9 +--- .../dataSubmissions/DataSubmissionActions.tsx | 21 +--------- .../dataSubmissions/QualityControl.tsx | 14 ++++++- 7 files changed, 76 insertions(+), 68 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index fa9501260..3039a5b27 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -105,7 +105,7 @@ describe('ExportValidationButton cases', () => { }, }]; - const { getByText } = render( + const { getByTestId } = render( @@ -114,7 +114,7 @@ describe('ExportValidationButton cases', () => { expect(called).toBe(false); // NOTE: This must be separate from the expect below to ensure its not called multiple times - await waitFor(() => UserEvent.click(getByText('Download QC Results'))); + await waitFor(() => UserEvent.click(getByTestId('export-validation-button'))); await waitFor(() => { expect(called).toBe(true); }); @@ -159,14 +159,14 @@ describe('ExportValidationButton cases', () => { ID: jest.fn().mockImplementation((result: QCResult) => result.submissionID), }; - const { getByText } = render( + const { getByTestId } = render( ); act(() => { - fireEvent.click(getByText('Download QC Results')); + fireEvent.click(getByTestId('export-validation-button')); }); await waitFor(() => { @@ -202,14 +202,14 @@ describe('ExportValidationButton cases', () => { }, }]; - const { getByText } = render( + const { getByTestId } = render( ); act(() => { - fireEvent.click(getByText('Download QC Results')); + fireEvent.click(getByTestId('export-validation-button')); }); await waitFor(() => { @@ -261,14 +261,14 @@ describe('ExportValidationButton cases', () => { NullValueField: jest.fn().mockImplementation(() => null), }; - const { getByText } = render( + const { getByTestId } = render( ); act(() => { - fireEvent.click(getByText('Download QC Results')); + fireEvent.click(getByTestId('export-validation-button')); }); await waitFor(() => { @@ -295,14 +295,14 @@ describe('ExportValidationButton cases', () => { error: new Error('Simulated network error'), }]; - const { getByText } = render( + const { getByTestId } = render( ); act(() => { - fireEvent.click(getByText('Download QC Results')); + fireEvent.click(getByTestId('export-validation-button')); }); await waitFor(() => { @@ -329,14 +329,14 @@ describe('ExportValidationButton cases', () => { }, }]; - const { getByText } = render( + const { getByTestId } = render( ); act(() => { - fireEvent.click(getByText('Download QC Results')); + fireEvent.click(getByTestId('export-validation-button')); }); await waitFor(() => { @@ -372,14 +372,14 @@ describe('ExportValidationButton cases', () => { }, }]; - const { getByText } = render( + const { getByTestId } = render( ); act(() => { - fireEvent.click(getByText('Download QC Results')); + fireEvent.click(getByTestId('export-validation-button')); }); await waitFor(() => { diff --git a/src/components/DataSubmissions/ExportValidationButton.tsx b/src/components/DataSubmissions/ExportValidationButton.tsx index df3d1abdc..7cc2d415a 100644 --- a/src/components/DataSubmissions/ExportValidationButton.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useLazyQuery } from '@apollo/client'; -import { LoadingButton } from '@mui/lab'; -import { ButtonProps } from '@mui/material'; +import { IconButtonProps, IconButton, styled } from '@mui/material'; +import { CloudDownload } from '@mui/icons-material'; import { useSnackbar } from 'notistack'; import dayjs from 'dayjs'; import { unparse } from 'papaparse'; @@ -21,14 +21,19 @@ export type Props = { * @example { "Batch ID": (d) => d.displayID } */ fields: Record string | number>; -} & ButtonProps; +} & IconButtonProps; + +const StyledIconButton = styled(IconButton)({ + color: "#606060", + marginRight: "38px", +}); /** * Provides the button and supporting functionality to export the validation results of a submission. * * @returns {React.FC} The export validation button. */ -export const ExportValidationButton: React.FC = ({ submission, fields, ...buttonProps }: Props) => { +export const ExportValidationButton: React.FC = ({ submission, fields, disabled, ...buttonProps }: Props) => { const { enqueueSnackbar } = useSnackbar(); const [loading, setLoading] = useState(false); @@ -89,14 +94,14 @@ export const ExportValidationButton: React.FC = ({ submission, fields, .. }; return ( - - Download QC Results - + + ); }; diff --git a/src/components/DataSubmissions/GenericTable.tsx b/src/components/DataSubmissions/GenericTable.tsx index 3508f8eda..bfdd8caae 100644 --- a/src/components/DataSubmissions/GenericTable.tsx +++ b/src/components/DataSubmissions/GenericTable.tsx @@ -156,6 +156,7 @@ type Props = { paginationPlacement?: CSSProperties["justifyContent"]; containerProps?: TableContainerProps; numRowsNoContent?: number; + AdditionalActions?: React.ReactNode; setItemKey?: (item: T, index: number) => string; onFetchData?: (params: FetchListing, force: boolean) => void; onOrderChange?: (order: Order) => void; @@ -174,6 +175,7 @@ const GenericTable = ({ paginationPlacement, containerProps, numRowsNoContent = 10, + AdditionalActions, setItemKey, onFetchData, onOrderChange, @@ -330,7 +332,8 @@ const GenericTable = ({ }} SelectProps={{ inputProps: { "aria-label": "rows per page" }, native: true }} backIconButtonProps={{ disabled: page === 0 || loading }} - ActionsComponent={PaginationActions} + // eslint-disable-next-line react/no-unstable-nested-components + ActionsComponent={(props) => } /> ); diff --git a/src/components/DataSubmissions/PaginationActions.tsx b/src/components/DataSubmissions/PaginationActions.tsx index 43110248b..dc10e1e5c 100644 --- a/src/components/DataSubmissions/PaginationActions.tsx +++ b/src/components/DataSubmissions/PaginationActions.tsx @@ -1,3 +1,4 @@ +import { FC } from 'react'; import { Pagination, PaginationItem, @@ -6,7 +7,7 @@ import { styled, } from "@mui/material"; -const StyledPagination = styled(Pagination)(() => ({ +const StyledPagination = styled(Pagination)({ marginLeft: "23px", marginRight: "30.59px", "& .MuiPagination-ul": { @@ -38,7 +39,8 @@ const StyledPagination = styled(Pagination)(() => ({ "& .MuiPagination-ul li:nth-last-of-type(2) .MuiPaginationItem-page": { borderRight: "1px solid #415B88", }, -})); +}); + const StyledPaginationItem = styled(PaginationItem)(({ selected }) => ({ color: selected ? "#004187" : "#415B88", fontWeight: selected ? 700 : 400, @@ -47,23 +49,33 @@ const StyledPaginationItem = styled(PaginationItem)(({ selected }) => ({ }, })); -const PaginationActions = ({ +export type CustomPaginationActionsProps = { + /** + * An optional prop to render additional action components. + */ + AdditionalActions?: React.ReactNode; +} & TablePaginationProps; + +const PaginationActions: FC = ({ count, page, - // onChange, rowsPerPage, + AdditionalActions, onPageChange, -}: TablePaginationProps) => ( - ( - - )} - /> +}: CustomPaginationActionsProps) => ( + <> + ( + + )} + /> + {AdditionalActions} + ); export default PaginationActions; diff --git a/src/content/dataSubmissions/DataSubmission.tsx b/src/content/dataSubmissions/DataSubmission.tsx index f5ae474fa..c62163c4c 100644 --- a/src/content/dataSubmissions/DataSubmission.tsx +++ b/src/content/dataSubmissions/DataSubmission.tsx @@ -33,7 +33,7 @@ import DataSubmissionSummary from "../../components/DataSubmissions/DataSubmissi import GenericTable, { Column, FetchListing, TableMethods } from "../../components/DataSubmissions/GenericTable"; import { FormatDate } from "../../utils"; import DataSubmissionActions from "./DataSubmissionActions"; -import QualityControl, { csvColumns } from "./QualityControl"; +import QualityControl from "./QualityControl"; import { ReactComponent as CopyIconSvg } from "../../assets/icons/copy_icon_2.svg"; import ErrorDialog from "./ErrorDialog"; import BatchTableContext from "./Contexts/BatchTableContext"; @@ -583,7 +583,7 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } containerProps={{ sx: { marginBottom: "8px" } }} /> - ) : } + ) : } {/* Return to Data Submission List Button */} @@ -597,11 +597,6 @@ const DataSubmission: FC = ({ submissionId, tab = URLTabs.DATA_ACTIVITY } disable: submitInfo?.disable, label: submitInfo?.isAdminOverride ? "Admin Submit" : "Submit", }} - exportActionButton={{ - fields: csvColumns, - disabled: !data?.totalQCResults?.total, - visible: tab === URLTabs.VALIDATION_RESULTS, - }} onError={(message: string) => enqueueSnackbar(message, { variant: "error" })} /> diff --git a/src/content/dataSubmissions/DataSubmissionActions.tsx b/src/content/dataSubmissions/DataSubmissionActions.tsx index 231793201..a4c9b4f4b 100644 --- a/src/content/dataSubmissions/DataSubmissionActions.tsx +++ b/src/content/dataSubmissions/DataSubmissionActions.tsx @@ -5,7 +5,6 @@ import { Button, OutlinedInput, Stack, Typography, styled } from "@mui/material" import { useAuthContext } from "../../components/Contexts/AuthContext"; import CustomDialog from "../../components/Shared/Dialog"; import { EXPORT_SUBMISSION, ExportSubmissionResp } from "../../graphql"; -import { ExportValidationButton, Props as ExportButtonProps } from '../../components/DataSubmissions/ExportValidationButton'; const StyledActionWrapper = styled(Stack)(() => ({ justifyContent: "center", @@ -125,23 +124,14 @@ type SubmitActionButton = { disable: boolean; }; -type ExportActionButton = { - disabled: boolean; - visible: boolean; - fields: ExportButtonProps["fields"]; -}; - type Props = { submission: Submission; submitActionButton: SubmitActionButton; - exportActionButton: ExportActionButton; onAction: (action: SubmissionAction, reviewComment?: string) => Promise; onError: (message: string) => void; }; -const DataSubmissionActions = ({ - submission, submitActionButton, exportActionButton, onAction, onError, -}: Props) => { +const DataSubmissionActions = ({ submission, submitActionButton, onAction, onError }: Props) => { const { user } = useAuthContext(); const [currentDialog, setCurrentDialog] = useState(null); @@ -294,15 +284,6 @@ const DataSubmissionActions = ({ ) : null} - {/* Validation Result Export Button */} - {exportActionButton.visible && ( - - )} - {/* Submit Dialog */} (d.errors?.length > 0 ? d.errors[0].description : d.warnings[0]?.description), }; -const QualityControl: FC = () => { +type Props = { + submission: Submission; +}; + +const QualityControl: FC = ({ submission }: Props) => { const { submissionId } = useParams(); const { watch, control } = useForm(); const { enqueueSnackbar } = useSnackbar(); @@ -343,6 +348,13 @@ const QualityControl: FC = () => { defaultOrder="desc" setItemKey={(item, idx) => `${idx}_${item.batchID}_${item.submittedID}`} onFetchData={handleFetchQCResults} + AdditionalActions={( + + )} /> Date: Thu, 28 Mar 2024 12:28:11 -0400 Subject: [PATCH 25/34] fix: Type error and incorrect filename formatting --- .../ExportValidationButton.test.tsx | 15 +++++++++------ .../DataSubmissions/ExportValidationButton.tsx | 3 ++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index 3039a5b27..2155fdc36 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -46,7 +46,8 @@ describe('ExportValidationButton cases', () => { conciergeName: '', conciergeEmail: '', createdAt: '', - updatedAt: '' + updatedAt: '', + intention: 'New' }; const baseQCResult: Omit = { @@ -121,13 +122,15 @@ describe('ExportValidationButton cases', () => { }); it.each<{ original: string, expected: string }>([ - { original: "A B C 1 2 3", expected: "ABC123" }, - { original: "long name".repeat(100), expected: "longname".repeat(100) }, + { original: "A B C 1 2 3", expected: "A-B-C-1-2-3" }, + { original: "long name".repeat(100), expected: "long-name".repeat(100) }, { original: "", expected: "" }, - { original: `non $alpha name $@!819`, expected: "nonalphaname819" }, + { original: `non $alpha name $@!819`, expected: "non-alpha-name-819" }, { original: " ", expected: "" }, - { original: `_-"a-b+c=d`, expected: "abcd" }, - ])("should safely name the CSV export file dynamically using submission name and export date", async ({ original, expected }) => { + { original: `_-"a-b+c=d`, expected: "-a-bcd" }, + { original: "CRDCDH-1234", expected: "CRDCDH-1234" }, + { original: "SPACE-AT-END ", expected: "SPACE-AT-END" }, + ])("should safely create the CSV filename using submission name and export date", async ({ original, expected }) => { jest.useFakeTimers().setSystemTime(new Date('2021-01-19T14:54:01Z')); const mocks: MockedResponse[] = [{ diff --git a/src/components/DataSubmissions/ExportValidationButton.tsx b/src/components/DataSubmissions/ExportValidationButton.tsx index 7cc2d415a..48b7d81e3 100644 --- a/src/components/DataSubmissions/ExportValidationButton.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.tsx @@ -70,7 +70,8 @@ export const ExportValidationButton: React.FC = ({ submission, fields, di } try { - const filename = `${filterAlphaNumeric(submission.name)}-${dayjs().format("YYYY-MM-DDTHHmmss")}.csv`; + const filteredName = filterAlphaNumeric(submission.name?.trim()?.replaceAll(" ", "-"), "-"); + const filename = `${filteredName}-${dayjs().format("YYYY-MM-DDTHHmmss")}.csv`; const unpacked = unpackQCResultSeverities(d.submissionQCResults.results); const fieldset = Object.entries(fields); const csvArray = []; From 5b787b5f4a61a8d9c40883817e4ac8fe183d9446 Mon Sep 17 00:00:00 2001 From: Alec M Date: Thu, 28 Mar 2024 16:55:22 -0400 Subject: [PATCH 26/34] fix: Incorrect exporting of mockEnqueue var --- .../DataSubmissions/ExportValidationButton.test.tsx | 9 ++++----- src/setupTests.ts | 6 +++--- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/components/DataSubmissions/ExportValidationButton.test.tsx b/src/components/DataSubmissions/ExportValidationButton.test.tsx index 2155fdc36..2ae642a99 100644 --- a/src/components/DataSubmissions/ExportValidationButton.test.tsx +++ b/src/components/DataSubmissions/ExportValidationButton.test.tsx @@ -6,7 +6,6 @@ import { GraphQLError } from 'graphql'; import { axe } from 'jest-axe'; import { ExportValidationButton } from './ExportValidationButton'; import { SUBMISSION_QC_RESULTS, SubmissionQCResultsResp } from '../../graphql'; -import { mockEnqueue } from '../../setupTests'; type ParentProps = { mocks?: MockedResponse[]; @@ -216,7 +215,7 @@ describe('ExportValidationButton cases', () => { }); await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith("There are no validation results to export.", { variant: "error" }); + expect(global.mockEnqueue).toHaveBeenCalledWith("There are no validation results to export.", { variant: "error" }); }); }); @@ -309,7 +308,7 @@ describe('ExportValidationButton cases', () => { }); await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith("Unable to retrieve submission quality control results.", { variant: "error" }); + expect(global.mockEnqueue).toHaveBeenCalledWith("Unable to retrieve submission quality control results.", { variant: "error" }); }); }); @@ -343,7 +342,7 @@ describe('ExportValidationButton cases', () => { }); await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith("Unable to retrieve submission quality control results.", { variant: "error" }); + expect(global.mockEnqueue).toHaveBeenCalledWith("Unable to retrieve submission quality control results.", { variant: "error" }); }); }); @@ -386,7 +385,7 @@ describe('ExportValidationButton cases', () => { }); await waitFor(() => { - expect(mockEnqueue).toHaveBeenCalledWith("Unable to export validation results.", { variant: "error" }); + expect(global.mockEnqueue).toHaveBeenCalledWith("Unable to export validation results.", { variant: "error" }); }); }); }); diff --git a/src/setupTests.ts b/src/setupTests.ts index 3ef9dd821..c3fe7dda1 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -5,11 +5,11 @@ import 'jest-axe/extend-expect'; * Mocks the enqueueSnackbar function from notistack for testing * * @note You must RESET all mocks after each test to avoid unexpected behavior - * @example expect(mockEnqueue).toHaveBeenCalledWith('message', { variant: 'error' }); + * @example expect(global.mockEnqueue).toHaveBeenCalledWith('message', { variant: 'error' }); * @see notistack documentation: https://notistack.com/getting-started */ -export const mockEnqueue = jest.fn(); +global.mockEnqueue = jest.fn(); jest.mock('notistack', () => ({ ...jest.requireActual('notistack'), - useSnackbar: () => ({ enqueueSnackbar: mockEnqueue }) + useSnackbar: () => ({ enqueueSnackbar: global.mockEnqueue }) })); From 5694423f1817024ff0d681c6037ecdcd213db7d2 Mon Sep 17 00:00:00 2001 From: Alec M Date: Fri, 29 Mar 2024 09:32:37 -0400 Subject: [PATCH 27/34] fix: Excel not rendering ErrorMessage descriptions properly --- src/content/dataSubmissions/QualityControl.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/content/dataSubmissions/QualityControl.tsx b/src/content/dataSubmissions/QualityControl.tsx index bff201d92..ded3a8858 100644 --- a/src/content/dataSubmissions/QualityControl.tsx +++ b/src/content/dataSubmissions/QualityControl.tsx @@ -162,7 +162,13 @@ export const csvColumns = { "Submitted Identifier": (d: QCResult) => d.submittedID, Severity: (d: QCResult) => d.severity, "Validated Date": (d: QCResult) => FormatDate(d?.validatedDate, "MM-DD-YYYY [at] hh:mm A", ""), - Issues: (d: QCResult) => (d.errors?.length > 0 ? d.errors[0].description : d.warnings[0]?.description), + Issues: (d: QCResult) => { + const value = d.errors[0].description ?? d.warnings[0]?.description; + + // NOTE: The ErrorMessage descriptions contain non-standard double quotes + // that don't render correctly in Excel. This replaces them with standard double quotes. + return value.replaceAll(/[“”‟〞"]/g, `"`); + }, }; type Props = { From 467fb35686383240b200b3d9a374b93fc48182cd Mon Sep 17 00:00:00 2001 From: Alec M Date: Fri, 29 Mar 2024 10:48:04 -0400 Subject: [PATCH 28/34] fix: Manually abort running query on fetch --- src/content/dataSubmissions/SubmittedData.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/content/dataSubmissions/SubmittedData.tsx b/src/content/dataSubmissions/SubmittedData.tsx index 24020de0f..4e0857ec6 100644 --- a/src/content/dataSubmissions/SubmittedData.tsx +++ b/src/content/dataSubmissions/SubmittedData.tsx @@ -21,6 +21,7 @@ const SubmittedData: FC = ({ submissionId }) => { const tableRef = useRef(null); const filterRef = useRef({ nodeType: "" }); const prevFilterRef = useRef({ nodeType: "" }); + const abortControllerRef = useRef(new AbortController()); const [loading, setLoading] = useState(false); const [columns, setColumns] = useState[]>([]); @@ -47,10 +48,16 @@ const SubmittedData: FC = ({ submissionId }) => { setTotalData(0); return; } + if (abortControllerRef.current && prevListing) { + abortControllerRef.current.abort(); + } setPrevListing(fetchListing); setLoading(true); + const abortController = new AbortController(); + abortControllerRef.current = abortController; + const { data: d, error } = await getSubmissionNodes({ variables: { _id: submissionId, @@ -60,11 +67,14 @@ const SubmittedData: FC = ({ submissionId }) => { orderBy, nodeType: filterRef.current.nodeType, }, - context: { clientName: 'backend' }, - fetchPolicy: 'no-cache' + context: { fetchOptions: { signal: abortController.signal } }, }); - if (error || !d?.getSubmissionNodes || !d?.getSubmissionNodes?.properties) { + if (abortController.signal.aborted) { + return; + } + + if (error || !d?.getSubmissionNodes || !d?.getSubmissionNodes?.properties?.length) { enqueueSnackbar("Unable to retrieve node data.", { variant: "error" }); setLoading(false); return; From f3d754f20286951c57bea56d28f53c7ee416713c Mon Sep 17 00:00:00 2001 From: Alec M Date: Mon, 1 Apr 2024 10:25:17 -0400 Subject: [PATCH 29/34] Move injectEnv.js to public/js --- .eslintrc.cjs | 2 +- conf/entrypoint.sh | 4 ++-- public/index.html | 2 +- public/{ => js}/injectEnv.js | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename public/{ => js}/injectEnv.js (100%) diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 24984e29d..ca4500e81 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -25,7 +25,7 @@ module.exports = { } }, root: true, - ignorePatterns: ["public/injectEnv.js", "public/js/session.js"], + ignorePatterns: ["public/js/*.js"], rules: { "react/jsx-filename-extension": [1, { extensions: [".js", ".jsx", ".tsx", ".ts"] }], "no-empty-function": "warn", diff --git a/conf/entrypoint.sh b/conf/entrypoint.sh index 70875490e..438038162 100755 --- a/conf/entrypoint.sh +++ b/conf/entrypoint.sh @@ -2,7 +2,7 @@ WWW_DIR=/usr/share/nginx/html INJECT_FILE_SRC="${WWW_DIR}/inject.template.js" -INJECT_FILE_DST="${WWW_DIR}/injectEnv.js" +INJECT_FILE_DST="${WWW_DIR}/js/injectEnv.js" envsubst < "${INJECT_FILE_SRC}" > "${INJECT_FILE_DST}" -[ -z "$@" ] && nginx -g 'daemon off;' || $@ \ No newline at end of file +[ -z "$@" ] && nginx -g 'daemon off;' || $@ diff --git a/public/index.html b/public/index.html index d97972502..d129cb850 100644 --- a/public/index.html +++ b/public/index.html @@ -14,7 +14,7 @@ CRDC DataHub - + diff --git a/public/injectEnv.js b/public/js/injectEnv.js similarity index 100% rename from public/injectEnv.js rename to public/js/injectEnv.js From 7ee0dbf435d614414828184097162165d571e035 Mon Sep 17 00:00:00 2001 From: Alec M Date: Mon, 1 Apr 2024 11:06:21 -0400 Subject: [PATCH 30/34] Move Window augmentation to separate file --- src/env.ts | 6 ------ src/types/Window.d.ts | 3 +++ 2 files changed, 3 insertions(+), 6 deletions(-) create mode 100644 src/types/Window.d.ts diff --git a/src/env.ts b/src/env.ts index 9364e6612..8983f972b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,9 +1,3 @@ -declare global { - interface Window { - injectedEnv: AppEnv; - } -} - const processEnv = process.env ?? {}; const { injectedEnv } = window ?? {}; diff --git a/src/types/Window.d.ts b/src/types/Window.d.ts new file mode 100644 index 000000000..9ed1c389e --- /dev/null +++ b/src/types/Window.d.ts @@ -0,0 +1,3 @@ +interface Window { + injectedEnv: AppEnv; +} From 5fb363a864f8780b51c11bcdfbe01614f5f8171c Mon Sep 17 00:00:00 2001 From: Alec M Date: Mon, 1 Apr 2024 11:19:01 -0400 Subject: [PATCH 31/34] fix: TypeScript not validating against injectEnv --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index efe152d76..0f5cd7382 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "esnext" ], "allowJs": true, + "checkJs": true, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, @@ -27,7 +28,6 @@ "src", ".eslintrc.cjs", "types/*.d.ts", - "conf", "public" ] -} \ No newline at end of file +} From 5bed03338e9bc0f19106fa12ee260fc781d01e93 Mon Sep 17 00:00:00 2001 From: Alec M Date: Mon, 1 Apr 2024 11:24:36 -0400 Subject: [PATCH 32/34] fix: Inject template contains unused variables --- conf/inject.template.js | 19 ------------------- tsconfig.json | 1 + 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/conf/inject.template.js b/conf/inject.template.js index f7987397d..997b04510 100644 --- a/conf/inject.template.js +++ b/conf/inject.template.js @@ -1,30 +1,11 @@ /* eslint-disable */ window.injectedEnv = { - NIH_CLIENT_ID: '${NIH_CLIENT_ID}', - NIH_AUTHORIZE_URL: '${NIH_AUTHORIZE_URL}', - NIH_REDIRECT_URL: '${NIH_REDIRECT_URL}', - REACT_APP_BACKEND_GETUSERINFO_API: '${REACT_APP_BACKEND_GETUSERINFO_API}', - REACT_APP_LOGIN_URL: '${REACT_APP_LOGIN_URL}', - REACT_APP_USER_LOGOUT_URL: '${REACT_APP_USER_LOGOUT_URL}', REACT_APP_BACKEND_API: '${REACT_APP_BACKEND_API}', - REACT_APP_BE_VERSION: '${REACT_APP_BE_VERSION}', REACT_APP_FE_VERSION: '${REACT_APP_FE_VERSION}', - REACT_APP_APPLICATION_VERSION: '${REACT_APP_APPLICATION_VERSION}', REACT_APP_GA_TRACKING_ID: '${REACT_APP_GA_TRACKING_ID}', - REACT_APP_FILE_SERVICE_API: '${REACT_APP_FILE_SERVICE_API}', - REACT_APP_AUTH_API: '${REACT_APP_AUTH_API}', - REACT_APP_GOOGLE_CLIENT_ID: '${REACT_APP_GOOGLE_CLIENT_ID}', - REACT_APP_AUTH_SERVICE_API: '${REACT_APP_AUTH_SERVICE_API}', - REACT_APP_USER_SERVICE_API: '${REACT_APP_USER_SERVICE_API}', REACT_APP_NIH_CLIENT_ID: '${REACT_APP_NIH_CLIENT_ID}', - REACT_APP_NIH_AUTH_URL: '${REACT_APP_NIH_AUTH_URL}', REACT_APP_NIH_AUTHORIZE_URL: '${NIH_AUTHORIZE_URL}', REACT_APP_NIH_REDIRECT_URL: '${REACT_APP_NIH_REDIRECT_URL}', - REACT_APP_BACKEND_PUBLIC_API: '${REACT_APP_BACKEND_PUBLIC_API}', - REACT_APP_AUTH: '${REACT_APP_AUTH}', REACT_APP_DEV_TIER: '${DEV_TIER}', REACT_APP_UPLOADER_CLI: '${REACT_APP_UPLOADER_CLI}', - PUBLIC_ACCESS: '${PUBLIC_ACCESS}', - NODE_LEVEL_ACCESS:'${NODE_LEVEL_ACCESS}', - NODE_LABEL: '${NODE_LABEL}' }; diff --git a/tsconfig.json b/tsconfig.json index 0f5cd7382..0b3795893 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ }, "include": [ "src", + "conf", ".eslintrc.cjs", "types/*.d.ts", "public" From 27295c468831a93c82eb116f4125629afdb9908d Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 2 Apr 2024 12:23:50 -0400 Subject: [PATCH 33/34] fix: GenericTable scrollbar scrolls the entire table container --- src/components/DataSubmissions/GenericTable.tsx | 15 +++++++++++---- src/content/dataSubmissions/SubmittedData.tsx | 1 + 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/DataSubmissions/GenericTable.tsx b/src/components/DataSubmissions/GenericTable.tsx index d60116c1d..b6ecd34a6 100644 --- a/src/components/DataSubmissions/GenericTable.tsx +++ b/src/components/DataSubmissions/GenericTable.tsx @@ -23,8 +23,7 @@ const StyledTableContainer = styled(TableContainer)({ border: "1px solid #6CACDA", marginBottom: "25px", position: "relative", - overflowX: "auto", - overflowY: "hidden", + overflow: "hidden", "& .MuiTableRow-root:nth-of-type(2n)": { background: "#E3EEF9", }, @@ -36,6 +35,12 @@ const StyledTableContainer = styled(TableContainer)({ }, }); +const StyledTable = styled(Table, { shouldForwardProp: (p) => p !== "horizontalScroll" })<{ horizontalScroll: boolean }>(({ horizontalScroll }) => ({ + whiteSpace: horizontalScroll ? "nowrap" : "initial", + display: horizontalScroll ? "block" : "table", + overflowX: horizontalScroll ? "auto" : "initial", +})); + const StyledTableHead = styled(TableHead)({ background: "#4D7C8F", }); @@ -149,6 +154,7 @@ type Props = { data: T[]; total: number; loading?: boolean; + horizontalScroll?: boolean; noContentText?: string; defaultOrder?: Order; defaultRowsPerPage?: number; @@ -168,6 +174,7 @@ const GenericTable = ({ data, total = 0, loading, + horizontalScroll = false, noContentText, defaultOrder = "desc", defaultRowsPerPage = 10, @@ -246,7 +253,7 @@ const GenericTable = ({ return ( {loading && ()} - + {columns.map((col: Column) => ( @@ -315,7 +322,7 @@ const GenericTable = ({ )} -
+ = ({ submissionId }) => { loading={loading} defaultRowsPerPage={20} defaultOrder="desc" + horizontalScroll setItemKey={(item, idx) => `${idx}_${item.nodeID}_${item.nodeID}`} onFetchData={handleFetchData} /> From 967561d02dd05aacfd0f5ac107dcb1105db71d97 Mon Sep 17 00:00:00 2001 From: Alec M Date: Tue, 2 Apr 2024 12:51:08 -0400 Subject: [PATCH 34/34] fix: Empty table `noContentText` not spanning full width --- src/components/DataSubmissions/GenericTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DataSubmissions/GenericTable.tsx b/src/components/DataSubmissions/GenericTable.tsx index b6ecd34a6..2d6231172 100644 --- a/src/components/DataSubmissions/GenericTable.tsx +++ b/src/components/DataSubmissions/GenericTable.tsx @@ -253,7 +253,7 @@ const GenericTable = ({ return ( {loading && ()} - + 0}> {columns.map((col: Column) => (