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
+
+
+
+
+
+ );
+ }
+}
+
+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_description}
+
+ Try it!
+
+
+ {button.visualize_image_src && (
+
+
+
+ )}
+
+ ))}
+
+ >
+ );
+ }
+
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';