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/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/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/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(