diff --git a/build/plotcss.js b/build/plotcss.js index 75d2ff3967d..438288c24a3 100644 --- a/build/plotcss.js +++ b/build/plotcss.js @@ -54,7 +54,8 @@ var rules = { "X .plotly-cloud-dialog .plotly-cloud-dialog-box": "box-sizing:border-box;min-width:300px;max-width:420px;padding:20px 24px;background-color:#fff;border:1px solid #e0e2e5;border-radius:4px;box-shadow:0 4px 16px rgba(0,0,0,.25);font-size:13px;color:#2a3f5f;", "X .plotly-cloud-dialog .plotly-cloud-dialog-title": "font-size:16px;font-weight:bold;margin-bottom:12px;", "X .plotly-cloud-dialog .plotly-cloud-dialog-message": "line-height:1.5;overflow-wrap:break-word;word-wrap:break-word;", - "X .plotly-cloud-dialog .plotly-cloud-dialog-message--hostname": "font-weight:bold;", + "X .plotly-cloud-dialog .plotly-cloud-dialog-message--hostname": "font-weight:bold;text-decoration:underline;", + "X .plotly-cloud-dialog .plotly-cloud-dialog-message--account": "margin-top:16px;padding:8px;border-radius:3px;font-size:.9em;background-color:#edf1f8;", "X .plotly-cloud-dialog .plotly-cloud-dialog-buttons": "display:flex;justify-content:flex-end;margin-top:20px;", "X .plotly-cloud-dialog .plotly-cloud-dialog-btn": "font-family:inherit;font-size:13px;padding:7px 16px;margin-left:8px;border-radius:3px;border:1px solid rgba(0,0,0,0);cursor:pointer;", "X .plotly-cloud-dialog .plotly-cloud-dialog-btn:focus-visible": "outline:2px solid #447adb;outline-offset:1px;", diff --git a/draftlogs/7928_change.md b/draftlogs/7928_change.md new file mode 100644 index 00000000000..bce9c2b3d5f --- /dev/null +++ b/draftlogs/7928_change.md @@ -0,0 +1 @@ +- Update "Share Chart" dialog with more informative wording [[#7928](https://github.com/plotly/plotly.js/pull/7928)] \ No newline at end of file diff --git a/src/components/modebar/buttons.js b/src/components/modebar/buttons.js index 7cd3a41e66c..fa69a854d6c 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -72,25 +72,29 @@ modeBarButtons.toImage = { modeBarButtons.sendChartToCloud = { name: 'sendChartToCloud', title: function (gd) { - return _(gd, 'Share Chart'); + return _(gd, 'Share chart...'); }, icon: Icons.cloudupload, click: function (gd) { var baseUrl = (window.PLOTLYENV || {}).BASE_URL || gd._context.plotlyServerURL; if (!baseUrl) { - console.error('No destination URL provided (plotlyServerURL is not set)'); + console.error('No destination URL provided (plotlyServerURL is empty)'); return; } - // Plotly Cloud origin, used to validate incoming messages and to target outgoing ones. - // `baseUrl` (plotlyServerURL) is the upload page that handles login and signals - // back when authentication succeeds. + // Validate that the provided plotlyServerURL is a valid URL + // with an http or https protocol + var baseUrlObj; try { - new URL(baseUrl); + baseUrlObj = new URL(baseUrl); } catch (e) { console.error('Invalid plotlyServerURL: ' + baseUrl); return; } + if (baseUrlObj.protocol !== 'https:' && baseUrlObj.protocol !== 'http:') { + console.error('Invalid protocol for plotlyServerURL: ' + baseUrl); + return; + } confirmCloudDialog(gd, baseUrl, function () { Plots.sendDataToCloud(gd, baseUrl); diff --git a/src/components/modebar/cloud_confirm.js b/src/components/modebar/cloud_confirm.js index d1c715acca3..30deec7bfb1 100644 --- a/src/components/modebar/cloud_confirm.js +++ b/src/components/modebar/cloud_confirm.js @@ -1,8 +1,99 @@ 'use strict'; -var d3 = require('@plotly/d3'); +const d3 = require('@plotly/d3'); -var _ = require('../../lib')._; +const _ = require('../../lib')._; +const dfltConfig = require('../../plot_api/plot_config').dfltConfig; + +const buildDialogBox = (gd, overlay, serverUrl, onClickConfirm, onClickCancel) => { + // Wording for dialog box. Must be defined inside this function rather than + // at the top of the file because localization requires a reference to the + // graph div (gd) + const DIALOG_TITLE = _(gd, 'Share chart'); + + // Messages to be shown when serverUrl matches the default (Plotly Cloud) URL + const DIALOG_MESSAGE_CLOUD = _(gd, 'This chart will be uploaded to {Plotly Cloud} to create a sharing link. Only you can see it until you change its visibility.'); + const DIALOG_MESSAGE_CLOUD_ACCOUNT = _(gd, "If you don't have a Plotly Cloud account yet, you'll have a chance to create one."); + + // Message to be shown when serverUrl is not the default URL + const DIALOG_MESSAGE_OTHER = _(gd, 'This chart will be sent to {serverUrl}.'); + + // Labels for buttons + const DIALOG_CANCEL = _(gd, 'Cancel'); + const DIALOG_CONFIRM = _(gd, 'Share'); + + const dialog = overlay.append('div') + .classed('plotly-cloud-dialog-box', true); + + dialog.append('div') + .classed('plotly-cloud-dialog-title', true) + .text(DIALOG_TITLE); + + if (serverUrl === dfltConfig.plotlyServerURL) { + // If serverUrl matches the default Plotly Cloud URL, + // show a custom message designed for Plotly Cloud + const description = dialog.append('div') + .classed('plotly-cloud-dialog-message', true); + + // Link to the base domain only, leaving the endpoint path + const serverUrlHref = new URL(serverUrl).origin; + + // Split description into three parts: Before {, between, and after } + const descriptionParts = DIALOG_MESSAGE_CLOUD.split(/(\{|\})/); + const beforePart = descriptionParts[0]; + const betweenPart = descriptionParts[2]; + const afterPart = descriptionParts[4]; + + // Append the parts to the description div + description.append('span').text(beforePart); + description.append('a') + .classed('plotly-cloud-dialog-message--hostname', true) + .attr('href', serverUrlHref) + .attr('target', '_blank') + .text(betweenPart); + description.append('span').text(afterPart); + + description.append('div') + .classed('plotly-cloud-dialog-message--account', true) + .text(DIALOG_MESSAGE_CLOUD_ACCOUNT); + } else { + // Otherwise, show a generic message with the server URL + // We can trust that serverUrl is a valid URL because it was validated in buttons.js + const serverUrlObj = new URL(serverUrl); + const serverUrlHostname = serverUrlObj.hostname; + // Link to the base domain only, leaving off any endpoint path + const serverUrlHref = serverUrlObj.origin; + const descriptionParts = DIALOG_MESSAGE_OTHER.split(/(\{|\})/); + const beforePart = descriptionParts[0]; + const afterPart = descriptionParts[4]; + + const description = dialog.append('div') + .classed('plotly-cloud-dialog-message', true); + + description.append('span').text(beforePart); + description.append('a') + .classed('plotly-cloud-dialog-message--hostname', true) + .attr('href', serverUrlHref) + .attr('target', '_blank') + .text(serverUrlHostname); + description.append('span').text(afterPart); + } + + const buttons = dialog.append('div') + .classed('plotly-cloud-dialog-buttons', true); + + buttons.append('button') + .classed('plotly-cloud-dialog-btn', true) + .classed('plotly-cloud-dialog-btn--cancel', true) + .text(DIALOG_CANCEL) + .on('click', onClickCancel); + + buttons.append('button') + .classed('plotly-cloud-dialog-btn', true) + .classed('plotly-cloud-dialog-btn--confirm', true) + .text(DIALOG_CONFIRM) + .on('click', onClickConfirm); +}; /** * Show a styled confirmation dialog before sharing a chart with Plotly Cloud. @@ -15,61 +106,42 @@ var _ = require('../../lib')._; * @param {string} serverUrl - destination shown in the dialog message * @param {function} onConfirm - called when the user confirms the upload */ -module.exports = function confirmCloudDialog(gd, serverUrl, onConfirm) { - var container = d3.select(gd._fullLayout._paperdiv.node()); +const confirmCloudDialog = (gd, serverUrl, onConfirm) => { + const container = d3.select(gd._fullLayout._paperdiv.node()); // Never stack dialogs - drop any that is already open. container.selectAll('.plotly-cloud-dialog').remove(); - var overlay = container + const overlay = container .append('div') .classed('plotly-cloud-dialog', true); - var dialog = overlay.append('div') - .classed('plotly-cloud-dialog-box', true); - - dialog.append('div') - .classed('plotly-cloud-dialog-title', true) - .text(_(gd, 'Share with Plotly Cloud')); - - var serverUrlText = new URL(serverUrl).hostname; - - var description = dialog.append('div'); - description.classed('plotly-cloud-dialog-message', true); - description.append('span').text(_(gd, 'This chart and its data will be sent to ')); - description.append('span').text(serverUrlText).classed('plotly-cloud-dialog-message--hostname', true); - description.append('span').text('. '); - - var buttons = dialog.append('div') - .classed('plotly-cloud-dialog-buttons', true); - - function close() { + const close = () => { overlay.remove(); document.removeEventListener('keydown', onKeydown); - } + }; - function onKeydown(e) { + const onKeydown = (e) => { if(e.key === 'Escape' || e.keyCode === 27) close(); - } + }; document.addEventListener('keydown', onKeydown); // Clicking the backdrop (but not the dialog box) cancels. - overlay.on('click', function() { + overlay.on('click', () => { if(d3.event.target === overlay.node()) close(); }); - buttons.append('button') - .classed('plotly-cloud-dialog-btn', true) - .classed('plotly-cloud-dialog-btn--cancel', true) - .text(_(gd, 'Cancel')) - .on('click', close); - - buttons.append('button') - .classed('plotly-cloud-dialog-btn', true) - .classed('plotly-cloud-dialog-btn--confirm', true) - .text(_(gd, 'Share')) - .on('click', function() { + // Build the dialog box and append it to the overlay + buildDialogBox( + gd, + overlay, + serverUrl, + () => { close(); onConfirm(); - }); + }, + close + ); }; + +module.exports = confirmCloudDialog; diff --git a/src/css/_cloud_dialog.scss b/src/css/_cloud_dialog.scss index fdf66fcee0f..37b8a36a947 100644 --- a/src/css/_cloud_dialog.scss +++ b/src/css/_cloud_dialog.scss @@ -44,6 +44,15 @@ &--hostname { font-weight: bold; + text-decoration: underline; + } + + &--account { + margin-top: 16px; + padding: 8px; + border-radius: 3px; + font-size: 0.9em; + background-color: vars.$color-bg-hint; } } diff --git a/test/jasmine/tests/config_test.js b/test/jasmine/tests/config_test.js index 2a04690027a..f46b041122b 100644 --- a/test/jasmine/tests/config_test.js +++ b/test/jasmine/tests/config_test.js @@ -2,6 +2,7 @@ var Plotly = require('../../../lib/index'); var Plots = require('../../../src/plots/plots'); var Lib = require('../../../src/lib'); var modeBarButtons = require('../../../src/components/modebar/buttons'); +var dfltConfig = require('../../../src/plot_api/plot_config').dfltConfig; var d3Select = require('../../strict-d3').select; var createGraphDiv = require('../assets/create_graph_div'); @@ -509,12 +510,43 @@ describe('config argument', function() { modeBarButtons.sendChartToCloud.click(gd); var msg = document.querySelector('.plotly-cloud-dialog-message'); expect(msg).not.toBe(null, 'confirmation dialog should be shown'); - expect(msg.textContent).toContain('example.plotly.com'); + expect(msg.textContent).toBe('This chart will be sent to example.plotly.com.'); + + // The host name is shown as a link to the server's origin, + // leaving off the endpoint path + var link = msg.querySelector('.plotly-cloud-dialog-message--hostname'); + expect(link).not.toBe(null, 'host name should be shown as a link'); + expect(link.textContent).toBe('example.plotly.com'); + expect(link.getAttribute('href')).toBe('https://example.plotly.com'); }) .then(done, done.fail); }); - it('should NOT open confirmation dialog when set to an invalid URL', function(done) { + it('should show Plotly Cloud wording when left at the default URL', function(done) { + Plotly.newPlot(gd, [], {}, {}) + .then(function() { + expect(gd._context.plotlyServerURL).toBe(dfltConfig.plotlyServerURL); + modeBarButtons.sendChartToCloud.click(gd); + + var msg = document.querySelector('.plotly-cloud-dialog-message'); + expect(msg).not.toBe(null, 'confirmation dialog should be shown'); + expect(msg.textContent).toContain('This chart will be uploaded to Plotly Cloud to create a sharing link.'); + + var link = msg.querySelector('.plotly-cloud-dialog-message--hostname'); + expect(link).not.toBe(null, 'Plotly Cloud should be shown as a link'); + expect(link.textContent).toBe('Plotly Cloud'); + expect(link.getAttribute('href')).toBe(new URL(dfltConfig.plotlyServerURL).origin); + + var account = msg.querySelector('.plotly-cloud-dialog-message--account'); + expect(account).not.toBe(null, 'account note should be shown'); + expect(account.textContent).toContain('Plotly Cloud account'); + }) + .then(done, done.fail); + }); + + it('should NOT open confirmation dialog when set to an unparseable URL', function(done) { + var errorSpy = spyOn(console, 'error'); + Plotly.newPlot(gd, [], {}, { plotlyServerURL: 'dummy' }) @@ -523,6 +555,21 @@ describe('config argument', function() { modeBarButtons.sendChartToCloud.click(gd); var msg = document.querySelector('.plotly-cloud-dialog-message'); expect(msg).toBe(null, 'confirmation dialog should not be shown'); + expect(errorSpy).toHaveBeenCalledWith('Invalid plotlyServerURL: dummy'); + }) + .then(done, done.fail); + }); + + it('should NOT open confirmation dialog when set to a non-http(s) URL', function(done) { + var errorSpy = spyOn(console, 'error'); + + Plotly.newPlot(gd, [], {}, { + plotlyServerURL: 'ftp://example.plotly.com' + }) + .then(function() { + modeBarButtons.sendChartToCloud.click(gd); + expect(document.querySelector('.plotly-cloud-dialog')).toBe(null, 'confirmation dialog should not be shown'); + expect(errorSpy).toHaveBeenCalledWith('Invalid protocol for plotlyServerURL: ftp://example.plotly.com'); }) .then(done, done.fail); }); @@ -543,10 +590,46 @@ describe('config argument', function() { // Should open the provided URL's origin in a new tab, // adding the current page's origin as a query parameter expect(openSpy).toHaveBeenCalledWith('https://example.plotly.com/endpoint?origin=http%3A%2F%2Flocalhost%3A9876', '_blank'); + + // Confirming should also dismiss the dialog + expect(document.querySelector('.plotly-cloud-dialog')).toBe(null, 'dialog should be closed'); }) .then(done, done.fail); }); + [{ + name: 'clicking cancel button', + dismiss: function() { + mouseEvent('click', 0, 0, {element: document.querySelector('.plotly-cloud-dialog-btn--cancel')}); + } + }, { + name: 'clicking the backdrop', + dismiss: function() { + mouseEvent('click', 0, 0, {element: document.querySelector('.plotly-cloud-dialog')}); + } + }, { + name: 'pressing Escape', + dismiss: function() { + document.dispatchEvent(new window.KeyboardEvent('keydown', {key: 'Escape'})); + } + }].forEach(function(spec) { + it('should close dialog without uploading when ' + spec.name, function(done) { + Plotly.newPlot(gd, [], {}, { + plotlyServerURL: 'https://example.plotly.com/endpoint' + }) + .then(function() { + modeBarButtons.sendChartToCloud.click(gd); + expect(document.querySelector('.plotly-cloud-dialog')).not.toBe(null, 'dialog should be shown'); + + spec.dismiss(); + + expect(document.querySelector('.plotly-cloud-dialog')).toBe(null, 'dialog should be closed'); + expect(openSpy).not.toHaveBeenCalled(); + }) + .then(done, done.fail); + }); + }); + it('has lesser priority than window env', function(done) { window.PLOTLYENV = {BASE_URL: 'https://yo.plotly.com/endpoint'};