feat(echarts-funnel): Implement % calculation type (#26290)

This commit is contained in:
Kamil Gabryjelski
2023-12-22 13:14:52 +01:00
committed by GitHub
parent 39ac45351b
commit 5400d30b20
5 changed files with 181 additions and 14 deletions

View File

@@ -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,
},
},
],
],
},
{

View File

@@ -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<CallbackDataParams, 'name' | 'value' | 'percent'>;
params: Pick<CallbackDataParams, 'name' | 'value' | 'percent' | 'data'>;
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: {

View File

@@ -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<EchartsFunnelFormData> &
CrossFilterTransformedProps &
ContextMenuTransformedProps;
export enum PercentCalcType {
TOTAL = 'total',
PREV_STEP = 'prev_step',
FIRST_STEP = 'first_step',
}

View File

@@ -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: '<NULL>' },
numberFormatter,
labelType: EchartsFunnelLabelTypeType.Key,
percentCalculationType: PercentCalcType.TOTAL,
}),
).toEqual('<NULL>');
expect(
@@ -136,6 +165,7 @@ describe('formatFunnelLabel', () => {
params: { ...params, name: '<NULL>' },
numberFormatter,
labelType: EchartsFunnelLabelTypeType.Key,
percentCalculationType: PercentCalcType.TOTAL,
sanitizeName: true,
}),
).toEqual('&lt;NULL&gt;');

View File

@@ -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()