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==