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:
Evan Rusackas
2026-03-02 22:51:09 -05:00
committed by GitHub
parent 6e84d29707
commit 0681df3d02
5 changed files with 618 additions and 243 deletions

View File

@@ -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 } },
},
});
});

View File

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

View File

@@ -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,

View File

@@ -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 },
});
});

View File

@@ -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);
}