mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
feat(theme): enable generalized ECharts theme overrides for array properties (#37965)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -766,3 +766,76 @@ test('Theme base theme integration arrays in themes are replaced entirely, not m
|
||||
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.COMPACT);
|
||||
expect(serialized.algorithm).not.toContain(ThemeAlgorithm.DEFAULT);
|
||||
});
|
||||
|
||||
test('Theme includes echartsOptionsOverrides from top-level config', () => {
|
||||
const config = {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
},
|
||||
echartsOptionsOverrides: {
|
||||
grid: { left: '10%' },
|
||||
tooltip: { trigger: 'axis' },
|
||||
},
|
||||
};
|
||||
|
||||
const theme = Theme.fromConfig(config as AnyThemeConfig);
|
||||
|
||||
expect((theme.theme as any).echartsOptionsOverrides).toEqual({
|
||||
grid: { left: '10%' },
|
||||
tooltip: { trigger: 'axis' },
|
||||
});
|
||||
});
|
||||
|
||||
test('Theme includes echartsOptionsOverridesByChartType from top-level config', () => {
|
||||
const config = {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
},
|
||||
echartsOptionsOverridesByChartType: {
|
||||
echarts_timeseries_bar: {
|
||||
series: { itemStyle: { borderRadius: [4, 4, 0, 0] } },
|
||||
},
|
||||
echarts_pie: {
|
||||
legend: { orient: 'vertical', right: 10 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const theme = Theme.fromConfig(config as AnyThemeConfig);
|
||||
|
||||
expect((theme.theme as any).echartsOptionsOverridesByChartType).toEqual({
|
||||
echarts_timeseries_bar: {
|
||||
series: { itemStyle: { borderRadius: [4, 4, 0, 0] } },
|
||||
},
|
||||
echarts_pie: {
|
||||
legend: { orient: 'vertical', right: 10 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('Theme includes both echartsOptionsOverrides and echartsOptionsOverridesByChartType', () => {
|
||||
const config = {
|
||||
token: {
|
||||
colorPrimary: '#ff0000',
|
||||
},
|
||||
echartsOptionsOverrides: {
|
||||
grid: { left: '10%' },
|
||||
},
|
||||
echartsOptionsOverridesByChartType: {
|
||||
echarts_bar: {
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const theme = Theme.fromConfig(config as AnyThemeConfig);
|
||||
|
||||
expect((theme.theme as any).echartsOptionsOverrides).toEqual({
|
||||
grid: { left: '10%' },
|
||||
});
|
||||
expect((theme.theme as any).echartsOptionsOverridesByChartType).toEqual({
|
||||
echarts_bar: {
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,11 +101,25 @@ export class Theme {
|
||||
// First phase: Let Ant Design compute the tokens
|
||||
const tokens = Theme.getFilteredAntdTheme(antdConfig);
|
||||
|
||||
// Extract Superset-specific properties from top-level config.
|
||||
// These are custom properties that aren't part of Ant Design's token system
|
||||
// but need to be passed through to the SupersetTheme for ECharts customization.
|
||||
const { echartsOptionsOverrides, echartsOptionsOverridesByChartType } =
|
||||
config as AnyThemeConfig & {
|
||||
echartsOptionsOverrides?: any;
|
||||
echartsOptionsOverridesByChartType?: Record<string, any>;
|
||||
};
|
||||
|
||||
// Set the base theme properties
|
||||
this.antdConfig = antdConfig;
|
||||
this.theme = {
|
||||
...tokens, // First apply Ant Design computed tokens
|
||||
...antdConfig.token, // Then override with our custom tokens
|
||||
// Include Superset-specific properties from top-level config
|
||||
...(echartsOptionsOverrides && { echartsOptionsOverrides }),
|
||||
...(echartsOptionsOverridesByChartType && {
|
||||
echartsOptionsOverridesByChartType,
|
||||
}),
|
||||
} as SupersetTheme;
|
||||
|
||||
// Update the providers with the fully formed theme
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { mergeReplaceArrays } from '@superset-ui/core';
|
||||
import { styled, useTheme } from '@apache-superset/core/ui';
|
||||
import { use, init, EChartsType, registerLocale } from 'echarts/core';
|
||||
import {
|
||||
@@ -66,6 +65,7 @@ import {
|
||||
import { LabelLayout } from 'echarts/features';
|
||||
import { EchartsHandler, EchartsProps, EchartsStylesProps } from '../types';
|
||||
import { DEFAULT_LOCALE } from '../constants';
|
||||
import { mergeEchartsThemeOverrides } from '../utils/themeOverrides';
|
||||
|
||||
// Define this interface here to avoid creating a dependency back to superset-frontend,
|
||||
// TODO: to move the type to @superset-ui/core
|
||||
@@ -258,7 +258,7 @@ function Echart(
|
||||
}
|
||||
: {};
|
||||
|
||||
const themedEchartOptions = mergeReplaceArrays(
|
||||
const themedEchartOptions = mergeEchartsThemeOverrides(
|
||||
baseTheme,
|
||||
echartOptions,
|
||||
globalOverrides,
|
||||
|
||||
@@ -16,248 +16,447 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { mergeReplaceArrays } from '@superset-ui/core';
|
||||
import { mergeEchartsThemeOverrides } from './themeOverrides';
|
||||
|
||||
describe('Theme Override Deep Merge Behavior', () => {
|
||||
test('should merge nested objects correctly', () => {
|
||||
const baseOptions = {
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '10%',
|
||||
// =============================================================================
|
||||
// Basic Deep Merge Behavior
|
||||
// =============================================================================
|
||||
|
||||
test('merges nested objects correctly', () => {
|
||||
const baseOptions = {
|
||||
grid: { left: '5%', right: '5%', top: '10%' },
|
||||
xAxis: { type: 'category', axisLabel: { fontSize: 12 } },
|
||||
};
|
||||
|
||||
const overrides = {
|
||||
grid: { left: '10%', bottom: '15%' },
|
||||
xAxis: { axisLabel: { color: '#333', rotate: 45 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(baseOptions, overrides);
|
||||
|
||||
expect(result).toEqual({
|
||||
grid: {
|
||||
left: '10%', // overridden
|
||||
right: '5%', // preserved
|
||||
top: '10%', // preserved
|
||||
bottom: '15%', // added
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category', // preserved
|
||||
axisLabel: {
|
||||
fontSize: 12, // preserved
|
||||
color: '#333', // added
|
||||
rotate: 45, // added
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
axisLabel: {
|
||||
fontSize: 12,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const globalOverrides = {
|
||||
grid: {
|
||||
left: '10%',
|
||||
bottom: '15%',
|
||||
},
|
||||
xAxis: {
|
||||
axisLabel: {
|
||||
color: '#333',
|
||||
rotate: 45,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeReplaceArrays(baseOptions, globalOverrides);
|
||||
|
||||
expect(result).toEqual({
|
||||
grid: {
|
||||
left: '10%', // overridden
|
||||
right: '5%', // preserved
|
||||
top: '10%', // preserved
|
||||
bottom: '15%', // added
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category', // preserved
|
||||
axisLabel: {
|
||||
fontSize: 12, // preserved
|
||||
color: '#333', // added
|
||||
rotate: 45, // added
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should replace arrays instead of merging them', () => {
|
||||
const baseOptions = {
|
||||
series: [
|
||||
{ name: 'Series 1', type: 'line' },
|
||||
{ name: 'Series 2', type: 'bar' },
|
||||
],
|
||||
};
|
||||
|
||||
const overrides = {
|
||||
series: [{ name: 'New Series', type: 'pie' }],
|
||||
};
|
||||
|
||||
const result = mergeReplaceArrays(baseOptions, overrides);
|
||||
|
||||
// Arrays are replaced entirely, not merged by index
|
||||
expect(result.series).toEqual([{ name: 'New Series', type: 'pie' }]);
|
||||
expect(result.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should handle null overrides correctly', () => {
|
||||
const baseOptions = {
|
||||
grid: {
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '10%',
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
};
|
||||
|
||||
const overrides = {
|
||||
grid: {
|
||||
left: null,
|
||||
bottom: '20%',
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: null,
|
||||
borderColor: '#ccc',
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeReplaceArrays(baseOptions, overrides);
|
||||
|
||||
expect(result).toEqual({
|
||||
grid: {
|
||||
left: null, // overridden with null
|
||||
right: '5%', // preserved (undefined values are ignored by lodash merge)
|
||||
top: '10%', // preserved
|
||||
bottom: '20%', // added
|
||||
},
|
||||
tooltip: {
|
||||
show: true, // preserved
|
||||
backgroundColor: null, // overridden with null
|
||||
borderColor: '#ccc', // added
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle override precedence correctly', () => {
|
||||
const baseTheme = {
|
||||
textStyle: { color: '#000', fontSize: 12 },
|
||||
};
|
||||
|
||||
const pluginOptions = {
|
||||
textStyle: { fontSize: 14 },
|
||||
title: { text: 'Chart Title' },
|
||||
};
|
||||
|
||||
const globalOverrides = {
|
||||
textStyle: { color: '#333' },
|
||||
grid: { left: '10%' },
|
||||
};
|
||||
|
||||
const chartOverrides = {
|
||||
textStyle: { color: '#666', fontWeight: 'bold' },
|
||||
legend: { orient: 'vertical' },
|
||||
};
|
||||
|
||||
// Simulate the merge order in Echart.tsx
|
||||
const result = mergeReplaceArrays(
|
||||
baseTheme,
|
||||
pluginOptions,
|
||||
globalOverrides,
|
||||
chartOverrides,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
textStyle: {
|
||||
color: '#666', // chart override wins
|
||||
fontSize: 14, // from plugin options
|
||||
fontWeight: 'bold', // from chart override
|
||||
},
|
||||
title: { text: 'Chart Title' }, // from plugin options
|
||||
grid: { left: '10%' }, // from global override
|
||||
legend: { orient: 'vertical' }, // from chart override
|
||||
});
|
||||
});
|
||||
|
||||
test('should preserve deep nested structures', () => {
|
||||
const baseOptions = {
|
||||
xAxis: {
|
||||
axisLabel: {
|
||||
textStyle: {
|
||||
color: '#000',
|
||||
fontSize: 12,
|
||||
fontFamily: 'Arial',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const overrides = {
|
||||
xAxis: {
|
||||
axisLabel: {
|
||||
textStyle: {
|
||||
color: '#333',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
rotate: 45,
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeReplaceArrays(baseOptions, overrides);
|
||||
|
||||
expect(result).toEqual({
|
||||
xAxis: {
|
||||
axisLabel: {
|
||||
textStyle: {
|
||||
color: '#333', // overridden
|
||||
fontSize: 12, // preserved
|
||||
fontFamily: 'Arial', // preserved
|
||||
fontWeight: 'bold', // added
|
||||
},
|
||||
rotate: 45, // added
|
||||
},
|
||||
splitLine: {
|
||||
show: true, // added
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle function values correctly', () => {
|
||||
const formatFunction = (value: any) => `${value}%`;
|
||||
const overrideFunction = (value: any) => `$${value}`;
|
||||
|
||||
const baseOptions = {
|
||||
yAxis: {
|
||||
axisLabel: {
|
||||
formatter: formatFunction,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const overrides = {
|
||||
yAxis: {
|
||||
axisLabel: {
|
||||
formatter: overrideFunction,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeReplaceArrays(baseOptions, overrides);
|
||||
|
||||
expect(result.yAxis.axisLabel.formatter).toBe(overrideFunction);
|
||||
expect(result.yAxis.axisLabel.formatter('100')).toBe('$100');
|
||||
});
|
||||
|
||||
test('should handle empty objects and arrays', () => {
|
||||
const baseOptions = {
|
||||
series: [{ name: 'Test', data: [1, 2, 3] }],
|
||||
grid: { left: '5%' },
|
||||
};
|
||||
|
||||
const emptyOverrides = {};
|
||||
const arrayOverride = { series: [] };
|
||||
const objectOverride = { grid: {} };
|
||||
|
||||
const resultEmpty = mergeReplaceArrays(baseOptions, emptyOverrides);
|
||||
const resultArray = mergeReplaceArrays(baseOptions, arrayOverride);
|
||||
const resultObject = mergeReplaceArrays(baseOptions, objectOverride);
|
||||
|
||||
expect(resultEmpty).toEqual(baseOptions);
|
||||
// Empty array completely replaces existing array
|
||||
expect(resultArray.series).toEqual([]);
|
||||
expect(resultObject.grid).toEqual({ left: '5%' });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('handles override precedence correctly (rightmost wins)', () => {
|
||||
const baseTheme = { textStyle: { color: '#000', fontSize: 12 } };
|
||||
const pluginOptions = {
|
||||
textStyle: { fontSize: 14 },
|
||||
title: { text: 'Chart' },
|
||||
};
|
||||
const globalOverrides = {
|
||||
textStyle: { color: '#333' },
|
||||
grid: { left: '10%' },
|
||||
};
|
||||
const chartOverrides = { textStyle: { color: '#666', fontWeight: 'bold' } };
|
||||
|
||||
const result = mergeEchartsThemeOverrides(
|
||||
baseTheme,
|
||||
pluginOptions,
|
||||
globalOverrides,
|
||||
chartOverrides,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
textStyle: {
|
||||
color: '#666', // chart override wins
|
||||
fontSize: 14, // from plugin options
|
||||
fontWeight: 'bold', // from chart override
|
||||
},
|
||||
title: { text: 'Chart' },
|
||||
grid: { left: '10%' },
|
||||
});
|
||||
});
|
||||
|
||||
test('handles null values correctly', () => {
|
||||
const base = { grid: { left: '5%', right: '5%' } };
|
||||
const overrides = { grid: { left: null, bottom: '20%' } };
|
||||
|
||||
const result = mergeEchartsThemeOverrides(base, overrides);
|
||||
|
||||
expect(result.grid).toEqual({
|
||||
left: null,
|
||||
right: '5%',
|
||||
bottom: '20%',
|
||||
});
|
||||
});
|
||||
|
||||
test('handles function values correctly', () => {
|
||||
const original = (v: number) => `${v}%`;
|
||||
const override = (v: number) => `$${v}`;
|
||||
|
||||
const base = { yAxis: { axisLabel: { formatter: original } } };
|
||||
const overrides = { yAxis: { axisLabel: { formatter: override } } };
|
||||
|
||||
const result = mergeEchartsThemeOverrides(base, overrides);
|
||||
|
||||
expect(result.yAxis.axisLabel.formatter).toBe(override);
|
||||
expect(result.yAxis.axisLabel.formatter(100)).toBe('$100');
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Array Replacement (Backward Compatibility)
|
||||
// =============================================================================
|
||||
|
||||
test('replaces arrays entirely when override is an array', () => {
|
||||
const base = {
|
||||
series: [
|
||||
{ name: 'Series 1', type: 'line' },
|
||||
{ name: 'Series 2', type: 'bar' },
|
||||
],
|
||||
};
|
||||
|
||||
const overrides = {
|
||||
series: [{ name: 'New Series', type: 'pie' }],
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(base, overrides);
|
||||
|
||||
expect(result.series).toEqual([{ name: 'New Series', type: 'pie' }]);
|
||||
expect(result.series).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('empty array override replaces existing array', () => {
|
||||
const base = { series: [{ name: 'Test', data: [1, 2, 3] }] };
|
||||
const overrides = { series: [] };
|
||||
|
||||
const result = mergeEchartsThemeOverrides(base, overrides);
|
||||
|
||||
expect(result.series).toEqual([]);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Object-to-Array Merging (NEW FEATURE)
|
||||
// =============================================================================
|
||||
|
||||
test('merges object override into each series array item', () => {
|
||||
const chartOptions = {
|
||||
series: [
|
||||
{ type: 'bar', name: 'Revenue', data: [1, 2, 3] },
|
||||
{ type: 'bar', name: 'Profit', data: [4, 5, 6] },
|
||||
],
|
||||
};
|
||||
|
||||
const override = {
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.series).toHaveLength(2);
|
||||
expect(result.series[0]).toEqual({
|
||||
type: 'bar',
|
||||
name: 'Revenue',
|
||||
data: [1, 2, 3],
|
||||
itemStyle: { borderRadius: 4 },
|
||||
});
|
||||
expect(result.series[1]).toEqual({
|
||||
type: 'bar',
|
||||
name: 'Profit',
|
||||
data: [4, 5, 6],
|
||||
itemStyle: { borderRadius: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
test('merges object override into each xAxis array item', () => {
|
||||
const chartOptions = {
|
||||
xAxis: [
|
||||
{ type: 'category', data: ['Mon', 'Tue'] },
|
||||
{ type: 'value', position: 'top' },
|
||||
],
|
||||
};
|
||||
|
||||
const override = {
|
||||
xAxis: { axisLabel: { rotate: 45 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.xAxis).toHaveLength(2);
|
||||
expect(result.xAxis[0]).toEqual({
|
||||
type: 'category',
|
||||
data: ['Mon', 'Tue'],
|
||||
axisLabel: { rotate: 45 },
|
||||
});
|
||||
expect(result.xAxis[1]).toEqual({
|
||||
type: 'value',
|
||||
position: 'top',
|
||||
axisLabel: { rotate: 45 },
|
||||
});
|
||||
});
|
||||
|
||||
test('merges object override into each yAxis array item', () => {
|
||||
const chartOptions = {
|
||||
yAxis: [
|
||||
{ type: 'value', name: 'Revenue' },
|
||||
{ type: 'value', name: 'Count' },
|
||||
],
|
||||
};
|
||||
|
||||
const override = {
|
||||
yAxis: { axisLine: { show: true }, splitLine: { show: false } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.yAxis).toHaveLength(2);
|
||||
expect(result.yAxis[0]).toEqual({
|
||||
type: 'value',
|
||||
name: 'Revenue',
|
||||
axisLine: { show: true },
|
||||
splitLine: { show: false },
|
||||
});
|
||||
expect(result.yAxis[1]).toEqual({
|
||||
type: 'value',
|
||||
name: 'Count',
|
||||
axisLine: { show: true },
|
||||
splitLine: { show: false },
|
||||
});
|
||||
});
|
||||
|
||||
test('merges object override into dataZoom array items', () => {
|
||||
const chartOptions = {
|
||||
dataZoom: [
|
||||
{ type: 'inside', xAxisIndex: 0 },
|
||||
{ type: 'slider', xAxisIndex: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const override = {
|
||||
dataZoom: { filterMode: 'filter' },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.dataZoom).toHaveLength(2);
|
||||
expect(result.dataZoom[0]).toEqual({
|
||||
type: 'inside',
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'filter',
|
||||
});
|
||||
expect(result.dataZoom[1]).toEqual({
|
||||
type: 'slider',
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'filter',
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves existing properties when merging into array items', () => {
|
||||
const chartOptions = {
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
itemStyle: { color: 'red', borderWidth: 2 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const override = {
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.series[0].itemStyle).toEqual({
|
||||
color: 'red', // preserved
|
||||
borderWidth: 2, // preserved
|
||||
borderRadius: 4, // added
|
||||
});
|
||||
});
|
||||
|
||||
test('applies multiple object overrides in order', () => {
|
||||
const chartOptions = {
|
||||
series: [{ type: 'bar' }],
|
||||
};
|
||||
|
||||
const globalOverride = {
|
||||
series: { itemStyle: { borderRadius: 2, color: 'blue' } },
|
||||
};
|
||||
|
||||
const chartOverride = {
|
||||
series: { itemStyle: { borderRadius: 8 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(
|
||||
chartOptions,
|
||||
globalOverride,
|
||||
chartOverride,
|
||||
);
|
||||
|
||||
expect(result.series[0].itemStyle).toEqual({
|
||||
borderRadius: 8, // chart override wins
|
||||
color: 'blue', // global override preserved
|
||||
});
|
||||
});
|
||||
|
||||
test('handles deeply nested overrides in array items', () => {
|
||||
const chartOptions = {
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
label: { show: true, position: 'top' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const override = {
|
||||
series: {
|
||||
label: { formatter: '{c}', fontSize: 14 },
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
},
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.series[0]).toEqual({
|
||||
type: 'bar',
|
||||
label: {
|
||||
show: true, // preserved
|
||||
position: 'top', // preserved
|
||||
formatter: '{c}', // added
|
||||
fontSize: 14, // added
|
||||
},
|
||||
itemStyle: { borderRadius: [4, 4, 0, 0] },
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
test('handles single object xAxis (not array) normally', () => {
|
||||
const chartOptions = {
|
||||
xAxis: { type: 'category', data: ['Mon', 'Tue'] },
|
||||
};
|
||||
|
||||
const override = {
|
||||
xAxis: { axisLabel: { rotate: 45 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.xAxis).toEqual({
|
||||
type: 'category',
|
||||
data: ['Mon', 'Tue'],
|
||||
axisLabel: { rotate: 45 },
|
||||
});
|
||||
});
|
||||
|
||||
test('skips non-object array items when merging', () => {
|
||||
const chartOptions = {
|
||||
series: [
|
||||
{ type: 'bar' },
|
||||
'invalid', // non-object item
|
||||
null, // null item
|
||||
{ type: 'line' },
|
||||
],
|
||||
};
|
||||
|
||||
const override = {
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
expect(result.series).toHaveLength(4);
|
||||
expect(result.series[0]).toEqual({
|
||||
type: 'bar',
|
||||
itemStyle: { borderRadius: 4 },
|
||||
});
|
||||
expect(result.series[1]).toBe('invalid'); // unchanged
|
||||
expect(result.series[2]).toBe(null); // unchanged
|
||||
expect(result.series[3]).toEqual({
|
||||
type: 'line',
|
||||
itemStyle: { borderRadius: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
test('handles empty overrides gracefully', () => {
|
||||
const chartOptions = {
|
||||
series: [{ type: 'bar' }],
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, {});
|
||||
|
||||
expect(result).toEqual(chartOptions);
|
||||
});
|
||||
|
||||
test('handles missing array property in base', () => {
|
||||
const chartOptions = {
|
||||
grid: { left: '10%' },
|
||||
};
|
||||
|
||||
const override = {
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(chartOptions, override);
|
||||
|
||||
// series override is just added as-is since there's no array to merge into
|
||||
expect(result).toEqual({
|
||||
grid: { left: '10%' },
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
});
|
||||
});
|
||||
|
||||
test('works with the full Echart.tsx merge pattern', () => {
|
||||
const baseTheme = {
|
||||
textStyle: { color: '#333' },
|
||||
};
|
||||
|
||||
const echartOptions = {
|
||||
series: [
|
||||
{ type: 'bar', data: [1, 2, 3] },
|
||||
{ type: 'bar', data: [4, 5, 6] },
|
||||
],
|
||||
xAxis: { type: 'category' },
|
||||
};
|
||||
|
||||
const globalOverrides = {
|
||||
series: { itemStyle: { opacity: 0.8 } },
|
||||
};
|
||||
|
||||
const chartOverrides = {
|
||||
series: { itemStyle: { borderRadius: 4 } },
|
||||
xAxis: { axisLabel: { rotate: 45 } },
|
||||
};
|
||||
|
||||
const result = mergeEchartsThemeOverrides(
|
||||
baseTheme,
|
||||
echartOptions,
|
||||
globalOverrides,
|
||||
chartOverrides,
|
||||
);
|
||||
|
||||
expect(result.textStyle).toEqual({ color: '#333' });
|
||||
expect(result.xAxis).toEqual({
|
||||
type: 'category',
|
||||
axisLabel: { rotate: 45 },
|
||||
});
|
||||
expect(result.series).toHaveLength(2);
|
||||
expect(result.series[0]).toEqual({
|
||||
type: 'bar',
|
||||
data: [1, 2, 3],
|
||||
itemStyle: { opacity: 0.8, borderRadius: 4 },
|
||||
});
|
||||
expect(result.series[1]).toEqual({
|
||||
type: 'bar',
|
||||
data: [4, 5, 6],
|
||||
itemStyle: { opacity: 0.8, borderRadius: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 { mergeWith, isPlainObject } from 'lodash';
|
||||
|
||||
/**
|
||||
* Custom merge function for ECharts theme overrides.
|
||||
*
|
||||
* This function extends lodash's mergeWith with special handling:
|
||||
* 1. Arrays in source values replace destination arrays entirely (backward compatibility)
|
||||
* 2. When source is a plain object and destination is an array, the object is merged
|
||||
* into each array item (allowing default styles to be applied to all items)
|
||||
*
|
||||
* This enables theme authors to write intuitive overrides like:
|
||||
* ```js
|
||||
* echartsOptionsOverridesByChartType: {
|
||||
* echarts_bar: {
|
||||
* series: { itemStyle: { borderRadius: 4 } }, // Applied to ALL series
|
||||
* yAxis: { axisLabel: { rotate: 45 } } // Applied to ALL y-axes
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Without this special handling, specifying `series` or `yAxis` as objects would
|
||||
* fail because the chart's actual values are arrays, and standard object merging
|
||||
* doesn't make sense for array-to-object merges.
|
||||
*
|
||||
* @param sources - Objects to merge (rightmost wins, with special array handling)
|
||||
* @returns Merged object with the custom array-object merge behavior
|
||||
*
|
||||
* @example
|
||||
* // Chart has multiple series:
|
||||
* const chartOptions = {
|
||||
* series: [
|
||||
* { type: 'bar', name: 'Revenue', data: [1, 2, 3] },
|
||||
* { type: 'bar', name: 'Profit', data: [4, 5, 6] }
|
||||
* ]
|
||||
* };
|
||||
*
|
||||
* // Theme override with object (not array):
|
||||
* const override = {
|
||||
* series: { itemStyle: { borderRadius: 4 } }
|
||||
* };
|
||||
*
|
||||
* // Result: borderRadius applied to EACH series
|
||||
* mergeEchartsThemeOverrides(chartOptions, override);
|
||||
* // {
|
||||
* // series: [
|
||||
* // { type: 'bar', name: 'Revenue', data: [1, 2, 3], itemStyle: { borderRadius: 4 } },
|
||||
* // { type: 'bar', name: 'Profit', data: [4, 5, 6], itemStyle: { borderRadius: 4 } }
|
||||
* // ]
|
||||
* // }
|
||||
*/
|
||||
export function mergeEchartsThemeOverrides<T = any>(...sources: any[]): T {
|
||||
const customizer = (objValue: any, srcValue: any): any => {
|
||||
// If source is an array, replace entirely (backward compatibility)
|
||||
if (Array.isArray(srcValue)) {
|
||||
return srcValue;
|
||||
}
|
||||
|
||||
// If destination is an array and source is a plain object,
|
||||
// merge the object into each array item (apply defaults to all items)
|
||||
if (Array.isArray(objValue) && isPlainObject(srcValue)) {
|
||||
return objValue.map(item =>
|
||||
isPlainObject(item) ? mergeWith({}, item, srcValue, customizer) : item,
|
||||
);
|
||||
}
|
||||
|
||||
// Let lodash handle other cases (deep object merge, primitives, etc.)
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return mergeWith({}, ...sources, customizer);
|
||||
}
|
||||
Reference in New Issue
Block a user