From 9ca3198effa01004919e75da30b5e287a5ae601b Mon Sep 17 00:00:00 2001 From: Ruslan Forostianov Date: Thu, 18 Jul 2024 22:24:31 +0200 Subject: [PATCH] RFC83: Make virtual study available for all users on their landing pages (#4923) * Render public virtual studies as a separate section * Use public virtual study metadata to blend it in regular studies --- .../local/runtime-config/portal.properties | 2 + .../local/specs/virtual-study.spec.js | 171 ++++++++++++++++++ .../api/session-service/sessionServiceAPI.ts | 16 ++ .../session-service/sessionServiceModels.ts | 2 + src/shared/api/urls.ts | 6 +- .../components/query/CancerStudyTreeData.ts | 34 ++++ src/shared/components/query/QueryStore.ts | 68 ++++++- .../components/query/studyList/StudyList.tsx | 5 +- 8 files changed, 295 insertions(+), 9 deletions(-) create mode 100644 end-to-end-test/local/specs/virtual-study.spec.js diff --git a/end-to-end-test/local/runtime-config/portal.properties b/end-to-end-test/local/runtime-config/portal.properties index 8d38e21908e..cad2c8f36e7 100644 --- a/end-to-end-test/local/runtime-config/portal.properties +++ b/end-to-end-test/local/runtime-config/portal.properties @@ -181,6 +181,8 @@ session.service.url= # session.service.user=user # session.service.password=pass +session.endpoint.publisher-api-key=SECRETKEY + # disabled tabs, | delimited # possible values: cancer_types_summary, mutual_exclusivity, plots, mutations, co_expression, enrichments, survival, network, download, bookmark, IGV disabled_tabs= diff --git a/end-to-end-test/local/specs/virtual-study.spec.js b/end-to-end-test/local/specs/virtual-study.spec.js new file mode 100644 index 00000000000..d3d873180bb --- /dev/null +++ b/end-to-end-test/local/specs/virtual-study.spec.js @@ -0,0 +1,171 @@ +var assert = require('assert'); +var goToUrlAndSetLocalStorage = require('../../shared/specUtils') + .goToUrlAndSetLocalStorage; + +const CBIOPORTAL_URL = process.env.CBIOPORTAL_URL.replace(/\/$/, ''); +const studyEs0Summary = CBIOPORTAL_URL + '/study/summary?id=study_es_0'; + +describe('Virtual Study life cycle', function() { + const vsTitle = 'Test VS ' + Date.now(); + let link; + let vsId; + const X_PUBLISHER_API_KEY = 'SECRETKEY'; + + it('Login and navigate to the study_es_0 study summary page', function() { + goToUrlAndSetLocalStorage(studyEs0Summary, true); + }); + it('Click Share Virtual Study button', function() { + const studyView = $('.studyView'); + const shareVSBtn = studyView.$( + 'button[data-tour="action-button-bookmark"]' + ); + shareVSBtn.waitForClickable(); + shareVSBtn.click(); + }); + it('Provide the title and save', function() { + const modalDialog = $('.modal-dialog'); + modalDialog.waitForDisplayed(); + const titleInput = modalDialog.$('input#sniglet'); + titleInput.setValue(vsTitle); + const saveBtn = modalDialog.$( + '[data-tour="virtual-study-summary-save-btn"]' + ); + saveBtn.click(); + modalDialog.$('.text-success').waitForDisplayed(); + const linkInput = modalDialog.$('input[type="text"]'); + link = linkInput.getValue(); + assert.ok( + link.startsWith('http'), + 'The value should be link, but was ' + link + ); + vsId = link + .split('?')[1] + .split('&') + .map(paramEqValue => paramEqValue.split('=')) + .find(([key, value]) => key === 'id')[1]; + assert.ok(vsId, 'Virtual Study ID has not to be empty'); + }); + it('See the VS in My Virtual Studies section on the landing page', function() { + goToUrlAndSetLocalStorage(CBIOPORTAL_URL, true); + const vsSection = $(`//*[text()="${vsTitle}"]/ancestor::ul[1]`); + vsSection.waitForDisplayed(); + const sectionTitle = vsSection.$('li label span'); + assert.equal(sectionTitle.getText(), 'My Virtual Studies'); + }); + it('Publish the VS', function() { + const result = browser.executeAsync( + function(cbioUrl, vsId, key, done) { + const url = cbioUrl + '/api/public_virtual_studies/' + vsId; + const headers = new Headers(); + headers.append('X-PUBLISHER-API-KEY', key); + fetch(url, { + method: 'POST', + headers: headers, + }) + .then(response => { + done({ + success: response.ok, + message: 'HTTP Status: ' + response.status, + }); + }) + .catch(error => { + done({ success: false, message: error.message }); + }); + }, + CBIOPORTAL_URL, + vsId, + X_PUBLISHER_API_KEY + ); + assert.ok(result.success, result.message); + }); + it('See the VS in Public Virtual Studies section on the landing page', function() { + goToUrlAndSetLocalStorage(CBIOPORTAL_URL, true); + const vsSection = $(`//*[text()="${vsTitle}"]/ancestor::ul[1]`); + vsSection.waitForDisplayed(); + const sectionTitle = vsSection.$('li label span'); + assert.equal(sectionTitle.getText(), 'Public Virtual Studies'); + }); + it('Re-publish the VS specifying PubMed ID and type of cancer', function() { + const result = browser.executeAsync( + function(cbioUrl, vsId, key, done) { + const headers = new Headers(); + headers.append('X-PUBLISHER-API-KEY', key); + fetch( + cbioUrl + + '/api/public_virtual_studies/' + + vsId + + '?pmid=28783718&typeOfCancerId=aca', + { + method: 'POST', + headers: headers, + } + ) + .then(response => { + done({ + success: response.ok, + message: 'HTTP Status: ' + response.status, + }); + }) + .catch(error => { + done({ success: false, message: error.message }); + }); + }, + CBIOPORTAL_URL, + vsId, + X_PUBLISHER_API_KEY + ); + assert.ok(result.success, result.message); + }); + it('See the VS in the Adrenocortical Adenoma section with PubMed link', function() { + goToUrlAndSetLocalStorage(CBIOPORTAL_URL, true); + const vsRow = $(`//*[text()="${vsTitle}"]/ancestor::li[1]`); + const vsSection = vsRow.parentElement(); + vsSection.waitForDisplayed(); + const sectionTitle = vsSection.$('li label span'); + assert.equal(sectionTitle.getText(), 'Adrenocortical Adenoma'); + //has PubMed link + assert.ok(vsRow.$('.fa-book').isExisting()); + }); + it('Un-publish the VS', function() { + const result = browser.executeAsync( + function(cbioUrl, vsId, key, done) { + const headers = new Headers(); + headers.append('X-PUBLISHER-API-KEY', key); + fetch(cbioUrl + '/api/public_virtual_studies/' + vsId, { + method: 'DELETE', + headers: headers, + }) + .then(response => { + done({ + success: response.ok, + message: 'HTTP Status: ' + response.status, + }); + }) + .catch(error => { + done({ success: false, message: error.message }); + }); + }, + CBIOPORTAL_URL, + vsId, + X_PUBLISHER_API_KEY + ); + assert.ok(result.success, result.message); + }); + + it('Removing the VS', function() { + goToUrlAndSetLocalStorage(CBIOPORTAL_URL, true); + const vsRow = $(`//*[text()="${vsTitle}"]/ancestor::li[1]`); + vsRow.waitForDisplayed(); + + const removeBtn = vsRow.$('.fa-trash'); + removeBtn.click(); + }); + + it('The VS disappears from the landing page', function() { + goToUrlAndSetLocalStorage(CBIOPORTAL_URL, true); + $('[data-test="cancerTypeListContainer"]').waitForDisplayed(); + const vsRowTitle = $(`//*[text()="${vsTitle}"]`); + browser.pause(100); + assert.ok(!vsRowTitle.isExisting()); + }); +}); diff --git a/src/shared/api/session-service/sessionServiceAPI.ts b/src/shared/api/session-service/sessionServiceAPI.ts index 0b429dfa430..8db41ce361f 100644 --- a/src/shared/api/session-service/sessionServiceAPI.ts +++ b/src/shared/api/session-service/sessionServiceAPI.ts @@ -17,6 +17,10 @@ export default class sessionServiceAPI { return `${getSessionUrl()}/virtual_study`; } + getPublicVirtualStudyServiceUrl() { + return getSessionUrl('api/public_virtual_studies'); + } + getSessionServiceUrl() { return `${getSessionUrl()}/main_session`; } @@ -44,6 +48,18 @@ export default class sessionServiceAPI { ); } + getPublicVirtualStudies(): Promise> { + return ( + request + .get(this.getPublicVirtualStudyServiceUrl()) + // @ts-ignore: this method comes from caching plugin and isn't in typing + .forceUpdate(true) + .then((res: any) => { + return res.body; + }) + ); + } + getVirtualStudy(id: string): Promise { return ( request diff --git a/src/shared/api/session-service/sessionServiceModels.ts b/src/shared/api/session-service/sessionServiceModels.ts index 89eae2248e5..01d85333b5b 100644 --- a/src/shared/api/session-service/sessionServiceModels.ts +++ b/src/shared/api/session-service/sessionServiceModels.ts @@ -33,6 +33,8 @@ export interface VirtualStudyData { studies: { id: string; samples: string[] }[]; origin: string[]; studyViewFilter: StudyViewFilter; + typeOfCancerId?: string; + pmid?: string; } export type GroupData = Omit; diff --git a/src/shared/api/urls.ts b/src/shared/api/urls.ts index 8aaddbbff41..b54dd9179ea 100644 --- a/src/shared/api/urls.ts +++ b/src/shared/api/urls.ts @@ -245,14 +245,14 @@ export function getGenomeNexusHgvsgUrl( : `${getServerConfig().genomenexus_website_url}/variant/${hgvsg}`; } -export function getSessionUrl() { +export function getSessionUrl(path = 'api/session') { if (getServerConfig() && getServerConfig().hasOwnProperty('apiRoot')) { // TODO: remove this after switch to AWS. This is a hack to use proxy // session-service from non apiRoot. We'll have to come up with a better // solution for auth portals - return buildCBioPortalPageUrl('api/session'); + return buildCBioPortalPageUrl(path); } else { - return buildCBioPortalAPIUrl('api/session'); + return buildCBioPortalAPIUrl(path); } } diff --git a/src/shared/components/query/CancerStudyTreeData.ts b/src/shared/components/query/CancerStudyTreeData.ts index ceadae2589f..4ec67c7090d 100644 --- a/src/shared/components/query/CancerStudyTreeData.ts +++ b/src/shared/components/query/CancerStudyTreeData.ts @@ -8,6 +8,7 @@ import { VirtualStudy } from 'shared/api/session-service/sessionServiceModels'; export const CANCER_TYPE_ROOT = 'tissue'; export const VIRTUAL_STUDY_NAME = 'My Virtual Studies'; +export const PUBLIC_VIRTUAL_STUDY_NAME = 'Public Virtual Studies'; export const PHYSICAL_STUDY_NAME = 'Studies'; export type CancerTypeWithVisibility = CancerType & { @@ -63,6 +64,16 @@ export default class CancerStudyTreeData { alwaysVisible: true, }; + publicVirtualStudyCategory: CancerTypeWithVisibility = { + id: 'public_virtual_studies_list', + dedicatedColor: '', + name: PUBLIC_VIRTUAL_STUDY_NAME, + parent: CANCER_TYPE_ROOT, + shortName: PUBLIC_VIRTUAL_STUDY_NAME, + cancerTypeId: PUBLIC_VIRTUAL_STUDY_NAME, + alwaysVisible: true, + }; + physicalStudyCategory: CancerTypeWithVisibility = { dedicatedColor: '', name: PHYSICAL_STUDY_NAME, @@ -83,6 +94,7 @@ export default class CancerStudyTreeData { allStudyTags = [], priorityStudies = {}, virtualStudies = [], + publicVirtualStudies = [], maxTreeDepth = 0, }: { cancerTypes: CancerTypeWithVisibility[]; @@ -90,6 +102,7 @@ export default class CancerStudyTreeData { allStudyTags: StudyTags[]; priorityStudies?: CategorizedConfigItems; virtualStudies?: VirtualStudy[]; + publicVirtualStudies?: VirtualStudy[]; maxTreeDepth: number; }) { let nodes: CancerTreeNode[]; @@ -99,6 +112,25 @@ export default class CancerStudyTreeData { // sort by name cancerTypes = CancerStudyTreeData.sortNodes(cancerTypes); + //map public virtual study to cancer study + const _publicVirtualStudies = publicVirtualStudies.map( + publicVirtualStudy => { + return { + allSampleCount: _.sumBy( + publicVirtualStudy.data.studies, + study => study.samples.length + ), + studyId: publicVirtualStudy.id, + name: publicVirtualStudy.data.name, + description: publicVirtualStudy.data.description, + cancerTypeId: + publicVirtualStudy.data.typeOfCancerId || + PUBLIC_VIRTUAL_STUDY_NAME, + pmid: publicVirtualStudy.data.pmid, + } as CancerStudy; + } + ); + //map virtual study to cancer study const _virtualStudies = virtualStudies .map(virtualstudy => { @@ -140,6 +172,7 @@ export default class CancerStudyTreeData { } // add virtual study category, and studies cancerTypes = [ + this.publicVirtualStudyCategory, this.virtualStudyCategory, this.physicalStudyCategory, ...this.priorityCategories, @@ -147,6 +180,7 @@ export default class CancerStudyTreeData { ...cancerTypes, ]; studies = CancerStudyTreeData.sortNodes([ + ..._publicVirtualStudies, ..._virtualStudies, ...studies, ]); diff --git a/src/shared/components/query/QueryStore.ts b/src/shared/components/query/QueryStore.ts index 30164aed980..13e878d188b 100644 --- a/src/shared/components/query/QueryStore.ts +++ b/src/shared/components/query/QueryStore.ts @@ -225,7 +225,13 @@ export class QueryStore { } @computed get virtualStudiesMap(): { [id: string]: VirtualStudy } { - return _.keyBy(this.userVirtualStudies.result, study => study.id); + return _.keyBy( + [ + ...this.userVirtualStudies.result, + ...this.publicVirtualStudies.result, + ], + study => study.id + ); } @computed get selectedVirtualStudies(): VirtualStudy[] { @@ -792,6 +798,19 @@ export class QueryStore { } }, []); + readonly publicVirtualStudies = remoteData(async () => { + if (ServerConfigHelpers.sessionServiceIsEnabled()) { + try { + const studies = await sessionServiceClient.getPublicVirtualStudies(); + return studies; + } catch (ex) { + return []; + } + } else { + return []; + } + }, []); + private readonly userVirtualStudiesSet = remoteData<{ [studyId: string]: VirtualStudy; }>({ @@ -808,18 +827,40 @@ export class QueryStore { default: {}, }); + private readonly publicVirtualStudiesSet = remoteData<{ + [studyId: string]: VirtualStudy; + }>({ + await: () => [this.publicVirtualStudies], + invoke: async () => { + return this.publicVirtualStudies.result.reduce( + (obj: { [studyId: string]: VirtualStudy }, item) => { + obj[item.id] = item; + return obj; + }, + {} + ); + }, + default: {}, + }); + private readonly sharedVirtualStudiesSet = remoteData<{ [studyId: string]: VirtualStudy; }>({ - await: () => [this.physicalStudiesSet, this.userVirtualStudiesSet], + await: () => [ + this.physicalStudiesSet, + this.userVirtualStudiesSet, + this.publicVirtualStudiesSet, + ], invoke: async () => { let physicalStudiesIdsSet = this.physicalStudiesSet.result; let virtualStudiesIdsSet = this.userVirtualStudiesSet.result; + let publicStudiesIdsSet = this.publicVirtualStudiesSet.result; let knownSelectableIds = Object.assign( [], Object.keys(physicalStudiesIdsSet), - Object.keys(virtualStudiesIdsSet) + Object.keys(virtualStudiesIdsSet), + Object.keys(publicStudiesIdsSet) ); //queried id that are not selectable(this would mostly be shared virtual study) @@ -864,12 +905,15 @@ export class QueryStore { private readonly selectedStudyToSampleSet = remoteData<{ [id: string]: { [id: string]: boolean }; }>({ - await: () => [this.userVirtualStudiesSet, this.sharedVirtualStudiesSet], + await: () => [ + this.userVirtualStudiesSet, + this.sharedVirtualStudiesSet, + this.publicVirtualStudiesSet, + ], invoke: async () => { let studyToSampleSet: { [id: string]: { [id: string]: boolean }; } = {}; - const physicalStudyIds = _.filter( this.allSelectedStudyIds, studyId => this.physicalStudiesSet.result[studyId] @@ -893,6 +937,7 @@ export class QueryStore { const _vs = { ...this.userVirtualStudiesSet.result, ...this.sharedVirtualStudiesSet.result, + ...this.publicVirtualStudiesSet.result, }; for (const id of this._allSelectedStudyIds.keys()) { @@ -945,6 +990,12 @@ export class QueryStore { ); } ); + _.each( + this.publicVirtualStudiesSet.result, + (virtualStudy, studyId) => { + result[studyId] = [studyId]; + } + ); return result; }, @@ -1490,6 +1541,9 @@ export class QueryStore { virtualStudies: this.forDownloadTab ? [] : this.userVirtualStudies.result, + publicVirtualStudies: this.forDownloadTab + ? [] + : this.publicVirtualStudies.result, maxTreeDepth: this.maxTreeDepth, }); } @@ -1529,6 +1583,10 @@ export class QueryStore { return !this.cancerStudyIdsSet.result[studyId]; } + public isPublicVirtualStudy(studyId: string): boolean { + return !!this.publicVirtualStudies.result.find(ps => ps.id == studyId); + } + public isDeletedVirtualStudy(studyId: string): boolean { if ( this.isVirtualStudy(studyId) && diff --git a/src/shared/components/query/studyList/StudyList.tsx b/src/shared/components/query/studyList/StudyList.tsx index b84244a39d8..7cd975d062e 100644 --- a/src/shared/components/query/studyList/StudyList.tsx +++ b/src/shared/components/query/studyList/StudyList.tsx @@ -303,7 +303,10 @@ export default class StudyList extends QueryStoreComponent< }, ]; - if (this.store.isVirtualStudy(study.studyId)) { + if ( + this.store.isVirtualStudy(study.studyId) && + !this.store.isPublicVirtualStudy(study.studyId) + ) { links.push({ icon: 'trash', tooltip: 'Delete this virtual study.',