This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 0681df3d029 feat(theme): enable generalized ECharts theme overrides
for array properties (#37965)
0681df3d029 is described below
commit 0681df3d0293cb2052b547e9d546fb633c9e522f
Author: Evan Rusackas <[email protected]>
AuthorDate: Mon Mar 2 22:51:09 2026 -0500
feat(theme): enable generalized ECharts theme overrides for array
properties (#37965)
Co-authored-by: Claude Opus 4.5 <[email protected]>
---
.../superset-core/src/ui/theme/Theme.test.tsx | 73 +++
.../packages/superset-core/src/ui/theme/Theme.tsx | 14 +
.../plugin-chart-echarts/src/components/Echart.tsx | 4 +-
.../src/utils/themeOverrides.test.ts | 623 ++++++++++++++-------
.../src/utils/themeOverrides.ts | 89 +++
5 files changed, 589 insertions(+), 214 deletions(-)
diff --git
a/superset-frontend/packages/superset-core/src/ui/theme/Theme.test.tsx
b/superset-frontend/packages/superset-core/src/ui/theme/Theme.test.tsx
index e837bd66db9..873cf94ec5f 100644
--- a/superset-frontend/packages/superset-core/src/ui/theme/Theme.test.tsx
+++ b/superset-frontend/packages/superset-core/src/ui/theme/Theme.test.tsx
@@ -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 } },
+ },
+ });
+});
diff --git a/superset-frontend/packages/superset-core/src/ui/theme/Theme.tsx
b/superset-frontend/packages/superset-core/src/ui/theme/Theme.tsx
index 6096d69bd7d..7a890982eaa 100644
--- a/superset-frontend/packages/superset-core/src/ui/theme/Theme.tsx
+++ b/superset-frontend/packages/superset-core/src/ui/theme/Theme.tsx
@@ -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
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index 1d44746cc7e..75c5316ef4c 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -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,
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
index 931f026cb5f..b2e9476b9d1 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
@@ -16,248 +16,447 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { mergeReplaceArrays } from '@superset-ui/core';
-
-describe('Theme Override Deep Merge Behavior', () => {
- test('should merge nested objects correctly', () => {
- const baseOptions = {
- grid: {
- left: '5%',
- right: '5%',
- top: '10%',
- },
- xAxis: {
- type: 'category',
- axisLabel: {
- fontSize: 12,
- },
- },
- };
+import { mergeEchartsThemeOverrides } from './themeOverrides';
- const globalOverrides = {
- grid: {
- left: '10%',
- bottom: '15%',
- },
- xAxis: {
- axisLabel: {
- color: '#333',
- rotate: 45,
- },
- },
- };
+//
=============================================================================
+// Basic Deep Merge Behavior
+//
=============================================================================
- const result = mergeReplaceArrays(baseOptions, globalOverrides);
+test('merges nested objects correctly', () => {
+ const baseOptions = {
+ grid: { left: '5%', right: '5%', top: '10%' },
+ xAxis: { type: 'category', axisLabel: { fontSize: 12 } },
+ };
- 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
- },
+ 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
},
- });
+ },
});
+});
+
+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,
+ );
- test('should replace arrays instead of merging them', () => {
- const baseOptions = {
- series: [
- { name: 'Series 1', type: 'line' },
- { name: 'Series 2', type: 'bar' },
- ],
- };
+ 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%' },
+ });
+});
- const overrides = {
- series: [{ name: 'New Series', type: 'pie' }],
- };
+test('handles null values correctly', () => {
+ const base = { grid: { left: '5%', right: '5%' } };
+ const overrides = { grid: { left: null, bottom: '20%' } };
- const result = mergeReplaceArrays(baseOptions, overrides);
+ const result = mergeEchartsThemeOverrides(base, overrides);
- // Arrays are replaced entirely, not merged by index
- expect(result.series).toEqual([{ name: 'New Series', type: 'pie' }]);
- expect(result.series).toHaveLength(1);
+ expect(result.grid).toEqual({
+ left: null,
+ right: '5%',
+ bottom: '20%',
});
+});
- test('should handle null overrides correctly', () => {
- const baseOptions = {
- grid: {
- left: '5%',
- right: '5%',
- top: '10%',
- },
- tooltip: {
- show: true,
- backgroundColor: '#fff',
- },
- };
+test('handles function values correctly', () => {
+ const original = (v: number) => `${v}%`;
+ const override = (v: number) => `$${v}`;
- const overrides = {
- grid: {
- left: null,
- bottom: '20%',
- },
- tooltip: {
- backgroundColor: null,
- borderColor: '#ccc',
- },
- };
+ const base = { yAxis: { axisLabel: { formatter: original } } };
+ const overrides = { yAxis: { axisLabel: { formatter: override } } };
- const result = mergeReplaceArrays(baseOptions, overrides);
+ const result = mergeEchartsThemeOverrides(base, 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
- },
- });
+ 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('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('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('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('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);
- test('should handle function values correctly', () => {
- const formatFunction = (value: any) => `${value}%`;
- const overrideFunction = (value: any) => `$${value}`;
+ 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',
+ });
+});
- const baseOptions = {
- yAxis: {
- axisLabel: {
- formatter: formatFunction,
- },
+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 overrides = {
- yAxis: {
- axisLabel: {
- formatter: overrideFunction,
- },
+ 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 result = mergeReplaceArrays(baseOptions, overrides);
+ const override = {
+ series: {
+ label: { formatter: '{c}', fontSize: 14 },
+ itemStyle: { borderRadius: [4, 4, 0, 0] },
+ },
+ };
- expect(result.yAxis.axisLabel.formatter).toBe(overrideFunction);
- expect(result.yAxis.axisLabel.formatter('100')).toBe('$100');
+ 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('should handle empty objects and arrays', () => {
- const baseOptions = {
- series: [{ name: 'Test', data: [1, 2, 3] }],
- grid: { left: '5%' },
- };
+test('handles single object xAxis (not array) normally', () => {
+ const chartOptions = {
+ xAxis: { type: 'category', data: ['Mon', 'Tue'] },
+ };
- const emptyOverrides = {};
- const arrayOverride = { series: [] };
- const objectOverride = { grid: {} };
+ const override = {
+ xAxis: { axisLabel: { rotate: 45 } },
+ };
- const resultEmpty = mergeReplaceArrays(baseOptions, emptyOverrides);
- const resultArray = mergeReplaceArrays(baseOptions, arrayOverride);
- const resultObject = mergeReplaceArrays(baseOptions, objectOverride);
+ const result = mergeEchartsThemeOverrides(chartOptions, override);
+
+ expect(result.xAxis).toEqual({
+ type: 'category',
+ data: ['Mon', 'Tue'],
+ axisLabel: { rotate: 45 },
+ });
+});
- expect(resultEmpty).toEqual(baseOptions);
- // Empty array completely replaces existing array
- expect(resultArray.series).toEqual([]);
- expect(resultObject.grid).toEqual({ left: '5%' });
+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 },
});
});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.ts
new file mode 100644
index 00000000000..46789d5b8a7
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.ts
@@ -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);
+}