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_title} + +

+

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

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