This is an automated email from the ASF dual-hosted git repository. rusackas pushed a commit to branch fix/echarts-series-theme-overrides in repository https://gitbox.apache.org/repos/asf/superset.git
commit cfc9e0a64fb82f215736ab9dc373bccad7a00e15 Author: Evan Rusackas <[email protected]> AuthorDate: Thu Feb 12 20:44:04 2026 -0800 feat(theme): enable generalized ECharts theme overrides for array properties Adds a custom merge function `mergeEchartsThemeOverrides` that handles ECharts theme overrides with special array-to-object merge behavior: 1. Arrays in source values replace destination arrays entirely (backward compat) 2. When source is a plain object and destination is an array, the object is merged into each array item (allowing default styles for 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 } } ``` Works for any array-based ECharts option: series, xAxis, yAxis, dataZoom, visualMap, etc. - no need to handle each property specially. Co-Authored-By: Claude Opus 4.5 <[email protected]> --- .../plugin-chart-echarts/src/components/Echart.tsx | 4 +- .../src/utils/themeOverrides.test.ts | 617 ++++++++++++++------- .../src/utils/themeOverrides.ts | 91 +++ 3 files changed, 498 insertions(+), 214 deletions(-) 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 c62f5535a30..2e3d5617e96 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 @@ -244,7 +244,7 @@ function Echart( ? theme.echartsOptionsOverridesByChartType?.[vizType] || {} : {}; - 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..5ab80512977 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,441 @@ * 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..79423dd726b --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.ts @@ -0,0 +1,91 @@ +/** + * 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); +}
