feat(explore): Add Echarts option editor (#37868)

This commit is contained in:
JUST.in DO IT
2026-03-04 08:34:34 -08:00
committed by GitHub
parent 80a29cd6fe
commit 35d0aad854
24 changed files with 2413 additions and 8 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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,

View File

@@ -170,6 +170,7 @@ export type InternalControlType =
| 'FixedOrMetricControl'
| 'ColorBreakpointsControl'
| 'HiddenControl'
| 'JSEditorControl'
| 'SelectAsyncControl'
| 'SelectControl'
| 'SliderControl'

View File

@@ -502,3 +502,9 @@ export const ConfigEditor = AsyncAceEditor([
'mode/yaml',
'theme/github',
]);
export const JSEditor = AsyncAceEditor([
'mode/javascript',
'mode/json',
'theme/github',
]);

View File

@@ -39,6 +39,7 @@ export {
AsyncAceEditor,
CssEditor,
JsonEditor,
JSEditor,
SQLEditor,
FullSQLEditor,
MarkdownEditor,

View File

@@ -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": "*",

View File

@@ -505,6 +505,7 @@ const config: ControlPanelConfig = {
},
},
],
['echart_options'],
],
},
],

View File

@@ -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,

View File

@@ -251,6 +251,7 @@ const config: ControlPanelConfig = {
},
},
],
['echart_options'],
],
},
],

View File

@@ -374,6 +374,7 @@ const config: ControlPanelConfig = {
...richTooltipSection,
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
...createAxisControl('y'),
['echart_options'],
],
},
],

View File

@@ -216,6 +216,7 @@ const config: ControlPanelConfig = {
},
},
],
['echart_options'],
],
},
],

View File

@@ -183,6 +183,7 @@ const config: ControlPanelConfig = {
},
},
],
['echart_options'],
],
},
],

View File

@@ -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,

View File

@@ -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';
/**

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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,
);
}
});

View File

@@ -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;

View File

@@ -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`,

View File

@@ -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();
});

View File

@@ -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>
)}
</>
);
}

View File

@@ -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,