diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx index 17c73d195bca3..76c1465357b73 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/controlPanel.tsx @@ -20,16 +20,20 @@ import React from 'react'; import { t } from '@superset-ui/core'; import { ControlPanelConfig, + ControlStateMapping, ControlSubSectionHeader, + D3_FORMAT_DOCS, D3_FORMAT_OPTIONS, D3_NUMBER_FORMAT_DESCRIPTION_VALUES_TEXT, + getStandardizedControls, sections, sharedControls, - ControlStateMapping, - getStandardizedControls, - D3_FORMAT_DOCS, } from '@superset-ui/chart-controls'; -import { DEFAULT_FORM_DATA, EchartsFunnelLabelTypeType } from './types'; +import { + DEFAULT_FORM_DATA, + EchartsFunnelLabelTypeType, + PercentCalcType, +} from './types'; import { legendSection } from '../controls'; const { labelType, numberFormat, showLabels, defaultTooltipLabel } = @@ -70,6 +74,25 @@ const config: ControlPanelConfig = { }, }, ], + [ + { + name: 'percent_calculation_type', + config: { + type: 'SelectControl', + label: t('% calculation'), + description: t( + 'Display percents in the label and tooltip as the percent of the total value, from the first step of the funnel, or from the previous step in the funnel.', + ), + choices: [ + [PercentCalcType.FIRST_STEP, t('Calculate from first step')], + [PercentCalcType.PREV_STEP, t('Calculate from previous step')], + [PercentCalcType.TOTAL, t('Percent of total')], + ], + default: PercentCalcType.FIRST_STEP, + renderTrigger: true, + }, + }, + ], ], }, { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts index 6b76d16074e1e..a8d8c9e65cc3d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts @@ -19,12 +19,12 @@ import { CategoricalColorNamespace, DataRecord, + getColumnLabel, getMetricLabel, getNumberFormatter, + getValueFormatter, NumberFormats, ValueFormatter, - getColumnLabel, - getValueFormatter, } from '@superset-ui/core'; import { CallbackDataParams } from 'echarts/types/src/util/types'; import { EChartsCoreOption, FunnelSeriesOption } from 'echarts'; @@ -34,6 +34,7 @@ import { EchartsFunnelFormData, EchartsFunnelLabelTypeType, FunnelChartTransformedProps, + PercentCalcType, } from './types'; import { extractGroupbyLabel, @@ -43,7 +44,7 @@ import { sanitizeHtml, } from '../utils/series'; import { defaultGrid } from '../defaults'; -import { OpacityEnum, DEFAULT_LEGEND_FORM_DATA } from '../constants'; +import { DEFAULT_LEGEND_FORM_DATA, OpacityEnum } from '../constants'; import { getDefaultTooltip } from '../utils/tooltip'; import { Refs } from '../types'; @@ -53,17 +54,32 @@ export function formatFunnelLabel({ params, labelType, numberFormatter, + percentCalculationType = PercentCalcType.FIRST_STEP, sanitizeName = false, }: { - params: Pick; + params: Pick; labelType: EchartsFunnelLabelTypeType; numberFormatter: ValueFormatter; + percentCalculationType?: PercentCalcType; sanitizeName?: boolean; }): string { - const { name: rawName = '', value, percent } = params; + const { name: rawName = '', value, percent: totalPercent, data } = params; const name = sanitizeName ? sanitizeHtml(rawName) : rawName; const formattedValue = numberFormatter(value as number); - const formattedPercent = percentFormatter((percent as number) / 100); + const { firstStepPercent, prevStepPercent } = data as { + firstStepPercent: number; + prevStepPercent: number; + }; + let percent; + + if (percentCalculationType === PercentCalcType.TOTAL) { + percent = (totalPercent ?? 0) / 100; + } else if (percentCalculationType === PercentCalcType.PREV_STEP) { + percent = prevStepPercent ?? 0; + } else { + percent = firstStepPercent ?? 0; + } + const formattedPercent = percentFormatter(percent); switch (labelType) { case EchartsFunnelLabelTypeType.Key: @@ -119,6 +135,7 @@ export default function transformProps( showTooltipLabels, showLegend, sliceId, + percentCalculationType, }: EchartsFunnelFormData = { ...DEFAULT_LEGEND_FORM_DATA, ...DEFAULT_FUNNEL_FORM_DATA, @@ -154,16 +171,24 @@ export default function transformProps( currencyFormat, ); - const transformedData: FunnelSeriesOption[] = data.map(datum => { + const transformedData: { + value: number; + name: string; + itemStyle: { color: string; opacity: OpacityEnum }; + }[] = data.map((datum, index) => { const name = extractGroupbyLabel({ datum, groupby: groupbyLabels, coltypeMapping: {}, }); + const value = datum[metricLabel] as number; const isFiltered = filterState.selectedValues && !filterState.selectedValues.includes(name); + const firstStepPercent = value / (data[0][metricLabel] as number); + const prevStepPercent = + index === 0 ? 1 : value / (data[index - 1][metricLabel] as number); return { - value: datum[metricLabel], + value, name, itemStyle: { color: colorFn(name, sliceId), @@ -171,6 +196,8 @@ export default function transformProps( ? OpacityEnum.SemiTransparent : OpacityEnum.NonTransparent, }, + firstStepPercent, + prevStepPercent, }; }); @@ -188,7 +215,12 @@ export default function transformProps( ); const formatter = (params: CallbackDataParams) => - formatFunnelLabel({ params, numberFormatter, labelType }); + formatFunnelLabel({ + params, + numberFormatter, + labelType, + percentCalculationType, + }); const defaultLabel = { formatter, @@ -237,6 +269,7 @@ export default function transformProps( params, numberFormatter, labelType: tooltipLabelType, + percentCalculationType, }), }, legend: { diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts index 3c58a7e0e49b0..928664e223c91 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/types.ts @@ -42,6 +42,7 @@ export type EchartsFunnelFormData = QueryFormData & gap: number; sort: 'descending' | 'ascending' | 'none' | undefined; orient: 'vertical' | 'horizontal' | undefined; + percentCalculationType: PercentCalcType; }; export enum EchartsFunnelLabelTypeType { @@ -78,3 +79,9 @@ export type FunnelChartTransformedProps = BaseTransformedProps & CrossFilterTransformedProps & ContextMenuTransformedProps; + +export enum PercentCalcType { + TOTAL = 'total', + PREV_STEP = 'prev_step', + FIRST_STEP = 'first_step', +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts index b71bab2ceb60a..9c1d35cdd3fd2 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Funnel/transformProps.test.ts @@ -27,6 +27,7 @@ import transformProps, { import { EchartsFunnelChartProps, EchartsFunnelLabelTypeType, + PercentCalcType, } from '../../src/Funnel/types'; describe('Funnel transformProps', () => { @@ -81,12 +82,18 @@ describe('Funnel transformProps', () => { describe('formatFunnelLabel', () => { it('should generate a valid funnel chart label', () => { const numberFormatter = getNumberFormatter(); - const params = { name: 'My Label', value: 1234, percent: 12.34 }; + const params = { + name: 'My Label', + value: 1234, + percent: 12.34, + data: { firstStepPercent: 0.5, prevStepPercent: 0.85 }, + }; expect( formatFunnelLabel({ params, numberFormatter, labelType: EchartsFunnelLabelTypeType.Key, + percentCalculationType: PercentCalcType.TOTAL, }), ).toEqual('My Label'); expect( @@ -94,6 +101,7 @@ describe('formatFunnelLabel', () => { params, numberFormatter, labelType: EchartsFunnelLabelTypeType.Value, + percentCalculationType: PercentCalcType.TOTAL, }), ).toEqual('1.23k'); expect( @@ -101,13 +109,31 @@ describe('formatFunnelLabel', () => { params, numberFormatter, labelType: EchartsFunnelLabelTypeType.Percent, + percentCalculationType: PercentCalcType.TOTAL, }), ).toEqual('12.34%'); + expect( + formatFunnelLabel({ + params, + numberFormatter, + labelType: EchartsFunnelLabelTypeType.Percent, + percentCalculationType: PercentCalcType.FIRST_STEP, + }), + ).toEqual('50.00%'); + expect( + formatFunnelLabel({ + params, + numberFormatter, + labelType: EchartsFunnelLabelTypeType.Percent, + percentCalculationType: PercentCalcType.PREV_STEP, + }), + ).toEqual('85.00%'); expect( formatFunnelLabel({ params, numberFormatter, labelType: EchartsFunnelLabelTypeType.KeyValue, + percentCalculationType: PercentCalcType.TOTAL, }), ).toEqual('My Label: 1.23k'); expect( @@ -115,6 +141,7 @@ describe('formatFunnelLabel', () => { params, numberFormatter, labelType: EchartsFunnelLabelTypeType.KeyPercent, + percentCalculationType: PercentCalcType.TOTAL, }), ).toEqual('My Label: 12.34%'); expect( @@ -122,6 +149,7 @@ describe('formatFunnelLabel', () => { params, numberFormatter, labelType: EchartsFunnelLabelTypeType.KeyValuePercent, + percentCalculationType: PercentCalcType.TOTAL, }), ).toEqual('My Label: 1.23k (12.34%)'); expect( @@ -129,6 +157,7 @@ describe('formatFunnelLabel', () => { params: { ...params, name: '' }, numberFormatter, labelType: EchartsFunnelLabelTypeType.Key, + percentCalculationType: PercentCalcType.TOTAL, }), ).toEqual(''); expect( @@ -136,6 +165,7 @@ describe('formatFunnelLabel', () => { params: { ...params, name: '' }, numberFormatter, labelType: EchartsFunnelLabelTypeType.Key, + percentCalculationType: PercentCalcType.TOTAL, sanitizeName: true, }), ).toEqual('<NULL>'); diff --git a/superset/migrations/versions/2023-12-15_17-58_06dd9ff00fe8_add_percent_calculation_type_funnel_.py b/superset/migrations/versions/2023-12-15_17-58_06dd9ff00fe8_add_percent_calculation_type_funnel_.py new file mode 100644 index 0000000000000..22b750b7615ab --- /dev/null +++ b/superset/migrations/versions/2023-12-15_17-58_06dd9ff00fe8_add_percent_calculation_type_funnel_.py @@ -0,0 +1,74 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +"""add_percent_calculation_type_funnel_chart + +Revision ID: 06dd9ff00fe8 +Revises: b7851ee5522f +Create Date: 2023-12-15 17:58:18.277951 + +""" +import json + +from alembic import op +from sqlalchemy import Column, Integer, String, Text +from sqlalchemy.ext.declarative import declarative_base + +from superset import db +from superset.migrations.shared.utils import paginated_update + +# revision identifiers, used by Alembic. +revision = "06dd9ff00fe8" +down_revision = "b7851ee5522f" + +Base = declarative_base() + + +class Slice(Base): + __tablename__ = "slices" + id = Column(Integer, primary_key=True) + viz_type = Column(String(250)) + params = Column(Text) + + +def upgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + + for slc in paginated_update( + session.query(Slice).filter(Slice.viz_type == "funnel") + ): + params = json.loads(slc.params) + percent_calculation = params.get("percent_calculation_type") + if not percent_calculation: + params["percent_calculation_type"] = "total" + slc.params = json.dumps(params) + session.close() + + +def downgrade(): + bind = op.get_bind() + session = db.Session(bind=bind) + + for slc in paginated_update( + session.query(Slice).filter(Slice.viz_type == "funnel") + ): + params = json.loads(slc.params) + percent_calculation = params.get("percent_calculation_type") + if percent_calculation: + del params["percent_calculation_type"] + slc.params = json.dumps(params) + session.close()