From c9cedb78cc0c8269517175d947f44aee316abe36 Mon Sep 17 00:00:00 2001 From: Tatiana Date: Mon, 5 Aug 2024 13:30:49 -0700 Subject: [PATCH] add "Files & Links" tab of the Patient View to the Study View (#10467) --- src/pages/studyView/StudyViewPage.tsx | 7 +- src/pages/studyView/StudyViewPageStore.ts | 37 ++ .../studyView/resources/FilesAndLinks.tsx | 343 ++++++++++++++++++ .../studyView/resources/ResourcesTab.tsx | 30 +- src/shared/api/urls.ts | 29 +- 5 files changed, 430 insertions(+), 16 deletions(-) create mode 100644 src/pages/studyView/resources/FilesAndLinks.tsx diff --git a/src/pages/studyView/StudyViewPage.tsx b/src/pages/studyView/StudyViewPage.tsx index 508fe36e461..0971f3c73fe 100644 --- a/src/pages/studyView/StudyViewPage.tsx +++ b/src/pages/studyView/StudyViewPage.tsx @@ -378,11 +378,8 @@ export default class StudyViewPage extends React.Component< } @computed get shouldShowResources() { - if (this.store.resourceIdToResourceData.isComplete) { - return _.some( - this.store.resourceIdToResourceData.result, - data => data.length > 0 - ); + if (this.store.resourceDefinitions.isComplete) { + return this.store.resourceDefinitions.result.length > 0; } else { return false; } diff --git a/src/pages/studyView/StudyViewPageStore.ts b/src/pages/studyView/StudyViewPageStore.ts index 98d9da7d788..c8f181f3131 100644 --- a/src/pages/studyView/StudyViewPageStore.ts +++ b/src/pages/studyView/StudyViewPageStore.ts @@ -5784,6 +5784,43 @@ export class StudyViewPageStore }, }); + readonly sampleResourceData = remoteData<{ + [sampleId: string]: ResourceData[]; + }>({ + await: () => [this.resourceDefinitions, this.samples], + invoke: () => { + const sampleResourceDefinitions = this.resourceDefinitions.result!.filter( + d => d.resourceType === 'SAMPLE' + ); + if (!sampleResourceDefinitions.length) { + return Promise.resolve({}); + } + + const samples = this.samples.result!; + const ret: { [sampleId: string]: ResourceData[] } = {}; + const promises = []; + for (const sample of samples) { + for (const resource of sampleResourceDefinitions) { + promises.push( + internalClient + .getAllResourceDataOfSampleInStudyUsingGET({ + sampleId: sample.sampleId, + studyId: sample.studyId, // TODO: + resourceId: resource.resourceId, + projection: 'DETAILED', + }) + .then(data => { + ret[sample.sampleId] = + ret[sample.sampleId] || []; + ret[sample.sampleId].push(...data); + }) + ); + } + } + return Promise.all(promises).then(() => ret); + }, + }); + readonly resourceIdToResourceData = remoteData<{ [resourceId: string]: ResourceData[]; }>({ diff --git a/src/pages/studyView/resources/FilesAndLinks.tsx b/src/pages/studyView/resources/FilesAndLinks.tsx new file mode 100644 index 00000000000..e868651f6c9 --- /dev/null +++ b/src/pages/studyView/resources/FilesAndLinks.tsx @@ -0,0 +1,343 @@ +import * as React from 'react'; +import { + Column, + default as LazyMobXTable, +} from 'shared/components/lazyMobXTable/LazyMobXTable'; +import { observer } from 'mobx-react'; +import _ from 'lodash'; +import internalClient from 'shared/api/cbioportalInternalClientInstance'; +import { Else, If, Then } from 'react-if'; +import { WindowWidthBox } from '../../../shared/components/WindowWidthBox/WindowWidthBox'; +import LoadingIndicator from 'shared/components/loadingIndicator/LoadingIndicator'; +import { + getSampleViewUrlWithPathname, + getPatientViewUrlWithPathname, +} from 'shared/api/urls'; +import { getAllClinicalDataByStudyViewFilter } from '../StudyViewUtils'; +import { StudyViewPageStore } from 'pages/studyView/StudyViewPageStore'; +import { isUrl, remoteData } from 'cbioportal-frontend-commons'; +import { makeObservable, observable, computed } from 'mobx'; +import { ResourceData, StudyViewFilter } from 'cbioportal-ts-api-client'; + +export interface IFilesLinksTable { + store: StudyViewPageStore; +} + +class FilesLinksTableComponent extends LazyMobXTable<{ + [id: string]: string | number; +}> {} + +const RECORD_LIMIT = 500; + +async function fetchResourceDataOfPatient(patientIds: Map) { + const ret: { [key: string]: ResourceData[] } = {}; + const promises = []; + for (let [patientId, studyId] of patientIds) { + promises.push( + internalClient + .getAllResourceDataOfPatientInStudyUsingGET({ + studyId: studyId, + patientId: patientId, + projection: 'DETAILED', + }) + .then(data => { + if (patientId in ret) { + ret[patientId].push(...data); + } else { + ret[patientId] = data; + } + }) + ); + } + + return Promise.all(promises).then(() => ret); +} + +async function fetchFilesLinksData( + filters: StudyViewFilter, + sampleIdResourceData: { [sampleId: string]: ResourceData[] }, + searchTerm: string | undefined, + sortAttributeId: string | undefined, + sortDirection: 'asc' | 'desc' | undefined, + recordLimit: number +) { + const sampleClinicalDataResponse = await getAllClinicalDataByStudyViewFilter( + filters, + searchTerm, + sortAttributeId, + sortDirection, + recordLimit, + 0 + ); + + // get unique patient Ids from clinical data to get their resources + // via fetchResourceDataOfPatient. + const patientIds = new Map(); + _.forEach(sampleClinicalDataResponse.data, data => { + _.forEach(data, item => { + patientIds.set(item.patientId, item.studyId); + }); + }); + + // get all resources for patients. + // improvement: use one request for getting a page of patients + // with their samples and resources. + const resourcesForPatients = await fetchResourceDataOfPatient(patientIds); + const buildItemsAndResources = (resourceData: { + [key: string]: ResourceData[]; + }) => { + const resourcesPerPatient: { [key: string]: number } = {}; + const items: { [attributeId: string]: string | number }[] = []; + _.forEach(resourceData, (data: ResourceData[]) => { + _.forEach(data, (resource: ResourceData) => { + items.push({ + studyId: resource.studyId, + patientId: resource.patientId, + sampleId: resource.sampleId, + resourcesPerPatient: 0, + typeOfResource: resource?.resourceDefinition?.displayName, + description: resource?.resourceDefinition?.description, + url: resource?.url, + } as { [attributeId: string]: string | number }); + }); + + if (data && data.length > 0) { + if (!(data[0].patientId in resourcesPerPatient)) + resourcesPerPatient[data[0].patientId] = 0; + + resourcesPerPatient[data[0].patientId] += data.length; + } + }); + + return { resourcesPerPatient, items }; + }; + + const resourcesForPatientsAndSamples: { [key: string]: ResourceData[] } = { + ...sampleIdResourceData, + ...resourcesForPatients, + }; + + // we create objects with the necessary properties for each resource + // calculate the total number of resources per patient. + const { resourcesPerPatient, items } = buildItemsAndResources( + resourcesForPatientsAndSamples + ); + + // set the number of resources available per patient. + _.forEach(items, item => { + item.resourcesPerPatient = resourcesPerPatient[item.patientId]; + }); + + // there is a requirement to sort initially by 'resourcesPerPatient' field + // in descending order. + const sortedData = _.orderBy(items, 'resourcesPerPatient', 'desc'); + return { + totalItems: sortedData.length, + data: _.values(sortedData), + }; +} + +@observer +export class FilesAndLinks extends React.Component { + constructor(props: IFilesLinksTable) { + super(props); + makeObservable(this); + } + + getDefaultColumnConfig( + key: string, + columnName: string, + isNumber?: boolean + ) { + return { + name: columnName || '', + headerRender: (data: string) => ( + {data} + ), + render: (data: { [id: string]: string }) => { + if (isUrl(data[key])) { + return ( + + {data[key]} + + ); + } + return {data[key]}; + }, + download: (data: { [id: string]: string }) => data[key] || '', + sortBy: (data: { [id: string]: any }) => { + if (data[key]) { + return data[key]; + } + return null; + }, + filter: ( + data: { [id: string]: string }, + filterString: string, + filterStringUpper: string + ) => { + if (data[key]) { + if (!isNumber) { + return (data[key] || '') + .toUpperCase() + .includes(filterStringUpper); + } + } + + return false; + }, + }; + } + + @observable searchTerm: string | undefined = undefined; + + readonly resourceData = remoteData({ + await: () => [ + this.props.store.selectedSamples, + this.props.store.resourceDefinitions, + this.props.store.sampleResourceData, + ], + onError: () => {}, + invoke: async () => { + if (this.props.store.selectedSamples.result.length === 0) { + return Promise.resolve({ totalItems: 0, data: [] }); + } + const resources = await fetchFilesLinksData( + this.props.store.filters, + this.props.store.sampleResourceData.result!, + this.searchTerm, + 'patientId', + 'asc', + RECORD_LIMIT + ); + + return Promise.resolve(resources); + }, + }); + + @computed get columns() { + let defaultColumns: Column<{ [id: string]: any }>[] = [ + { + ...this.getDefaultColumnConfig('patientId', 'Patient ID'), + render: (data: { [id: string]: string }) => { + return ( + + {data.patientId} + + ); + }, + }, + + { + ...this.getDefaultColumnConfig('sampleId', 'Sample ID'), + render: (data: { [id: string]: string }) => { + return ( + + {data.sampleId} + + ); + }, + }, + + { + ...this.getDefaultColumnConfig( + 'typeOfResource', + 'Type Of Resource' + ), + render: (data: { [id: string]: string }) => { + return ( +
+ + + {data.typeOfResource} + +
+ ); + }, + }, + + { + ...this.getDefaultColumnConfig('description', 'Description'), + render: (data: { [id: string]: string }) => { + return
{data.description}
; + }, + }, + + { + ...this.getDefaultColumnConfig( + 'resourcesPerPatient', + 'Number of Resource Per Patient', + true + ), + render: (data: { [id: string]: number }) => { + return
{data.resourcesPerPatient}
; + }, + }, + ]; + + return defaultColumns; + } + + public render() { + return ( + + + + + + + + + + { + this.resourceData.result + ?.totalItems + }{' '} + resources + + + } + data={this.resourceData.result?.data || []} + columns={this.columns} + showColumnVisibility={false} + showCountHeader={false} + showFilterClearButton={false} + showCopyDownload={false} + initialSortColumn={'resourcesPerPatient'} + initialSortDirection={'desc'} + /> + + + + + ); + } +} diff --git a/src/pages/studyView/resources/ResourcesTab.tsx b/src/pages/studyView/resources/ResourcesTab.tsx index a4699f4526a..f730ab01c89 100644 --- a/src/pages/studyView/resources/ResourcesTab.tsx +++ b/src/pages/studyView/resources/ResourcesTab.tsx @@ -7,6 +7,8 @@ import { StudyViewPageStore } from '../StudyViewPageStore'; import { ResourceData } from 'cbioportal-ts-api-client'; import ResourceTable from 'shared/components/resources/ResourceTable'; +import { FilesAndLinks } from './FilesAndLinks'; + export interface IResourcesTabProps { store: StudyViewPageStore; openResource: (resource: ResourceData) => void; @@ -52,15 +54,25 @@ export default class ResourcesTab extends React.Component< render() { return ( -
- -
-
-
{this.studyResources.component}
+
+
+ +
+
+
{this.studyResources.component}
+
+
+
+

+ Patient and Sample Resources +

+ +
+
); } diff --git a/src/shared/api/urls.ts b/src/shared/api/urls.ts index b54dd9179ea..771af857233 100644 --- a/src/shared/api/urls.ts +++ b/src/shared/api/urls.ts @@ -120,6 +120,15 @@ export function getSampleViewUrl( studyId: string, sampleId: string, navIds?: { patientId: string; studyId: string }[] +) { + return getSampleViewUrlWithPathname(studyId, sampleId, 'patient', navIds); +} + +export function getSampleViewUrlWithPathname( + studyId: string, + sampleId: string, + pathname: string = 'patient', + navIds?: { patientId: string; studyId: string }[] ) { let hash: any = undefined; if (navIds) { @@ -127,8 +136,9 @@ export function getSampleViewUrl( .map(id => `${id.studyId}:${id.patientId}`) .join(',')}`; } - return buildCBioPortalPageUrl('patient', { sampleId, studyId }, hash); + return buildCBioPortalPageUrl(pathname, { sampleId, studyId }, hash); } + export function getPatientViewUrl( studyId: string, caseId: string, @@ -140,7 +150,22 @@ export function getPatientViewUrl( .map(id => `${id.studyId}:${id.patientId}`) .join(',')}`; } - return buildCBioPortalPageUrl('patient', { studyId, caseId }, hash); + return getPatientViewUrlWithPathname(studyId, caseId, 'patient', navIds); +} + +export function getPatientViewUrlWithPathname( + studyId: string, + caseId: string, + pathname: string = 'patient', + navIds?: { patientId: string; studyId: string }[] +) { + let hash: any = undefined; + if (navIds) { + hash = `navCaseIds=${navIds + .map(id => `${id.studyId}:${id.patientId}`) + .join(',')}`; + } + return buildCBioPortalPageUrl(pathname, { studyId, caseId }, hash); } export function getComparisonUrl(params: Partial) {