diff --git a/app/components/download-graph.hbs b/app/components/download-graph.hbs index ab9f2db3905..03298ee6716 100644 --- a/app/components/download-graph.hbs +++ b/app/components/download-graph.hbs @@ -1,12 +1,30 @@ -{{#if this.loadTask.last.error}} - - There was an error loading the Google Charts code. - Please try again later. - -{{else}} -
-{{/if}} \ No newline at end of file +
+ {{#if this.chartjs.loadTask.isRunning}} + + {{else if this.chartjs.loadTask.lastSuccessful.value}} + + {{else}} +
+

Sorry, there was a problem loading the graphing code.

+ +
+ {{/if}} +
\ No newline at end of file diff --git a/app/components/download-graph.js b/app/components/download-graph.js index ba37f45a053..e0f83760110 100644 --- a/app/components/download-graph.js +++ b/app/components/download-graph.js @@ -1,210 +1,91 @@ import { action } from '@ember/object'; import { inject as service } from '@ember/service'; +import { waitForPromise } from '@ember/test-waiters'; import Component from '@glimmer/component'; -import { task } from 'ember-concurrency'; - -import { ExternalScriptError } from '../services/google-charts'; -import { ignoreCancellation } from '../utils/concurrency'; +import window from 'ember-window-mock'; // Colors by http://colorbrewer2.org/#type=diverging&scheme=RdBu&n=10 const COLORS = ['#67001f', '#b2182b', '#d6604d', '#f4a582', '#92c5de', '#4393c3', '#2166ac', '#053061']; +const BG_COLORS = ['#d3b5bc', '#eabdc0', '#f3d0ca', '#fce4d9', '#deedf5', '#c9deed', '#2166ac', '#053061']; export default class DownloadGraph extends Component { - @service googleCharts; - - resizeHandler = () => this.renderChart(); - - constructor() { - super(...arguments); - - this.loadTask - .perform() - .catch(ignoreCancellation) - .catch(error => { - // ignore `ExternalScriptError` errors since we handle those in the template - if (!(error instanceof ExternalScriptError)) { - throw error; - } - }); + @service chartjs; - window.addEventListener('resize', this.resizeHandler, false); + @action loadChartJs() { + waitForPromise(this.chartjs.loadTask.perform()).catch(() => { + // Ignore Promise rejections. We'll handle them through the derived state properties. + }); } - willDestroy() { - super.willDestroy(...arguments); - window.removeEventListener('resize', this.resizeHandler); + @action createChart(element) { + let Chart = this.chartjs.loadTask.lastSuccessful.value; + + this.chart = new Chart(element, { + type: 'line', + data: this.data, + options: { + layout: { + padding: 10, + }, + scales: { + xAxes: [{ type: 'time', time: { stepSize: 7, tooltipFormat: 'MMM D', unit: 'day' } }], + yAxes: [{ stacked: true, ticks: { min: 0, precision: 0 } }], + }, + tooltips: { + mode: 'index', + intersect: false, + position: 'nearest', + }, + }, + }); } - @task(function* () { - if (!this.googleCharts.loaded) { - yield this.googleCharts.load(); - this.renderChart(); - } - }) - loadTask; + @action updateChart() { + let { chart, animate } = this.chart; - @action - renderChart(element) { - if (element) { - this.chartElement = element; - } else if (this.chartElement) { - element = this.chartElement; - } else { - return; - } + if (chart) { + chart.data = this.data; - let data = this.args.data; - - let subarray_length = (data[1] || []).length; - - // Start at 1 to skip the date element in the 0th - // location in the array. - for (let k = 1; k < subarray_length; k++) { - let on = false; - - // Start at 1 because the 0th entry in the array - // is an array of version numbers. - // - // End before the last element is reached because we never - // want to change the last element. - for (let i = 1; i < data.length - 1; i++) { - // k + 1 because the first entry in the array is the date - let value = data[i][k]; - - // If we are "off" and are looking at a zero - // replace the data at this point with `null`. - // - // Null tells google.visualization to stop drawing - // the line altogether. - if (!on && value === 0) { - data[i][k] = null; - } - - // If we are off and the value is not zero, we - // need to turn back on. (keep the value the same though) - else if (!on && value !== 0) { - on = true; - - // We previously wrote a null into data[i - 1][k + 1], - // so to make the graph look pretty, we'll switch it back - // to the zero that it was before. - if (i >= 2) { - data[i - 1][k] = 0; - } - } - // If we are on and the value is zero, turn off - // but keep the zero in the array - else if (on && value === 0) { - on = false; - } + if (animate) { + chart.update(); + } else { + chart.update(0); } } + } - let { loaded, visualization } = this.googleCharts; - - let show = data && loaded; - element.style.display = show ? '' : 'none'; - if (!show) { - return; - } - - let myData = visualization.arrayToDataTable(data); - - let dateFmt = new visualization.DateFormat({ - pattern: 'LLL d, yyyy', - }); - dateFmt.format(myData, 0); - - // Create a formatter to use for daily download numbers - let numberFormatWhole = new visualization.NumberFormat({ - pattern: '#,##0', - }); - - // Create a formatter to use for 7-day average numbers - let numberFormatDecimal = new visualization.NumberFormat({ - pattern: '#,##0.0', - }); + @action destroyChart() { + this.chart.destroy(); + } - // use a DataView to calculate an x-day moving average - let days = 7; - let view = new visualization.DataView(myData); - let moving_avg_func_for_col = function (col) { - return function (dt, row) { - // For the last rows (the *first* days, remember, the dataset is - // backwards), we cannot calculate the avg. of previous days. - if (row >= dt.getNumberOfRows() - days) { - return null; - } + @action reloadPage() { + window.location.reload(); + } - let total = 0; - for (let i = days; i > 0; i--) { - total += dt.getValue(row + i, col); - } - let avg = total / days; + get data() { + let [labels, ...rows] = this.args.data; + + let datasets = labels + .slice(1) + .map((label, index) => ({ + data: rows.map(row => ({ x: row[0], y: row[index + 1] })), + label: label, + })) + .reverse() + .map(({ label, data }, index) => { return { - v: avg, - f: numberFormatDecimal.formatValue(avg), + backgroundColor: BG_COLORS[index], + borderColor: COLORS[index], + borderWidth: 2, + cubicInterpolationMode: 'monotone', + data: data, + label: label, + pointHoverBorderWidth: 2, + pointHoverRadius: 5, }; - }; - }; - - let columns = [0]; // 0 = datetime - let seriesOption = {}; - let [headers] = data; - // Walk over the headers/colums in reverse order, as the newest version - // is at the end, but in the UI we want it at the top of the chart legend. - - range(headers.length - 1, 0, -1).forEach((dataCol, i) => { - // Set the number format for the colum in the data table. - numberFormatWhole.format(myData, dataCol); - columns.push(dataCol); // add the column itself - columns.push({ - // add a 'calculated' column, the moving average - type: 'number', - label: `${headers[dataCol]} ${days}-day avg.`, - calc: moving_avg_func_for_col(dataCol), }); - // Note: while the columns start with index 1 (because 0 is the time - // axis), the series configuration starts with index 0. - seriesOption[i * 2] = { - type: 'scatter', - color: COLORS[i % COLORS.length], - pointSize: 3, - pointShape: 'square', - }; - seriesOption[i * 2 + 1] = { - type: 'area', - color: COLORS[i % COLORS.length], - lineWidth: 2, - curveType: 'function', - visibleInLegend: false, - }; - }); - view.setColumns(columns); - - let chart = new visualization.ComboChart(element); - chart.draw(view, { - chartArea: { left: 85, width: '77%', height: '80%' }, - hAxis: { - minorGridlines: { count: 8 }, - }, - vAxis: { - minorGridlines: { count: 5 }, - viewWindow: { min: 0 }, - }, - isStacked: true, - focusTarget: 'category', - seriesType: 'scatter', - series: seriesOption, - }); - } -} -function range(start, end, step) { - let array = []; - for (let i = start; i !== end; i += step) { - array.push(i); + return { datasets }; } - return array; } diff --git a/app/components/download-graph.module.css b/app/components/download-graph.module.css new file mode 100644 index 00000000000..affe29b2288 --- /dev/null +++ b/app/components/download-graph.module.css @@ -0,0 +1,19 @@ +.wrapper { + display: grid; + place-items: center; + border: solid 1px var(--gray-border); + border-radius: 5px; + min-height: 400px; + + &.auto-height { + min-height: auto; + } +} + +.spinner { + transform: scale(3.0); +} + +.error { + text-align: center; +} diff --git a/app/routes/application.js b/app/routes/application.js index 50d5b5bc498..dfe9f279266 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -8,7 +8,6 @@ import { shouldPolyfill as shouldPolyfillNumberFormat } from '@formatjs/intl-num import { shouldPolyfill as shouldPolyfillPluralRules } from '@formatjs/intl-pluralrules/should-polyfill'; export default class ApplicationRoute extends Route { - @service googleCharts; @service notifications; @service progress; @service session; @@ -23,12 +22,6 @@ export default class ApplicationRoute extends Route { // eslint-disable-next-line ember-concurrency/no-perform-without-catch this.session.loadUserTask.perform(); - // start loading the Google Charts JS library already - // and ignore any errors since we will catch them again - // anyway when we call `load()` from the `DownloadGraph` - // component - this.googleCharts.load().catch(() => {}); - // load `Intl` polyfills if necessary let polyfillImports = []; if (shouldPolyfillGetCanonicalLocales()) { diff --git a/app/services/chartjs.js b/app/services/chartjs.js new file mode 100644 index 00000000000..12129e5ccec --- /dev/null +++ b/app/services/chartjs.js @@ -0,0 +1,12 @@ +import Service from '@ember/service'; + +import { task } from 'ember-concurrency'; + +export default class ChartJsLoader extends Service { + @(task(function* () { + let Chart = yield import('chart.js').then(module => module.default); + Chart.platform.disableCSSInjection = true; + return Chart; + }).drop()) + loadTask; +} diff --git a/app/services/google-charts.js b/app/services/google-charts.js deleted file mode 100644 index 4d69a13fe6e..00000000000 --- a/app/services/google-charts.js +++ /dev/null @@ -1,59 +0,0 @@ -import { alias, bool } from '@ember/object/computed'; -import Service from '@ember/service'; - -import { task } from 'ember-concurrency'; - -import { ignoreCancellation } from '../utils/concurrency'; - -export default class GoogleChartsService extends Service { - @alias('loadTask.lastSuccessful.value') visualization; - @bool('visualization') loaded; - - async load() { - await this.loadTask.perform().catch(ignoreCancellation); - } - - @(task(function* () { - let api = yield loadJsApi(); - yield loadCoreChart(api); - return api.visualization; - }).keepLatest()) - loadTask; -} - -async function loadScript(src) { - await new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = src; - script.addEventListener('load', resolve); - script.addEventListener('error', event => { - reject(new ExternalScriptError(event.target.src)); - }); - document.body.append(script); - }); -} - -export class ExternalScriptError extends Error { - constructor(url) { - let message = `Failed to load script at ${url}`; - super(message); - this.name = 'ExternalScriptError'; - this.url = url; - } -} - -async function loadJsApi() { - if (!window.google) { - await loadScript('https://www.gstatic.com/charts/loader.js'); - } - return window.google; -} - -async function loadCoreChart(api) { - await new Promise(resolve => { - api.load('visualization', '1.0', { - packages: ['corechart'], - callback: resolve, - }); - }); -} diff --git a/app/styles/crate/version.module.css b/app/styles/crate/version.module.css index 5f644ec48b3..f2dc90a894d 100644 --- a/app/styles/crate/version.module.css +++ b/app/styles/crate/version.module.css @@ -277,11 +277,6 @@ div.header { } } -.graph-data { - width: 100%; - height: 500px; -} - .yanked { composes: yanked from '../shared/typography.module.css'; } diff --git a/config/nginx.conf.erb b/config/nginx.conf.erb index b55bfe40d6d..8e96bb040c8 100644 --- a/config/nginx.conf.erb +++ b/config/nginx.conf.erb @@ -211,7 +211,7 @@ http { add_header X-Frame-Options "SAMEORIGIN"; add_header X-XSS-Protection "1; mode=block"; - add_header Content-Security-Policy "default-src 'self'; connect-src 'self' *.ingest.sentry.io https://docs.rs https://<%= s3_host(ENV) %>; script-src 'self' 'unsafe-eval' https://www.gstatic.com 'sha256-n1+BB7Ckjcal1Pr7QNBh/dKRTtBQsIytFodRiIosXdE='; style-src 'self' 'unsafe-inline' https://www.gstatic.com https://code.cdn.mozilla.net; font-src https://code.cdn.mozilla.net; img-src *; object-src 'none'"; + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' *.ingest.sentry.io https://docs.rs https://<%= s3_host(ENV) %>; script-src 'self' 'unsafe-eval' 'sha256-n1+BB7Ckjcal1Pr7QNBh/dKRTtBQsIytFodRiIosXdE='; style-src 'self' 'unsafe-inline' https://code.cdn.mozilla.net; font-src https://code.cdn.mozilla.net; img-src *; object-src 'none'"; add_header Access-Control-Allow-Origin "*"; add_header Strict-Transport-Security "max-age=31536000" always; diff --git a/ember-cli-build.js b/ember-cli-build.js index 253affedf07..ff3b93a91ca 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -25,6 +25,13 @@ module.exports = function (defaults) { ]; let app = new EmberApp(defaults, { + autoImport: { + webpack: { + externals: { + moment: 'moment', + }, + }, + }, babel: { plugins: [require.resolve('ember-auto-import/babel-plugin')], }, @@ -52,6 +59,7 @@ module.exports = function (defaults) { }); app.import('node_modules/normalize.css/normalize.css', { prepend: true }); + app.import('node_modules/chart.js/dist/Chart.min.css'); app.import('vendor/qunit.css', { type: 'test' }); diff --git a/package.json b/package.json index fb07c2bd9b6..bd10bf821fd 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@formatjs/intl-pluralrules": "4.0.1", "@sentry/browser": "5.29.2", "@sentry/integrations": "5.29.2", + "chart.js": "^2.9.4", "copy-text-to-clipboard": "2.2.0", "fastboot-app-server": "3.0.0", "morgan": "1.10.0", diff --git a/tests/components/download-graph-test.js b/tests/components/download-graph-test.js new file mode 100644 index 00000000000..d089c5e2bc2 --- /dev/null +++ b/tests/components/download-graph-test.js @@ -0,0 +1,88 @@ +import { click, render, settled, waitFor } from '@ember/test-helpers'; +import { setupRenderingTest } from 'ember-qunit'; +import { module, test } from 'qunit'; + +import Service from '@ember/service'; +import { defer } from 'rsvp'; + +import { hbs } from 'ember-cli-htmlbars'; +import { task } from 'ember-concurrency'; +import window from 'ember-window-mock'; +import { setupWindowMock } from 'ember-window-mock/test-support'; + +module('Component | DownloadGraph', function (hooks) { + setupRenderingTest(hooks); + setupWindowMock(hooks); + + test('happy path', async function (assert) { + this.data = exampleData(); + + await render(hbs``); + assert.dom('[data-test-download-graph]').exists(); + assert.dom('[data-test-download-graph] [data-test-spinner]').doesNotExist(); + assert.dom('[data-test-download-graph] canvas').exists(); + assert.dom('[data-test-download-graph] [data-test-error]').doesNotExist(); + }); + + test('loading spinner', async function (assert) { + this.data = exampleData(); + + let deferred = defer(); + + class MockService extends Service { + @(task(function* () { + yield deferred.promise; + return yield import('chart.js').then(module => module.default); + }).drop()) + loadTask; + } + + this.owner.register('service:chartjs', MockService); + + render(hbs``); + await waitFor('[data-test-download-graph] [data-test-spinner]'); + assert.dom('[data-test-download-graph]').exists(); + assert.dom('[data-test-download-graph] [data-test-spinner]').exists(); + assert.dom('[data-test-download-graph] canvas').doesNotExist(); + assert.dom('[data-test-download-graph] [data-test-error]').doesNotExist(); + + deferred.resolve(); + await settled(); + assert.dom('[data-test-download-graph]').exists(); + assert.dom('[data-test-download-graph] [data-test-spinner]').doesNotExist(); + assert.dom('[data-test-download-graph] canvas').exists(); + assert.dom('[data-test-download-graph] [data-test-error]').doesNotExist(); + }); + + test('error behavior', async function (assert) { + class MockService extends Service { + // eslint-disable-next-line require-yield + @(task(function* () { + throw new Error('nope'); + }).drop()) + loadTask; + } + + this.owner.register('service:chartjs', MockService); + + await render(hbs``); + assert.dom('[data-test-download-graph]').exists(); + assert.dom('[data-test-download-graph] [data-test-spinner]').doesNotExist(); + assert.dom('[data-test-download-graph] canvas').doesNotExist(); + assert.dom('[data-test-download-graph] [data-test-error]').exists(); + + window.location.reload = () => assert.step('reload'); + await click('[data-test-download-graph] [data-test-reload]'); + assert.verifySteps(['reload']); + }); +}); + +function exampleData() { + return [ + ['Date', 'Other', '1.0.52', '1.0.53', '1.0.54', '1.0.55', '1.0.56'], + [new Date('2020-12-30'), 36745, 201, 2228, 4298, 3702, 30520], + [new Date('2020-12-29'), 33242, 261, 1650, 4277, 4157, 31631], + [new Date('2020-12-28'), 19981, 181, 968, 2786, 2414, 23616], + [new Date('2020-12-27'), 19064, 186, 873, 2477, 15713, 3815], + ]; +} diff --git a/yarn.lock b/yarn.lock index e1b3bd02bf0..9f8c1974f57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4793,6 +4793,29 @@ charm@^1.0.0: dependencies: inherits "^2.0.1" +chart.js@^2.9.4: + version "2.9.4" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.9.4.tgz#0827f9563faffb2dc5c06562f8eb10337d5b9684" + integrity sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A== + dependencies: + chartjs-color "^2.1.0" + moment "^2.10.2" + +chartjs-color-string@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz#1df096621c0e70720a64f4135ea171d051402f71" + integrity sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A== + dependencies: + color-name "^1.0.0" + +chartjs-color@^2.1.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/chartjs-color/-/chartjs-color-2.4.1.tgz#6118bba202fe1ea79dd7f7c0f9da93467296c3b0" + integrity sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w== + dependencies: + chartjs-color-string "^0.6.0" + color-convert "^1.9.3" + cheerio@^0.22.0: version "0.22.0" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e" @@ -5067,7 +5090,7 @@ collection-visit@^1.0.0: map-visit "^1.0.0" object-visit "^1.0.0" -color-convert@^1.9.0, color-convert@^1.9.1: +color-convert@^1.9.0, color-convert@^1.9.1, color-convert@^1.9.3: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== @@ -10810,7 +10833,7 @@ moment-timezone@^0.5.13: dependencies: moment ">= 2.9.0" -"moment@>= 2.9.0", moment@^2.19.3: +"moment@>= 2.9.0", moment@^2.10.2, moment@^2.19.3: version "2.29.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==