diff --git a/OPEN-SOURCE-DOCUMENTATION b/OPEN-SOURCE-DOCUMENTATION index d242204f16f..ed2c7966eb4 100644 --- a/OPEN-SOURCE-DOCUMENTATION +++ b/OPEN-SOURCE-DOCUMENTATION @@ -49,3 +49,20 @@ Available under license: 5. Products derived from this software may not be called "ColorBrewer", nor may "ColorBrewer" appear in their name, without prior written permission of Cynthia Brewer. + +* JavaScript/CSS Font Detector + +JavaScript/CSS Font Detector +---------------------------- +Available under license: + + JavaScript code to detect available availability of a + particular font in a browser using JavaScript and CSS. + + Author : Lalit Patel + Website: http://www.lalit.org/lab/javascript-css-font-detect/ + License: Apache Software License 2.0 + http://www.apache.org/licenses/LICENSE-2.0 + + + diff --git a/my-index.ejs b/my-index.ejs index c130d72a534..7439a385201 100644 --- a/my-index.ejs +++ b/my-index.ejs @@ -10,37 +10,36 @@ window.frontendConfig = { } + /* REMOVED: This is an example of how to add custom tabs to the patient page. Enabling this will clobber localStorage.frontendConfig that is set through the browser console localStorage.frontendConfig = JSON.stringify( - { - serverConfig:{ - - custom_tabs:[ - { - title: 'Sync Tab', - id: 'customTab1', - location: 'PATIENT_PAGE', - mountCallback: `(div)=>{ - $(div).html("tab for patient " + window.location.search.split("=").slice(-1)) - }`, - }, { - title: 'Async Tab', - id: 'customTab2', - location: 'PATIENT_PAGE', - hideAsync: `()=>{ - return new Promise((resolve)=>{ - setTimeout(()=>{ - resolve(true); - }, 2000); - }); - }`, - }, - ] - - } - - } - ); + serverConfig:{ + + custom_tabs:[ + { + title: 'Sync Tab', + id: 'customTab1', + location: 'PATIENT_PAGE', + mountCallback: `(div)=>{ + $(div).html("tab for patient " + window.location.search.split("=").slice(-1)) + }`, + }, + { + title: 'Async Tab', + id: 'customTab2', + location: 'PATIENT_PAGE', + hideAsync: `()=>{ + return new Promise((resolve)=>{ + setTimeout(()=>{ + resolve(true); + }, 2000); + }); + }`, + }, + ] + } + }); + */ function renderCustomTab1(div, tab){ $(div).append(`
this is the content for ${tab.title}
`); 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/package.json b/package.json index c55556f4b9d..58827ef3597 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "cbioportal-frontend", "private": true, - "version": "3.3.283", + "version": "3.3.284", "workspaces": { "packages": [ ".", @@ -159,10 +159,10 @@ "bootstrap-sass": "3.4.1", "bowser": "^1.7.1", "bundle-loader": "^0.5.4", - "cbioportal-clinical-timeline": "^0.3.83", - "cbioportal-frontend-commons": "^0.5.67", + "cbioportal-clinical-timeline": "^0.3.84", + "cbioportal-frontend-commons": "^0.5.68", "cbioportal-ts-api-client": "^0.9.73", - "cbioportal-utils": "^0.3.41", + "cbioportal-utils": "^0.3.42", "chart.js": "^2.6.0", "classnames": "^2.2.5", "clinical-timeline": "0.0.30", @@ -189,7 +189,7 @@ "fmin": "^0.0.2", "font-awesome": "^4.7.0", "fork-ts-checker-webpack-plugin": "^6.3.3", - "genome-nexus-ts-api-client": "^1.1.32", + "genome-nexus-ts-api-client": "^1.1.33", "git-revision-webpack-plugin": "^5.0.0", "history": "4.10.1", "html-webpack-plugin": "^5.3.2", @@ -227,7 +227,7 @@ "mobx-utils": "6.0.1", "numeral": "^2.0.6", "object-sizeof": "^1.2.0", - "oncokb-frontend-commons": "^0.0.25", + "oncokb-frontend-commons": "^0.0.26", "oncokb-styles": "~1.4.2", "oncokb-ts-api-client": "^1.3.5", "oncoprintjs": "^6.0.5", @@ -272,7 +272,7 @@ "react-markdown": "^7.0.1", "react-mfb": "^0.6.0", "react-motion": "^0.4.7", - "react-mutation-mapper": "^0.8.111", + "react-mutation-mapper": "^0.8.112", "react-overlays": "0.7.4", "react-portal": "^4.2.0", "react-rangeslider": "^2.1.0", diff --git a/packages/cbioportal-clinical-timeline/package.json b/packages/cbioportal-clinical-timeline/package.json index 8ae5fa1f792..774755e88e3 100644 --- a/packages/cbioportal-clinical-timeline/package.json +++ b/packages/cbioportal-clinical-timeline/package.json @@ -1,7 +1,7 @@ { "name": "cbioportal-clinical-timeline", "description": "cBioPortal Clinical Timeline", - "version": "0.3.83", + "version": "0.3.84", "main": "dist/index.js", "module": "dist/index.es.js", "jsnext:main": "dist/index.es.js", @@ -39,7 +39,7 @@ }, "dependencies": { "autobind-decorator": "^2.1.0", - "cbioportal-frontend-commons": "^0.5.67", + "cbioportal-frontend-commons": "^0.5.68", "lodash": "^4.17.11", "react-bootstrap": "^0.31.5", "react-overlays": "0.7.4", diff --git a/packages/cbioportal-frontend-commons/package.json b/packages/cbioportal-frontend-commons/package.json index 3595d6e7814..8ee17b636b8 100644 --- a/packages/cbioportal-frontend-commons/package.json +++ b/packages/cbioportal-frontend-commons/package.json @@ -1,7 +1,7 @@ { "name": "cbioportal-frontend-commons", "description": "cBioPortal Frontend Modules", - "version": "0.5.67", + "version": "0.5.68", "main": "dist/index.js", "module": "dist/index.es.js", "jsnext:main": "dist/index.es.js", @@ -38,7 +38,7 @@ }, "dependencies": { "autobind-decorator": "^2.1.0", - "cbioportal-utils": "^0.3.41", + "cbioportal-utils": "^0.3.42", "classnames": "^2.2.5", "jquery": "^3.2.1", "juice": "^10.0.0", diff --git a/packages/cbioportal-utils/package.json b/packages/cbioportal-utils/package.json index f8c8f34a11f..1ea24e21e65 100644 --- a/packages/cbioportal-utils/package.json +++ b/packages/cbioportal-utils/package.json @@ -1,7 +1,7 @@ { "name": "cbioportal-utils", "description": "cBioPortal Utilities", - "version": "0.3.41", + "version": "0.3.42", "main": "dist/index.js", "module": "dist/index.es.js", "jsnext:main": "dist/index.es.js", @@ -30,7 +30,7 @@ }, "dependencies": { "buffer": "^6.0.3", - "genome-nexus-ts-api-client": "^1.1.32", + "genome-nexus-ts-api-client": "^1.1.33", "lodash": "^4.17.15", "oncokb-ts-api-client": "^1.3.5", "superagent": "^3.8.3", diff --git a/packages/genome-nexus-ts-api-client/package.json b/packages/genome-nexus-ts-api-client/package.json index 94d02dde6c0..373788fe37a 100644 --- a/packages/genome-nexus-ts-api-client/package.json +++ b/packages/genome-nexus-ts-api-client/package.json @@ -1,7 +1,7 @@ { "name": "genome-nexus-ts-api-client", "description": "Genome Nexus API Client for TypeScript", - "version": "1.1.32", + "version": "1.1.33", "main": "dist/index.js", "module": "dist/index.es.js", "jsnext:main": "dist/index.es.js", diff --git a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI-docs.json b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI-docs.json index 1c2683aeb99..a88b7a9ab2e 100644 --- a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI-docs.json +++ b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI-docs.json @@ -1348,6 +1348,18 @@ } } }, + "AlphaMissense": { + "type": "object", + "properties": { + "pathogenicity": { + "type": "string" + }, + "score": { + "type": "number", + "format": "double" + } + } + }, "ArticleAbstract": { "type": "object", "properties": { @@ -3309,6 +3321,9 @@ "transcript_id" ], "properties": { + "alphaMissense": { + "$ref": "#/definitions/AlphaMissense" + }, "amino_acids": { "type": "string", "description": "Amino acids" @@ -3413,6 +3428,9 @@ "transcriptId" ], "properties": { + "alphaMissense": { + "$ref": "#/definitions/AlphaMissense" + }, "aminoAcidAlt": { "type": "string", "description": "Alt Amino Acid" @@ -3725,6 +3743,9 @@ "variant" ], "properties": { + "alphaMissense": { + "$ref": "#/definitions/AlphaMissense" + }, "assemblyName": { "type": "string", "description": "Assembly name" diff --git a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI.ts b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI.ts index 58dba3d740f..44367862cc5 100644 --- a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI.ts +++ b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPI.ts @@ -72,6 +72,12 @@ export type AlleleNumber = { export type Alleles = { 'allele': string +}; +export type AlphaMissense = { + 'pathogenicity': string + + 'score': number + }; export type ArticleAbstract = { 'abstract': string @@ -840,7 +846,9 @@ export type StatsByTumorType = { }; export type TranscriptConsequence = { - 'amino_acids': string + 'alphaMissense': AlphaMissense + + 'amino_acids': string 'canonical': string @@ -886,7 +894,9 @@ export type TranscriptConsequence = { }; export type TranscriptConsequenceSummary = { - 'aminoAcidAlt': string + 'alphaMissense': AlphaMissense + + 'aminoAcidAlt': string 'aminoAcidRef': string @@ -1026,7 +1036,9 @@ export type VariantAnnotation = { }; export type VariantAnnotationSummary = { - 'assemblyName': string + 'alphaMissense': AlphaMissense + + 'assemblyName': string 'canonicalTranscriptId': string diff --git a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal-docs.json b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal-docs.json index 9fa1a4d7329..01071225578 100644 --- a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal-docs.json +++ b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal-docs.json @@ -1104,6 +1104,18 @@ } } }, + "AlphaMissense": { + "type": "object", + "properties": { + "pathogenicity": { + "type": "string" + }, + "score": { + "type": "number", + "format": "double" + } + } + }, "Cosmic": { "type": "object", "properties": { @@ -2211,6 +2223,9 @@ "transcriptId" ], "properties": { + "alphaMissense": { + "$ref": "#/definitions/AlphaMissense" + }, "aminoAcidAlt": { "type": "string", "description": "Alt Amino Acid" @@ -2306,6 +2321,9 @@ "variant" ], "properties": { + "alphaMissense": { + "$ref": "#/definitions/AlphaMissense" + }, "assemblyName": { "type": "string", "description": "Assembly name" diff --git a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal.ts b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal.ts index 3f0b07b07be..ca2ac86b0e0 100644 --- a/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal.ts +++ b/packages/genome-nexus-ts-api-client/src/generated/GenomeNexusAPIInternal.ts @@ -76,6 +76,12 @@ export type AlleleNumber = { export type Alleles = { 'allele': string +}; +export type AlphaMissense = { + 'pathogenicity': string + + 'score': number + }; export type Cosmic = { 'alt': string @@ -542,7 +548,9 @@ export type StatsByTumorType = { }; export type TranscriptConsequenceSummary = { - 'aminoAcidAlt': string + 'alphaMissense': AlphaMissense + + 'aminoAcidAlt': string 'aminoAcidRef': string @@ -586,7 +594,9 @@ export type TranscriptConsequenceSummary = { }; export type VariantAnnotationSummary = { - 'assemblyName': string + 'alphaMissense': AlphaMissense + + 'assemblyName': string 'canonicalTranscriptId': string diff --git a/packages/oncokb-frontend-commons/package.json b/packages/oncokb-frontend-commons/package.json index a09b017e38e..7ca1ceef407 100644 --- a/packages/oncokb-frontend-commons/package.json +++ b/packages/oncokb-frontend-commons/package.json @@ -1,6 +1,6 @@ { "name": "oncokb-frontend-commons", - "version": "0.0.25", + "version": "0.0.26", "description": "OncoKB Frontend Modules", "main": "dist/index.js", "module": "dist/index.es.js", @@ -35,7 +35,7 @@ "react-dom": "^15.0.0 || ^16.0.0" }, "dependencies": { - "cbioportal-utils": "^0.3.41", + "cbioportal-utils": "^0.3.42", "classnames": "^2.2.5", "lodash": "^4.17.15", "oncokb-styles": "~1.4.2", diff --git a/packages/react-mutation-mapper/package.json b/packages/react-mutation-mapper/package.json index ba0cd447833..350b884edc0 100644 --- a/packages/react-mutation-mapper/package.json +++ b/packages/react-mutation-mapper/package.json @@ -1,6 +1,6 @@ { "name": "react-mutation-mapper", - "version": "0.8.111", + "version": "0.8.112", "description": "Generic Mutation Mapper", "main": "dist/index.js", "module": "dist/index.es.js", @@ -39,14 +39,14 @@ }, "dependencies": { "autobind-decorator": "^2.1.0", - "cbioportal-frontend-commons": "^0.5.67", - "cbioportal-utils": "^0.3.41", + "cbioportal-frontend-commons": "^0.5.68", + "cbioportal-utils": "^0.3.42", "classnames": "^2.2.5", - "genome-nexus-ts-api-client": "^1.1.32", + "genome-nexus-ts-api-client": "^1.1.33", "jquery": "^3.2.1", "lodash": "^4.17.15", "memoize-weak-decorator": "^1.0.3", - "oncokb-frontend-commons": "^0.0.25", + "oncokb-frontend-commons": "^0.0.26", "oncokb-styles": "~1.4.2", "oncokb-ts-api-client": "^1.3.5", "react-collapse": "^4.0.3", diff --git a/packages/react-variant-view/package.json b/packages/react-variant-view/package.json index 1acde42e168..dfedbd99fa8 100644 --- a/packages/react-variant-view/package.json +++ b/packages/react-variant-view/package.json @@ -1,6 +1,6 @@ { "name": "react-variant-view", - "version": "0.3.112", + "version": "0.3.113", "description": "cBioPortal Variant Viewer", "main": "dist/index.js", "module": "dist/index.es.js", @@ -39,11 +39,11 @@ }, "dependencies": { "autobind-decorator": "^2.1.0", - "cbioportal-frontend-commons": "^0.5.67", - "cbioportal-utils": "^0.3.41", + "cbioportal-frontend-commons": "^0.5.68", + "cbioportal-utils": "^0.3.42", "classnames": "^2.2.5", "font-awesome": "^4.7.0", - "genome-nexus-ts-api-client": "^1.1.32", + "genome-nexus-ts-api-client": "^1.1.33", "jquery": "^3.2.1", "lodash": "^4.17.15", "oncokb-styles": "~1.4.2", @@ -52,7 +52,7 @@ "react-collapse": "4.0.3", "react-if": "^2.1.0", "react-motion": "^0.5.2", - "react-mutation-mapper": "^0.8.111", + "react-mutation-mapper": "^0.8.112", "react-rangeslider": "^2.2.0", "react-select": "^3.0.4", "react-table": "^6.10.0", diff --git a/src/appBootstrapper.tsx b/src/appBootstrapper.tsx index 901a49bff29..d36088736a4 100755 --- a/src/appBootstrapper.tsx +++ b/src/appBootstrapper.tsx @@ -217,6 +217,25 @@ let render = (key?: number) => { browserWindow.isMSKCIS = true; } + // @ts-ignore + if (stores.appStore.serverConfig.app_name === 'public-portal') { + stores.appStore.serverConfig.download_custom_buttons_json = `[ + { + "id": "avm", + "name": "AVM for cBioPortal", + "tooltip": "Launch AVM for cBioPortal with data (copied to clipboard)", + "image_src": "https://aquminmedical.com/images/content/AquminLogoSimple.png", + "required_user_agent": "Win", + "required_installed_font_family": "AVMInstalled", + "url_format": "avm://?importclipboard&-AutoMode=true&-ProjectNameHint={studyName}&-ImportDataLength={dataLength}", + "visualize_title": "AVM for cBioPortal (Windows)", + "visualize_href": "https://bit.ly/avm-cbioportal", + "visualize_description": "Windows software that loads data into 3D Landscapes for interactive visualization and pathway analysis. Download table data directly from cBioPortal.", + "visualize_image_src": "https://github.com/user-attachments/assets/5c17f5ed-0357-4ffa-a6e1-5a9d435dd3c5" + } + ]`; + } + if (stores.appStore.serverConfig.app_name === 'mskcc-portal') { datadogLogs.init({ clientToken: 'pub9a94ebb002f105ff44d8e427b6549775', diff --git a/src/config/IAppConfig.ts b/src/config/IAppConfig.ts index 28a3a858e09..9c06c7498d5 100644 --- a/src/config/IAppConfig.ts +++ b/src/config/IAppConfig.ts @@ -186,4 +186,5 @@ export interface IServerConfig { vaf_log_scale_default: boolean; // this has a default skin_study_view_show_sv_table: boolean; // this has a default enable_study_tags: boolean; + download_custom_buttons_json: string; } diff --git a/src/config/config.ts b/src/config/config.ts index 9e096df6b07..63477c6a7e6 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -340,7 +340,10 @@ export function initializeServerConfiguration(rawConfiguration: any) { ); } catch (err) { // ignore - console.log('Error parsing localStorage.frontendConfig'); + console.log( + 'Error parsing localStorage.frontendConfig:' + + localStorage.frontendConfig + ); } } diff --git a/src/config/serverConfigDefaults.ts b/src/config/serverConfigDefaults.ts index be24a0d9c55..7355495920e 100644 --- a/src/config/serverConfigDefaults.ts +++ b/src/config/serverConfigDefaults.ts @@ -243,6 +243,8 @@ export const ServerConfigDefaults: Partial = { vaf_log_scale_default: false, skin_study_view_show_sv_table: false, + + download_custom_buttons_json: '', }; export default ServerConfigDefaults; diff --git a/src/pages/patientView/SVGIcons.tsx b/src/pages/patientView/SVGIcons.tsx deleted file mode 100644 index 0d459d98b61..00000000000 --- a/src/pages/patientView/SVGIcons.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as React from 'react'; - -export function getMouseIcon(): JSX.Element { - return ( - - - - - - - - - - - ); -} diff --git a/src/pages/patientView/SampleManager.tsx b/src/pages/patientView/SampleManager.tsx index 34e68e9884f..4666bb2cb6d 100644 --- a/src/pages/patientView/SampleManager.tsx +++ b/src/pages/patientView/SampleManager.tsx @@ -251,6 +251,11 @@ class SampleManager { .DERIVED_NORMALIZED_CASE_TYPE === 'Xenograft' ) { color = styles.sampleColorXenograft; + } else if ( + this.clinicalDataLegacyCleanAndDerived[sample.id] + .DERIVED_NORMALIZED_CASE_TYPE === 'Organoid' + ) { + color = styles.sampleColorOrganoid; } else if ( this.clinicalDataLegacyCleanAndDerived[sample.id] .DERIVED_NORMALIZED_CASE_TYPE === 'Plasma' diff --git a/src/pages/patientView/clinicalInformation/lib/clinicalAttributesStyleConsts.ts b/src/pages/patientView/clinicalInformation/lib/clinicalAttributesStyleConsts.ts index 3b7f0e91e61..e85d79bf32d 100644 --- a/src/pages/patientView/clinicalInformation/lib/clinicalAttributesStyleConsts.ts +++ b/src/pages/patientView/clinicalInformation/lib/clinicalAttributesStyleConsts.ts @@ -14,7 +14,7 @@ export const colors = { sampleColorRecurrence: 'orange', sampleColorMetastasis: 'red', sampleColorCfdna: 'blue', - sampleColorXenograft: 'pink', + sampleColorOrganoid: 'paleVioletRed', }; /** diff --git a/src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js b/src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js index 7c18725645e..78c9ba9f0bb 100644 --- a/src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js +++ b/src/pages/patientView/clinicalInformation/lib/clinicalAttributesUtil.js @@ -140,6 +140,8 @@ function derive(clinicalData) { caseTypeLower.indexOf('pdx') >= 0 ) { caseTypeNormalized = 'Xenograft'; + } else if (caseTypeLower.indexOf('organoid') >= 0) { + caseTypeNormalized = 'Organoid'; } else if (caseTypeLower.indexOf('cfdna') >= 0) { caseTypeNormalized = 'cfDNA'; } else if (caseTypeLower.indexOf('prim') >= 0) { diff --git a/src/pages/patientView/patientHeader/style/clinicalAttributes.scss b/src/pages/patientView/patientHeader/style/clinicalAttributes.scss index a14920d11de..4ecd810d04e 100644 --- a/src/pages/patientView/patientHeader/style/clinicalAttributes.scss +++ b/src/pages/patientView/patientHeader/style/clinicalAttributes.scss @@ -3,6 +3,7 @@ $sample-color-recurrence: orange; $sample-color-metastasis: red; $sample-color-cfdna: blue; $sample-color-xenograft: pink; +$sample-color-organoid: paleVioletRed; $sample-color-plasma: gold; $sample-color-ctdna: lightblue; $sample-color-urine: yellow; @@ -16,6 +17,7 @@ $sample-color-rna: grey; sampleColorMetastasis: $sample-color-metastasis; sampleColorCfdna: $sample-color-cfdna; sampleColorXenograft: $sample-color-xenograft; + sampleColorOrganoid: $sample-color-organoid; sampleColorPlasma: $sample-color-plasma; sampleColorCtdna: $sample-color-ctdna; sampleColorUrine: $sample-color-urine; diff --git a/src/pages/patientView/sampleHeader/SampleSummaryList.tsx b/src/pages/patientView/sampleHeader/SampleSummaryList.tsx index 79949cfdf4f..e9c6540e5a3 100644 --- a/src/pages/patientView/sampleHeader/SampleSummaryList.tsx +++ b/src/pages/patientView/sampleHeader/SampleSummaryList.tsx @@ -4,7 +4,6 @@ import { observer } from 'mobx-react'; import { ClinicalDataBySampleId } from 'cbioportal-ts-api-client'; import _ from 'lodash'; import { getSpanElementsFromCleanData } from '../clinicalInformation/lib/clinicalAttributesUtil'; -import { getMouseIcon } from '../SVGIcons'; import { getSampleViewUrl } from 'shared/api/urls'; import SignificantMutationalSignatures from '../patientHeader/SignificantMutationalSignatures'; import { PatientViewPageStore } from '../clinicalInformation/PatientViewPageStore'; @@ -18,6 +17,9 @@ import { OtherBiomarkersQueryType } from 'oncokb-frontend-commons'; import { OtherBiomarkerAnnotation } from '../oncokb/OtherBiomarkerAnnotation'; import { IGenePanelModal } from 'pages/patientView/PatientViewPage'; +import mouseIcon from './mouse_icon.svg'; +import organoidIcon from './organoid_icon.svg'; + export type ISampleSummaryListProps = { sampleManager: SampleManager; patientViewPageStore: PatientViewPageStore; @@ -64,7 +66,9 @@ export default class SampleSummaryList extends React.Component< ) : null; } - private isPDX(sample: ClinicalDataBySampleId): boolean { + private getDerivedNormalizedCaseType( + sample: ClinicalDataBySampleId + ): string | undefined { return ( this.props.sampleManager && this.props.sampleManager.clinicalDataLegacyCleanAndDerived && @@ -73,10 +77,18 @@ export default class SampleSummaryList extends React.Component< ] && this.props.sampleManager.clinicalDataLegacyCleanAndDerived[ sample.id - ].DERIVED_NORMALIZED_CASE_TYPE === 'Xenograft' + ].DERIVED_NORMALIZED_CASE_TYPE ); } + private isPDX(sample: ClinicalDataBySampleId): boolean { + return this.getDerivedNormalizedCaseType(sample) === 'Xenograft'; + } + + private isOrganoid(sample: ClinicalDataBySampleId): boolean { + return this.getDerivedNormalizedCaseType(sample) === 'Organoid'; + } + public render() { let sampleHeader: (JSX.Element | undefined)[] | null = null; sampleHeader = _.map( @@ -89,6 +101,7 @@ export default class SampleSummaryList extends React.Component< } const isPDX = this.isPDX(sample); + const isOrganoid = this.isOrganoid(sample); return (
@@ -99,8 +112,9 @@ export default class SampleSummaryList extends React.Component< '', {'\u00A0'} - {isPDX && getMouseIcon()} - {isPDX && '\u00A0'} + {isPDX && } + {isOrganoid && } + {(isPDX || isOrganoid) && '\u00A0'} + + + + + + + + + diff --git a/src/pages/patientView/sampleHeader/organoid_icon.svg b/src/pages/patientView/sampleHeader/organoid_icon.svg new file mode 100644 index 00000000000..9caf94fb259 --- /dev/null +++ b/src/pages/patientView/sampleHeader/organoid_icon.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + 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/pages/staticPages/visualize/Visualize.tsx b/src/pages/staticPages/visualize/Visualize.tsx index 731ccc39ddd..446bbb01e4c 100644 --- a/src/pages/staticPages/visualize/Visualize.tsx +++ b/src/pages/staticPages/visualize/Visualize.tsx @@ -6,9 +6,61 @@ import { PageLayout } from 'shared/components/PageLayout/PageLayout'; import './styles.scss'; import styles from './visualize.module.scss'; import { getNCBIlink } from 'cbioportal-frontend-commons'; +import { getCustomButtonConfigs } from 'shared/components/CustomButton/CustomButtonServerConfig'; @observer export default class Visualize extends React.Component<{}, {}> { + /** + * Display the 'visualize_html' data associated with serverConfig.download_custom_buttons_json + * @returns JSX.element + */ + customButtonsSection() { + const displayButtons = getCustomButtonConfigs().filter( + button => button.visualize_href + ); + if (!displayButtons || displayButtons.length === 0) { + return; + } + + return ( + <> +
+ +

3rd party tools not maintained by cBioPortal community

+ +
+ {displayButtons.map((button, index) => ( +
+

+ + {button.visualize_title} + +

+

+ {button.visualize_description} + + Try it! + +

+ {button.visualize_image_src && ( + + {button.visualize_title} + + )} +
+ ))} +
+ + ); + } + public render() { return ( @@ -128,6 +180,8 @@ export default class Visualize extends React.Component<{}, {}> { + + {this.customButtonsSection()} ); } diff --git a/src/pages/staticPages/visualize/visualize.module.scss b/src/pages/staticPages/visualize/visualize.module.scss index 51c54ed3edc..a544514a009 100644 --- a/src/pages/staticPages/visualize/visualize.module.scss +++ b/src/pages/staticPages/visualize/visualize.module.scss @@ -4,3 +4,10 @@ padding-right: 40px; } } + +.customToolArray { + > div { + width: 550px; + padding-right: 40px; + } +} diff --git a/src/pages/staticPages/visualize/visualize.module.scss.d.ts b/src/pages/staticPages/visualize/visualize.module.scss.d.ts index bc1c0b39141..7127fd4766d 100644 --- a/src/pages/staticPages/visualize/visualize.module.scss.d.ts +++ b/src/pages/staticPages/visualize/visualize.module.scss.d.ts @@ -1,4 +1,5 @@ declare const styles: { + readonly "customToolArray": string; readonly "toolArray": string; }; export = styles; diff --git a/src/shared/components/CustomButton/CustomButton.spec.tsx b/src/shared/components/CustomButton/CustomButton.spec.tsx new file mode 100644 index 00000000000..45109679d6c --- /dev/null +++ b/src/shared/components/CustomButton/CustomButton.spec.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CustomButton } from './CustomButton'; +import { CustomButtonConfig } from './CustomButtonConfig'; +import { ICustomButtonProps, CustomButtonUrlParameters } from './ICustomButton'; + +jest.mock('cbioportal-frontend-commons', () => ({ + DefaultTooltip: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +describe('CustomButton Component', () => { + const testData = 'test data'; + const testDataLengthString = testData.length.toString(); + const testUrlFormat = + 'http://example.com?study={studyName}&-DataLength={dataLength}'; + const testStudyName = 'Test Study'; + const navigatorClipboardOriginal = navigator.clipboard; + + // we used to use window.location to navigate, then changed to window.open + const windowLocationOriginal = window.location; + const windowOpenOriginal = window.open; + const windowOpenMock = jest.fn(); + + const mockJson: string = ` +[ + { + "id": "test", + "name": "Test Tool", + "tooltip": "This button shows that the Test Tool is working", + "image_src": "https://frontend.cbioportal.org/reactapp/images/369b022222badf37b2b0c284f4ae2284.png", + "url_format": "https://eu.httpbin.org/anything?-StudyName={studyName}&-ImportDataLength={dataLength}" + } +] + `; + + const mockProps: ICustomButtonProps = { + toolConfig: { + name: 'Test', + id: 'test-tool', + url_format: testUrlFormat, + tooltip: 'Test Tooltip', + image_src: 'test-icon.png', + }, + baseTooltipProps: {}, + overlayClassName: '', + downloadDataAsync: () => Promise.resolve(testData), + urlFormatOverrides: {}, + }; + + beforeEach(() => { + (window as any).groupComparisonPage = { + store: { + displayedStudies: { + result: [{ name: testStudyName }], + }, + }, + }; + + // mock clipboard + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValueOnce(''), + }, + }); + + // Mock window.location.href + delete (window as any).location; + (window as any).location = { + href: '', + assign: jest.fn().mockImplementation(url => { + (window as any).location.href = url; + }), + }; + + // Mock window.open + (window as any).open = windowOpenMock; + }); + + afterEach(() => { + delete (window as any).groupComparisonPage; + Object.assign(navigator, navigatorClipboardOriginal); + window.location = windowLocationOriginal; + window.open = windowOpenOriginal; + }); + + it('parses json correctly and creates Config objects', () => { + const config = CustomButtonConfig.parseCustomButtonConfigs(mockJson); + expect(config.length).toBe(1); + expect(config[0].id).toBe('test'); + // TECH: compiler doesn't know that config[0] is valid, so we add a spurious optional chaining operator + expect(config[0]?.isAvailable?.()).toBe(true); + }); + + it('renders correctly', () => { + render(); + expect(screen.getByRole('button')).toBeTruthy(); + }); + + it('returns the correct study name from getSingleStudyName', () => { + const component = new CustomButton(mockProps); + expect(component.getSingleStudyName()).toBe('Test Study'); + }); + + it('calls handleClick on button click', () => { + const handleClickSpy = jest.spyOn( + CustomButton.prototype, + 'handleClick' + ); + const { getByRole } = render(); + const button = getByRole('button'); + fireEvent.click(button); + expect(handleClickSpy).toHaveBeenCalled(); + }); + + it('copies data to clipboard and calls openCustomUrl', async () => { + const openCustomUrlSpy = jest.spyOn( + CustomButton.prototype, + 'openCustomUrl' + ); + const { getByRole } = render(); + const button = getByRole('button'); + + fireEvent.click(button); + + await waitFor(() => + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(testData) + ); + + await waitFor(() => expect(openCustomUrlSpy).toHaveBeenCalled()); + + expect(openCustomUrlSpy).toHaveBeenCalledWith({ + dataLength: testDataLengthString, + }); + }); + + it('formats URL correctly and redirects', () => { + const component = new CustomButton(mockProps); + const urlParametersLaunch: CustomButtonUrlParameters = { + studyName: testStudyName, + dataLength: testDataLengthString, + }; + + // LOW: should manually assemble using actual test property values + const expectedUrl = + 'http://example.com?study=Test%20Study&-DataLength=9'; + + component.openCustomUrl(urlParametersLaunch); + + expect(windowOpenMock).toHaveBeenCalledWith(expectedUrl, '_blank'); + }); +}); diff --git a/src/shared/components/CustomButton/CustomButton.tsx b/src/shared/components/CustomButton/CustomButton.tsx new file mode 100644 index 00000000000..eea167cb9d0 --- /dev/null +++ b/src/shared/components/CustomButton/CustomButton.tsx @@ -0,0 +1,156 @@ +import * as React from 'react'; +import { Button, ButtonGroup } from 'react-bootstrap'; +import { CancerStudy } from 'cbioportal-ts-api-client'; +import { DefaultTooltip } from 'cbioportal-frontend-commons'; +import { + ICustomButtonConfig, + ICustomButtonProps, + CustomButtonUrlParameters, +} from './ICustomButton'; +import { CustomButtonConfig } from './CustomButtonConfig'; +import './styles.scss'; + +export class CustomButton extends React.Component { + constructor(props: ICustomButtonProps) { + super(props); + } + + get config(): ICustomButtonConfig { + return this.props.toolConfig; + } + + // OPTIMIZE: this is computed when needed. It could be lazy, so it's only computed once, but it's unlikely to be called more than once per instance + get urlParametersDefault(): CustomButtonUrlParameters { + return { + studyName: this.getSingleStudyName() ?? 'cBioPortal Data', + }; + } + + // RETURNS: the name of the study for the current context, if exactly one study; null otherwise + getSingleStudyName(): string | null { + // extract the study name from the current context + // CODEP: GroupComparisonPag stores a reference in the window, so when we are embedded there we can get details about which studies + const groupComparisonPage = (window as any).groupComparisonPage; + if (!groupComparisonPage) { + return null; + } + + const studies: CancerStudy[] = + groupComparisonPage.store.displayedStudies.result; + + if (studies.length === 1) { + return studies[0].name; + } else { + return null; + } + } + + openCustomUrl(urlParametersLaunch: CustomButtonUrlParameters) { + // assemble final available urlParameters + const urlParameters: CustomButtonUrlParameters = { + ...this.urlParametersDefault, + ...this.props.urlFormatOverrides, + ...urlParametersLaunch, + }; + + // e.g. url_format: 'foo://?-ProjectName={studyName}' + const urlFormat = this.props.toolConfig.url_format; + + // Replace all parameter references in urlFormat with the appropriate property in urlParameters + var url = urlFormat; + Object.keys(urlParameters).forEach(key => { + const value = urlParameters[key] ?? ''; + // TECH: location.href.set will actually encode the value, but we do it here for deterministic results with unit tests + url = url.replace( + new RegExp(`\{${key}\}`, 'g'), + encodeURIComponent(value) + ); + }); + + try { + window.open(url, '_blank'); + } catch (e) { + // TECH: in practice, this never gets hit. If the URL protocol is not supported, then a blank window appears. + alert('Launching ' + this.config.name + ' failed: ' + e); + } + } + + /** + * Passes the data to the CustomButton handler. For now, uses the clipboard, then opens custom URL. + * OPTIMIZE: compress the data or use a more efficient format + * @param data The data to pass to the handler. + */ + handleDataReady(data: string | undefined) { + if (!data) { + console.log('CustomButton: data is undefined'); + return; + } + + const urlParametersLaunch: CustomButtonUrlParameters = { + dataLength: data.length.toString(), + }; + + /* REF: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API + * Clipboard API supported in Chrome 66+, Firefox 63+, Safari 10.1+, Edge 79+, Opera 53+ + */ + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard + .writeText(data) + .then(() => { + console.log( + 'Data copied to clipboard - size:' + data.length + ); + this.openCustomUrl(urlParametersLaunch); + }) + .catch(err => { + console.error( + this.config.name + ' - Could not copy text: ', + err + ); + }); + } else { + // TODO: proper way to report a failure? + alert( + this.config.name + + ' launch failed: clipboard API is not avaialble.' + ); + } + } + + /** + * Downloads the data (async) then invokes handleDataReady, which will run the CustomHandler logic. + */ + handleClick() { + console.log( + 'CustomButton.handleLaunchStart:' + this.props.toolConfig.id + ); + + if (this.props.downloadDataAsync) { + this.props + .downloadDataAsync() + ?.then(data => this.handleDataReady(data)); + } else { + console.error(this.config.name + ': downloadData is not defined'); + } + } + + public render() { + const tool = this.props.toolConfig; + + return ( + {tool.tooltip}} + {...this.props.baseTooltipProps} + overlayClassName={this.props.overlayClassName} + > + + + ); + } +} diff --git a/src/shared/components/CustomButton/CustomButtonConfig.ts b/src/shared/components/CustomButton/CustomButtonConfig.ts new file mode 100644 index 00000000000..667cfbf09aa --- /dev/null +++ b/src/shared/components/CustomButton/CustomButtonConfig.ts @@ -0,0 +1,101 @@ +import { FontDetector } from './utils/FontDetector'; +import { ICustomButtonConfig } from './ICustomButton'; +import memoize from 'memoize-weak-decorator'; + +/** + * Define a CustomButton to display (in CopyDownloadButtons). + * Clicking on the button will launch it using the url_format + */ +export class CustomButtonConfig implements ICustomButtonConfig { + id: string; + name: string; + tooltip: string; + image_src: string; + required_user_agent?: string; + required_installed_font_family?: string; + url_format: string; + visualize_href?: string; + visualize_title?: string; + visualize_description?: string; + visualize_image_src?: string; + + public static parseCustomButtonConfigs( + customButtonsJson: string + ): ICustomButtonConfig[] { + if (!customButtonsJson) { + return []; + } else { + return JSON.parse(customButtonsJson).map( + (item: any) => + new CustomButtonConfig(item as ICustomButtonConfig) + ); + } + } + + /** + * Creates a new instance of the CustomButtonConfig class. + * @param config - The configuration object for the custom button. + */ + constructor(config: ICustomButtonConfig) { + this.id = config.id; + this.name = config.name; + this.tooltip = config.tooltip; + this.image_src = config.image_src; + this.required_user_agent = config.required_user_agent; + this.required_installed_font_family = + config.required_installed_font_family; + this.url_format = config.url_format; + this.visualize_href = config.visualize_href; + this.visualize_title = config.visualize_title; + this.visualize_description = config.visualize_description; + this.visualize_image_src = config.visualize_image_src; + } + + /** + * Checks if the CustomButton is available in the current context per the defined reuqirements. + * @returns A boolean value indicating if is available. + */ + isAvailable(): boolean { + const resultComputed = this.computeIsCustomButtonAvailable(); + // console.log(toolConfig.id + '.isAvailable.Computed:' + resultComputed); + return resultComputed; + } + + @memoize + checkToolRequirementsPlatform( + required_userAgent: string | undefined + ): boolean { + if (!required_userAgent) { + return true; + } + + return navigator.userAgent.indexOf(required_userAgent) >= 0; + } + + // OPTIMIZE: want to @memoize, but if user installs font, it wouldn't be detected. + checkToolRequirementsFontFamily(fontFamily: string | undefined): boolean { + if (!fontFamily) { + return true; + } + + const detector = new FontDetector(); + const result = detector.detect(fontFamily); + return result; + } + + computeIsCustomButtonAvailable(): boolean { + if (!this.checkToolRequirementsPlatform(this.required_user_agent)) { + return false; + } + + if ( + !this.checkToolRequirementsFontFamily( + this.required_installed_font_family + ) + ) { + return false; + } + + return true; + } +} diff --git a/src/shared/components/CustomButton/CustomButtonServerConfig.ts b/src/shared/components/CustomButton/CustomButtonServerConfig.ts new file mode 100644 index 00000000000..d6f4ae8181d --- /dev/null +++ b/src/shared/components/CustomButton/CustomButtonServerConfig.ts @@ -0,0 +1,24 @@ +import { getServerConfig } from 'config/config'; +import { CustomButtonConfig } from './CustomButtonConfig'; +import { ICustomButtonConfig } from './ICustomButton'; + +/** + * Lazy initialization from a JSON file configured on the server, which may define an array of CustomButtonConfig objects. + * @returns The CustomButtonConfigs from the server configuration. + */ +export const getCustomButtonConfigs = (() => { + let customButtons: ICustomButtonConfig[] | undefined = undefined; + + return (): ICustomButtonConfig[] => { + if (!customButtons) { + // Initialize + const customButtonsJson = getServerConfig() + .download_custom_buttons_json; + customButtons = CustomButtonConfig.parseCustomButtonConfigs( + customButtonsJson + ); + // console.log('CustomButtons: ' + customButtons.map(button => button.id).join(",")); + } + return customButtons; + }; +})(); diff --git a/src/shared/components/CustomButton/ICustomButton.ts b/src/shared/components/CustomButton/ICustomButton.ts new file mode 100644 index 00000000000..ca20bf6f2e8 --- /dev/null +++ b/src/shared/components/CustomButton/ICustomButton.ts @@ -0,0 +1,37 @@ +/** + * Properties that may be referenced from url_format, like "{studyName}". + * TECH: all properties are string, since it's easier for the TypeScript indexing operator. E.g. dataLength as string instead of integer. + */ +export type CustomButtonUrlParameters = { + studyName?: string; + dataLength?: string; + [key: string]: string | undefined; +}; + +/** + * This interface defines the properties that can be passed to the CustomButton component. + */ +export interface ICustomButtonProps { + toolConfig: ICustomButtonConfig; + // this is an object that contains a property map + baseTooltipProps: any; + overlayClassName?: string; + downloadDataAsync?: () => Promise; + urlFormatOverrides?: CustomButtonUrlParameters; +} + +export interface ICustomButtonConfig { + id: string; + name: string; + tooltip: string; + image_src: string; + required_user_agent?: string; + required_installed_font_family?: string; + url_format: string; + visualize_href?: string; + visualize_title?: string; + visualize_description?: string; + visualize_image_src?: string; + + isAvailable?(): boolean; +} diff --git a/src/shared/components/CustomButton/styles.scss b/src/shared/components/CustomButton/styles.scss new file mode 100644 index 00000000000..520c0e03ba9 --- /dev/null +++ b/src/shared/components/CustomButton/styles.scss @@ -0,0 +1,4 @@ +.customButtonImage { + width: 18px; + height: 18px; +} diff --git a/src/shared/components/CustomButton/utils/FontDetector.ts b/src/shared/components/CustomButton/utils/FontDetector.ts new file mode 100644 index 00000000000..485be3e8e51 --- /dev/null +++ b/src/shared/components/CustomButton/utils/FontDetector.ts @@ -0,0 +1,89 @@ +/** + * TypeScript class to detect if a font is installed + * + * ORIGINAL HEADER: + * JavaScript code to detect available availability of a + * particular font in a browser using JavaScript and CSS. + * + * Author : Lalit Patel + * Website: http://www.lalit.org/lab/javascript-css-font-detect/ + * License: Apache Software License 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * Version: 0.15 (21 Sep 2009) + * Changed comparision font to default from sans-default-default, + * as in FF3.0 font of child element didn't fallback + * to parent element if the font is missing. + * Version: 0.2 (04 Mar 2012) + * Comparing font against all the 3 generic font families ie, + * 'monospace', 'sans-serif' and 'sans'. If it doesn't match all 3 + * then that font is 100% not available in the system + * Version: 0.3 (24 Mar 2012) + * Replaced sans with serif in the list of baseFonts + * TypeScript Reactor: July 3, 2024 + */ + +/** + * Usage: d = new Detector(); + * d.detect('font name'); + */ + +export interface IFontDetector { + detect: (font: string) => boolean; +} + +export class FontDetector implements IFontDetector { + // a font will be compared against all the three default fonts. + // and if it doesn't match all 3 then that font is not available. + baseFonts = ['monospace', 'sans-serif', 'serif']; + + // we use m or w because these two characters take up the maximum width. + // And we use a LLi so that the same matching fonts can get separated + testString = 'mmmmmmmmmmlli'; + + // we test using 72px font size, we may use any size. I guess larger the better. + testSize = '72px'; + + detect: (font: string) => boolean; + + constructor() { + // precompute for the test + var defaultWidth: { [key: string]: number } = {}; + var defaultHeight: { [key: string]: number } = {}; + + var html = document.getElementsByTagName('body')[0]; + + // create a SPAN in the document to get the width of the text we use to test + var span = document.createElement('span'); + span.style.fontSize = this.testSize; + span.innerHTML = this.testString; + + const baseFonts = this.baseFonts; + for (var index in baseFonts) { + //get the default width for the three base fonts + span.style.fontFamily = baseFonts[index]; + html.appendChild(span); + defaultWidth[baseFonts[index]] = span.offsetWidth; + defaultHeight[baseFonts[index]] = span.offsetHeight; + html.removeChild(span); + } + + // expose a detect() function that leverages that state + this.detect = (font: string): boolean => { + // console.log("detect:" + font); + for (var index in baseFonts) { + // name of the font along with the base font for fallback. + span.style.fontFamily = font + ',' + baseFonts[index]; + // add the span with the test font, and see if it's actually using a baseFont + html.appendChild(span); + var matched = + span.offsetWidth != defaultWidth[baseFonts[index]] || + span.offsetHeight != defaultHeight[baseFonts[index]]; + html.removeChild(span); + if (matched) { + return true; + } + } + return false; + }; + } +} diff --git a/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx b/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx index ba62bfbde36..c454c09639a 100644 --- a/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx +++ b/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx @@ -3,6 +3,8 @@ import { If } from 'react-if'; import { Button, ButtonGroup } from 'react-bootstrap'; import { DefaultTooltip } from 'cbioportal-frontend-commons'; import { ICopyDownloadInputsProps } from './ICopyDownloadControls'; +import { getCustomButtonConfigs } from 'shared/components/CustomButton/CustomButtonServerConfig'; +import { CustomButton } from '../CustomButton/CustomButton'; export interface ICopyDownloadButtonsProps extends ICopyDownloadInputsProps { copyButtonRef?: (el: HTMLButtonElement | null) => void; @@ -78,6 +80,27 @@ export class CopyDownloadButtons extends React.Component< ); } + customButtons() { + // TECH: was not working with returning multiple items in JSX.Element[], so moved the conditional here. + if (!this.props.showDownload) { + return null; + } + + return getCustomButtonConfigs() + .filter(tool => tool.isAvailable?.() ?? true) + .map((tool, index: number) => { + return ( + + ); + }); + } + public render() { return ( @@ -86,6 +109,7 @@ export class CopyDownloadButtons extends React.Component< {this.downloadButton()} + {this.customButtons()} ); diff --git a/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx b/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx index dd11fb8483d..1de69f70092 100644 --- a/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx +++ b/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx @@ -90,6 +90,7 @@ export class CopyDownloadControls extends React.Component< copyLabel={this.props.copyLabel} downloadLabel={this.props.downloadLabel} handleDownload={this.handleDownload} + downloadDataAsync={this.downloadDataAsStringAsync} handleCopy={this.handleCopy} copyButtonRef={(el: HTMLButtonElement) => { this._copyButton = el; @@ -102,6 +103,18 @@ export class CopyDownloadControls extends React.Component< ); } + /** + * Wrapper around downloadData() to return as a Promise for ICopyDownloadButtonsProps + * see TECH_DOWNLOADDATA + */ + private downloadDataAsStringAsync = (): Promise => { + if (this.props.downloadData) { + return this.props.downloadData().then(data => data.text); + } else { + return Promise.resolve(undefined); + } + }; + public downloadIndicatorModal(): JSX.Element { return ( void; handleCopy?: () => void; + // expose downloadData() to allow button to handle the data on it's own. + // TECH_DOWNLOADDATA: CopyDownloadButtons.downloadData needs to be async so it can work with either async context (IAsyncCopyDownloadControlsProps) or synchronous context (SimpleCopyDownloadControls) + downloadDataAsync?: () => Promise; } diff --git a/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx b/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx index 0fdf4581abe..314a5025896 100644 --- a/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx +++ b/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx @@ -84,6 +84,7 @@ export class SimpleCopyDownloadControls extends React.Component< for ICopyDownloadButtonsProps + * See TECH_DOWNLOADDATA + */ + private downloadDataAsPromise = (): Promise => { + const data = this.props.downloadData?.(); + return Promise.resolve(data); + }; + private handleDownload() { if (this.props.downloadData) { fileDownload( 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/components/query/QueryStore.ts b/src/shared/components/query/QueryStore.ts index 13e878d188b..0ffabd04c6e 100644 --- a/src/shared/components/query/QueryStore.ts +++ b/src/shared/components/query/QueryStore.ts @@ -1170,6 +1170,10 @@ export class QueryStore { return _.sumBy(this.selectableSelectedStudies, s => s.allSampleCount); } + @computed get sampleCountForAllStudies() { + return _.sumBy(this.selectableStudies, s => s.allSampleCount); + } + readonly sampleLists = remoteData({ invoke: async () => { if (!this.isSingleNonVirtualStudySelected) { @@ -1578,6 +1582,10 @@ export class QueryStore { .filter(_.identity); } + @computed get selectableStudies() { + return Array.from(this.treeData.map_studyId_cancerStudy.values()); + } + public isVirtualStudy(studyId: string): boolean { // if the study id doesn't correspond to one in this.cancerStudies, then its a virtual Study return !this.cancerStudyIdsSet.result[studyId]; diff --git a/src/shared/components/query/StudySelectorStats.tsx b/src/shared/components/query/StudySelectorStats.tsx index 732c6454e26..cedbddc6000 100644 --- a/src/shared/components/query/StudySelectorStats.tsx +++ b/src/shared/components/query/StudySelectorStats.tsx @@ -18,6 +18,10 @@ export const StudySelectorStats: React.FunctionComponent<{ let numSelectedStudies = expr( () => props.store.selectableSelectedStudyIds.length ); + let numAllStudies = expr( + () => props.store.selectableStudies.length + ); + return ( <> - {numSelectedStudies}{' '} - {numSelectedStudies === 1 ? 'study' : 'studies'}{' '} - selected ( - - {props.store.sampleCountForSelectedStudies} - {' '} - samples) + {props.store.selectableSelectedStudies.length == + 0 && ( +
+ {numAllStudies} studies available + ( + + { + props.store + .sampleCountForAllStudies + } + {' '} + samples) +
+ )} + + {props.store.selectableSelectedStudies.length > + 0 && ( +
+ {numSelectedStudies}{' '} + {numSelectedStudies === 1 + ? 'study' + : 'studies'}{' '} + selected ( + + { + props.store + .sampleCountForSelectedStudies + } + {' '} + samples) +
+ )}
{props.store.selectableSelectedStudies.length > 0 && ( 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}`; +} diff --git a/typings/missing.d.ts b/typings/missing.d.ts index 062e6bdc71e..03416d87525 100644 --- a/typings/missing.d.ts +++ b/typings/missing.d.ts @@ -2,6 +2,7 @@ declare module '*.scss'; declare module '*.json'; declare module '*.md'; +declare module '*.svg'; // these packages are missing typings declare module 'fmin';