mirror of
https://github.com/apache/superset.git
synced 2026-09-01 21:11:28 +00:00
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
import { ChartProps } from '@superset-ui/core';
|
|
import { supersetTheme } from '@apache-superset/core/theme';
|
|
import type { CallbackDataParams } from 'echarts/types/src/util/types';
|
|
import {
|
|
EchartsButterflyChartProps,
|
|
ButterflyTransformedProps,
|
|
} from '../../src/Butterfly/types';
|
|
import transformProps from '../../src/Butterfly/transformProps';
|
|
import { NULL_STRING, OpacityEnum } from '../../src/constants';
|
|
|
|
const categoryKeyA = 'A__["A"]';
|
|
const categoryKeyB = 'B__["B"]';
|
|
|
|
type SeriesDataPoint = {
|
|
name?: string;
|
|
value?: number;
|
|
itemStyle?: { opacity?: number };
|
|
};
|
|
|
|
type ButterflyTestSeries = {
|
|
name?: string;
|
|
data?: SeriesDataPoint[];
|
|
itemStyle?: { color?: string };
|
|
label?: {
|
|
show?: boolean;
|
|
formatter?: (params: CallbackDataParams) => string;
|
|
};
|
|
};
|
|
|
|
type ButterflyTestEchartOptions = {
|
|
series?: ButterflyTestSeries[];
|
|
xAxis?: {
|
|
name?: string;
|
|
nameGap?: number;
|
|
axisLabel?: { formatter?: (value: number) => string };
|
|
};
|
|
yAxis?: {
|
|
name?: string;
|
|
nameGap?: number;
|
|
data?: string[];
|
|
axisLabel?: { rotate?: number };
|
|
};
|
|
legend?: { orient?: string; data?: string[] };
|
|
grid?: { left?: number; top?: number };
|
|
tooltip?: {
|
|
show?: boolean;
|
|
formatter?: (params: CallbackDataParams | CallbackDataParams[]) => string;
|
|
};
|
|
};
|
|
|
|
const getEchartOptions = (
|
|
props: ButterflyTransformedProps,
|
|
): ButterflyTestEchartOptions =>
|
|
props.echartOptions as ButterflyTestEchartOptions;
|
|
|
|
const extractSeriesValues = (props: ButterflyTransformedProps) => {
|
|
const series = getEchartOptions(props).series ?? [];
|
|
return series.map(item => (item.data ?? []).map(entry => entry.value));
|
|
};
|
|
|
|
const extractSeriesNames = (props: ButterflyTransformedProps) => {
|
|
const series = getEchartOptions(props).series ?? [];
|
|
return series.map(item => item.name);
|
|
};
|
|
|
|
const data: Record<string, unknown>[] = [
|
|
{ category: 'A', left_sum: 10, right_sum: 25 },
|
|
{ category: 'B', left_sum: 5, right_sum: 19 },
|
|
];
|
|
|
|
const formData = {
|
|
groupby: ['category'],
|
|
left_metric: 'left_sum',
|
|
right_metric: 'right_sum',
|
|
left_color: { r: 84, g: 112, b: 198 },
|
|
right_color: { r: 145, g: 204, b: 117 },
|
|
showValue: true,
|
|
showLegend: true,
|
|
};
|
|
|
|
const createChartProps = (
|
|
overrides: Record<string, unknown> = {},
|
|
queryData: Record<string, unknown>[] = data,
|
|
) =>
|
|
({
|
|
...new ChartProps({
|
|
formData: { ...formData, ...overrides },
|
|
width: 800,
|
|
height: 600,
|
|
queriesData: [{ data: queryData }],
|
|
theme: supersetTheme,
|
|
...((overrides.hooks ? { hooks: overrides.hooks } : {}) as object),
|
|
}),
|
|
filterState: overrides.filterState ?? {},
|
|
emitCrossFilters: overrides.emitCrossFilters,
|
|
inContextMenu: overrides.inContextMenu,
|
|
}) as unknown as EchartsButterflyChartProps;
|
|
|
|
test('transforms chart props into diverging bar series', () => {
|
|
const transformedProps = transformProps(createChartProps());
|
|
|
|
expect(extractSeriesValues(transformedProps)).toEqual([
|
|
[-10, -5],
|
|
[25, 19],
|
|
]);
|
|
});
|
|
|
|
test('assigns composite category keys to each bar data point', () => {
|
|
const transformedProps = transformProps(createChartProps());
|
|
const series = getEchartOptions(transformedProps).series ?? [];
|
|
|
|
expect(series[0]?.data?.map(point => point.name)).toEqual([
|
|
categoryKeyA,
|
|
categoryKeyB,
|
|
]);
|
|
expect(series[1]?.data?.map(point => point.name)).toEqual([
|
|
categoryKeyA,
|
|
categoryKeyB,
|
|
]);
|
|
});
|
|
|
|
test('uses absolute values for negative right-side metrics', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({}, [{ category: 'A', left_sum: -8, right_sum: -15 }]),
|
|
);
|
|
|
|
expect(extractSeriesValues(transformedProps)).toEqual([[-8], [15]]);
|
|
});
|
|
|
|
test('formats null categories and missing metric values', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({}, [
|
|
{ category: null, left_sum: undefined, right_sum: 7 },
|
|
]),
|
|
);
|
|
const { yAxis } = getEchartOptions(transformedProps);
|
|
|
|
expect(yAxis?.data).toEqual([NULL_STRING]);
|
|
const [leftValues, rightValues] = extractSeriesValues(transformedProps);
|
|
expect(Math.abs(leftValues[0] as number)).toBe(0);
|
|
expect(rightValues).toEqual([7]);
|
|
});
|
|
|
|
test('applies custom series labels, colors, and axis titles', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({
|
|
left_label: 'Left side',
|
|
right_label: 'Right side',
|
|
left_color: { r: 255, g: 0, b: 0 },
|
|
right_color: { r: 0, g: 255, b: 0 },
|
|
x_axis_label: 'Value axis',
|
|
y_axis_label: 'Category axis',
|
|
}),
|
|
);
|
|
const { series, xAxis, yAxis } = getEchartOptions(transformedProps);
|
|
|
|
expect(extractSeriesNames(transformedProps)).toEqual([
|
|
'Left side',
|
|
'Right side',
|
|
]);
|
|
expect(series?.[0]?.itemStyle?.color).toBe('#ff0000');
|
|
expect(series?.[1]?.itemStyle?.color).toBe('#00ff00');
|
|
expect(xAxis?.name).toBe('Value axis');
|
|
expect(yAxis?.name).toBe('Category axis');
|
|
});
|
|
|
|
test('applies legend orientation, sort, and axis margin settings', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({
|
|
legendOrientation: 'left',
|
|
legendSort: 'desc',
|
|
xAxisLabelRotation: 45,
|
|
x_axis_title_margin: 60,
|
|
y_axis_title_margin: 80,
|
|
}),
|
|
);
|
|
const { legend, xAxis, yAxis, grid } = getEchartOptions(transformedProps);
|
|
|
|
expect(legend?.orient).toBe('vertical');
|
|
expect(legend?.data).toEqual(['right_sum', 'left_sum']);
|
|
expect(xAxis?.nameGap).toBe(60);
|
|
expect(yAxis?.axisLabel?.rotate).toBe(45);
|
|
expect(yAxis?.nameGap).toBe(80);
|
|
expect(grid?.left).toBeGreaterThan(80);
|
|
expect(grid?.top).toBeGreaterThan(60);
|
|
});
|
|
|
|
test('hides value labels when showValue is false', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({ showValue: false }),
|
|
);
|
|
const { series } = getEchartOptions(transformedProps);
|
|
|
|
expect(series?.[0]?.label?.show).toBe(false);
|
|
expect(series?.[1]?.label?.show).toBe(false);
|
|
});
|
|
|
|
test('hides zero value labels but keeps non-zero labels', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({}, [{ category: 'A', left_sum: 0, right_sum: 12 }]),
|
|
);
|
|
const formatter =
|
|
getEchartOptions(transformedProps).series?.[0]?.label?.formatter;
|
|
|
|
expect(formatter?.({ value: 0 } as CallbackDataParams)).toBe('');
|
|
expect(formatter?.({ value: -10 } as CallbackDataParams)).toBe('10');
|
|
});
|
|
|
|
test('formats axis and tooltip values as absolute numbers', () => {
|
|
const transformedProps = transformProps(createChartProps());
|
|
const { xAxis, tooltip } = getEchartOptions(transformedProps);
|
|
|
|
expect(xAxis?.axisLabel?.formatter?.(-25)).toBe('25');
|
|
|
|
const tooltipHtml = tooltip?.formatter?.([
|
|
{
|
|
name: categoryKeyA,
|
|
dataIndex: 0,
|
|
seriesName: 'left_sum',
|
|
value: -10,
|
|
} as CallbackDataParams,
|
|
{
|
|
name: categoryKeyA,
|
|
dataIndex: 0,
|
|
seriesName: 'right_sum',
|
|
value: 25,
|
|
} as CallbackDataParams,
|
|
]);
|
|
|
|
expect(tooltipHtml).toContain('A');
|
|
expect(tooltipHtml).not.toContain(categoryKeyA);
|
|
expect(tooltipHtml).toContain('left_sum');
|
|
expect(tooltipHtml).toContain('right_sum');
|
|
expect(tooltipHtml).toContain('10');
|
|
expect(tooltipHtml).toContain('25');
|
|
});
|
|
|
|
test('shows the category label in the tooltip when ECharts reports a unique key', () => {
|
|
const transformedProps = transformProps(createChartProps());
|
|
const tooltipHtml = getEchartOptions(transformedProps).tooltip?.formatter?.({
|
|
name: categoryKeyA,
|
|
seriesName: 'left_sum',
|
|
value: -10,
|
|
} as CallbackDataParams);
|
|
|
|
expect(tooltipHtml).toContain('A');
|
|
expect(tooltipHtml).not.toContain(categoryKeyA);
|
|
});
|
|
|
|
test('hides tooltip while the context menu is open', () => {
|
|
const transformedProps = transformProps(createChartProps());
|
|
const withContextMenu = transformProps(
|
|
createChartProps({ inContextMenu: true }),
|
|
);
|
|
|
|
expect(getEchartOptions(transformedProps).tooltip?.show).toBe(true);
|
|
expect(getEchartOptions(withContextMenu).tooltip?.show).toBe(false);
|
|
});
|
|
|
|
test('builds labelMap and groupby for drill and cross-filter handlers', () => {
|
|
const transformedProps = transformProps(createChartProps());
|
|
|
|
expect(transformedProps.groupby).toEqual(['category']);
|
|
expect(transformedProps.labelMap).toEqual({
|
|
'A__["A"]': ['A'],
|
|
'B__["B"]': ['B'],
|
|
});
|
|
});
|
|
|
|
test('uses unique keys for interactions and readable labels on the y-axis', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({ groupby: ['country', 'state'] }, [
|
|
{ country: 'US', state: 'CA', left_sum: 4, right_sum: 6 },
|
|
{ country: 'US', state: 'NY', left_sum: 8, right_sum: 3 },
|
|
]),
|
|
);
|
|
const series = getEchartOptions(transformedProps).series ?? [];
|
|
const firstKey = 'US, CA__["US","CA"]';
|
|
const secondKey = 'US, NY__["US","NY"]';
|
|
|
|
expect(firstKey).not.toBe(secondKey);
|
|
expect(series[0]?.data?.map(point => point.name)).toEqual([
|
|
firstKey,
|
|
secondKey,
|
|
]);
|
|
expect(transformedProps.labelMap).toEqual({
|
|
[firstKey]: ['US', 'CA'],
|
|
[secondKey]: ['US', 'NY'],
|
|
});
|
|
expect(getEchartOptions(transformedProps).yAxis?.data).toEqual([
|
|
'US, CA',
|
|
'US, NY',
|
|
]);
|
|
});
|
|
|
|
test('dims unselected categories when a cross-filter is active', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({
|
|
filterState: { selectedValues: [categoryKeyA] },
|
|
}),
|
|
);
|
|
const series = getEchartOptions(transformedProps).series ?? [];
|
|
|
|
expect(series[0]?.data?.[0]?.itemStyle?.opacity).toBe(
|
|
OpacityEnum.NonTransparent,
|
|
);
|
|
expect(series[0]?.data?.[1]?.itemStyle?.opacity).toBe(
|
|
OpacityEnum.SemiTransparent,
|
|
);
|
|
expect(series[1]?.data?.[1]?.itemStyle?.opacity).toBe(
|
|
OpacityEnum.SemiTransparent,
|
|
);
|
|
});
|
|
|
|
test('maps selectedValues to category indexes', () => {
|
|
const transformedProps = transformProps(
|
|
createChartProps({
|
|
filterState: { selectedValues: [categoryKeyB] },
|
|
}),
|
|
);
|
|
|
|
expect(transformedProps.selectedValues).toEqual({ 1: categoryKeyB });
|
|
});
|