diff --git a/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ClinicalEntityData.tsx b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ClinicalEntityData.tsx new file mode 100644 index 00000000..5ed012ee --- /dev/null +++ b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ClinicalEntityData.tsx @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { ClinicalInput, ClinicalSearchResults } from '@/__generated__/clinical/graphql'; +import CLINICAL_ENTITY_DATA_QUERY from '@/app/gql/clinical/CLINICAL_ENTITY_DATA_QUERY'; +import { useClinicalQuery } from '@/app/hooks/useApolloQuery'; +import { ContentPlaceholder, DnaLoader, css } from '@icgc-argo/uikit'; +import { useEffect } from 'react'; +import { + ClinicalEntitySearchResultResponse, + CompletionStates, + aliasSortNames, + aliasedEntityNames, + clinicalEntityFields, + defaultClinicalEntityFilters, + emptyClinicalDataResponse, + emptySearchResponse, +} from '../common'; +import { formatTableErrors } from '../tableDataRefactor'; +import ClinicalEntityDataTable from './ClinicalEntityDataTable'; +import { ErrorTable, ErrorTableProps } from './ErrorTable'; +import { mock } from './mock'; +import { + defaultDonorSettings, + defaultEntityPageSettings, + usePageSettings, +} from './usePageSettings'; + +export type DonorEntry = { + row: string; + isNew: boolean; + [k: string]: string | number | boolean; +}; + +const NoDataCell = () => ( +
+ + No Data + +
+); + +const defaultErrorPageSettings = { + page: 0, + pageSize: 5, + sorted: [{ id: 'donorId', desc: true }], +}; + +const validateEntityQueryName = (entityQuery) => { + const entities = typeof entityQuery === 'string' ? [entityQuery] : entityQuery; + return entities.map((entityName) => clinicalEntityFields.find((entity) => entity === entityName)); +}; + +type GetEntityDataProps = { + program: string; + entityType: string | string[]; + page: number; + pageSize: number; + sort: string; + completionState: CompletionStates; + donorIds: number[]; + submitterDonorIds: string[]; +}; +export const useGetEntityData = ({ + program, + entityType, + page, + pageSize, + sort, + completionState, + donorIds, + submitterDonorIds, +}: GetEntityDataProps) => { + console.log('get data', { + program, + entityType, + page, + pageSize, + sort, + completionState, + donorIds, + submitterDonorIds, + }); + const entityTypes = validateEntityQueryName(entityType); + + return useClinicalQuery(CLINICAL_ENTITY_DATA_QUERY, { + errorPolicy: 'all', + fetchPolicy: 'cache-and-network', + variables: { + programShortName: program, + filters: { + ...defaultClinicalEntityFilters, + sort, + page, + pageSize, + completionState, + donorIds, + submitterDonorIds, + entityTypes, + }, + }, + }); +}; + +/** + * + * returns a donorId array and a submitterDonorId array to filter + */ +type CI = Required>; +const getDonorIdFilters = ({ searchResults, currentDonors, useDefaultQuery }): CI => { + const donorIds = useDefaultQuery + ? [] + : currentDonors.length + ? currentDonors + : searchResults.map(({ donorId }: ClinicalSearchResults) => donorId); + + const submitterDonorIds = + useDefaultQuery || currentDonors.length + ? [] + : searchResults + .map(({ submitterDonorId }: ClinicalSearchResults) => submitterDonorId) + .filter((id) => !!id); + + return { + donorIds, + submitterDonorIds, + }; +}; + +// query, sort, format data for table, search etc +type ClinicalEntityDataTableProps = { + entityType: string; + program: string; + completionState: CompletionStates; + currentDonors: number[]; + donorSearchResults: ClinicalEntitySearchResultResponse; + useDefaultQuery: boolean; + noData: boolean; +}; +const ClinicalEntityData = ({ + entityType, + program, + completionState = CompletionStates['all'], + currentDonors, + donorSearchResults = emptySearchResponse, + useDefaultQuery, + noData, +}: ClinicalEntityDataTableProps) => { + // Clinical Data table page + const defaultPageSettings = + useDefaultQuery && entityType === 'donor' ? defaultDonorSettings : defaultEntityPageSettings; + const [pageSettings, setPageSettings] = usePageSettings(defaultPageSettings); + const { page, pageSize, sorted } = pageSettings; + + // reset paging + useEffect(() => { + setPageSettings(defaultPageSettings); + }, [entityType, useDefaultQuery]); + + const { desc, id } = sorted[0]; + const sortKey = aliasSortNames[id] || id; + const sort = `${desc ? '-' : ''}${sortKey}`; + + const { + clinicalSearchResults: { searchResults, totalResults }, + } = donorSearchResults || emptySearchResponse; + const { donorIds, submitterDonorIds: submittedDonorIdsToFilter } = getDonorIdFilters({ + currentDonors, + searchResults, + useDefaultQuery, + }); + const nextSearchPage = (page + 1) * pageSize; + const clinicalDataPropertyFilters = { + donorIds, + submitterDonorIds: submittedDonorIdsToFilter.slice( + page * pageSize, + nextSearchPage < totalResults ? nextSearchPage : totalResults, + ), + }; + + const updatePageSettings = (key, value) => { + const newPageSettings = { ...pageSettings, [key]: value }; + + if (key === 'pageSize' && value !== pageSettings.pageSize) { + // Prevents bug querying nonexistent data + newPageSettings.page = 0; + } + setPageSettings(newPageSettings); + return newPageSettings; + }; + + // API query + const { data: clinicalEntityData, loading } = useGetEntityData({ + program, + entityType, + completionState, + page, + pageSize, + sort, + ...clinicalDataPropertyFilters, + }); + + // This is the core of the logic here + // this data response => UI + console.log('data', clinicalEntityData); + + const { clinicalData } = + clinicalEntityData == undefined || loading ? emptyClinicalDataResponse : clinicalEntityData; + + const noTableData = noData || clinicalData.clinicalEntities.length === 0; + + const aliasedEntityName = aliasedEntityNames[entityType]; + + const { clinicalErrors = [] } = clinicalData; + const { tableErrors, totalErrorsAmount } = formatTableErrors({ + clinicalErrors: mock.clinicalErrors, + aliasedEntityName: mock.aliasedEntityName, + }); + console.log('te', tableErrors, 'total', totalErrorsAmount); + + const errorTableProps: ErrorTableProps = { + tableErrors, + totalErrorsAmount, + entityType, + program, + }; + + const clinicalDataTableProps = { + aliasedEntityName, + totalResults, + page, + pageSize, + entityType, + currentDonors, + useDefaultQuery, + clinicalData, + sortingFn: () => null, + }; + + return loading ? ( + + ) : noTableData ? ( + + ) : ( + <> + {totalErrorsAmount > 0 && ( +
+ +
+ )} + + + ); +}; + +export default ClinicalEntityData; diff --git a/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ClinicalEntityDataTable.tsx b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ClinicalEntityDataTable.tsx new file mode 100644 index 00000000..915facb0 --- /dev/null +++ b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ClinicalEntityDataTable.tsx @@ -0,0 +1,435 @@ +/* + * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved + * + * This program and the accompanying materials are made available under the terms of + * the GNU Affero General Public License v3.0. You should have received a copy of the + * GNU Affero General Public License along with this program. + * If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT + * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, + * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED + * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER + * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN + * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { TableInfoHeaderContainer } from '@/app/components/Table/common'; +import { Icon, Table, Typography, css, useTheme } from '@icgc-argo/uikit'; +import memoize from 'lodash/memoize'; +import { createRef, useState } from 'react'; +import { + Cell, + ClinicalCoreCompletionHeader, + TopLevelHeader, + styleThickBorder, +} from '../ClinicalDataTableComp'; +import { aliasedEntityFields, aliasedEntityNames } from '../common'; + +export type DonorEntry = { + row: string; + isNew: boolean; + [k: string]: string | number | boolean; +}; + +const emptyCompletion = { + DO: 0, + PD: 0, + FO: 0, + NS: 0, + TR: 0, + TS: 0, +}; + +const completionColumnHeaders = { + donor: 'DO', + primaryDiagnosis: 'PD', + normalSpecimens: 'NS', + tumourSpecimens: 'TS', + treatments: 'TR', + followUps: 'FO', +}; + +const coreCompletionFields = Object.keys(completionColumnHeaders); + +const getColumnWidth = memoize<(keyString: string, showCompletionStats: boolean) => number>( + (keyString, showCompletionStats) => { + const minWidth = keyString === 'donor_id' ? 70 : showCompletionStats ? 40 : 95; + const maxWidth = showCompletionStats ? 45 : 200; + const spacePerChar = 8; + const margin = 10; + const targetWidth = keyString.length * spacePerChar + margin; + return Math.max(Math.min(maxWidth, targetWidth), minWidth); + }, +); + +const parseRecords = (records, showCompletionStats, completionStats) => + records.map((record) => { + let clinicalRecord = {}; + record.forEach((r) => { + const displayKey = r.name; + clinicalRecord[displayKey] = displayKey === 'donor_id' ? `DO${r.value}` : r.value || ''; + if (showCompletionStats && displayKey === 'donor_id') { + const completionRecord = completionStats.find((stat) => stat.donorId === parseInt(r.value)); + + if (!completionRecord) { + clinicalRecord = { ...clinicalRecord, ...emptyCompletion }; + } else { + const { coreCompletion, entityData: completionEntityData } = completionRecord; + + coreCompletionFields.forEach((field) => { + const completionField = completionColumnHeaders[field]; + const isSpecimenField = + completionField === completionColumnHeaders['normalSpecimens'] || + completionField === completionColumnHeaders['tumourSpecimens']; + + if (!isSpecimenField) { + const completionValue = coreCompletion[field]; + clinicalRecord[completionField] = completionValue || 0; + } else { + const { + specimens: { + coreCompletionPercentage, + normalSpecimensPercentage, + tumourSpecimensPercentage, + normalSubmissions, + tumourSubmissions, + }, + } = completionEntityData; + + if (coreCompletionPercentage === 1) { + clinicalRecord[completionField] = 1; + } else { + const currentPercentage = + completionField === completionColumnHeaders['normalSpecimens'] + ? normalSpecimensPercentage + : tumourSpecimensPercentage; + const currentSubmissions = + completionField === completionColumnHeaders['normalSpecimens'] + ? normalSubmissions + : tumourSubmissions; + const hasErrors = currentPercentage !== 1; + const value = hasErrors ? currentSubmissions : currentPercentage; + clinicalRecord[completionField] = value; + } + } + }); + } + } + }); + + return clinicalRecord; + }); + +// very hardcoded styling logic +const getHeaderBorder = (key, showCompletionStats) => + (showCompletionStats && key === completionColumnHeaders.followUps) || + (!showCompletionStats && key === 'donor_id') || + key === 'FO' + ? styleThickBorder + : ''; + +const getCellStyles = ( + state, + row, + column, + showCompletionStats, + clinicalErrors, + entityType, + clinicalData, + theme, + stickyDonorIDColumnsWidth, +) => { + const { original } = row; + const { id } = column; + const isCompletionCell = + showCompletionStats && Object.values(completionColumnHeaders).includes(id); + + const isSpecimenCell = + isCompletionCell && + (id === completionColumnHeaders.normalSpecimens || + id === completionColumnHeaders.tumourSpecimens); + + const originalDonorId = original['donor_id']; + const cellDonorId = parseInt( + originalDonorId && originalDonorId.includes('DO') + ? originalDonorId.substring(2) + : originalDonorId, + ); + + const donorErrorData = clinicalErrors + .filter((donor) => donor.donorId === cellDonorId) + .map((donor) => donor.errors) + .flat(); + + const columnErrorData = + donorErrorData.length && + donorErrorData.filter( + (error) => + error && + (error.entityName === entityType || + (aliasedEntityFields.includes(error.entityName) && + aliasedEntityNames[entityType] === error.entityName)) && + error.fieldName === id, + ); + + const hasClinicalErrors = columnErrorData && columnErrorData.length >= 1; + + let hasCompletionErrors = isCompletionCell && original[id] !== 1; + + if (isSpecimenCell) { + const completionData = clinicalData.clinicalEntities.find( + (entity) => entity.entityName === aliasedEntityNames['donor'], + ).completionStats; + + const completionRecord = + isCompletionCell && + completionData.find((stat) => stat.donorId === parseInt(originalDonorId.substr(2))); + + if (completionRecord) { + const { entityData: completionEntityData } = completionRecord; + + const { + specimens: { normalSpecimensPercentage, tumourSpecimensPercentage }, + } = completionEntityData; + + const currentPercentage = + id === completionColumnHeaders['normalSpecimens'] + ? normalSpecimensPercentage + : tumourSpecimensPercentage; + + hasCompletionErrors = currentPercentage !== 1; + } + } + + const specificErrorValue = + hasClinicalErrors && + columnErrorData.filter( + (error) => + (error.errorType === 'INVALID_BY_SCRIPT' || error.errorType === 'INVALID_ENUM_VALUE') && + (error.info?.value === original[id] || + (error.info?.value && error.info.value[0] === original[id]) || + (error.info.value === null && !Boolean(original[id]))), + ); + + const fieldError = + hasClinicalErrors && + columnErrorData.filter( + (error) => + (error.errorType === 'UNRECOGNIZED_FIELD' || + error.errorType === 'MISSING_REQUIRED_FIELD') && + error.fieldName === id, + ); + + const errorState = + // Completion Stats === 1 indicates Complete + // 0 is Incomplete, <1 Incorrect Sample / Specimen Ratio + (isCompletionCell && hasCompletionErrors) || + specificErrorValue?.length > 0 || + fieldError?.length > 0; + + // use Emotion styling + const headerDonorIdStyle = css` + background: white, + position: absolute, + `; + const stickyMarginStyle = css` + margin-left: ${stickyDonorIDColumnsWidth}; + `; + const style = css` + color: ${isCompletionCell && !errorState && theme.colors.accent1_dark}; + background: ${errorState && theme.colors.error_4}; + ${getHeaderBorder(id, showCompletionStats)} + ${column.Header === 'donor_id' && headerDonorIdStyle}; + ${column.Header === 'DO' && stickyMarginStyle}; + ${column.Header === 'program_id' && !showCompletionStats && stickyMarginStyle}; + `; + + return { + style, + isCompletionCell, + errorState, + }; +}; + +type ClinicalEntityDataTableProps = { + entityType: string; + currentDonors: number[]; + useDefaultQuery: boolean; + aliasedEntityName: string; + page: number; + pageSize: number; + clinicalData: any; + sortingFn: any; + totalResults: number; +}; +const ClinicalEntityDataTable = ({ + entityType, + aliasedEntityName, + currentDonors, + useDefaultQuery, + totalResults, + page, + pageSize, + clinicalData, + sortingFn, +}: ClinicalEntityDataTableProps) => { + console.log('cc', clinicalData); + const theme = useTheme(); + const containerRef = createRef(); + + const entityData = clinicalData.clinicalEntities.find( + (entity) => entity.entityName === aliasedEntityName, + ); + + const clinicalErrors = clinicalData.clinicalErrors; + + const { completionStats, entityName, entityFields } = entityData; + const showCompletionStats = completionStats && entityName === aliasedEntityNames.donor; + + // totalDocs affects pagination and display text + // If using default query, or using search but not filtering by donor in URL, then we display total number of search results + // Else we use the total number of results that match our query + const totalDocs = + (useDefaultQuery && entityType === 'donor') || + (!currentDonors.length && totalResults > entityData.totalDocs) + ? totalResults + : entityData.totalDocs; + + // iterate for field names not in entity fields + // add completion column headers if showing completion stats + const columnNames = [ + ...entityData.records[0] + .filter((record) => !entityFields.includes(record.name)) + .map((record) => record.name), + ...(showCompletionStats && Object.values(completionColumnHeaders)), + ]; + + const records = parseRecords(entityData.records, showCompletionStats, completionStats).sort( + sortingFn, + ); + + const [stickyDonorIDColumnsWidth, setStickyDonorIDColumnsWidth] = useState(74); + + let columns = []; + columns = columnNames.map((key) => { + return { + id: key, + accessorKey: key, + Header: key, + minWidth: getColumnWidth(key, showCompletionStats), + }; + }); + + if (showCompletionStats) { + columns = [ + { + id: 'clinical_core_completion_header', + meta: { customHeader: true }, + sortingFn, + header: () => , + + columns: columns.slice(0, 7).map((column, index) => ({ + ...column, + sortingFn, + header: (props) => { + const value = props.header.id; + const coreCompletionColumnsCount = 7; + const isLastElement = index === coreCompletionColumnsCount - 1; + const isSticky = value === 'donor_id'; + const isSorted = props.sorted; + + return {value}; + }, + meta: { customCell: true, customHeader: true }, + cell: (context) => { + const value = context.getValue(); + const isSticky = context.column.id === 'donor_id'; + + const { isCompletionCell, errorState, style } = getCellStyles( + undefined, + context.row, + context.column, + showCompletionStats, + clinicalErrors, + entityType, + clinicalData, + theme, + stickyDonorIDColumnsWidth, + ); + + const showSuccessSvg = isCompletionCell && !errorState; + + const content = showSuccessSvg ? ( + + ) : ( + value + ); + + return ( + + {content} + + ); + }, + })), + }, + { + id: 'submitted_donor_data_header', + meta: { customHeader: true }, + header: () => ( + + ), + columns: columns.slice(7), + }, + ]; + } + + const tableMin = totalDocs > 0 ? page * pageSize + 1 : totalDocs; + const tableMax = totalDocs < (page + 1) * pageSize ? totalDocs : (page + 1) * pageSize; + + return ( +
+ + Showing {tableMin} - {tableMax} of {totalDocs} records + + } + /> + + + ); +}; + +export default ClinicalEntityDataTable; diff --git a/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ErrorTable.tsx b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ErrorTable.tsx new file mode 100644 index 00000000..9929f2ff --- /dev/null +++ b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/ErrorTable.tsx @@ -0,0 +1,118 @@ +import ErrorNotification, { ErrorReportColumns } from '@/app/components/ErrorNotification'; +import { errorNotificationTableProps } from '@/app/components/ErrorNotification/ErrorNotificationDefaultTable'; +import CLINICAL_SCHEMA_VERSION from '@/app/gql/clinical/CLINICAL_SCHEMA_VERSION'; +import { useAppConfigContext } from '@/app/hooks/AppProvider'; +import { useClinicalQuery } from '@/app/hooks/useApolloQuery'; +import { PROGRAM_CLINICAL_SUBMISSION_PATH, PROGRAM_SHORT_NAME_PATH } from '@/global/constants'; +import { ColumnDef, Link, NOTIFICATION_VARIANTS, Table, css } from '@icgc-argo/uikit'; +import urljoin from 'url-join'; +import { clinicalEntityDisplayNames } from '../common'; + +const errorColumns = [ + { + accessorKey: 'entries', + Header: '# Affected Records', + id: 'entries', + maxWidth: 135, + }, + { + accessorKey: 'fieldName', + Header: `Field with Error`, + id: 'fieldName', + maxWidth: 215, + }, + { + accessorKey: 'errorMessage', + Header: `Error Description`, + id: 'errorMessage', + }, +]; + +const Subtitle = ({ program = '' }) => { + const { DOCS_URL_ROOT } = useAppConfigContext(); + const DOCS_DICTIONARY_PAGE = urljoin(DOCS_URL_ROOT, '/dictionary/'); + const latestDictionaryResponse = useClinicalQuery(CLINICAL_SCHEMA_VERSION); + + return ( +
+ + {!latestDictionaryResponse.loading && + `Version ${latestDictionaryResponse.data.clinicalSubmissionSchemaVersion}`} + {' '} + of the data dictionary was released and has made some donors invalid. Please download the + error report to view the affected donors, then submit a corrected TSV file in the{' '} + + Submit Clinical Data{' '} + + workspace. +
+ ); +}; + +type ErrorTableColumns = { + entries: number; + errorMessage: string; + fieldName: string; +}; + +type ErrorTableColumnProperties = { + accessorKey: keyof ErrorTableColumns; + header: string; + maxSize?: number; +}; + +type DefaultErrorColumns = { + errorReportColumns: ErrorReportColumns[]; + errorTableColumns: ColumnDef[]; +}; + +const getErrorColumns = (): DefaultErrorColumns => { + const errorTableColumns: ErrorTableColumnProperties[] = [ + { accessorKey: 'entries', header: '# Affected Records', maxSize: 135 }, + { accessorKey: 'fieldName', header: `Field with Error`, maxSize: 215 }, + { accessorKey: 'errorMessage', header: `Error Description` }, + ]; + + const errorReportColumns: ErrorReportColumns[] = errorTableColumns.map( + ({ accessorKey, header }) => ({ + header, + id: accessorKey, + }), + ); + + return { errorReportColumns, errorTableColumns }; +}; + +export type ErrorTableProps = { + totalErrorsAmount: number; + entityType: string; + program: string; + tableErrors: any; +}; +export const ErrorTable = ({ + totalErrorsAmount, + entityType, + program, + tableErrors, +}: ErrorTableProps) => { + const { errorReportColumns, errorTableColumns } = getErrorColumns(); + + return ( + } + reportData={tableErrors} + reportColumns={errorReportColumns} + tableComponent={ +
+ } + /> + ); +}; diff --git a/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/usePageSettings.ts b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/usePageSettings.ts new file mode 100644 index 00000000..bcf19d81 --- /dev/null +++ b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalDataMain/usePageSettings.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; +import { defaultClinicalEntityFilters } from '../common'; + +export const defaultEntityPageSettings = { + page: defaultClinicalEntityFilters.page, + pageSize: defaultClinicalEntityFilters.pageSize, + sorted: [{ id: 'donorId', desc: true }], +}; + +export const defaultDonorSettings = { + ...defaultEntityPageSettings, + sorted: [{ id: 'completionStats.coreCompletionPercentage', desc: false }], +}; + +export const usePageSettings = (defaultState) => { + const [pageSettings, setPageSettings] = useState(defaultState); + const { page, pageSize, sorted } = pageSettings; + return [pageSettings, setPageSettings]; +}; diff --git a/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalEntityDataTable.tsx b/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalEntityDataTable.tsx deleted file mode 100644 index 3da8ab91..00000000 --- a/src/app/(post-login)/submission/program/[shortName]/clinical-data/ClinicalEntityDataTable.tsx +++ /dev/null @@ -1,759 +0,0 @@ -/* - * Copyright (c) 2023 The Ontario Institute for Cancer Research. All rights reserved - * - * This program and the accompanying materials are made available under the terms of - * the GNU Affero General Public License v3.0. You should have received a copy of the - * GNU Affero General Public License along with this program. - * If not, see . - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY - * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES - * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT - * SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED - * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; - * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER - * IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN - * ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -import { ClinicalSearchResults } from '@/__generated__/clinical/graphql'; -import ErrorNotification from '@/app/components/ErrorNotification'; -import { TableInfoHeaderContainer } from '@/app/components/Table/common'; -import CLINICAL_ENTITY_DATA_QUERY from '@/app/gql/clinical/CLINICAL_ENTITY_DATA_QUERY'; -import CLINICAL_SCHEMA_VERSION from '@/app/gql/clinical/CLINICAL_SCHEMA_VERSION'; -import { useAppConfigContext } from '@/app/hooks/AppProvider'; -import { useClinicalQuery } from '@/app/hooks/useApolloQuery'; -import { PROGRAM_CLINICAL_SUBMISSION_PATH, PROGRAM_SHORT_NAME_PATH } from '@/global/constants'; -import { - ContentPlaceholder, - DnaLoader, - Icon, - Link, - NOTIFICATION_VARIANTS, - Table, - Typography, - css, - useTheme, -} from '@icgc-argo/uikit'; -import memoize from 'lodash/memoize'; -import { createRef, useEffect, useState } from 'react'; -import urljoin from 'url-join'; -import { - Cell, - ClinicalCoreCompletionHeader, - TopLevelHeader, - styleThickBorder, -} from './ClinicalDataTableComp'; -import { - ClinicalEntitySearchResultResponse, - CompletionStates, - aliasSortNames, - aliasedEntityFields, - aliasedEntityNames, - clinicalEntityDisplayNames, - clinicalEntityFields, - defaultClinicalEntityFilters, - emptyClinicalDataResponse, - emptySearchResponse, -} from './common'; - -export type DonorEntry = { - row: string; - isNew: boolean; - [k: string]: string | number | boolean; -}; - -const errorColumns = [ - { - accessorKey: 'entries', - Header: '# Affected Records', - id: 'entries', - maxWidth: 135, - }, - { - accessorKey: 'fieldName', - Header: `Field with Error`, - id: 'fieldName', - maxWidth: 215, - }, - { - accessorKey: 'errorMessage', - Header: `Error Description`, - id: 'errorMessage', - }, -]; - -const NoDataCell = () => ( -
- - No Data - -
-); - -const completionKeys = Object.values(aliasSortNames); -const completionColumnNames = Object.keys(aliasSortNames); -const emptyCompletion = { - DO: 0, - PD: 0, - FO: 0, - NS: 0, - TR: 0, - TS: 0, -}; - -const noDataCompletionStats = [ - { - donor_id: 0, - ...emptyCompletion, - }, -]; - -const completionColumnHeaders = { - donor: 'DO', - primaryDiagnosis: 'PD', - normalSpecimens: 'NS', - tumourSpecimens: 'TS', - treatments: 'TR', - followUps: 'FO', -}; - -const coreCompletionFields = Object.keys(completionColumnHeaders); - -const getColumnWidth = memoize< - (keyString: string, showCompletionStats: boolean, noData: boolean) => number ->((keyString, showCompletionStats, noData) => { - const minWidth = keyString === 'donor_id' ? 70 : showCompletionStats ? 40 : 95; - const maxWidth = noData && showCompletionStats ? 45 : 200; - const spacePerChar = 8; - const margin = 10; - const targetWidth = keyString.length * spacePerChar + margin; - return Math.max(Math.min(maxWidth, targetWidth), minWidth); -}); - -const defaultEntityPageSettings = { - page: defaultClinicalEntityFilters.page, - pageSize: defaultClinicalEntityFilters.pageSize, - sorted: [{ id: 'donorId', desc: true }], -}; - -const defaultDonorSettings = { - ...defaultEntityPageSettings, - sorted: [{ id: 'completionStats.coreCompletionPercentage', desc: false }], -}; - -const defaultErrorPageSettings = { - page: 0, - pageSize: 5, - sorted: [{ id: 'donorId', desc: true }], -}; - -const validateEntityQueryName = (entityQuery) => { - const entities = typeof entityQuery === 'string' ? [entityQuery] : entityQuery; - return entities.map((entityName) => clinicalEntityFields.find((entity) => entity === entityName)); -}; - -export const useGetEntityData = ( - program: string, - entityType: string | string[], - page: number, - pageSize: number, - sort: string, - completionState: CompletionStates, - donorIds: number[], - submitterDonorIds: string[], -) => - useClinicalQuery(CLINICAL_ENTITY_DATA_QUERY, { - errorPolicy: 'all', - fetchPolicy: 'cache-and-network', - variables: { - programShortName: program, - filters: { - ...defaultClinicalEntityFilters, - sort, - page, - pageSize, - completionState, - donorIds, - submitterDonorIds, - entityTypes: validateEntityQueryName(entityType), - }, - }, - }); - -const ClinicalEntityDataTable = ({ - entityType, - program, - completionState = CompletionStates['all'], - currentDonors, - donorSearchResults = emptySearchResponse, - useDefaultQuery, - noData, -}: { - entityType: string; - program: string; - completionState: CompletionStates; - currentDonors: number[]; - donorSearchResults: ClinicalEntitySearchResultResponse; - useDefaultQuery: boolean; - noData: boolean; -}) => { - const { DOCS_URL_ROOT } = useAppConfigContext(); - const DOCS_DICTIONARY_PAGE = urljoin(DOCS_URL_ROOT, '/dictionary/'); - - // Init + Page Settings - let totalDocs = 0; - let showCompletionStats = false; - let records = []; - let columns = []; - const theme = useTheme(); - const containerRef = createRef(); - const defaultPageSettings = - useDefaultQuery && entityType === 'donor' ? defaultDonorSettings : defaultEntityPageSettings; - const [pageSettings, setPageSettings] = useState(defaultPageSettings); - const { page, pageSize, sorted } = pageSettings; - const [errorPageSettings, setErrorPageSettings] = useState(defaultErrorPageSettings); - const { page: errorPage, pageSize: errorPageSize, sorted: errorSorted } = errorPageSettings; - const { desc, id } = sorted[0]; - const sortKey = aliasSortNames[id] || id; - const sort = `${desc ? '-' : ''}${sortKey}`; - - const { - clinicalSearchResults: { searchResults, totalResults }, - } = donorSearchResults || emptySearchResponse; - - const nextSearchPage = (page + 1) * pageSize; - - const donorIds = useDefaultQuery - ? [] - : currentDonors.length - ? currentDonors - : searchResults.map(({ donorId }: ClinicalSearchResults) => donorId); - - const submitterDonorIds = - useDefaultQuery || currentDonors.length - ? [] - : searchResults - .map(({ submitterDonorId }: ClinicalSearchResults) => submitterDonorId) - .filter((id) => !!id) - .slice(page * pageSize, nextSearchPage < totalResults ? nextSearchPage : totalResults); - - const latestDictionaryResponse = useClinicalQuery(CLINICAL_SCHEMA_VERSION); - const Subtitle = ({ program = '' }) => ( -
- - {!latestDictionaryResponse.loading && - `Version ${latestDictionaryResponse.data.clinicalSubmissionSchemaVersion}`} - {' '} - of the data dictionary was released and has made some donors invalid. Please download the - error report to view the affected donors, then submit a corrected TSV file in the{' '} - - Submit Clinical Data{' '} - - workspace. -
- ); - - const updatePageSettings = (key, value) => { - const newPageSettings = { ...pageSettings, [key]: value }; - - if (key === 'pageSize' && value !== pageSettings.pageSize) { - // Prevents bug querying nonexistent data - newPageSettings.page = 0; - } - setPageSettings(newPageSettings); - return newPageSettings; - }; - - useEffect(() => { - setPageSettings(defaultPageSettings); - setErrorPageSettings(defaultErrorPageSettings); - }, [entityType, useDefaultQuery]); - - const { data: clinicalEntityData, loading } = useGetEntityData( - program, - entityType, - page, - pageSize, - sort, - completionState, - donorIds, - submitterDonorIds, - ); - - const { clinicalData } = - clinicalEntityData == undefined || loading ? emptyClinicalDataResponse : clinicalEntityData; - - const noTableData = noData || clinicalData.clinicalEntities.length === 0; - - // Collect Error Data - const { clinicalErrors = [] } = clinicalData; - const tableErrorGroups = []; - - clinicalErrors.forEach((donor) => { - const relatedErrors = donor.errors.filter( - (error) => error.entityName === aliasedEntityNames[entityType], - ); - - relatedErrors.forEach((error) => { - const { donorId } = donor; - const { errorType, message, fieldName } = error; - const relatedErrorGroup = tableErrorGroups.find( - (tableErrorGroup) => - tableErrorGroup[0].errorType === errorType && - tableErrorGroup[0].message === message && - tableErrorGroup[0].fieldName === fieldName, - ); - const tableError = { ...error, donorId }; - - if (!relatedErrorGroup) { - tableErrorGroups.push([tableError]); - } else { - relatedErrorGroup.push(tableError); - } - }); - }); - - const tableErrors = tableErrorGroups.map((errorGroup) => { - // Counts Number of Records affected for each Error Object - const { fieldName, entityName, message, errorType } = errorGroup[0]; - - const errorMessage = - errorType === 'UNRECOGNIZED_FIELD' - ? `${fieldName} is not a field within the latest dictionary. Please remove this from the ${entityName}.tsv file before submitting.` - : message; - - const entries = errorGroup.length; - - return { - entries, - fieldName, - entityName, - errorMessage, - }; - }); - - const totalErrors = tableErrors.reduce( - (errorCount, errorGroup) => errorCount + errorGroup.entries, - 0, - ); - const hasErrors = totalErrors > 0; - - const sortEntityData = (prev, next) => { - let sortVal = 0; - - if (hasErrors) { - // If Current Entity has Errors, Prioritize Data w/ Errors - const { errorsA, errorsB } = clinicalErrors.reduce( - (acc, current) => { - if (current.donorId == prev['donor_id']) { - acc.errorsA = -1; - } - if (current.donorId == next['donor_id']) { - acc.errorsB = 1; - } - return acc; - }, - { errorsA: 0, errorsB: 0 }, - ); - - sortVal += errorsA + errorsB; - } - - // Handles Manual User Sorting by Core Completion columns - const completionSortIndex = completionKeys.indexOf(sortKey); - - if (completionSortIndex) { - const completionSortKey = completionColumnNames[completionSortIndex]; - const completionA = prev[completionSortKey]; - const completionB = next[completionSortKey]; - - sortVal = completionA === completionB ? 0 : completionA > completionB ? -1 : 1; - sortVal *= desc ? -1 : 1; - } - - return sortVal; - }; - - // Map Completion Stats + Entity Data - if (noTableData) { - showCompletionStats = true; - records = noDataCompletionStats; - } else { - const entityData = clinicalData.clinicalEntities.find( - (entity) => entity.entityName === aliasedEntityNames[entityType], - ); - columns = [...entityData.entityFields]; - const { completionStats, entityName } = entityData; - showCompletionStats = !!(completionStats && entityName === aliasedEntityNames.donor); - - // totalDocs affects pagination and display text - // If using default query, or using search but not filtering by donor in URL, then we display total number of search results - // Else we use the total number of results that match our query - totalDocs = - (useDefaultQuery && entityType === 'donor') || - (!currentDonors.length && totalResults > entityData.totalDocs) - ? totalResults - : entityData.totalDocs; - - entityData.records.forEach((record) => { - record.forEach((r) => { - if (!columns.includes(r.name)) columns.push(r.name); - }); - }); - if (showCompletionStats) { - columns.splice(1, 0, ...Object.values(completionColumnHeaders)); - } - - records = entityData.records - .map((record) => { - let clinicalRecord = {}; - record.forEach((r) => { - const displayKey = r.name; - clinicalRecord[displayKey] = displayKey === 'donor_id' ? `DO${r.value}` : r.value || ''; - if (showCompletionStats && displayKey === 'donor_id') { - const completionRecord = completionStats.find( - (stat) => stat.donorId === parseInt(r.value), - ); - - if (!completionRecord) { - clinicalRecord = { ...clinicalRecord, ...emptyCompletion }; - } else { - const { coreCompletion, entityData: completionEntityData } = completionRecord; - - coreCompletionFields.forEach((field) => { - const completionField = completionColumnHeaders[field]; - const isSpecimenField = - completionField === completionColumnHeaders['normalSpecimens'] || - completionField === completionColumnHeaders['tumourSpecimens']; - - if (!isSpecimenField) { - const completionValue = coreCompletion[field]; - clinicalRecord[completionField] = completionValue || 0; - } else { - const { - specimens: { - coreCompletionPercentage, - normalSpecimensPercentage, - tumourSpecimensPercentage, - normalSubmissions, - tumourSubmissions, - }, - } = completionEntityData; - - if (coreCompletionPercentage === 1) { - clinicalRecord[completionField] = 1; - } else { - const currentPercentage = - completionField === completionColumnHeaders['normalSpecimens'] - ? normalSpecimensPercentage - : tumourSpecimensPercentage; - const currentSubmissions = - completionField === completionColumnHeaders['normalSpecimens'] - ? normalSubmissions - : tumourSubmissions; - const hasErrors = currentPercentage !== 1; - const value = hasErrors ? currentSubmissions : currentPercentage; - clinicalRecord[completionField] = value; - } - } - }); - } - } - }); - - return clinicalRecord; - }) - .sort(sortEntityData); - } - - const getHeaderBorder = (key) => - (showCompletionStats && key === completionColumnHeaders.followUps) || - (!showCompletionStats && key === 'donor_id') || - key === 'FO' - ? styleThickBorder - : ''; - - const [stickyDonorIDColumnsWidth, setStickyDonorIDColumnsWidth] = useState(74); - - const getCellStyles = (state, row, column) => { - const { original } = row; - const { id } = column; - const isCompletionCell = - showCompletionStats && Object.values(completionColumnHeaders).includes(id); - - const isSpecimenCell = - isCompletionCell && - (id === completionColumnHeaders.normalSpecimens || - id === completionColumnHeaders.tumourSpecimens); - - const originalDonorId = original['donor_id']; - const cellDonorId = parseInt( - originalDonorId && originalDonorId.includes('DO') - ? originalDonorId.substring(2) - : originalDonorId, - ); - - const donorErrorData = clinicalErrors - .filter((donor) => donor.donorId === cellDonorId) - .map((donor) => donor.errors) - .flat(); - - const columnErrorData = - donorErrorData.length && - donorErrorData.filter( - (error) => - error && - (error.entityName === entityType || - (aliasedEntityFields.includes(error.entityName) && - aliasedEntityNames[entityType] === error.entityName)) && - error.fieldName === id, - ); - - const hasClinicalErrors = columnErrorData && columnErrorData.length >= 1; - - let hasCompletionErrors = isCompletionCell && original[id] !== 1; - - if (isSpecimenCell) { - const completionData = clinicalData.clinicalEntities.find( - (entity) => entity.entityName === aliasedEntityNames['donor'], - ).completionStats; - - const completionRecord = - isCompletionCell && - completionData.find((stat) => stat.donorId === parseInt(originalDonorId.substr(2))); - - if (completionRecord) { - const { entityData: completionEntityData } = completionRecord; - - const { - specimens: { normalSpecimensPercentage, tumourSpecimensPercentage }, - } = completionEntityData; - - const currentPercentage = - id === completionColumnHeaders['normalSpecimens'] - ? normalSpecimensPercentage - : tumourSpecimensPercentage; - - hasCompletionErrors = currentPercentage !== 1; - } - } - - const specificErrorValue = - hasClinicalErrors && - columnErrorData.filter( - (error) => - (error.errorType === 'INVALID_BY_SCRIPT' || error.errorType === 'INVALID_ENUM_VALUE') && - (error.info?.value === original[id] || - (error.info?.value && error.info.value[0] === original[id]) || - (error.info.value === null && !Boolean(original[id]))), - ); - - const fieldError = - hasClinicalErrors && - columnErrorData.filter( - (error) => - (error.errorType === 'UNRECOGNIZED_FIELD' || - error.errorType === 'MISSING_REQUIRED_FIELD') && - error.fieldName === id, - ); - - const errorState = - // Completion Stats === 1 indicates Complete - // 0 is Incomplete, <1 Incorrect Sample / Specimen Ratio - (isCompletionCell && hasCompletionErrors) || - specificErrorValue?.length > 0 || - fieldError?.length > 0; - - // use Emotion styling - const headerDonorIdStyle = css` - background: white, - position: absolute, - `; - const stickyMarginStyle = css` - margin-left: ${stickyDonorIDColumnsWidth}; - `; - const style = css` - color: ${isCompletionCell && !errorState && theme.colors.accent1_dark}; - background: ${errorState && theme.colors.error_4}; - ${getHeaderBorder(id)} - ${column.Header === 'donor_id' && headerDonorIdStyle}; - ${column.Header === 'DO' && stickyMarginStyle}; - ${column.Header === 'program_id' && !showCompletionStats && stickyMarginStyle}; - `; - - return { - style, - isCompletionCell, - errorState, - }; - }; - - columns = columns.map((key) => { - return { - id: key, - accessorKey: key, - Header: key, - minWidth: getColumnWidth(key, showCompletionStats, noTableData), - }; - }); - - if (showCompletionStats) { - columns = [ - { - id: 'clinical_core_completion_header', - meta: { customHeader: true }, - sortingFn: sortEntityData, - header: () => , - - columns: columns.slice(0, 7).map((column, index) => ({ - ...column, - sortingFn: sortEntityData, - header: (props) => { - const value = props.header.id; - const coreCompletionColumnsCount = 7; - const isLastElement = index === coreCompletionColumnsCount - 1; - const isSticky = value === 'donor_id'; - const isSorted = props.sorted; - - return {value}; - }, - meta: { customCell: true, customHeader: true }, - cell: (context) => { - const value = context.getValue(); - const isSticky = context.column.id === 'donor_id'; - - const { isCompletionCell, errorState, style } = getCellStyles( - undefined, - context.row, - context.column, - ); - - const showSuccessSvg = isCompletionCell && !errorState; - - const content = showSuccessSvg ? ( - - ) : ( - value - ); - - return ( - - {content} - - ); - }, - })), - }, - { - id: 'submitted_donor_data_header', - meta: { customHeader: true }, - header: () => ( - - ), - columns: columns.slice(7), - }, - ]; - } - - const tableMin = totalDocs > 0 ? page * pageSize + 1 : totalDocs; - const tableMax = totalDocs < (page + 1) * pageSize ? totalDocs : (page + 1) * pageSize; - const numTablePages = Math.ceil(totalDocs / pageSize); - const numErrorPages = Math.ceil(totalErrors / errorPageSize); - - return loading ? ( - - ) : noTableData ? ( - - ) : ( -
- {hasErrors && ( -
- } - errors={tableErrors} - columnConfig={errorColumns} - tableProps={{ - page: errorPage, - pages: numErrorPages, - pageSize: errorPageSize, - sorted: errorSorted, - onPageChange: (value) => updatePageSettings('page', value), - onPageSizeChange: (value) => updatePageSettings('pageSize', value), - onSortedChange: (value) => updatePageSettings('sorted', value), - // TODO: Test + Update Pagination in #2267 - // https://github.com/icgc-argo/platform-ui/issues/2267 - showPagination: false, - }} - /> -
- )} - - - Showing {tableMin} - {tableMax} of {totalDocs} records - - } - /> -
- - ); -}; - -export default ClinicalEntityDataTable; diff --git a/src/app/(post-login)/submission/program/[shortName]/clinical-data/page.tsx b/src/app/(post-login)/submission/program/[shortName]/clinical-data/page.tsx index 383aeb67..70ec9f7f 100644 --- a/src/app/(post-login)/submission/program/[shortName]/clinical-data/page.tsx +++ b/src/app/(post-login)/submission/program/[shortName]/clinical-data/page.tsx @@ -27,11 +27,9 @@ import { useClinicalQuery } from '@/app/hooks/useApolloQuery'; import useUrlParamState from '@/app/hooks/useUrlParamState'; import { notNull, parseDonorIdString } from '@/global/utils'; import { css } from '@/lib/emotion'; -import { useQuery } from '@apollo/client'; import { Container, Loader, Typography, VerticalTabs, useTheme } from '@icgc-argo/uikit'; import { useState } from 'react'; import { setConfiguration } from 'react-grid-system'; -import ClinicalEntityDataTable from './ClinicalEntityDataTable'; import ClinicalDownloadButton from './DownloadButtons'; import SearchBar from './SearchBar'; import { @@ -137,7 +135,7 @@ const ClinicalDataPageComp = ({ programShortName }: { programShortName: string } // Side Menu Query // Populates Clinical Entity Table, Side Menu, Title Bar - const { data: sideMenuQuery, loading: sideMenuLoading } = useQuery( + const { data: sideMenuQuery, loading: sideMenuLoading } = useClinicalQuery( SUBMITTED_DATA_SIDE_MENU_QUERY, { errorPolicy: 'all', @@ -178,21 +176,23 @@ const ClinicalDataPageComp = ({ programShortName }: { programShortName: string } submitterDonorIds: useDefaultQuery ? [] : entityTableSubmitterDonorIds.filter(notNull), }; - const menuItems = clinicalEntityFields.map((entity) => ( - setSelectedClinicalEntityTab(aliasedEntityNames[entity])} - disabled={ - !clinicalData.clinicalEntities.some((e) => e?.entityName === aliasedEntityNames[entity]) - } - > - {clinicalEntityDisplayNames[entity]} - {hasClinicalErrors(clinicalData, entity) && ( - ! - )} - - )); + const menuItems = clinicalEntityFields.map((entity) => { + const aliasedEntityName = aliasedEntityNames[entity]; + + return ( + setSelectedClinicalEntityTab(aliasedEntityName)} + disabled={!clinicalData.clinicalEntities.some((e) => e.entityName === aliasedEntityName)} + > + {clinicalEntityDisplayNames[entity]} + {hasClinicalErrors(clinicalData, entity) && ( + ! + )} + + ); + }); return (
@@ -279,7 +279,7 @@ const ClinicalDataPageComp = ({ programShortName }: { programShortName: string } margin-top: 16px; `} > - + /> */}
{' '} diff --git a/src/app/(post-login)/submission/program/[shortName]/clinical-data/tableDataRefactor.tsx b/src/app/(post-login)/submission/program/[shortName]/clinical-data/tableDataRefactor.tsx new file mode 100644 index 00000000..104676e2 --- /dev/null +++ b/src/app/(post-login)/submission/program/[shortName]/clinical-data/tableDataRefactor.tsx @@ -0,0 +1,79 @@ +/** + * Reduce errors across all records into an object detailing: + * - which fields have an error, the error description and how many rows are affected + * example: if there are 7 records with fields of "cancer_type_code" that have the error MISSING_REQUIRED_FIELD + * return: something like {affectedfields: 7, error_field: cancer_type_code, error_message: "cancer_type_code is a required field"} + **/ + +export const formatTableErrors = ({ clinicalErrors, aliasedEntityName }) => { + const tableErrorGroups = []; + // { + // "donorId": 262500, + // "submitterDonorId": "Pat-1", + // "errors": [ + // { + // "errorType": "INVALID_BY_SCRIPT", + // "fieldName": "lymph_nodes_examined_method", + // "index": 0, + // "info": { + // "value": null, + // "__typename": "ClinicalErrorInfo" + // }, + // "message": "The 'lymph_nodes_examined_method' field must be submitted if the 'lymph_nodes_examined_status' field is 'Yes'", + // "entityName": "primary_diagnosis", + // "__typename": "ClinicalErrorRecord" + // }]} + // [{donorId..., errors: [{....all primary diag}]},{donorId..., errors: [{....all donor}]}] etc + // no need to loop over every single error because they are grouped already + clinicalErrors.forEach((donor) => { + const relatedErrors = donor.errors.filter((error) => error.entityName === aliasedEntityName); + console.log('realted', relatedErrors); + relatedErrors.forEach((error) => { + const { donorId } = donor; + const { errorType, message, fieldName } = error; + const relatedErrorGroup = tableErrorGroups.find( + (tableErrorGroup) => + tableErrorGroup[0].errorType === errorType && + tableErrorGroup[0].message === message && + tableErrorGroup[0].fieldName === fieldName, + ); + const tableError = { ...error, donorId }; + + if (!relatedErrorGroup) { + tableErrorGroups.push([tableError]); + } else { + relatedErrorGroup.push(tableError); + } + }); + }); + + console.log('table error groups', tableErrorGroups); + + const tableErrors = tableErrorGroups.map((errorGroup) => { + // Counts Number of Records affected for each Error Object + const { fieldName, entityName, message, errorType } = errorGroup[0]; + + const errorMessage = + errorType === 'UNRECOGNIZED_FIELD' + ? `${fieldName} is not a field within the latest dictionary. Please remove this from the ${entityName}.tsv file before submitting.` + : message; + + const entries = errorGroup.length; + + return { + entries, + fieldName, + entityName, + errorMessage, + }; + }); + + const totalErrorsAmount = tableErrors.reduce( + (errorCount, errorGroup) => errorCount + errorGroup.entries, + 0, + ); + + return { tableErrors, totalErrorsAmount }; +}; + +//