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