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..c06e42fe350 --- /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..358ea14ee03 100644 --- a/src/components/modebar/buttons.js +++ b/src/components/modebar/buttons.js @@ -5,7 +5,7 @@ var Plots = require('../../plots/plots'); var axisIds = require('../../plots/cartesian/axis_ids'); var Icons = require('../../fonts/ploticon'); var eraseActiveShape = require('../shapes/draw').eraseActiveShape; -var confirmCloudDialog = require('./cloud_confirm'); +var confirmCloudDialog = require('./share_chart/dialog'); var Lib = require('../../lib'); var _ = Lib._; @@ -72,25 +72,30 @@ 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; } + const supportedProtocols = ['http:', 'https:']; + if (!supportedProtocols.includes(baseUrlObj.protocol)) { + console.error(`Invalid protocol '${baseUrlObj.protocol}' in plotlyServerURL '${baseUrl}'. Must be one of: ${supportedProtocols.join(', ')}`); + 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 deleted file mode 100644 index d1c715acca3..00000000000 --- a/src/components/modebar/cloud_confirm.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict'; - -var d3 = require('@plotly/d3'); - -var _ = require('../../lib')._; - -/** - * Show a styled confirmation dialog before sharing a chart with Plotly Cloud. - * - * The dialog is appended to the plot's positioning container (.svg-container) - * so it is centered over the plot rather than the whole viewport. It can be - * dismissed by clicking Cancel, clicking the backdrop, or pressing Escape. - * - * @param {DOM node} gd - the graph div, used to scope the dialog to the plot - * @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()); - - // Never stack dialogs - drop any that is already open. - container.selectAll('.plotly-cloud-dialog').remove(); - - var 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() { - overlay.remove(); - document.removeEventListener('keydown', onKeydown); - } - - function 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() { - 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() { - close(); - onConfirm(); - }); -}; diff --git a/src/components/modebar/share_chart/dialog.js b/src/components/modebar/share_chart/dialog.js new file mode 100644 index 00000000000..1792687bedb --- /dev/null +++ b/src/components/modebar/share_chart/dialog.js @@ -0,0 +1,148 @@ +'use strict'; + +const d3 = require('@plotly/d3'); + +const { dfltConfig } = require('../../../plot_api/plot_config'); +const getDialogStrings = require('./strings'); + +/** + * Build the chart-sharing confirmation dialog box and add it to DOM + * inside the overlay element. + * + * The message wording depends on the destination: when serverUrl is the + * default Plotly Cloud URL we show wording specific to Plotly Cloud, + * otherwise we show a generic message naming the server's hostname. + * + * @param {DOM node} gd - the graph div (used for localizing strings) + * @param {d3 selection} overlay - the dialog backdrop element to append the box to + * @param {string} serverUrl - destination URL (must be a valid URL) + * @param {function} onClickConfirm - called when the confirm button is clicked + * @param {function} onClickCancel - called when the cancel button is clicked + */ +const buildDialogBox = (gd, overlay, serverUrl, onClickConfirm, onClickCancel) => { + + const strings = getDialogStrings(gd); + + const dialog = overlay.append('div') + .classed('plotly-cloud-dialog-box', true); + + dialog.append('div') + .classed('plotly-cloud-dialog-title', true) + .text(strings.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 = strings.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(strings.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 = strings.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(strings.DIALOG_CANCEL) + .on('click', onClickCancel); + + buttons.append('button') + .classed('plotly-cloud-dialog-btn', true) + .classed('plotly-cloud-dialog-btn--confirm', true) + .text(strings.DIALOG_CONFIRM) + .on('click', onClickConfirm); +}; + +/** + * Show a styled confirmation dialog before sharing a chart with Plotly Cloud. + * + * The dialog is appended to the plot's positioning container (.svg-container) + * so it is centered over the plot rather than the whole viewport. It can be + * dismissed by clicking Cancel, clicking the backdrop, or pressing Escape. + * + * @param {DOM node} gd - the graph div, used to scope the dialog to the plot + * @param {string} serverUrl - destination shown in the dialog message + * @param {function} onConfirm - called when the user confirms the upload + */ +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(); + + const overlay = container + .append('div') + .classed('plotly-cloud-dialog', true); + + const close = () => { + overlay.remove(); + document.removeEventListener('keydown', onKeydown); + }; + + 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', () => { + if(d3.event.target === overlay.node()) close(); + }); + + // Build the dialog box and append it to the overlay + buildDialogBox( + gd, + overlay, + serverUrl, + () => { + close(); + onConfirm(); + }, + close + ); +}; + +module.exports = confirmCloudDialog; diff --git a/src/components/modebar/share_chart/strings.js b/src/components/modebar/share_chart/strings.js new file mode 100644 index 00000000000..6eb0084969c --- /dev/null +++ b/src/components/modebar/share_chart/strings.js @@ -0,0 +1,32 @@ +const _ = require('../../../lib')._; + +/** + * Get the localized wording for the share chart dialog box. + * + * The strings must be built inside this function rather than defined + * as constants because localization requires a reference to the graph div. + * + * Braces in the message strings mark the span of text that dialog.js turns + * into a link to the destination server, so translations must keep them. + * + * @param {DOM node} gd - the graph div, used for localization + * @returns {object} object containing localized strings + */ +const getDialogStrings = function(gd) { + return { + DIALOG_TITLE: _(gd, 'Share chart'), + + // Messages to be shown when serverUrl matches the default (Plotly Cloud) URL + 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.'), + 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 + DIALOG_MESSAGE_OTHER: _(gd, 'This chart will be sent to {serverUrl}.'), + + // Labels for buttons + DIALOG_CANCEL: _(gd, 'Cancel'), + DIALOG_CONFIRM: _(gd, 'Share'), + } +} + +module.exports = getDialogStrings; \ No newline at end of file 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..6055da55216 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 'ftp:' in plotlyServerURL 'ftp://example.plotly.com'. Must be one of: http:, https:"); }) .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'};