From 50adab2bad7b15db0a430c56069ba5d025044efd Mon Sep 17 00:00:00 2001 From: Gautam Sarawagi <101802666+gautamsarawagi@users.noreply.github.com> Date: Mon, 19 Aug 2024 21:06:02 +0530 Subject: [PATCH] Add ability to spawn code notebooks from cBioPortal queries (#4856) * Add ability to spawn juypyeter lite code notebooks populated with data from Oncoprint --- notebook/README.md | 5 + .../oncoprinter/JupyterNotebookModal.tsx | 148 ++++++++++++++++++ .../tools/oncoprinter/Oncoprinter.tsx | 60 +++++++ .../tools/oncoprinter/OncoprinterStore.ts | 16 ++ .../tools/oncoprinter/OncoprinterTool.tsx | 14 ++ .../oncoprint/ResultsViewOncoprint.tsx | 109 ++++++++++++- .../oncoprint/controls/OncoprintControls.tsx | 28 +++- src/shared/lib/calculation/JSONtoCSV.ts | 21 +++ 8 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 notebook/README.md create mode 100644 src/pages/staticPages/tools/oncoprinter/JupyterNotebookModal.tsx create mode 100644 src/shared/lib/calculation/JSONtoCSV.ts diff --git a/notebook/README.md b/notebook/README.md new file mode 100644 index 00000000000..f84df0b2e68 --- /dev/null +++ b/notebook/README.md @@ -0,0 +1,5 @@ + +## For using the notebook and its contents: + +1. The code for the Jupyterlite extension and the environment can be accessed from [here](https://github.com/cBioPortal/cbio-jupyter). +2. The instructions to use it present in this [file](https://github.com/cBioPortal/cbio-jupyter/blob/main/README.md) diff --git a/src/pages/staticPages/tools/oncoprinter/JupyterNotebookModal.tsx b/src/pages/staticPages/tools/oncoprinter/JupyterNotebookModal.tsx new file mode 100644 index 00000000000..1ab32c45d5c --- /dev/null +++ b/src/pages/staticPages/tools/oncoprinter/JupyterNotebookModal.tsx @@ -0,0 +1,148 @@ +import { action, observable } from 'mobx'; +import React from 'react'; +import { + Modal, + Form, + FormControl, + FormGroup, + ControlLabel, + Button, +} from 'react-bootstrap'; +import { buildCBioPortalPageUrl } from 'shared/api/urls'; + +interface FilenameModalProps { + show: boolean; + fileContent: string; + fileName: string; + handleClose: () => void; +} + +interface FilenameModalState { + folderName: string; + validated: boolean; + errorMessage: string; +} + +class JupyterNoteBookModal extends React.Component< + FilenameModalProps, + FilenameModalState +> { + public channel: BroadcastChannel; + + constructor(props: FilenameModalProps) { + super(props); + this.state = { + folderName: '', + validated: false, + errorMessage: '', + }; + this.channel = new BroadcastChannel('jupyter_channel'); + } + + componentDidMount() { + this.setState({ folderName: '' }); + } + + handleChange = (event: React.ChangeEvent) => { + this.setState({ folderName: event.target.value, errorMessage: '' }); + }; + + @action + handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const { folderName } = this.state; + + if (folderName.trim() === '' || /\s/.test(folderName)) { + this.setState({ + validated: false, + errorMessage: 'Session name cannot be empty or contain spaces', + }); + return; + } + + const { fileContent, fileName } = this.props; + + const data = { + type: 'from-cbioportal-to-jupyterlite', + fileContent: fileContent, + filename: `${fileName}.csv`, + folderName: folderName, + }; + + const jupyterNotebookTool = window.open( + 'https://cbio-jupyter.netlify.app/lite/lab/index.html', + '_blank' + ); + + if (jupyterNotebookTool) { + const receiveMessage = (event: MessageEvent) => { + if (event.data.type === 'jupyterlite-ready') { + console.log('Now sending the data...'); + jupyterNotebookTool.postMessage(data, '*'); + window.removeEventListener('message', receiveMessage); + this.props.handleClose(); + } + }; + + window.addEventListener('message', receiveMessage); + } + + this.setState({ folderName: '', validated: false, errorMessage: '' }); + // this.props.handleClose(); + }; + + render() { + const { show, handleClose } = this.props; + const { folderName, errorMessage } = this.state; + + return ( + + + Enter Session Name + + +
+ this.handleSubmit( + (e as unknown) as React.FormEvent< + HTMLFormElement + > + ) + } + > + + + Session Name + + + this.handleChange( + (e as unknown) as React.ChangeEvent< + HTMLInputElement + > + ) + } + required + /> + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+ + + + +
+
+
+ ); + } +} + +export default JupyterNoteBookModal; diff --git a/src/pages/staticPages/tools/oncoprinter/Oncoprinter.tsx b/src/pages/staticPages/tools/oncoprinter/Oncoprinter.tsx index eaa6db3ca3a..6372aedef8a 100644 --- a/src/pages/staticPages/tools/oncoprinter/Oncoprinter.tsx +++ b/src/pages/staticPages/tools/oncoprinter/Oncoprinter.tsx @@ -38,6 +38,8 @@ import ClinicalTrackColorPicker from 'shared/components/oncoprint/ClinicalTrackC import classnames from 'classnames'; import { getDefaultClinicalAttributeColoringForStringDatatype } from './OncoprinterToolUtils'; import { OncoprintColorModal } from 'shared/components/oncoprint/OncoprintColorModal'; +import JupyterNoteBookModal from './JupyterNotebookModal'; +import { convertToCSV } from 'shared/lib/calculation/JSONtoCSV'; interface IOncoprinterProps { divId: string; @@ -73,6 +75,20 @@ export default class Oncoprinter extends React.Component< @observable.ref public oncoprint: OncoprintJS | undefined = undefined; + @observable public showJupyterNotebookModal = false; + @observable private jupyterFileContent = ''; + @observable private jupyterFileName = ''; + + @action + private openJupyterNotebookModal = () => { + this.showJupyterNotebookModal = true; + }; + + @action + private closeJupyterNotebookModal = () => { + this.showJupyterNotebookModal = false; + }; + constructor(props: IOncoprinterProps) { super(props); @@ -280,6 +296,44 @@ export default class Oncoprinter extends React.Component< file += `${caseId}\n`; } fileDownload(file, `OncoPrintSamples.txt`); + break; + case 'jupyterNoteBook': + const fieldsToKeep = [ + 'hugoGeneSymbol', + 'alterationType', + 'chr', + 'startPosition', + 'endPosition', + 'referenceAllele', + 'variantAllele', + 'proteinChange', + 'proteinPosStart', + 'proteinPosEnd', + 'mutationType', + 'oncoKbOncogenic', + 'patientId', + 'sampleId', + 'isHotspot', + ]; + + if ( + this.props.store._mutations && + this.props.store._studyIds + ) { + const allGenesMutationsCsv = convertToCSV( + this.props.store.mutationsDataProps, + fieldsToKeep + ); + + this.jupyterFileContent = allGenesMutationsCsv; + + this.jupyterFileName = this.props.store.studyIdProps.join( + '&' + ); + + this.openJupyterNotebookModal(); + } + break; } }, @@ -569,6 +623,12 @@ export default class Oncoprinter extends React.Component< + ); } diff --git a/src/pages/staticPages/tools/oncoprinter/OncoprinterStore.ts b/src/pages/staticPages/tools/oncoprinter/OncoprinterStore.ts index 81de7451567..e5215251b53 100644 --- a/src/pages/staticPages/tools/oncoprinter/OncoprinterStore.ts +++ b/src/pages/staticPages/tools/oncoprinter/OncoprinterStore.ts @@ -73,6 +73,9 @@ export default class OncoprinterStore { @observable hideGermlineMutations = false; @observable customDriverWarningHidden: boolean; + @observable _mutations: string | undefined = undefined; + @observable _studyIds: string | undefined = undefined; + @observable _userSelectedClinicalTracksColors: { [trackLabel: string]: { [attributeValue: string]: RGBAColor; @@ -205,6 +208,19 @@ export default class OncoprinterStore { this.initialize(); } + @action public setJupyterInput(mutations: string, studyIds: string) { + this._mutations = mutations; + this._studyIds = studyIds; + } + + @computed get mutationsDataProps() { + if (this._mutations) return JSON.parse(this._mutations); + } + + @computed get studyIdProps() { + if (this._studyIds) return JSON.parse(this._studyIds); + } + @computed get parsedGeneticInputLines() { if (!this._geneticDataInput) { return { diff --git a/src/pages/staticPages/tools/oncoprinter/OncoprinterTool.tsx b/src/pages/staticPages/tools/oncoprinter/OncoprinterTool.tsx index 15c53c7c490..fbb3142ad79 100644 --- a/src/pages/staticPages/tools/oncoprinter/OncoprinterTool.tsx +++ b/src/pages/staticPages/tools/oncoprinter/OncoprinterTool.tsx @@ -63,6 +63,10 @@ export default class OncoprinterTool extends React.Component< @observable geneOrderInput = ''; @observable sampleOrderInput = ''; + // jupyter incoming data + @observable mutations = ''; + @observable studyIds = ''; + constructor(props: IOncoprinterToolProps) { super(props); makeObservable(this); @@ -76,11 +80,17 @@ export default class OncoprinterTool extends React.Component< this.geneticDataInput = postData.genetic; this.clinicalDataInput = postData.clinical; this.heatmapDataInput = postData.heatmap; + this.studyIds = postData.studyIds; + this.mutations = postData.mutations; + this.doSubmit( this.geneticDataInput, this.clinicalDataInput, this.heatmapDataInput ); + + this.handleJupyterData(this.mutations, this.studyIds); + getBrowserWindow().clientPostedData = null; } } @@ -163,6 +173,10 @@ export default class OncoprinterTool extends React.Component< } } + @action private handleJupyterData(mutations: string, studyIds: string) { + this.store.setJupyterInput(mutations, studyIds); + } + @autobind private geneticFileInputRef(input: HTMLInputElement | null) { this.geneticFileInput = input; } diff --git a/src/shared/components/oncoprint/ResultsViewOncoprint.tsx b/src/shared/components/oncoprint/ResultsViewOncoprint.tsx index d96fa535133..89772ce219d 100644 --- a/src/shared/components/oncoprint/ResultsViewOncoprint.tsx +++ b/src/shared/components/oncoprint/ResultsViewOncoprint.tsx @@ -15,7 +15,7 @@ import { remoteData, svgToPdfDownload, } from 'cbioportal-frontend-commons'; -import { getRemoteDataGroupStatus } from 'cbioportal-utils'; +import { getRemoteDataGroupStatus, Mutation } from 'cbioportal-utils'; import Oncoprint, { ClinicalTrackSpec, ClinicalTrackConfig, @@ -59,7 +59,7 @@ import { getServerConfig } from 'config/config'; import LoadingIndicator from 'shared/components/loadingIndicator/LoadingIndicator'; import { OncoprintJS, RGBAColor, TrackGroupIndex, TrackId } from 'oncoprintjs'; import fileDownload from 'react-file-download'; -import tabularDownload from './tabularDownload'; +import tabularDownload, { getTabularDownloadData } from './tabularDownload'; import classNames from 'classnames'; import { clinicalAttributeIsLocallyComputed, @@ -99,6 +99,8 @@ import ClinicalTrackColorPicker from './ClinicalTrackColorPicker'; import { hexToRGBA, rgbaToHex } from 'shared/lib/Colors'; import classnames from 'classnames'; import { OncoprintColorModal } from './OncoprintColorModal'; +import JupyterNoteBookModal from 'pages/staticPages/tools/oncoprinter/JupyterNotebookModal'; +import { convertToCSV } from 'shared/lib/calculation/JSONtoCSV'; interface IResultsViewOncoprintProps { divId: string; @@ -773,6 +775,24 @@ export default class ResultsViewOncoprint extends React.Component< this.mouseInsideBounds = false; } + // jupyternotebook modal handling: + + @observable public showJupyterNotebookModal = false; + @observable private jupyterFileContent: string | undefined = ''; + @observable private jupyterFileName: string | undefined = ''; + + @action + private openJupyterNotebookModal = () => { + this.showJupyterNotebookModal = true; + }; + + @action + private closeJupyterNotebookModal = () => { + this.showJupyterNotebookModal = false; + this.jupyterFileContent = undefined; + this.jupyterFileName = undefined; + }; + private buildControlsHandlers() { return { onSelectColumnType: (type: OncoprintAnalysisCaseType) => { @@ -1053,6 +1073,8 @@ export default class ResultsViewOncoprint extends React.Component< this.genesetHeatmapTracks, this.props.store .clinicalAttributeIdToClinicalAttribute, + this.props.store.mutationsByGene, + this.props.store.studyIds, ], ( samples: Sample[], @@ -1063,7 +1085,11 @@ export default class ResultsViewOncoprint extends React.Component< genesetHeatmapTracks: IGenesetHeatmapTrackSpec[], attributeIdToAttribute: { [attributeId: string]: ClinicalAttribute; - } + }, + mutationsByGenes: { + [gene: string]: Mutation[]; + }, + studyIds: string[] ) => { const caseIds = this.oncoprintAnalysisCaseType === @@ -1115,14 +1141,82 @@ export default class ResultsViewOncoprint extends React.Component< const oncoprinterWindow = window.open( buildCBioPortalPageUrl('/oncoprinter') ) as any; + + // extra data that needs to be send for jupyter-notebook + const allMutations = Object.values( + mutationsByGenes + ).reduce( + (acc, geneArray) => [...acc, ...geneArray], + [] + ); + oncoprinterWindow.clientPostedData = { genetic: geneticInput, clinical: clinicalInput, heatmap: heatmapInput, + mutations: JSON.stringify(allMutations), + studyIds: JSON.stringify(studyIds), }; } ); break; + case 'jupyterNoteBook': + onMobxPromise( + [ + this.props.store.sampleKeyToSample, + this.props.store.patientKeyToPatient, + this.props.store.mutationsByGene, + this.props.store.studyIds, + ], + ( + sampleKeyToSample: { + [sampleKey: string]: Sample; + }, + patientKeyToPatient: any, + mutationsByGenes: { + [gene: string]: Mutation[]; + }, + studyIds: string[] + ) => { + const allGenesMutations = Object.values( + mutationsByGenes + ).reduce( + (acc, geneArray) => [...acc, ...geneArray], + [] + ); + + const fieldsToKeep = [ + 'hugoGeneSymbol', + 'alterationType', + 'chr', + 'startPosition', + 'endPosition', + 'referenceAllele', + 'variantAllele', + 'proteinChange', + 'proteinPosStart', + 'proteinPosEnd', + 'mutationType', + 'oncoKbOncogenic', + 'patientId', + 'sampleId', + 'isHotspot', + ]; + + const allGenesMutationsCsv = convertToCSV( + allGenesMutations, + fieldsToKeep + ); + + this.jupyterFileContent = allGenesMutationsCsv; + + this.jupyterFileName = studyIds.join('&'); + + // sending content to the modal + this.openJupyterNotebookModal(); + } + ); + break; } }, onSetHorzZoom: (z: number) => { @@ -2333,6 +2427,15 @@ export default class ResultsViewOncoprint extends React.Component< + + {this.jupyterFileContent && this.jupyterFileName && ( + + )} ); } diff --git a/src/shared/components/oncoprint/controls/OncoprintControls.tsx b/src/shared/components/oncoprint/controls/OncoprintControls.tsx index 887dfd78745..e2edf627fd0 100644 --- a/src/shared/components/oncoprint/controls/OncoprintControls.tsx +++ b/src/shared/components/oncoprint/controls/OncoprintControls.tsx @@ -68,7 +68,14 @@ export interface IOncoprintControlsHandlers onClickSortAlphabetical?: () => void; onClickSortCaseListOrder?: () => void; onClickDownload?: ( - type: 'pdf' | 'png' | 'svg' | 'order' | 'tabular' | 'oncoprinter' + type: + | 'pdf' + | 'png' + | 'svg' + | 'order' + | 'tabular' + | 'oncoprinter' + | 'jupyterNoteBook' ) => void; onChangeSelectedClinicalTracks?: ( trackConfigs: ClinicalTrackConfig[] @@ -135,6 +142,7 @@ export interface IOncoprintControlsProps { handlers: IOncoprintControlsHandlers; state: IOncoprintControlsState; oncoprinterMode?: boolean; + jupyterNotebookMode?: boolean; molecularProfileIdToMolecularProfile?: { [molecularProfileId: string]: MolecularProfile; }; @@ -182,6 +190,7 @@ const EVENT_KEY = { downloadOrder: '28', downloadTabular: '29', downloadOncoprinter: '29.1', + openJupyterNotebook: '32', horzZoomSlider: '30', viewNGCHM: '31', }; @@ -428,6 +437,10 @@ export default class OncoprintControls extends React.Component< this.props.handlers.onClickDownload && this.props.handlers.onClickDownload('oncoprinter'); break; + case EVENT_KEY.openJupyterNotebook: + this.props.handlers.onClickDownload && + this.props.handlers.onClickDownload('jupyterNoteBook'); + break; case EVENT_KEY.viewNGCHM: if ( this.props.state.ngchmButtonActive && @@ -1141,6 +1154,19 @@ export default class OncoprintControls extends React.Component< Open in Oncoprinter )} + + {!this.props.jupyterNotebookMode && + getServerConfig().skin_hide_download_controls === + DownloadControlOption.SHOW_ALL && ( + + )} ) : null; }); diff --git a/src/shared/lib/calculation/JSONtoCSV.ts b/src/shared/lib/calculation/JSONtoCSV.ts new file mode 100644 index 00000000000..54529c0f8b9 --- /dev/null +++ b/src/shared/lib/calculation/JSONtoCSV.ts @@ -0,0 +1,21 @@ +type T = any; + +export function convertToCSV(jsonArray: Array, fieldsToKeep?: string[]) { + if (!jsonArray.length) { + return ''; + } + // Create the header + const csvHeader = fieldsToKeep?.join(','); + + // Create the rows + const csvRows = jsonArray + .map(item => { + return fieldsToKeep + ?.map(field => { + return item[field as keyof T] || ''; + }) + .join(','); + }) + .join('\n'); + return `${csvHeader}\r\n${csvRows}`; +}