This is an automated email from the ASF dual-hosted git repository.
justinpark 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 35d0aad854 feat(explore): Add Echarts option editor (#37868)
35d0aad854 is described below
commit 35d0aad854a176a25718a1774c5f28a86da1e27b
Author: JUST.in DO IT <[email protected]>
AuthorDate: Wed Mar 4 08:34:34 2026 -0800
feat(explore): Add Echarts option editor (#37868)
---
superset-frontend/package-lock.json | 29 +-
.../packages/superset-core/src/api/editors.ts | 10 +-
.../src/shared-controls/sharedControls.tsx | 15 +
.../superset-ui-chart-controls/src/types.ts | 1 +
.../src/components/AsyncAceEditor/index.tsx | 6 +
.../superset-ui-core/src/components/index.ts | 1 +
.../plugins/plugin-chart-echarts/package.json | 4 +-
.../src/MixedTimeseries/controlPanel.tsx | 1 +
.../src/MixedTimeseries/transformProps.ts | 20 +-
.../src/Timeseries/Area/controlPanel.tsx | 1 +
.../src/Timeseries/Regular/Bar/controlPanel.tsx | 1 +
.../src/Timeseries/Regular/Line/controlPanel.tsx | 1 +
.../Timeseries/Regular/SmoothLine/controlPanel.tsx | 1 +
.../src/Timeseries/transformProps.ts | 21 +-
.../plugins/plugin-chart-echarts/src/index.ts | 3 +
.../src/utils/eChartOptionsSchema.ts | 827 +++++++++++++++++++++
.../src/utils/mergeCustomEChartOptions.test.ts | 163 ++++
.../src/utils/mergeCustomEChartOptions.ts | 79 ++
.../src/utils/safeEChartOptionsParser.test.ts | 525 +++++++++++++
.../src/utils/safeEChartOptionsParser.ts | 477 ++++++++++++
.../src/core/editors/AceEditorProvider.tsx | 3 +
.../components/controls/JSEditorControl.test.tsx | 125 ++++
.../components/controls/JSEditorControl.tsx | 105 +++
.../src/explore/components/controls/index.ts | 2 +
24 files changed, 2413 insertions(+), 8 deletions(-)
diff --git a/superset-frontend/package-lock.json
b/superset-frontend/package-lock.json
index fe9c92dc49..52dd41c7e4 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -51377,6 +51377,15 @@
"numcodecs": "^0.3.2"
}
},
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity":
"sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
@@ -51752,12 +51761,14 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "*",
"@testing-library/user-event": "*",
+ "acorn": "*",
"ace-builds": "^1.4.14",
"brace": "^0.11.1",
"memoize-one": "^5.1.1",
"react": "^17.0.2",
"react-ace": "^10.1.0",
- "react-dom": "^17.0.2"
+ "react-dom": "^17.0.2",
+ "zod": "*"
}
},
"packages/superset-ui-core": {
@@ -53233,8 +53244,10 @@
"dependencies": {
"@types/d3-array": "^3.2.2",
"@types/react-redux": "^7.1.34",
+ "acorn": "^8.9.0",
"d3-array": "^3.2.4",
- "lodash": "^4.17.23"
+ "lodash": "^4.17.23",
+ "zod": "^4.3.6"
},
"peerDependencies": {
"@apache-superset/core": "*",
@@ -53246,6 +53259,18 @@
"react": "^17.0.2"
}
},
+ "plugins/plugin-chart-echarts/node_modules/acorn": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz",
+ "integrity":
"sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"plugins/plugin-chart-echarts/node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
diff --git a/superset-frontend/packages/superset-core/src/api/editors.ts
b/superset-frontend/packages/superset-core/src/api/editors.ts
index 334f28a49e..1113bc8d72 100644
--- a/superset-frontend/packages/superset-core/src/api/editors.ts
+++ b/superset-frontend/packages/superset-core/src/api/editors.ts
@@ -40,7 +40,15 @@ import type { SupersetTheme } from '../ui';
/**
* Supported editor languages.
*/
-export type EditorLanguage = 'sql' | 'json' | 'yaml' | 'markdown' | 'css';
+export type EditorLanguage =
+ | 'sql'
+ | 'json'
+ | 'yaml'
+ | 'markdown'
+ | 'css'
+ | 'python'
+ | 'text'
+ | 'javascript';
/**
* Describes an editor that can be contributed to the application.
diff --git
a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
index bf087565ab..0ec021d11f 100644
---
a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
+++
b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
@@ -453,6 +453,20 @@ const order_by_cols: SharedControlConfig<'SelectControl'>
= {
resetOnHide: false,
};
+const echart_options: SharedControlConfig<'JSEditorControl'> = {
+ type: 'JSEditorControl',
+ label: t('ECharts Options (JS object literals)'),
+ description: t(
+ 'A JavaScript object that adheres to the ECharts options specification, ' +
+ 'overriding other control options with higher precedence. ' +
+ '(i.e. { title: { text: "My Chart" }, tooltip: { trigger: "item" } }). '
+
+ 'Details: https://echarts.apache.org/en/option.html. ',
+ ),
+ default: '{}',
+ renderTrigger: true,
+ validators: [],
+};
+
const sharedControls: Record<string, SharedControlConfig<any>> = {
metrics: dndAdhocMetricsControl,
metric: dndAdhocMetricControl,
@@ -499,6 +513,7 @@ const sharedControls: Record<string,
SharedControlConfig<any>> = {
currency_format,
sort_by_metric,
order_by_cols,
+ echart_options,
// Add all Matrixify controls
...matrixifyControls,
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
index b501b7c9bf..8825f5aca6 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
@@ -170,6 +170,7 @@ export type InternalControlType =
| 'FixedOrMetricControl'
| 'ColorBreakpointsControl'
| 'HiddenControl'
+ | 'JSEditorControl'
| 'SelectAsyncControl'
| 'SelectControl'
| 'SliderControl'
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
index 90a2cafcc9..6ec452d5a9 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/AsyncAceEditor/index.tsx
@@ -502,3 +502,9 @@ export const ConfigEditor = AsyncAceEditor([
'mode/yaml',
'theme/github',
]);
+
+export const JSEditor = AsyncAceEditor([
+ 'mode/javascript',
+ 'mode/json',
+ 'theme/github',
+]);
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/index.ts
b/superset-frontend/packages/superset-ui-core/src/components/index.ts
index 30bad84f4d..048504ccfd 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/index.ts
+++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts
@@ -39,6 +39,7 @@ export {
AsyncAceEditor,
CssEditor,
JsonEditor,
+ JSEditor,
SQLEditor,
FullSQLEditor,
MarkdownEditor,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/package.json
b/superset-frontend/plugins/plugin-chart-echarts/package.json
index 3bba8cfea3..c56ac50513 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/package.json
+++ b/superset-frontend/plugins/plugin-chart-echarts/package.json
@@ -26,8 +26,10 @@
"dependencies": {
"@types/d3-array": "^3.2.2",
"@types/react-redux": "^7.1.34",
+ "acorn": "^8.9.0",
"d3-array": "^3.2.4",
- "lodash": "^4.17.23"
+ "lodash": "^4.17.23",
+ "zod": "^4.3.6"
},
"peerDependencies": {
"@apache-superset/core": "*",
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
index 0ec6080c50..ea5c8ca005 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/controlPanel.tsx
@@ -505,6 +505,7 @@ const config: ControlPanelConfig = {
},
},
],
+ ['echart_options'],
],
},
],
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
index 26094b3a10..0d58f82532 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts
@@ -58,6 +58,7 @@ import {
Refs,
} from '../types';
import { parseAxisBound } from '../utils/controls';
+import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
import {
dedupSeries,
extractDataTotalValues,
@@ -99,6 +100,7 @@ import {
getYAxisFormatter,
} from '../utils/formatters';
import { getMetricDisplayName } from '../utils/metricDisplayName';
+import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
const getFormatter = (
customFormatters: Record<string, ValueFormatter>,
@@ -122,7 +124,7 @@ export default function transformProps(
const {
width,
height,
- formData,
+ formData: { echartOptions: _echartOptions, ...formData },
queriesData,
hooks,
filterState,
@@ -803,11 +805,25 @@ export default function transformProps(
focusedSeries = seriesName;
};
+ let customEchartOptions;
+ try {
+ // Parse custom EChart options safely using AST analysis
+ // This replaces the unsafe `new Function()` approach with a secure parser
+ // that only allows static data structures (no function callbacks)
+ customEchartOptions = safeParseEChartOptions(_echartOptions);
+ } catch (_) {
+ customEchartOptions = undefined;
+ }
+
+ const mergedEchartOptions = customEchartOptions
+ ? mergeCustomEChartOptions(echartOptions, customEchartOptions)
+ : echartOptions;
+
return {
formData,
width,
height,
- echartOptions,
+ echartOptions: mergedEchartOptions,
setDataMask,
emitCrossFilters,
labelMap,
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
index 56048de1d2..8eb4295de5 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Area/controlPanel.tsx
@@ -251,6 +251,7 @@ const config: ControlPanelConfig = {
},
},
],
+ ['echart_options'],
],
},
],
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
index b8f62d42f8..9a1a92d12b 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Bar/controlPanel.tsx
@@ -374,6 +374,7 @@ const config: ControlPanelConfig = {
...richTooltipSection,
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
...createAxisControl('y'),
+ ['echart_options'],
],
},
],
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
index 1a45e7d23d..2c339e70f3 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
@@ -216,6 +216,7 @@ const config: ControlPanelConfig = {
},
},
],
+ ['echart_options'],
],
},
],
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
index 62f805531b..c5c2f31bea 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/SmoothLine/controlPanel.tsx
@@ -183,6 +183,7 @@ const config: ControlPanelConfig = {
},
},
],
+ ['echart_options'],
],
},
],
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
index ab9abfb792..b2fcb7b7cd 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -113,6 +113,8 @@ import {
getXAxisFormatter,
getYAxisFormatter,
} from '../utils/formatters';
+import { safeParseEChartOptions } from '../utils/safeEChartOptionsParser';
+import { mergeCustomEChartOptions } from '../utils/mergeCustomEChartOptions';
export default function transformProps(
chartProps: EchartsTimeseriesChartProps,
@@ -122,7 +124,7 @@ export default function transformProps(
height,
filterState,
legendState,
- formData,
+ formData: { echartOptions: _echartOptions, ...formData },
hooks,
queriesData,
datasource,
@@ -967,8 +969,23 @@ export default function transformProps(
const onFocusedSeries = (seriesName: string | null) => {
focusedSeries = seriesName;
};
+
+ let customEchartOptions;
+ try {
+ // Parse custom EChart options safely using AST analysis
+ // This replaces the unsafe `new Function()` approach with a secure parser
+ // that only allows static data structures (no function callbacks)
+ customEchartOptions = safeParseEChartOptions(_echartOptions);
+ } catch (_) {
+ customEchartOptions = undefined;
+ }
+
+ const mergedEchartOptions = customEchartOptions
+ ? mergeCustomEChartOptions(echartOptions, customEchartOptions)
+ : echartOptions;
+
return {
- echartOptions,
+ echartOptions: mergedEchartOptions,
emitCrossFilters,
formData,
groupby: groupBy,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
index 14fcb1eb45..abb57d9e6e 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/index.ts
@@ -65,6 +65,9 @@ export { default as GanttTransformProps } from
'./Gantt/transformProps';
export { DEFAULT_FORM_DATA as TimeseriesDefaultFormData } from
'./Timeseries/constants';
+export * from './utils/eChartOptionsSchema';
+export * from './utils/safeEChartOptionsParser';
+
export * from './types';
/**
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/eChartOptionsSchema.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eChartOptionsSchema.ts
new file mode 100644
index 0000000000..f8318dd857
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/eChartOptionsSchema.ts
@@ -0,0 +1,827 @@
+/**
+ * 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.
+ */
+
+/**
+ * Unified ECharts Options Schema
+ *
+ * This file serves as the single source of truth for:
+ * 1. Runtime validation (Zod schema)
+ * 2. TypeScript types (inferred from Zod)
+ *
+ * Reference: https://echarts.apache.org/en/option.html
+ */
+
+import { z } from 'zod';
+
+//
=============================================================================
+// Common Schemas
+//
=============================================================================
+
+/** Color value - hex, rgb, rgba, or named color */
+const colorSchema = z.string();
+
+/** Numeric or percentage string (e.g., '50%') */
+const numberOrPercentSchema = z.union([z.number(), z.string()]);
+
+/** Line type */
+const lineTypeSchema = z.union([
+ z.enum(['solid', 'dashed', 'dotted']),
+ z.array(z.number()),
+]);
+
+/** Font weight */
+const fontWeightSchema = z.union([
+ z.enum(['normal', 'bold', 'bolder', 'lighter']),
+ z.number().min(100).max(900),
+]);
+
+/** Font style */
+const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']);
+
+/** Symbol type */
+const symbolTypeSchema = z.string();
+
+//
=============================================================================
+// Text Style Schema
+//
=============================================================================
+
+export const textStyleSchema = z.object({
+ color: colorSchema.optional(),
+ fontStyle: fontStyleSchema.optional(),
+ fontWeight: fontWeightSchema.optional(),
+ fontFamily: z.string().optional(),
+ fontSize: z.number().optional(),
+ lineHeight: z.number().optional(),
+ width: numberOrPercentSchema.optional(),
+ height: numberOrPercentSchema.optional(),
+ textBorderColor: colorSchema.optional(),
+ textBorderWidth: z.number().optional(),
+ textBorderType: lineTypeSchema.optional(),
+ textBorderDashOffset: z.number().optional(),
+ textShadowColor: colorSchema.optional(),
+ textShadowBlur: z.number().optional(),
+ textShadowOffsetX: z.number().optional(),
+ textShadowOffsetY: z.number().optional(),
+ overflow: z.enum(['none', 'truncate', 'break', 'breakAll']).optional(),
+ ellipsis: z.string().optional(),
+});
+
+//
=============================================================================
+// Style Schemas
+//
=============================================================================
+
+export const lineStyleSchema = z.object({
+ color: colorSchema.optional(),
+ width: z.number().optional(),
+ type: lineTypeSchema.optional(),
+ dashOffset: z.number().optional(),
+ cap: z.enum(['butt', 'round', 'square']).optional(),
+ join: z.enum(['bevel', 'round', 'miter']).optional(),
+ miterLimit: z.number().optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+ opacity: z.number().min(0).max(1).optional(),
+});
+
+export const areaStyleSchema = z.object({
+ color: z.union([colorSchema, z.array(colorSchema)]).optional(),
+ origin: z.union([z.enum(['auto', 'start', 'end']), z.number()]).optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+ opacity: z.number().min(0).max(1).optional(),
+});
+
+export const itemStyleSchema = z.object({
+ color: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderWidth: z.number().optional(),
+ borderType: lineTypeSchema.optional(),
+ borderRadius: z.union([z.number(), z.array(z.number())]).optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+ opacity: z.number().min(0).max(1).optional(),
+});
+
+//
=============================================================================
+// Label Schema
+//
=============================================================================
+
+export const labelSchema = z.object({
+ show: z.boolean().optional(),
+ position: z
+ .enum([
+ 'top',
+ 'left',
+ 'right',
+ 'bottom',
+ 'inside',
+ 'insideLeft',
+ 'insideRight',
+ 'insideTop',
+ 'insideBottom',
+ 'insideTopLeft',
+ 'insideBottomLeft',
+ 'insideTopRight',
+ 'insideBottomRight',
+ 'outside',
+ ])
+ .optional(),
+ distance: z.number().optional(),
+ rotate: z.number().optional(),
+ offset: z.array(z.number()).optional(),
+ formatter: z.string().optional(), // Only string formatters allowed, not
functions
+ color: colorSchema.optional(),
+ fontStyle: fontStyleSchema.optional(),
+ fontWeight: fontWeightSchema.optional(),
+ fontFamily: z.string().optional(),
+ fontSize: z.number().optional(),
+ lineHeight: z.number().optional(),
+});
+
+//
=============================================================================
+// Title Schema
+//
=============================================================================
+
+export const titleSchema = z.object({
+ id: z.string().optional(),
+ show: z.boolean().optional(),
+ text: z.string().optional(),
+ link: z.string().optional(),
+ target: z.enum(['self', 'blank']).optional(),
+ textStyle: textStyleSchema.optional(),
+ subtext: z.string().optional(),
+ sublink: z.string().optional(),
+ subtarget: z.enum(['self', 'blank']).optional(),
+ subtextStyle: textStyleSchema.optional(),
+ textAlign: z.enum(['left', 'center', 'right']).optional(),
+ textVerticalAlign: z.enum(['top', 'middle', 'bottom']).optional(),
+ triggerEvent: z.boolean().optional(),
+ padding: z.union([z.number(), z.array(z.number())]).optional(),
+ itemGap: z.number().optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+ left: numberOrPercentSchema.optional(),
+ top: numberOrPercentSchema.optional(),
+ right: numberOrPercentSchema.optional(),
+ bottom: numberOrPercentSchema.optional(),
+ backgroundColor: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderWidth: z.number().optional(),
+ borderRadius: z.union([z.number(), z.array(z.number())]).optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+});
+
+//
=============================================================================
+// Legend Schema
+//
=============================================================================
+
+export const legendSchema = z.object({
+ id: z.string().optional(),
+ type: z.enum(['plain', 'scroll']).optional(),
+ show: z.boolean().optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+ left: numberOrPercentSchema.optional(),
+ top: numberOrPercentSchema.optional(),
+ right: numberOrPercentSchema.optional(),
+ bottom: numberOrPercentSchema.optional(),
+ width: numberOrPercentSchema.optional(),
+ height: numberOrPercentSchema.optional(),
+ orient: z.enum(['horizontal', 'vertical']).optional(),
+ align: z.enum(['auto', 'left', 'right']).optional(),
+ padding: z.union([z.number(), z.array(z.number())]).optional(),
+ itemGap: z.number().optional(),
+ itemWidth: z.number().optional(),
+ itemHeight: z.number().optional(),
+ itemStyle: itemStyleSchema.optional(),
+ lineStyle: lineStyleSchema.optional(),
+ textStyle: textStyleSchema.optional(),
+ icon: symbolTypeSchema.optional(),
+ selectedMode: z
+ .union([z.boolean(), z.enum(['single', 'multiple', 'series'])])
+ .optional(),
+ inactiveColor: colorSchema.optional(),
+ inactiveBorderColor: colorSchema.optional(),
+ inactiveBorderWidth: z.number().optional(),
+ backgroundColor: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderWidth: z.number().optional(),
+ borderRadius: z.union([z.number(), z.array(z.number())]).optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+ pageButtonItemGap: z.number().optional(),
+ pageButtonGap: z.number().optional(),
+ pageButtonPosition: z.enum(['start', 'end']).optional(),
+ pageIconColor: colorSchema.optional(),
+ pageIconInactiveColor: colorSchema.optional(),
+ pageIconSize: z.union([z.number(), z.array(z.number())]).optional(),
+ pageTextStyle: textStyleSchema.optional(),
+});
+
+//
=============================================================================
+// Grid Schema
+//
=============================================================================
+
+export const gridSchema = z.object({
+ id: z.string().optional(),
+ show: z.boolean().optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+ left: numberOrPercentSchema.optional(),
+ top: numberOrPercentSchema.optional(),
+ right: numberOrPercentSchema.optional(),
+ bottom: numberOrPercentSchema.optional(),
+ width: numberOrPercentSchema.optional(),
+ height: numberOrPercentSchema.optional(),
+ containLabel: z.boolean().optional(),
+ backgroundColor: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderWidth: z.number().optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+});
+
+//
=============================================================================
+// Axis Schemas
+//
=============================================================================
+
+const axisLineSchema = z.object({
+ show: z.boolean().optional(),
+ onZero: z.boolean().optional(),
+ onZeroAxisIndex: z.number().optional(),
+ symbol: z.union([z.string(), z.array(z.string())]).optional(),
+ symbolSize: z.array(z.number()).optional(),
+ symbolOffset: z.union([z.number(), z.array(z.number())]).optional(),
+ lineStyle: lineStyleSchema.optional(),
+});
+
+const axisTickSchema = z.object({
+ show: z.boolean().optional(),
+ alignWithLabel: z.boolean().optional(),
+ interval: z.union([z.number(), z.literal('auto')]).optional(),
+ inside: z.boolean().optional(),
+ length: z.number().optional(),
+ lineStyle: lineStyleSchema.optional(),
+});
+
+const axisLabelSchema = z.object({
+ show: z.boolean().optional(),
+ interval: z.union([z.number(), z.literal('auto')]).optional(),
+ inside: z.boolean().optional(),
+ rotate: z.number().optional(),
+ margin: z.number().optional(),
+ formatter: z.string().optional(), // Only string formatters
+ showMinLabel: z.boolean().optional(),
+ showMaxLabel: z.boolean().optional(),
+ hideOverlap: z.boolean().optional(),
+ color: colorSchema.optional(),
+ fontStyle: fontStyleSchema.optional(),
+ fontWeight: fontWeightSchema.optional(),
+ fontFamily: z.string().optional(),
+ fontSize: z.number().optional(),
+ align: z.enum(['left', 'center', 'right']).optional(),
+ verticalAlign: z.enum(['top', 'middle', 'bottom']).optional(),
+});
+
+const splitLineSchema = z.object({
+ show: z.boolean().optional(),
+ interval: z.union([z.number(), z.literal('auto')]).optional(),
+ lineStyle: lineStyleSchema.optional(),
+});
+
+const splitAreaSchema = z.object({
+ show: z.boolean().optional(),
+ interval: z.union([z.number(), z.literal('auto')]).optional(),
+ areaStyle: areaStyleSchema.optional(),
+});
+
+export const axisSchema = z.object({
+ id: z.string().optional(),
+ show: z.boolean().optional(),
+ gridIndex: z.number().optional(),
+ alignTicks: z.boolean().optional(),
+ position: z.enum(['top', 'bottom', 'left', 'right']).optional(),
+ offset: z.number().optional(),
+ type: z.enum(['value', 'category', 'time', 'log']).optional(),
+ name: z.string().optional(),
+ nameLocation: z.enum(['start', 'middle', 'center', 'end']).optional(),
+ nameTextStyle: textStyleSchema.optional(),
+ nameGap: z.number().optional(),
+ nameRotate: z.number().optional(),
+ inverse: z.boolean().optional(),
+ boundaryGap: z
+ .union([z.boolean(), z.array(z.union([z.string(), z.number()]))])
+ .optional(),
+ min: z.union([z.number(), z.string(), z.literal('dataMin')]).optional(),
+ max: z.union([z.number(), z.string(), z.literal('dataMax')]).optional(),
+ scale: z.boolean().optional(),
+ splitNumber: z.number().optional(),
+ minInterval: z.number().optional(),
+ maxInterval: z.number().optional(),
+ interval: z.number().optional(),
+ logBase: z.number().optional(),
+ silent: z.boolean().optional(),
+ triggerEvent: z.boolean().optional(),
+ axisLine: axisLineSchema.optional(),
+ axisTick: axisTickSchema.optional(),
+ minorTick: axisTickSchema.optional(),
+ axisLabel: axisLabelSchema.optional(),
+ splitLine: splitLineSchema.optional(),
+ minorSplitLine: splitLineSchema.optional(),
+ splitArea: splitAreaSchema.optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+});
+
+//
=============================================================================
+// Tooltip Schema
+//
=============================================================================
+
+export const tooltipSchema = z.object({
+ show: z.boolean().optional(),
+ trigger: z.enum(['item', 'axis', 'none']).optional(),
+ triggerOn: z
+ .enum(['mousemove', 'click', 'mousemove|click', 'none'])
+ .optional(),
+ alwaysShowContent: z.boolean().optional(),
+ showDelay: z.number().optional(),
+ hideDelay: z.number().optional(),
+ enterable: z.boolean().optional(),
+ renderMode: z.enum(['html', 'richText']).optional(),
+ confine: z.boolean().optional(),
+ appendToBody: z.boolean().optional(),
+ transitionDuration: z.number().optional(),
+ position: z
+ .union([
+ z.enum(['inside', 'top', 'left', 'right', 'bottom']),
+ z.array(z.union([z.number(), z.string()])),
+ ])
+ .optional(),
+ formatter: z.string().optional(), // Only string formatters
+ padding: z.union([z.number(), z.array(z.number())]).optional(),
+ backgroundColor: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderWidth: z.number().optional(),
+ borderRadius: z.number().optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+ textStyle: textStyleSchema.optional(),
+ extraCssText: z.string().optional(),
+ order: z
+ .enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc'])
+ .optional(),
+});
+
+//
=============================================================================
+// DataZoom Schema
+//
=============================================================================
+
+export const dataZoomSchema = z.object({
+ type: z.enum(['slider', 'inside']).optional(),
+ id: z.string().optional(),
+ show: z.boolean().optional(),
+ disabled: z.boolean().optional(),
+ xAxisIndex: z.union([z.number(), z.array(z.number())]).optional(),
+ yAxisIndex: z.union([z.number(), z.array(z.number())]).optional(),
+ filterMode: z.enum(['filter', 'weakFilter', 'empty', 'none']).optional(),
+ start: z.number().optional(),
+ end: z.number().optional(),
+ startValue: z.union([z.number(), z.string()]).optional(),
+ endValue: z.union([z.number(), z.string()]).optional(),
+ minSpan: z.number().optional(),
+ maxSpan: z.number().optional(),
+ minValueSpan: z.union([z.number(), z.string()]).optional(),
+ maxValueSpan: z.union([z.number(), z.string()]).optional(),
+ orient: z.enum(['horizontal', 'vertical']).optional(),
+ zoomLock: z.boolean().optional(),
+ throttle: z.number().optional(),
+ rangeMode: z.array(z.enum(['value', 'percent'])).optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+ left: numberOrPercentSchema.optional(),
+ top: numberOrPercentSchema.optional(),
+ right: numberOrPercentSchema.optional(),
+ bottom: numberOrPercentSchema.optional(),
+ width: numberOrPercentSchema.optional(),
+ height: numberOrPercentSchema.optional(),
+ backgroundColor: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderRadius: z.number().optional(),
+ fillerColor: colorSchema.optional(),
+ handleSize: numberOrPercentSchema.optional(),
+ handleStyle: itemStyleSchema.optional(),
+ moveHandleSize: z.number().optional(),
+ moveHandleStyle: itemStyleSchema.optional(),
+ labelPrecision: z.union([z.number(), z.literal('auto')]).optional(),
+ textStyle: textStyleSchema.optional(),
+ realtime: z.boolean().optional(),
+ showDetail: z.boolean().optional(),
+ showDataShadow: z.union([z.boolean(), z.literal('auto')]).optional(),
+ zoomOnMouseWheel: z
+ .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])])
+ .optional(),
+ moveOnMouseMove: z
+ .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])])
+ .optional(),
+ moveOnMouseWheel: z
+ .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])])
+ .optional(),
+ preventDefaultMouseMove: z.boolean().optional(),
+});
+
+//
=============================================================================
+// Toolbox Schema
+//
=============================================================================
+
+export const toolboxSchema = z.object({
+ id: z.string().optional(),
+ show: z.boolean().optional(),
+ orient: z.enum(['horizontal', 'vertical']).optional(),
+ itemSize: z.number().optional(),
+ itemGap: z.number().optional(),
+ showTitle: z.boolean().optional(),
+ feature: z.record(z.string(), z.unknown()).optional(),
+ iconStyle: itemStyleSchema.optional(),
+ emphasis: z
+ .object({
+ iconStyle: itemStyleSchema.optional(),
+ })
+ .optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+ left: numberOrPercentSchema.optional(),
+ top: numberOrPercentSchema.optional(),
+ right: numberOrPercentSchema.optional(),
+ bottom: numberOrPercentSchema.optional(),
+ width: numberOrPercentSchema.optional(),
+ height: numberOrPercentSchema.optional(),
+});
+
+//
=============================================================================
+// VisualMap Schema
+//
=============================================================================
+
+export const visualMapSchema = z.object({
+ type: z.enum(['continuous', 'piecewise']).optional(),
+ id: z.string().optional(),
+ min: z.number().optional(),
+ max: z.number().optional(),
+ range: z.array(z.number()).optional(),
+ calculable: z.boolean().optional(),
+ realtime: z.boolean().optional(),
+ inverse: z.boolean().optional(),
+ precision: z.number().optional(),
+ itemWidth: z.number().optional(),
+ itemHeight: z.number().optional(),
+ align: z.enum(['auto', 'left', 'right', 'top', 'bottom']).optional(),
+ text: z.array(z.string()).optional(),
+ textGap: z.number().optional(),
+ show: z.boolean().optional(),
+ dimension: z.union([z.number(), z.string()]).optional(),
+ seriesIndex: z.union([z.number(), z.array(z.number())]).optional(),
+ hoverLink: z.boolean().optional(),
+ inRange: z.record(z.string(), z.unknown()).optional(),
+ outOfRange: z.record(z.string(), z.unknown()).optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+ left: numberOrPercentSchema.optional(),
+ top: numberOrPercentSchema.optional(),
+ right: numberOrPercentSchema.optional(),
+ bottom: numberOrPercentSchema.optional(),
+ orient: z.enum(['horizontal', 'vertical']).optional(),
+ padding: z.union([z.number(), z.array(z.number())]).optional(),
+ backgroundColor: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderWidth: z.number().optional(),
+ color: z.array(colorSchema).optional(),
+ textStyle: textStyleSchema.optional(),
+ splitNumber: z.number().optional(),
+ pieces: z.array(z.record(z.string(), z.unknown())).optional(),
+ categories: z.array(z.string()).optional(),
+ minOpen: z.boolean().optional(),
+ maxOpen: z.boolean().optional(),
+ selectedMode: z
+ .union([z.boolean(), z.enum(['single', 'multiple'])])
+ .optional(),
+ showLabel: z.boolean().optional(),
+ itemGap: z.number().optional(),
+ itemSymbol: symbolTypeSchema.optional(),
+});
+
+//
=============================================================================
+// Series Schema
+//
=============================================================================
+
+const emphasisSchema = z.object({
+ disabled: z.boolean().optional(),
+ focus: z
+ .enum(['none', 'self', 'series', 'ancestor', 'descendant', 'relative'])
+ .optional(),
+ blurScope: z.enum(['coordinateSystem', 'series', 'global']).optional(),
+ scale: z.union([z.boolean(), z.number()]).optional(),
+ label: labelSchema.optional(),
+ itemStyle: itemStyleSchema.optional(),
+ lineStyle: lineStyleSchema.optional(),
+ areaStyle: areaStyleSchema.optional(),
+});
+
+const stateSchema = z.object({
+ label: labelSchema.optional(),
+ itemStyle: itemStyleSchema.optional(),
+ lineStyle: lineStyleSchema.optional(),
+ areaStyle: areaStyleSchema.optional(),
+});
+
+export const seriesSchema = z.object({
+ type: z.string().optional(),
+ id: z.string().optional(),
+ name: z.string().optional(),
+ colorBy: z.enum(['series', 'data']).optional(),
+ legendHoverLink: z.boolean().optional(),
+ coordinateSystem: z.string().optional(),
+ xAxisIndex: z.number().optional(),
+ yAxisIndex: z.number().optional(),
+ polarIndex: z.number().optional(),
+ geoIndex: z.number().optional(),
+ calendarIndex: z.number().optional(),
+ label: labelSchema.optional(),
+ labelLine: z
+ .object({
+ show: z.boolean().optional(),
+ showAbove: z.boolean().optional(),
+ length: z.number().optional(),
+ length2: z.number().optional(),
+ smooth: z.union([z.boolean(), z.number()]).optional(),
+ minTurnAngle: z.number().optional(),
+ lineStyle: lineStyleSchema.optional(),
+ })
+ .optional(),
+ labelLayout: z
+ .object({
+ hideOverlap: z.boolean().optional(),
+ moveOverlap: z.enum(['shiftX', 'shiftY']).optional(),
+ })
+ .optional(),
+ itemStyle: itemStyleSchema.optional(),
+ lineStyle: lineStyleSchema.optional(),
+ areaStyle: areaStyleSchema.optional(),
+ emphasis: emphasisSchema.optional(),
+ blur: stateSchema.optional(),
+ select: stateSchema.optional(),
+ selectedMode: z
+ .union([z.boolean(), z.enum(['single', 'multiple', 'series'])])
+ .optional(),
+ zlevel: z.number().optional(),
+ z: z.number().optional(),
+ silent: z.boolean().optional(),
+ cursor: z.string().optional(),
+ animation: z.boolean().optional(),
+ animationThreshold: z.number().optional(),
+ animationDuration: z.number().optional(),
+ animationEasing: z.string().optional(),
+ animationDelay: z.number().optional(),
+ animationDurationUpdate: z.number().optional(),
+ animationEasingUpdate: z.string().optional(),
+ animationDelayUpdate: z.number().optional(),
+});
+
+//
=============================================================================
+// Graphic Schema
+//
=============================================================================
+
+export const graphicElementSchema = z.object({
+ type: z
+ .enum([
+ 'group',
+ 'image',
+ 'text',
+ 'rect',
+ 'circle',
+ 'ring',
+ 'sector',
+ 'arc',
+ 'polygon',
+ 'polyline',
+ 'line',
+ 'bezierCurve',
+ ])
+ .optional(),
+ id: z.string().optional(),
+ $action: z.enum(['merge', 'replace', 'remove']).optional(),
+ left: numberOrPercentSchema.optional(),
+ top: numberOrPercentSchema.optional(),
+ right: numberOrPercentSchema.optional(),
+ bottom: numberOrPercentSchema.optional(),
+ bounding: z.enum(['all', 'raw']).optional(),
+ z: z.number().optional(),
+ zlevel: z.number().optional(),
+ silent: z.boolean().optional(),
+ invisible: z.boolean().optional(),
+ cursor: z.string().optional(),
+ draggable: z
+ .union([z.boolean(), z.enum(['horizontal', 'vertical'])])
+ .optional(),
+ progressive: z.boolean().optional(),
+ width: z.number().optional(),
+ height: z.number().optional(),
+ shape: z.record(z.string(), z.unknown()).optional(),
+ style: z.record(z.string(), z.unknown()).optional(),
+ rotation: z.number().optional(),
+ scaleX: z.number().optional(),
+ scaleY: z.number().optional(),
+ originX: z.number().optional(),
+ originY: z.number().optional(),
+ children: z.array(z.record(z.string(), z.unknown())).optional(),
+});
+
+//
=============================================================================
+// AxisPointer Schema
+//
=============================================================================
+
+export const axisPointerSchema = z.object({
+ id: z.string().optional(),
+ show: z.boolean().optional(),
+ type: z.enum(['line', 'shadow', 'none']).optional(),
+ axis: z.enum(['x', 'y']).optional(),
+ snap: z.boolean().optional(),
+ z: z.number().optional(),
+ label: z
+ .object({
+ show: z.boolean().optional(),
+ precision: z.union([z.number(), z.literal('auto')]).optional(),
+ margin: z.number().optional(),
+ color: colorSchema.optional(),
+ fontStyle: fontStyleSchema.optional(),
+ fontWeight: fontWeightSchema.optional(),
+ fontFamily: z.string().optional(),
+ fontSize: z.number().optional(),
+ lineHeight: z.number().optional(),
+ backgroundColor: colorSchema.optional(),
+ borderColor: colorSchema.optional(),
+ borderWidth: z.number().optional(),
+ borderRadius: z.number().optional(),
+ padding: z.union([z.number(), z.array(z.number())]).optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+ })
+ .optional(),
+ lineStyle: lineStyleSchema.optional(),
+ shadowStyle: areaStyleSchema.optional(),
+ triggerTooltip: z.boolean().optional(),
+ value: z.number().optional(),
+ status: z.enum(['show', 'hide']).optional(),
+ handle: z
+ .object({
+ show: z.boolean().optional(),
+ icon: z.string().optional(),
+ size: z.union([z.number(), z.array(z.number())]).optional(),
+ margin: z.number().optional(),
+ color: colorSchema.optional(),
+ throttle: z.number().optional(),
+ shadowBlur: z.number().optional(),
+ shadowColor: colorSchema.optional(),
+ shadowOffsetX: z.number().optional(),
+ shadowOffsetY: z.number().optional(),
+ })
+ .optional(),
+ link: z.array(z.record(z.string(), z.unknown())).optional(),
+});
+
+//
=============================================================================
+// Root Schema - CustomEChartOptions
+//
=============================================================================
+
+/**
+ * Helper to create schema that accepts object or array of objects
+ */
+function objectOrArray<T extends z.ZodTypeAny>(schema: T) {
+ return z.union([schema, z.array(schema)]);
+}
+
+/**
+ * Main ECharts Options Schema
+ *
+ * This schema validates user-provided custom ECharts options.
+ * It intentionally excludes function callbacks for security.
+ */
+export const customEChartOptionsSchema = z.object({
+ // Global options
+ backgroundColor: colorSchema.optional(),
+ darkMode: z.union([z.boolean(), z.literal('auto')]).optional(),
+ textStyle: textStyleSchema.optional(),
+ useUTC: z.boolean().optional(),
+
+ // Animation options
+ animation: z.boolean().optional(),
+ animationThreshold: z.number().optional(),
+ animationDuration: z.number().optional(),
+ animationEasing: z.string().optional(),
+ animationDelay: z.number().optional(),
+ animationDurationUpdate: z.number().optional(),
+ animationEasingUpdate: z.string().optional(),
+ animationDelayUpdate: z.number().optional(),
+ stateAnimation: z
+ .object({
+ duration: z.number().optional(),
+ easing: z.string().optional(),
+ })
+ .optional(),
+
+ // Component options (can be object or array)
+ title: objectOrArray(titleSchema).optional(),
+ legend: objectOrArray(legendSchema).optional(),
+ grid: objectOrArray(gridSchema).optional(),
+ xAxis: objectOrArray(axisSchema).optional(),
+ yAxis: objectOrArray(axisSchema).optional(),
+ tooltip: tooltipSchema.optional(),
+ toolbox: toolboxSchema.optional(),
+ dataZoom: objectOrArray(dataZoomSchema).optional(),
+ visualMap: objectOrArray(visualMapSchema).optional(),
+ axisPointer: axisPointerSchema.optional(),
+ graphic: objectOrArray(graphicElementSchema).optional(),
+ series: objectOrArray(seriesSchema).optional(),
+});
+
+//
=============================================================================
+// Type Exports (inferred from Zod schemas)
+//
=============================================================================
+
+export type TextStyleOption = z.infer<typeof textStyleSchema>;
+export type LineStyleOption = z.infer<typeof lineStyleSchema>;
+export type AreaStyleOption = z.infer<typeof areaStyleSchema>;
+export type ItemStyleOption = z.infer<typeof itemStyleSchema>;
+export type LabelOption = z.infer<typeof labelSchema>;
+export type TitleOption = z.infer<typeof titleSchema>;
+export type LegendOption = z.infer<typeof legendSchema>;
+export type GridOption = z.infer<typeof gridSchema>;
+export type AxisOption = z.infer<typeof axisSchema>;
+export type TooltipOption = z.infer<typeof tooltipSchema>;
+export type DataZoomOption = z.infer<typeof dataZoomSchema>;
+export type ToolboxOption = z.infer<typeof toolboxSchema>;
+export type VisualMapOption = z.infer<typeof visualMapSchema>;
+export type SeriesOption = z.infer<typeof seriesSchema>;
+export type GraphicElementOption = z.infer<typeof graphicElementSchema>;
+export type AxisPointerOption = z.infer<typeof axisPointerSchema>;
+
+/** Main custom ECharts options type */
+export type CustomEChartOptions = z.infer<typeof customEChartOptionsSchema>;
+
+//
=============================================================================
+// Validation Functions
+//
=============================================================================
+
+/**
+ * Validates custom EChart options against the schema.
+ * Returns a result object with success status and validated data or errors.
+ */
+export function validateEChartOptions(data: unknown) {
+ return customEChartOptionsSchema.safeParse(data);
+}
+
+/**
+ * Validates the options and returns them if valid.
+ * Returns an empty object if validation fails.
+ */
+export function parseEChartOptionsStrict(data: unknown): CustomEChartOptions {
+ const result = customEChartOptionsSchema.safeParse(data);
+ if (result.success) {
+ return result.data;
+ }
+ // Return empty object on failure
+ return {};
+}
+
+export default customEChartOptionsSchema;
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.test.ts
new file mode 100644
index 0000000000..d818b99ace
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.test.ts
@@ -0,0 +1,163 @@
+/**
+ * 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 { mergeCustomEChartOptions } from './mergeCustomEChartOptions';
+import type { CustomEChartOptions } from './eChartOptionsSchema';
+
+test('mergeCustomEChartOptions returns base options when custom is undefined',
() => {
+ const base = { title: { text: 'Base Title' } };
+ const result = mergeCustomEChartOptions(base, undefined);
+
+ expect(result).toEqual(base);
+});
+
+test('mergeCustomEChartOptions merges simple properties', () => {
+ const base = { backgroundColor: '#fff' };
+ const custom: CustomEChartOptions = { backgroundColor: '#000' };
+
+ const result = mergeCustomEChartOptions(base, custom);
+
+ expect(result.backgroundColor).toBe('#000');
+});
+
+test('mergeCustomEChartOptions deep merges nested objects', () => {
+ const base = {
+ title: {
+ text: 'Base Title',
+ textStyle: {
+ color: '#333',
+ fontSize: 14,
+ },
+ },
+ };
+ const custom: CustomEChartOptions = {
+ title: {
+ textStyle: {
+ color: '#ff0000',
+ },
+ },
+ };
+
+ const result = mergeCustomEChartOptions(base, custom);
+
+ expect(result.title).toEqual({
+ text: 'Base Title',
+ textStyle: {
+ color: '#ff0000',
+ fontSize: 14,
+ },
+ });
+});
+
+test('mergeCustomEChartOptions replaces arrays entirely', () => {
+ const base = {
+ series: [{ name: 'A', type: 'line' }],
+ };
+ const custom: CustomEChartOptions = {
+ series: [
+ { name: 'B', type: 'bar' },
+ { name: 'C', type: 'pie' },
+ ],
+ };
+
+ const result = mergeCustomEChartOptions(base, custom);
+
+ expect(result.series).toEqual([
+ { name: 'B', type: 'bar' },
+ { name: 'C', type: 'pie' },
+ ]);
+});
+
+test('mergeCustomEChartOptions does not mutate original base object', () => {
+ const base = {
+ title: { text: 'Original' },
+ grid: { top: 10 },
+ };
+ const originalBase = JSON.parse(JSON.stringify(base));
+ const custom: CustomEChartOptions = {
+ title: { text: 'Modified' },
+ };
+
+ mergeCustomEChartOptions(base, custom);
+
+ expect(base).toEqual(originalBase);
+});
+
+test('mergeCustomEChartOptions handles complex nested structures', () => {
+ const base = {
+ grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+ xAxis: { type: 'category', axisLine: { lineStyle: { color: '#999' } } },
+ yAxis: { type: 'value', splitLine: { lineStyle: { type: 'solid' } } },
+ tooltip: { trigger: 'axis' },
+ };
+ const custom: CustomEChartOptions = {
+ grid: { top: 50 },
+ xAxis: { axisLine: { lineStyle: { width: 2 } } },
+ tooltip: { backgroundColor: 'rgba(0,0,0,0.8)' },
+ };
+
+ const result = mergeCustomEChartOptions(base, custom);
+
+ expect(result.grid).toEqual({
+ left: '3%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true,
+ top: 50,
+ });
+ expect(result.xAxis).toEqual({
+ type: 'category',
+ axisLine: { lineStyle: { color: '#999', width: 2 } },
+ });
+ expect(result.tooltip).toEqual({
+ trigger: 'axis',
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ });
+});
+
+test('mergeCustomEChartOptions handles null values in custom options', () => {
+ const base = { title: { text: 'Title' }, legend: { show: true } };
+ const custom: CustomEChartOptions = { title: { text: 'New Title' } };
+
+ const result = mergeCustomEChartOptions(base, custom);
+
+ expect(result.title).toEqual({ text: 'New Title' });
+ expect(result.legend).toEqual({ show: true });
+});
+
+test('mergeCustomEChartOptions adds new properties from custom', () => {
+ const base = { title: { text: 'Title' } };
+ const custom: CustomEChartOptions = {
+ legend: { show: true, orient: 'horizontal' },
+ };
+
+ const result = mergeCustomEChartOptions(base, custom);
+
+ expect(result.title).toEqual({ text: 'Title' });
+ expect(result.legend).toEqual({ show: true, orient: 'horizontal' });
+});
+
+test('mergeCustomEChartOptions handles empty custom options', () => {
+ const base = { title: { text: 'Title' } };
+ const custom: CustomEChartOptions = {};
+
+ const result = mergeCustomEChartOptions(base, custom);
+
+ expect(result).toEqual(base);
+});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.ts
new file mode 100644
index 0000000000..fdb75a70c1
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/mergeCustomEChartOptions.ts
@@ -0,0 +1,79 @@
+/**
+ * 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 type { EChartsCoreOption } from 'echarts/core';
+import type { CustomEChartOptions } from './eChartOptionsSchema';
+
+type PlainObject = Record<string, unknown>;
+
+function isPlainObject(value: unknown): value is PlainObject {
+ return (
+ typeof value === 'object' &&
+ value !== null &&
+ !Array.isArray(value) &&
+ Object.prototype.toString.call(value) === '[object Object]'
+ );
+}
+
+/**
+ * Deep merges custom EChart options into base options.
+ * Arrays are replaced entirely, objects are merged recursively.
+ *
+ * @param baseOptions - The base ECharts options object
+ * @param customOptions - Custom options to merge (from safeParseEChartOptions)
+ * @returns Merged ECharts options
+ */
+export function mergeCustomEChartOptions<T extends EChartsCoreOption>(
+ baseOptions: T,
+ customOptions: CustomEChartOptions | undefined,
+): T & Partial<CustomEChartOptions> {
+ type MergedResult = T & Partial<CustomEChartOptions>;
+
+ if (!customOptions) {
+ return baseOptions as MergedResult;
+ }
+
+ const result = { ...baseOptions } as MergedResult;
+
+ for (const key of Object.keys(customOptions) as Array<
+ keyof typeof customOptions
+ >) {
+ const customValue = customOptions[key];
+ const baseValue = result[key as keyof T];
+
+ if (customValue === undefined) {
+ continue;
+ }
+
+ if (isPlainObject(customValue) && isPlainObject(baseValue)) {
+ // Recursively merge nested objects
+ (result as PlainObject)[key] = mergeCustomEChartOptions(
+ baseValue as EChartsCoreOption,
+ customValue as CustomEChartOptions,
+ );
+ } else {
+ // Replace arrays and primitive values directly
+ (result as PlainObject)[key] = customValue;
+ }
+ }
+
+ return result;
+}
+
+export default mergeCustomEChartOptions;
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.test.ts
new file mode 100644
index 0000000000..59c351f328
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.test.ts
@@ -0,0 +1,525 @@
+/**
+ * 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 {
+ parseEChartOptions,
+ safeParseEChartOptions,
+ EChartOptionsParseError,
+} from './safeEChartOptionsParser';
+
+test('parseEChartOptions returns undefined for empty input', () => {
+ expect(parseEChartOptions(undefined)).toEqual({
+ success: true,
+ data: undefined,
+ });
+ expect(parseEChartOptions('')).toEqual({ success: true, data: undefined });
+ expect(parseEChartOptions(' ')).toEqual({ success: true, data: undefined
});
+});
+
+test('parseEChartOptions parses simple object literals', () => {
+ const input = `{ title: { text: 'My Chart' } }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ title: { text: 'My Chart' },
+ });
+});
+
+test('parseEChartOptions parses nested objects and arrays', () => {
+ const input = `{
+ grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
+ xAxis: { type: 'category' },
+ yAxis: [{ type: 'value' }, { type: 'log' }],
+ series: [
+ { name: 'Series 1', type: 'line' },
+ { name: 'Series 2', type: 'bar' }
+ ]
+ }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ grid: { top: 50, bottom: 50, left: '10%', right: '10%' },
+ xAxis: { type: 'category' },
+ yAxis: [{ type: 'value' }, { type: 'log' }],
+ series: [
+ { name: 'Series 1', type: 'line' },
+ { name: 'Series 2', type: 'bar' },
+ ],
+ });
+});
+
+test('parseEChartOptions handles negative numbers in valid properties', () => {
+ const input = `{ xAxis: { nameRotate: -45, offset: -10 } }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ xAxis: { nameRotate: -45, offset: -10 } });
+});
+
+test('parseEChartOptions handles boolean values in valid properties', () => {
+ const input = `{ animation: true, useUTC: false }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ animation: true, useUTC: false });
+});
+
+test('parseEChartOptions throws for special numeric values like -Infinity', ()
=> {
+ // Special values like -Infinity are not valid JSON-serializable values
+ const input = `{ xAxis: { min: -Infinity, splitNumber: 5 } }`;
+
+ // -Infinity is not a valid value for 'min' in the schema
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions throws for function expressions', () => {
+ const input = `{ formatter: function(value) { return value; } }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as
EChartOptionsParseError).errorType).toBe('security_error');
+ }
+});
+
+test('parseEChartOptions throws for arrow functions', () => {
+ const input = `{ formatter: (value) => value }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as
EChartOptionsParseError).errorType).toBe('security_error');
+ }
+});
+
+test('parseEChartOptions throws for function calls', () => {
+ const input = `{ value: eval('1+1') }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as
EChartOptionsParseError).errorType).toBe('security_error');
+ }
+});
+
+test('parseEChartOptions throws for dangerous identifiers', () => {
+ const dangerousInputs = [
+ `{ x: window }`,
+ `{ x: document }`,
+ `{ x: globalThis }`,
+ `{ x: process }`,
+ `{ x: require }`,
+ `{ x: constructor }`,
+ `{ x: __proto__ }`,
+ ];
+
+ dangerousInputs.forEach(input => {
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as EChartOptionsParseError).errorType).toBe(
+ 'security_error',
+ );
+ }
+ });
+});
+
+test('parseEChartOptions throws for computed properties', () => {
+ const input = `{ [dynamicKey]: 'value' }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions throws for template literals with expressions', () =>
{
+ const input = '{ text: `Hello ${name}` }';
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions allows simple template literals in valid properties',
() => {
+ const input = '{ title: { text: `Hello World` } }';
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({ title: { text: 'Hello World' } });
+});
+
+test('parseEChartOptions throws for new expressions', () => {
+ const input = `{ date: new Date() }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions throws for member expressions', () => {
+ const input = `{ value: Math.PI }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+});
+
+test('parseEChartOptions handles trailing commas (JSON5-like)', () => {
+ const input = `{
+ title: { text: 'Chart', },
+ series: [
+ { name: 'A', },
+ { name: 'B', },
+ ],
+ }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ title: { text: 'Chart' },
+ series: [{ name: 'A' }, { name: 'B' }],
+ });
+});
+
+test('parseEChartOptions handles unquoted keys in nested objects', () => {
+ // Unknown top-level keys are filtered, but valid nested keys work
+ const input = `{ title: { text: 'value', show: true } }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ title: { text: 'value', show: true },
+ });
+});
+
+test('safeParseEChartOptions throws on parse error', () => {
+ expect(() => safeParseEChartOptions('{ invalid')).toThrow(
+ EChartOptionsParseError,
+ );
+});
+
+test('safeParseEChartOptions throws on security error', () => {
+ expect(() => safeParseEChartOptions('{ fn: () => {} }')).toThrow(
+ EChartOptionsParseError,
+ );
+});
+
+test('safeParseEChartOptions returns data on success', () => {
+ const result = safeParseEChartOptions(`{ title: { text: 'Test' } }`);
+ expect(result).toEqual({ title: { text: 'Test' } });
+});
+
+test('parseEChartOptions handles complex real-world EChart options', () => {
+ const input = `{
+ title: {
+ text: 'Sales Overview',
+ subtext: 'Monthly Data',
+ left: 'center',
+ textStyle: {
+ color: '#333',
+ fontSize: 18,
+ fontWeight: 'bold'
+ }
+ },
+ legend: {
+ orient: 'vertical',
+ left: 'left',
+ top: 50
+ },
+ grid: {
+ left: '3%',
+ right: '4%',
+ bottom: '3%',
+ containLabel: true
+ },
+ tooltip: {
+ trigger: 'axis',
+ backgroundColor: 'rgba(255, 255, 255, 0.9)',
+ borderColor: '#ccc',
+ borderWidth: 1,
+ textStyle: {
+ color: '#333'
+ }
+ },
+ xAxis: {
+ type: 'category',
+ boundaryGap: false,
+ axisLine: {
+ lineStyle: {
+ color: '#999'
+ }
+ }
+ },
+ yAxis: {
+ type: 'value',
+ splitLine: {
+ lineStyle: {
+ type: 'dashed'
+ }
+ }
+ },
+ dataZoom: [
+ {
+ type: 'slider',
+ start: 0,
+ end: 100
+ },
+ {
+ type: 'inside'
+ }
+ ]
+ }`;
+
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data?.title).toBeDefined();
+ expect(result.data?.legend).toBeDefined();
+ expect(result.data?.grid).toBeDefined();
+ expect(result.data?.tooltip).toBeDefined();
+ expect(result.data?.xAxis).toBeDefined();
+ expect(result.data?.yAxis).toBeDefined();
+ expect(result.data?.dataZoom).toHaveLength(2);
+});
+
+//
=============================================================================
+// Schema Validation Tests
+//
=============================================================================
+
+test('parseEChartOptions throws when title is a string instead of object', ()
=> {
+ // title should be TitleOption (object), not string
+ const input = `{ title: 'text' }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as EChartOptionsParseError).errorType).toBe(
+ 'validation_error',
+ );
+ expect(
+ (error as EChartOptionsParseError).validationErrors.length,
+ ).toBeGreaterThan(0);
+ }
+});
+
+test('parseEChartOptions throws when grid is a string instead of object', ()
=> {
+ const input = `{ grid: 'invalid' }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as EChartOptionsParseError).errorType).toBe(
+ 'validation_error',
+ );
+ }
+});
+
+test('parseEChartOptions throws when nested property has wrong type', () => {
+ // textStyle should be object, not string - this invalidates the entire title
+ const input = `{ title: { text: 'Chart', textStyle: 'invalid' } }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as EChartOptionsParseError).errorType).toBe(
+ 'validation_error',
+ );
+ }
+});
+
+test('parseEChartOptions keeps valid nested objects', () => {
+ const input = `{ title: { text: 'Chart', textStyle: { color: '#333',
fontSize: 14 } } }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data?.title).toEqual({
+ text: 'Chart',
+ textStyle: { color: '#333', fontSize: 14 },
+ });
+});
+
+test('parseEChartOptions throws when some properties are invalid', () => {
+ const input = `{
+ title: { text: 'Valid Title' },
+ legend: 'invalid',
+ grid: { top: 50 }
+ }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as EChartOptionsParseError).errorType).toBe(
+ 'validation_error',
+ );
+ expect(
+ (error as EChartOptionsParseError).validationErrors.some(e =>
+ e.includes('legend'),
+ ),
+ ).toBe(true);
+ }
+});
+
+test('parseEChartOptions ignores unknown top-level properties', () => {
+ const input = `{
+ title: { text: 'Chart' },
+ unknownProperty: 'should be filtered',
+ anotherUnknown: { nested: 'value' }
+ }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data?.title).toEqual({ text: 'Chart' });
+ expect(
+ (result.data as Record<string, unknown>)?.unknownProperty,
+ ).toBeUndefined();
+ expect(
+ (result.data as Record<string, unknown>)?.anotherUnknown,
+ ).toBeUndefined();
+});
+
+test('parseEChartOptions throws when array has invalid items', () => {
+ // dataZoom array should contain objects, not strings
+ const input = `{
+ dataZoom: [
+ { type: 'slider', start: 0 },
+ 'invalid',
+ { type: 'inside' }
+ ]
+ }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as EChartOptionsParseError).errorType).toBe(
+ 'validation_error',
+ );
+ expect(
+ (error as EChartOptionsParseError).validationErrors.some(e =>
+ e.includes('dataZoom'),
+ ),
+ ).toBe(true);
+ }
+});
+
+test('parseEChartOptions validates tooltip properties', () => {
+ const input = `{
+ tooltip: {
+ trigger: 'axis',
+ show: true,
+ padding: 10
+ }
+ }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data?.tooltip).toEqual({
+ trigger: 'axis',
+ show: true,
+ padding: 10,
+ });
+});
+
+test('parseEChartOptions validates xAxis type property', () => {
+ const input = `{
+ xAxis: {
+ type: 'category',
+ name: 'X Axis',
+ axisLabel: {
+ rotate: 45,
+ fontSize: 12
+ }
+ }
+ }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data?.xAxis).toEqual({
+ type: 'category',
+ name: 'X Axis',
+ axisLabel: {
+ rotate: 45,
+ fontSize: 12,
+ },
+ });
+});
+
+test('parseEChartOptions throws when number is used where string expected', ()
=> {
+ // backgroundColor should be string, not number
+ const input = `{ backgroundColor: 123 }`;
+
+ expect(() => parseEChartOptions(input)).toThrow(EChartOptionsParseError);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ expect((error as EChartOptionsParseError).errorType).toBe(
+ 'validation_error',
+ );
+ }
+});
+
+test('parseEChartOptions accepts valid animation options', () => {
+ const input = `{
+ animation: true,
+ animationDuration: 1000,
+ animationEasing: 'cubicOut',
+ animationDelay: 100
+ }`;
+ const result = parseEChartOptions(input);
+
+ expect(result.success).toBe(true);
+ expect(result.data).toEqual({
+ animation: true,
+ animationDuration: 1000,
+ animationEasing: 'cubicOut',
+ animationDelay: 100,
+ });
+});
+
+test('EChartOptionsParseError contains validation error details', () => {
+ const input = `{ title: 'invalid', grid: 123 }`;
+
+ expect.assertions(5);
+ try {
+ parseEChartOptions(input);
+ } catch (error) {
+ expect(error).toBeInstanceOf(EChartOptionsParseError);
+ const parseError = error as EChartOptionsParseError;
+ expect(parseError.errorType).toBe('validation_error');
+ expect(parseError.validationErrors.length).toBe(2);
+ expect(parseError.validationErrors.some(e => e.includes('title'))).toBe(
+ true,
+ );
+ expect(parseError.validationErrors.some(e => e.includes('grid'))).toBe(
+ true,
+ );
+ }
+});
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.ts
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.ts
new file mode 100644
index 0000000000..528a602199
--- /dev/null
+++
b/superset-frontend/plugins/plugin-chart-echarts/src/utils/safeEChartOptionsParser.ts
@@ -0,0 +1,477 @@
+/**
+ * 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 { parse } from 'acorn';
+import type { Node } from 'acorn';
+import type { z } from 'zod';
+import {
+ customEChartOptionsSchema,
+ titleSchema,
+ legendSchema,
+ gridSchema,
+ axisSchema,
+ tooltipSchema,
+ dataZoomSchema,
+ toolboxSchema,
+ visualMapSchema,
+ seriesSchema,
+ graphicElementSchema,
+ axisPointerSchema,
+ textStyleSchema,
+ type CustomEChartOptions,
+} from './eChartOptionsSchema';
+
+//
=============================================================================
+// Custom Error Class
+//
=============================================================================
+
+/**
+ * Custom error class for EChart options parsing errors
+ */
+export class EChartOptionsParseError extends Error {
+ public readonly errorType:
+ | 'parse_error'
+ | 'security_error'
+ | 'validation_error';
+
+ public readonly validationErrors: string[];
+
+ public readonly location?: { line: number; column: number };
+
+ constructor(
+ message: string,
+ errorType:
+ | 'parse_error'
+ | 'security_error'
+ | 'validation_error' = 'parse_error',
+ validationErrors: string[] = [],
+ location?: { line: number; column: number },
+ ) {
+ super(message);
+ this.name = 'EChartOptionsParseError';
+ this.errorType = errorType;
+ this.validationErrors = validationErrors;
+ this.location = location;
+ }
+}
+
+//
=============================================================================
+// Partial Validation Helper
+//
=============================================================================
+
+/**
+ * Maps top-level property names to their Zod schemas for partial validation
+ */
+const propertySchemas: Record<string, z.ZodTypeAny> = {
+ title: titleSchema,
+ legend: legendSchema,
+ grid: gridSchema,
+ xAxis: axisSchema,
+ yAxis: axisSchema,
+ tooltip: tooltipSchema,
+ dataZoom: dataZoomSchema,
+ toolbox: toolboxSchema,
+ visualMap: visualMapSchema,
+ series: seriesSchema,
+ graphic: graphicElementSchema,
+ axisPointer: axisPointerSchema,
+ textStyle: textStyleSchema,
+};
+
+/**
+ * Validates each property individually and returns only valid properties.
+ * This allows partial validation where invalid properties are filtered out
+ * while valid ones are kept. Throws an error if any validation issues are
found.
+ */
+function validatePartial(data: Record<string, unknown>): CustomEChartOptions {
+ const result: Record<string, unknown> = {};
+ const validationErrors: string[] = [];
+
+ for (const [key, value] of Object.entries(data)) {
+ if (value === undefined) continue;
+
+ const schema = propertySchemas[key];
+
+ if (schema) {
+ // For properties with known schemas, validate them
+ if (Array.isArray(value)) {
+ // Validate array items individually
+ const validItems = value
+ .map((item, index) => {
+ const itemResult = schema.safeParse(item);
+ if (itemResult.success) {
+ return itemResult.data;
+ }
+ validationErrors.push(
+ `Invalid array item in "${key}[${index}]":
${itemResult.error.issues.map(e => e.message).join(', ')}`,
+ );
+ return null;
+ })
+ .filter(item => item !== null);
+
+ if (validItems.length > 0) {
+ result[key] = validItems;
+ }
+ } else {
+ // Validate single object
+ const propResult = schema.safeParse(value);
+ if (propResult.success) {
+ result[key] = propResult.data;
+ } else {
+ validationErrors.push(
+ `Invalid property "${key}": ${propResult.error.issues.map(e =>
e.message).join(', ')}`,
+ );
+ }
+ }
+ } else {
+ // For primitive properties (animation, backgroundColor, etc.), validate
with full schema
+ const primitiveResult =
+ customEChartOptionsSchema.shape[
+ key as keyof typeof customEChartOptionsSchema.shape
+ ]?.safeParse(value);
+
+ if (primitiveResult?.success) {
+ result[key] = primitiveResult.data;
+ } else if (primitiveResult) {
+ validationErrors.push(
+ `Invalid property "${key}": ${primitiveResult.error?.issues.map(e =>
e.message).join(', ') ?? 'Invalid value'}`,
+ );
+ }
+ // Unknown properties are silently ignored
+ }
+ }
+
+ if (validationErrors.length > 0) {
+ throw new EChartOptionsParseError(
+ 'EChart options validation failed',
+ 'validation_error',
+ validationErrors,
+ );
+ }
+
+ return result as CustomEChartOptions;
+}
+
+//
=============================================================================
+// AST Safety Validation
+//
=============================================================================
+
+/**
+ * Safe AST node types that are allowed in EChart options.
+ * These represent static data structures without executable code.
+ */
+const SAFE_NODE_TYPES = new Set([
+ 'ObjectExpression',
+ 'ArrayExpression',
+ 'Literal',
+ 'Property',
+ 'Identifier',
+ 'UnaryExpression',
+ 'TemplateLiteral',
+ 'TemplateElement',
+]);
+
+const ALLOWED_UNARY_OPERATORS = new Set(['-', '+']);
+
+const DANGEROUS_IDENTIFIERS = new Set([
+ 'eval',
+ 'Function',
+ 'constructor',
+ 'prototype',
+ '__proto__',
+ 'window',
+ 'document',
+ 'globalThis',
+ 'process',
+ 'require',
+ 'import',
+ 'module',
+ 'exports',
+]);
+
+/**
+ * Recursively validates that an AST node contains only safe constructs.
+ * Throws an error if any unsafe patterns are detected.
+ */
+function validateNode(node: Node, path: string[] = []): void {
+ if (!node || typeof node !== 'object') {
+ return;
+ }
+
+ const nodeType = node.type;
+
+ if (!SAFE_NODE_TYPES.has(nodeType)) {
+ throw new Error(
+ `Unsafe node type "${nodeType}" at path: ${path.join('.')}. ` +
+ `Only static data structures are allowed.`,
+ );
+ }
+
+ switch (nodeType) {
+ case 'Identifier': {
+ const identNode = node as Node & { name: string };
+ if (DANGEROUS_IDENTIFIERS.has(identNode.name)) {
+ throw new Error(
+ `Dangerous identifier "${identNode.name}" detected at path:
${path.join('.')}`,
+ );
+ }
+ break;
+ }
+
+ case 'UnaryExpression': {
+ const unaryNode = node as Node & { operator: string; argument: Node };
+ if (!ALLOWED_UNARY_OPERATORS.has(unaryNode.operator)) {
+ throw new Error(
+ `Unsafe unary operator "${unaryNode.operator}" at path:
${path.join('.')}`,
+ );
+ }
+ validateNode(unaryNode.argument, [...path, 'argument']);
+ break;
+ }
+
+ case 'ObjectExpression': {
+ const objNode = node as Node & { properties: Node[] };
+ objNode.properties.forEach((prop, index) => {
+ validateNode(prop, [...path, `property[${index}]`]);
+ });
+ break;
+ }
+
+ case 'ArrayExpression': {
+ const arrNode = node as Node & { elements: (Node | null)[] };
+ arrNode.elements.forEach((elem, index) => {
+ if (elem) {
+ validateNode(elem, [...path, `element[${index}]`]);
+ }
+ });
+ break;
+ }
+
+ case 'Property': {
+ const propNode = node as Node & {
+ key: Node;
+ value: Node;
+ computed: boolean;
+ };
+ if (propNode.computed) {
+ throw new Error(
+ `Computed properties are not allowed at path: ${path.join('.')}`,
+ );
+ }
+ validateNode(propNode.key, [...path, 'key']);
+ validateNode(propNode.value, [...path, 'value']);
+ break;
+ }
+
+ case 'TemplateLiteral': {
+ const templateNode = node as Node & {
+ expressions: Node[];
+ quasis: Node[];
+ };
+ if (templateNode.expressions.length > 0) {
+ throw new Error(
+ `Template literals with expressions are not allowed at path:
${path.join('.')}`,
+ );
+ }
+ templateNode.quasis.forEach((quasi, index) => {
+ validateNode(quasi, [...path, `quasi[${index}]`]);
+ });
+ break;
+ }
+
+ case 'Literal':
+ case 'TemplateElement':
+ break;
+
+ default:
+ throw new Error(`Unhandled node type: ${nodeType}`);
+ }
+}
+
+/**
+ * Converts a validated AST node to a JavaScript value.
+ */
+function astToValue(node: Node): unknown {
+ switch (node.type) {
+ case 'Literal': {
+ const litNode = node as Node & { value: unknown };
+ return litNode.value;
+ }
+
+ case 'UnaryExpression': {
+ const unaryNode = node as Node & { operator: string; argument: Node };
+ const argValue = astToValue(unaryNode.argument) as number;
+ return unaryNode.operator === '-' ? -argValue : +argValue;
+ }
+
+ case 'Identifier': {
+ const identNode = node as Node & { name: string };
+ if (identNode.name === 'undefined') return undefined;
+ if (identNode.name === 'null') return null;
+ if (identNode.name === 'true') return true;
+ if (identNode.name === 'false') return false;
+ if (identNode.name === 'NaN') return NaN;
+ if (identNode.name === 'Infinity') return Infinity;
+ return identNode.name;
+ }
+
+ case 'ObjectExpression': {
+ const objNode = node as Node & { properties: Node[] };
+ const objResult: Record<string, unknown> = {};
+ objNode.properties.forEach(prop => {
+ const propNode = prop as Node & { key: Node; value: Node };
+ const key = astToValue(propNode.key) as string;
+ const value = astToValue(propNode.value);
+ objResult[key] = value;
+ });
+ return objResult;
+ }
+
+ case 'ArrayExpression': {
+ const arrNode = node as Node & { elements: (Node | null)[] };
+ return arrNode.elements.map(elem => (elem ? astToValue(elem) : null));
+ }
+
+ case 'TemplateLiteral': {
+ const templateNode = node as Node & {
+ quasis: Array<Node & { value: { cooked: string } }>;
+ };
+ return templateNode.quasis.map(q => q.value.cooked).join('');
+ }
+
+ default:
+ throw new Error(`Cannot convert node type: ${node.type}`);
+ }
+}
+
+//
=============================================================================
+// Parse Result Types
+//
=============================================================================
+
+interface ParseResult {
+ success: boolean;
+ data?: CustomEChartOptions;
+}
+
+//
=============================================================================
+// Public API
+//
=============================================================================
+
+/**
+ * Safely parses a JavaScript object literal string into an ECharts options
object.
+ *
+ * This function performs two-stage validation:
+ * 1. AST analysis for security (blocks functions, eval, etc.)
+ * 2. Zod schema validation for type correctness
+ *
+ * @param input - A string containing a JavaScript object literal
+ * @returns ParseResult with either the parsed/validated data or throws
EChartOptionsParseError
+ * @throws {EChartOptionsParseError} When parsing fails or validation errors
occur
+ *
+ * @example
+ * ```typescript
+ * // Valid input
+ * const result = parseEChartOptions(`{
+ * title: { text: 'My Chart', left: 'center' },
+ * grid: { top: 50, bottom: 50 }
+ * }`);
+ *
+ * // Invalid input (title should be object, not string)
+ * // Throws EChartOptionsParseError with validation errors
+ * const result2 = parseEChartOptions(`{ title: 'text' }`);
+ * ```
+ */
+export function parseEChartOptions(input: string | undefined): ParseResult {
+ if (!input || typeof input !== 'string') {
+ return { success: true, data: undefined };
+ }
+
+ const trimmed = input.trim();
+ if (!trimmed) {
+ return { success: true, data: undefined };
+ }
+
+ // Step 1: Parse into AST
+ const wrappedInput = `(${trimmed})`;
+ let ast: Node & { body: Array<Node & { expression: Node }> };
+
+ try {
+ ast = parse(wrappedInput, {
+ ecmaVersion: 2020,
+ sourceType: 'script',
+ }) as Node & { body: Array<Node & { expression: Node }> };
+ } catch (error) {
+ const err = error as Error & { loc?: { line: number; column: number } };
+ throw new EChartOptionsParseError(err.message, 'parse_error', [], err.loc);
+ }
+
+ if (
+ !ast.body ||
+ ast.body.length !== 1 ||
+ ast.body[0].type !== 'ExpressionStatement'
+ ) {
+ throw new EChartOptionsParseError(
+ 'Input must be a single object literal expression (e.g., { key: value
})',
+ 'parse_error',
+ );
+ }
+
+ const { expression } = ast.body[0];
+
+ if (expression.type !== 'ObjectExpression') {
+ throw new EChartOptionsParseError(
+ `Expected an object literal, but got: ${expression.type}`,
+ 'parse_error',
+ );
+ }
+
+ // Step 2: Validate AST for security (no functions, eval, etc.)
+ try {
+ validateNode(expression);
+ } catch (error) {
+ const err = error as Error;
+ throw new EChartOptionsParseError(err.message, 'security_error');
+ }
+
+ // Step 3: Convert AST to JavaScript object
+ const rawData = astToValue(expression);
+
+ // Step 4: Validate against Zod schema with partial/lenient mode
+ // This will throw EChartOptionsParseError if validation fails
+ const validatedData = validatePartial(rawData as Record<string, unknown>);
+
+ return { success: true, data: validatedData };
+}
+
+/**
+ * Validates and parses EChart options.
+ * Throws EChartOptionsParseError on failure for the caller to handle.
+ *
+ * @param input - A string containing a JavaScript object literal
+ * @returns The parsed and validated EChart options, or undefined for empty
input
+ * @throws {EChartOptionsParseError} When parsing fails or validation errors
occur
+ */
+export function safeParseEChartOptions(
+ input: string | undefined,
+): CustomEChartOptions | undefined {
+ const result = parseEChartOptions(input);
+ return result.data;
+}
+
+export default parseEChartOptions;
diff --git a/superset-frontend/src/core/editors/AceEditorProvider.tsx
b/superset-frontend/src/core/editors/AceEditorProvider.tsx
index 89c8a57911..9236403a3b 100644
--- a/superset-frontend/src/core/editors/AceEditorProvider.tsx
+++ b/superset-frontend/src/core/editors/AceEditorProvider.tsx
@@ -42,6 +42,7 @@ import {
MarkdownEditor,
CssEditor,
ConfigEditor,
+ JSEditor,
type AceCompleterKeyword,
} from '@superset-ui/core/components';
import { Disposable } from '../models';
@@ -70,6 +71,8 @@ const getEditorComponent = (language: string) => {
return CssEditor;
case 'yaml':
return ConfigEditor;
+ case 'javascript':
+ return JSEditor;
default:
console.warn(
`Unknown editor language "${language}", falling back to SQL editor`,
diff --git
a/superset-frontend/src/explore/components/controls/JSEditorControl.test.tsx
b/superset-frontend/src/explore/components/controls/JSEditorControl.test.tsx
new file mode 100644
index 0000000000..5a083702b9
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/JSEditorControl.test.tsx
@@ -0,0 +1,125 @@
+/**
+ * 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 type { ReactNode } from 'react';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import JSEditorControl from 'src/explore/components/controls/JSEditorControl';
+
+jest.mock('react-virtualized-auto-sizer', () => ({
+ __esModule: true,
+ default: ({
+ children,
+ }: {
+ children: (params: { width: number; height: number }) => ReactNode;
+ }) => children({ width: 500, height: 250 }),
+}));
+
+jest.mock('src/core/editors', () => ({
+ EditorHost: ({
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (v: string) => void;
+ }) => (
+ <textarea
+ data-test="js-editor"
+ defaultValue={value}
+ onChange={e => onChange?.(e.target.value)}
+ />
+ ),
+}));
+
+jest.mock('src/hooks/useDebounceValue', () => ({
+ useDebounceValue: (value: string) => value,
+}));
+
+const defaultProps = {
+ name: 'echartOptions',
+ label: 'EChart Options',
+ onChange: jest.fn(),
+ value: '',
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('renders the control with label', () => {
+ render(<JSEditorControl {...defaultProps} />);
+ expect(screen.getByText('EChart Options')).toBeInTheDocument();
+});
+
+test('renders the editor', () => {
+ render(<JSEditorControl {...defaultProps} />);
+ expect(screen.getByTestId('js-editor')).toBeInTheDocument();
+});
+
+test('renders with initial value', () => {
+ const value = "{ title: { text: 'Test' } }";
+ render(<JSEditorControl {...defaultProps} value={value} />);
+ const editor = screen.getByTestId('js-editor');
+ expect(editor).toHaveValue(value);
+});
+
+test('calls onChange when editor content changes', async () => {
+ render(<JSEditorControl {...defaultProps} />);
+ const editor = screen.getByTestId('js-editor');
+ await userEvent.type(editor, '{ }');
+ expect(defaultProps.onChange).toHaveBeenCalled();
+});
+
+test('displays validation error for invalid syntax', () => {
+ const invalidValue = '{ invalid syntax';
+ render(<JSEditorControl {...defaultProps} value={invalidValue} />);
+ expect(screen.getByTestId('error-tooltip')).toBeInTheDocument();
+});
+
+test('displays validation error for function expressions', () => {
+ const valueWithFunction = '{ formatter: () => {} }';
+ render(<JSEditorControl {...defaultProps} value={valueWithFunction} />);
+ expect(screen.getByTestId('error-tooltip')).toBeInTheDocument();
+});
+
+test('does not display error for valid EChart options', () => {
+ const validValue = "{ title: { text: 'Valid Chart' }, grid: { top: 50 } }";
+ render(<JSEditorControl {...defaultProps} value={validValue} />);
+ expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('does not display error for empty value', () => {
+ render(<JSEditorControl {...defaultProps} value="" />);
+ expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('does not display error for undefined value', () => {
+ render(<JSEditorControl {...defaultProps} value={undefined} />);
+ expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('renders with description tooltip', () => {
+ const description = 'Custom EChart configuration options';
+ render(<JSEditorControl {...defaultProps} description={description} />);
+ expect(screen.getByText('EChart Options')).toBeInTheDocument();
+});
+
+test('uses name as label when label is not provided', () => {
+ const props = { ...defaultProps, label: undefined };
+ render(<JSEditorControl {...props} />);
+ expect(screen.getByText('echartOptions')).toBeInTheDocument();
+});
diff --git
a/superset-frontend/src/explore/components/controls/JSEditorControl.tsx
b/superset-frontend/src/explore/components/controls/JSEditorControl.tsx
new file mode 100644
index 0000000000..f25d641953
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/JSEditorControl.tsx
@@ -0,0 +1,105 @@
+/**
+ * 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 { useMemo } from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import ControlHeader, {
+ ControlHeaderProps,
+} from 'src/explore/components/ControlHeader';
+import { styled } from '@apache-superset/core';
+import { ControlComponentProps } from '@superset-ui/chart-controls';
+import {
+ safeParseEChartOptions,
+ EChartOptionsParseError,
+} from '@superset-ui/plugin-chart-echarts';
+import { EditorHost } from 'src/core/editors';
+import { useDebounceValue } from 'src/hooks/useDebounceValue';
+
+const Container = styled.div`
+ border: 1px solid ${({ theme }) => theme.colorBorder};
+ border-radius: ${({ theme }) => theme.borderRadius}px;
+ overflow: hidden;
+`;
+
+const ErrorMessage = styled.div`
+ color: ${({ theme }) => theme.colorErrorText};
+`;
+
+export default function JSEditorControl({
+ name,
+ label,
+ description,
+ renderTrigger,
+ hovered,
+ tooltipOnClick,
+ onChange,
+ value,
+}: ControlHeaderProps & ControlComponentProps<string>) {
+ const debouncedValue = useDebounceValue(value);
+ const error = useMemo(() => {
+ try {
+ safeParseEChartOptions(debouncedValue ?? '');
+ return null;
+ } catch (err) {
+ if (err instanceof EChartOptionsParseError) {
+ return err;
+ }
+ throw err;
+ }
+ }, [debouncedValue]);
+ const headerProps = {
+ name,
+ label: label ?? name,
+ description,
+ renderTrigger,
+ validationErrors: error?.message ? [error.message] : undefined,
+ hovered,
+ tooltipOnClick,
+ };
+
+ return (
+ <>
+ <ControlHeader {...headerProps} />
+ <Container>
+ <AutoSizer disableHeight>
+ {({ width }) => (
+ <EditorHost
+ id="echart-js-editor"
+ value={value ?? ''}
+ onChange={val => onChange?.(val)}
+ language="javascript"
+ tabSize={2}
+ lineNumbers
+ width={`${width}px`}
+ height="250px"
+ />
+ )}
+ </AutoSizer>
+ </Container>
+ {error && (
+ <ErrorMessage>
+ {error.validationErrors.length > 0 ? (
+ error.validationErrors.map((err, idx) => <div
key={idx}>{err}</div>)
+ ) : (
+ <div>{error.message}</div>
+ )}
+ </ErrorMessage>
+ )}
+ </>
+ );
+}
diff --git a/superset-frontend/src/explore/components/controls/index.ts
b/superset-frontend/src/explore/components/controls/index.ts
index 46b70ac27d..326da43ce8 100644
--- a/superset-frontend/src/explore/components/controls/index.ts
+++ b/superset-frontend/src/explore/components/controls/index.ts
@@ -59,6 +59,7 @@ import NumberControl from './NumberControl';
import TimeRangeControl from './TimeRangeControl';
import ColorBreakpointsControl from './ColorBreakpointsControl';
import MatrixifyDimensionControl from './MatrixifyDimensionControl';
+import JSEditorControl from './JSEditorControl';
const extensionsRegistry = getExtensionsRegistry();
const DateFilterControlExtension = extensionsRegistry.get(
@@ -85,6 +86,7 @@ const controlMap = {
FixedOrMetricControl,
ColorBreakpointsControl,
HiddenControl,
+ JSEditorControl,
LayerConfigsControl,
MapViewControl,
SelectAsyncControl,