From dddf499a362ce277a7f5d0c18686c9d7d6ebe686 Mon Sep 17 00:00:00 2001 From: Anton Kulyk <kuliks.anton@gmail.com> Date: Wed, 25 Sep 2024 17:50:26 +0100 Subject: [PATCH] Additional tooltip metrics for waterfall and scatter charts (#48088) --- .../visualizations-charts/scatter.cy.spec.js | 54 ++++++- .../waterfall.cy.spec.js | 147 ++++++++++++++---- .../echarts/cartesian/option/tooltip.tsx | 6 + .../visualizations/echarts/tooltip/index.tsx | 21 ++- .../visualizations/lib/settings/graph.js | 7 +- .../shared/settings/cartesian-chart.ts | 35 +++-- .../CartesianChart/CartesianChart.tsx | 5 +- .../CartesianChart/chart-definition.ts | 7 - .../visualizations/CartesianChart/events.ts | 25 ++- .../CartesianChart/use-models-and-option.ts | 10 +- 10 files changed, 243 insertions(+), 74 deletions(-) diff --git a/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js b/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js index 74612c2db25..f8e7c4493f7 100644 --- a/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js +++ b/e2e/test/scenarios/visualizations-charts/scatter.cy.spec.js @@ -2,7 +2,9 @@ import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { assertEChartsTooltip, + assertEChartsTooltipNotContain, cartesianChartCircle, + leftSidebar, restore, visitQuestionAdhoc, } from "e2e/support/helpers"; @@ -43,11 +45,8 @@ describe("scenarios > visualizations > scatter", () => { triggerPopoverForBubble(); assertEChartsTooltip({ + header: "May 2023", rows: [ - { - name: "Created At", - value: "May 2023", - }, { name: "Count", value: "271", @@ -80,11 +79,8 @@ describe("scenarios > visualizations > scatter", () => { triggerPopoverForBubble(); assertEChartsTooltip({ + header: "May 2023", rows: [ - { - name: "Created At", - value: "May 2023", - }, { name: "Orders count", value: "271", @@ -145,6 +141,48 @@ select 10 as size, 2 as x, 5 as y`, }); }); }); + + it("should allow adding non-series columns to the tooltip", () => { + visitQuestionAdhoc({ + display: "scatter", + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { "source-table": ORDERS_ID }, + }, + visualization_settings: { + "graph.metrics": ["TAX"], + "graph.dimensions": ["SUBTOTAL"], + }, + }); + + cartesianChartCircle().first().realHover(); + assertEChartsTooltip({ + header: "15.69", + rows: [{ name: "Tax", value: "0.86" }], + }); + assertEChartsTooltipNotContain(["Total", "Discount", "Quantity"]); + + cy.findByTestId("viz-settings-button").click(); + + leftSidebar().within(() => { + cy.findByText("Display").click(); + cy.findByPlaceholderText("Enter metric names").click(); + }); + cy.findByRole("option", { name: "Total" }).click(); + cy.findByRole("option", { name: "Discount" }).click(); + + cartesianChartCircle().first().realHover(); + assertEChartsTooltip({ + header: "15.69", + rows: [ + { name: "Tax", value: "0.86" }, + { name: "Total", value: "16.55" }, + { name: "Discount", value: "(empty)" }, + ], + }); + assertEChartsTooltipNotContain(["Quantity"]); + }); }); function triggerPopoverForBubble(index = 13) { diff --git a/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js b/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js index 340fa886fd0..aec96100d95 100644 --- a/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js +++ b/e2e/test/scenarios/visualizations-charts/waterfall.cy.spec.js @@ -2,10 +2,14 @@ import { SAMPLE_DB_ID } from "e2e/support/cypress_data"; import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database"; import { assertEChartsTooltip, + assertEChartsTooltipNotContain, chartPathWithFillColor, + createQuestion, echartsContainer, + leftSidebar, openNativeEditor, openOrdersTable, + popover, restore, summarize, visitQuestionAdhoc, @@ -138,32 +142,79 @@ describe("scenarios > visualizations > waterfall", () => { echartsContainer().get("text").contains("Total").should("not.exist"); }); - it("should show error for multi-series questions (metabase#15152)", () => { - visitQuestionAdhoc({ - dataset_query: { - type: "query", - query: { - "source-table": ORDERS_ID, - aggregation: [["count"], ["sum", ["field-id", ORDERS.TOTAL]]], - breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }]], - }, - database: SAMPLE_DB_ID, + describe("multi-series (metabase#15152)", () => { + const DATASET_QUERY = { + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["count"], ["sum", ["field-id", ORDERS.TOTAL]]], + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }]], }, - display: "line", - }); + database: SAMPLE_DB_ID, + }; - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Visualization").click(); - switchToWaterfallDisplay(); - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText("Waterfall chart does not support multiple series"); + function testSwitchingToWaterfall() { + cy.findByTestId("viz-type-button").click(); + switchToWaterfallDisplay(); - echartsContainer().should("not.exist"); - cy.findByTestId("remove-count").click(); - echartsContainer().should("exist"); // Chart renders after removing the second metric + echartsContainer().within(() => { + cy.findByText("Created At").should("exist"); // x-axis + cy.findByText("Count").should("exist"); // y-axis + cy.findByText("Sum of Total").should("not.exist"); + + // x-axis labels (some) + ["2022", "2023", "2026", "Total"].forEach(label => { + cy.findByText(label).should("exist"); + }); + + // y-axis labels (some) + ["0", "3,000", "6,000", "18,000", "21,000"].forEach(label => { + cy.findByText(label).should("exist"); + }); + }); + + leftSidebar().within(() => { + cy.findByText("Count").should("exist"); + cy.findByText("Sum of Total").should("not.exist"); + cy.findByText(/Add another/).should("not.exist"); + + cy.findByText("Count").click(); + }); + popover().findByText("Sum of Total").click(); + leftSidebar().within(() => { + cy.findByText("Sum of Total").should("exist"); + cy.findByText("Count").should("not.exist"); + }); + + echartsContainer().within(() => { + cy.findByText("Sum of Total").should("exist"); // x-axis + cy.findByText("Created At").should("exist"); // y-axis + cy.findByText("Count").should("not.exist"); + + // x-axis labels (some) + ["2022", "2023", "2026", "Total"].forEach(label => { + cy.findByText(label).should("exist"); + }); + + // y-axis labels (some) + ["0", "300,000", "900,000", "1,800,000"].forEach(label => { + cy.findByText(label).should("exist"); + }); + }); + } - // eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage - cy.findByText(/Add another/).should("not.exist"); + it("should correctly switch into single-series mode for ad-hoc queries", () => { + visitQuestionAdhoc({ dataset_query: DATASET_QUERY, display: "line" }); + testSwitchingToWaterfall(); + }); + + it("should correctly switch into single-series mode for ad-hoc queries", () => { + createQuestion( + { name: "Q1", query: DATASET_QUERY.query, display: "line" }, + { visitQuestion: true }, + ); + testSwitchingToWaterfall(); + }); }); it("should not allow you to choose X-axis breakout", () => { @@ -305,10 +356,6 @@ describe("scenarios > visualizations > waterfall", () => { assertEChartsTooltip({ rows: [ - { - name: "C1", - value: "a", - }, { name: "C2", value: "0.2", @@ -317,6 +364,54 @@ describe("scenarios > visualizations > waterfall", () => { }); }); + it("should allow adding non-series columns to the tooltip", () => { + const INCREASE_COLOR = "#00FF00"; + + function getFirstWaterfallSegment() { + return echartsContainer().find(`path[fill='${INCREASE_COLOR}']`).first(); + } + + visitQuestionAdhoc({ + display: "waterfall", + dataset_query: { + type: "query", + database: SAMPLE_DB_ID, + query: { + "source-table": ORDERS_ID, + aggregation: [["count"], ["sum", ["field-id", ORDERS.TOTAL]]], + breakout: [["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }]], + }, + }, + visualization_settings: { + "waterfall.increase_color": INCREASE_COLOR, + }, + }); + + getFirstWaterfallSegment().realHover(); + assertEChartsTooltip({ + header: "2022", + rows: [{ name: "Count", value: "744", color: INCREASE_COLOR }], + }); + assertEChartsTooltipNotContain(["Sum of Total"]); + + cy.findByTestId("viz-settings-button").click(); + + leftSidebar().within(() => { + cy.findByText("Display").click(); + cy.findByPlaceholderText("Enter metric names").click(); + }); + cy.findByRole("option", { name: "Sum of Total" }).click(); + + getFirstWaterfallSegment().realHover(); + assertEChartsTooltip({ + header: "2022", + rows: [ + { name: "Count", value: "744", color: INCREASE_COLOR }, + { name: "Sum of Total", value: "42,156.87" }, + ], + }); + }); + describe("scenarios > visualizations > waterfall settings", () => { beforeEach(() => { restore(); diff --git a/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx b/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx index 5cde775c28f..1aba5917a9a 100644 --- a/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx +++ b/frontend/src/metabase/visualizations/echarts/cartesian/option/tooltip.tsx @@ -4,6 +4,7 @@ import { renderToString } from "react-dom/server"; import { EChartsTooltip } from "metabase/visualizations/components/ChartTooltip/EChartsTooltip"; import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; import { getTooltipModel } from "metabase/visualizations/visualizations/CartesianChart/events"; +import type { CardDisplayType } from "metabase-types/api"; import { getTooltipBaseOption } from "../../tooltip"; import { @@ -14,6 +15,7 @@ import type { BaseCartesianChartModel, DataKey } from "../model/types"; interface ChartItemTooltip { dataIndex: number; + display: CardDisplayType; seriesId?: DataKey | null; settings: ComputedVisualizationSettings; chartModel: BaseCartesianChartModel; @@ -23,6 +25,7 @@ const ChartItemTooltip = ({ chartModel, settings, dataIndex, + display, seriesId, }: ChartItemTooltip) => { if (dataIndex == null || seriesId == null) { @@ -33,6 +36,7 @@ const ChartItemTooltip = ({ chartModel, settings, dataIndex, + display, seriesId, ); @@ -46,6 +50,7 @@ const ChartItemTooltip = ({ export const getTooltipOption = ( chartModel: BaseCartesianChartModel, settings: ComputedVisualizationSettings, + display: CardDisplayType, containerRef: React.RefObject<HTMLDivElement>, ): TooltipOption => { return { @@ -70,6 +75,7 @@ export const getTooltipOption = ( settings={settings} chartModel={chartModel} dataIndex={dataIndex} + display={display} seriesId={seriesId} />, ); diff --git a/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx b/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx index 1c0eabdc096..0e4d6d428d1 100644 --- a/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx +++ b/frontend/src/metabase/visualizations/echarts/tooltip/index.tsx @@ -5,6 +5,7 @@ import _ from "underscore"; import { isNotNull } from "metabase/lib/types"; import TooltipStyles from "metabase/visualizations/components/ChartTooltip/EChartsTooltip/EChartsTooltip.module.css"; +import type { ComputedVisualizationSettings } from "metabase/visualizations/types"; import type { ClickObject } from "metabase-lib"; import type { BaseCartesianChartModel } from "../cartesian/model/types"; @@ -130,14 +131,20 @@ export const useClickedStateTooltipSync = ( export const useCartesianChartSeriesColorsClasses = ( chartModel: BaseCartesianChartModel, + settings: ComputedVisualizationSettings, ) => { - const hexColors = useMemo( - () => - chartModel.seriesModels - .map(seriesModel => seriesModel.color) - .filter(isNotNull), - [chartModel], - ); + const hexColors = useMemo(() => { + const seriesColors = chartModel.seriesModels + .map(seriesModel => seriesModel.color) + .filter(isNotNull); + + const settingColors = [ + settings["waterfall.increase_color"], + settings["waterfall.decrease_color"], + ].filter(isNotNull); + + return [...seriesColors, ...settingColors]; + }, [chartModel, settings]); return useInjectSeriesColorsClasses(hexColors); }; diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js index 634a075c96b..d1541aa8bb6 100644 --- a/frontend/src/metabase/visualizations/lib/settings/graph.js +++ b/frontend/src/metabase/visualizations/lib/settings/graph.js @@ -279,12 +279,7 @@ export const LEGEND_SETTINGS = { export const TOOLTIP_SETTINGS = { "graph.tooltip_type": { - getDefault: ([{ card }]) => { - const shouldShowComparisonTooltip = !["waterfall", "scatter"].includes( - card.display, - ); - return shouldShowComparisonTooltip ? "series_comparison" : "default"; - }, + getDefault: () => "series_comparison", hidden: true, }, "graph.tooltip_columns": { diff --git a/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts b/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts index 77bd77cd985..3df9e4d8c73 100644 --- a/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts +++ b/frontend/src/metabase/visualizations/shared/settings/cartesian-chart.ts @@ -2,7 +2,10 @@ import { t } from "ttag"; import _ from "underscore"; import { isNotNull } from "metabase/lib/types"; -import { getMaxDimensionsSupported } from "metabase/visualizations"; +import { + getMaxDimensionsSupported, + getMaxMetricsSupported, +} from "metabase/visualizations"; import { getCardsColumns } from "metabase/visualizations/echarts/cartesian/model"; import { dimensionIsNumeric } from "metabase/visualizations/lib/numeric"; import { dimensionIsTimeseries } from "metabase/visualizations/lib/timeseries"; @@ -38,19 +41,19 @@ export function getDefaultMetricFilter(display: string) { } export function getAreDimensionsAndMetricsValid(rawSeries: RawSeries) { - return rawSeries.some( - ({ card, data }) => - columnsAreValid( - card.visualization_settings["graph.dimensions"], - data, - getDefaultDimensionFilter(card.display), - ) && - columnsAreValid( - card.visualization_settings["graph.metrics"], - data, - getDefaultMetricFilter(card.display), - ), - ); + return rawSeries.some(({ card, data }) => { + const dimensions = card.visualization_settings["graph.dimensions"]; + const metrics = card.visualization_settings["graph.metrics"]; + + const dimensionsFilter = getDefaultDimensionFilter(card.display); + const metricsFilter = getDefaultMetricFilter(card.display); + + return ( + columnsAreValid(dimensions, data, dimensionsFilter) && + columnsAreValid(metrics, data, metricsFilter) && + (metrics ?? []).length <= getMaxMetricsSupported(card.display) + ); + }); } export function getDefaultDimensions( @@ -74,6 +77,7 @@ export function getDefaultMetrics( rawSeries: RawSeries, settings: ComputedVisualizationSettings, ) { + const [{ card }] = rawSeries; const prevMetrics = settings["graph.metrics"] ?? []; const defaultMetrics = getDefaultColumns(rawSeries).metrics; if ( @@ -83,8 +87,7 @@ export function getDefaultMetrics( ) { return prevMetrics; } - - return defaultMetrics; + return defaultMetrics.slice(0, getMaxMetricsSupported(card.display)); } export const STACKABLE_SERIES_DISPLAY_TYPES = new Set(["area", "bar"]); diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx b/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx index 9fb5ab77cab..67c95abeca7 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/CartesianChart.tsx @@ -121,7 +121,10 @@ function _CartesianChart(props: VisualizationProps) { }, []); const canSelectTitle = !!onChangeCardAndRun; - const seriesColorsCss = useCartesianChartSeriesColorsClasses(chartModel); + const seriesColorsCss = useCartesianChartSeriesColorsClasses( + chartModel, + settings, + ); useCloseTooltipOnScroll(chartRef); diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts b/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts index b73ee179770..700f507cb40 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/chart-definition.ts @@ -1,4 +1,3 @@ -import { t } from "ttag"; import _ from "underscore"; import { GRAPH_GOAL_SETTINGS } from "metabase/visualizations/lib/settings/goal"; @@ -50,12 +49,6 @@ export const getCartesianChartDefinition = ( }, checkRenderable(series, settings) { - if (series.length > (this.maxMetricsSupported ?? Infinity)) { - throw new Error( - t`${this.uiName} chart does not support multiple series`, - ); - } - validateDatasetRows(series); validateChartDataSettings(settings); validateStacking(settings); diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts b/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts index 7641dc0b917..35c3c05e83f 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/events.ts @@ -63,6 +63,7 @@ import type Metadata from "metabase-lib/v1/metadata/Metadata"; import { isNative } from "metabase-lib/v1/queries/utils/card"; import { getColumnKey } from "metabase-lib/v1/queries/utils/column-key"; import type { + CardDisplayType, CardId, RawSeries, TimelineEvent, @@ -371,6 +372,7 @@ export const getTooltipModel = ( chartModel: BaseCartesianChartModel, settings: ComputedVisualizationSettings, echartsDataIndex: number, + display: CardDisplayType, seriesDataKey: DataKey, ): EChartsTooltipModel | null => { const dataIndex = getDataIndex( @@ -419,6 +421,7 @@ export const getTooltipModel = ( settings, datum, dataIndex, + display, hoveredSeries, ); }; @@ -473,6 +476,7 @@ export const getSeriesOnlyTooltipModel = ( settings: ComputedVisualizationSettings, datum: Datum, dataIndex: number, + display: CardDisplayType, hoveredSeries: SeriesModel, ): EChartsTooltipModel | null => { const header = String( @@ -498,7 +502,9 @@ export const getSeriesOnlyTooltipModel = ( return { isFocused, name: seriesModel.name, - markerColorClass: getMarkerColorClass(seriesModel.color), + markerColorClass: getMarkerColorClass( + getSeriesOnlyTooltipRowColor(seriesModel, datum, settings, display), + ), values: [ formatValueForTooltip({ value: datum[seriesModel.dataKey], @@ -530,6 +536,23 @@ export const getSeriesOnlyTooltipModel = ( }; }; +const getSeriesOnlyTooltipRowColor = ( + seriesModel: SeriesModel, + datum: Datum, + settings: ComputedVisualizationSettings, + display: CardDisplayType, +) => { + const value = datum[seriesModel.dataKey]; + if (display === "waterfall" && typeof value === "number") { + const color = + value >= 0 + ? settings["waterfall.increase_color"] + : settings["waterfall.decrease_color"]; + return color ?? seriesModel.color; + } + return seriesModel.color; +}; + export const getStackedTooltipModel = ( chartModel: BaseCartesianChartModel, settings: ComputedVisualizationSettings, diff --git a/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts b/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts index e4c1808b854..9830c97045a 100644 --- a/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts +++ b/frontend/src/metabase/visualizations/visualizations/CartesianChart/use-models-and-option.ts @@ -18,6 +18,7 @@ import { getWaterfallChartModel } from "metabase/visualizations/echarts/cartesia import { getWaterfallChartOption } from "metabase/visualizations/echarts/cartesian/waterfall/option"; import { useBrowserRenderingContext } from "metabase/visualizations/hooks/use-browser-rendering-context"; import type { VisualizationProps } from "metabase/visualizations/types"; +import type { CardDisplayType } from "metabase-types/api"; export function useModelsAndOption( { @@ -122,8 +123,13 @@ export function useModelsAndOption( }, [selectedTimelineEventIds, hovered?.timelineEvents]); const tooltipOption = useMemo(() => { - return getTooltipOption(chartModel, settings, containerRef); - }, [chartModel, settings, containerRef]); + return getTooltipOption( + chartModel, + settings, + card.display as CardDisplayType, + containerRef, + ); + }, [chartModel, settings, card.display, containerRef]); const option = useMemo(() => { if (width === 0 || height === 0) { -- GitLab