diff --git a/src/pages/patientView/PatientViewPage.tsx b/src/pages/patientView/PatientViewPage.tsx index 6eac869b2b8..575385709b4 100644 --- a/src/pages/patientView/PatientViewPage.tsx +++ b/src/pages/patientView/PatientViewPage.tsx @@ -277,6 +277,11 @@ export class PatientViewPageInner extends React.Component< @computed get shouldShowResources(): boolean { + const tabId: string = this.urlWrapper.activeTabId; + if (tabId === 'filesAndLinks') { + return true; + } + if (this.pageStore.resourceIdToResourceData.isComplete) { return _.some( this.pageStore.resourceIdToResourceData.result, diff --git a/src/pages/patientView/resources/ResourcesTab.tsx b/src/pages/patientView/resources/ResourcesTab.tsx index 86ee1678e00..f34b3beeea8 100644 --- a/src/pages/patientView/resources/ResourcesTab.tsx +++ b/src/pages/patientView/resources/ResourcesTab.tsx @@ -118,6 +118,40 @@ export default class ResourcesTab extends React.Component< }, }); + readonly showNoResource = MakeMobxView({ + await: () => [this.props.store.resourceIdToResourceData], + render: () => { + const shouldShowNoResource = () => { + if (this.props.store.resourceIdToResourceData.isComplete) { + return !_.some( + this.props.store.resourceIdToResourceData.result, + data => data.length > 0 + ); + } + return true; + }; + + if (shouldShowNoResource()) { + return ( +
+

+ Resources for {this.props.store.patientId} +

+ +
+ ); + } else { + return null; + } + }, + }); + render() { return (
@@ -136,6 +170,7 @@ export default class ResourcesTab extends React.Component< {this.patientResources.component} {this.sampleResources.component} {this.studyResources.component} + {this.showNoResource.component}
); 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..cafc79680de 100644 --- a/src/pages/studyView/StudyViewPageStore.ts +++ b/src/pages/studyView/StudyViewPageStore.ts @@ -5784,6 +5784,44 @@ 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 res = _(this.samples.result!) + .map(sample => + sampleResourceDefinitions.map(resource => + internalClient.getAllResourceDataOfSampleInStudyUsingGET( + { + sampleId: sample.sampleId, + studyId: sample.studyId, + resourceId: resource.resourceId, + projection: 'DETAILED', + } + ) + ) + ) + .flatten() + .value(); + + return Promise.all(res).then(resData => + _(resData) + .flatMap() + .groupBy('sampleId') + .mapValues(data => data) + .value() + ); + }, + }); + 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..39f5cea2c08 --- /dev/null +++ b/src/pages/studyView/resources/FilesAndLinks.tsx @@ -0,0 +1,329 @@ +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 fetchFilesLinksData( + filters: StudyViewFilter, + sampleIdResourceData: { [sampleId: string]: ResourceData[] }, + searchTerm: string | undefined, + sortAttributeId: string | undefined, + sortDirection: 'asc' | 'desc' | undefined, + recordLimit: number +) { + const studyClinicalDataResponse = await getAllClinicalDataByStudyViewFilter( + filters, + searchTerm, + sortAttributeId, + sortDirection, + recordLimit, + 0 + ); + const getResourceDataOfPatients = async () => { + const resourcesPerPatient = _(studyClinicalDataResponse.data) + .flatMap(clinicaldataItems => clinicaldataItems) + .uniqBy('patientId') + .map(resource => + internalClient.getAllResourceDataOfPatientInStudyUsingGET({ + studyId: resource.studyId, + patientId: resource.patientId, + projection: 'DETAILED', + }) + ) + .flatten() + .value(); + + return Promise.all(resourcesPerPatient).then(resourcesPerPatient => + _(resourcesPerPatient) + .flatMap() + .groupBy('patientId') + .value() + ); + }; + + const resourcesForPatients = await getResourceDataOfPatients(); + const buildItemsAndResources = (resourceData: { + [key: string]: ResourceData[]; + }) => { + const resourcesPerPatient: { [key: string]: number } = {}; + const items: { [attributeId: string]: string | number }[] = _( + resourceData + ) + .flatMap(data => + data.map(resource => ({ + studyId: resource.studyId, + patientId: resource.patientId, + sampleId: resource.sampleId, + resourcesPerPatient: 0, + typeOfResource: resource?.resourceDefinition?.displayName, + description: resource?.resourceDefinition?.description, + url: resource?.url, + })) + ) + .value(); + + _(resourceData).forEach(data => { + const patientId = data[0]?.patientId; + if (patientId) { + resourcesPerPatient[patientId] = + (resourcesPerPatient[patientId] || 0) + 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) { diff --git a/src/shared/components/resources/ResourceTable.tsx b/src/shared/components/resources/ResourceTable.tsx index 2939bb7c33d..53e20061e9a 100644 --- a/src/shared/components/resources/ResourceTable.tsx +++ b/src/shared/components/resources/ResourceTable.tsx @@ -58,38 +58,48 @@ const ResourceTable = observer( Resource - Description + {resourceTable.data.length > 0 && Description} - {resourceTable.data.map(resource => ( + {resourceTable.data.length === 0 ? ( - - openResource(resource)}> - {icon(resource)} - {resource.resourceDefinition.displayName || - resource.url} - + + There are no results - - - - Open in new window - - - {resource.resourceDefinition.description} - ))} + ) : ( + resourceTable.data.map(resource => ( + + + openResource(resource)}> + {icon(resource)} + {resource.resourceDefinition + .displayName || resource.url} + + + + + + Open in new window + + + + {resource.resourceDefinition.description} + + + )) + )} );