You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ma...@apache.org on 2025/08/28 00:55:06 UTC
(superset) 01/01: feat: Add ECharts options overrides to theme system
This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch demo-claudette
in repository https://gitbox.apache.org/repos/asf/superset.git
commit c8b2a7f23052769d8b746b116a32a55d6fab5dc8
Author: Maxime Beauchemin <ma...@gmail.com>
AuthorDate: Wed Aug 27 17:54:23 2025 -0700
feat: Add ECharts options overrides to theme system
## Summary
Implements fine-grained theming control for ECharts visualizations through the Superset theme system. Users can now override ECharts configuration options globally or per chart type without modifying individual chart configurations.
## Key Features
- **Global overrides**: Apply settings to all ECharts visualizations via `echartsOptionsOverrides`
- **Chart-specific overrides**: Target specific chart types via `echartsOptionsOverridesByChartType`
- **Smart merging**: Deep object merging with array replacement using lodash `mergeWith`
- **Proper precedence**: Chart-specific > Global > Plugin defaults > Base theme
## Technical Implementation
- Added theme interface properties to `SupersetTheme` with TypeScript support
- Enhanced core `Echart.tsx` component with intelligent merge logic at single injection point
- Updated chart components to pass `vizType` for chart-specific targeting
- Implemented custom array replacement logic to ensure proper ECharts configuration behavior
## Testing & Validation
- Comprehensive unit tests covering merge behavior and edge cases
- All pre-commit hooks passing (ESLint, TypeScript, formatting)
- Documentation updated with usage examples and best practices
## Usage Example
```javascript
// Theme configuration
{
echartsOptionsOverrides: {
grid: { left: "10%", right: "10%" },
tooltip: { backgroundColor: "rgba(0,0,0,0.8)" }
},
echartsOptionsOverridesByChartType: {
echarts_pie: { legend: { orient: "vertical" } },
echarts_timeseries: { xAxis: { axisLabel: { rotate: 45 } } }
}
}
```
This enables powerful corporate branding and custom styling capabilities while maintaining backward compatibility.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <no...@anthropic.com>
---
docs/docs/configuration/theming.mdx | 200 ++++++++++++++
superset-frontend/package-lock.json | 7 +-
.../packages/superset-ui-core/src/theme/types.ts | 11 +-
.../src/Bubble/EchartsBubble.tsx | 3 +-
.../src/Funnel/EchartsFunnel.tsx | 4 +-
.../src/Gauge/EchartsGauge.tsx | 4 +-
.../plugin-chart-echarts/src/Pie/EchartsPie.tsx | 4 +-
.../src/Radar/EchartsRadar.tsx | 4 +-
.../src/Timeseries/EchartsTimeseries.tsx | 1 +
.../src/components/Echart.test.tsx | 305 +++++++++++++++++++++
.../plugin-chart-echarts/src/components/Echart.tsx | 19 +-
.../plugins/plugin-chart-echarts/src/types.ts | 1 +
.../src/utils/themeOverrides.test.ts | 289 +++++++++++++++++++
13 files changed, 839 insertions(+), 13 deletions(-)
diff --git a/docs/docs/configuration/theming.mdx b/docs/docs/configuration/theming.mdx
index 13ee29d39f..e37d4eb027 100644
--- a/docs/docs/configuration/theming.mdx
+++ b/docs/docs/configuration/theming.mdx
@@ -165,6 +165,206 @@ Or in the CRUD interface theme JSON:
This feature works with the stock Docker image - no custom build required!
+## ECharts Configuration Overrides
+
+:::note
+Available since Superset 6.0
+:::
+
+Superset provides fine-grained control over ECharts visualizations through theme-level configuration overrides. This allows you to customize the appearance and behavior of all ECharts-based charts without modifying individual chart configurations.
+
+### Global ECharts Overrides
+
+Apply settings to all ECharts visualizations using `echartsOptionsOverrides`:
+
+```python
+THEME_DEFAULT = {
+ "token": {
+ "colorPrimary": "#2893B3",
+ # ... other Ant Design tokens
+ },
+ "echartsOptionsOverrides": {
+ "grid": {
+ "left": "10%",
+ "right": "10%",
+ "top": "15%",
+ "bottom": "15%"
+ },
+ "tooltip": {
+ "backgroundColor": "rgba(0, 0, 0, 0.8)",
+ "borderColor": "#ccc",
+ "textStyle": {
+ "color": "#fff"
+ }
+ },
+ "legend": {
+ "textStyle": {
+ "fontSize": 14,
+ "fontWeight": "bold"
+ }
+ }
+ }
+}
+```
+
+### Chart-Specific Overrides
+
+Target specific chart types using `echartsOptionsOverridesByChartType`:
+
+```python
+THEME_DEFAULT = {
+ "token": {
+ "colorPrimary": "#2893B3",
+ # ... other tokens
+ },
+ "echartsOptionsOverridesByChartType": {
+ "echarts_pie": {
+ "legend": {
+ "orient": "vertical",
+ "right": 10,
+ "top": "center"
+ }
+ },
+ "echarts_timeseries": {
+ "xAxis": {
+ "axisLabel": {
+ "rotate": 45,
+ "fontSize": 12
+ }
+ },
+ "dataZoom": [{
+ "type": "slider",
+ "show": True,
+ "start": 0,
+ "end": 100
+ }]
+ },
+ "echarts_bubble": {
+ "grid": {
+ "left": "15%",
+ "bottom": "20%"
+ }
+ }
+ }
+}
+```
+
+### UI Configuration
+
+You can also configure ECharts overrides through the theme CRUD interface:
+
+```json
+{
+ "token": {
+ "colorPrimary": "#2893B3"
+ },
+ "echartsOptionsOverrides": {
+ "grid": {
+ "left": "10%",
+ "right": "10%"
+ },
+ "tooltip": {
+ "backgroundColor": "rgba(0, 0, 0, 0.8)"
+ }
+ },
+ "echartsOptionsOverridesByChartType": {
+ "echarts_pie": {
+ "legend": {
+ "orient": "vertical",
+ "right": 10
+ }
+ }
+ }
+}
+```
+
+### Override Precedence
+
+The system applies overrides in the following order (last wins):
+
+1. **Base ECharts theme** - Default Superset styling
+2. **Plugin options** - Chart-specific configurations
+3. **Global overrides** - `echartsOptionsOverrides`
+4. **Chart-specific overrides** - `echartsOptionsOverridesByChartType[chartType]`
+
+This ensures chart-specific overrides take precedence over global ones.
+
+### Common Chart Types
+
+Available chart types for `echartsOptionsOverridesByChartType`:
+
+- `echarts_timeseries` - Time series/line charts
+- `echarts_pie` - Pie and donut charts
+- `echarts_bubble` - Bubble/scatter charts
+- `echarts_funnel` - Funnel charts
+- `echarts_gauge` - Gauge charts
+- `echarts_radar` - Radar charts
+- `echarts_boxplot` - Box plot charts
+- `echarts_treemap` - Treemap charts
+- `echarts_sunburst` - Sunburst charts
+- `echarts_graph` - Network/graph charts
+- `echarts_sankey` - Sankey diagrams
+- `echarts_heatmap` - Heatmaps
+- `echarts_mixed_timeseries` - Mixed time series
+
+### Best Practices
+
+1. **Start with global overrides** for consistent styling across all charts
+2. **Use chart-specific overrides** for unique requirements per visualization type
+3. **Test thoroughly** as overrides use deep merge - nested objects are combined, but arrays are completely replaced
+4. **Document your overrides** to help team members understand custom styling
+5. **Consider performance** - complex overrides may impact chart rendering speed
+
+### Example: Corporate Branding
+
+```python
+# Complete corporate theme with ECharts customization
+THEME_DEFAULT = {
+ "token": {
+ "colorPrimary": "#1B4D3E",
+ "fontFamily": "Corporate Sans, Arial, sans-serif"
+ },
+ "echartsOptionsOverrides": {
+ "grid": {
+ "left": "8%",
+ "right": "8%",
+ "top": "12%",
+ "bottom": "12%"
+ },
+ "textStyle": {
+ "fontFamily": "Corporate Sans, Arial, sans-serif"
+ },
+ "title": {
+ "textStyle": {
+ "color": "#1B4D3E",
+ "fontSize": 18,
+ "fontWeight": "bold"
+ }
+ }
+ },
+ "echartsOptionsOverridesByChartType": {
+ "echarts_timeseries": {
+ "xAxis": {
+ "axisLabel": {
+ "color": "#666",
+ "fontSize": 11
+ }
+ }
+ },
+ "echarts_pie": {
+ "legend": {
+ "textStyle": {
+ "fontSize": 12
+ },
+ "itemGap": 20
+ }
+ }
+ }
+}
+```
+
+This feature provides powerful theming capabilities while maintaining the flexibility of ECharts' extensive configuration options.
+
## Advanced Features
- **System Themes**: Manage system-wide default and dark themes via UI or configuration
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index 0f5538f1f4..1eda99b676 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -53602,10 +53602,6 @@
"kdbush": "^4.0.2"
}
},
- "node_modules/superset-core": {
- "resolved": "packages/superset-core",
- "link": true
- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -60600,7 +60596,8 @@
}
},
"packages/superset-core": {
- "version": "0.0.1",
+ "name": "@apache-superset/core",
+ "version": "0.0.1-rc2",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
diff --git a/superset-frontend/packages/superset-ui-core/src/theme/types.ts b/superset-frontend/packages/superset-ui-core/src/theme/types.ts
index 39dfc4d671..2a4dc61694 100644
--- a/superset-frontend/packages/superset-ui-core/src/theme/types.ts
+++ b/superset-frontend/packages/superset-ui-core/src/theme/types.ts
@@ -362,7 +362,16 @@ export type AllowedAntdTokenKeys = Extract<
export type SharedAntdTokens = Pick<AntdTokens, AllowedAntdTokenKeys>;
/** The final shape for our custom theme object, combining shared antd + superset specifics. */
-export type SupersetTheme = SharedAntdTokens & SupersetSpecificTokens;
+export type SupersetTheme = SharedAntdTokens &
+ SupersetSpecificTokens & {
+ /** Global ECharts configuration overrides applied to all chart types */
+ echartsOptionsOverrides?: any;
+
+ /** Chart-specific ECharts configuration overrides keyed by viz_type */
+ echartsOptionsOverridesByChartType?: {
+ [chartType: string]: any;
+ };
+ };
export interface ThemeStorage {
getItem(key: string): string | null;
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx
index 4aab4a2d24..e580a9e8ba 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx
@@ -20,13 +20,14 @@ import { BubbleChartTransformedProps } from './types';
import Echart from '../components/Echart';
export default function EchartsBubble(props: BubbleChartTransformedProps) {
- const { height, width, echartOptions, refs } = props;
+ const { height, width, echartOptions, refs, formData } = props;
return (
<Echart
height={height}
width={width}
echartOptions={echartOptions}
refs={refs}
+ vizType={formData.viz_type}
/>
);
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx
index 4ae0ab4920..7c3c5c82e2 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx
@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsFunnel(props: FunnelChartTransformedProps) {
- const { height, width, echartOptions, selectedValues, refs } = props;
+ const { height, width, echartOptions, selectedValues, refs, formData } =
+ props;
const eventHandlers = allEventHandlers(props);
@@ -33,6 +34,7 @@ export default function EchartsFunnel(props: FunnelChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
+ vizType={formData.viz_type}
/>
);
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx
index 14ec2ebb7c..72f8b4c784 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx
@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsGauge(props: GaugeChartTransformedProps) {
- const { height, width, echartOptions, selectedValues, refs } = props;
+ const { height, width, echartOptions, selectedValues, refs, formData } =
+ props;
const eventHandlers = allEventHandlers(props);
@@ -33,6 +34,7 @@ export default function EchartsGauge(props: GaugeChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
+ vizType={formData.viz_type}
/>
);
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx
index fb82cd53bc..f5ae33fe0b 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx
@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsPie(props: PieChartTransformedProps) {
- const { height, width, echartOptions, selectedValues, refs } = props;
+ const { height, width, echartOptions, selectedValues, refs, formData } =
+ props;
const eventHandlers = allEventHandlers(props);
@@ -33,6 +34,7 @@ export default function EchartsPie(props: PieChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
+ vizType={formData.viz_type}
/>
);
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx
index b8937bd42d..ec0379dd28 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx
@@ -21,7 +21,8 @@ import Echart from '../components/Echart';
import { allEventHandlers } from '../utils/eventHandlers';
export default function EchartsRadar(props: RadarChartTransformedProps) {
- const { height, width, echartOptions, selectedValues, refs } = props;
+ const { height, width, echartOptions, selectedValues, refs, formData } =
+ props;
const eventHandlers = allEventHandlers(props);
return (
@@ -32,6 +33,7 @@ export default function EchartsRadar(props: RadarChartTransformedProps) {
echartOptions={echartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
+ vizType={formData.viz_type}
/>
);
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
index 79bec925a8..3b52725269 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -282,6 +282,7 @@ export default function EchartsTimeseries({
eventHandlers={eventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
+ vizType={formData.viz_type}
/>
</>
);
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
new file mode 100644
index 0000000000..b2fd72f390
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.test.tsx
@@ -0,0 +1,305 @@
+/**
+ * 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 { render } from '@superset-ui/core/spec';
+import { SupersetTheme, ThemeProvider } from '@superset-ui/core';
+import Echart from './Echart';
+import { EchartsProps } from '../types';
+
+const mockEchartsInstance = {
+ setOption: jest.fn(),
+ resize: jest.fn(),
+ dispose: jest.fn(),
+ off: jest.fn(),
+ on: jest.fn(),
+ dispatchAction: jest.fn(),
+ getZr: jest.fn(() => ({
+ off: jest.fn(),
+ on: jest.fn(),
+ })),
+};
+
+// Mock ECharts
+jest.mock('echarts/core', () => ({
+ use: jest.fn(),
+ init: jest.fn(() => mockEchartsInstance),
+ registerLocale: jest.fn(),
+}));
+
+// Mock all ECharts components and features
+jest.mock('echarts/charts', () => ({
+ SankeyChart: jest.fn(),
+ PieChart: jest.fn(),
+ BarChart: jest.fn(),
+ FunnelChart: jest.fn(),
+ GaugeChart: jest.fn(),
+ GraphChart: jest.fn(),
+ LineChart: jest.fn(),
+ ScatterChart: jest.fn(),
+ RadarChart: jest.fn(),
+ BoxplotChart: jest.fn(),
+ TreeChart: jest.fn(),
+ TreemapChart: jest.fn(),
+ HeatmapChart: jest.fn(),
+ SunburstChart: jest.fn(),
+ CustomChart: jest.fn(),
+}));
+
+jest.mock('echarts/renderers', () => ({
+ CanvasRenderer: jest.fn(),
+}));
+
+jest.mock('echarts/components', () => ({
+ TooltipComponent: jest.fn(),
+ TitleComponent: jest.fn(),
+ GridComponent: jest.fn(),
+ VisualMapComponent: jest.fn(),
+ LegendComponent: jest.fn(),
+ DataZoomComponent: jest.fn(),
+ ToolboxComponent: jest.fn(),
+ GraphicComponent: jest.fn(),
+ AriaComponent: jest.fn(),
+ MarkAreaComponent: jest.fn(),
+ MarkLineComponent: jest.fn(),
+}));
+
+jest.mock('echarts/features', () => ({
+ LabelLayout: jest.fn(),
+}));
+
+// Mock Redux selector
+jest.mock('react-redux', () => ({
+ useSelector: jest.fn(() => 'EN'),
+}));
+
+const mockBaseTheme: SupersetTheme = {
+ colorText: '#000000',
+ colorTextSecondary: '#666666',
+ fontFamily: 'Inter, sans-serif',
+ colorBgContainer: '#ffffff',
+ colorPrimary: '#1890ff',
+ colorSplit: '#f0f0f0',
+} as SupersetTheme;
+
+const defaultProps: EchartsProps = {
+ height: 400,
+ width: 600,
+ echartOptions: {
+ title: { text: 'Test Chart' },
+ xAxis: { type: 'category' },
+ yAxis: { type: 'value' },
+ },
+ refs: { echartRef: { current: null }, divRef: { current: null } },
+};
+
+describe('Echart Theme Overrides', () => {
+ test('should merge global theme overrides correctly', () => {
+ const themeWithGlobalOverrides: SupersetTheme = {
+ ...mockBaseTheme,
+ echartsOptionsOverrides: {
+ grid: {
+ left: '10%',
+ right: '10%',
+ },
+ tooltip: {
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ },
+ },
+ };
+
+ const { container } = render(
+ <ThemeProvider theme={themeWithGlobalOverrides}>
+ <Echart {...defaultProps} />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ test('should merge chart-specific theme overrides correctly', () => {
+ const themeWithChartOverrides: SupersetTheme = {
+ ...mockBaseTheme,
+ echartsOptionsOverridesByChartType: {
+ echarts_pie: {
+ legend: {
+ orient: 'vertical',
+ right: 10,
+ },
+ },
+ echarts_timeseries: {
+ xAxis: {
+ axisLabel: {
+ rotate: 45,
+ },
+ },
+ },
+ },
+ };
+
+ const { container } = render(
+ <ThemeProvider theme={themeWithChartOverrides}>
+ <Echart {...defaultProps} vizType="echarts_pie" />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ test('should prioritize chart-specific overrides over global overrides', () => {
+ const themeWithBothOverrides: SupersetTheme = {
+ ...mockBaseTheme,
+ echartsOptionsOverrides: {
+ grid: {
+ left: '5%',
+ right: '5%',
+ },
+ legend: {
+ orient: 'horizontal',
+ bottom: 10,
+ },
+ },
+ echartsOptionsOverridesByChartType: {
+ echarts_pie: {
+ legend: {
+ orient: 'vertical',
+ right: 10,
+ },
+ },
+ },
+ };
+
+ const { container } = render(
+ <ThemeProvider theme={themeWithBothOverrides}>
+ <Echart {...defaultProps} vizType="echarts_pie" />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ test('should work without any theme overrides', () => {
+ const { container } = render(
+ <ThemeProvider theme={mockBaseTheme}>
+ <Echart {...defaultProps} />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ test('should work without vizType prop', () => {
+ const themeWithOverrides: SupersetTheme = {
+ ...mockBaseTheme,
+ echartsOptionsOverrides: {
+ grid: {
+ left: '5%',
+ },
+ },
+ echartsOptionsOverridesByChartType: {
+ echarts_pie: {
+ legend: {
+ orient: 'vertical',
+ },
+ },
+ },
+ };
+
+ const { container } = render(
+ <ThemeProvider theme={themeWithOverrides}>
+ <Echart {...defaultProps} />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ test('should handle nested object merging correctly', () => {
+ const themeWithNestedOverrides: SupersetTheme = {
+ ...mockBaseTheme,
+ echartsOptionsOverrides: {
+ xAxis: {
+ axisLabel: {
+ color: '#333',
+ rotate: 0,
+ },
+ splitLine: {
+ show: true,
+ },
+ },
+ },
+ };
+
+ const propsWithAxis = {
+ ...defaultProps,
+ echartOptions: {
+ ...defaultProps.echartOptions,
+ xAxis: {
+ type: 'category',
+ axisLabel: {
+ fontSize: 12,
+ },
+ },
+ },
+ };
+
+ const { container } = render(
+ <ThemeProvider theme={themeWithNestedOverrides}>
+ <Echart {...propsWithAxis} />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ test('should handle empty override objects gracefully', () => {
+ const themeWithEmptyOverrides: SupersetTheme = {
+ ...mockBaseTheme,
+ echartsOptionsOverrides: {},
+ echartsOptionsOverridesByChartType: {},
+ };
+
+ const { container } = render(
+ <ThemeProvider theme={themeWithEmptyOverrides}>
+ <Echart {...defaultProps} vizType="echarts_pie" />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+
+ test('should handle null and undefined values in overrides', () => {
+ const themeWithNullOverrides: SupersetTheme = {
+ ...mockBaseTheme,
+ echartsOptionsOverrides: {
+ grid: {
+ left: null,
+ right: undefined,
+ top: '10%',
+ },
+ },
+ };
+
+ const { container } = render(
+ <ThemeProvider theme={themeWithNullOverrides}>
+ <Echart {...defaultProps} />
+ </ThemeProvider>,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
+});
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 88d2b5a9dc..178afb9839 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -27,7 +27,7 @@ import {
Ref,
useState,
} from 'react';
-import { merge } from 'lodash';
+import { mergeWith } from 'lodash';
import { useSelector } from 'react-redux';
@@ -131,6 +131,7 @@ function Echart(
zrEventHandlers,
selectedValues = {},
refs,
+ vizType,
}: EchartsProps,
ref: Ref<EchartsHandler>,
) {
@@ -231,10 +232,24 @@ function Echart(
return echartsTheme;
};
- const themedEchartOptions = merge(
+ // Custom replacer function to replace arrays instead of merging them
+ const replaceArrays = (objValue: any, srcValue: any) => {
+ if (Array.isArray(srcValue)) {
+ return srcValue; // Replace arrays entirely
+ }
+ // Let lodash handle object merging for non-arrays
+ return undefined;
+ };
+
+ const themedEchartOptions = mergeWith(
{},
getEchartsTheme(echartOptions),
echartOptions,
+ theme.echartsOptionsOverrides || {},
+ vizType
+ ? theme.echartsOptionsOverridesByChartType?.[vizType] || {}
+ : {},
+ replaceArrays,
);
chartRef.current?.setOption(themedEchartOptions, true);
}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
index fe923003b3..33c9cc07b9 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
@@ -55,6 +55,7 @@ export interface EchartsProps {
selectedValues?: Record<number, string>;
forceClear?: boolean;
refs: Refs;
+ vizType?: string;
}
export interface EchartsHandler {
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
new file mode 100644
index 0000000000..7578e80fd4
--- /dev/null
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/themeOverrides.test.ts
@@ -0,0 +1,289 @@
+/**
+ * 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 } from 'lodash';
+
+describe('Theme Override Deep Merge Behavior', () => {
+ // Custom replacer function to replace arrays instead of merging them
+ const replaceArrays = (objValue: any, srcValue: any) => {
+ if (Array.isArray(srcValue)) {
+ return srcValue; // Replace arrays entirely
+ }
+ // Let lodash handle object merging for non-arrays
+ return undefined;
+ };
+
+ test('should merge nested objects correctly', () => {
+ const baseOptions = {
+ grid: {
+ left: '5%',
+ right: '5%',
+ top: '10%',
+ },
+ xAxis: {
+ type: 'category',
+ axisLabel: {
+ fontSize: 12,
+ },
+ },
+ };
+
+ const globalOverrides = {
+ grid: {
+ left: '10%',
+ bottom: '15%',
+ },
+ xAxis: {
+ axisLabel: {
+ color: '#333',
+ rotate: 45,
+ },
+ },
+ };
+
+ const result = mergeWith({}, baseOptions, globalOverrides, replaceArrays);
+
+ 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 = mergeWith({}, baseOptions, overrides, replaceArrays);
+
+ // 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 = mergeWith({}, baseOptions, overrides, replaceArrays);
+
+ 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 = mergeWith(
+ {},
+ baseTheme,
+ pluginOptions,
+ globalOverrides,
+ chartOverrides,
+ replaceArrays,
+ );
+
+ 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 = mergeWith({}, baseOptions, overrides, replaceArrays);
+
+ 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 = mergeWith({}, baseOptions, overrides, replaceArrays);
+
+ 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 = mergeWith(
+ {},
+ baseOptions,
+ emptyOverrides,
+ replaceArrays,
+ );
+ const resultArray = mergeWith(
+ {},
+ baseOptions,
+ arrayOverride,
+ replaceArrays,
+ );
+ const resultObject = mergeWith(
+ {},
+ baseOptions,
+ objectOverride,
+ replaceArrays,
+ );
+
+ expect(resultEmpty).toEqual(baseOptions);
+ // Empty array completely replaces existing array
+ expect(resultArray.series).toEqual([]);
+ expect(resultObject.grid).toEqual({ left: '5%' });
+ });
+});