diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx index 07bcaa255b0b28fc887723c29a5db3a7077bbdb2..d388dfcec98a16d474d28e8b6b51051b30e1122d 100644 --- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx +++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx @@ -56,8 +56,12 @@ export default class LineAreaBarChart extends Component<*, VisualizationProps, * return getChartTypeFromData(cols, rows, false) != null; } - static checkRenderable([{ data: { cols, rows} }], settings) { - if (rows.length < 1) { throw new MinRowsError(1, rows.length); } + static checkRenderable(series, settings) { + const singleSeriesHasNoRows = ({ data: { cols, rows} }) => rows.length < 1; + if (_.every(series, singleSeriesHasNoRows)) { + throw new MinRowsError(1, 0); + } + const dimensions = (settings["graph.dimensions"] || []).filter(name => name); const metrics = (settings["graph.metrics"] || []).filter(name => name); if (dimensions.length < 1 || metrics.length < 1) { diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index bfc935a93c8a4d2eb64c4c7244af5d42c5f434a1..44563d1b2daad708d0c8e7247a20d20658e289bb 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -275,7 +275,7 @@ export default class Visualization extends Component<*, Props, State> { if (!error) { // $FlowFixMe - noResults = series[0] && series[0].data && datasetContainsNoResults(series[0].data); + noResults = _.every(series, s => s && s.data && datasetContainsNoResults(s.data)); } let extra = ( diff --git a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js index a2ecd311f47ef29c49de0db216ad4f33c9701925..e2e681c57aab5b14f1b37919798c3a47968dbb64 100644 --- a/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js +++ b/frontend/src/metabase/visualizations/lib/LineAreaBarRenderer.js @@ -33,6 +33,10 @@ import { determineSeriesIndexFromElement } from "./tooltip"; import { formatValue } from "metabase/lib/formatting"; import { parseTimestamp } from "metabase/lib/time"; +import { datasetContainsNoResults } from "metabase/lib/dataset"; + +import type { Series } from "metabase/meta/types/Visualization" + const MIN_PIXELS_PER_TICK = { x: 100, y: 32 }; const BAR_PADDING_RATIO = 0.2; const DEFAULT_INTERPOLATION = "linear"; @@ -103,11 +107,15 @@ function initChart(chart, element) { } function applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xInterval) { + // find the first nonempty single series + // $FlowFixMe + const firstSeries: Series = _.find(series, (s) => !datasetContainsNoResults(s.data)); + // setup an x-axis where the dimension is a timeseries - let dimensionColumn = series[0].data.cols[0]; + let dimensionColumn = firstSeries.data.cols[0]; // get the data's timezone offset from the first row - let dataOffset = parseTimestamp(series[0].data.rows[0][0]).utcOffset() / 60; + let dataOffset = parseTimestamp(firstSeries.data.rows[0][0]).utcOffset() / 60; // compute the data interval let dataInterval = xInterval; @@ -149,7 +157,10 @@ function applyChartTimeseriesXAxis(chart, settings, series, xValues, xDomain, xI } function applyChartQuantitativeXAxis(chart, settings, series, xValues, xDomain, xInterval) { - const dimensionColumn = series[0].data.cols[0]; + // find the first nonempty single series + // $FlowFixMe + const firstSeries: Series = _.find(series, (s) => !datasetContainsNoResults(s.data)); + const dimensionColumn = firstSeries.data.cols[0]; if (settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel(settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), X_LABEL_PADDING); @@ -187,7 +198,11 @@ function applyChartQuantitativeXAxis(chart, settings, series, xValues, xDomain, } function applyChartOrdinalXAxis(chart, settings, series, xValues) { - const dimensionColumn = series[0].data.cols[0]; + // find the first nonempty single series + // $FlowFixMe + const firstSeries: Series = _.find(series, (s) => !datasetContainsNoResults(s.data)); + + const dimensionColumn = firstSeries.data.cols[0]; if (settings["graph.x_axis.labels_enabled"]) { chart.xAxisLabel(settings["graph.x_axis.title_text"] || getFriendlyName(dimensionColumn), X_LABEL_PADDING); @@ -812,10 +827,14 @@ export default function lineAreaBar(element, { series, onHoverChange, onVisualiz const isQuantitative = ["linear", "log", "pow"].indexOf(settings["graph.x_axis.scale"]) >= 0; const isOrdinal = !isTimeseries && !isQuantitative; - const isDimensionTimeseries = dimensionIsTimeseries(series[0].data); - const isDimensionNumeric = dimensionIsNumeric(series[0].data); + // find the first nonempty single series + // $FlowFixMe + const firstSeries: Series = _.find(series, (s) => !datasetContainsNoResults(s.data)); + + const isDimensionTimeseries = dimensionIsTimeseries(firstSeries.data); + const isDimensionNumeric = dimensionIsNumeric(firstSeries.data); - if (series[0].data.cols.length < 2) { + if (firstSeries.data.cols.length < 2) { throw new Error("This chart type requires at least 2 columns."); } @@ -955,7 +974,11 @@ export default function lineAreaBar(element, { series, onHoverChange, onVisualiz dimension = dataset.dimension(d => d[0]); groups = datas.map((data, seriesIndex) => { + // If the value is empty, pass a dummy array to crossfilter + data = data.length > 0 ? data : [[null, null]]; + let dim = crossfilter(data).dimension(d => d[0]); + return data[0].slice(1).map((_, metricIndex) => reduceGroup(dim.group(), metricIndex + 1, () => warn(UNAGGREGATED_DATA_WARNING(series[seriesIndex].data.cols[0]))) ); diff --git a/frontend/test/unit/visualizations/lib/LineAreaBarRenderer.spec.js b/frontend/test/unit/visualizations/lib/LineAreaBarRenderer.spec.js index e3e5033981c577cb0fc5b1ce86e4018a9dfac329..fe7c0ca28814874dd39d2ae19bac89049fcaeb08 100644 --- a/frontend/test/unit/visualizations/lib/LineAreaBarRenderer.spec.js +++ b/frontend/test/unit/visualizations/lib/LineAreaBarRenderer.spec.js @@ -4,7 +4,7 @@ import { formatValue } from "metabase/lib/formatting"; import d3 from "d3"; -import { DateTimeColumn, NumberColumn } from "../../support/visualizations"; +import { DateTimeColumn, NumberColumn, StringColumn } from "../../support/visualizations"; let formatTz = (offset) => (offset < 0 ? "-" : "+") + d3.format("02d")(Math.abs(offset)) + ":00" @@ -26,10 +26,12 @@ describe("LineAreaBarRenderer", () => { it("should display numeric year in X-axis and tooltip correctly", (done) => { renderTimeseriesLine({ - rows: [ - [2015, 1], - [2016, 2], - [2017, 3] + rowsOfSeries: [ + [ + [2015, 1], + [2016, 2], + [2017, 3] + ] ], unit: "year", onHoverChange: (hover) => { @@ -53,8 +55,9 @@ describe("LineAreaBarRenderer", () => { ["2016-10-03T20:00:00.000" + tz, 1], ["2016-10-03T21:00:00.000" + tz, 1], ]; + renderTimeseriesLine({ - rows, + rowsOfSeries: [rows], unit: "hour", onHoverChange: (hover) => { let expected = rows.map(row => formatValue(row[0], { column: DateTimeColumn({ unit: "hour" }) })); @@ -78,7 +81,7 @@ describe("LineAreaBarRenderer", () => { ["2016-01-01T04:00:00.000" + tz, 1] ]; renderTimeseriesLine({ - rows, + rowsOfSeries: [rows], unit: "hour", onHoverChange: (hover) => { expect(formatValue(rows[0][0], { column: DateTimeColumn({ unit: "hour" }) })).toEqual( @@ -99,6 +102,84 @@ describe("LineAreaBarRenderer", () => { dispatchUIEvent(qs("svg .dot"), "mousemove"); }); + describe("should render correctly a compound line graph", () => { + const rowsOfNonemptyCard = [ + [2015, 1], + [2016, 2], + [2017, 3] + ] + + it("when only second series is not empty", () => { + renderTimeseriesLine({ + rowsOfSeries: [ + [], rowsOfNonemptyCard, [], [] + ], + unit: "hour" + }); + + // A simple check to ensure that lines are rendered as expected + expect(qs("svg .line")).not.toBe(null); + }); + + it("when only first series is not empty", () => { + renderTimeseriesLine({ + rowsOfSeries: [ + rowsOfNonemptyCard, [], [], [] + ], + unit: "hour" + }); + + expect(qs("svg .line")).not.toBe(null); + }); + + it("when there are many empty and nonempty values ", () => { + renderTimeseriesLine({ + rowsOfSeries: [ + [], rowsOfNonemptyCard, [], [], rowsOfNonemptyCard, [], rowsOfNonemptyCard + ], + unit: "hour" + }); + expect(qs("svg .line")).not.toBe(null); + }); + }) + + describe("should render correctly a compound bar graph", () => { + it("when only second series is not empty", () => { + renderScalarBar({ + scalars: [ + ["Non-empty value", null], + ["Empty value", 25] + ] + }) + expect(qs("svg .bar")).not.toBe(null); + }); + + it("when only first series is not empty", () => { + renderScalarBar({ + scalars: [ + ["Non-empty value", 15], + ["Empty value", null] + ] + }) + expect(qs("svg .bar")).not.toBe(null); + }); + + it("when there are many empty and nonempty scalars", () => { + renderScalarBar({ + scalars: [ + ["Empty value", null], + ["Non-empty value", 15], + ["2nd empty value", null], + ["2nd non-empty value", 35], + ["3rd empty value", null], + ["4rd empty value", null], + ["3rd non-empty value", 0], + ] + }) + expect(qs("svg .bar")).not.toBe(null); + }); + }) + // querySelector shortcut const qs = (selector) => element.querySelector(selector); @@ -106,15 +187,15 @@ describe("LineAreaBarRenderer", () => { const qsa = (selector) => [...element.querySelectorAll(selector)]; // helper for timeseries line charts - const renderTimeseriesLine = ({ rows, onHoverChange, unit }) => { + const renderTimeseriesLine = ({ rowsOfSeries, onHoverChange, unit }) => { lineAreaBarRenderer(element, { chartType: "line", - series: [{ + series: rowsOfSeries.map((rows) => ({ data: { "cols" : [DateTimeColumn({ unit }), NumberColumn()], "rows" : rows } - }], + })), settings: { "graph.x_axis.scale": "timeseries", "graph.x_axis.axis_enabled": true, @@ -123,6 +204,27 @@ describe("LineAreaBarRenderer", () => { onHoverChange }); } + + const renderScalarBar = ({ scalars, onHoverChange, unit }) => { + lineAreaBarRenderer(element, { + chartType: "bar", + series: scalars.map((scalar) => ({ + data: { + "cols" : [StringColumn(), NumberColumn()], + "rows" : [scalar] + } + })), + settings: { + "bar.scalar_series": true, + "funnel.type": "bar", + "graph.colors": ["#509ee3", "#9cc177", "#a989c5", "#ef8c8c"], + "graph.x_axis.axis_enabled": true, + "graph.x_axis.scale": "ordinal", + "graph.x_axis._is_numeric": false + }, + onHoverChange + }); + } }); function dispatchUIEvent(element, eventName) {