Custom code
-
diff --git a/client/app/visualizations/chart/chart.html b/client/app/visualizations/chart/chart.html
deleted file mode 100644
index 8c2cbedc34..0000000000
--- a/client/app/visualizations/chart/chart.html
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json
new file mode 100644
index 0000000000..7fd5acd9c0
--- /dev/null
+++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-grouped.json
@@ -0,0 +1,40 @@
+{
+ "input": {
+ "data": [
+ { "a": 42, "b": 10, "g": "first" },
+ { "a": 62, "b": 73, "g": "first" },
+ { "a": 21, "b": 82, "g": "second" },
+ { "a": 85, "b": 50, "g": "first" },
+ { "a": 95, "b": 32, "g": "second" }
+ ],
+ "options": {
+ "columnMapping": {
+ "a": "x",
+ "b": "y",
+ "g": "series"
+ },
+ "seriesOptions": {}
+ }
+ },
+ "output": {
+ "data": [
+ {
+ "name": "first",
+ "type": "column",
+ "data": [
+ { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "g": "first" } },
+ { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73, "g": "first" } },
+ { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50, "g": "first" } }
+ ]
+ },
+ {
+ "name": "second",
+ "type": "column",
+ "data": [
+ { "x": 21, "y": 82, "$raw": { "a": 21, "b": 82, "g": "second" } },
+ { "x": 95, "y": 32, "$raw": { "a": 95, "b": 32, "g": "second" } }
+ ]
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json
new file mode 100644
index 0000000000..df4fa93629
--- /dev/null
+++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-multiple-y.json
@@ -0,0 +1,41 @@
+{
+ "input": {
+ "data": [
+ { "a": 42, "b": 10, "c": 41, "d": 92 },
+ { "a": 62, "b": 73 },
+ { "a": 21, "b": null, "c": 33 },
+ { "a": 85, "b": 50 },
+ { "a": 95 }
+ ],
+ "options": {
+ "columnMapping": {
+ "a": "x",
+ "b": "y",
+ "c": "y"
+ },
+ "seriesOptions": {}
+ }
+ },
+ "output": {
+ "data": [
+ {
+ "name": "b",
+ "type": "column",
+ "data": [
+ { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } },
+ { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73 } },
+ { "x": 21, "y": null, "$raw": { "a": 21, "b": null, "c": 33 } },
+ { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50 } }
+ ]
+ },
+ {
+ "name": "c",
+ "type": "column",
+ "data": [
+ { "x": 42, "y": 41, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } },
+ { "x": 21, "y": 33, "$raw": { "a": 21, "b": null, "c": 33 } }
+ ]
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json
new file mode 100644
index 0000000000..65f7c05cdc
--- /dev/null
+++ b/client/app/visualizations/chart/fixtures/getChartData/multiple-series-sorted.json
@@ -0,0 +1,43 @@
+{
+ "input": {
+ "data": [
+ { "a": 42, "b": 10, "g": "first" },
+ { "a": 62, "b": 73, "g": "first" },
+ { "a": 21, "b": 82, "g": "second" },
+ { "a": 85, "b": 50, "g": "first" },
+ { "a": 95, "b": 32, "g": "second" }
+ ],
+ "options": {
+ "columnMapping": {
+ "a": "x",
+ "b": "y",
+ "g": "series"
+ },
+ "seriesOptions": {
+ "first": { "zIndex": 2 },
+ "second": { "zIndex": 1 }
+ }
+ }
+ },
+ "output": {
+ "data": [
+ {
+ "name": "second",
+ "type": "column",
+ "data": [
+ { "x": 21, "y": 82, "$raw": { "a": 21, "b": 82, "g": "second" } },
+ { "x": 95, "y": 32, "$raw": { "a": 95, "b": 32, "g": "second" } }
+ ]
+ },
+ {
+ "name": "first",
+ "type": "column",
+ "data": [
+ { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "g": "first" } },
+ { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73, "g": "first" } },
+ { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50, "g": "first" } }
+ ]
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/fixtures/getChartData/single-series.json b/client/app/visualizations/chart/fixtures/getChartData/single-series.json
new file mode 100644
index 0000000000..748ef9a921
--- /dev/null
+++ b/client/app/visualizations/chart/fixtures/getChartData/single-series.json
@@ -0,0 +1,32 @@
+{
+ "input": {
+ "data": [
+ { "a": 42, "b": 10, "c": 41, "d": 92 },
+ { "a": 62, "b": 73 },
+ { "a": 21, "b": null },
+ { "a": 85, "b": 50 },
+ { "a": 95 }
+ ],
+ "options": {
+ "columnMapping": {
+ "a": "x",
+ "b": "y"
+ },
+ "seriesOptions": {}
+ }
+ },
+ "output": {
+ "data": [
+ {
+ "name": "b",
+ "type": "column",
+ "data": [
+ { "x": 42, "y": 10, "$raw": { "a": 42, "b": 10, "c": 41, "d": 92 } },
+ { "x": 62, "y": 73, "$raw": { "a": 62, "b": 73 } },
+ { "x": 21, "y": null, "$raw": { "a": 21, "b": null } },
+ { "x": 85, "y": 50, "$raw": { "a": 85, "b": 50 } }
+ ]
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/getChartData.js b/client/app/visualizations/chart/getChartData.js
index 8e8f1ebd39..d9255c70c4 100644
--- a/client/app/visualizations/chart/getChartData.js
+++ b/client/app/visualizations/chart/getChartData.js
@@ -26,12 +26,11 @@ export default function getChartData(data, options) {
let sizeValue = null;
let zValue = null;
- forOwn(row, (v, definition) => {
+ forOwn(row, (value, definition) => {
definition = '' + definition;
const definitionParts = definition.split('::') || definition.split('__');
const name = definitionParts[0];
const type = mappings ? mappings[definition] : definitionParts[1];
- let value = v;
if (type === 'unused') {
return;
@@ -42,9 +41,6 @@ export default function getChartData(data, options) {
point[type] = value;
}
if (type === 'y') {
- if (value == null) {
- value = 0;
- }
yValues[name] = value;
point[type] = value;
}
diff --git a/client/app/visualizations/chart/getChartData.test.js b/client/app/visualizations/chart/getChartData.test.js
new file mode 100644
index 0000000000..5d1239f6d4
--- /dev/null
+++ b/client/app/visualizations/chart/getChartData.test.js
@@ -0,0 +1,32 @@
+/* eslint-disable global-require, import/no-unresolved */
+import getChartData from './getChartData';
+
+describe('Visualizations', () => {
+ describe('Chart', () => {
+ describe('getChartData', () => {
+ test('Single series', () => {
+ const { input, output } = require('./fixtures/getChartData/single-series');
+ const data = getChartData(input.data, input.options);
+ expect(data).toEqual(output.data);
+ });
+
+ test('Multiple series: multiple Y mappings', () => {
+ const { input, output } = require('./fixtures/getChartData/multiple-series-multiple-y');
+ const data = getChartData(input.data, input.options);
+ expect(data).toEqual(output.data);
+ });
+
+ test('Multiple series: grouped', () => {
+ const { input, output } = require('./fixtures/getChartData/multiple-series-grouped');
+ const data = getChartData(input.data, input.options);
+ expect(data).toEqual(output.data);
+ });
+
+ test('Multiple series: sorted', () => {
+ const { input, output } = require('./fixtures/getChartData/multiple-series-sorted');
+ const data = getChartData(input.data, input.options);
+ expect(data).toEqual(output.data);
+ });
+ });
+ });
+});
diff --git a/client/app/visualizations/chart/index.js b/client/app/visualizations/chart/index.js
index f6857717b9..d8a3e2fed5 100644
--- a/client/app/visualizations/chart/index.js
+++ b/client/app/visualizations/chart/index.js
@@ -6,9 +6,10 @@ import { registerVisualization } from '@/visualizations';
import { clientConfig } from '@/services/auth';
import ColorPalette from '@/visualizations/ColorPalette';
import getChartData from './getChartData';
-import template from './chart.html';
import editorTemplate from './chart-editor.html';
+import Renderer from './Renderer';
+
const DEFAULT_OPTIONS = {
globalSeriesType: 'column',
sortX: true,
@@ -27,6 +28,8 @@ const DEFAULT_OPTIONS = {
percentFormat: '0[.]00%',
// dateTimeFormat: 'DD/MM/YYYY HH:mm', // will be set from clientConfig
textFormat: '', // default: combination of {{ @@yPercent }} ({{ @@y }} ± {{ @@yError }})
+
+ missingValuesAsZero: true,
};
function initEditorForm(options, columns) {
@@ -69,26 +72,6 @@ function initEditorForm(options, columns) {
return result;
}
-const ChartRenderer = {
- template,
- bindings: {
- data: '<',
- options: '<',
- },
- controller($scope) {
- this.chartSeries = [];
-
- const update = () => {
- if (this.data) {
- this.chartSeries = getChartData(this.data.rows, this.options);
- }
- };
-
- $scope.$watch('$ctrl.data', update);
- $scope.$watch('$ctrl.options', update, true);
- },
-};
-
const ChartEditor = {
template: editorTemplate,
bindings: {
@@ -304,7 +287,6 @@ const ChartEditor = {
};
export default function init(ngModule) {
- ngModule.component('chartRenderer', ChartRenderer);
ngModule.component('chartEditor', ChartEditor);
ngModule.run(($injector) => {
@@ -312,11 +294,21 @@ export default function init(ngModule) {
type: 'CHART',
name: 'Chart',
isDefault: true,
- getOptions: options => merge({}, DEFAULT_OPTIONS, {
- showDataLabels: options.globalSeriesType === 'pie',
- dateTimeFormat: clientConfig.dateTimeFormat,
- }, options),
- Renderer: angular2react('chartRenderer', ChartRenderer, $injector),
+ getOptions: (options) => {
+ const result = merge({}, DEFAULT_OPTIONS, {
+ showDataLabels: options.globalSeriesType === 'pie',
+ dateTimeFormat: clientConfig.dateTimeFormat,
+ }, options);
+
+ // Backward compatibility
+ if (['normal', 'percent'].indexOf(result.series.stacking) >= 0) {
+ result.series.percentValues = result.series.stacking === 'percent';
+ result.series.stacking = 'stack';
+ }
+
+ return result;
+ },
+ Renderer,
Editor: angular2react('chartEditor', ChartEditor, $injector),
defaultColumns: 3,
diff --git a/client/app/visualizations/chart/plotly/applyLayoutFixes.js b/client/app/visualizations/chart/plotly/applyLayoutFixes.js
new file mode 100644
index 0000000000..56b3fa7d5e
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/applyLayoutFixes.js
@@ -0,0 +1,100 @@
+import { find, pick, reduce } from 'lodash';
+
+function fixLegendContainer(plotlyElement) {
+ const legend = plotlyElement.querySelector('.legend');
+ if (legend) {
+ let node = legend.parentNode;
+ while (node) {
+ if (node.tagName.toLowerCase() === 'svg') {
+ node.style.overflow = 'visible';
+ break;
+ }
+ node = node.parentNode;
+ }
+ }
+}
+
+export default function applyLayoutFixes(plotlyElement, layout, updatePlot) {
+ // update layout size to plot container
+ layout.width = Math.floor(plotlyElement.offsetWidth);
+ layout.height = Math.floor(plotlyElement.offsetHeight);
+
+ const transformName = find([
+ 'transform',
+ 'WebkitTransform',
+ 'MozTransform',
+ 'MsTransform',
+ 'OTransform',
+ ], prop => prop in plotlyElement.style);
+
+ if (layout.width <= 600) {
+ // change legend orientation to horizontal; plotly has a bug with this
+ // legend alignment - it does not preserve enough space under the plot;
+ // so we'll hack this: update plot (it will re-render legend), compute
+ // legend height, reduce plot size by legend height (but not less than
+ // half of plot container's height - legend will have max height equal to
+ // plot height), re-render plot again and offset legend to the space under
+ // the plot.
+ layout.legend = {
+ orientation: 'h',
+ // locate legend inside of plot area - otherwise plotly will preserve
+ // some amount of space under the plot; also this will limit legend height
+ // to plot's height
+ y: 0,
+ x: 0,
+ xanchor: 'left',
+ yanchor: 'bottom',
+ };
+
+ // set `overflow: visible` to svg containing legend because later we will
+ // position legend outside of it
+ fixLegendContainer(plotlyElement);
+
+ updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])).then(() => {
+ const legend = plotlyElement.querySelector('.legend'); // eslint-disable-line no-shadow
+ if (legend) {
+ // compute real height of legend - items may be split into few columnns,
+ // also scrollbar may be shown
+ const bounds = reduce(legend.querySelectorAll('.traces'), (result, node) => {
+ const b = node.getBoundingClientRect();
+ result = result || b;
+ return {
+ top: Math.min(result.top, b.top),
+ bottom: Math.max(result.bottom, b.bottom),
+ };
+ }, null);
+ // here we have two values:
+ // 1. height of plot container excluding height of legend items;
+ // it may be any value between 0 and plot container's height;
+ // 2. half of plot containers height. Legend cannot be larger than
+ // plot; if legend is too large, plotly will reduce it's height and
+ // show a scrollbar; in this case, height of plot === height of legend,
+ // so we can split container's height half by half between them.
+ layout.height = Math.floor(Math.max(
+ layout.height / 2,
+ layout.height - (bounds.bottom - bounds.top),
+ ));
+ // offset the legend
+ legend.style[transformName] = 'translate(0, ' + layout.height + 'px)';
+ updatePlot(plotlyElement, pick(layout, ['height']));
+ }
+ });
+ } else {
+ layout.legend = {
+ orientation: 'v',
+ // vertical legend will be rendered properly, so just place it to the right
+ // side of plot
+ y: 1,
+ x: 1,
+ xanchor: 'left',
+ yanchor: 'top',
+ };
+
+ const legend = plotlyElement.querySelector('.legend');
+ if (legend) {
+ legend.style[transformName] = null;
+ }
+
+ updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend']));
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/customChartUtils.js b/client/app/visualizations/chart/plotly/customChartUtils.js
new file mode 100644
index 0000000000..a6970100e0
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/customChartUtils.js
@@ -0,0 +1,40 @@
+import { each } from 'lodash';
+import { normalizeValue } from './utils';
+
+export function prepareCustomChartData(series) {
+ const x = [];
+ const ys = {};
+
+ each(series, ({ name, data }) => {
+ ys[name] = [];
+ each(data, (point) => {
+ x.push(normalizeValue(point.x));
+ ys[name].push(normalizeValue(point.y));
+ });
+ });
+
+ return { x, ys };
+}
+
+export function createCustomChartRenderer(code, logErrorsToConsole = false) {
+ // Create a function from custom code; catch syntax errors
+ let render = () => {};
+ try {
+ render = new Function('x, ys, element, Plotly', code); // eslint-disable-line no-new-func
+ } catch (err) {
+ if (logErrorsToConsole) {
+ console.log(`Error while executing custom graph: ${err}`); // eslint-disable-line no-console
+ }
+ }
+
+ // Return function that will invoke custom code; catch runtime errors
+ return (x, ys, element, Plotly) => {
+ try {
+ render(x, ys, element, Plotly);
+ } catch (err) {
+ if (logErrorsToConsole) {
+ console.log(`Error while executing custom graph: ${err}`); // eslint-disable-line no-console
+ }
+ }
+ };
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json
new file mode 100644
index 0000000000..8a31fe2c83
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/default.json
@@ -0,0 +1,56 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "column",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "column", "color": "red" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "type": "bar",
+ "name": "a",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "textposition": "inside",
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json
new file mode 100644
index 0000000000..3a29cdf376
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/normalized.json
@@ -0,0 +1,81 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "column",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true }, "percentValues": true },
+ "seriesOptions": {
+ "a": { "type": "column", "color": "red" },
+ "b": { "type": "column", "color": "blue" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ },
+ {
+ "name": "b",
+ "data": [
+ { "x": "x1", "y": 40, "yError": 0 },
+ { "x": "x2", "y": 30, "yError": 0 },
+ { "x": "x3", "y": 20, "yError": 0 },
+ { "x": "x4", "y": 10, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "type": "bar",
+ "name": "a",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [20, 40, 60, 80],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"],
+ "textposition": "inside",
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ },
+ {
+ "visible": true,
+ "type": "bar",
+ "name": "b",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [80, 60, 40, 20],
+ "error_y": { "array": [0, 0, 0, 0], "color": "blue" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"],
+ "textposition": "inside",
+ "marker": { "color": "blue" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json
new file mode 100644
index 0000000000..cb54f92407
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bar/stacked.json
@@ -0,0 +1,81 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "column",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "column", "color": "red" },
+ "b": { "type": "column", "color": "blue" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ },
+ {
+ "name": "b",
+ "data": [
+ { "x": "x1", "y": 1, "yError": 0 },
+ { "x": "x2", "y": 2, "yError": 0 },
+ { "x": "x3", "y": 3, "yError": 0 },
+ { "x": "x4", "y": 4, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "type": "bar",
+ "name": "a",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "textposition": "inside",
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ },
+ {
+ "visible": true,
+ "type": "bar",
+ "name": "b",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [1, 2, 3, 4],
+ "error_y": { "array": [0, 0, 0, 0], "color": "blue" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"],
+ "textposition": "inside",
+ "marker": { "color": "blue" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json
new file mode 100644
index 0000000000..5a5ba12dbe
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/default.json
@@ -0,0 +1,57 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "box",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "box", "color": "red" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "type": "box",
+ "mode": "markers",
+ "boxpoints": "outliers",
+ "hoverinfo": false,
+ "marker": { "color": "red", "size": 3 },
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json
new file mode 100644
index 0000000000..710cf6bd11
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/box/with-points.json
@@ -0,0 +1,60 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "box",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "box", "color": "red" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true,
+ "showpoints": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "type": "box",
+ "mode": "markers",
+ "boxpoints": "all",
+ "jitter": 0.3,
+ "pointpos": -1.8,
+ "hoverinfo": false,
+ "marker": { "color": "red", "size": 3 },
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json
new file mode 100644
index 0000000000..10e9b45505
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/bubble/default.json
@@ -0,0 +1,55 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "bubble",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "bubble", "color": "red" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0, "size": 51 },
+ { "x": "x2", "y": 20, "yError": 0, "size": 52 },
+ { "x": "x3", "y": 30, "yError": 0, "size": 53 },
+ { "x": "x4", "y": 40, "yError": 0, "size": 54 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "mode": "markers",
+ "marker": { "color": "red", "size": [51, 52, 53, 54] },
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0: 51", "20 ± 0: 52", "30 ± 0: 53", "40 ± 0: 54"],
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json
new file mode 100644
index 0000000000..4006b3d12e
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/default.json
@@ -0,0 +1,33 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "heatmap",
+ "colorScheme": "Bluered",
+ "seriesOptions": {},
+ "showDataLabels": false
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": 12, "y": 21, "zVal": 3 },
+ { "x": 11, "y": 22, "zVal": 2 },
+ { "x": 11, "y": 21, "zVal": 1 },
+ { "x": 12, "y": 22, "zVal": 4 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "x": [12, 11],
+ "y": [21, 22],
+ "z": [[3, 1], [4, 2]],
+ "type": "heatmap",
+ "name": "",
+ "colorscale": "Bluered"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json
new file mode 100644
index 0000000000..ff1e16e0f5
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/reversed.json
@@ -0,0 +1,35 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "heatmap",
+ "colorScheme": "Bluered",
+ "seriesOptions": {},
+ "showDataLabels": false,
+ "reverseX": true,
+ "reverseY": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": 12, "y": 21, "zVal": 3 },
+ { "x": 11, "y": 22, "zVal": 2 },
+ { "x": 11, "y": 21, "zVal": 1 },
+ { "x": 12, "y": 22, "zVal": 4 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "x": [11, 12],
+ "y": [22, 21],
+ "z": [[2, 4], [1, 3]],
+ "type": "heatmap",
+ "name": "",
+ "colorscale": "Bluered"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json
new file mode 100644
index 0000000000..ac8d69f694
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted-reversed.json
@@ -0,0 +1,37 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "heatmap",
+ "colorScheme": "Bluered",
+ "seriesOptions": {},
+ "showDataLabels": false,
+ "sortX": true,
+ "sortY": true,
+ "reverseX": true,
+ "reverseY": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": 12, "y": 21, "zVal": 3 },
+ { "x": 11, "y": 22, "zVal": 2 },
+ { "x": 11, "y": 21, "zVal": 1 },
+ { "x": 12, "y": 22, "zVal": 4 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "x": [12, 11],
+ "y": [22, 21],
+ "z": [[4, 2], [3, 1]],
+ "type": "heatmap",
+ "name": "",
+ "colorscale": "Bluered"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json
new file mode 100644
index 0000000000..3073a32916
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/sorted.json
@@ -0,0 +1,35 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "heatmap",
+ "colorScheme": "Bluered",
+ "seriesOptions": {},
+ "showDataLabels": false,
+ "sortX": true,
+ "sortY": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": 12, "y": 21, "zVal": 3 },
+ { "x": 11, "y": 22, "zVal": 2 },
+ { "x": 11, "y": 21, "zVal": 1 },
+ { "x": 12, "y": 22, "zVal": 4 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "x": [11, 12],
+ "y": [21, 22],
+ "z": [[1, 3], [2, 4]],
+ "type": "heatmap",
+ "name": "",
+ "colorscale": "Bluered"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json
new file mode 100644
index 0000000000..87a2583541
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/heatmap/with-labels.json
@@ -0,0 +1,44 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "heatmap",
+ "colorScheme": "Bluered",
+ "seriesOptions": {},
+ "showDataLabels": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": 12, "y": 21, "zVal": 3 },
+ { "x": 11, "y": 22, "zVal": 2 },
+ { "x": 11, "y": 21, "zVal": 1 },
+ { "x": 12, "y": 22, "zVal": 4 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "x": [12, 11],
+ "y": [21, 22],
+ "z": [[3, 1], [4, 2]],
+ "type": "heatmap",
+ "name": "",
+ "colorscale": "Bluered"
+ },
+ {
+ "x": [12, 11, 12, 11],
+ "y": [21, 21, 22, 22],
+ "mode": "text",
+ "hoverinfo": "skip",
+ "showlegend": false,
+ "text": ["3", "1", "4", "2"],
+ "textfont": {
+ "color": ["black", "black", "black", "black"]
+ }
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json
new file mode 100644
index 0000000000..ca3f540979
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/default.json
@@ -0,0 +1,55 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "line",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "line", "color": "red" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json
new file mode 100644
index 0000000000..108be880c8
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/keep-missing-values.json
@@ -0,0 +1,77 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "line",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "line", "color": "red" },
+ "b": { "type": "line", "color": "blue" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": false
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ },
+ {
+ "name": "b",
+ "data": [
+ { "x": "x2", "y": 2, "yError": 0 },
+ { "x": "x4", "y": 4, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ },
+ {
+ "visible": true,
+ "name": "b",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [null, 22, null, 44],
+ "error_y": { "array": [null, 0, null, 0], "color": "blue" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["", "2 ± 0", "", "4 ± 0"],
+ "marker": { "color": "blue" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json
new file mode 100644
index 0000000000..23e6a15df2
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/missing-values-0.json
@@ -0,0 +1,77 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "line",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "line", "color": "red" },
+ "b": { "type": "line", "color": "blue" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ },
+ {
+ "name": "b",
+ "data": [
+ { "x": "x2", "y": 2, "yError": 0 },
+ { "x": "x4", "y": 4, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ },
+ {
+ "visible": true,
+ "name": "b",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 22, 30, 44],
+ "error_y": { "array": [null, 0, null, 0], "color": "blue" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["0", "2 ± 0", "0", "4 ± 0"],
+ "marker": { "color": "blue" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json
new file mode 100644
index 0000000000..a5b25b6e79
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized-stacked.json
@@ -0,0 +1,79 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "line",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true }, "percentValues": true },
+ "seriesOptions": {
+ "a": { "type": "line", "color": "red" },
+ "b": { "type": "line", "color": "blue" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ },
+ {
+ "name": "b",
+ "data": [
+ { "x": "x1", "y": 40, "yError": 0 },
+ { "x": "x2", "y": 30, "yError": 0 },
+ { "x": "x3", "y": 20, "yError": 0 },
+ { "x": "x4", "y": 10, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [20, 40, 60, 80],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ },
+ {
+ "visible": true,
+ "name": "b",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [100, 100, 100, 100],
+ "error_y": { "array": [0, 0, 0, 0], "color": "blue" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"],
+ "marker": { "color": "blue" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json
new file mode 100644
index 0000000000..c016e392d4
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/normalized.json
@@ -0,0 +1,79 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "line",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true }, "percentValues": true },
+ "seriesOptions": {
+ "a": { "type": "line", "color": "red" },
+ "b": { "type": "line", "color": "blue" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ },
+ {
+ "name": "b",
+ "data": [
+ { "x": "x1", "y": 40, "yError": 0 },
+ { "x": "x2", "y": 30, "yError": 0 },
+ { "x": "x3", "y": 20, "yError": 0 },
+ { "x": "x4", "y": 10, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [20, 40, 60, 80],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["20% (10 ± 0)", "40% (20 ± 0)", "60% (30 ± 0)", "80% (40 ± 0)"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ },
+ {
+ "visible": true,
+ "name": "b",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [80, 60, 40, 20],
+ "error_y": { "array": [0, 0, 0, 0], "color": "blue" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["80% (40 ± 0)", "60% (30 ± 0)", "40% (20 ± 0)", "20% (10 ± 0)"],
+ "marker": { "color": "blue" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json
new file mode 100644
index 0000000000..bcb7a5157b
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/line-area/stacked.json
@@ -0,0 +1,79 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "line",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "line", "color": "red" },
+ "b": { "type": "line", "color": "blue" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ },
+ {
+ "name": "b",
+ "data": [
+ { "x": "x1", "y": 1, "yError": 0 },
+ { "x": "x2", "y": 2, "yError": 0 },
+ { "x": "x3", "y": 3, "yError": 0 },
+ { "x": "x4", "y": 4, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ },
+ {
+ "visible": true,
+ "name": "b",
+ "mode": "lines+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [11, 22, 33, 44],
+ "error_y": { "array": [0, 0, 0, 0], "color": "blue" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["1 ± 0", "2 ± 0", "3 ± 0", "4 ± 0"],
+ "marker": { "color": "blue" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json
new file mode 100644
index 0000000000..2b8be824d1
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/custom-tooltip.json
@@ -0,0 +1,57 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "pie",
+ "seriesOptions": {},
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "{{ @@name }}: {{ @@yPercent }} ({{ @@y }})",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "columnMapping": {
+ "x": "x",
+ "y": "y"
+ }
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "a1", "y": 10 },
+ { "x": "a2", "y": 60 },
+ { "x": "a3", "y": 100 },
+ { "x": "a4", "y": 30 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "values": [10, 60, 100, 30],
+ "labels": ["a1", "a2", "a3", "a4"],
+ "type": "pie",
+ "hole": 0.4,
+ "marker": {
+ "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"]
+ },
+ "hoverinfo": "text+label",
+ "hover": [],
+ "text": ["a: 5% (10)", "a: 30% (60)", "a: 50% (100)", "a: 15% (30)"],
+ "textinfo": "percent",
+ "textposition": "inside",
+ "textfont": { "color": "#ffffff" },
+ "name": "a",
+ "direction": "counterclockwise",
+ "domain": { "x": [0, 0.98], "y": [0, 0.9] }
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json
new file mode 100644
index 0000000000..ffabb1db31
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/default.json
@@ -0,0 +1,57 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "pie",
+ "seriesOptions": {},
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "columnMapping": {
+ "x": "x",
+ "y": "y"
+ }
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "a1", "y": 10 },
+ { "x": "a2", "y": 60 },
+ { "x": "a3", "y": 100 },
+ { "x": "a4", "y": 30 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "values": [10, 60, 100, 30],
+ "labels": ["a1", "a2", "a3", "a4"],
+ "type": "pie",
+ "hole": 0.4,
+ "marker": {
+ "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"]
+ },
+ "hoverinfo": "text+label",
+ "hover": [],
+ "text": ["5% (10)", "30% (60)", "50% (100)", "15% (30)"],
+ "textinfo": "percent",
+ "textposition": "inside",
+ "textfont": { "color": "#ffffff" },
+ "name": "a",
+ "direction": "counterclockwise",
+ "domain": { "x": [0, 0.98], "y": [0, 0.9] }
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json
new file mode 100644
index 0000000000..8ef0d06136
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-labels.json
@@ -0,0 +1,57 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "pie",
+ "seriesOptions": {},
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": false,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "columnMapping": {
+ "x": "x",
+ "y": "y"
+ }
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "a1", "y": 10 },
+ { "x": "a2", "y": 60 },
+ { "x": "a3", "y": 100 },
+ { "x": "a4", "y": 30 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "values": [10, 60, 100, 30],
+ "labels": ["a1", "a2", "a3", "a4"],
+ "type": "pie",
+ "hole": 0.4,
+ "marker": {
+ "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"]
+ },
+ "hoverinfo": "text+label",
+ "hover": [],
+ "text": ["5% (10)", "30% (60)", "50% (100)", "15% (30)"],
+ "textinfo": "none",
+ "textposition": "inside",
+ "textfont": { "color": "#ffffff" },
+ "name": "a",
+ "direction": "counterclockwise",
+ "domain": { "x": [0, 0.98], "y": [0, 0.9] }
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json
new file mode 100644
index 0000000000..a5c69b24a7
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/pie/without-x.json
@@ -0,0 +1,53 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "pie",
+ "seriesOptions": {},
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "a1", "y": 10 },
+ { "x": "a2", "y": 60 },
+ { "x": "a3", "y": 100 },
+ { "x": "a4", "y": 30 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "values": [10, 60, 100, 30],
+ "labels": ["Slice 0", "Slice 0", "Slice 0", "Slice 0"],
+ "type": "pie",
+ "hole": 0.4,
+ "marker": {
+ "colors": ["#356AFF", "#E92828", "#3BD973", "#604FE9"]
+ },
+ "hoverinfo": "text+label",
+ "hover": [],
+ "text": ["15% (30)", "15% (30)", "15% (30)", "15% (30)"],
+ "textinfo": "percent",
+ "textposition": "inside",
+ "textfont": { "color": "#ffffff" },
+ "name": "a",
+ "direction": "counterclockwise",
+ "domain": { "x": [0, 0.98], "y": [0, 0.9] }
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json
new file mode 100644
index 0000000000..5daed94941
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/default.json
@@ -0,0 +1,56 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "scatter",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": true,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "scatter", "color": "red" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "type": "scatter",
+ "mode": "markers+text",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json
new file mode 100644
index 0000000000..9267346196
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareData/scatter/without-labels.json
@@ -0,0 +1,56 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "scatter",
+ "numberFormat": "0,0[.]00000",
+ "percentFormat": "0[.]00%",
+ "textFormat": "",
+ "showDataLabels": false,
+ "direction": { "type": "counterclockwise" },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } },
+ "seriesOptions": {
+ "a": { "type": "scatter", "color": "red" }
+ },
+ "columnMapping": {
+ "x": "x",
+ "y1": "y"
+ },
+ "missingValuesAsZero": true
+ },
+ "data": [
+ {
+ "name": "a",
+ "data": [
+ { "x": "x1", "y": 10, "yError": 0 },
+ { "x": "x2", "y": 20, "yError": 0 },
+ { "x": "x3", "y": 30, "yError": 0 },
+ { "x": "x4", "y": 40, "yError": 0 }
+ ]
+ }
+ ]
+ },
+ "output": {
+ "series": [
+ {
+ "visible": true,
+ "name": "a",
+ "type": "scatter",
+ "mode": "markers",
+ "x": ["x1", "x2", "x3", "x4"],
+ "y": [10, 20, 30, 40],
+ "error_y": { "array": [0, 0, 0, 0], "color": "red" },
+ "hoverinfo": "text+x+name",
+ "hover": [],
+ "text": ["10 ± 0", "20 ± 0", "30 ± 0", "40 ± 0"],
+ "marker": { "color": "red" },
+ "insidetextfont": { "color": "#333333" },
+ "yaxis": "y"
+ }
+ ]
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json
new file mode 100644
index 0000000000..2ca978f540
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-single-axis.json
@@ -0,0 +1,38 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "box",
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }
+ },
+ "series": [
+ { "name": "a" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": true,
+ "boxmode": "group",
+ "boxgroupgap": 0.50,
+ "xaxis": {
+ "automargin": true,
+ "showticklabels": true,
+ "title": null,
+ "type": "-"
+ },
+ "yaxis": {
+ "automargin": true,
+ "title": null,
+ "type": "linear"
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json
new file mode 100644
index 0000000000..db513dd4a3
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/box-with-second-axis.json
@@ -0,0 +1,46 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "box",
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }
+ },
+ "series": [
+ { "name": "a" },
+ { "name": "b", "yaxis": "y2" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": true,
+ "boxmode": "group",
+ "boxgroupgap": 0.50,
+ "xaxis": {
+ "automargin": true,
+ "showticklabels": true,
+ "title": null,
+ "type": "-"
+ },
+ "yaxis": {
+ "automargin": true,
+ "title": null,
+ "type": "linear"
+ },
+ "yaxis2": {
+ "automargin": true,
+ "title": null,
+ "type": "linear",
+ "overlaying": "y",
+ "side": "right"
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json
new file mode 100644
index 0000000000..f80aa28be2
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-single-axis.json
@@ -0,0 +1,36 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "column",
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }
+ },
+ "series": [
+ { "name": "a" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": true,
+ "xaxis": {
+ "automargin": true,
+ "showticklabels": true,
+ "title": null,
+ "type": "-"
+ },
+ "yaxis": {
+ "automargin": true,
+ "title": null,
+ "type": "linear"
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json
new file mode 100644
index 0000000000..2d83b6a12e
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-second-axis.json
@@ -0,0 +1,44 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "column",
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }
+ },
+ "series": [
+ { "name": "a" },
+ { "name": "b", "yaxis": "y2" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": true,
+ "xaxis": {
+ "automargin": true,
+ "showticklabels": true,
+ "title": null,
+ "type": "-"
+ },
+ "yaxis": {
+ "automargin": true,
+ "title": null,
+ "type": "linear"
+ },
+ "yaxis2": {
+ "automargin": true,
+ "title": null,
+ "type": "linear",
+ "overlaying": "y",
+ "side": "right"
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json
new file mode 100644
index 0000000000..7dfd7c1e10
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-with-stacking.json
@@ -0,0 +1,38 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "column",
+ "legend": { "enabled": false },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": "stack", "error_y": { "type": "data", "visible": true } }
+ },
+ "series": [
+ { "name": "a" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": false,
+ "barmode": "relative",
+ "xaxis": {
+ "automargin": true,
+ "showticklabels": true,
+ "title": null,
+ "type": "-"
+ },
+ "yaxis": {
+ "automargin": true,
+ "title": null,
+ "type": "linear"
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json
new file mode 100644
index 0000000000..93747d5c03
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/default-without-legend.json
@@ -0,0 +1,37 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "column",
+ "legend": { "enabled": false },
+ "xAxis": { "type": "-", "labels": { "enabled": true } },
+ "yAxis": [
+ { "type": "linear" },
+ { "type": "linear", "opposite": true }
+ ],
+ "series": { "stacking": null, "error_y": { "type": "data", "visible": true } }
+ },
+ "series": [
+ { "name": "a" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": false,
+ "xaxis": {
+ "automargin": true,
+ "showticklabels": true,
+ "title": null,
+ "type": "-"
+ },
+ "yaxis": {
+ "automargin": true,
+ "title": null,
+ "type": "linear"
+ }
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json
new file mode 100644
index 0000000000..ef935b4b12
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-multiple-series.json
@@ -0,0 +1,48 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "pie",
+ "textFormat": ""
+ },
+ "series": [
+ { "name": "a" },
+ { "name": "b" },
+ { "name": "c" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": true,
+ "annotations": [
+ {
+ "x": 0.24,
+ "y": 0.485,
+ "xanchor": "center",
+ "yanchor": "top",
+ "text": "a",
+ "showarrow": false
+ },
+ {
+ "x": 0.74,
+ "y": 0.485,
+ "xanchor": "center",
+ "yanchor": "top",
+ "text": "b",
+ "showarrow": false
+ },
+ {
+ "x": 0.24,
+ "y": 0.985,
+ "xanchor": "center",
+ "yanchor": "top",
+ "text": "c",
+ "showarrow": false
+ }
+ ]
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json
new file mode 100644
index 0000000000..c306a7dfc4
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie-without-annotations.json
@@ -0,0 +1,21 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "pie",
+ "textFormat": "{{ @@name }}"
+ },
+ "series": [
+ { "name": "a" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": true,
+ "annotations": []
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json
new file mode 100644
index 0000000000..be6ec72584
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/fixtures/prepareLayout/pie.json
@@ -0,0 +1,30 @@
+{
+ "input": {
+ "options": {
+ "globalSeriesType": "pie",
+ "textFormat": ""
+ },
+ "series": [
+ { "name": "a" }
+ ]
+ },
+ "output": {
+ "layout": {
+ "margin": { "l": 10, "r": 10, "b": 10, "t": 25, "pad": 4 },
+ "width": 400,
+ "height": 300,
+ "autosize": true,
+ "showlegend": true,
+ "annotations": [
+ {
+ "x": 0.49,
+ "y": 0.985,
+ "xanchor": "center",
+ "yanchor": "top",
+ "text": "a",
+ "showarrow": false
+ }
+ ]
+ }
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/index.js b/client/app/visualizations/chart/plotly/index.js
index 4d0a91d4d0..3aa3ad382c 100644
--- a/client/app/visualizations/chart/plotly/index.js
+++ b/client/app/visualizations/chart/plotly/index.js
@@ -1,5 +1,3 @@
-import { each, debounce, isArray, isObject } from 'lodash';
-
import Plotly from 'plotly.js/lib/core';
import bar from 'plotly.js/lib/bar';
import pie from 'plotly.js/lib/pie';
@@ -7,134 +5,23 @@ import histogram from 'plotly.js/lib/histogram';
import box from 'plotly.js/lib/box';
import heatmap from 'plotly.js/lib/heatmap';
-import {
- prepareData,
- prepareLayout,
- updateData,
- updateLayout,
- normalizeValue,
-} from './utils';
+import prepareData from './prepareData';
+import prepareLayout from './prepareLayout';
+import updateData from './updateData';
+import applyLayoutFixes from './applyLayoutFixes';
+import { prepareCustomChartData, createCustomChartRenderer } from './customChartUtils';
Plotly.register([bar, pie, histogram, box, heatmap]);
Plotly.setPlotConfig({
modeBarButtonsToRemove: ['sendDataToCloud'],
});
-const PlotlyChart = () => ({
- restrict: 'E',
- template: '
',
- scope: {
- options: '=',
- series: '=',
- },
- link(scope, element) {
- const plotlyElement = element[0].querySelector('.plotly-chart-container');
- const plotlyOptions = { showLink: false, displaylogo: false };
- let layout = {};
- let data = [];
-
- function update() {
- if (['normal', 'percent'].indexOf(scope.options.series.stacking) >= 0) {
- // Backward compatibility
- scope.options.series.percentValues = scope.options.series.stacking === 'percent';
- scope.options.series.stacking = 'stack';
- }
-
- data = prepareData(scope.series, scope.options);
- updateData(data, scope.options);
- layout = prepareLayout(plotlyElement, scope.series, scope.options, data);
-
- // It will auto-purge previous graph
- Plotly.newPlot(plotlyElement, data, layout, plotlyOptions).then(() => {
- updateLayout(plotlyElement, layout, (e, u) => Plotly.relayout(e, u));
- });
-
- plotlyElement.on('plotly_restyle', (updates) => {
- // This event is triggered if some plotly data/layout has changed.
- // We need to catch only changes of traces visibility to update stacking
- if (isArray(updates) && isObject(updates[0]) && updates[0].visible) {
- updateData(data, scope.options);
- Plotly.relayout(plotlyElement, layout);
- }
- });
- }
- update();
-
- scope.$watch('series', (oldValue, newValue) => {
- if (oldValue !== newValue) {
- update();
- }
- });
- scope.$watch('options', (oldValue, newValue) => {
- if (oldValue !== newValue) {
- update();
- }
- }, true);
-
- scope.handleResize = debounce(() => {
- updateLayout(plotlyElement, layout, (e, u) => Plotly.relayout(e, u));
- }, 50);
- },
-});
-
-const CustomPlotlyChart = clientConfig => ({
- restrict: 'E',
- template: '
',
- scope: {
- series: '=',
- options: '=',
- },
- link(scope, element) {
- if (!clientConfig.allowCustomJSVisualizations) {
- return;
- }
-
- const refresh = () => {
- // Clear existing data with blank data for succeeding codeCall adds data to existing plot.
- Plotly.newPlot(element[0].firstChild);
-
- try {
- // eslint-disable-next-line no-new-func
- const codeCall = new Function('x, ys, element, Plotly', scope.options.customCode);
- codeCall(scope.x, scope.ys, element[0].children[0], Plotly);
- } catch (err) {
- if (scope.options.enableConsoleLogs) {
- // eslint-disable-next-line no-console
- console.log(`Error while executing custom graph: ${err}`);
- }
- }
- };
-
- const timeSeriesToPlotlySeries = () => {
- scope.x = [];
- scope.ys = {};
- each(scope.series, (series) => {
- scope.ys[series.name] = [];
- each(series.data, (point) => {
- scope.x.push(normalizeValue(point.x));
- scope.ys[series.name].push(normalizeValue(point.y));
- });
- });
- };
-
- scope.handleResize = () => {
- refresh();
- };
-
- scope.$watch('[options.customCode, options.autoRedraw]', () => {
- refresh();
- }, true);
-
- scope.$watch('series', () => {
- timeSeriesToPlotlySeries();
- refresh();
- }, true);
- },
-});
-
-export default function init(ngModule) {
- ngModule.directive('plotlyChart', PlotlyChart);
- ngModule.directive('customPlotlyChart', CustomPlotlyChart);
-}
-
-init.init = true;
+export {
+ Plotly,
+ prepareData,
+ prepareLayout,
+ updateData,
+ applyLayoutFixes,
+ prepareCustomChartData,
+ createCustomChartRenderer,
+};
diff --git a/client/app/visualizations/chart/plotly/prepareData.js b/client/app/visualizations/chart/plotly/prepareData.js
new file mode 100644
index 0000000000..e8819d9986
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/prepareData.js
@@ -0,0 +1,12 @@
+import preparePieData from './preparePieData';
+import prepareHeatmapData from './prepareHeatmapData';
+import prepareDefaultData from './prepareDefaultData';
+import updateData from './updateData';
+
+export default function prepareData(seriesList, options) {
+ switch (options.globalSeriesType) {
+ case 'pie': return updateData(preparePieData(seriesList, options), options);
+ case 'heatmap': return updateData(prepareHeatmapData(seriesList, options, options));
+ default: return updateData(prepareDefaultData(seriesList, options), options);
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/prepareData.test.js b/client/app/visualizations/chart/plotly/prepareData.test.js
new file mode 100644
index 0000000000..3aaf54d8c1
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/prepareData.test.js
@@ -0,0 +1,160 @@
+/* eslint-disable global-require, import/no-unresolved */
+import prepareData from './prepareData';
+
+function cleanSeries(series) {
+ return series.map(({ sourceData, ...rest }) => rest);
+}
+
+describe('Visualizations', () => {
+ describe('Chart', () => {
+ describe('prepareData', () => {
+ describe('heatmap', () => {
+ test('default', () => {
+ const { input, output } = require('./fixtures/prepareData/heatmap/default');
+ const series = prepareData(input.data, input.options);
+ expect(series).toEqual(output.series);
+ });
+ test('sorted', () => {
+ const { input, output } = require('./fixtures/prepareData/heatmap/sorted');
+ const series = prepareData(input.data, input.options);
+ expect(series).toEqual(output.series);
+ });
+ test('reversed', () => {
+ const { input, output } = require('./fixtures/prepareData/heatmap/reversed');
+ const series = prepareData(input.data, input.options);
+ expect(series).toEqual(output.series);
+ });
+ test('sorted & reversed', () => {
+ const { input, output } = require('./fixtures/prepareData/heatmap/sorted');
+ const series = prepareData(input.data, input.options);
+ expect(series).toEqual(output.series);
+ });
+ test('with labels', () => {
+ const { input, output } = require('./fixtures/prepareData/heatmap/with-labels');
+ const series = prepareData(input.data, input.options);
+ expect(series).toEqual(output.series);
+ });
+ });
+
+ describe('pie', () => {
+ test('default', () => {
+ const { input, output } = require('./fixtures/prepareData/pie/default');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('without X mapped', () => {
+ const { input, output } = require('./fixtures/prepareData/pie/without-x');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('without labels', () => {
+ const { input, output } = require('./fixtures/prepareData/pie/without-labels');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('custom tooltip', () => {
+ const { input, output } = require('./fixtures/prepareData/pie/custom-tooltip');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+ });
+
+ describe('bar (column)', () => {
+ test('default', () => {
+ const { input, output } = require('./fixtures/prepareData/bar/default');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('stacked', () => {
+ const { input, output } = require('./fixtures/prepareData/bar/stacked');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('normalized values', () => {
+ const { input, output } = require('./fixtures/prepareData/bar/normalized');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+ });
+
+ describe('lines & area', () => {
+ test('default', () => {
+ const { input, output } = require('./fixtures/prepareData/line-area/default');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('stacked', () => {
+ const { input, output } = require('./fixtures/prepareData/line-area/stacked');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('normalized values', () => {
+ const { input, output } = require('./fixtures/prepareData/line-area/normalized');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('stacked & normalized values', () => {
+ const { input, output } = require('./fixtures/prepareData/line-area/normalized-stacked');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('keep missing values', () => {
+ const { input, output } = require('./fixtures/prepareData/line-area/keep-missing-values');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('convert missing values to 0', () => {
+ const { input, output } = require('./fixtures/prepareData/line-area/missing-values-0');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+ });
+
+ describe('scatter', () => {
+ test('default', () => {
+ const { input, output } = require('./fixtures/prepareData/scatter/default');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('without labels', () => {
+ const { input, output } = require('./fixtures/prepareData/scatter/without-labels');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+ });
+
+ describe('bubble', () => {
+ test('default', () => {
+ const { input, output } = require('./fixtures/prepareData/bubble/default');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+ });
+
+ describe('box', () => {
+ test('default', () => {
+ const { input, output } = require('./fixtures/prepareData/box/default');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+
+ test('with points', () => {
+ const { input, output } = require('./fixtures/prepareData/box/with-points');
+ const series = cleanSeries(prepareData(input.data, input.options));
+ expect(series).toEqual(output.series);
+ });
+ });
+ });
+ });
+});
diff --git a/client/app/visualizations/chart/plotly/prepareDefaultData.js b/client/app/visualizations/chart/plotly/prepareDefaultData.js
new file mode 100644
index 0000000000..aeedae3ba3
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/prepareDefaultData.js
@@ -0,0 +1,177 @@
+import { isNil, each, includes, isString, map, sortBy } from 'lodash';
+import { cleanNumber, normalizeValue, getSeriesAxis } from './utils';
+import { ColorPaletteArray } from '@/visualizations/ColorPalette';
+
+function getSeriesColor(seriesOptions, seriesIndex) {
+ return seriesOptions.color || ColorPaletteArray[seriesIndex % ColorPaletteArray.length];
+}
+
+function getFontColor(backgroundColor) {
+ let result = '#333333';
+ if (isString(backgroundColor)) {
+ let matches = /#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(backgroundColor);
+ let r;
+ let g;
+ let b;
+ if (matches) {
+ r = parseInt(matches[1], 16);
+ g = parseInt(matches[2], 16);
+ b = parseInt(matches[3], 16);
+ } else {
+ matches = /#?([0-9a-f])([0-9a-f])([0-9a-f])/i.exec(backgroundColor);
+ if (matches) {
+ r = parseInt(matches[1] + matches[1], 16);
+ g = parseInt(matches[2] + matches[2], 16);
+ b = parseInt(matches[3] + matches[3], 16);
+ } else {
+ return result;
+ }
+ }
+
+ const lightness = r * 0.299 + g * 0.587 + b * 0.114;
+ if (lightness < 170) {
+ result = '#ffffff';
+ }
+ }
+
+ return result;
+}
+
+function getHoverInfoPattern(options) {
+ const hasX = /{{\s*@@x\s*}}/.test(options.textFormat);
+ const hasName = /{{\s*@@name\s*}}/.test(options.textFormat);
+ let result = 'text';
+ if (!hasX) result += '+x';
+ if (!hasName) result += '+name';
+ return result;
+}
+
+function prepareBarSeries(series, options) {
+ series.type = 'bar';
+ if (options.showDataLabels) {
+ series.textposition = 'inside';
+ }
+ return series;
+}
+
+function prepareLineSeries(series, options) {
+ series.mode = 'lines' + (options.showDataLabels ? '+text' : '');
+ return series;
+}
+
+function prepareAreaSeries(series, options) {
+ series.mode = 'lines' + (options.showDataLabels ? '+text' : '');
+ series.fill = options.series.stacking ? 'tonexty' : 'tozeroy';
+ return series;
+}
+
+function prepareScatterSeries(series, options) {
+ series.type = 'scatter';
+ series.mode = 'markers' + (options.showDataLabels ? '+text' : '');
+ return series;
+}
+
+function prepareBubbleSeries(series, options, { seriesColor, data }) {
+ series.mode = 'markers';
+ series.marker = {
+ color: seriesColor,
+ size: map(data, i => i.size),
+ };
+ return series;
+}
+
+function prepareBoxSeries(series, options, { seriesColor }) {
+ series.type = 'box';
+ series.mode = 'markers';
+
+ series.boxpoints = 'outliers';
+ series.hoverinfo = false;
+ series.marker = {
+ color: seriesColor,
+ size: 3,
+ };
+ if (options.showpoints) {
+ series.boxpoints = 'all';
+ series.jitter = 0.3;
+ series.pointpos = -1.8;
+ }
+ return series;
+}
+
+function prepareSeries(series, options, additionalOptions) {
+ const { hoverInfoPattern, index } = additionalOptions;
+
+ const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType };
+ const seriesColor = getSeriesColor(seriesOptions, index);
+ const seriesYAxis = getSeriesAxis(series, options);
+
+ // Sort by x - `Map` preserves order of items
+ const data = options.sortX ? sortBy(series.data, d => normalizeValue(d.x, options.xAxis.type)) : series.data;
+
+ // For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size;
+ // for other types `y` is always number
+ const cleanYValue = includes(['bubble', 'scatter'], seriesOptions.type) ? normalizeValue : (v) => {
+ v = cleanNumber(v);
+ return (options.missingValuesAsZero && isNil(v)) ? 0.0 : v;
+ };
+
+ const sourceData = new Map();
+ const xValues = [];
+ const yValues = [];
+ const yErrorValues = [];
+ each(data, (row) => {
+ const x = normalizeValue(row.x, options.xAxis.type); // number/datetime/category
+ const y = cleanYValue(row.y, seriesYAxis === 'y2' ? options.yAxis[1].type : options.yAxis[0].type); // depends on series type!
+ const yError = cleanNumber(row.yError); // always number
+ const size = cleanNumber(row.size); // always number
+ sourceData.set(x, {
+ x,
+ y,
+ yError,
+ size,
+ yPercent: null, // will be updated later
+ row,
+ });
+ xValues.push(x);
+ yValues.push(y);
+ yErrorValues.push(yError);
+ });
+
+ const plotlySeries = {
+ visible: true,
+ hoverinfo: hoverInfoPattern,
+ x: xValues,
+ y: yValues,
+ error_y: {
+ array: yErrorValues,
+ color: seriesColor,
+ },
+ name: seriesOptions.name || series.name,
+ marker: { color: seriesColor },
+ insidetextfont: {
+ color: getFontColor(seriesColor),
+ },
+ yaxis: seriesYAxis,
+ sourceData,
+ };
+
+ additionalOptions = { ...additionalOptions, seriesColor, data };
+
+ switch (seriesOptions.type) {
+ case 'column': return prepareBarSeries(plotlySeries, options, additionalOptions);
+ case 'line': return prepareLineSeries(plotlySeries, options, additionalOptions);
+ case 'area': return prepareAreaSeries(plotlySeries, options, additionalOptions);
+ case 'scatter': return prepareScatterSeries(plotlySeries, options, additionalOptions);
+ case 'bubble': return prepareBubbleSeries(plotlySeries, options, additionalOptions);
+ case 'box': return prepareBoxSeries(plotlySeries, options, additionalOptions);
+ default: return plotlySeries;
+ }
+}
+
+export default function prepareDefaultData(seriesList, options) {
+ const additionalOptions = {
+ hoverInfoPattern: getHoverInfoPattern(options),
+ };
+
+ return map(seriesList, (series, index) => prepareSeries(series, options, { ...additionalOptions, index }));
+}
diff --git a/client/app/visualizations/chart/plotly/prepareHeatmapData.js b/client/app/visualizations/chart/plotly/prepareHeatmapData.js
new file mode 100644
index 0000000000..0f3448c669
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/prepareHeatmapData.js
@@ -0,0 +1,109 @@
+import { map, max, uniq, sortBy, flatten, find } from 'lodash';
+import { createNumberFormatter } from '@/lib/value-format';
+
+const defaultColorScheme = [
+ [0, '#356aff'],
+ [0.14, '#4a7aff'],
+ [0.28, '#5d87ff'],
+ [0.42, '#7398ff'],
+ [0.56, '#fb8c8c'],
+ [0.71, '#ec6463'],
+ [0.86, '#ec4949'],
+ [1, '#e92827'],
+];
+
+function prepareSeries(series, options, additionalOptions) {
+ const { colorScheme, formatNumber } = additionalOptions;
+
+ const plotlySeries = {
+ x: [],
+ y: [],
+ z: [],
+ type: 'heatmap',
+ name: '',
+ colorscale: colorScheme,
+ };
+
+ plotlySeries.x = uniq(map(series.data, v => v.x));
+ plotlySeries.y = uniq(map(series.data, v => v.y));
+
+ if (options.sortX) {
+ plotlySeries.x = sortBy(plotlySeries.x);
+ }
+
+ if (options.sortY) {
+ plotlySeries.y = sortBy(plotlySeries.y);
+ }
+
+ if (options.reverseX) {
+ plotlySeries.x.reverse();
+ }
+
+ if (options.reverseY) {
+ plotlySeries.y.reverse();
+ }
+
+ const zMax = max(map(series.data, d => d.zVal));
+
+ // Use text trace instead of default annotation for better performance
+ const dataLabels = {
+ x: [],
+ y: [],
+ mode: 'text',
+ hoverinfo: 'skip',
+ showlegend: false,
+ text: [],
+ textfont: {
+ color: [],
+ },
+ };
+
+ for (let i = 0; i < plotlySeries.y.length; i += 1) {
+ const item = [];
+ for (let j = 0; j < plotlySeries.x.length; j += 1) {
+ const datum = find(
+ series.data,
+ { x: plotlySeries.x[j], y: plotlySeries.y[i] },
+ );
+
+ const zValue = datum && datum.zVal || 0;
+ item.push(zValue);
+
+ if (isFinite(zMax) && options.showDataLabels) {
+ dataLabels.x.push(plotlySeries.x[j]);
+ dataLabels.y.push(plotlySeries.y[i]);
+ dataLabels.text.push(formatNumber(zValue));
+ if (options.colorScheme && options.colorScheme === 'Custom...') {
+ dataLabels.textfont.color.push('white');
+ } else {
+ dataLabels.textfont.color.push((zValue / zMax) < 0.25 ? 'white' : 'black');
+ }
+ }
+ }
+ plotlySeries.z.push(item);
+ }
+
+ if (isFinite(zMax) && options.showDataLabels) {
+ return [plotlySeries, dataLabels];
+ }
+ return [plotlySeries];
+}
+
+export default function prepareHeatmapData(seriesList, options) {
+ let colorScheme = [];
+
+ if (!options.colorScheme) {
+ colorScheme = defaultColorScheme;
+ } else if (options.colorScheme === 'Custom...') {
+ colorScheme = [[0, options.heatMinColor], [1, options.heatMaxColor]];
+ } else {
+ colorScheme = options.colorScheme;
+ }
+
+ const additionalOptions = {
+ colorScheme,
+ formatNumber: createNumberFormatter(options.numberFormat),
+ };
+
+ return flatten(map(seriesList, series => prepareSeries(series, options, additionalOptions)));
+}
diff --git a/client/app/visualizations/chart/plotly/prepareLayout.js b/client/app/visualizations/chart/plotly/prepareLayout.js
new file mode 100644
index 0000000000..b028eecbb8
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/prepareLayout.js
@@ -0,0 +1,128 @@
+import { filter, has, isNumber, isObject, isUndefined, map, max, min } from 'lodash';
+import { getPieDimensions } from './preparePieData';
+
+function getAxisTitle(axis) {
+ return isObject(axis.title) ? axis.title.text : null;
+}
+
+function getAxisScaleType(axis) {
+ switch (axis.type) {
+ case 'datetime': return 'date';
+ case 'logarithmic': return 'log';
+ default: return axis.type;
+ }
+}
+
+function calculateAxisRange(seriesList, minValue, maxValue) {
+ if (!isNumber(minValue)) {
+ minValue = Math.min(0, min(map(seriesList, series => min(series.y))));
+ }
+ if (!isNumber(maxValue)) {
+ maxValue = max(map(seriesList, series => max(series.y)));
+ }
+ return [minValue, maxValue];
+}
+
+function prepareXAxis(axisOptions, additionalOptions) {
+ const axis = {
+ title: getAxisTitle(axisOptions),
+ type: getAxisScaleType(axisOptions),
+ automargin: true,
+ };
+
+ if (additionalOptions.sortX && axis.type === 'category') {
+ if (additionalOptions.reverseX) {
+ axis.categoryorder = 'category descending';
+ } else {
+ axis.categoryorder = 'category ascending';
+ }
+ }
+
+ if (!isUndefined(axisOptions.labels)) {
+ axis.showticklabels = axisOptions.labels.enabled;
+ }
+
+ return axis;
+}
+
+function prepareYAxis(axisOptions, additionalOptions, data) {
+ const axis = {
+ title: getAxisTitle(axisOptions),
+ type: getAxisScaleType(axisOptions),
+ automargin: true,
+ };
+
+ if (isNumber(axisOptions.rangeMin) || isNumber(axisOptions.rangeMax)) {
+ axis.range = calculateAxisRange(data, axisOptions.rangeMin, axisOptions.rangeMax);
+ }
+
+ return axis;
+}
+
+function preparePieLayout(layout, options, data) {
+ const hasName = /{{\s*@@name\s*}}/.test(options.textFormat);
+
+ const { cellsInRow, cellWidth, cellHeight, xPadding } = getPieDimensions(data);
+
+ if (hasName) {
+ layout.annotations = [];
+ } else {
+ layout.annotations = filter(map(data, (series, index) => {
+ const xPosition = (index % cellsInRow) * cellWidth;
+ const yPosition = Math.floor(index / cellsInRow) * cellHeight;
+ return {
+ x: xPosition + ((cellWidth - xPadding) / 2),
+ y: yPosition + cellHeight - 0.015,
+ xanchor: 'center',
+ yanchor: 'top',
+ text: series.name,
+ showarrow: false,
+ };
+ }));
+ }
+
+ return layout;
+}
+
+function prepareDefaultLayout(layout, options, data) {
+ const ySeries = data.filter(s => s.yaxis !== 'y2');
+ const y2Series = data.filter(s => s.yaxis === 'y2');
+
+ layout.xaxis = prepareXAxis(options.xAxis, options);
+
+ layout.yaxis = prepareYAxis(options.yAxis[0], options, ySeries);
+ if (y2Series.length > 0) {
+ layout.yaxis2 = prepareYAxis(options.yAxis[1], options, y2Series);
+ layout.yaxis2.overlaying = 'y';
+ layout.yaxis2.side = 'right';
+ }
+
+ if (options.series.stacking) {
+ layout.barmode = 'relative';
+ }
+
+ return layout;
+}
+
+function prepareBoxLayout(layout, options, data) {
+ layout = prepareDefaultLayout(layout, options, data);
+ layout.boxmode = 'group';
+ layout.boxgroupgap = 0.50;
+ return layout;
+}
+
+export default function prepareLayout(element, options, data) {
+ const layout = {
+ margin: { l: 10, r: 10, b: 10, t: 25, pad: 4 },
+ width: Math.floor(element.offsetWidth),
+ height: Math.floor(element.offsetHeight),
+ autosize: true,
+ showlegend: has(options, 'legend') ? options.legend.enabled : true,
+ };
+
+ switch (options.globalSeriesType) {
+ case 'pie': return preparePieLayout(layout, options, data);
+ case 'box': return prepareBoxLayout(layout, options, data);
+ default: return prepareDefaultLayout(layout, options, data);
+ }
+}
diff --git a/client/app/visualizations/chart/plotly/prepareLayout.test.js b/client/app/visualizations/chart/plotly/prepareLayout.test.js
new file mode 100644
index 0000000000..6af330cbcd
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/prepareLayout.test.js
@@ -0,0 +1,64 @@
+/* eslint-disable global-require, import/no-unresolved */
+import prepareLayout from './prepareLayout';
+
+const fakeElement = { offsetWidth: 400, offsetHeight: 300 };
+
+describe('Visualizations', () => {
+ describe('Chart', () => {
+ describe('prepareLayout', () => {
+ test('Pie', () => {
+ const { input, output } = require('./fixtures/prepareLayout/pie');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Pie without annotations', () => {
+ const { input, output } = require('./fixtures/prepareLayout/pie-without-annotations');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Pie with multiple series', () => {
+ const { input, output } = require('./fixtures/prepareLayout/pie-multiple-series');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Box with single Y axis', () => {
+ const { input, output } = require('./fixtures/prepareLayout/box-single-axis');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Box with second Y axis', () => {
+ const { input, output } = require('./fixtures/prepareLayout/box-with-second-axis');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Default with single Y axis', () => {
+ const { input, output } = require('./fixtures/prepareLayout/default-single-axis');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Default with second Y axis', () => {
+ const { input, output } = require('./fixtures/prepareLayout/default-with-second-axis');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Default without legend', () => {
+ const { input, output } = require('./fixtures/prepareLayout/default-without-legend');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+
+ test('Default with stacking', () => {
+ const { input, output } = require('./fixtures/prepareLayout/default-with-stacking');
+ const layout = prepareLayout(fakeElement, input.options, input.series);
+ expect(layout).toEqual(output.layout);
+ });
+ });
+ });
+});
diff --git a/client/app/visualizations/chart/plotly/preparePieData.js b/client/app/visualizations/chart/plotly/preparePieData.js
new file mode 100644
index 0000000000..b8ac696993
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/preparePieData.js
@@ -0,0 +1,96 @@
+import { each, includes, isString, map, reduce } from 'lodash';
+import d3 from 'd3';
+import { ColorPaletteArray } from '@/visualizations/ColorPalette';
+
+import { cleanNumber, normalizeValue } from './utils';
+
+export function getPieDimensions(series) {
+ const rows = series.length > 2 ? 2 : 1;
+ const cellsInRow = Math.ceil(series.length / rows);
+ const cellWidth = 1 / cellsInRow;
+ const cellHeight = 1 / rows;
+ const xPadding = 0.02;
+ const yPadding = 0.1;
+
+ return { rows, cellsInRow, cellWidth, cellHeight, xPadding, yPadding };
+}
+
+function getPieHoverInfoPattern(options) {
+ const hasX = /{{\s*@@x\s*}}/.test(options.textFormat);
+ let result = 'text';
+ if (!hasX) result += '+label';
+ return result;
+}
+
+function prepareSeries(series, options, additionalOptions) {
+ const {
+ cellWidth, cellHeight, xPadding, yPadding, cellsInRow, hasX,
+ index, hoverInfoPattern, getValueColor,
+ } = additionalOptions;
+
+ const xPosition = (index % cellsInRow) * cellWidth;
+ const yPosition = Math.floor(index / cellsInRow) * cellHeight;
+
+ const labels = [];
+ const values = [];
+ const sourceData = new Map();
+ const seriesTotal = reduce(series.data, (result, row) => {
+ const y = cleanNumber(row.y);
+ return result + Math.abs(y);
+ }, 0);
+ each(series.data, (row) => {
+ const x = hasX ? normalizeValue(row.x, options.xAxis.type) : `Slice ${index}`;
+ const y = cleanNumber(row.y);
+ labels.push(x);
+ values.push(y);
+ sourceData.set(x, {
+ x,
+ y,
+ yPercent: y / seriesTotal * 100,
+ row,
+ });
+ });
+
+ return {
+ visible: true,
+ values,
+ labels,
+ type: 'pie',
+ hole: 0.4,
+ marker: {
+ colors: map(series.data, row => getValueColor(row.x)),
+ },
+ hoverinfo: hoverInfoPattern,
+ text: [],
+ textinfo: options.showDataLabels ? 'percent' : 'none',
+ textposition: 'inside',
+ textfont: { color: '#ffffff' },
+ name: series.name,
+ direction: options.direction.type,
+ domain: {
+ x: [xPosition, xPosition + cellWidth - xPadding],
+ y: [yPosition, yPosition + cellHeight - yPadding],
+ },
+ sourceData,
+ };
+}
+
+export default function preparePieData(seriesList, options) {
+ // we will use this to assign colors for values that have no explicitly set color
+ const getDefaultColor = d3.scale.ordinal().domain([]).range(ColorPaletteArray);
+ const valuesColors = {};
+ each(options.valuesOptions, (item, key) => {
+ if (isString(item.color) && (item.color !== '')) {
+ valuesColors[key] = item.color;
+ }
+ });
+
+ const additionalOptions = {
+ ...getPieDimensions(seriesList),
+ hasX: includes(options.columnMapping, 'x'),
+ hoverInfoPattern: getPieHoverInfoPattern(options),
+ getValueColor: v => valuesColors[v] || getDefaultColor(v),
+ };
+
+ return map(seriesList, (series, index) => prepareSeries(series, options, { ...additionalOptions, index }));
+}
diff --git a/client/app/visualizations/chart/plotly/updateData.js b/client/app/visualizations/chart/plotly/updateData.js
new file mode 100644
index 0000000000..b91f043ebe
--- /dev/null
+++ b/client/app/visualizations/chart/plotly/updateData.js
@@ -0,0 +1,223 @@
+import { isNil, each, extend, filter, identity, includes, map, sortBy } from 'lodash';
+import { createNumberFormatter, formatSimpleTemplate } from '@/lib/value-format';
+import { normalizeValue } from './utils';
+
+function shouldUseUnifiedXAxis(options) {
+ return options.sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box');
+}
+
+function defaultFormatSeriesText(item) {
+ let result = item['@@y'];
+ if (item['@@yError'] !== undefined) {
+ result = `${result} \u00B1 ${item['@@yError']}`;
+ }
+ if (item['@@yPercent'] !== undefined) {
+ result = `${item['@@yPercent']} (${result})`;
+ }
+ if (item['@@size'] !== undefined) {
+ result = `${result}: ${item['@@size']}`;
+ }
+ return result;
+}
+
+function defaultFormatSeriesTextForPie(item) {
+ return item['@@yPercent'] + ' (' + item['@@y'] + ')';
+}
+
+function createTextFormatter(options) {
+ if (options.textFormat === '') {
+ return options.globalSeriesType === 'pie' ? defaultFormatSeriesTextForPie : defaultFormatSeriesText;
+ }
+ return item => formatSimpleTemplate(options.textFormat, item);
+}
+
+function formatValue(value, axis, options) {
+ let axisType = null;
+ switch (axis) {
+ case 'x': axisType = options.xAxis.type; break;
+ case 'y': axisType = options.yAxis[0].type; break;
+ case 'y2': axisType = options.yAxis[1].type; break;
+ // no default
+ }
+ return normalizeValue(value, axisType, options.dateTimeFormat);
+}
+
+function updateSeriesText(seriesList, options) {
+ const formatNumber = createNumberFormatter(options.numberFormat);
+ const formatPercent = createNumberFormatter(options.percentFormat);
+ const formatText = createTextFormatter(options);
+
+ const defaultY = options.missingValuesAsZero ? 0.0 : null;
+
+ each(seriesList, (series) => {
+ const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType };
+
+ series.text = [];
+ series.hover = [];
+ const xValues = (options.globalSeriesType === 'pie') ? series.labels : series.x;
+ xValues.forEach((x) => {
+ const text = {
+ '@@name': series.name,
+ };
+ const item = series.sourceData.get(x) || { x, y: defaultY, row: { x, y: defaultY } };
+
+ const yValueIsAny = includes(['bubble', 'scatter'], seriesOptions.type);
+
+ // for `formatValue` we have to use original value of `x` and `y`: `item.x`/`item.y` contains value
+ // already processed with `normalizeValue`, and if they were `moment` instances - they are formatted
+ // using default (ISO) date/time format. Here we need to use custom date/time format, so we pass original value
+ // to `formatValue` which will call `normalizeValue` again, but this time with different date/time format
+ // (if needed)
+ text['@@x'] = formatValue(item.row.x, 'x', options);
+ text['@@y'] = yValueIsAny ? formatValue(item.row.y, series.yaxis, options) : formatNumber(item.y);
+ if (item.yError !== undefined) {
+ text['@@yError'] = formatNumber(item.yError);
+ }
+ if (item.size !== undefined) {
+ text['@@size'] = formatNumber(item.size);
+ }
+
+ if (options.series.percentValues || (options.globalSeriesType === 'pie')) {
+ text['@@yPercent'] = formatPercent(Math.abs(item.yPercent));
+ }
+
+ extend(text, item.row.$raw);
+
+ series.text.push(formatText(text));
+ });
+ });
+}
+
+function updatePercentValues(seriesList, options) {
+ if (options.series.percentValues) {
+ // Some series may not have corresponding x-values;
+ // do calculations for each x only for series that do have that x
+ const sumOfCorrespondingPoints = new Map();
+ each(seriesList, (series) => {
+ series.sourceData.forEach((item) => {
+ const sum = sumOfCorrespondingPoints.get(item.x) || 0;
+ sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y || 0.0));
+ });
+ });
+
+ each(seriesList, (series) => {
+ const yValues = [];
+
+ series.sourceData.forEach((item) => {
+ if (isNil(item.y) && !options.missingValuesAsZero) {
+ item.yPercent = null;
+ } else {
+ const sum = sumOfCorrespondingPoints.get(item.x);
+ item.yPercent = item.y / sum * 100;
+ }
+ yValues.push(item.yPercent);
+ });
+
+ series.y = yValues;
+ });
+ }
+}
+
+function getUnifiedXAxisValues(seriesList, sorted) {
+ const set = new Set();
+ each(seriesList, (series) => {
+ // `Map.forEach` will walk items in insertion order
+ series.sourceData.forEach((item) => {
+ set.add(item.x);
+ });
+ });
+
+ const result = [...set];
+ return sorted ? sortBy(result, identity) : result;
+}
+
+function updateUnifiedXAxisValues(seriesList, options) {
+ const unifiedX = getUnifiedXAxisValues(seriesList, options.sortX);
+ const defaultY = options.missingValuesAsZero ? 0.0 : null;
+ each(seriesList, (series) => {
+ series.x = [];
+ series.y = [];
+ series.error_y.array = [];
+ each(unifiedX, (x) => {
+ series.x.push(x);
+ const item = series.sourceData.get(x);
+ if (item) {
+ series.y.push(options.series.percentValues ? item.yPercent : item.y);
+ series.error_y.array.push(item.yError);
+ } else {
+ series.y.push(defaultY);
+ series.error_y.array.push(null);
+ }
+ });
+ });
+}
+
+function updatePieData(seriesList, options) {
+ updateSeriesText(seriesList, options);
+}
+
+function updateLineAreaData(seriesList, options) {
+ // Apply "percent values" modification
+ updatePercentValues(seriesList, options);
+ if (options.series.stacking) {
+ updateUnifiedXAxisValues(seriesList, options);
+
+ // Calculate cumulative value for each x tick
+ const cumulativeValues = {};
+ each(seriesList, (series) => {
+ series.y = map(series.y, (y, i) => {
+ if (isNil(y) && !options.missingValuesAsZero) {
+ return null;
+ }
+ const x = series.x[i];
+ const stackedY = y + (cumulativeValues[x] || 0.0);
+ cumulativeValues[x] = stackedY;
+ return stackedY;
+ });
+ });
+ } else {
+ if (shouldUseUnifiedXAxis(options)) {
+ updateUnifiedXAxisValues(seriesList, options);
+ }
+ }
+
+ // Finally - update text labels
+ updateSeriesText(seriesList, options);
+}
+
+function updateDefaultData(seriesList, options) {
+ // Apply "percent values" modification
+ updatePercentValues(seriesList, options);
+
+ if (!options.series.stacking) {
+ if (shouldUseUnifiedXAxis(options)) {
+ updateUnifiedXAxisValues(seriesList, options);
+ }
+ }
+
+ // Finally - update text labels
+ updateSeriesText(seriesList, options);
+}
+
+export default function updateData(seriesList, options) {
+ // Use only visible series
+ const visibleSeriesList = filter(seriesList, s => s.visible === true);
+
+ if (visibleSeriesList.length > 0) {
+ switch (options.globalSeriesType) {
+ case 'pie':
+ updatePieData(visibleSeriesList, options);
+ break;
+ case 'line':
+ case 'area':
+ updateLineAreaData(visibleSeriesList, options);
+ break;
+ case 'heatmap':
+ break;
+ default:
+ updateDefaultData(visibleSeriesList, options);
+ break;
+ }
+ }
+ return seriesList;
+}
diff --git a/client/app/visualizations/chart/plotly/utils.js b/client/app/visualizations/chart/plotly/utils.js
index 0c02734baf..006339c46f 100644
--- a/client/app/visualizations/chart/plotly/utils.js
+++ b/client/app/visualizations/chart/plotly/utils.js
@@ -1,80 +1,17 @@
-import {
- isArray, isNumber, isString, isUndefined, includes, min, max, has, find,
- each, values, sortBy, identity, filter, map, extend, reduce, pick, flatten, uniq,
-} from 'lodash';
+import { isUndefined } from 'lodash';
import moment from 'moment';
-import d3 from 'd3';
import plotlyCleanNumber from 'plotly.js/src/lib/clean_number';
-import { createFormatter, formatSimpleTemplate } from '@/lib/value-format';
-import { ColorPaletteArray } from '@/visualizations/ColorPalette';
-function cleanNumber(value) {
- return isUndefined(value) ? value : (plotlyCleanNumber(value) || 0.0);
+export function cleanNumber(value) {
+ return isUndefined(value) ? value : plotlyCleanNumber(value);
}
-function defaultFormatSeriesText(item) {
- let result = item['@@y'];
- if (item['@@yError'] !== undefined) {
- result = `${result} \u00B1 ${item['@@yError']}`;
+export function getSeriesAxis(series, options) {
+ const seriesOptions = options.seriesOptions[series.name] || { type: options.globalSeriesType };
+ if ((seriesOptions.yAxis === 1) && (!options.series.stacking || (seriesOptions.type === 'line'))) {
+ return 'y2';
}
- if (item['@@yPercent'] !== undefined) {
- result = `${item['@@yPercent']} (${result})`;
- }
- if (item['@@size'] !== undefined) {
- result = `${result}: ${item['@@size']}`;
- }
- return result;
-}
-
-function defaultFormatSeriesTextForPie(item) {
- return item['@@yPercent'] + ' (' + item['@@y'] + ')';
-}
-
-function getFontColor(bgcolor) {
- let result = '#333333';
- if (isString(bgcolor)) {
- let matches = /#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i.exec(bgcolor);
- let r;
- let g;
- let b;
- if (matches) {
- r = parseInt(matches[1], 16);
- g = parseInt(matches[2], 16);
- b = parseInt(matches[3], 16);
- } else {
- matches = /#?([0-9a-f])([0-9a-f])([0-9a-f])/i.exec(bgcolor);
- if (matches) {
- r = parseInt(matches[1] + matches[1], 16);
- g = parseInt(matches[2] + matches[2], 16);
- b = parseInt(matches[3] + matches[3], 16);
- } else {
- return result;
- }
- }
-
- const lightness = r * 0.299 + g * 0.587 + b * 0.114;
- if (lightness < 170) {
- result = '#ffffff';
- }
- }
-
- return result;
-}
-
-function getPieHoverInfoPattern(options) {
- const hasX = /{{\s*@@x\s*}}/.test(options.textFormat);
- let result = 'text';
- if (!hasX) result += '+label';
- return result;
-}
-
-function getHoverInfoPattern(options) {
- const hasX = /{{\s*@@x\s*}}/.test(options.textFormat);
- const hasName = /{{\s*@@name\s*}}/.test(options.textFormat);
- let result = 'text';
- if (!hasX) result += '+x';
- if (!hasName) result += '+name';
- return result;
+ return 'y';
}
export function normalizeValue(value, axisType, dateTimeFormat = 'YYYY-MM-DD HH:mm:ss') {
@@ -86,743 +23,3 @@ export function normalizeValue(value, axisType, dateTimeFormat = 'YYYY-MM-DD HH:
}
return value;
}
-
-function naturalSort($a, $b) {
- if ($a === $b) {
- return 0;
- } else if ($a < $b) {
- return -1;
- }
- return 1;
-}
-
-function calculateAxisRange(seriesList, minValue, maxValue) {
- if (!isNumber(minValue)) {
- minValue = Math.min(0, min(map(seriesList, series => min(series.y))));
- }
- if (!isNumber(maxValue)) {
- maxValue = max(map(seriesList, series => max(series.y)));
- }
- return [minValue, maxValue];
-}
-
-function getScaleType(scale) {
- if (scale === 'datetime') {
- return 'date';
- }
- if (scale === 'logarithmic') {
- return 'log';
- }
- return scale;
-}
-
-function getSeriesColor(seriesOptions, seriesIndex) {
- return seriesOptions.color || ColorPaletteArray[seriesIndex % ColorPaletteArray.length];
-}
-
-function getTitle(axis) {
- if (!isUndefined(axis) && !isUndefined(axis.title)) {
- return axis.title.text;
- }
- return null;
-}
-
-function setType(series, type, options) {
- switch (type) {
- case 'column':
- series.type = 'bar';
- if (options.showDataLabels) {
- series.textposition = 'inside';
- }
- break;
- case 'line':
- series.mode = 'lines' + (options.showDataLabels ? '+text' : '');
- break;
- case 'area':
- series.mode = 'lines' + (options.showDataLabels ? '+text' : '');
- series.fill = options.series.stacking === null ? 'tozeroy' : 'tonexty';
- break;
- case 'scatter':
- series.type = 'scatter';
- series.mode = 'markers' + (options.showDataLabels ? '+text' : '');
- break;
- case 'bubble':
- series.mode = 'markers';
- break;
- case 'box':
- series.type = 'box';
- series.mode = 'markers';
- break;
- default:
- break;
- }
-}
-
-function calculateDimensions(series, options) {
- const rows = series.length > 2 ? 2 : 1;
- const cellsInRow = Math.ceil(series.length / rows);
- const cellWidth = 1 / cellsInRow;
- const cellHeight = 1 / rows;
- const xPadding = 0.02;
- const yPadding = 0.1;
-
- const hasX = includes(values(options.columnMapping), 'x');
- const hasY2 = !!find(series, (serie) => {
- const seriesOptions = options.seriesOptions[serie.name] || { type: options.globalSeriesType };
- return (seriesOptions.yAxis === 1) && (
- (options.series.stacking === null) || (seriesOptions.type === 'line')
- );
- });
-
- return {
- rows, cellsInRow, cellWidth, cellHeight, xPadding, yPadding, hasX, hasY2,
- };
-}
-
-function getUnifiedXAxisValues(seriesList, sorted) {
- const set = new Set();
- each(seriesList, (series) => {
- // `Map.forEach` will walk items in insertion order
- series.sourceData.forEach((item) => {
- set.add(item.x);
- });
- });
-
- const result = [];
- // `Set.forEach` will walk items in insertion order
- set.forEach((item) => {
- result.push(item);
- });
-
- return sorted ? sortBy(result, identity) : result;
-}
-
-function preparePieData(seriesList, options) {
- const {
- cellWidth, cellHeight, xPadding, yPadding, cellsInRow, hasX,
- } = calculateDimensions(seriesList, options);
-
- const formatNumber = createFormatter({
- displayAs: 'number',
- numberFormat: options.numberFormat,
- });
- const formatPercent = createFormatter({
- displayAs: 'number',
- numberFormat: options.percentFormat,
- });
- const formatText = options.textFormat === ''
- ? defaultFormatSeriesTextForPie :
- item => formatSimpleTemplate(options.textFormat, item);
-
- const hoverinfo = getPieHoverInfoPattern(options);
-
- // we will use this to assign colors for values that have not explicitly set color
- const getDefaultColor = d3.scale.ordinal().domain([]).range(ColorPaletteArray);
- const valuesColors = {};
- each(options.valuesOptions, (item, key) => {
- if (isString(item.color) && (item.color !== '')) {
- valuesColors[key] = item.color;
- }
- });
-
- return map(seriesList, (serie, index) => {
- const xPosition = (index % cellsInRow) * cellWidth;
- const yPosition = Math.floor(index / cellsInRow) * cellHeight;
-
- const sourceData = new Map();
- const seriesTotal = reduce(serie.data, (result, row) => {
- const y = cleanNumber(row.y);
- return result + Math.abs(y);
- }, 0);
- each(serie.data, (row) => {
- const x = normalizeValue(row.x);
- const y = cleanNumber(row.y);
- sourceData.set(x, {
- x,
- y,
- yPercent: y / seriesTotal * 100,
- raw: extend({}, row.$raw, {
- // use custom display format - see also `updateSeriesText`
- '@@x': normalizeValue(row.x, options.xAxis.type, options.dateTimeFormat),
- }),
- });
- });
-
- return {
- values: map(serie.data, i => i.y),
- labels: map(serie.data, row => (hasX ? normalizeValue(row.x) : `Slice ${index}`)),
- type: 'pie',
- hole: 0.4,
- marker: {
- colors: map(serie.data, row => valuesColors[row.x] || getDefaultColor(row.x)),
- },
- hoverinfo,
- text: [],
- textinfo: options.showDataLabels ? 'percent' : 'none',
- textposition: 'inside',
- textfont: { color: '#ffffff' },
- name: serie.name,
- direction: options.direction.type,
- domain: {
- x: [xPosition, xPosition + cellWidth - xPadding],
- y: [yPosition, yPosition + cellHeight - yPadding],
- },
- sourceData,
- formatNumber,
- formatPercent,
- formatText,
- };
- });
-}
-
-function prepareHeatmapData(seriesList, options) {
- const defaultColorScheme = [
- [0, '#356aff'],
- [0.14, '#4a7aff'],
- [0.28, '#5d87ff'],
- [0.42, '#7398ff'],
- [0.56, '#fb8c8c'],
- [0.71, '#ec6463'],
- [0.86, '#ec4949'],
- [1, '#e92827'],
- ];
-
- const formatNumber = createFormatter({
- displayAs: 'number',
- numberFormat: options.numberFormat,
- });
-
- let colorScheme = [];
-
- if (!options.colorScheme) {
- colorScheme = defaultColorScheme;
- } else if (options.colorScheme === 'Custom...') {
- colorScheme = [[0, options.heatMinColor], [1, options.heatMaxColor]];
- } else {
- colorScheme = options.colorScheme;
- }
-
- return map(seriesList, (series) => {
- const plotlySeries = {
- x: [],
- y: [],
- z: [],
- type: 'heatmap',
- name: '',
- colorscale: colorScheme,
- };
-
- plotlySeries.x = uniq(map(series.data, 'x'));
- plotlySeries.y = uniq(map(series.data, 'y'));
-
- if (options.sortX) {
- plotlySeries.x.sort(naturalSort);
- }
-
- if (options.sortY) {
- plotlySeries.y.sort(naturalSort);
- }
-
- if (options.reverseX) {
- plotlySeries.x.reverse();
- }
-
- if (options.reverseY) {
- plotlySeries.y.reverse();
- }
-
- const zMax = max(map(series.data, 'zVal'));
-
- // Use text trace instead of default annotation for better performance
- const dataLabels = {
- x: [],
- y: [],
- mode: 'text',
- hoverinfo: 'skip',
- showlegend: false,
- text: [],
- textfont: {
- color: [],
- },
- };
-
- for (let i = 0; i < plotlySeries.y.length; i += 1) {
- const item = [];
- for (let j = 0; j < plotlySeries.x.length; j += 1) {
- const datum = find(
- series.data,
- { x: plotlySeries.x[j], y: plotlySeries.y[i] },
- );
-
- const zValue = datum ? datum.zVal : 0;
- item.push(zValue);
-
- if (isFinite(zMax) && options.showDataLabels) {
- dataLabels.x.push(plotlySeries.x[j]);
- dataLabels.y.push(plotlySeries.y[i]);
- dataLabels.text.push(formatNumber(zValue));
- if (options.colorScheme && options.colorScheme === 'Custom...') {
- dataLabels.textfont.color.push('white');
- } else {
- dataLabels.textfont.color.push((zValue / zMax) < 0.25 ? 'white' : 'black');
- }
- }
- }
- plotlySeries.z.push(item);
- }
-
- if (isFinite(zMax) && options.showDataLabels) {
- return [plotlySeries, dataLabels];
- }
- return [plotlySeries];
- });
-}
-
-function prepareChartData(seriesList, options) {
- const sortX = (options.sortX === true) || (options.sortX === undefined);
-
- const formatNumber = createFormatter({
- displayAs: 'number',
- numberFormat: options.numberFormat,
- });
- const formatPercent = createFormatter({
- displayAs: 'number',
- numberFormat: options.percentFormat,
- });
- const formatText = options.textFormat === ''
- ? defaultFormatSeriesText :
- item => formatSimpleTemplate(options.textFormat, item);
-
- const hoverinfo = getHoverInfoPattern(options);
-
- return map(seriesList, (series, index) => {
- const seriesOptions = options.seriesOptions[series.name] ||
- { type: options.globalSeriesType };
-
- const seriesColor = getSeriesColor(seriesOptions, index);
-
- // Sort by x - `Map` preserves order of items
- const data = sortX ? sortBy(series.data, d => normalizeValue(d.x, options.xAxis.type)) : series.data;
-
- // For bubble/scatter charts `y` may be any (similar to `x`) - numeric is only bubble size;
- // for other types `y` is always number
- const cleanYValue = includes(['bubble', 'scatter'], seriesOptions.type) ? normalizeValue : cleanNumber;
-
- const sourceData = new Map();
- const xValues = [];
- const yValues = [];
- const yErrorValues = [];
- each(data, (row) => {
- const x = normalizeValue(row.x, options.xAxis.type); // number/datetime/category
- const y = cleanYValue(row.y, options.yAxis[0].type); // depends on series type!
- const yError = cleanNumber(row.yError); // always number
- const size = cleanNumber(row.size); // always number
- sourceData.set(x, {
- x,
- y,
- yError,
- size,
- yPercent: null, // will be updated later
- raw: extend({}, row.$raw, {
- // use custom display format - see also `updateSeriesText`
- '@@x': normalizeValue(row.x, options.xAxis.type, options.dateTimeFormat),
- }),
- });
- xValues.push(x);
- yValues.push(y);
- yErrorValues.push(yError);
- });
-
- const plotlySeries = {
- visible: true,
- hoverinfo,
- x: xValues,
- y: yValues,
- error_y: {
- array: yErrorValues,
- color: seriesColor,
- },
- name: seriesOptions.name || series.name,
- marker: { color: seriesColor },
- insidetextfont: {
- color: getFontColor(seriesColor),
- },
- sourceData,
- formatNumber,
- formatPercent,
- formatText,
- };
-
- if (
- (seriesOptions.yAxis === 1) &&
- ((options.series.stacking === null) || (seriesOptions.type === 'line'))
- ) {
- plotlySeries.yaxis = 'y2';
- }
-
- setType(plotlySeries, seriesOptions.type, options);
-
- if (seriesOptions.type === 'bubble') {
- plotlySeries.marker = {
- color: seriesColor,
- size: map(data, i => i.size),
- };
- } else if (seriesOptions.type === 'box') {
- plotlySeries.boxpoints = 'outliers';
- plotlySeries.hoverinfo = false;
- plotlySeries.marker = {
- color: seriesColor,
- size: 3,
- };
- if (options.showpoints) {
- plotlySeries.boxpoints = 'all';
- plotlySeries.jitter = 0.3;
- plotlySeries.pointpos = -1.8;
- }
- }
-
- return plotlySeries;
- });
-}
-
-export function prepareData(seriesList, options) {
- if (options.globalSeriesType === 'pie') {
- return preparePieData(seriesList, options);
- }
- if (options.globalSeriesType === 'heatmap') {
- return flatten(prepareHeatmapData(seriesList, options));
- }
- return prepareChartData(seriesList, options);
-}
-
-export function prepareLayout(element, seriesList, options, data) {
- const {
- cellsInRow, cellWidth, cellHeight, xPadding, hasY2,
- } = calculateDimensions(seriesList, options);
-
- const result = {
- margin: {
- l: 10,
- r: 10,
- b: 10,
- t: 25,
- pad: 4,
- },
- width: Math.floor(element.offsetWidth),
- height: Math.floor(element.offsetHeight),
- autosize: true,
- showlegend: has(options, 'legend') ? options.legend.enabled : true,
- };
-
- if (options.globalSeriesType === 'pie') {
- const hasName = /{{\s*@@name\s*}}/.test(options.textFormat);
-
- if (hasName) {
- result.annotations = [];
- } else {
- result.annotations = filter(map(seriesList, (series, index) => {
- const xPosition = (index % cellsInRow) * cellWidth;
- const yPosition = Math.floor(index / cellsInRow) * cellHeight;
- return {
- x: xPosition + ((cellWidth - xPadding) / 2),
- y: yPosition + cellHeight - 0.015,
- xanchor: 'center',
- yanchor: 'top',
- text: (options.seriesOptions[series.name] || {}).name || series.name,
- showarrow: false,
- };
- }));
- }
- } else {
- if (options.globalSeriesType === 'box') {
- result.boxmode = 'group';
- result.boxgroupgap = 0.50;
- }
-
- result.xaxis = {
- title: getTitle(options.xAxis),
- type: getScaleType(options.xAxis.type),
- automargin: true,
- };
-
- if (options.sortX && result.xaxis.type === 'category') {
- if (options.reverseX) {
- result.xaxis.categoryorder = 'category descending';
- } else {
- result.xaxis.categoryorder = 'category ascending';
- }
- }
-
- if (!isUndefined(options.xAxis.labels)) {
- result.xaxis.showticklabels = options.xAxis.labels.enabled;
- }
-
- if (isArray(options.yAxis)) {
- result.yaxis = {
- title: getTitle(options.yAxis[0]),
- type: getScaleType(options.yAxis[0].type),
- automargin: true,
- };
-
- if (isNumber(options.yAxis[0].rangeMin) || isNumber(options.yAxis[0].rangeMax)) {
- result.yaxis.range = calculateAxisRange(
- data.filter(s => !s.yaxis !== 'y2'),
- options.yAxis[0].rangeMin,
- options.yAxis[0].rangeMax,
- );
- }
- }
-
- if (hasY2 && !isUndefined(options.yAxis)) {
- result.yaxis2 = {
- title: getTitle(options.yAxis[1]),
- type: getScaleType(options.yAxis[1].type),
- overlaying: 'y',
- side: 'right',
- automargin: true,
- };
-
- if (isNumber(options.yAxis[1].rangeMin) || isNumber(options.yAxis[1].rangeMax)) {
- result.yaxis2.range = calculateAxisRange(
- data.filter(s => s.yaxis === 'y2'),
- options.yAxis[1].rangeMin,
- options.yAxis[1].rangeMax,
- );
- }
- }
-
- if (options.series.stacking) {
- result.barmode = 'relative';
- }
- }
-
- return result;
-}
-
-function updateSeriesText(seriesList, options) {
- each(seriesList, (series) => {
- const seriesOptions = options.seriesOptions[series.name] ||
- { type: options.globalSeriesType };
-
- series.text = [];
- series.hover = [];
- const xValues = (options.globalSeriesType === 'pie') ? series.labels : series.x;
- xValues.forEach((x) => {
- const text = {
- '@@name': series.name,
- // '@@x' is already in `item.$raw`
- };
- const item = series.sourceData.get(x);
- if (item) {
- text['@@y'] = includes(['bubble', 'scatter'], seriesOptions.type) ? item.y : series.formatNumber(item.y);
- if (item.yError !== undefined) {
- text['@@yError'] = series.formatNumber(item.yError);
- }
- if (item.size !== undefined) {
- text['@@size'] = series.formatNumber(item.size);
- }
-
- if (options.series.percentValues || (options.globalSeriesType === 'pie')) {
- text['@@yPercent'] = series.formatPercent(Math.abs(item.yPercent));
- }
-
- extend(text, item.raw);
- }
-
- series.text.push(series.formatText(text));
- });
- });
- return seriesList;
-}
-
-function updatePercentValues(seriesList, options) {
- if (options.series.percentValues && (seriesList.length > 0)) {
- // Some series may not have corresponding x-values;
- // do calculations for each x only for series that do have that x
- const sumOfCorrespondingPoints = new Map();
- each(seriesList, (series) => {
- series.sourceData.forEach((item) => {
- const sum = sumOfCorrespondingPoints.get(item.x) || 0;
- sumOfCorrespondingPoints.set(item.x, sum + Math.abs(item.y));
- });
- });
-
- each(seriesList, (series) => {
- const yValues = [];
-
- series.sourceData.forEach((item) => {
- const sum = sumOfCorrespondingPoints.get(item.x);
- item.yPercent = Math.sign(item.y) * Math.abs(item.y) / sum * 100;
- yValues.push(item.yPercent);
- });
-
- series.y = yValues;
- });
- }
-
- return seriesList;
-}
-
-function updateUnifiedXAxisValues(seriesList, options, sorted, defaultY) {
- const unifiedX = getUnifiedXAxisValues(seriesList, sorted);
- defaultY = defaultY === undefined ? null : defaultY;
- each(seriesList, (series) => {
- series.x = [];
- series.y = [];
- series.error_y.array = [];
- each(unifiedX, (x) => {
- series.x.push(x);
- const item = series.sourceData.get(x);
- if (item) {
- series.y.push(options.series.percentValues ? item.yPercent : item.y);
- series.error_y.array.push(item.yError);
- } else {
- series.y.push(defaultY);
- series.error_y.array.push(null);
- }
- });
- });
-}
-
-export function updateData(seriesList, options) {
- if (seriesList.length === 0) {
- return seriesList;
- }
- if (options.globalSeriesType === 'pie') {
- updateSeriesText(seriesList, options);
- return seriesList;
- }
- if (options.globalSeriesType === 'heatmap') {
- return seriesList;
- }
-
- // Use only visible series
- seriesList = filter(seriesList, s => s.visible === true);
-
- // Apply "percent values" modification
- updatePercentValues(seriesList, options);
-
- const sortX = (options.sortX === true) || (options.sortX === undefined);
-
- if (options.series.stacking) {
- if (['line', 'area'].indexOf(options.globalSeriesType) >= 0) {
- updateUnifiedXAxisValues(seriesList, options, sortX, 0);
-
- // Calculate cumulative value for each x tick
- let prevSeries = null;
- each(seriesList, (series) => {
- if (prevSeries) {
- series.y = map(series.y, (y, i) => prevSeries.y[i] + y);
- }
- prevSeries = series;
- });
- }
- } else {
- const useUnifiedXAxis = sortX && (options.xAxis.type === 'category') && (options.globalSeriesType !== 'box');
- if (useUnifiedXAxis) {
- updateUnifiedXAxisValues(seriesList, options, sortX);
- }
- }
-
- // Finally - update text labels
- updateSeriesText(seriesList, options);
-}
-
-function fixLegendContainer(plotlyElement) {
- const legend = plotlyElement.querySelector('.legend');
- if (legend) {
- let node = legend.parentNode;
- while (node) {
- if (node.tagName.toLowerCase() === 'svg') {
- node.style.overflow = 'visible';
- break;
- }
- node = node.parentNode;
- }
- }
-}
-
-export function updateLayout(plotlyElement, layout, updatePlot) {
- // update layout size to plot container
- layout.width = Math.floor(plotlyElement.offsetWidth);
- layout.height = Math.floor(plotlyElement.offsetHeight);
-
- const transformName = find([
- 'transform',
- 'WebkitTransform',
- 'MozTransform',
- 'MsTransform',
- 'OTransform',
- ], prop => prop in plotlyElement.style);
-
- if (layout.width <= 600) {
- // change legend orientation to horizontal; plotly has a bug with this
- // legend alignment - it does not preserve enough space under the plot;
- // so we'll hack this: update plot (it will re-render legend), compute
- // legend height, reduce plot size by legend height (but not less than
- // half of plot container's height - legend will have max height equal to
- // plot height), re-render plot again and offset legend to the space under
- // the plot.
- layout.legend = {
- orientation: 'h',
- // locate legend inside of plot area - otherwise plotly will preserve
- // some amount of space under the plot; also this will limit legend height
- // to plot's height
- y: 0,
- x: 0,
- xanchor: 'left',
- yanchor: 'bottom',
- };
-
- // set `overflow: visible` to svg containing legend because later we will
- // position legend outside of it
- fixLegendContainer(plotlyElement);
-
- updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend'])).then(() => {
- const legend = plotlyElement.querySelector('.legend'); // eslint-disable-line no-shadow
- if (legend) {
- // compute real height of legend - items may be split into few columnns,
- // also scrollbar may be shown
- const bounds = reduce(legend.querySelectorAll('.traces'), (result, node) => {
- const b = node.getBoundingClientRect();
- result = result || b;
- return {
- top: Math.min(result.top, b.top),
- bottom: Math.max(result.bottom, b.bottom),
- };
- }, null);
- // here we have two values:
- // 1. height of plot container excluding height of legend items;
- // it may be any value between 0 and plot container's height;
- // 2. half of plot containers height. Legend cannot be larger than
- // plot; if legend is too large, plotly will reduce it's height and
- // show a scrollbar; in this case, height of plot === height of legend,
- // so we can split container's height half by half between them.
- layout.height = Math.floor(Math.max(
- layout.height / 2,
- layout.height - (bounds.bottom - bounds.top),
- ));
- // offset the legend
- legend.style[transformName] = 'translate(0, ' + layout.height + 'px)';
- updatePlot(plotlyElement, pick(layout, ['height']));
- }
- });
- } else {
- layout.legend = {
- orientation: 'v',
- // vertical legend will be rendered properly, so just place it to the right
- // side of plot
- y: 1,
- x: 1,
- xanchor: 'left',
- yanchor: 'top',
- };
-
- const legend = plotlyElement.querySelector('.legend');
- if (legend) {
- legend.style[transformName] = null;
- }
-
- updatePlot(plotlyElement, pick(layout, ['width', 'height', 'legend']));
- }
-}
diff --git a/client/app/visualizations/choropleth/utils.js b/client/app/visualizations/choropleth/utils.js
index 15b3e80544..4b65171e59 100644
--- a/client/app/visualizations/choropleth/utils.js
+++ b/client/app/visualizations/choropleth/utils.js
@@ -1,6 +1,6 @@
import chroma from 'chroma-js';
import _ from 'lodash';
-import { createFormatter } from '@/lib/value-format';
+import { createNumberFormatter as createFormatter } from '@/lib/value-format';
export const AdditionalColors = {
White: '#ffffff',
@@ -13,10 +13,7 @@ export function darkenColor(color) {
}
export function createNumberFormatter(format, placeholder) {
- const formatter = createFormatter({
- displayAs: 'number',
- numberFormat: format,
- });
+ const formatter = createFormatter(format);
return (value) => {
if (_.isNumber(value) && isFinite(value)) {
return formatter(value);
diff --git a/client/app/visualizations/counter/Editor/FormatSettings.jsx b/client/app/visualizations/counter/Editor/FormatSettings.jsx
new file mode 100644
index 0000000000..144a1e3566
--- /dev/null
+++ b/client/app/visualizations/counter/Editor/FormatSettings.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import * as Grid from 'antd/lib/grid';
+import Input from 'antd/lib/input';
+import InputNumber from 'antd/lib/input-number';
+import Switch from 'antd/lib/switch';
+import { EditorPropTypes } from '@/visualizations';
+
+import { isValueNumber } from '../utils';
+
+export default function FormatSettings({ options, data, onOptionsChange }) {
+ const inputsEnabled = isValueNumber(data.rows, options);
+ return (
+
+
+
+ Formatting Decimal Place
+
+
+ onOptionsChange({ stringDecimal })}
+ />
+
+
+
+
+
+ Formatting Decimal Character
+
+
+ onOptionsChange({ stringDecChar: e.target.value })}
+ />
+
+
+
+
+
+ Formatting Thousands Separator
+
+
+ onOptionsChange({ stringThouSep: e.target.value })}
+ />
+
+
+
+
+
+ Formatting String Prefix
+
+
+ onOptionsChange({ stringPrefix: e.target.value })}
+ />
+
+
+
+
+
+ Formatting String Suffix
+
+
+ onOptionsChange({ stringSuffix: e.target.value })}
+ />
+
+
+
+
+ onOptionsChange({ formatTargetValue })}
+ />
+ Format Target Value
+
+
+ );
+}
+
+FormatSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/counter/Editor/GeneralSettings.jsx b/client/app/visualizations/counter/Editor/GeneralSettings.jsx
new file mode 100644
index 0000000000..554a588bc9
--- /dev/null
+++ b/client/app/visualizations/counter/Editor/GeneralSettings.jsx
@@ -0,0 +1,113 @@
+import { map } from 'lodash';
+import React from 'react';
+import * as Grid from 'antd/lib/grid';
+import Select from 'antd/lib/select';
+import Input from 'antd/lib/input';
+import InputNumber from 'antd/lib/input-number';
+import Switch from 'antd/lib/switch';
+import { EditorPropTypes } from '@/visualizations';
+
+export default function GeneralSettings({ options, data, visualizationName, onOptionsChange }) {
+ return (
+
+
+
+ Counter Label
+
+
+ onOptionsChange({ counterLabel: e.target.value })}
+ />
+
+
+
+
+
+ Counter Value Column Name
+
+
+ onOptionsChange({ counterColName })}
+ >
+ {map(data.columns, col => (
+ {col.name}
+ ))}
+
+
+
+
+
+
+ Counter Value Row Number
+
+
+ onOptionsChange({ rowNumber })}
+ />
+
+
+
+
+
+ Target Value Column Name
+
+
+ onOptionsChange({ targetColName })}
+ >
+ No target value
+ {map(data.columns, col => (
+ {col.name}
+ ))}
+
+
+
+
+
+
+ Target Value Row Number
+
+
+ onOptionsChange({ targetRowNumber })}
+ />
+
+
+
+
+ onOptionsChange({ countRow })}
+ />
+ Count Rows
+
+
+ );
+}
+
+GeneralSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/counter/Editor/index.jsx b/client/app/visualizations/counter/Editor/index.jsx
new file mode 100644
index 0000000000..e897a56672
--- /dev/null
+++ b/client/app/visualizations/counter/Editor/index.jsx
@@ -0,0 +1,28 @@
+import { merge } from 'lodash';
+import React from 'react';
+import Tabs from 'antd/lib/tabs';
+import { EditorPropTypes } from '@/visualizations';
+
+import GeneralSettings from './GeneralSettings';
+import FormatSettings from './FormatSettings';
+
+export default function Editor(props) {
+ const { options, onOptionsChange } = props;
+
+ const optionsChanged = (newOptions) => {
+ onOptionsChange(merge({}, options, newOptions));
+ };
+
+ return (
+
+ General}>
+
+
+ Format}>
+
+
+
+ );
+}
+
+Editor.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/counter/Renderer.jsx b/client/app/visualizations/counter/Renderer.jsx
new file mode 100644
index 0000000000..417ef24edb
--- /dev/null
+++ b/client/app/visualizations/counter/Renderer.jsx
@@ -0,0 +1,73 @@
+import { isFinite } from 'lodash';
+import React, { useState, useEffect } from 'react';
+import cx from 'classnames';
+import resizeObserver from '@/services/resizeObserver';
+import { RendererPropTypes } from '@/visualizations';
+
+import { getCounterData } from './utils';
+
+import './render.less';
+
+function getCounterStyles(scale) {
+ return {
+ msTransform: `scale(${scale})`,
+ MozTransform: `scale(${scale})`,
+ WebkitTransform: `scale(${scale})`,
+ transform: `scale(${scale})`,
+ };
+}
+
+function getCounterScale(container) {
+ const inner = container.firstChild;
+ const scale = Math.min(container.offsetWidth / inner.offsetWidth, container.offsetHeight / inner.offsetHeight);
+ return Number(isFinite(scale) ? scale : 1).toFixed(2); // keep only two decimal places
+}
+
+export default function Renderer({ data, options, visualizationName }) {
+ const [scale, setScale] = useState('1.00');
+ const [container, setContainer] = useState(null);
+
+ useEffect(() => {
+ if (container) {
+ const unwatch = resizeObserver(container, () => {
+ setScale(getCounterScale(container));
+ });
+ return unwatch;
+ }
+ }, [container]);
+
+ useEffect(() => {
+ if (container) {
+ // update scaling when options or data change (new formatting, values, etc.
+ // may change inner container dimensions which will not be tracked by `resizeObserver`);
+ setScale(getCounterScale(container));
+ }
+ }, [data, options, container]);
+
+ const {
+ showTrend, trendPositive,
+ counterValue, counterValueTooltip,
+ targetValue, targetValueTooltip,
+ counterLabel,
+ } = getCounterData(data.rows, options, visualizationName);
+ return (
+
+
+
+
{counterValue}
+ {targetValue && (
+
({targetValue})
+ )}
+
{counterLabel}
+
+
+
+ );
+}
+
+Renderer.propTypes = RendererPropTypes;
diff --git a/client/app/visualizations/counter/counter-editor.html b/client/app/visualizations/counter/counter-editor.html
deleted file mode 100644
index f47238c4ac..0000000000
--- a/client/app/visualizations/counter/counter-editor.html
+++ /dev/null
@@ -1,95 +0,0 @@
-
diff --git a/client/app/visualizations/counter/counter.html b/client/app/visualizations/counter/counter.html
deleted file mode 100644
index d7493f4378..0000000000
--- a/client/app/visualizations/counter/counter.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
- {{ counterValue }}
- ({{ targetValue }})
- {{counterLabel}}
-
-
diff --git a/client/app/visualizations/counter/index.js b/client/app/visualizations/counter/index.js
index 2ece76b2b2..07bd18af5d 100644
--- a/client/app/visualizations/counter/index.js
+++ b/client/app/visualizations/counter/index.js
@@ -1,10 +1,7 @@
-import { isNumber, toString } from 'lodash';
-import numeral from 'numeral';
-import { angular2react } from 'angular2react';
import { registerVisualization } from '@/visualizations';
-import counterTemplate from './counter.html';
-import counterEditorTemplate from './counter-editor.html';
+import Renderer from './Renderer';
+import Editor from './Editor';
const DEFAULT_OPTIONS = {
counterLabel: '',
@@ -17,207 +14,16 @@ const DEFAULT_OPTIONS = {
tooltipFormat: '0,0.000', // TODO: Show in editor
};
-// TODO: allow user to specify number format string instead of delimiters only
-// It will allow to remove this function (move all that weird formatting logic to a migration
-// that will set number format for all existing counter visualization)
-function numberFormat(value, decimalPoints, decimalDelimiter, thousandsDelimiter) {
- // Temporarily update locale data (restore defaults after formatting)
- const locale = numeral.localeData();
- const savedDelimiters = locale.delimiters;
+export default function init() {
+ registerVisualization({
+ type: 'COUNTER',
+ name: 'Counter',
+ getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }),
+ Renderer,
+ Editor,
- // Mimic old behavior - AngularJS `number` filter defaults:
- // - `,` as thousands delimiter
- // - `.` as decimal delimiter
- // - three decimal points
- locale.delimiters = {
- thousands: ',',
- decimal: '.',
- };
- let formatString = '0,0.000';
- if (
- (Number.isFinite(decimalPoints) && (decimalPoints >= 0)) ||
- decimalDelimiter ||
- thousandsDelimiter
- ) {
- locale.delimiters = {
- thousands: thousandsDelimiter,
- decimal: decimalDelimiter || '.',
- };
-
- formatString = '0,0';
- if (decimalPoints > 0) {
- formatString += '.';
- while (decimalPoints > 0) {
- formatString += '0';
- decimalPoints -= 1;
- }
- }
- }
- const result = numeral(value).format(formatString);
-
- locale.delimiters = savedDelimiters;
- return result;
-}
-
-// TODO: Need to review this function, it does not properly handle edge cases.
-function getRowNumber(index, size) {
- if (index >= 0) {
- return index - 1;
- }
-
- if (Math.abs(index) > size) {
- index %= size;
- }
-
- return size + index;
-}
-
-function formatValue(value, { stringPrefix, stringSuffix, stringDecimal, stringDecChar, stringThouSep }) {
- if (isNumber(value)) {
- value = numberFormat(value, stringDecimal, stringDecChar, stringThouSep);
- return toString(stringPrefix) + value + toString(stringSuffix);
- }
- return toString(value);
-}
-
-function formatTooltip(value, formatString) {
- if (isNumber(value)) {
- return numeral(value).format(formatString);
- }
- return toString(value);
-}
-
-const CounterRenderer = {
- template: counterTemplate,
- bindings: {
- data: '<',
- options: '<',
- visualizationName: '<',
- },
- controller($scope, $element, $timeout) {
- $scope.fontSize = '1em';
-
- $scope.scale = 1;
- const root = $element[0].querySelector('counter');
- const container = $element[0].querySelector('counter > div');
- $scope.handleResize = () => {
- const scale = Math.min(root.offsetWidth / container.offsetWidth, root.offsetHeight / container.offsetHeight);
- $scope.scale = Math.floor(scale * 100) / 100; // keep only two decimal places
- };
-
- const update = () => {
- const options = this.options;
- const data = this.data.rows;
-
- if (data.length > 0) {
- const rowNumber = getRowNumber(options.rowNumber, data.length);
- const targetRowNumber = getRowNumber(options.targetRowNumber, data.length);
- const counterColName = options.counterColName;
- const targetColName = options.targetColName;
- const counterLabel = options.counterLabel;
-
- if (counterLabel) {
- $scope.counterLabel = counterLabel;
- } else {
- $scope.counterLabel = this.visualizationName;
- }
-
- if (options.countRow) {
- $scope.counterValue = data.length;
- } else if (counterColName) {
- $scope.counterValue = data[rowNumber][counterColName];
- }
-
- $scope.showTrend = false;
- if (targetColName) {
- $scope.targetValue = data[targetRowNumber][targetColName];
-
- if (Number.isFinite($scope.counterValue) && Number.isFinite($scope.targetValue)) {
- const delta = $scope.counterValue - $scope.targetValue;
- $scope.showTrend = true;
- $scope.trendPositive = delta >= 0;
- }
- } else {
- $scope.targetValue = null;
- }
-
- $scope.counterValueTooltip = formatTooltip($scope.counterValue, options.tooltipFormat);
- $scope.targetValueTooltip = formatTooltip($scope.targetValue, options.tooltipFormat);
-
- $scope.counterValue = formatValue($scope.counterValue, options);
-
- if (options.formatTargetValue) {
- $scope.targetValue = formatValue($scope.targetValue, options);
- } else {
- if (Number.isFinite($scope.targetValue)) {
- $scope.targetValue = numeral($scope.targetValue).format('0[.]00[0]');
- }
- }
- }
-
- $timeout(() => {
- $scope.handleResize();
- });
- };
-
- $scope.$watch('$ctrl.data', update);
- $scope.$watch('$ctrl.options', update, true);
- },
-};
-
-const CounterEditor = {
- template: counterEditorTemplate,
- bindings: {
- data: '<',
- options: '<',
- visualizationName: '<',
- onOptionsChange: '<',
- },
- controller($scope) {
- this.currentTab = 'general';
- this.changeTab = (tab) => {
- this.currentTab = tab;
- };
-
- this.isValueNumber = () => {
- const options = this.options;
- const data = this.data.rows;
-
- if (data.length > 0) {
- const rowNumber = getRowNumber(options.rowNumber, data.length);
- const counterColName = options.counterColName;
-
- if (options.countRow) {
- this.counterValue = data.length;
- } else if (counterColName) {
- this.counterValue = data[rowNumber][counterColName];
- }
- }
-
- return isNumber(this.counterValue);
- };
-
- $scope.$watch('$ctrl.options', (options) => {
- this.onOptionsChange(options);
- }, true);
- },
-};
-
-export default function init(ngModule) {
- ngModule.component('counterRenderer', CounterRenderer);
- ngModule.component('counterEditor', CounterEditor);
-
- ngModule.run(($injector) => {
- registerVisualization({
- type: 'COUNTER',
- name: 'Counter',
- getOptions: options => ({ ...DEFAULT_OPTIONS, ...options }),
- Renderer: angular2react('counterRenderer', CounterRenderer, $injector),
- Editor: angular2react('counterEditor', CounterEditor, $injector),
-
- defaultColumns: 2,
- defaultRows: 5,
- });
+ defaultColumns: 2,
+ defaultRows: 5,
});
}
diff --git a/client/app/visualizations/counter/render.less b/client/app/visualizations/counter/render.less
new file mode 100755
index 0000000000..252d0c0242
--- /dev/null
+++ b/client/app/visualizations/counter/render.less
@@ -0,0 +1,46 @@
+.counter-visualization-container {
+ display: block;
+ text-align: center;
+ padding: 15px 10px;
+ overflow: hidden;
+
+ .counter-visualization-content {
+ margin: 0;
+ padding: 0;
+ font-size: 80px;
+ line-height: normal;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+
+ .counter-visualization-value,
+ .counter-visualization-target {
+ font-size: 1em;
+ display: block;
+ }
+
+ .counter-visualization-label {
+ font-size: 0.5em;
+ display: block;
+ }
+
+ .counter-visualization-target {
+ color: #ccc;
+ }
+
+ .counter-visualization-label {
+ font-size: 0.5em;
+ display: block;
+ }
+ }
+
+ &.trend-positive .counter-visualization-value {
+ color: #5cb85c;
+ }
+
+ &.trend-negative .counter-visualization-value {
+ color: #d9534f;
+ }
+}
diff --git a/client/app/visualizations/counter/utils.js b/client/app/visualizations/counter/utils.js
new file mode 100644
index 0000000000..8a4bd1b0f6
--- /dev/null
+++ b/client/app/visualizations/counter/utils.js
@@ -0,0 +1,141 @@
+import { isNumber, isFinite, toString } from 'lodash';
+import numeral from 'numeral';
+
+// TODO: allow user to specify number format string instead of delimiters only
+// It will allow to remove this function (move all that weird formatting logic to a migration
+// that will set number format for all existing counter visualization)
+function numberFormat(value, decimalPoints, decimalDelimiter, thousandsDelimiter) {
+ // Temporarily update locale data (restore defaults after formatting)
+ const locale = numeral.localeData();
+ const savedDelimiters = locale.delimiters;
+
+ // Mimic old behavior - AngularJS `number` filter defaults:
+ // - `,` as thousands delimiter
+ // - `.` as decimal delimiter
+ // - three decimal points
+ locale.delimiters = {
+ thousands: ',',
+ decimal: '.',
+ };
+ let formatString = '0,0.000';
+ if (
+ (Number.isFinite(decimalPoints) && (decimalPoints >= 0)) ||
+ decimalDelimiter ||
+ thousandsDelimiter
+ ) {
+ locale.delimiters = {
+ thousands: thousandsDelimiter,
+ decimal: decimalDelimiter || '.',
+ };
+
+ formatString = '0,0';
+ if (decimalPoints > 0) {
+ formatString += '.';
+ while (decimalPoints > 0) {
+ formatString += '0';
+ decimalPoints -= 1;
+ }
+ }
+ }
+ const result = numeral(value).format(formatString);
+
+ locale.delimiters = savedDelimiters;
+ return result;
+}
+
+// 0 - special case, use first record
+// 1..N - 1-based record number from beginning (wraps if greater than dataset size)
+// -1..-N - 1-based record number from end (wraps if greater than dataset size)
+function getRowNumber(index, rowsCount) {
+ index = parseInt(index, 10) || 0;
+ if (index === 0) {
+ return index;
+ }
+ const wrappedIndex = (Math.abs(index) - 1) % rowsCount;
+ return index > 0 ? wrappedIndex : rowsCount - wrappedIndex - 1;
+}
+
+function formatValue(value, { stringPrefix, stringSuffix, stringDecimal, stringDecChar, stringThouSep }) {
+ if (isNumber(value)) {
+ value = numberFormat(value, stringDecimal, stringDecChar, stringThouSep);
+ return toString(stringPrefix) + value + toString(stringSuffix);
+ }
+ return toString(value);
+}
+
+function formatTooltip(value, formatString) {
+ if (isNumber(value)) {
+ return numeral(value).format(formatString);
+ }
+ return toString(value);
+}
+
+export function getCounterData(rows, options, visualizationName) {
+ const result = {};
+
+ const rowsCount = rows.length;
+ if (rowsCount > 0) {
+ const rowNumber = getRowNumber(options.rowNumber, rowsCount);
+ const targetRowNumber = getRowNumber(options.targetRowNumber, rowsCount);
+ const counterColName = options.counterColName;
+ const targetColName = options.targetColName;
+ const counterLabel = options.counterLabel;
+
+ if (counterLabel) {
+ result.counterLabel = counterLabel;
+ } else {
+ result.counterLabel = visualizationName;
+ }
+
+ if (options.countRow) {
+ result.counterValue = rowsCount;
+ } else if (counterColName) {
+ result.counterValue = rows[rowNumber][counterColName];
+ }
+
+ result.showTrend = false;
+ if (targetColName) {
+ result.targetValue = rows[targetRowNumber][targetColName];
+
+ if (Number.isFinite(result.counterValue) && isFinite(result.targetValue)) {
+ const delta = result.counterValue - result.targetValue;
+ result.showTrend = true;
+ result.trendPositive = delta >= 0;
+ }
+ } else {
+ result.targetValue = null;
+ }
+
+ result.counterValueTooltip = formatTooltip(result.counterValue, options.tooltipFormat);
+ result.targetValueTooltip = formatTooltip(result.targetValue, options.tooltipFormat);
+
+ result.counterValue = formatValue(result.counterValue, options);
+
+ if (options.formatTargetValue) {
+ result.targetValue = formatValue(result.targetValue, options);
+ } else {
+ if (isFinite(result.targetValue)) {
+ result.targetValue = numeral(result.targetValue).format('0[.]00[0]');
+ }
+ }
+ }
+
+ return result;
+}
+
+export function isValueNumber(rows, options) {
+ if (options.countRow) {
+ return true; // array length is always a number
+ }
+
+ const rowsCount = rows.length;
+ if (rowsCount > 0) {
+ const rowNumber = getRowNumber(options.rowNumber, rowsCount);
+ const counterColName = options.counterColName;
+ if (counterColName) {
+ return isNumber(rows[rowNumber][counterColName]);
+ }
+ }
+
+ return false;
+}
diff --git a/client/app/visualizations/index.js b/client/app/visualizations/index.js
index ba731d9cad..02ddad2287 100644
--- a/client/app/visualizations/index.js
+++ b/client/app/visualizations/index.js
@@ -25,6 +25,7 @@ export const RendererPropTypes = {
data: Data.isRequired,
options: VisualizationOptions.isRequired,
onOptionsChange: PropTypes.func, // (newOptions) => void
+ context: PropTypes.oneOf(['query', 'widget']).isRequired,
};
// For each visualization's editor
diff --git a/client/app/visualizations/table/Renderer.jsx b/client/app/visualizations/table/Renderer.jsx
index 2880bb81dc..35b5bd70a4 100644
--- a/client/app/visualizations/table/Renderer.jsx
+++ b/client/app/visualizations/table/Renderer.jsx
@@ -8,7 +8,7 @@ import { prepareColumns, filterRows, sortRows } from './utils';
import './renderer.less';
-export default function Renderer({ options, data }) {
+export default function Renderer({ options, data, context }) {
const [rowKeyPrefix, setRowKeyPrefix] = useState(`row:1:${options.itemsPerPage}:`);
const [searchTerm, setSearchTerm] = useState('');
const [orderBy, setOrderBy] = useState([]);
@@ -61,11 +61,13 @@ export default function Renderer({ options, data }) {
return (
rowKeyPrefix + index}
pagination={{
+ size: context === 'widget' ? 'small' : '',
position: 'bottom',
pageSize: options.itemsPerPage,
hideOnSinglePage: true,
diff --git a/client/app/visualizations/table/renderer.less b/client/app/visualizations/table/renderer.less
index 05dcb66017..2ee4e5a318 100644
--- a/client/app/visualizations/table/renderer.less
+++ b/client/app/visualizations/table/renderer.less
@@ -8,7 +8,7 @@
margin-bottom: 0;
}
- .ant-table-body {
+ .ant-table {
overflow-x: auto;
}
@@ -100,4 +100,27 @@
}
}
}
+
+ /* START table x scroll */
+ .dashboard-widget-wrapper:not(.widget-auto-height-enabled) & {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+
+ & div {
+ height: inherit;
+ }
+
+ .ant-spin-container {
+ display: flex;
+ flex-direction: column;
+
+ .ant-table {
+ flex-grow: 1;
+ }
+ }
+ }
+ /* END */
}
diff --git a/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js b/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js
index b30041e155..85541d8ebe 100644
--- a/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js
+++ b/client/cypress/integration/dashboard/grid_compliant_widgets_spec.js
@@ -1,7 +1,7 @@
/* global cy */
import { createDashboard, addTextbox } from '../../support/redash-api';
-import { getWidgetTestId, editDashboard, dragBy, resizeBy } from '../../support/dashboard';
+import { getWidgetTestId, editDashboard, resizeBy } from '../../support/dashboard';
describe('Grid compliant widgets', () => {
@@ -26,19 +26,22 @@ describe('Grid compliant widgets', () => {
});
it('stays put when dragged under snap threshold', () => {
- dragBy(cy.get('@textboxEl'), 90)
+ cy.get('@textboxEl')
+ .dragBy(90)
.invoke('offset')
.should('have.property', 'left', 15); // no change, 15 -> 15
});
it('moves one column when dragged over snap threshold', () => {
- dragBy(cy.get('@textboxEl'), 110)
+ cy.get('@textboxEl')
+ .dragBy(110)
.invoke('offset')
.should('have.property', 'left', 215); // moved by 200, 15 -> 215
});
it('moves two columns when dragged over snap threshold', () => {
- dragBy(cy.get('@textboxEl'), 330)
+ cy.get('@textboxEl')
+ .dragBy(330)
.invoke('offset')
.should('have.property', 'left', 415); // moved by 400, 15 -> 415
});
@@ -49,7 +52,8 @@ describe('Grid compliant widgets', () => {
cy.route('POST', 'api/widgets/*').as('WidgetSave');
editDashboard();
- dragBy(cy.get('@textboxEl'), 330);
+ cy.get('@textboxEl')
+ .dragBy(330);
cy.wait('@WidgetSave');
});
});
diff --git a/client/cypress/integration/dashboard/widget_spec.js b/client/cypress/integration/dashboard/widget_spec.js
index 743035e559..7a42bbd1e6 100644
--- a/client/cypress/integration/dashboard/widget_spec.js
+++ b/client/cypress/integration/dashboard/widget_spec.js
@@ -139,4 +139,36 @@ describe('Widget', () => {
});
});
});
+
+ it('sets the correct height of table visualization', function () {
+ const queryData = {
+ query: `select '${'loremipsum'.repeat(15)}' FROM generate_series(1,15)`,
+ };
+
+ const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
+
+ createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => {
+ cy.visit(this.dashboardUrl);
+ cy.getByTestId('TableVisualization')
+ .its('0.offsetHeight')
+ .should('eq', 381);
+ cy.percySnapshot('Shows correct height of table visualization');
+ });
+ });
+
+ it('shows fixed pagination for overflowing tabular content ', function () {
+ const queryData = {
+ query: 'select \'lorem ipsum\' FROM generate_series(1,50)',
+ };
+
+ const widgetOptions = { position: { col: 0, row: 0, sizeX: 3, sizeY: 10, autoHeight: false } };
+
+ createQueryAndAddWidget(this.dashboardId, queryData, widgetOptions).then(() => {
+ cy.visit(this.dashboardUrl);
+ cy.getByTestId('TableVisualization')
+ .next('.ant-pagination.mini')
+ .should('be.visible');
+ cy.percySnapshot('Shows fixed mini pagination for overflowing tabular content');
+ });
+ });
});
diff --git a/client/cypress/integration/query/parameter_spec.js b/client/cypress/integration/query/parameter_spec.js
index 63eeeaea31..e6824f9a11 100644
--- a/client/cypress/integration/query/parameter_spec.js
+++ b/client/cypress/integration/query/parameter_spec.js
@@ -506,4 +506,82 @@ describe('Parameter', () => {
cy.getByTestId('ExecuteButton').should('not.be.disabled');
});
});
+
+ describe('Draggable', () => {
+ beforeEach(() => {
+ const queryData = {
+ name: 'Draggable',
+ query: "SELECT '{{param1}}', '{{param2}}', '{{param3}}', '{{param4}}' AS parameter",
+ options: {
+ parameters: [
+ { name: 'param1', title: 'Parameter 1', type: 'text' },
+ { name: 'param2', title: 'Parameter 2', type: 'text' },
+ { name: 'param3', title: 'Parameter 3', type: 'text' },
+ { name: 'param4', title: 'Parameter 4', type: 'text' },
+ ],
+ },
+ };
+
+ createQuery(queryData, false)
+ .then(({ id }) => cy.visit(`/queries/${id}/source`));
+
+ cy.get('.parameter-block')
+ .first()
+ .invoke('width')
+ .as('paramWidth');
+ });
+
+ const dragParam = (paramName, offsetLeft, offsetTop) => {
+ cy.getByTestId(`DragHandle-${paramName}`)
+ .trigger('mouseover')
+ .trigger('mousedown');
+
+ cy.get('.parameter-dragged .drag-handle')
+ .trigger('mousemove', offsetLeft, offsetTop, { force: true })
+ .trigger('mouseup', { force: true });
+ };
+
+ it('is possible to rearrange parameters', function () {
+ dragParam('param1', this.paramWidth, 1);
+ dragParam('param4', -this.paramWidth, 1);
+
+ cy.reload();
+
+ const expectedOrder = ['Parameter 2', 'Parameter 1', 'Parameter 4', 'Parameter 3'];
+ cy.get('.parameter-container label')
+ .each(($label, index) => expect($label).to.have.text(expectedOrder[index]));
+ });
+ });
+
+ describe('Parameter Settings', () => {
+ beforeEach(() => {
+ const queryData = {
+ name: 'Draggable',
+ query: "SELECT '{{parameter}}' AS parameter",
+ options: {
+ parameters: [
+ { name: 'parameter', title: 'Parameter', type: 'text' },
+ ],
+ },
+ };
+
+ createQuery(queryData, false)
+ .then(({ id }) => cy.visit(`/queries/${id}/source`));
+
+ cy.getByTestId('ParameterSettings-parameter').click();
+ });
+
+ it('changes the parameter title', () => {
+ cy.getByTestId('ParameterTitleInput')
+ .type('{selectall}New Parameter Name');
+ cy.getByTestId('SaveParameterSettings')
+ .click();
+
+ cy.contains('Query saved');
+ cy.reload();
+
+ cy.getByTestId('ParameterName-parameter')
+ .contains('label', 'New Parameter Name');
+ });
+ });
});
diff --git a/client/cypress/integration/visualizations/counter_spec.js b/client/cypress/integration/visualizations/counter_spec.js
new file mode 100644
index 0000000000..dadfa68f49
--- /dev/null
+++ b/client/cypress/integration/visualizations/counter_spec.js
@@ -0,0 +1,207 @@
+/* global cy, Cypress */
+
+import { createQuery } from '../../support/redash-api';
+
+const SQL = `
+ SELECT 27182.8182846 AS a, 20000 AS b, 'lorem' AS c UNION ALL
+ SELECT 31415.9265359 AS a, 40000 AS b, 'ipsum' AS c
+`;
+
+describe('Counter', () => {
+ const viewportWidth = Cypress.config('viewportWidth');
+
+ beforeEach(() => {
+ cy.login();
+ createQuery({ query: SQL }).then(({ id }) => {
+ cy.visit(`queries/${id}/source`);
+ cy.getByTestId('ExecuteButton').click();
+ });
+ });
+
+ it('creates simple Counter', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.a
+ `);
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (with defaults)', { widths: [viewportWidth] });
+ });
+
+ it('creates Counter with custom label', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.a
+ `);
+
+ cy.fillInputs({
+ 'Counter.General.Label': 'Custom Label',
+ });
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (custom label)', { widths: [viewportWidth] });
+ });
+
+ it('creates Counter with non-numeric value', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.c
+
+ Counter.General.TargetValueColumn
+ Counter.General.TargetValueColumn.c
+ `);
+
+ cy.fillInputs({
+ 'Counter.General.TargetValueRowNumber': '2',
+ });
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (non-numeric value)', { widths: [viewportWidth] });
+ });
+
+ it('creates Counter with target value (trend positive)', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.a
+
+ Counter.General.TargetValueColumn
+ Counter.General.TargetValueColumn.b
+ `);
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (target value + trend positive)', { widths: [viewportWidth] });
+ });
+
+ it('creates Counter with custom row number (trend negative)', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.a
+
+ Counter.General.TargetValueColumn
+ Counter.General.TargetValueColumn.b
+ `);
+
+ cy.fillInputs({
+ 'Counter.General.ValueRowNumber': '2',
+ 'Counter.General.TargetValueRowNumber': '2',
+ });
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (row number + trend negative)', { widths: [viewportWidth] });
+ });
+
+ it('creates Counter with count rows', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.a
+
+ Counter.General.CountRows
+ `);
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (count rows)', { widths: [viewportWidth] });
+ });
+
+ it('creates Counter with formatting', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.a
+
+ Counter.General.TargetValueColumn
+ Counter.General.TargetValueColumn.b
+
+ Counter.EditorTabs.Formatting
+ `);
+
+ cy.fillInputs({
+ 'Counter.Formatting.DecimalPlace': '4',
+ 'Counter.Formatting.DecimalCharacter': ',',
+ 'Counter.Formatting.ThousandsSeparator': '`',
+ 'Counter.Formatting.StringPrefix': '$',
+ 'Counter.Formatting.StringSuffix': '%',
+ });
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (custom formatting)', { widths: [viewportWidth] });
+ });
+
+ it('creates Counter with target value formatting', () => {
+ cy.clickThrough(`
+ NewVisualization
+ VisualizationType
+ VisualizationType.COUNTER
+
+ Counter.General.ValueColumn
+ Counter.General.ValueColumn.a
+
+ Counter.General.TargetValueColumn
+ Counter.General.TargetValueColumn.b
+
+ Counter.EditorTabs.Formatting
+ Counter.Formatting.FormatTargetValue
+ `);
+
+ cy.fillInputs({
+ 'Counter.Formatting.DecimalPlace': '4',
+ 'Counter.Formatting.DecimalCharacter': ',',
+ 'Counter.Formatting.ThousandsSeparator': '`',
+ 'Counter.Formatting.StringPrefix': '$',
+ 'Counter.Formatting.StringSuffix': '%',
+ });
+
+ cy.getByTestId('VisualizationPreview').find('.counter-visualization-container').should('exist');
+
+ // wait a bit before taking snapshot
+ cy.wait(500); // eslint-disable-line cypress/no-unnecessary-waiting
+ cy.percySnapshot('Visualizations - Counter (format target value)', { widths: [viewportWidth] });
+ });
+});
diff --git a/client/cypress/support/commands.js b/client/cypress/support/commands.js
index 37654fccdf..67fd12244d 100644
--- a/client/cypress/support/commands.js
+++ b/client/cypress/support/commands.js
@@ -50,3 +50,18 @@ Cypress.Commands.add('fillInputs', (elements) => {
cy.getByTestId(testId).clear().type(value);
});
});
+
+Cypress.Commands.add('dragBy', { prevSubject: true }, (subject, offsetLeft, offsetTop, force = false) => {
+ if (!offsetLeft) {
+ offsetLeft = 1;
+ }
+ if (!offsetTop) {
+ offsetTop = 1;
+ }
+ return cy.wrap(subject)
+ .trigger('mouseover', { force })
+ .trigger('mousedown', 'topLeft', { force })
+ .trigger('mousemove', 1, 1, { force }) // must have at least 2 mousemove events for react-grid-layout to trigger onLayoutChange
+ .trigger('mousemove', offsetLeft, offsetTop, { force })
+ .trigger('mouseup', { force });
+});
diff --git a/client/cypress/support/dashboard/index.js b/client/cypress/support/dashboard/index.js
index b2a2224a69..cd7b38a0c1 100644
--- a/client/cypress/support/dashboard/index.js
+++ b/client/cypress/support/dashboard/index.js
@@ -37,24 +37,9 @@ export function shareDashboard() {
return cy.getByTestId('SecretAddress').invoke('val');
}
-export function dragBy(wrapper, offsetLeft, offsetTop, force = false) {
- if (!offsetLeft) {
- offsetLeft = 1;
- }
- if (!offsetTop) {
- offsetTop = 1;
- }
- return wrapper
- .trigger('mouseover', { force })
- .trigger('mousedown', 'topLeft', { force })
- .trigger('mousemove', 1, 1, { force }) // must have at least 2 mousemove events for react-grid-layout to trigger onLayoutChange
- .trigger('mousemove', offsetLeft, offsetTop, { force })
- .trigger('mouseup', { force });
-}
-
export function resizeBy(wrapper, offsetLeft = 0, offsetTop = 0) {
return wrapper
.within(() => {
- dragBy(cy.get(RESIZE_HANDLE_SELECTOR), offsetLeft, offsetTop, true);
+ cy.get(RESIZE_HANDLE_SELECTOR).dragBy(offsetLeft, offsetTop, true);
});
}
diff --git a/client/jsconfig.json b/client/jsconfig.json
new file mode 100644
index 0000000000..fe68d7ac70
--- /dev/null
+++ b/client/jsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["./app/*"]
+ }
+ },
+ "exclude": ["dist"]
+}
\ No newline at end of file
diff --git a/docker-compose.yml b/docker-compose.yml
index ce0fcd257e..d2fe7fd80e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -43,6 +43,9 @@ services:
# The following turns the DB into less durable, but gains significant performance improvements for the tests run (x3
# improvement on my personal machine). We should consider moving this into a dedicated Docker Compose configuration for
# tests.
+ environment:
+ POSTGRES_USER: 'postgres'
+ POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "15432:5432"
command: "postgres -c fsync=off -c full_page_writes=off -c synchronous_commit=OFF"
diff --git a/migrations/versions/969126bd800f_.py b/migrations/versions/969126bd800f_.py
index 051b97e695..4a476ef956 100644
--- a/migrations/versions/969126bd800f_.py
+++ b/migrations/versions/969126bd800f_.py
@@ -24,15 +24,20 @@ def upgrade():
# Update widgets position data:
column_size = 3
print("Updating dashboards position data:")
- for dashboard in Dashboard.query:
- print(" Updating dashboard: {}".format(dashboard.id))
- layout = simplejson.loads(dashboard.layout)
+ dashboard_result = db.session.execute("SELECT id, layout FROM dashboards")
+ for dashboard in dashboard_result:
+ print(" Updating dashboard: {}".format(dashboard['id']))
+ layout = simplejson.loads(dashboard['layout'])
print(" Building widgets map:")
widgets = {}
- for w in dashboard.widgets:
- print(" Widget: {}".format(w.id))
- widgets[w.id] = w
+ widget_result = db.session.execute(
+ "SELECT id, options, width FROM widgets WHERE dashboard_id=:dashboard_id",
+ {"dashboard_id" : dashboard['id']})
+ for w in widget_result:
+ print(" Widget: {}".format(w['id']))
+ widgets[w['id']] = w
+ widget_result.close()
print(" Iterating over layout:")
for row_index, row in enumerate(layout):
@@ -47,17 +52,18 @@ def upgrade():
if widget is None:
continue
- options = simplejson.loads(widget.options) or {}
+ options = simplejson.loads(widget['options']) or {}
options['position'] = {
"row": row_index,
"col": column_index * column_size,
"sizeX": column_size * widget.width
}
- widget.options = simplejson.dumps(options)
-
- db.session.add(widget)
+ db.session.execute(
+ "UPDATE widgets SET options=:options WHERE id=:id",
+ {"options" : simplejson.dumps(options), "id" : widget_id})
+ dashboard_result.close()
db.session.commit()
# Remove legacy columns no longer in use.
diff --git a/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py b/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py
index 86f1eb47e7..2ca5e9cd75 100644
--- a/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py
+++ b/migrations/versions/98af61feea92_add_encrypted_options_to_data_sources.py
@@ -29,7 +29,7 @@ def upgrade():
data_sources = table(
'data_sources',
sa.Column('id', sa.Integer, primary_key=True),
- sa.Column('encrypted_options', ConfigurationContainer.as_mutable(EncryptedConfiguration(sa.Text, settings.SECRET_KEY, FernetEngine))),
+ sa.Column('encrypted_options', ConfigurationContainer.as_mutable(EncryptedConfiguration(sa.Text, settings.DATASOURCE_SECRET_KEY, FernetEngine))),
sa.Column('options', ConfigurationContainer.as_mutable(Configuration)))
conn = op.get_bind()
diff --git a/package-lock.json b/package-lock.json
index 00020ef7fe..974b270862 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "redash-client",
- "version": "8.0.0-beta",
+ "version": "8.0.1",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1652,6 +1652,22 @@
"resize-observer-polyfill": "^1.5.1",
"shallowequal": "^1.1.0",
"warning": "~4.0.3"
+ },
+ "dependencies": {
+ "lodash": {
+ "version": "4.17.15",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",
+ "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="
+ },
+ "rc-progress": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-2.3.0.tgz",
+ "integrity": "sha512-hYBKFSsNgD7jsF8j+ZC1J8y5UIC2X/ktCYI/OQhQNSX6mGV1IXnUCjAd9gbLmzmpChPvKyymRNfckScUNiTpFQ==",
+ "requires": {
+ "babel-runtime": "6.x",
+ "prop-types": "^15.5.8"
+ }
+ }
}
},
"any-observable": {
@@ -9710,7 +9726,6 @@
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
"integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
- "dev": true,
"requires": {
"loose-envify": "^1.0.0"
}
@@ -14602,15 +14617,6 @@
"react-lifecycles-compat": "^3.0.4"
}
},
- "rc-progress": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-2.3.0.tgz",
- "integrity": "sha512-hYBKFSsNgD7jsF8j+ZC1J8y5UIC2X/ktCYI/OQhQNSX6mGV1IXnUCjAd9gbLmzmpChPvKyymRNfckScUNiTpFQ==",
- "requires": {
- "babel-runtime": "6.x",
- "prop-types": "^15.5.8"
- }
- },
"rc-rate": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.5.0.tgz",
@@ -14866,13 +14872,14 @@
}
},
"rc-util": {
- "version": "4.6.0",
- "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.6.0.tgz",
- "integrity": "sha512-rbgrzm1/i8mgfwOI4t1CwWK7wGe+OwX+dNa7PVMgxZYPBADGh86eD4OcJO1UKGeajIMDUUKMluaZxvgraQIOmw==",
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.8.4.tgz",
+ "integrity": "sha512-1B2h0/pMXfSUBRAgPdoDIKK5XBuzLBuLI9rLwUEW163SPoDvfb9jmg3ymBPtzne2jWgwtdNw4j0vIq/8Yo849A==",
"requires": {
"add-dom-event-listener": "^1.1.0",
"babel-runtime": "6.x",
"prop-types": "^15.5.10",
+ "react-lifecycles-compat": "^3.0.4",
"shallowequal": "^0.2.2"
},
"dependencies": {
@@ -14982,6 +14989,31 @@
"resize-observer-polyfill": "^1.5.0"
}
},
+ "react-sortable-hoc": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-1.9.1.tgz",
+ "integrity": "sha512-2VeofjRav8+eZeE5Nm/+b8mrA94rQ+gBsqhXi8pRBSjOWNqslU3ZEm+0XhSlfoXJY2lkgHipfYAUuJbDtCixRg==",
+ "requires": {
+ "@babel/runtime": "^7.2.0",
+ "invariant": "^2.2.4",
+ "prop-types": "^15.5.7"
+ },
+ "dependencies": {
+ "@babel/runtime": {
+ "version": "7.5.5",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.5.5.tgz",
+ "integrity": "sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==",
+ "requires": {
+ "regenerator-runtime": "^0.13.2"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
+ "integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
+ }
+ }
+ },
"react-test-renderer": {
"version": "16.8.3",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-16.8.3.tgz",
diff --git a/package.json b/package.json
index d011d2573d..458f7f8fa8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "redash-client",
- "version": "8.0.0-beta",
+ "version": "8.0.1",
"description": "The frontend part of Redash.",
"main": "index.js",
"scripts": {
@@ -16,7 +16,7 @@
"lint:ci": "npm run lint -- --format junit --output-file /tmp/test-results/eslint/results.xml",
"test": "TZ=Africa/Khartoum jest",
"test:watch": "jest --watch",
- "cypress:install": "npm install --no-save cypress@^3.1.5 @percy/cypress@^0.2.3 atob@2.1.2",
+ "cypress:install": "npm install --no-save cypress@3.4.1 @percy/cypress@^0.2.3 atob@2.1.2",
"cypress": "node client/cypress/cypress.js"
},
"repository": {
@@ -82,7 +82,9 @@
"react-ace": "^6.1.0",
"react-dom": "^16.8.3",
"react-grid-layout": "git+https://github.com/getredash/react-grid-layout.git",
+ "react-sortable-hoc": "^1.9.1",
"react2angular": "^3.2.1",
+ "tinycolor2": "^1.4.1",
"ui-select": "^0.19.8"
},
"devDependencies": {
diff --git a/redash/__init__.py b/redash/__init__.py
index 0cd1a5db03..2a5e4e6a57 100644
--- a/redash/__init__.py
+++ b/redash/__init__.py
@@ -16,7 +16,7 @@
from .query_runner import import_query_runners
from .destinations import import_destinations
-__version__ = '8.0.0-beta'
+__version__ = '8.0.1'
if os.environ.get("REMOTE_DEBUG"):
diff --git a/redash/authentication/saml_auth.py b/redash/authentication/saml_auth.py
index c34b212be7..fd15f7d053 100644
--- a/redash/authentication/saml_auth.py
+++ b/redash/authentication/saml_auth.py
@@ -113,7 +113,7 @@ def sp_initiated(org_slug=None):
redirect_url = None
# Select the IdP URL to send the AuthN request to
for key, value in info['headers']:
- if key is 'Location':
+ if key == 'Location':
redirect_url = value
response = redirect(redirect_url, code=302)
diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py
index a698dfb496..dd1eea6451 100644
--- a/redash/handlers/authentication.py
+++ b/redash/handlers/authentication.py
@@ -225,6 +225,9 @@ def client_config():
}
else:
client_config = {}
+
+ if current_user.has_permission('admin') and current_org.get_setting('beacon_consent') is None:
+ client_config['showBeaconConsentMessage'] = True
defaults = {
'allowScriptsInUserInput': settings.ALLOW_SCRIPTS_IN_USER_INPUT,
@@ -263,18 +266,6 @@ def messages():
return messages
-def messages():
- messages = []
-
- if not current_user.is_email_verified:
- messages.append('email-not-verified')
-
- if settings.ALLOW_PARAMETERS_IN_EMBEDS:
- messages.append('using-deprecated-embed-feature')
-
- return messages
-
-
@routes.route('/api/config', methods=['GET'])
def config(org_slug=None):
return json_response({
diff --git a/redash/handlers/dashboards.py b/redash/handlers/dashboards.py
index 954e9da4f7..a5da024bbc 100644
--- a/redash/handlers/dashboards.py
+++ b/redash/handlers/dashboards.py
@@ -8,7 +8,7 @@
order_results as _order_results)
from redash.permissions import (can_modify, require_admin_or_owner,
require_object_modify_permission,
- require_permission)
+ require_permission, is_public_access_allowed)
from redash.security import csp_allows_embeding
from redash.serializers import serialize_dashboard
from sqlalchemy.orm.exc import StaleDataError
@@ -265,6 +265,7 @@ def post(self, dashboard_id):
"""
dashboard = models.Dashboard.get_by_id_and_org(dashboard_id, self.current_org)
require_admin_or_owner(dashboard.user_id)
+ is_public_access_allowed()
api_key = models.ApiKey.create_for_object(dashboard, self.current_user)
models.db.session.flush()
models.db.session.commit()
diff --git a/redash/handlers/embed.py b/redash/handlers/embed.py
index 62805f3a5d..342f176603 100644
--- a/redash/handlers/embed.py
+++ b/redash/handlers/embed.py
@@ -8,6 +8,7 @@
from redash.handlers import routes
from redash.handlers.base import (get_object_or_404, org_scoped_rule,
record_event)
+from redash.permissions import is_public_access_allowed
from redash.handlers.static import render_index
from redash.security import csp_allows_embeding
diff --git a/redash/models/__init__.py b/redash/models/__init__.py
index a4671e5824..cc160884c8 100644
--- a/redash/models/__init__.py
+++ b/redash/models/__init__.py
@@ -791,14 +791,25 @@ def evaluate(self):
data = json_loads(self.query_rel.latest_query_data.data)
if data['rows'] and self.options['column'] in data['rows'][0]:
+ operators = {
+ '>': lambda v, t: v > t,
+ '>=': lambda v, t: v >= t,
+ '<': lambda v, t: v < t,
+ '<=': lambda v, t: v <= t,
+ '==': lambda v, t: v == t,
+ '!=': lambda v, t: v != t,
+
+ # backward compatibility
+ 'greater than': lambda v, t: v > t,
+ 'less than': lambda v, t: v < t,
+ 'equals': lambda v, t: v == t,
+ }
+ should_trigger = operators.get(self.options['op'], lambda v, t: False)
+
value = data['rows'][0][self.options['column']]
- op = self.options['op']
+ threshold = self.options['value']
- if op == 'greater than' and value > self.options['value']:
- new_state = self.TRIGGERED_STATE
- elif op == 'less than' and value < self.options['value']:
- new_state = self.TRIGGERED_STATE
- elif op == 'equals' and value == self.options['value']:
+ if should_trigger(value, threshold):
new_state = self.TRIGGERED_STATE
else:
new_state = self.OK_STATE
diff --git a/redash/models/parameterized_query.py b/redash/models/parameterized_query.py
index 784e609cc3..c42eeaf581 100644
--- a/redash/models/parameterized_query.py
+++ b/redash/models/parameterized_query.py
@@ -6,13 +6,15 @@
from funcy import distinct
from dateutil.parser import parse
+from six import string_types, text_type
+
def _pluck_name_and_value(default_column, row):
row = {k.lower(): v for k, v in row.items()}
name_column = "name" if "name" in row.keys() else default_column.lower()
value_column = "value" if "value" in row.keys() else default_column.lower()
- return {"name": row[name_column], "value": unicode(row[value_column])}
+ return {"name": row[name_column], "value": text_type(row[value_column])}
def _load_result(query_id, org):
@@ -107,8 +109,8 @@ def _is_date_range(obj):
def _is_value_within_options(value, dropdown_options, allow_list=False):
if isinstance(value, list):
- return allow_list and set(map(unicode, value)).issubset(set(dropdown_options))
- return unicode(value) in dropdown_options
+ return allow_list and set(map(text_type, value)).issubset(set(dropdown_options))
+ return text_type(value) in dropdown_options
class ParameterizedQuery(object):
@@ -142,11 +144,11 @@ def _valid(self, name, value):
query_id = definition.get('queryId')
allow_multiple_values = isinstance(definition.get('multiValuesOptions'), dict)
- if isinstance(enum_options, basestring):
+ if isinstance(enum_options, string_types):
enum_options = enum_options.split('\n')
validators = {
- "text": lambda value: isinstance(value, basestring),
+ "text": lambda value: isinstance(value, string_types),
"number": _is_number,
"enum": lambda value: _is_value_within_options(value,
enum_options,
diff --git a/redash/permissions.py b/redash/permissions.py
index d928d918c9..185d90f6c6 100644
--- a/redash/permissions.py
+++ b/redash/permissions.py
@@ -101,6 +101,10 @@ def require_admin_or_owner(object_owner_id):
abort(403, message="You don't have permission to edit this resource.")
+def is_public_access_allowed():
+ abort(403, message="Creating public dashboards is not allowed.")
+
+
def can_modify(obj, user):
return is_admin_or_owner(obj.user_id) or user.has_access(obj, ACCESS_TYPE_MODIFY)
diff --git a/redash/query_runner/__init__.py b/redash/query_runner/__init__.py
index c1d473cd1c..cdd0943639 100644
--- a/redash/query_runner/__init__.py
+++ b/redash/query_runner/__init__.py
@@ -3,6 +3,8 @@
from dateutil import parser
import requests
+from six import text_type
+
from redash import settings
from redash.utils import json_loads
@@ -54,6 +56,7 @@ class NotSupported(Exception):
class BaseQueryRunner(object):
deprecated = False
+ should_annotate_query = True
noop_query = None
def __init__(self, configuration):
@@ -72,14 +75,26 @@ def type(cls):
def enabled(cls):
return True
- @classmethod
- def annotate_query(cls):
- return True
-
@classmethod
def configuration_schema(cls):
return {}
+ def annotate_query(self, query, metadata):
+ if not self.should_annotate_query:
+ return query
+
+ annotation = u", ".join([u"{}: {}".format(k, v) for k, v in metadata.iteritems()])
+ annotated_query = u"/* {} */ {}".format(annotation, query)
+ return annotated_query
+
+ def annotate_query_with_single_line_comment(self, query, metadata):
+ if not self.should_annotate_query:
+ return query
+
+ annotation = u", ".join([u"{}: {}".format(k, v) for k, v in metadata.iteritems()])
+ annotated_query = u"-- {} -- \n {}".format(annotation, query)
+ return annotated_query
+
def test_connection(self):
if self.noop_query is None:
raise NotImplementedError()
@@ -148,6 +163,7 @@ def _get_tables_stats(self, tables_dict):
class BaseHTTPQueryRunner(BaseQueryRunner):
+ should_annotate_query = False
response_error = "Endpoint returned unexpected status code"
requires_authentication = False
requires_url = True
@@ -299,7 +315,7 @@ def guess_type_from_string(string_value):
except (ValueError, OverflowError):
pass
- if unicode(string_value).lower() in ('true', 'false'):
+ if text_type(string_value).lower() in ('true', 'false'):
return TYPE_BOOLEAN
try:
diff --git a/redash/query_runner/athena.py b/redash/query_runner/athena.py
index db13297caa..7cecbc676e 100644
--- a/redash/query_runner/athena.py
+++ b/redash/query_runner/athena.py
@@ -7,6 +7,7 @@
logger = logging.getLogger(__name__)
ANNOTATE_QUERY = parse_boolean(os.environ.get('ATHENA_ANNOTATE_QUERY', 'true'))
+ANNOTATE_QUERY_FOR_DML = parse_boolean(os.environ.get('ATHENA_ANNOTATE_QUERY_FOR_DML', 'true'))
SHOW_EXTRA_SETTINGS = parse_boolean(os.environ.get('ATHENA_SHOW_EXTRA_SETTINGS', 'true'))
ASSUME_ROLE = parse_boolean(os.environ.get('ATHENA_ASSUME_ROLE', 'false'))
OPTIONAL_CREDENTIALS = parse_boolean(os.environ.get('ATHENA_OPTIONAL_CREDENTIALS', 'true'))
@@ -132,9 +133,13 @@ def configuration_schema(cls):
def enabled(cls):
return enabled
- @classmethod
- def annotate_query(cls):
- return ANNOTATE_QUERY
+ def annotate_query(self, query, metadata):
+ if ANNOTATE_QUERY:
+ if ANNOTATE_QUERY_FOR_DML:
+ return super(Athena, self).annotate_query_with_single_line_comment(query, metadata)
+ else:
+ return super(Athena, self).annotate_query(query, metadata)
+ return query
@classmethod
def type(cls):
diff --git a/redash/query_runner/azure_kusto.py b/redash/query_runner/azure_kusto.py
new file mode 100644
index 0000000000..d045f54d71
--- /dev/null
+++ b/redash/query_runner/azure_kusto.py
@@ -0,0 +1,156 @@
+from redash.query_runner import BaseQueryRunner, register
+from redash.query_runner import TYPE_STRING, TYPE_DATE, TYPE_DATETIME, TYPE_INTEGER, TYPE_FLOAT, TYPE_BOOLEAN
+from redash.utils import json_dumps, json_loads
+
+
+try:
+ from azure.kusto.data.request import KustoClient, KustoConnectionStringBuilder
+ from azure.kusto.data.exceptions import KustoServiceError
+ enabled = True
+except ImportError:
+ enabled = False
+
+TYPES_MAP = {
+ 'boolean': TYPE_BOOLEAN,
+ 'datetime': TYPE_DATETIME,
+ 'date': TYPE_DATE,
+ 'dynamic': TYPE_STRING,
+ 'guid': TYPE_STRING,
+ 'int': TYPE_INTEGER,
+ 'long': TYPE_INTEGER,
+ 'real': TYPE_FLOAT,
+ 'string': TYPE_STRING,
+ 'timespan': TYPE_STRING,
+ 'decimal': TYPE_FLOAT
+}
+
+
+class AzureKusto(BaseQueryRunner):
+ should_annotate_query = False
+ noop_query = "let noop = datatable (Noop:string)[1]; noop"
+
+ def __init__(self, configuration):
+ super(AzureKusto, self).__init__(configuration)
+ self.syntax = 'custom'
+
+ @classmethod
+ def configuration_schema(cls):
+ return {
+ "type": "object",
+ "properties": {
+ "cluster": {
+ "type": "string"
+ },
+ "azure_ad_client_id": {
+ "type": "string",
+ "title": "Azure AD Client ID"
+ },
+ "azure_ad_client_secret": {
+ "type": "string",
+ "title": "Azure AD Client Secret"
+ },
+ "azure_ad_tenant_id": {
+ "type": "string",
+ "title": "Azure AD Tenant Id"
+ },
+ "database": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "cluster", "azure_ad_client_id", "azure_ad_client_secret",
+ "azure_ad_tenant_id", "database"
+ ],
+ "order": [
+ "cluster", "azure_ad_client_id", "azure_ad_client_secret",
+ "azure_ad_tenant_id", "database"
+ ],
+ "secret": ["azure_ad_client_secret"]
+ }
+
+ @classmethod
+ def enabled(cls):
+ return enabled
+
+ @classmethod
+ def type(cls):
+ return "azure_kusto"
+
+ @classmethod
+ def name(cls):
+ return "Azure Data Explorer (Kusto)"
+
+ def run_query(self, query, user):
+
+ kcsb = KustoConnectionStringBuilder.with_aad_application_key_authentication(
+ connection_string=self.configuration['cluster'],
+ aad_app_id=self.configuration['azure_ad_client_id'],
+ app_key=self.configuration['azure_ad_client_secret'],
+ authority_id=self.configuration['azure_ad_tenant_id'])
+
+ client = KustoClient(kcsb)
+
+ db = self.configuration['database']
+ try:
+ response = client.execute(db, query)
+
+ result_cols = response.primary_results[0].columns
+ result_rows = response.primary_results[0].rows
+
+ columns = []
+ rows = []
+ for c in result_cols:
+ columns.append({
+ 'name': c.column_name,
+ 'friendly_name': c.column_name,
+ 'type': TYPES_MAP.get(c.column_type, None)
+ })
+
+ # rows must be [{'column1': value, 'column2': value}]
+ for row in result_rows:
+ rows.append(row.to_dict())
+
+ error = None
+ data = {'columns': columns, 'rows': rows}
+ json_data = json_dumps(data)
+
+ except KustoServiceError as err:
+ json_data = None
+ try:
+ error = err.args[1][0]['error']['@message']
+ except (IndexError, KeyError):
+ error = err.args[1]
+ except KeyboardInterrupt:
+ json_data = None
+ error = "Query cancelled by user."
+
+ return json_data, error
+
+ def get_schema(self, get_stats=False):
+ query = ".show database schema as json"
+
+ results, error = self.run_query(query, None)
+
+ if error is not None:
+ raise Exception("Failed getting schema.")
+
+ results = json_loads(results)
+
+ schema_as_json = json_loads(results['rows'][0]['DatabaseSchema'])
+ tables_list = schema_as_json['Databases'][self.configuration['database']]['Tables'].values()
+
+ schema = {}
+
+ for table in tables_list:
+ table_name = table['Name']
+
+ if table_name not in schema:
+ schema[table_name] = {'name': table_name, 'columns': []}
+
+ for column in table['OrderedColumns']:
+ schema[table_name]['columns'].append(column['Name'])
+
+ return schema.values()
+
+
+register(AzureKusto)
diff --git a/redash/query_runner/big_query.py b/redash/query_runner/big_query.py
index d85574372a..782a600409 100644
--- a/redash/query_runner/big_query.py
+++ b/redash/query_runner/big_query.py
@@ -83,6 +83,7 @@ def _get_query_results(jobs, project_id, location, job_id, start_index):
class BigQuery(BaseQueryRunner):
+ should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
@@ -133,10 +134,6 @@ def configuration_schema(cls):
'secret': ['jsonKeyFile']
}
- @classmethod
- def annotate_query(cls):
- return False
-
def _get_bigquery_service(self):
scope = [
"https://www.googleapis.com/auth/bigquery",
diff --git a/redash/query_runner/couchbase.py b/redash/query_runner/couchbase.py
index f6e839fb0b..093cd387a6 100644
--- a/redash/query_runner/couchbase.py
+++ b/redash/query_runner/couchbase.py
@@ -2,9 +2,11 @@
import logging
from dateutil.parser import parse
+from six import text_type
from redash.query_runner import *
from redash.utils import JSONEncoder, json_dumps, json_loads, parse_human_time
+from redash.utils.compat import long
import json
logger = logging.getLogger(__name__)
@@ -17,7 +19,7 @@
TYPES_MAP = {
str: TYPE_STRING,
- unicode: TYPE_STRING,
+ text_type: TYPE_STRING,
int: TYPE_INTEGER,
long: TYPE_INTEGER,
float: TYPE_FLOAT,
@@ -68,7 +70,7 @@ def parse_results(results):
class Couchbase(BaseQueryRunner):
-
+ should_annotate_query = False
noop_query = 'Select 1'
@classmethod
@@ -107,10 +109,6 @@ def __init__(self, configuration):
def enabled(cls):
return True
- @classmethod
- def annotate_query(cls):
- return False
-
def test_connection(self):
result = self.call_service(self.noop_query, '')
diff --git a/redash/query_runner/dgraph.py b/redash/query_runner/dgraph.py
index 60a12edbd1..d5342e163e 100644
--- a/redash/query_runner/dgraph.py
+++ b/redash/query_runner/dgraph.py
@@ -29,6 +29,7 @@ def reduce_item(reduced_item, key, value):
class Dgraph(BaseQueryRunner):
+ should_annotate_query = False
noop_query = """
{
test() {
@@ -64,13 +65,7 @@ def type(cls):
def enabled(cls):
return enabled
- @classmethod
- def annotate_query(cls):
- """Dgraph uses '#' as a comment delimiter, not '/* */'"""
- return False
-
def run_dgraph_query_raw(self, query):
-
servers = self.configuration.get('servers')
client_stub = pydgraph.DgraphClientStub(servers)
diff --git a/redash/query_runner/drill.py b/redash/query_runner/drill.py
index 780e74072b..5c5ce12db8 100644
--- a/redash/query_runner/drill.py
+++ b/redash/query_runner/drill.py
@@ -4,6 +4,8 @@
from dateutil import parser
+from six import text_type
+
from redash.query_runner import (
BaseHTTPQueryRunner, register,
TYPE_DATETIME, TYPE_INTEGER, TYPE_FLOAT, TYPE_BOOLEAN,
@@ -26,12 +28,12 @@ def convert_type(string_value, actual_type):
return float(string_value)
if actual_type == TYPE_BOOLEAN:
- return unicode(string_value).lower() == 'true'
+ return text_type(string_value).lower() == 'true'
if actual_type == TYPE_DATETIME:
return parser.parse(string_value)
- return unicode(string_value)
+ return text_type(string_value)
# Parse Drill API response and translate it to accepted format
diff --git a/redash/query_runner/dynamodb_sql.py b/redash/query_runner/dynamodb_sql.py
index 014d1bd5f6..12be5a7f65 100644
--- a/redash/query_runner/dynamodb_sql.py
+++ b/redash/query_runner/dynamodb_sql.py
@@ -33,6 +33,8 @@
class DynamoDBSQL(BaseSQLQueryRunner):
+ should_annotate_query = False
+
@classmethod
def configuration_schema(cls):
return {
@@ -57,10 +59,6 @@ def test_connection(self):
engine = self._connect()
list(engine.connection.list_tables())
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def type(cls):
return "dynamodb_sql"
diff --git a/redash/query_runner/elasticsearch.py b/redash/query_runner/elasticsearch.py
index 22e08d3108..2bb338a767 100644
--- a/redash/query_runner/elasticsearch.py
+++ b/redash/query_runner/elasticsearch.py
@@ -4,9 +4,11 @@
import requests
from requests.auth import HTTPBasicAuth
+from six import string_types, text_type
from redash.query_runner import *
from redash.utils import json_dumps, json_loads
+from redash.utils.compat import long
try:
import http.client as http_client
@@ -35,7 +37,7 @@
PYTHON_TYPES_MAPPING = {
str: TYPE_STRING,
- unicode: TYPE_STRING,
+ text_type: TYPE_STRING,
bool: TYPE_BOOLEAN,
int: TYPE_INTEGER,
long: TYPE_INTEGER,
@@ -44,6 +46,7 @@
class BaseElasticSearch(BaseQueryRunner):
+ should_annotate_query = False
DEBUG_ENABLED = False
@classmethod
@@ -286,15 +289,10 @@ def test_connection(self):
class Kibana(BaseElasticSearch):
-
@classmethod
def enabled(cls):
return True
- @classmethod
- def annotate_query(cls):
- return False
-
def _execute_simple_query(self, url, auth, _from, mappings, result_fields, result_columns, result_rows):
url += "&from={0}".format(_from)
r = requests.get(url, auth=self.auth)
@@ -345,7 +343,7 @@ def run_query(self, query, user):
result_columns = []
result_rows = []
- if isinstance(query_data, str) or isinstance(query_data, unicode):
+ if isinstance(query_data, string_types):
_from = 0
while True:
query_size = size if limit >= (_from + size) else (limit - _from)
@@ -377,15 +375,10 @@ def run_query(self, query, user):
class ElasticSearch(BaseElasticSearch):
-
@classmethod
def enabled(cls):
return True
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def name(cls):
return 'Elasticsearch'
diff --git a/redash/query_runner/google_analytics.py b/redash/query_runner/google_analytics.py
index 71be522015..479403d6de 100644
--- a/redash/query_runner/google_analytics.py
+++ b/redash/query_runner/google_analytics.py
@@ -78,9 +78,7 @@ def parse_ga_response(response):
class GoogleAnalytics(BaseSQLQueryRunner):
- @classmethod
- def annotate_query(cls):
- return False
+ should_annotate_query = False
@classmethod
def type(cls):
diff --git a/redash/query_runner/google_spreadsheets.py b/redash/query_runner/google_spreadsheets.py
index 5b144f4459..5c369d3534 100644
--- a/redash/query_runner/google_spreadsheets.py
+++ b/redash/query_runner/google_spreadsheets.py
@@ -139,14 +139,12 @@ def request(self, *args, **kwargs):
class GoogleSpreadsheet(BaseQueryRunner):
+ should_annotate_query = False
+
def __init__(self, configuration):
super(GoogleSpreadsheet, self).__init__(configuration)
self.syntax = 'custom'
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def name(cls):
return "Google Sheets"
diff --git a/redash/query_runner/graphite.py b/redash/query_runner/graphite.py
index 1fb5ec1503..711584c70d 100644
--- a/redash/query_runner/graphite.py
+++ b/redash/query_runner/graphite.py
@@ -26,6 +26,8 @@ def _transform_result(response):
class Graphite(BaseQueryRunner):
+ should_annotate_query = False
+
@classmethod
def configuration_schema(cls):
return {
@@ -49,10 +51,6 @@ def configuration_schema(cls):
'secret': ['password']
}
- @classmethod
- def annotate_query(cls):
- return False
-
def __init__(self, configuration):
super(Graphite, self).__init__(configuration)
self.syntax = 'custom'
diff --git a/redash/query_runner/hive_ds.py b/redash/query_runner/hive_ds.py
index 2107d0d0b9..6b10e23ebc 100644
--- a/redash/query_runner/hive_ds.py
+++ b/redash/query_runner/hive_ds.py
@@ -9,6 +9,7 @@
try:
from pyhive import hive
+ from pyhive.exc import DatabaseError
from thrift.transport import THttpClient
enabled = True
except ImportError:
@@ -36,6 +37,7 @@
class Hive(BaseSQLQueryRunner):
+ should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
@@ -60,10 +62,6 @@ def configuration_schema(cls):
"required": ["host"]
}
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def type(cls):
return "hive"
@@ -132,6 +130,12 @@ def run_query(self, query, user):
connection.cancel()
error = "Query cancelled by user."
json_data = None
+ except DatabaseError as e:
+ try:
+ error = e.args[0].status.errorMessage
+ except AttributeError:
+ error = str(e)
+ json_data = None
finally:
if connection:
connection.close()
diff --git a/redash/query_runner/influx_db.py b/redash/query_runner/influx_db.py
index 47f3a4201f..bec53c8d27 100644
--- a/redash/query_runner/influx_db.py
+++ b/redash/query_runner/influx_db.py
@@ -48,6 +48,7 @@ def _transform_result(results):
class InfluxDB(BaseQueryRunner):
+ should_annotate_query = False
noop_query = "show measurements limit 1"
@classmethod
@@ -66,10 +67,6 @@ def configuration_schema(cls):
def enabled(cls):
return enabled
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def type(cls):
return "influxdb"
diff --git a/redash/query_runner/jql.py b/redash/query_runner/jql.py
index d24ee0b9f8..76e707e3a3 100644
--- a/redash/query_runner/jql.py
+++ b/redash/query_runner/jql.py
@@ -150,10 +150,6 @@ class JiraJQL(BaseHTTPQueryRunner):
def name(cls):
return "JIRA (JQL)"
- @classmethod
- def annotate_query(cls):
- return False
-
def __init__(self, configuration):
super(JiraJQL, self).__init__(configuration)
self.syntax = 'json'
diff --git a/redash/query_runner/json_ds.py b/redash/query_runner/json_ds.py
index 4b16ebc0d7..9cf3226ed8 100644
--- a/redash/query_runner/json_ds.py
+++ b/redash/query_runner/json_ds.py
@@ -5,7 +5,9 @@
import datetime
from urlparse import urlparse
from funcy import compact, project
+from six import text_type
from redash.utils import json_dumps
+from redash.utils.compat import long
from redash.query_runner import (BaseHTTPQueryRunner, register,
TYPE_BOOLEAN, TYPE_DATETIME, TYPE_FLOAT,
TYPE_INTEGER, TYPE_STRING)
@@ -25,19 +27,19 @@ def parse_query(query):
return params
except ValueError as e:
logging.exception(e)
- error = unicode(e)
+ error = text_type(e)
raise QueryParseError(error)
def is_private_address(url):
hostname = urlparse(url).hostname
ip_address = socket.gethostbyname(hostname)
- return ipaddress.ip_address(unicode(ip_address)).is_private
+ return ipaddress.ip_address(text_type(ip_address)).is_private
TYPES_MAP = {
str: TYPE_STRING,
- unicode: TYPE_STRING,
+ text_type: TYPE_STRING,
int: TYPE_INTEGER,
long: TYPE_INTEGER,
float: TYPE_FLOAT,
@@ -157,10 +159,6 @@ def configuration_schema(cls):
'order': ['username', 'password']
}
- @classmethod
- def annotate_query(cls):
- return False
-
def __init__(self, configuration):
super(JSON, self).__init__(configuration)
self.syntax = 'yaml'
diff --git a/redash/query_runner/memsql_ds.py b/redash/query_runner/memsql_ds.py
index bbec2836d4..917e4962cb 100644
--- a/redash/query_runner/memsql_ds.py
+++ b/redash/query_runner/memsql_ds.py
@@ -37,6 +37,7 @@
class MemSQL(BaseSQLQueryRunner):
+ should_annotate_query = False
noop_query = 'SELECT 1'
@classmethod
@@ -62,10 +63,6 @@ def configuration_schema(cls):
"secret": ["password"]
}
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def type(cls):
return "memsql"
diff --git a/redash/query_runner/mongodb.py b/redash/query_runner/mongodb.py
index c6fbdc9760..b6dad02747 100644
--- a/redash/query_runner/mongodb.py
+++ b/redash/query_runner/mongodb.py
@@ -3,9 +3,11 @@
import re
from dateutil.parser import parse
+from six import string_types, text_type
from redash.query_runner import *
from redash.utils import JSONEncoder, json_dumps, json_loads, parse_human_time
+from redash.utils.compat import long
logger = logging.getLogger(__name__)
@@ -24,7 +26,7 @@
TYPES_MAP = {
str: TYPE_STRING,
- unicode: TYPE_STRING,
+ text_type: TYPE_STRING,
int: TYPE_INTEGER,
long: TYPE_INTEGER,
float: TYPE_FLOAT,
@@ -56,7 +58,7 @@ def parse_oids(oids):
def datetime_parser(dct):
for k, v in dct.iteritems():
- if isinstance(v, basestring):
+ if isinstance(v, string_types):
m = date_regex.findall(v)
if len(m) > 0:
dct[k] = parse(m[0], yearfirst=True)
@@ -119,6 +121,8 @@ def parse_results(results):
class MongoDB(BaseQueryRunner):
+ should_annotate_query = False
+
@classmethod
def configuration_schema(cls):
return {
@@ -144,10 +148,6 @@ def configuration_schema(cls):
def enabled(cls):
return enabled
- @classmethod
- def annotate_query(cls):
- return False
-
def __init__(self, configuration):
super(MongoDB, self).__init__(configuration)
diff --git a/redash/query_runner/mssql.py b/redash/query_runner/mssql.py
index c4b4fea1e0..4349acebf3 100644
--- a/redash/query_runner/mssql.py
+++ b/redash/query_runner/mssql.py
@@ -26,6 +26,7 @@
class SqlServer(BaseSQLQueryRunner):
+ should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
@@ -78,10 +79,6 @@ def name(cls):
def type(cls):
return "mssql"
- @classmethod
- def annotate_query(cls):
- return False
-
def _get_tables(self, schema):
query = """
SELECT table_schema, table_name, column_name
diff --git a/redash/query_runner/mssql_odbc.py b/redash/query_runner/mssql_odbc.py
index a729e037c7..7736c56fba 100644
--- a/redash/query_runner/mssql_odbc.py
+++ b/redash/query_runner/mssql_odbc.py
@@ -16,6 +16,7 @@
class SQLServerODBC(BaseSQLQueryRunner):
+ should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
@@ -68,10 +69,6 @@ def name(cls):
def type(cls):
return "mssql_odbc"
- @classmethod
- def annotate_query(cls):
- return False
-
def _get_tables(self, schema):
query = """
SELECT table_schema, table_name, column_name
diff --git a/redash/query_runner/mysql.py b/redash/query_runner/mysql.py
index b6592eb790..907fb15b54 100644
--- a/redash/query_runner/mysql.py
+++ b/redash/query_runner/mysql.py
@@ -2,10 +2,16 @@
import os
import threading
-from redash.query_runner import *
+from redash.query_runner import TYPE_FLOAT, TYPE_INTEGER, TYPE_DATETIME, TYPE_STRING, TYPE_DATE, BaseSQLQueryRunner, InterruptException, register
from redash.settings import parse_boolean
from redash.utils import json_dumps, json_loads
+try:
+ import MySQLdb
+ enabled = True
+except ImportError:
+ enabled = False
+
logger = logging.getLogger(__name__)
types_map = {
0: TYPE_FLOAT,
@@ -37,7 +43,8 @@ class Mysql(BaseSQLQueryRunner):
@classmethod
def configuration_schema(cls):
- show_ssl_settings = parse_boolean(os.environ.get('MYSQL_SHOW_SSL_SETTINGS', 'true'))
+ show_ssl_settings = parse_boolean(
+ os.environ.get('MYSQL_SHOW_SSL_SETTINGS', 'true'))
schema = {
'type': 'object',
@@ -74,8 +81,10 @@ def configuration_schema(cls):
'title': 'Use SSL'
},
'ssl_cacert': {
- 'type': 'string',
- 'title': 'Path to CA certificate file to verify peer against (SSL)'
+ 'type':
+ 'string',
+ 'title':
+ 'Path to CA certificate file to verify peer against (SSL)'
},
'ssl_cert': {
'type': 'string',
@@ -95,12 +104,26 @@ def name(cls):
@classmethod
def enabled(cls):
- try:
- import MySQLdb
- except ImportError:
- return False
+ return enabled
+
+ def _connection(self):
+ params = dict(host=self.configuration.get('host', ''),
+ user=self.configuration.get('user', ''),
+ passwd=self.configuration.get('passwd', ''),
+ db=self.configuration['db'],
+ port=self.configuration.get('port', 3306),
+ charset='utf8',
+ use_unicode=True,
+ connect_timeout=60)
+
+ ssl_options = self._get_ssl_parameters()
- return True
+ if ssl_options:
+ params['ssl'] = ssl_options
+
+ connection = MySQLdb.connect(**params)
+
+ return connection
def _get_tables(self, schema):
query = """
@@ -120,7 +143,8 @@ def _get_tables(self, schema):
for row in results['rows']:
if row['table_schema'] != self.configuration['db']:
- table_name = u'{}.{}'.format(row['table_schema'], row['table_name'])
+ table_name = u'{}.{}'.format(row['table_schema'],
+ row['table_name'])
else:
table_name = row['table_name']
@@ -132,23 +156,15 @@ def _get_tables(self, schema):
return schema.values()
def run_query(self, query, user):
- import MySQLdb
-
ev = threading.Event()
thread_id = ""
r = Result()
t = None
try:
- connection = MySQLdb.connect(host=self.configuration.get('host', ''),
- user=self.configuration.get('user', ''),
- passwd=self.configuration.get('passwd', ''),
- db=self.configuration['db'],
- port=self.configuration.get('port', 3306),
- charset='utf8', use_unicode=True,
- ssl=self._get_ssl_parameters(),
- connect_timeout=60)
+ connection = self._connection()
thread_id = connection.thread_id()
- t = threading.Thread(target=self._run_query, args=(query, user, connection, r, ev))
+ t = threading.Thread(target=self._run_query,
+ args=(query, user, connection, r, ev))
t.start()
while not ev.wait(1):
pass
@@ -163,8 +179,6 @@ def run_query(self, query, user):
return r.json_data, r.error
def _run_query(self, query, user, connection, r, ev):
- import MySQLdb
-
try:
cursor = connection.cursor()
logger.debug("MySQL running query: %s", query)
@@ -180,8 +194,12 @@ def _run_query(self, query, user, connection, r, ev):
# TODO - very similar to pg.py
if desc is not None:
- columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in desc])
- rows = [dict(zip((c['name'] for c in columns), row)) for row in data]
+ columns = self.fetch_columns([(i[0], types_map.get(i[1], None))
+ for i in desc])
+ rows = [
+ dict(zip((c['name'] for c in columns), row))
+ for row in data
+ ]
data = {'columns': columns, 'rows': rows}
r.json_data = json_dumps(data)
@@ -202,12 +220,13 @@ def _run_query(self, query, user, connection, r, ev):
connection.close()
def _get_ssl_parameters(self):
+ if not self.configuration.get('use_ssl'):
+ return None
+
ssl_params = {}
if self.configuration.get('use_ssl'):
- config_map = dict(ssl_cacert='ca',
- ssl_cert='cert',
- ssl_key='key')
+ config_map = dict(ssl_cacert='ca', ssl_cert='cert', ssl_key='key')
for key, cfg in config_map.items():
val = self.configuration.get(key)
if val:
@@ -216,20 +235,12 @@ def _get_ssl_parameters(self):
return ssl_params
def _cancel(self, thread_id):
- import MySQLdb
connection = None
cursor = None
error = None
try:
- connection = MySQLdb.connect(host=self.configuration.get('host', ''),
- user=self.configuration.get('user', ''),
- passwd=self.configuration.get('passwd', ''),
- db=self.configuration['db'],
- port=self.configuration.get('port', 3306),
- charset='utf8', use_unicode=True,
- ssl=self._get_ssl_parameters(),
- connect_timeout=60)
+ connection = self._connection()
cursor = connection.cursor()
query = "KILL %d" % (thread_id)
logging.debug(query)
@@ -289,10 +300,11 @@ def configuration_schema(cls):
def _get_ssl_parameters(self):
if self.configuration.get('use_ssl'):
- ca_path = os.path.join(os.path.dirname(__file__), './files/rds-combined-ca-bundle.pem')
+ ca_path = os.path.join(os.path.dirname(__file__),
+ './files/rds-combined-ca-bundle.pem')
return {'ca': ca_path}
- return {}
+ return None
register(Mysql)
diff --git a/redash/query_runner/pg.py b/redash/query_runner/pg.py
index df5dacfba1..e9e4cc5431 100644
--- a/redash/query_runner/pg.py
+++ b/redash/query_runner/pg.py
@@ -38,10 +38,8 @@ def default(self, o):
items = [
o._bounds[0],
- str(o._lower),
- ', ',
- str(o._upper),
- o._bounds[1]
+ str(o._lower), ', ',
+ str(o._upper), o._bounds[1]
]
return ''.join(items)
@@ -92,9 +90,9 @@ def configuration_schema(cls):
"title": "Database Name"
},
"sslmode": {
- "type": "string",
- "title": "SSL Mode",
- "default": "prefer"
+ "type": "string",
+ "title": "SSL Mode",
+ "default": "prefer"
}
},
"order": ['host', 'port', 'user', 'password'],
@@ -116,7 +114,8 @@ def _get_definitions(self, schema, query):
for row in results['rows']:
if row['table_schema'] != 'public':
- table_name = u'{}.{}'.format(row['table_schema'], row['table_name'])
+ table_name = u'{}.{}'.format(row['table_schema'],
+ row['table_name'])
else:
table_name = row['table_name']
@@ -168,13 +167,14 @@ def _get_tables(self, schema):
return schema.values()
def _get_connection(self):
- connection = psycopg2.connect(user=self.configuration.get('user'),
- password=self.configuration.get('password'),
- host=self.configuration.get('host'),
- port=self.configuration.get('port'),
- dbname=self.configuration.get('dbname'),
- sslmode=self.configuration.get('sslmode'),
- async_=True)
+ connection = psycopg2.connect(
+ user=self.configuration.get('user'),
+ password=self.configuration.get('password'),
+ host=self.configuration.get('host'),
+ port=self.configuration.get('port'),
+ dbname=self.configuration.get('dbname'),
+ sslmode=self.configuration.get('sslmode'),
+ async_=True)
return connection
@@ -189,12 +189,18 @@ def run_query(self, query, user):
_wait(connection)
if cursor.description is not None:
- columns = self.fetch_columns([(i[0], types_map.get(i[1], None)) for i in cursor.description])
- rows = [dict(zip((c['name'] for c in columns), row)) for row in cursor]
+ columns = self.fetch_columns([(i[0], types_map.get(i[1], None))
+ for i in cursor.description])
+ rows = [
+ dict(zip((c['name'] for c in columns), row))
+ for row in cursor
+ ]
data = {'columns': columns, 'rows': rows}
error = None
- json_data = json_dumps(data, ignore_nan=True, cls=PostgreSQLJSONEncoder)
+ json_data = json_dumps(data,
+ ignore_nan=True,
+ cls=PostgreSQLJSONEncoder)
else:
error = 'Query completed but it returned no data.'
json_data = None
@@ -220,22 +226,23 @@ def type(cls):
return "redshift"
def _get_connection(self):
- sslrootcert_path = os.path.join(os.path.dirname(__file__), './files/redshift-ca-bundle.crt')
-
- connection = psycopg2.connect(user=self.configuration.get('user'),
- password=self.configuration.get('password'),
- host=self.configuration.get('host'),
- port=self.configuration.get('port'),
- dbname=self.configuration.get('dbname'),
- sslmode=self.configuration.get('sslmode', 'prefer'),
- sslrootcert=sslrootcert_path,
- async_=True)
+ sslrootcert_path = os.path.join(os.path.dirname(__file__),
+ './files/redshift-ca-bundle.crt')
+
+ connection = psycopg2.connect(
+ user=self.configuration.get('user'),
+ password=self.configuration.get('password'),
+ host=self.configuration.get('host'),
+ port=self.configuration.get('port'),
+ dbname=self.configuration.get('dbname'),
+ sslmode=self.configuration.get('sslmode', 'prefer'),
+ sslrootcert=sslrootcert_path,
+ async_=True)
return connection
@classmethod
def configuration_schema(cls):
-
return {
"type": "object",
"properties": {
@@ -256,15 +263,39 @@ def configuration_schema(cls):
"title": "Database Name"
},
"sslmode": {
- "type": "string",
- "title": "SSL Mode",
- "default": "prefer"
- }
+ "type": "string",
+ "title": "SSL Mode",
+ "default": "prefer"
+ },
+ "adhoc_query_group": {
+ "type": "string",
+ "title": "Query Group for Adhoc Queries",
+ "default": "default"
+ },
+ "scheduled_query_group": {
+ "type": "string",
+ "title": "Query Group for Scheduled Queries",
+ "default": "default"
+ },
},
- "order": ['host', 'port', 'user', 'password'],
+ "order": ['host', 'port', 'user', 'password', 'dbname', 'sslmode', 'adhoc_query_group', 'scheduled_query_group'],
"required": ["dbname", "user", "password", "host", "port"],
"secret": ["password"]
}
+
+ def annotate_query(self, query, metadata):
+ annotated = super(Redshift, self).annotate_query(query, metadata)
+
+ if metadata.get('Scheduled', False):
+ query_group = self.configuration.get('scheduled_query_group')
+ else:
+ query_group = self.configuration.get('adhoc_query_group')
+
+ if query_group:
+ set_query_group = 'set query_group to {};'.format(query_group)
+ annotated = '{}\n{}'.format(set_query_group, annotated)
+
+ return annotated
def _get_tables(self, schema):
# Use svv_columns to include internal & external (Spectrum) tables and views data for Redshift
@@ -300,7 +331,6 @@ def _get_tables(self, schema):
class CockroachDB(PostgreSQL):
-
@classmethod
def type(cls):
return "cockroach"
diff --git a/redash/query_runner/prometheus.py b/redash/query_runner/prometheus.py
index 088291df86..6279d90a69 100644
--- a/redash/query_runner/prometheus.py
+++ b/redash/query_runner/prometheus.py
@@ -64,6 +64,7 @@ def convert_query_range(payload):
class Prometheus(BaseQueryRunner):
+ should_annotate_query = False
@classmethod
def configuration_schema(cls):
@@ -78,10 +79,6 @@ def configuration_schema(cls):
"required": ["url"]
}
- @classmethod
- def annotate_query(cls):
- return False
-
def test_connection(self):
resp = requests.get(self.configuration.get("url", None))
return resp.ok
diff --git a/redash/query_runner/python.py b/redash/query_runner/python.py
index 36209cd0ea..8a516965d3 100644
--- a/redash/query_runner/python.py
+++ b/redash/query_runner/python.py
@@ -36,6 +36,8 @@ def __call__(self):
class Python(BaseQueryRunner):
+ should_annotate_query = False
+
safe_builtins = (
'sorted', 'reversed', 'map', 'reduce', 'any', 'all',
'slice', 'filter', 'len', 'next', 'enumerate',
@@ -63,10 +65,6 @@ def configuration_schema(cls):
def enabled(cls):
return True
- @classmethod
- def annotate_query(cls):
- return False
-
def __init__(self, configuration):
super(Python, self).__init__(configuration)
diff --git a/redash/query_runner/qubole.py b/redash/query_runner/qubole.py
index d62260cd5f..82276ca139 100644
--- a/redash/query_runner/qubole.py
+++ b/redash/query_runner/qubole.py
@@ -11,19 +11,26 @@
try:
import qds_sdk
from qds_sdk.qubole import Qubole as qbol
- from qds_sdk.commands import Command, HiveCommand, PrestoCommand
+ from qds_sdk.commands import Command, HiveCommand
+ from qds_sdk.commands import SqlCommand, PrestoCommand
enabled = True
except ImportError:
enabled = False
class Qubole(BaseQueryRunner):
+ should_annotate_query = False
@classmethod
def configuration_schema(cls):
return {
"type": "object",
"properties": {
+ "query_type": {
+ "type": "string",
+ "title": "Query Type (quantum / presto / hive)",
+ "default": "hive"
+ },
"endpoint": {
"type": "string",
"title": "API Endpoint",
@@ -37,38 +44,47 @@ def configuration_schema(cls):
"type": "string",
"title": "Cluster Label",
"default": "default"
- },
- "query_type": {
- "type": "string",
- "title": "Query Type (hive or presto)",
- "default": "hive"
}
},
- "order": ["endpoint", "token", "cluster"],
- "required": ["endpoint", "token", "cluster"],
+ "order": ["query_type", "endpoint", "token", "cluster"],
+ "required": ["endpoint", "token"],
"secret": ["token"]
}
@classmethod
- def enabled(cls):
- return enabled
+ def type(cls):
+ return "qubole"
@classmethod
- def annotate_query(cls):
- return False
+ def name(cls):
+ return "Qubole"
+
+ @classmethod
+ def enabled(cls):
+ return enabled
def test_connection(self):
headers = self._get_header()
- r = requests.head("%s/api/latest/users" % self.configuration['endpoint'], headers=headers)
+ r = requests.head("%s/api/latest/users" % self.configuration.get('endpoint'), headers=headers)
r.status_code == 200
def run_query(self, query, user):
- qbol.configure(api_token=self.configuration['token'],
- api_url='%s/api' % self.configuration['endpoint'])
+ qbol.configure(api_token=self.configuration.get('token'),
+ api_url='%s/api' % self.configuration.get('endpoint'))
try:
- cls = PrestoCommand if(self.configuration['query_type'] == 'presto') else HiveCommand
- cmd = cls.create(query=query, label=self.configuration['cluster'])
+ query_type = self.configuration.get('query_type', 'hive')
+
+ if query_type == 'quantum':
+ cmd = SqlCommand.create(query=query)
+ elif query_type == 'hive':
+ cmd = HiveCommand.create(query=query, label=self.configuration.get('cluster'))
+ elif query_type == 'presto':
+ cmd = PrestoCommand.create(query=query, label=self.configuration.get('cluster'))
+ else:
+ raise Exception("Invalid Query Type:%s.\
+ It must be : hive / presto / quantum." % self.configuration.get('query_type'))
+
logging.info("Qubole command created with Id: %s and Status: %s", cmd.id, cmd.status)
while not Command.is_done(cmd.status):
@@ -106,7 +122,7 @@ def get_schema(self, get_stats=False):
try:
headers = self._get_header()
content = requests.get("%s/api/latest/hive?describe=true&per_page=10000" %
- self.configuration['endpoint'], headers=headers)
+ self.configuration.get('endpoint'), headers=headers)
data = content.json()
for schema in data['schemas']:
@@ -127,7 +143,7 @@ def get_schema(self, get_stats=False):
def _get_header(self):
return {"Content-type": "application/json", "Accept": "application/json",
- "X-AUTH-TOKEN": self.configuration['token']}
+ "X-AUTH-TOKEN": self.configuration.get('token')}
register(Qubole)
diff --git a/redash/query_runner/query_results.py b/redash/query_runner/query_results.py
index 910df7c9c1..97e174e398 100644
--- a/redash/query_runner/query_results.py
+++ b/redash/query_runner/query_results.py
@@ -3,7 +3,7 @@
import sqlite3
from redash import models
-from redash.permissions import has_access, not_view_only
+from redash.permissions import has_access, view_only
from redash.query_runner import BaseQueryRunner, TYPE_STRING, guess_type, register
from redash.utils import json_dumps, json_loads
@@ -24,7 +24,8 @@ def extract_query_ids(query):
def extract_cached_query_ids(query):
- queries = re.findall(r'(?:join|from)\s+cached_query_(\d+)', query, re.IGNORECASE)
+ queries = re.findall(r'(?:join|from)\s+cached_query_(\d+)', query,
+ re.IGNORECASE)
return [int(q) for q in queries]
@@ -34,9 +35,11 @@ def _load_query(user, query_id):
if user.org_id != query.org_id:
raise PermissionError("Query id {} not found.".format(query.id))
- if not has_access(query.data_source, user, not_view_only):
- raise PermissionError(u"You are not allowed to execute queries on {} data source (used for query id {}).".format(
- query.data_source.name, query.id))
+ # TODO: this duplicates some of the logic we already have in the redash.handlers.query_results.
+ # We should merge it so it's consistent.
+ if not has_access(query.data_source, user, view_only):
+ raise PermissionError(u"You do not have access to query id {}.".format(
+ query.id))
return query
@@ -47,16 +50,22 @@ def get_query_results(user, query_id, bring_from_cache):
if query.latest_query_data_id is not None:
results = query.latest_query_data.data
else:
- raise Exception("No cached result available for query {}.".format(query.id))
+ raise Exception("No cached result available for query {}.".format(
+ query.id))
else:
- results, error = query.data_source.query_runner.run_query(query.query_text, user)
+ results, error = query.data_source.query_runner.run_query(
+ query.query_text, user)
if error:
- raise Exception("Failed loading results for query id {}.".format(query.id))
+ raise Exception("Failed loading results for query id {}.".format(
+ query.id))
return json_loads(results)
-def create_tables_from_query_ids(user, connection, query_ids, cached_query_ids=[]):
+def create_tables_from_query_ids(user,
+ connection,
+ query_ids,
+ cached_query_ids=[]):
for query_id in set(cached_query_ids):
results = get_query_results(user, query_id, True)
table_name = 'cached_query_{query_id}'.format(query_id=query_id)
@@ -81,8 +90,7 @@ def flatten(value):
def create_table(connection, table_name, query_results):
try:
- columns = [column['name']
- for column in query_results['columns']]
+ columns = [column['name'] for column in query_results['columns']]
safe_columns = [fix_column_name(column) for column in columns]
column_list = ", ".join(safe_columns)
@@ -91,7 +99,8 @@ def create_table(connection, table_name, query_results):
logger.debug("CREATE TABLE query: %s", create_table)
connection.execute(create_table)
except sqlite3.OperationalError as exc:
- raise CreateTableError(u"Error creating table {}: {}".format(table_name, exc.message))
+ raise CreateTableError(u"Error creating table {}: {}".format(
+ table_name, exc.message))
insert_template = u"insert into {table_name} ({column_list}) values ({place_holders})".format(
table_name=table_name,
@@ -104,19 +113,12 @@ def create_table(connection, table_name, query_results):
class Results(BaseQueryRunner):
+ should_annotate_query = False
noop_query = 'SELECT 1'
@classmethod
def configuration_schema(cls):
- return {
- "type": "object",
- "properties": {
- }
- }
-
- @classmethod
- def annotate_query(cls):
- return False
+ return {"type": "object", "properties": {}}
@classmethod
def name(cls):
@@ -127,7 +129,8 @@ def run_query(self, query, user):
query_ids = extract_query_ids(query)
cached_query_ids = extract_cached_query_ids(query)
- create_tables_from_query_ids(user, connection, query_ids, cached_query_ids)
+ create_tables_from_query_ids(user, connection, query_ids,
+ cached_query_ids)
cursor = connection.cursor()
@@ -135,8 +138,8 @@ def run_query(self, query, user):
cursor.execute(query)
if cursor.description is not None:
- columns = self.fetch_columns(
- [(i[0], None) for i in cursor.description])
+ columns = self.fetch_columns([(i[0], None)
+ for i in cursor.description])
rows = []
column_names = [c['name'] for c in columns]
diff --git a/redash/query_runner/salesforce.py b/redash/query_runner/salesforce.py
index b1187bef58..8cc72910ff 100644
--- a/redash/query_runner/salesforce.py
+++ b/redash/query_runner/salesforce.py
@@ -50,15 +50,12 @@
class Salesforce(BaseQueryRunner):
-
+ should_annotate_query = False
+
@classmethod
def enabled(cls):
return enabled
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def configuration_schema(cls):
return {
diff --git a/redash/query_runner/script.py b/redash/query_runner/script.py
index 38e3ae62c5..6c529e9e39 100644
--- a/redash/query_runner/script.py
+++ b/redash/query_runner/script.py
@@ -29,9 +29,7 @@ def run_script(script, shell):
class Script(BaseQueryRunner):
- @classmethod
- def annotate_query(cls):
- return False
+ should_annotate_query = False
@classmethod
def enabled(cls):
diff --git a/redash/query_runner/treasuredata.py b/redash/query_runner/treasuredata.py
index 320f4e3457..895becaac3 100644
--- a/redash/query_runner/treasuredata.py
+++ b/redash/query_runner/treasuredata.py
@@ -34,6 +34,7 @@
class TreasureData(BaseQueryRunner):
+ should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
@@ -67,10 +68,6 @@ def configuration_schema(cls):
def enabled(cls):
return enabled
- @classmethod
- def annotate_query(cls):
- return False
-
@classmethod
def type(cls):
return "treasuredata"
diff --git a/redash/query_runner/uptycs.py b/redash/query_runner/uptycs.py
index 9e6e7ff989..c2573a26d0 100644
--- a/redash/query_runner/uptycs.py
+++ b/redash/query_runner/uptycs.py
@@ -10,6 +10,7 @@
class Uptycs(BaseSQLQueryRunner):
+ should_annotate_query = False
noop_query = "SELECT 1"
@classmethod
@@ -40,10 +41,6 @@ def configuration_schema(cls):
"secret": ["secret", "key"]
}
- @classmethod
- def annotate_query(cls):
- return False
-
def generate_header(self, key, secret):
header = {}
utcnow = datetime.datetime.utcnow()
diff --git a/redash/query_runner/url.py b/redash/query_runner/url.py
index d32a20ee01..d53cf1a9f0 100644
--- a/redash/query_runner/url.py
+++ b/redash/query_runner/url.py
@@ -6,10 +6,6 @@
class Url(BaseHTTPQueryRunner):
requires_url = False
- @classmethod
- def annotate_query(cls):
- return False
-
def test_connection(self):
pass
diff --git a/redash/query_runner/yandex_metrica.py b/redash/query_runner/yandex_metrica.py
index 82f47d8565..d008b8a505 100644
--- a/redash/query_runner/yandex_metrica.py
+++ b/redash/query_runner/yandex_metrica.py
@@ -62,9 +62,7 @@ def parse_ym_response(response):
class YandexMetrica(BaseSQLQueryRunner):
- @classmethod
- def annotate_query(cls):
- return False
+ should_annotate_query = False
@classmethod
def type(cls):
diff --git a/redash/serializers/query_result.py b/redash/serializers/query_result.py
index 6432795c66..ff737e3a28 100644
--- a/redash/serializers/query_result.py
+++ b/redash/serializers/query_result.py
@@ -70,7 +70,7 @@ def serialize_query_result_to_csv(query_result):
query_data = json_loads(query_result.data)
- fieldnames, special_columns = _get_column_lists(query_data['columns'])
+ fieldnames, special_columns = _get_column_lists(query_data['columns'] or [])
writer = csv.DictWriter(s, extrasaction="ignore", fieldnames=fieldnames)
writer.writer = UnicodeWriter(s)
diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py
index e2f0323043..1a7154dfe6 100644
--- a/redash/settings/__init__.py
+++ b/redash/settings/__init__.py
@@ -293,6 +293,7 @@ def email_server_is_configured():
'redash.query_runner.json_ds',
'redash.query_runner.cass',
'redash.query_runner.dgraph',
+ 'redash.query_runner.azure_kusto',
]
enabled_query_runners = array_from_string(os.environ.get("REDASH_ENABLED_QUERY_RUNNERS", ",".join(default_query_runners)))
diff --git a/redash/settings/organization.py b/redash/settings/organization.py
index 37c5b73260..853a6cd4ec 100644
--- a/redash/settings/organization.py
+++ b/redash/settings/organization.py
@@ -35,6 +35,7 @@
os.environ.get('REDASH_SEND_EMAIL_ON_FAILED_SCHEDULED_QUERIES', 'false'))
settings = {
+ "beacon_consent": None,
"auth_password_login_enabled": PASSWORD_LOGIN_ENABLED,
"auth_saml_enabled": SAML_LOGIN_ENABLED,
"auth_saml_entity_id": SAML_ENTITY_ID,
diff --git a/redash/tasks/queries.py b/redash/tasks/queries.py
index 1ba0c8fc79..0ee9cd0ab0 100644
--- a/redash/tasks/queries.py
+++ b/redash/tasks/queries.py
@@ -400,16 +400,12 @@ def run(self):
return result
def _annotate_query(self, query_runner):
- if query_runner.annotate_query():
- self.metadata['Task ID'] = self.task.request.id
- self.metadata['Query Hash'] = self.query_hash
- self.metadata['Queue'] = self.task.request.delivery_info['routing_key']
-
- annotation = u", ".join([u"{}: {}".format(k, v) for k, v in self.metadata.iteritems()])
- annotated_query = u"/* {} */ {}".format(annotation, self.query)
- else:
- annotated_query = self.query
- return annotated_query
+ self.metadata['Task ID'] = self.task.request.id
+ self.metadata['Query Hash'] = self.query_hash
+ self.metadata['Queue'] = self.task.request.delivery_info['routing_key']
+ self.metadata['Scheduled'] = self.scheduled_query is not None
+
+ return query_runner.annotate_query(self.query, self.metadata)
def _log_progress(self, state):
logger.info(
diff --git a/redash/utils/__init__.py b/redash/utils/__init__.py
index 8f14931bbf..5f396e29b9 100644
--- a/redash/utils/__init__.py
+++ b/redash/utils/__init__.py
@@ -21,6 +21,11 @@
from .human_time import parse_human_time
+try:
+ buffer
+except NameError:
+ buffer = bytes
+
COMMENTS_REGEX = re.compile("/\*.*?\*/")
WRITER_ENCODING = os.environ.get('REDASH_CSV_WRITER_ENCODING', 'utf-8')
WRITER_ERRORS = os.environ.get('REDASH_CSV_WRITER_ERRORS', 'strict')
diff --git a/redash/utils/compat.py b/redash/utils/compat.py
new file mode 100644
index 0000000000..cb4ebfb8ab
--- /dev/null
+++ b/redash/utils/compat.py
@@ -0,0 +1,4 @@
+try:
+ long = long
+except NameError:
+ long = int
diff --git a/redash/utils/sentry.py b/redash/utils/sentry.py
index b1ce2e5e5a..1947e1a3a2 100644
--- a/redash/utils/sentry.py
+++ b/redash/utils/sentry.py
@@ -1,6 +1,8 @@
import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.celery import CeleryIntegration
+from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
+from sentry_sdk.integrations.redis import RedisIntegration
from redash import settings, __version__
@@ -23,5 +25,5 @@ def init():
release=__version__,
before_send=before_send,
send_default_pii=True,
- integrations=[FlaskIntegration(), CeleryIntegration()]
+ integrations=[FlaskIntegration(), CeleryIntegration(), SqlalchemyIntegration(), RedisIntegration()]
)
diff --git a/redash/version_check.py b/redash/version_check.py
index 8d6e5b0bfd..0870460b8b 100644
--- a/redash/version_check.py
+++ b/redash/version_check.py
@@ -4,23 +4,74 @@
from redash import __version__ as current_version
from redash import redis_connection
+from redash.models import db, Organization
from redash.utils import json_dumps
REDIS_KEY = "new_version_available"
+def usage_data():
+ counts_query = """
+ SELECT 'users_count' as name, count(0) as value
+ FROM users
+ WHERE disabled_at is null
+
+ UNION ALL
+
+ SELECT 'queries_count' as name, count(0) as value
+ FROM queries
+ WHERE is_archived is false
+
+ UNION ALL
+
+ SELECT 'alerts_count' as name, count(0) as value
+ FROM alerts
+
+ UNION ALL
+
+ SELECT 'dashboards_count' as name, count(0) as value
+ FROM dashboards
+ WHERE is_archived is false
+
+ UNION ALL
+
+ SELECT 'widgets_count' as name, count(0) as value
+ FROM widgets
+ WHERE visualization_id is not null
+
+ UNION ALL
+
+ SELECT 'textbox_count' as name, count(0) as value
+ FROM widgets
+ WHERE visualization_id is null
+ """
+
+ data_sources_query = "SELECT type, count(0) FROM data_sources GROUP by 1"
+ visualizations_query = "SELECT type, count(0) FROM visualizations GROUP by 1"
+ destinations_query = "SELECT type, count(0) FROM notification_destinations GROUP by 1"
+
+ data = {name: value for (name, value) in db.session.execute(counts_query)}
+ data['data_sources'] = {name: value for (name, value) in db.session.execute(data_sources_query)}
+ data['visualization_types'] = {name: value for (name, value) in db.session.execute(visualizations_query)}
+ data['destination_types'] = {name: value for (name, value) in db.session.execute(destinations_query)}
+
+ return data
+
+
def run_version_check():
logging.info("Performing version check.")
logging.info("Current version: %s", current_version)
- data = json_dumps({
+ data = {
'current_version': current_version
- })
- headers = {'content-type': 'application/json'}
+ }
+
+ if Organization.query.first().get_setting('beacon_consent'):
+ data['usage'] = usage_data()
try:
response = requests.post('https://version.redash.io/api/report?channel=stable',
- data=data, headers=headers, timeout=3.0)
+ json=data, timeout=3.0)
latest_version = response.json()['release']['version']
_compare_and_update(latest_version)
diff --git a/requirements.txt b/requirements.txt
index d6a3363880..432d7cfd01 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -21,12 +21,12 @@ passlib==1.6.2
aniso8601==1.1.0
blinker==1.3
psycopg2==2.7.3.2
-python-dateutil==2.7.5
+python-dateutil==2.8.0
pytz==2016.7
PyYAML==3.12
redis==3.2.1
requests==2.21.0
-six==1.11.0
+six==1.12.0
SQLAlchemy==1.2.12
# We can't upgrade SQLAlchemy-Searchable version as newer versions require PostgreSQL > 9.6, but we target older versions at the moment.
SQLAlchemy-Searchable==0.10.6
@@ -43,7 +43,7 @@ RestrictedPython==3.6.0
pysaml2==4.5.0
pycrypto==2.6.1
funcy==1.7.1
-sentry-sdk==0.7.2
+sentry-sdk==0.11.2
semver==2.2.1
xlsxwriter==0.9.3
pystache==0.5.4
@@ -56,7 +56,7 @@ user-agents==1.1.0
python-geoip-geolite2==2015.303
chromelogger==0.4.3
pypd==1.1.0
-disposable-email-domains
+disposable-email-domains>=0.0.52
gevent==1.4.0
# Install the dependencies of the bin/bundle-extensions script here.
# It has its own requirements file to simplify the frontend client build process
diff --git a/requirements_all_ds.txt b/requirements_all_ds.txt
index 387b45b3e4..71347cfae8 100644
--- a/requirements_all_ds.txt
+++ b/requirements_all_ds.txt
@@ -8,11 +8,11 @@ pyhive==0.5.1
pymongo[tls,srv]==3.6.1
vertica-python==0.8.0
td-client==0.8.0
-pymssql==2.1.3
+pymssql==2.1.4
dql==0.5.24
dynamo3==0.4.7
boto3==1.9.115
-botocore==1.12.115
+botocore==1.12.220
sasl>=0.1.3
thrift>=0.8.0
thrift_sasl>=0.1.0
@@ -31,3 +31,6 @@ phoenixdb==0.7
# certifi is needed to support MongoDB and SSL:
certifi
pydgraph==1.2.0
+azure-kusto-data==0.0.32
+# pandas is a requirement for pymapd but pip will pick versions that are python3 only
+pandas==0.24.0
diff --git a/requirements_dev.txt b/requirements_dev.txt
index 7e6b60a9e5..aa9a55479b 100644
--- a/requirements_dev.txt
+++ b/requirements_dev.txt
@@ -6,7 +6,7 @@ mock==2.0.0
# PyMongo and Athena dependencies are needed for some of the unit tests:
# (this is not perfect and we should resolve this in a different way)
pymongo[tls,srv]==3.6.1
-botocore==1.12.115
+botocore==1.12.220
PyAthena>=1.5.0
ptvsd==4.2.3
freezegun==0.3.11
diff --git a/tests/query_runner/test_query_results.py b/tests/query_runner/test_query_results.py
index db047e587f..8c3da3e387 100644
--- a/tests/query_runner/test_query_results.py
+++ b/tests/query_runner/test_query_results.py
@@ -3,7 +3,9 @@
import pytest
-from redash.query_runner.query_results import (CreateTableError, PermissionError, _load_query, create_table, extract_cached_query_ids, extract_query_ids, fix_column_name)
+from redash.query_runner.query_results import (
+ CreateTableError, PermissionError, _load_query, create_table,
+ extract_cached_query_ids, extract_query_ids, fix_column_name)
from tests import BaseTestCase
@@ -28,40 +30,86 @@ def test_finds_queries_with_whitespace_characters(self):
class TestCreateTable(TestCase):
def test_creates_table_with_colons_in_column_name(self):
connection = sqlite3.connect(':memory:')
- results = {'columns': [{'name': 'ga:newUsers'}, {
- 'name': 'test2'}], 'rows': [{'ga:newUsers': 123, 'test2': 2}]}
+ results = {
+ 'columns': [{
+ 'name': 'ga:newUsers'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': [{
+ 'ga:newUsers': 123,
+ 'test2': 2
+ }]
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
connection.execute('SELECT 1 FROM query_123')
def test_creates_table_with_double_quotes_in_column_name(self):
connection = sqlite3.connect(':memory:')
- results = {'columns': [{'name': 'ga:newUsers'}, {
- 'name': '"test2"'}], 'rows': [{'ga:newUsers': 123, '"test2"': 2}]}
+ results = {
+ 'columns': [{
+ 'name': 'ga:newUsers'
+ }, {
+ 'name': '"test2"'
+ }],
+ 'rows': [{
+ 'ga:newUsers': 123,
+ '"test2"': 2
+ }]
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
connection.execute('SELECT 1 FROM query_123')
def test_creates_table(self):
connection = sqlite3.connect(':memory:')
- results = {'columns': [{'name': 'test1'},
- {'name': 'test2'}], 'rows': []}
+ results = {
+ 'columns': [{
+ 'name': 'test1'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': []
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
connection.execute('SELECT 1 FROM query_123')
def test_creates_table_with_missing_columns(self):
connection = sqlite3.connect(':memory:')
- results = {'columns': [{'name': 'test1'}, {'name': 'test2'}], 'rows': [
- {'test1': 1, 'test2': 2}, {'test1': 3}]}
+ results = {
+ 'columns': [{
+ 'name': 'test1'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': [{
+ 'test1': 1,
+ 'test2': 2
+ }, {
+ 'test1': 3
+ }]
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
connection.execute('SELECT 1 FROM query_123')
def test_creates_table_with_spaces_in_column_name(self):
connection = sqlite3.connect(':memory:')
- results = {'columns': [{'name': 'two words'}, {'name': 'test2'}], 'rows': [
- {'two words': 1, 'test2': 2}, {'test1': 3}]}
+ results = {
+ 'columns': [{
+ 'name': 'two words'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': [{
+ 'two words': 1,
+ 'test2': 2
+ }, {
+ 'test1': 3
+ }]
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
connection.execute('SELECT 1 FROM query_123')
@@ -69,8 +117,15 @@ def test_creates_table_with_spaces_in_column_name(self):
def test_creates_table_with_dashes_in_column_name(self):
connection = sqlite3.connect(':memory:')
results = {
- 'columns': [{'name': 'two-words'}, {'name': 'test2'}],
- 'rows': [{'two-words': 1, 'test2': 2}]
+ 'columns': [{
+ 'name': 'two-words'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': [{
+ 'two-words': 1,
+ 'test2': 2
+ }]
}
table_name = 'query_123'
create_table(connection, table_name, results)
@@ -79,8 +134,17 @@ def test_creates_table_with_dashes_in_column_name(self):
def test_creates_table_with_non_ascii_in_column_name(self):
connection = sqlite3.connect(':memory:')
- results = {'columns': [{'name': u'\xe4'}, {'name': 'test2'}], 'rows': [
- {u'\xe4': 1, 'test2': 2}]}
+ results = {
+ 'columns': [{
+ 'name': u'\xe4'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': [{
+ u'\xe4': 1,
+ 'test2': 2
+ }]
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
connection.execute('SELECT 1 FROM query_123')
@@ -95,8 +159,14 @@ def test_shows_meaningful_error_on_failure_to_create_table(self):
def test_loads_results(self):
connection = sqlite3.connect(':memory:')
rows = [{'test1': 1, 'test2': 'test'}, {'test1': 2, 'test2': 'test2'}]
- results = {'columns': [{'name': 'test1'},
- {'name': 'test2'}], 'rows': rows}
+ results = {
+ 'columns': [{
+ 'name': 'test1'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': rows
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
self.assertEquals(
@@ -104,9 +174,15 @@ def test_loads_results(self):
def test_loads_list_and_dict_results(self):
connection = sqlite3.connect(':memory:')
- rows = [{'test1': [1,2,3]}, {'test2': {'a': 'b'}}]
- results = {'columns': [{'name': 'test1'},
- {'name': 'test2'}], 'rows': rows}
+ rows = [{'test1': [1, 2, 3]}, {'test2': {'a': 'b'}}]
+ results = {
+ 'columns': [{
+ 'name': 'test1'
+ }, {
+ 'name': 'test2'
+ }],
+ 'rows': rows
+ }
table_name = 'query_123'
create_table(connection, table_name, results)
self.assertEquals(
@@ -135,6 +211,15 @@ def test_returns_query(self):
loaded = _load_query(user, query.id)
self.assertEquals(query, loaded)
+ def test_returns_query_when_user_has_view_only_access(self):
+ ds = self.factory.create_data_source(
+ group=self.factory.org.default_group, view_only=True)
+ query = self.factory.create_query(data_source=ds)
+ user = self.factory.create_user()
+
+ loaded = _load_query(user, query.id)
+ self.assertEquals(query, loaded)
+
class TestExtractCachedQueryIds(TestCase):
def test_works_with_simple_query(self):
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 9117ccc9dd..493d3fcaef 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,6 +4,11 @@
from redash.utils import (build_url, collect_parameters_from_request,
filter_none, json_dumps, generate_token)
+try:
+ buffer
+except NameError:
+ buffer = bytes
+
DummyRequest = namedtuple('DummyRequest', ['host', 'scheme'])