mirror of
https://github.com/apache/superset.git
synced 2026-04-07 10:31:50 +00:00
feat(explore): Add Echarts option editor (#37868)
This commit is contained in:
29
superset-frontend/package-lock.json
generated
29
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -170,6 +170,7 @@ export type InternalControlType =
|
||||
| 'FixedOrMetricControl'
|
||||
| 'ColorBreakpointsControl'
|
||||
| 'HiddenControl'
|
||||
| 'JSEditorControl'
|
||||
| 'SelectAsyncControl'
|
||||
| 'SelectControl'
|
||||
| 'SliderControl'
|
||||
|
||||
@@ -502,3 +502,9 @@ export const ConfigEditor = AsyncAceEditor([
|
||||
'mode/yaml',
|
||||
'theme/github',
|
||||
]);
|
||||
|
||||
export const JSEditor = AsyncAceEditor([
|
||||
'mode/javascript',
|
||||
'mode/json',
|
||||
'theme/github',
|
||||
]);
|
||||
|
||||
@@ -39,6 +39,7 @@ export {
|
||||
AsyncAceEditor,
|
||||
CssEditor,
|
||||
JsonEditor,
|
||||
JSEditor,
|
||||
SQLEditor,
|
||||
FullSQLEditor,
|
||||
MarkdownEditor,
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -505,6 +505,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
['echart_options'],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -251,6 +251,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
['echart_options'],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -374,6 +374,7 @@ const config: ControlPanelConfig = {
|
||||
...richTooltipSection,
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
...createAxisControl('y'),
|
||||
['echart_options'],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -216,6 +216,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
['echart_options'],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -183,6 +183,7 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
['echart_options'],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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`,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user