From e9a08df7a362cbc2ec3dd70e52ab4cfc84f89609 Mon Sep 17 00:00:00 2001 From: Charles Haynes <33608920+haynescd@users.noreply.github.com> Date: Wed, 7 Jun 2023 10:42:06 -0400 Subject: [PATCH] Feature/ch/10184 support patient filters sent via postdata (#4635) * refactor StudyId extractor * :hammer: Refactor Extraction code of StudyViewURL Query * Update StudyViewPage to accept filterJson from PostData * Remove debug statement * Update localdb test * Update postedQuery.spec.js * Update to use correct json format of patientIdentifiers * Remove only from test, and small clean up * Update StudyViewPage to use lodash unescape to convert html entity * Remove localdist from getUrl fn --- .../local/specs/core/postedquery.spec.js | 28 +++ end-to-end-test/shared/specUtils.js | 26 ++- src/pages/studyView/StudyViewPage.tsx | 27 +++ src/pages/studyView/StudyViewPageStore.ts | 156 +++------------ .../studyView/StudyViewQueryExtractor.ts | 187 ++++++++++++++++++ 5 files changed, 289 insertions(+), 135 deletions(-) create mode 100644 src/pages/studyView/StudyViewQueryExtractor.ts diff --git a/end-to-end-test/local/specs/core/postedquery.spec.js b/end-to-end-test/local/specs/core/postedquery.spec.js index e6bce7fc6ec..53f209bc5ae 100644 --- a/end-to-end-test/local/specs/core/postedquery.spec.js +++ b/end-to-end-test/local/specs/core/postedquery.spec.js @@ -7,6 +7,7 @@ var _ = require('lodash'); var { useExternalFrontend, waitForOncoprint, + getElementByTestHandle, } = require('../../../shared/specUtils'); const CBIOPORTAL_URL = process.env.CBIOPORTAL_URL.replace(/\/$/, ''); @@ -56,3 +57,30 @@ describe('posting query parameters (instead of GET) to query page', function() { waitForOncoprint(); }); }); + +describe('Post Data for StudyView Filtering with filterJson via HTTP Post', () => { + it('Verify PatientIdentifier Filter via postData', () => { + const filterJsonQuery = { + filterJson: + '{"patientIdentifiers":[{"studyId":"lgg_ucsf_2014_test_generic_assay","patientId":"P01"}]}', + }; + + const NUMBER_OF_PATIENTS_AFTER_FILTER = 1; + + goToUrlAndSetLocalStorage(`${CBIOPORTAL_URL}`, true); + + postDataToUrl( + `${CBIOPORTAL_URL}/study/summary?id=lgg_ucsf_2014_test_generic_assay`, + filterJsonQuery + ); + + getElementByTestHandle('selected-patients').waitForExist({ + timeout: 20000, + }); + + assert.equal( + getElementByTestHandle('selected-patients').getText(), + NUMBER_OF_PATIENTS_AFTER_FILTER + ); + }); +}); diff --git a/end-to-end-test/shared/specUtils.js b/end-to-end-test/shared/specUtils.js index 17e89d6a8aa..4595b2c9800 100644 --- a/end-to-end-test/shared/specUtils.js +++ b/end-to-end-test/shared/specUtils.js @@ -189,6 +189,22 @@ function setDropdownOpen( ); } +/** + * @param {string} url + * @returns {string} modifiedUrl + */ +function getUrl(url) { + if (!useExternalFrontend) { + console.log('Connecting to: ' + url); + } else { + const urlparam = 'localdev'; + const prefix = url.indexOf('?') > 0 ? '&' : '?'; + console.log('Connecting to: ' + `${url}${prefix}${urlparam}=true`); + url = `${url}${prefix}${urlparam}=true`; + } + return url; +} + function goToUrlAndSetLocalStorage(url, authenticated = false) { const currentUrl = browser.getUrl(); const needToLogin = @@ -554,12 +570,20 @@ function getOncoprintGroupHeaderOptionsElements(trackGroupIndex) { }; } +/** + * + * @param {string} url + * @param {any} data + * @param {boolean} authenticated + */ function postDataToUrl(url, data, authenticated = true) { const currentUrl = browser.getUrl(); const needToLogin = authenticated && (!currentUrl || !currentUrl.includes('http')); + + url = getUrl(url); browser.execute( - (url, data) => { + (/** @type {string} */ url, /** @type {any} */ data) => { function formSubmit(url, params) { // method="smart" means submit with GET iff the URL wouldn't be too long diff --git a/src/pages/studyView/StudyViewPage.tsx b/src/pages/studyView/StudyViewPage.tsx index 8c0996ba77a..96d632ebe9d 100644 --- a/src/pages/studyView/StudyViewPage.tsx +++ b/src/pages/studyView/StudyViewPage.tsx @@ -76,6 +76,7 @@ import { CustomChartData } from 'shared/api/session-service/sessionServiceModels import { HelpWidget } from 'shared/components/HelpWidget/HelpWidget'; import { buildCBioPortalPageUrl } from 'shared/api/urls'; import StudyViewPageSettingsMenu from 'pages/studyView/menu/StudyViewPageSettingsMenu'; +import QueryString from 'qs'; export interface IStudyViewPageProps { routing: any; @@ -191,6 +192,12 @@ export default class StudyViewPage extends React.Component< } } + // Overrite filterJson from URL with what is defined in postData + const postDataFilterJson = this.getFilterJsonFromPostData(); + if (postDataFilterJson) { + newStudyViewFilter.filterJson = postDataFilterJson; + } + let updateStoreFromURLPromise = remoteData(() => Promise.resolve([])); if (!_.isEqual(newStudyViewFilter, this.store.studyViewQueryFilter)) { this.store.studyViewQueryFilter = newStudyViewFilter; @@ -234,6 +241,26 @@ export default class StudyViewPage extends React.Component< }, 500); } + private getFilterJsonFromPostData(): string | undefined { + let filterJson: string | undefined; + + const parsedFilterJson = _.unescape( + getBrowserWindow()?.postData?.filterJson + ); + + if (parsedFilterJson) { + try { + JSON.parse(parsedFilterJson); + filterJson = parsedFilterJson; + } catch (error) { + console.error( + `PostData.filterJson does not have valid JSON, error: ${error}` + ); + } + } + return filterJson; + } + @autobind private toolbarRef(ref: any) { this.toolbar = ref; diff --git a/src/pages/studyView/StudyViewPageStore.ts b/src/pages/studyView/StudyViewPageStore.ts index ad94f865ec4..eab2662033f 100644 --- a/src/pages/studyView/StudyViewPageStore.ts +++ b/src/pages/studyView/StudyViewPageStore.ts @@ -273,6 +273,13 @@ import { PatientIdentifier, PatientIdentifierFilter, } from 'shared/model/PatientIdentifierFilter'; +import { + ClinicalAttributeQueryExtractor, + SharedGroupsAndCustomDataQueryExtractor, + StudyIdQueryExtractor, + StudyViewFilterQueryExtractor, + StudyViewQueryExtractor, +} from './StudyViewQueryExtractor'; export const STUDY_VIEW_FILTER_AUTOSUBMIT = 'study_view_filter_autosubmit'; @@ -2136,114 +2143,27 @@ export class StudyViewPageStore @action async updateStoreFromURL(query: StudyViewURLQuery): Promise { - let studyIdsString: string = ''; - let studyIds: string[] = []; - if (query.studyId) { - studyIdsString = query.studyId; - } - if (query.cancer_study_id) { - studyIdsString = query.cancer_study_id; - } - if (query.id) { - studyIdsString = query.id; - } - if (studyIdsString) { - studyIds = studyIdsString.trim().split(','); - if (!_.isEqual(studyIds, toJS(this.studyIds))) { - // update if different - this.studyIds = studyIds; - } - } - if (query.sharedGroups) { - this.sharedGroupSet = stringListToSet( - query.sharedGroups.trim().split(',') - ); - // Open group comparison manager if there are shared groups in the url - this.showComparisonGroupUI = true; - } - if (query.sharedCustomData) { - this.sharedCustomChartSet = stringListToSet( - query.sharedCustomData.trim().split(',') - ); - this.showCustomDataSelectionUI = true; - } + const queryExtractors: Array> = [ + new StudyIdQueryExtractor(), + new SharedGroupsAndCustomDataQueryExtractor(), + ]; - // We do not support studyIds in the query filters - let filters: Partial = {}; + const asyncQueryExtractors: Array + >> = []; if (query.filterJson) { - const parsedFilterJson = this.parseRawFilterJson(query.filterJson); - if (query.filterJson.includes('patientIdentifiers')) { - const sampleListIds = studyIds.map(s => s.concat('', '_all')); - const samples = await this.fetchSamplesWithSampleListIds( - sampleListIds - ); - filters = this.getStudyViewFilterFromPatientIdentifierFilter( - parsedFilterJson as PatientIdentifierFilter, - samples - ); - } else { - filters = parsedFilterJson as Partial; - } - this.updateStoreByFilters(filters); + asyncQueryExtractors.push(new StudyViewFilterQueryExtractor()); } else if (query.filterAttributeId && query.filterValues) { - const clinicalAttributes = _.uniqBy( - await defaultClient.fetchClinicalAttributesUsingPOST({ - studyIds: studyIds, - }), - clinicalAttribute => - `${clinicalAttribute.patientAttribute}-${clinicalAttribute.clinicalAttributeId}` - ); - - const matchedAttr = _.find( - clinicalAttributes, - (attr: ClinicalAttribute) => - attr.clinicalAttributeId.toUpperCase() === - query.filterAttributeId!.toUpperCase() - ); - if (matchedAttr !== undefined) { - if (matchedAttr.datatype == DataType.NUMBER) { - filters.clinicalDataFilters = [ - { - attributeId: matchedAttr.clinicalAttributeId, - values: query - .filterValues!.split(',') - .map(range => { - const convertResult = range.split('-'); - return { - start: Number(convertResult[0]), - end: Number(convertResult[1]), - } as DataFilterValue; - }), - } as ClinicalDataFilter, - ]; - } else { - filters.clinicalDataFilters = [ - { - attributeId: matchedAttr.clinicalAttributeId, - values: getClinicalEqualityFilterValuesByString( - query.filterValues - ).map(value => ({ value })), - } as ClinicalDataFilter, - ]; - } - this.updateStoreByFilters(filters); - } else { - this.pageStatusMessages['unknownClinicalAttribute'] = { - message: `The clinical attribute ${query.filterAttributeId} is not available for this study`, - status: 'danger', - }; - } + asyncQueryExtractors.push(new ClinicalAttributeQueryExtractor()); } - } - parseRawFilterJson(filterJson: string): any { - let rawJson; - try { - rawJson = JSON.parse(decodeURIComponent(filterJson)); - } catch (e) { - console.error('FilterJson invalid Json: error: ', e); + for (const extractor of queryExtractors) { + extractor.accept(query, this); } - return rawJson; + + await Promise.all( + asyncQueryExtractors.map(ex => ex.accept(query, this)) + ); } fetchSamplesWithSampleListIds(sampleListIds: string[]) { @@ -2255,38 +2175,6 @@ export class StudyViewPageStore }); } - getStudyViewFilterFromPatientIdentifierFilter( - patientIdentifierFilter: PatientIdentifierFilter, - samples: Sample[] - ): Partial { - const filters: Partial = {}; - const sampleIdentifiers = this.convertPatientIdentifiersToSampleIdentifiers( - patientIdentifierFilter.patientIdentifiers, - samples - ); - if (sampleIdentifiers.length > 0) { - filters.sampleIdentifiers = sampleIdentifiers; - } - return filters; - } - - convertPatientIdentifiersToSampleIdentifiers( - patientIdentifiers: Array, - samples: Sample[] - ): SampleIdentifier[] { - const patientIdentifiersMap = new Map( - patientIdentifiers.map(p => [p.studyId.concat('_', p.patientId), p]) - ); - return samples - .filter(s => - patientIdentifiersMap.has(s.studyId.concat('_', s.patientId)) - ) - .map(s => ({ - sampleId: s.sampleId, - studyId: s.studyId, - })); - } - @computed get initialFilters(): StudyViewFilter { let initialFilter = {} as StudyViewFilter; diff --git a/src/pages/studyView/StudyViewQueryExtractor.ts b/src/pages/studyView/StudyViewQueryExtractor.ts new file mode 100644 index 00000000000..c1555b9af9a --- /dev/null +++ b/src/pages/studyView/StudyViewQueryExtractor.ts @@ -0,0 +1,187 @@ +import { toJS } from 'mobx'; +import { StudyViewPageStore, StudyViewURLQuery } from './StudyViewPageStore'; +import _ from 'lodash'; +import { + ClinicalAttribute, + ClinicalDataFilter, + DataFilterValue, + StudyViewFilter, +} from 'cbioportal-ts-api-client/dist'; +import { + PatientIdentifier, + PatientIdentifierFilter, +} from 'shared/model/PatientIdentifierFilter'; +import { Sample } from 'cbioportal-ts-api-client/dist'; +import { SampleIdentifier } from 'cbioportal-ts-api-client/dist'; +import defaultClient from 'shared/api/cbioportalClientInstance'; +import { + getClinicalEqualityFilterValuesByString, + DataType, +} from './StudyViewUtils'; +import { stringListToSet } from 'cbioportal-frontend-commons'; + +export interface StudyViewQueryExtractor { + accept(query: StudyViewURLQuery, store: StudyViewPageStore): T; +} + +// TODO: Refactor even further to abstract all updates to StudyViewPageStore.... Should only be doing this at one place +export class StudyIdQueryExtractor implements StudyViewQueryExtractor { + accept(query: StudyViewURLQuery, store: StudyViewPageStore): void { + let studyIds: Array = []; + const studyIdsString = + query.studyId ?? query.cancer_study_id ?? query.id ?? ''; + if (studyIdsString) { + studyIds = studyIdsString.trim().split(','); + if (!_.isEqual(studyIds, toJS(store.studyIds))) { + // update if different + store.studyIds = studyIds; + } + } + } +} + +export class SharedGroupsAndCustomDataQueryExtractor + implements StudyViewQueryExtractor { + accept(query: StudyViewURLQuery, store: StudyViewPageStore): void { + if (query.sharedGroups) { + store.sharedGroupSet = stringListToSet( + query.sharedGroups.trim().split(',') + ); + // Open group comparison manager if there are shared groups in the url + store.showComparisonGroupUI = true; + } + if (query.sharedCustomData) { + store.sharedCustomChartSet = stringListToSet( + query.sharedCustomData.trim().split(',') + ); + store.showCustomDataSelectionUI = true; + } + } +} + +export class StudyViewFilterQueryExtractor + implements StudyViewQueryExtractor> { + async accept( + query: StudyViewURLQuery, + store: StudyViewPageStore + ): Promise { + let filters: Partial = {}; + const parsedFilterJson = this.parseRawFilterJson(query.filterJson!); + if (query.filterJson!.includes('patientIdentifiers')) { + const sampleListIds = store.studyIds.map(s => s.concat('', '_all')); + const samples = await store.fetchSamplesWithSampleListIds( + sampleListIds + ); + filters = this.getStudyViewFilterFromPatientIdentifierFilter( + parsedFilterJson as PatientIdentifierFilter, + samples + ); + } else { + filters = parsedFilterJson as Partial; + } + store.updateStoreByFilters(filters); + } + + parseRawFilterJson(filterJson: string): any { + let parsedJson; + try { + parsedJson = JSON.parse(decodeURIComponent(filterJson)); + } catch (e) { + console.error('FilterJson invalid Json: error: ', e); + } + return parsedJson; + } + + getStudyViewFilterFromPatientIdentifierFilter( + patientIdentifierFilter: PatientIdentifierFilter, + samples: Sample[] + ): Partial { + const filters: Partial = {}; + try { + const sampleIdentifiers = this.convertPatientIdentifiersToSampleIdentifiers( + patientIdentifierFilter.patientIdentifiers, + samples + ); + if (sampleIdentifiers.length > 0) { + filters.sampleIdentifiers = sampleIdentifiers; + } + } catch (err) { + console.error( + `Failure to extract SampleIds from PatientIdentifier filter error: ${err}` + ); + } + return filters; + } + + convertPatientIdentifiersToSampleIdentifiers( + patientIdentifiers: Array, + samples: Sample[] + ): SampleIdentifier[] { + const patientIdentifiersMap = new Map( + patientIdentifiers.map(p => [p.studyId.concat('_', p.patientId), p]) + ); + return samples + .filter(s => + patientIdentifiersMap.has(s.studyId.concat('_', s.patientId)) + ) + .map(s => ({ + sampleId: s.sampleId, + studyId: s.studyId, + })); + } +} + +export class ClinicalAttributeQueryExtractor + implements StudyViewQueryExtractor> { + async accept( + query: StudyViewURLQuery, + store: StudyViewPageStore + ): Promise { + const filters: Partial = {}; + const clinicalAttributes = _.uniqBy( + await defaultClient.fetchClinicalAttributesUsingPOST({ + studyIds: store.studyIds, + }), + clinicalAttribute => + `${clinicalAttribute.patientAttribute}-${clinicalAttribute.clinicalAttributeId}` + ); + + const matchedAttr = _.find( + clinicalAttributes, + (attr: ClinicalAttribute) => + attr.clinicalAttributeId.toUpperCase() === + query.filterAttributeId!.toUpperCase() + ); + if (matchedAttr !== undefined) { + if (matchedAttr.datatype == DataType.NUMBER) { + filters.clinicalDataFilters = [ + { + attributeId: matchedAttr.clinicalAttributeId, + values: query.filterValues!.split(',').map(range => { + const convertResult = range.split('-'); + return { + start: Number(convertResult[0]), + end: Number(convertResult[1]), + } as DataFilterValue; + }), + } as ClinicalDataFilter, + ]; + } else { + filters.clinicalDataFilters = [ + { + attributeId: matchedAttr.clinicalAttributeId, + values: getClinicalEqualityFilterValuesByString( + query.filterValues! + ).map(value => ({ value })), + } as ClinicalDataFilter, + ]; + } + store.updateStoreByFilters(filters); + } else { + store.pageStatusMessages['unknownClinicalAttribute'] = { + message: `The clinical attribute ${query.filterAttributeId} is not available for this study`, + status: 'danger', + }; + } + } +}