-
-
-
-
{this.studyResources.component}
+
+
+
+
+
+
{this.studyResources.component}
+
+
+
+
+ Patient and Sample Resources
+
+
+
+
);
}
diff --git a/src/shared/api/urls.ts b/src/shared/api/urls.ts
index b54dd9179ea..5ade1d701c6 100644
--- a/src/shared/api/urls.ts
+++ b/src/shared/api/urls.ts
@@ -120,6 +120,15 @@ export function getSampleViewUrl(
studyId: string,
sampleId: string,
navIds?: { patientId: string; studyId: string }[]
+) {
+ return getSampleViewUrlWithPathname(studyId, sampleId, 'patient', navIds);
+}
+
+export function getSampleViewUrlWithPathname(
+ studyId: string,
+ sampleId: string,
+ pathname: string = 'patient',
+ navIds?: { patientId: string; studyId: string }[]
) {
let hash: any = undefined;
if (navIds) {
@@ -127,8 +136,18 @@ export function getSampleViewUrl(
.map(id => `${id.studyId}:${id.patientId}`)
.join(',')}`;
}
- return buildCBioPortalPageUrl('patient', { sampleId, studyId }, hash);
+ return buildCBioPortalPageUrl(pathname, { sampleId, studyId }, hash);
}
+
+export function getResourceViewUrlWithPathname(
+ studyId: string,
+ pathname: string,
+ patientId: string
+) {
+ let caseId: string = `${patientId}`;
+ return buildCBioPortalPageUrl(pathname, { studyId, caseId });
+}
+
export function getPatientViewUrl(
studyId: string,
caseId: string,
@@ -140,7 +159,22 @@ export function getPatientViewUrl(
.map(id => `${id.studyId}:${id.patientId}`)
.join(',')}`;
}
- return buildCBioPortalPageUrl('patient', { studyId, caseId }, hash);
+ return getPatientViewUrlWithPathname(studyId, caseId, 'patient', navIds);
+}
+
+export function getPatientViewUrlWithPathname(
+ studyId: string,
+ caseId: string,
+ pathname: string = 'patient',
+ navIds?: { patientId: string; studyId: string }[]
+) {
+ let hash: any = undefined;
+ if (navIds) {
+ hash = `navCaseIds=${navIds
+ .map(id => `${id.studyId}:${id.patientId}`)
+ .join(',')}`;
+ }
+ return buildCBioPortalPageUrl(pathname, { studyId, caseId }, hash);
}
export function getComparisonUrl(params: Partial
) {
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..19dbdcb4bdd
--- /dev/null
+++ b/src/shared/components/CustomButton/CustomButton.tsx
@@ -0,0 +1,174 @@
+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',
+ };
+ }
+
+ /**
+ * extract the study name from the current context.
+ * @returns the name of the study for the current context; null if cannot be determined
+ *
+ * CODEP: There are two contexts we can handle:
+ * 1) GroupComparisonPage - stores reference in window.groupComparisonPage:
+ * groupComparisonPage.store.displayedStudies
+ * 2) ResultsViewPage - stores reference in window.resultsViewPageStore:
+ * resultsViewPageStore.queriedStudies
+ * Both are likely MobxPromiseInputParamsWithDefault objects.
+ */
+ getSingleStudyName(): string | null {
+ var studies: CancerStudy[] | null = null;
+ const groupComparisonPage = (window as any).groupComparisonPage;
+ if (groupComparisonPage != null) {
+ studies = groupComparisonPage.store.displayedStudies.result;
+ } else {
+ const resultsViewPageStore = (window as any).resultsViewPageStore;
+ if (resultsViewPageStore != null) {
+ studies = resultsViewPageStore.queriedStudies.result;
+ }
+ }
+
+ if (studies == null) {
+ return null;
+ }
+
+ switch (studies.length) {
+ case 0:
+ return null;
+ case 1:
+ return studies[0].name;
+ default:
+ return 'Combined Studies';
+ }
+ }
+
+ openCustomUrl(urlParametersLaunch: CustomButtonUrlParameters) {
+ // assemble final available urlParameters
+ const urlParameters: CustomButtonUrlParameters = {
+ ...this.urlParametersDefault,
+ ...this.props.urlFormatOverrides,
+ ...urlParametersLaunch,
+ };
+
+ // e.g. url_format: 'foo://?-ProjectName={studyName}'
+ const urlFormat = this.props.toolConfig.url_format;
+
+ // Replace all parameter references in urlFormat with the appropriate property in urlParameters
+ var url = urlFormat;
+ Object.keys(urlParameters).forEach(key => {
+ const value = urlParameters[key] ?? '';
+ // TECH: location.href.set will actually encode the value, but we do it here for deterministic results with unit tests
+ url = url.replace(
+ new RegExp(`\{${key}\}`, 'g'),
+ encodeURIComponent(value)
+ );
+ });
+
+ try {
+ window.open(url, '_blank');
+ } catch (e) {
+ // TECH: in practice, this never gets hit. If the URL protocol is not supported, then a blank window appears.
+ alert('Launching ' + this.config.name + ' failed: ' + e);
+ }
+ }
+
+ /**
+ * Passes the data to the CustomButton handler. For now, uses the clipboard, then opens custom URL.
+ * OPTIMIZE: compress the data or use a more efficient format
+ * @param data The data to pass to the handler.
+ */
+ handleDataReady(data: string | undefined) {
+ if (!data) {
+ console.log('CustomButton: data is undefined');
+ return;
+ }
+
+ const urlParametersLaunch: CustomButtonUrlParameters = {
+ dataLength: data.length.toString(),
+ };
+
+ /* REF: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API
+ * Clipboard API supported in Chrome 66+, Firefox 63+, Safari 10.1+, Edge 79+, Opera 53+
+ */
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard
+ .writeText(data)
+ .then(() => {
+ console.log(
+ 'Data copied to clipboard - size:' + data.length
+ );
+ this.openCustomUrl(urlParametersLaunch);
+ })
+ .catch(err => {
+ console.error(
+ this.config.name + ' - Could not copy text: ',
+ err
+ );
+ });
+ } else {
+ // TODO: proper way to report a failure?
+ alert(
+ this.config.name +
+ ' launch failed: clipboard API is not avaialble.'
+ );
+ }
+ }
+
+ /**
+ * Downloads the data (async) then invokes handleDataReady, which will run the CustomHandler logic.
+ */
+ handleClick() {
+ console.log(
+ 'CustomButton.handleLaunchStart:' + this.props.toolConfig.id
+ );
+
+ if (this.props.downloadDataAsync) {
+ this.props
+ .downloadDataAsync()
+ ?.then(data => this.handleDataReady(data));
+ } else {
+ console.error(this.config.name + ': downloadData is not defined');
+ }
+ }
+
+ public render() {
+ const tool = this.props.toolConfig;
+
+ return (
+ {tool.tooltip}}
+ {...this.props.baseTooltipProps}
+ overlayClassName={this.props.overlayClassName}
+ >
+
+
+ );
+ }
+}
diff --git a/src/shared/components/CustomButton/CustomButtonConfig.ts b/src/shared/components/CustomButton/CustomButtonConfig.ts
new file mode 100644
index 00000000000..667cfbf09aa
--- /dev/null
+++ b/src/shared/components/CustomButton/CustomButtonConfig.ts
@@ -0,0 +1,101 @@
+import { FontDetector } from './utils/FontDetector';
+import { ICustomButtonConfig } from './ICustomButton';
+import memoize from 'memoize-weak-decorator';
+
+/**
+ * Define a CustomButton to display (in CopyDownloadButtons).
+ * Clicking on the button will launch it using the url_format
+ */
+export class CustomButtonConfig implements ICustomButtonConfig {
+ id: string;
+ name: string;
+ tooltip: string;
+ image_src: string;
+ required_user_agent?: string;
+ required_installed_font_family?: string;
+ url_format: string;
+ visualize_href?: string;
+ visualize_title?: string;
+ visualize_description?: string;
+ visualize_image_src?: string;
+
+ public static parseCustomButtonConfigs(
+ customButtonsJson: string
+ ): ICustomButtonConfig[] {
+ if (!customButtonsJson) {
+ return [];
+ } else {
+ return JSON.parse(customButtonsJson).map(
+ (item: any) =>
+ new CustomButtonConfig(item as ICustomButtonConfig)
+ );
+ }
+ }
+
+ /**
+ * Creates a new instance of the CustomButtonConfig class.
+ * @param config - The configuration object for the custom button.
+ */
+ constructor(config: ICustomButtonConfig) {
+ this.id = config.id;
+ this.name = config.name;
+ this.tooltip = config.tooltip;
+ this.image_src = config.image_src;
+ this.required_user_agent = config.required_user_agent;
+ this.required_installed_font_family =
+ config.required_installed_font_family;
+ this.url_format = config.url_format;
+ this.visualize_href = config.visualize_href;
+ this.visualize_title = config.visualize_title;
+ this.visualize_description = config.visualize_description;
+ this.visualize_image_src = config.visualize_image_src;
+ }
+
+ /**
+ * Checks if the CustomButton is available in the current context per the defined reuqirements.
+ * @returns A boolean value indicating if is available.
+ */
+ isAvailable(): boolean {
+ const resultComputed = this.computeIsCustomButtonAvailable();
+ // console.log(toolConfig.id + '.isAvailable.Computed:' + resultComputed);
+ return resultComputed;
+ }
+
+ @memoize
+ checkToolRequirementsPlatform(
+ required_userAgent: string | undefined
+ ): boolean {
+ if (!required_userAgent) {
+ return true;
+ }
+
+ return navigator.userAgent.indexOf(required_userAgent) >= 0;
+ }
+
+ // OPTIMIZE: want to @memoize, but if user installs font, it wouldn't be detected.
+ checkToolRequirementsFontFamily(fontFamily: string | undefined): boolean {
+ if (!fontFamily) {
+ return true;
+ }
+
+ const detector = new FontDetector();
+ const result = detector.detect(fontFamily);
+ return result;
+ }
+
+ computeIsCustomButtonAvailable(): boolean {
+ if (!this.checkToolRequirementsPlatform(this.required_user_agent)) {
+ return false;
+ }
+
+ if (
+ !this.checkToolRequirementsFontFamily(
+ this.required_installed_font_family
+ )
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/shared/components/CustomButton/CustomButtonServerConfig.ts b/src/shared/components/CustomButton/CustomButtonServerConfig.ts
new file mode 100644
index 00000000000..d6f4ae8181d
--- /dev/null
+++ b/src/shared/components/CustomButton/CustomButtonServerConfig.ts
@@ -0,0 +1,24 @@
+import { getServerConfig } from 'config/config';
+import { CustomButtonConfig } from './CustomButtonConfig';
+import { ICustomButtonConfig } from './ICustomButton';
+
+/**
+ * Lazy initialization from a JSON file configured on the server, which may define an array of CustomButtonConfig objects.
+ * @returns The CustomButtonConfigs from the server configuration.
+ */
+export const getCustomButtonConfigs = (() => {
+ let customButtons: ICustomButtonConfig[] | undefined = undefined;
+
+ return (): ICustomButtonConfig[] => {
+ if (!customButtons) {
+ // Initialize
+ const customButtonsJson = getServerConfig()
+ .download_custom_buttons_json;
+ customButtons = CustomButtonConfig.parseCustomButtonConfigs(
+ customButtonsJson
+ );
+ // console.log('CustomButtons: ' + customButtons.map(button => button.id).join(","));
+ }
+ return customButtons;
+ };
+})();
diff --git a/src/shared/components/CustomButton/ICustomButton.ts b/src/shared/components/CustomButton/ICustomButton.ts
new file mode 100644
index 00000000000..ca20bf6f2e8
--- /dev/null
+++ b/src/shared/components/CustomButton/ICustomButton.ts
@@ -0,0 +1,37 @@
+/**
+ * Properties that may be referenced from url_format, like "{studyName}".
+ * TECH: all properties are string, since it's easier for the TypeScript indexing operator. E.g. dataLength as string instead of integer.
+ */
+export type CustomButtonUrlParameters = {
+ studyName?: string;
+ dataLength?: string;
+ [key: string]: string | undefined;
+};
+
+/**
+ * This interface defines the properties that can be passed to the CustomButton component.
+ */
+export interface ICustomButtonProps {
+ toolConfig: ICustomButtonConfig;
+ // this is an object that contains a property map
+ baseTooltipProps: any;
+ overlayClassName?: string;
+ downloadDataAsync?: () => Promise;
+ urlFormatOverrides?: CustomButtonUrlParameters;
+}
+
+export interface ICustomButtonConfig {
+ id: string;
+ name: string;
+ tooltip: string;
+ image_src: string;
+ required_user_agent?: string;
+ required_installed_font_family?: string;
+ url_format: string;
+ visualize_href?: string;
+ visualize_title?: string;
+ visualize_description?: string;
+ visualize_image_src?: string;
+
+ isAvailable?(): boolean;
+}
diff --git a/src/shared/components/CustomButton/styles.scss b/src/shared/components/CustomButton/styles.scss
new file mode 100644
index 00000000000..520c0e03ba9
--- /dev/null
+++ b/src/shared/components/CustomButton/styles.scss
@@ -0,0 +1,4 @@
+.customButtonImage {
+ width: 18px;
+ height: 18px;
+}
diff --git a/src/shared/components/CustomButton/utils/FontDetector.ts b/src/shared/components/CustomButton/utils/FontDetector.ts
new file mode 100644
index 00000000000..485be3e8e51
--- /dev/null
+++ b/src/shared/components/CustomButton/utils/FontDetector.ts
@@ -0,0 +1,89 @@
+/**
+ * TypeScript class to detect if a font is installed
+ *
+ * ORIGINAL HEADER:
+ * JavaScript code to detect available availability of a
+ * particular font in a browser using JavaScript and CSS.
+ *
+ * Author : Lalit Patel
+ * Website: http://www.lalit.org/lab/javascript-css-font-detect/
+ * License: Apache Software License 2.0
+ * http://www.apache.org/licenses/LICENSE-2.0
+ * Version: 0.15 (21 Sep 2009)
+ * Changed comparision font to default from sans-default-default,
+ * as in FF3.0 font of child element didn't fallback
+ * to parent element if the font is missing.
+ * Version: 0.2 (04 Mar 2012)
+ * Comparing font against all the 3 generic font families ie,
+ * 'monospace', 'sans-serif' and 'sans'. If it doesn't match all 3
+ * then that font is 100% not available in the system
+ * Version: 0.3 (24 Mar 2012)
+ * Replaced sans with serif in the list of baseFonts
+ * TypeScript Reactor: July 3, 2024
+ */
+
+/**
+ * Usage: d = new Detector();
+ * d.detect('font name');
+ */
+
+export interface IFontDetector {
+ detect: (font: string) => boolean;
+}
+
+export class FontDetector implements IFontDetector {
+ // a font will be compared against all the three default fonts.
+ // and if it doesn't match all 3 then that font is not available.
+ baseFonts = ['monospace', 'sans-serif', 'serif'];
+
+ // we use m or w because these two characters take up the maximum width.
+ // And we use a LLi so that the same matching fonts can get separated
+ testString = 'mmmmmmmmmmlli';
+
+ // we test using 72px font size, we may use any size. I guess larger the better.
+ testSize = '72px';
+
+ detect: (font: string) => boolean;
+
+ constructor() {
+ // precompute for the test
+ var defaultWidth: { [key: string]: number } = {};
+ var defaultHeight: { [key: string]: number } = {};
+
+ var html = document.getElementsByTagName('body')[0];
+
+ // create a SPAN in the document to get the width of the text we use to test
+ var span = document.createElement('span');
+ span.style.fontSize = this.testSize;
+ span.innerHTML = this.testString;
+
+ const baseFonts = this.baseFonts;
+ for (var index in baseFonts) {
+ //get the default width for the three base fonts
+ span.style.fontFamily = baseFonts[index];
+ html.appendChild(span);
+ defaultWidth[baseFonts[index]] = span.offsetWidth;
+ defaultHeight[baseFonts[index]] = span.offsetHeight;
+ html.removeChild(span);
+ }
+
+ // expose a detect() function that leverages that state
+ this.detect = (font: string): boolean => {
+ // console.log("detect:" + font);
+ for (var index in baseFonts) {
+ // name of the font along with the base font for fallback.
+ span.style.fontFamily = font + ',' + baseFonts[index];
+ // add the span with the test font, and see if it's actually using a baseFont
+ html.appendChild(span);
+ var matched =
+ span.offsetWidth != defaultWidth[baseFonts[index]] ||
+ span.offsetHeight != defaultHeight[baseFonts[index]];
+ html.removeChild(span);
+ if (matched) {
+ return true;
+ }
+ }
+ return false;
+ };
+ }
+}
diff --git a/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx b/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx
index ba62bfbde36..c454c09639a 100644
--- a/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx
+++ b/src/shared/components/copyDownloadControls/CopyDownloadButtons.tsx
@@ -3,6 +3,8 @@ import { If } from 'react-if';
import { Button, ButtonGroup } from 'react-bootstrap';
import { DefaultTooltip } from 'cbioportal-frontend-commons';
import { ICopyDownloadInputsProps } from './ICopyDownloadControls';
+import { getCustomButtonConfigs } from 'shared/components/CustomButton/CustomButtonServerConfig';
+import { CustomButton } from '../CustomButton/CustomButton';
export interface ICopyDownloadButtonsProps extends ICopyDownloadInputsProps {
copyButtonRef?: (el: HTMLButtonElement | null) => void;
@@ -78,6 +80,27 @@ export class CopyDownloadButtons extends React.Component<
);
}
+ customButtons() {
+ // TECH: was not working with returning multiple items in JSX.Element[], so moved the conditional here.
+ if (!this.props.showDownload) {
+ return null;
+ }
+
+ return getCustomButtonConfigs()
+ .filter(tool => tool.isAvailable?.() ?? true)
+ .map((tool, index: number) => {
+ return (
+
+ );
+ });
+ }
+
public render() {
return (
@@ -86,6 +109,7 @@ export class CopyDownloadButtons extends React.Component<
{this.downloadButton()}
+ {this.customButtons()}
);
diff --git a/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx b/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx
index dd11fb8483d..1de69f70092 100644
--- a/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx
+++ b/src/shared/components/copyDownloadControls/CopyDownloadControls.tsx
@@ -90,6 +90,7 @@ export class CopyDownloadControls extends React.Component<
copyLabel={this.props.copyLabel}
downloadLabel={this.props.downloadLabel}
handleDownload={this.handleDownload}
+ downloadDataAsync={this.downloadDataAsStringAsync}
handleCopy={this.handleCopy}
copyButtonRef={(el: HTMLButtonElement) => {
this._copyButton = el;
@@ -102,6 +103,18 @@ export class CopyDownloadControls extends React.Component<
);
}
+ /**
+ * Wrapper around downloadData() to return as a Promise for ICopyDownloadButtonsProps
+ * see TECH_DOWNLOADDATA
+ */
+ private downloadDataAsStringAsync = (): Promise => {
+ if (this.props.downloadData) {
+ return this.props.downloadData().then(data => data.text);
+ } else {
+ return Promise.resolve(undefined);
+ }
+ };
+
public downloadIndicatorModal(): JSX.Element {
return (
void;
handleCopy?: () => void;
+ // expose downloadData() to allow button to handle the data on it's own.
+ // TECH_DOWNLOADDATA: CopyDownloadButtons.downloadData needs to be async so it can work with either async context (IAsyncCopyDownloadControlsProps) or synchronous context (SimpleCopyDownloadControls)
+ downloadDataAsync?: () => Promise;
}
diff --git a/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx b/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx
index 0fdf4581abe..314a5025896 100644
--- a/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx
+++ b/src/shared/components/copyDownloadControls/SimpleCopyDownloadControls.tsx
@@ -84,6 +84,7 @@ export class SimpleCopyDownloadControls extends React.Component<
for ICopyDownloadButtonsProps
+ * See TECH_DOWNLOADDATA
+ */
+ private downloadDataAsPromise = (): Promise => {
+ const data = this.props.downloadData?.();
+ return Promise.resolve(data);
+ };
+
private handleDownload() {
if (this.props.downloadData) {
fileDownload(
diff --git a/src/shared/components/oncoprint/Oncoprint.tsx b/src/shared/components/oncoprint/Oncoprint.tsx
index 48e01dc6d59..ccb9653ecab 100644
--- a/src/shared/components/oncoprint/Oncoprint.tsx
+++ b/src/shared/components/oncoprint/Oncoprint.tsx
@@ -22,6 +22,7 @@ import {
} from 'shared/model/AnnotatedMutation';
import { CustomDriverNumericGeneMolecularData } from 'shared/model/CustomDriverNumericGeneMolecularData';
import { ExtendedAlteration } from 'shared/model/ExtendedAlteration';
+import { GAP_MODE_ENUM } from 'oncoprintjs';
export type CategoricalTrackDatum = {
entity: string;
@@ -90,7 +91,7 @@ export class ClinicalTrackConfig {
export type ClinicalTrackConfigChange = {
stableId?: string;
sortOrder?: string;
- gapOn?: boolean;
+ gapMode?: GAP_MODE_ENUM;
};
export type ClinicalTrackConfigMap = {
@@ -313,7 +314,7 @@ export interface IOncoprintProps {
onDeleteClinicalTrack?: (key: string) => void;
onDeleteGeneticTrack?: (key: string, sublabel: string) => void;
onTrackSortDirectionChange?: (trackId: TrackId, dir: number) => void;
- onTrackGapChange?: (trackId: TrackId, gap: boolean) => void;
+ onTrackGapChange?: (trackId: TrackId, gap: GAP_MODE_ENUM) => void;
trackKeySelectedForEdit?: string | null;
setTrackKeySelectedForEdit?: (key: string | null) => void;
diff --git a/src/shared/components/oncoprint/ResultsViewOncoprint.tsx b/src/shared/components/oncoprint/ResultsViewOncoprint.tsx
index d96fa535133..aee6055c16f 100644
--- a/src/shared/components/oncoprint/ResultsViewOncoprint.tsx
+++ b/src/shared/components/oncoprint/ResultsViewOncoprint.tsx
@@ -15,7 +15,7 @@ import {
remoteData,
svgToPdfDownload,
} from 'cbioportal-frontend-commons';
-import { getRemoteDataGroupStatus } from 'cbioportal-utils';
+import { getRemoteDataGroupStatus, Mutation } from 'cbioportal-utils';
import Oncoprint, {
ClinicalTrackSpec,
ClinicalTrackConfig,
@@ -59,7 +59,7 @@ import { getServerConfig } from 'config/config';
import LoadingIndicator from 'shared/components/loadingIndicator/LoadingIndicator';
import { OncoprintJS, RGBAColor, TrackGroupIndex, TrackId } from 'oncoprintjs';
import fileDownload from 'react-file-download';
-import tabularDownload from './tabularDownload';
+import tabularDownload, { getTabularDownloadData } from './tabularDownload';
import classNames from 'classnames';
import {
clinicalAttributeIsLocallyComputed,
@@ -99,6 +99,9 @@ import ClinicalTrackColorPicker from './ClinicalTrackColorPicker';
import { hexToRGBA, rgbaToHex } from 'shared/lib/Colors';
import classnames from 'classnames';
import { OncoprintColorModal } from './OncoprintColorModal';
+import JupyterNoteBookModal from 'pages/staticPages/tools/oncoprinter/JupyterNotebookModal';
+import { convertToCSV } from 'shared/lib/calculation/JSONtoCSV';
+import { GAP_MODE_ENUM } from 'oncoprintjs';
interface IResultsViewOncoprintProps {
divId: string;
@@ -773,6 +776,24 @@ export default class ResultsViewOncoprint extends React.Component<
this.mouseInsideBounds = false;
}
+ // jupyternotebook modal handling:
+
+ @observable public showJupyterNotebookModal = false;
+ @observable private jupyterFileContent: string | undefined = '';
+ @observable private jupyterFileName: string | undefined = '';
+
+ @action
+ private openJupyterNotebookModal = () => {
+ this.showJupyterNotebookModal = true;
+ };
+
+ @action
+ private closeJupyterNotebookModal = () => {
+ this.showJupyterNotebookModal = false;
+ this.jupyterFileContent = undefined;
+ this.jupyterFileName = undefined;
+ };
+
private buildControlsHandlers() {
return {
onSelectColumnType: (type: OncoprintAnalysisCaseType) => {
@@ -1053,6 +1074,8 @@ export default class ResultsViewOncoprint extends React.Component<
this.genesetHeatmapTracks,
this.props.store
.clinicalAttributeIdToClinicalAttribute,
+ this.props.store.mutationsByGene,
+ this.props.store.studyIds,
],
(
samples: Sample[],
@@ -1063,7 +1086,11 @@ export default class ResultsViewOncoprint extends React.Component<
genesetHeatmapTracks: IGenesetHeatmapTrackSpec[],
attributeIdToAttribute: {
[attributeId: string]: ClinicalAttribute;
- }
+ },
+ mutationsByGenes: {
+ [gene: string]: Mutation[];
+ },
+ studyIds: string[]
) => {
const caseIds =
this.oncoprintAnalysisCaseType ===
@@ -1115,14 +1142,82 @@ export default class ResultsViewOncoprint extends React.Component<
const oncoprinterWindow = window.open(
buildCBioPortalPageUrl('/oncoprinter')
) as any;
+
+ // extra data that needs to be send for jupyter-notebook
+ const allMutations = Object.values(
+ mutationsByGenes
+ ).reduce(
+ (acc, geneArray) => [...acc, ...geneArray],
+ []
+ );
+
oncoprinterWindow.clientPostedData = {
genetic: geneticInput,
clinical: clinicalInput,
heatmap: heatmapInput,
+ mutations: JSON.stringify(allMutations),
+ studyIds: JSON.stringify(studyIds),
};
}
);
break;
+ case 'jupyterNoteBook':
+ onMobxPromise(
+ [
+ this.props.store.sampleKeyToSample,
+ this.props.store.patientKeyToPatient,
+ this.props.store.mutationsByGene,
+ this.props.store.studyIds,
+ ],
+ (
+ sampleKeyToSample: {
+ [sampleKey: string]: Sample;
+ },
+ patientKeyToPatient: any,
+ mutationsByGenes: {
+ [gene: string]: Mutation[];
+ },
+ studyIds: string[]
+ ) => {
+ const allGenesMutations = Object.values(
+ mutationsByGenes
+ ).reduce(
+ (acc, geneArray) => [...acc, ...geneArray],
+ []
+ );
+
+ const fieldsToKeep = [
+ 'hugoGeneSymbol',
+ 'alterationType',
+ 'chr',
+ 'startPosition',
+ 'endPosition',
+ 'referenceAllele',
+ 'variantAllele',
+ 'proteinChange',
+ 'proteinPosStart',
+ 'proteinPosEnd',
+ 'mutationType',
+ 'oncoKbOncogenic',
+ 'patientId',
+ 'sampleId',
+ 'isHotspot',
+ ];
+
+ const allGenesMutationsCsv = convertToCSV(
+ allGenesMutations,
+ fieldsToKeep
+ );
+
+ this.jupyterFileContent = allGenesMutationsCsv;
+
+ this.jupyterFileName = studyIds.join('&');
+
+ // sending content to the modal
+ this.openJupyterNotebookModal();
+ }
+ );
+ break;
}
},
onSetHorzZoom: (z: number) => {
@@ -1577,9 +1672,8 @@ export default class ResultsViewOncoprint extends React.Component<
* Called when a track gap is added from within oncoprintjs UI
*/
@action.bound
- @action.bound
- private onTrackGapChange(trackId: TrackId, gapOn: boolean) {
- this.handleClinicalTrackChange(trackId, { gapOn });
+ private onTrackGapChange(trackId: TrackId, mode: GAP_MODE_ENUM) {
+ this.handleClinicalTrackChange(trackId, { gapMode: mode });
}
private handleClinicalTrackChange(
@@ -2333,6 +2427,15 @@ export default class ResultsViewOncoprint extends React.Component<
+
+ {this.jupyterFileContent && this.jupyterFileName && (
+