diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index fe9c92dc494..52dd41c7e42 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 334f28a49e1..1113bc8d729 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 bf087565ab7..0ec021d11f6 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> = { metrics: dndAdhocMetricsControl, metric: dndAdhocMetricControl, @@ -499,6 +513,7 @@ const sharedControls: Record> = { 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 b501b7c9bff..8825f5aca64 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 90a2cafcc9a..6ec452d5a9e 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 30bad84f4d8..048504ccfd6 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 3bba8cfea3e..c56ac505132 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 0ec6080c50f..ea5c8ca0053 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 26094b3a106..0d58f825322 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, @@ -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 56048de1d23..8eb4295de58 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 b8f62d42f87..9a1a92d12b6 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, [{t('Y Axis')}], ...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 1a45e7d23dd..2c339e70f30 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 62f805531b2..c5c2f31bead 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 ab9abfb792a..b2fcb7b7cdb 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 14fcb1eb450..abb57d9e6e8 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 00000000000..f8318dd8577 --- /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(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; +export type LineStyleOption = z.infer; +export type AreaStyleOption = z.infer; +export type ItemStyleOption = z.infer; +export type LabelOption = z.infer; +export type TitleOption = z.infer; +export type LegendOption = z.infer; +export type GridOption = z.infer; +export type AxisOption = z.infer; +export type TooltipOption = z.infer; +export type DataZoomOption = z.infer; +export type ToolboxOption = z.infer; +export type VisualMapOption = z.infer; +export type SeriesOption = z.infer; +export type GraphicElementOption = z.infer; +export type AxisPointerOption = z.infer; + +/** Main custom ECharts options type */ +export type CustomEChartOptions = z.infer; + +// ============================================================================= +// 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 00000000000..d818b99acea --- /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 00000000000..fdb75a70c1b --- /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; + +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( + baseOptions: T, + customOptions: CustomEChartOptions | undefined, +): T & Partial { + type MergedResult = T & Partial; + + 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 00000000000..59c351f328d --- /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)?.unknownProperty, + ).toBeUndefined(); + expect( + (result.data as Record)?.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 00000000000..528a602199b --- /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 = { + 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): CustomEChartOptions { + const result: Record = {}; + 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 = {}; + 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; + }; + 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 }; + + try { + ast = parse(wrappedInput, { + ecmaVersion: 2020, + sourceType: 'script', + }) as Node & { body: Array }; + } 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); + + 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 89c8a579117..9236403a3b3 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 00000000000..5a083702b94 --- /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; + }) => ( +