codeant-ai-for-open-source[bot] commented on code in PR #37868: URL: https://github.com/apache/superset/pull/37868#discussion_r2850299229
########## superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts: ########## @@ -0,0 +1,827 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Unified ECharts Options Schema + * + * This file serves as the single source of truth for: + * 1. Runtime validation (Zod schema) + * 2. TypeScript types (inferred from Zod) + * + * Reference: https://echarts.apache.org/en/option.html + */ + +import { z } from 'zod'; + +// ============================================================================= +// Common Schemas +// ============================================================================= + +/** Color value - hex, rgb, rgba, or named color */ +const colorSchema = z.string(); + +/** Numeric or percentage string (e.g., '50%') */ +const numberOrPercentSchema = z.union([z.number(), z.string()]); + +/** Line type */ +const lineTypeSchema = z.union([ + z.enum(['solid', 'dashed', 'dotted']), + z.array(z.number()), +]); + +/** Font weight */ +const fontWeightSchema = z.union([ + z.enum(['normal', 'bold', 'bolder', 'lighter']), + z.number().min(100).max(900), +]); + +/** Font style */ +const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']); + +/** Symbol type */ +const symbolTypeSchema = z.string(); + +// ============================================================================= +// Text Style Schema +// ============================================================================= + +export const textStyleSchema = z.object({ + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + lineHeight: z.number().optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + textBorderColor: colorSchema.optional(), + textBorderWidth: z.number().optional(), + textBorderType: lineTypeSchema.optional(), + textBorderDashOffset: z.number().optional(), + textShadowColor: colorSchema.optional(), + textShadowBlur: z.number().optional(), + textShadowOffsetX: z.number().optional(), + textShadowOffsetY: z.number().optional(), + overflow: z.enum(['none', 'truncate', 'break', 'breakAll']).optional(), + ellipsis: z.string().optional(), +}); + +// ============================================================================= +// Style Schemas +// ============================================================================= + +export const lineStyleSchema = z.object({ + color: colorSchema.optional(), + width: z.number().optional(), + type: lineTypeSchema.optional(), + dashOffset: z.number().optional(), + cap: z.enum(['butt', 'round', 'square']).optional(), + join: z.enum(['bevel', 'round', 'miter']).optional(), + miterLimit: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +export const areaStyleSchema = z.object({ + color: z.union([colorSchema, z.array(colorSchema)]).optional(), + origin: z.union([z.enum(['auto', 'start', 'end']), z.number()]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +export const itemStyleSchema = z.object({ + color: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderType: lineTypeSchema.optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +// ============================================================================= +// Label Schema +// ============================================================================= + +export const labelSchema = z.object({ + show: z.boolean().optional(), + position: z + .enum([ + 'top', + 'left', + 'right', + 'bottom', + 'inside', + 'insideLeft', + 'insideRight', + 'insideTop', + 'insideBottom', + 'insideTopLeft', + 'insideBottomLeft', + 'insideTopRight', + 'insideBottomRight', + 'outside', + ]) + .optional(), + distance: z.number().optional(), + rotate: z.number().optional(), + offset: z.array(z.number()).optional(), + formatter: z.string().optional(), // Only string formatters allowed, not functions + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + lineHeight: z.number().optional(), +}); + +// ============================================================================= +// Title Schema +// ============================================================================= + +export const titleSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + text: z.string().optional(), + link: z.string().optional(), + target: z.enum(['self', 'blank']).optional(), + textStyle: textStyleSchema.optional(), + subtext: z.string().optional(), + sublink: z.string().optional(), + subtarget: z.enum(['self', 'blank']).optional(), + subtextStyle: textStyleSchema.optional(), + textAlign: z.enum(['left', 'center', 'right']).optional(), + textVerticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), + triggerEvent: z.boolean().optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + itemGap: z.number().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), +}); + +// ============================================================================= +// Legend Schema +// ============================================================================= + +export const legendSchema = z.object({ + id: z.string().optional(), + type: z.enum(['plain', 'scroll']).optional(), + show: z.boolean().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + align: z.enum(['auto', 'left', 'right']).optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + itemGap: z.number().optional(), + itemWidth: z.number().optional(), + itemHeight: z.number().optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + textStyle: textStyleSchema.optional(), + icon: symbolTypeSchema.optional(), + selectedMode: z + .union([z.boolean(), z.enum(['single', 'multiple', 'series'])]) + .optional(), + inactiveColor: colorSchema.optional(), + inactiveBorderColor: colorSchema.optional(), + inactiveBorderWidth: z.number().optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + pageButtonItemGap: z.number().optional(), + pageButtonGap: z.number().optional(), + pageButtonPosition: z.enum(['start', 'end']).optional(), + pageIconColor: colorSchema.optional(), + pageIconInactiveColor: colorSchema.optional(), + pageIconSize: z.union([z.number(), z.array(z.number())]).optional(), + pageTextStyle: textStyleSchema.optional(), +}); + +// ============================================================================= +// Grid Schema +// ============================================================================= + +export const gridSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + containLabel: z.boolean().optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), +}); + +// ============================================================================= +// Axis Schemas +// ============================================================================= + +const axisLineSchema = z.object({ + show: z.boolean().optional(), + onZero: z.boolean().optional(), + onZeroAxisIndex: z.number().optional(), + symbol: z.union([z.string(), z.array(z.string())]).optional(), + symbolSize: z.array(z.number()).optional(), + symbolOffset: z.union([z.number(), z.array(z.number())]).optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const axisTickSchema = z.object({ + show: z.boolean().optional(), + alignWithLabel: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + inside: z.boolean().optional(), + length: z.number().optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const axisLabelSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + inside: z.boolean().optional(), + rotate: z.number().optional(), + margin: z.number().optional(), + formatter: z.string().optional(), // Only string formatters + showMinLabel: z.boolean().optional(), + showMaxLabel: z.boolean().optional(), + hideOverlap: z.boolean().optional(), + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + align: z.enum(['left', 'center', 'right']).optional(), + verticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), +}); + +const splitLineSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const splitAreaSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + areaStyle: areaStyleSchema.optional(), +}); + +export const axisSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + gridIndex: z.number().optional(), + alignTicks: z.boolean().optional(), + position: z.enum(['top', 'bottom', 'left', 'right']).optional(), + offset: z.number().optional(), + type: z.enum(['value', 'category', 'time', 'log']).optional(), + name: z.string().optional(), + nameLocation: z.enum(['start', 'middle', 'center', 'end']).optional(), + nameTextStyle: textStyleSchema.optional(), + nameGap: z.number().optional(), + nameRotate: z.number().optional(), + inverse: z.boolean().optional(), + boundaryGap: z + .union([z.boolean(), z.array(z.union([z.string(), z.number()]))]) + .optional(), + min: z.union([z.number(), z.string(), z.literal('dataMin')]).optional(), + max: z.union([z.number(), z.string(), z.literal('dataMax')]).optional(), + scale: z.boolean().optional(), + splitNumber: z.number().optional(), + minInterval: z.number().optional(), + maxInterval: z.number().optional(), + interval: z.number().optional(), + logBase: z.number().optional(), + silent: z.boolean().optional(), + triggerEvent: z.boolean().optional(), + axisLine: axisLineSchema.optional(), + axisTick: axisTickSchema.optional(), + minorTick: axisTickSchema.optional(), + axisLabel: axisLabelSchema.optional(), + splitLine: splitLineSchema.optional(), + minorSplitLine: splitLineSchema.optional(), + splitArea: splitAreaSchema.optional(), + zlevel: z.number().optional(), + z: z.number().optional(), +}); + +// ============================================================================= +// Tooltip Schema +// ============================================================================= + +export const tooltipSchema = z.object({ + show: z.boolean().optional(), + trigger: z.enum(['item', 'axis', 'none']).optional(), + triggerOn: z + .enum(['mousemove', 'click', 'mousemove|click', 'none']) + .optional(), + alwaysShowContent: z.boolean().optional(), + showDelay: z.number().optional(), + hideDelay: z.number().optional(), + enterable: z.boolean().optional(), + renderMode: z.enum(['html', 'richText']).optional(), + confine: z.boolean().optional(), + appendToBody: z.boolean().optional(), + transitionDuration: z.number().optional(), + position: z + .union([ + z.enum(['inside', 'top', 'left', 'right', 'bottom']), + z.array(z.union([z.number(), z.string()])), + ]) + .optional(), + formatter: z.string().optional(), // Only string formatters + padding: z.union([z.number(), z.array(z.number())]).optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + textStyle: textStyleSchema.optional(), + extraCssText: z.string().optional(), + order: z + .enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc']) + .optional(), +}); + +// ============================================================================= +// DataZoom Schema +// ============================================================================= + +export const dataZoomSchema = z.object({ + type: z.enum(['slider', 'inside']).optional(), Review Comment: **Suggestion:** The dataZoom type enum excludes the valid 'select' mode, so any user-defined custom options that configure a selection-type dataZoom will be rejected by the Zod validation and cannot be applied. [logic error] <details> <summary><b>Severity Level:</b> Major ⚠️</summary> ```mdx - ⚠️ Selection-style dataZoom configs rejected by validation. - ⚠️ Users cannot enable select zoom via custom options. ``` </details> ```suggestion type: z.enum(['slider', 'inside', 'select']).optional(), ``` <details> <summary><b>Steps of Reproduction ✅ </b></summary> ```mdx 1. Import the validator from `superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts`: `import { validateEChartOptions, parseEChartOptionsStrict } from './echartOptionsSchema';`. 2. Call `validateEChartOptions` with a configuration that uses selection-based data zoom: `validateEChartOptions({ dataZoom: { type: 'select' } });`. 3. The root `customEChartOptionsSchema` (lines 740–776) validates `dataZoom` using `dataZoomSchema` from line 410 via `objectOrArray(dataZoomSchema)`. 4. Inside `dataZoomSchema`, the `type` field at line 411 is `z.enum(['slider', 'inside'])`, so `'select'` is rejected, causing `safeParse` to fail and leading `parseEChartOptionsStrict` (lines 818–824) or any caller to drop the entire `dataZoom` configuration (returning `{}` for invalid input). ``` </details> <details> <summary><b>Prompt for AI Agent 🤖 </b></summary> ```mdx This is a comment left during a code review. **Path:** superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts **Line:** 411:411 **Comment:** *Logic Error: The dataZoom type enum excludes the valid 'select' mode, so any user-defined custom options that configure a selection-type dataZoom will be rejected by the Zod validation and cannot be applied. Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise. ``` </details> <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=20f7d778ecdafd9f96048bf8e54e559e3f416c5f512be61eb8b1fec7d346d537&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=20f7d778ecdafd9f96048bf8e54e559e3f416c5f512be61eb8b1fec7d346d537&reaction=dislike'>👎</a> ########## superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts: ########## @@ -0,0 +1,827 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Unified ECharts Options Schema + * + * This file serves as the single source of truth for: + * 1. Runtime validation (Zod schema) + * 2. TypeScript types (inferred from Zod) + * + * Reference: https://echarts.apache.org/en/option.html + */ + +import { z } from 'zod'; + +// ============================================================================= +// Common Schemas +// ============================================================================= + +/** Color value - hex, rgb, rgba, or named color */ +const colorSchema = z.string(); + +/** Numeric or percentage string (e.g., '50%') */ +const numberOrPercentSchema = z.union([z.number(), z.string()]); + +/** Line type */ +const lineTypeSchema = z.union([ + z.enum(['solid', 'dashed', 'dotted']), + z.array(z.number()), +]); + +/** Font weight */ +const fontWeightSchema = z.union([ + z.enum(['normal', 'bold', 'bolder', 'lighter']), + z.number().min(100).max(900), +]); + +/** Font style */ +const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']); + +/** Symbol type */ +const symbolTypeSchema = z.string(); + +// ============================================================================= +// Text Style Schema +// ============================================================================= + +export const textStyleSchema = z.object({ + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + lineHeight: z.number().optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + textBorderColor: colorSchema.optional(), + textBorderWidth: z.number().optional(), + textBorderType: lineTypeSchema.optional(), + textBorderDashOffset: z.number().optional(), + textShadowColor: colorSchema.optional(), + textShadowBlur: z.number().optional(), + textShadowOffsetX: z.number().optional(), + textShadowOffsetY: z.number().optional(), + overflow: z.enum(['none', 'truncate', 'break', 'breakAll']).optional(), + ellipsis: z.string().optional(), +}); + +// ============================================================================= +// Style Schemas +// ============================================================================= + +export const lineStyleSchema = z.object({ + color: colorSchema.optional(), + width: z.number().optional(), + type: lineTypeSchema.optional(), + dashOffset: z.number().optional(), + cap: z.enum(['butt', 'round', 'square']).optional(), + join: z.enum(['bevel', 'round', 'miter']).optional(), + miterLimit: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +export const areaStyleSchema = z.object({ + color: z.union([colorSchema, z.array(colorSchema)]).optional(), + origin: z.union([z.enum(['auto', 'start', 'end']), z.number()]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +export const itemStyleSchema = z.object({ + color: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderType: lineTypeSchema.optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +// ============================================================================= +// Label Schema +// ============================================================================= + +export const labelSchema = z.object({ + show: z.boolean().optional(), + position: z + .enum([ + 'top', + 'left', + 'right', + 'bottom', + 'inside', + 'insideLeft', + 'insideRight', + 'insideTop', + 'insideBottom', + 'insideTopLeft', + 'insideBottomLeft', + 'insideTopRight', + 'insideBottomRight', + 'outside', + ]) + .optional(), + distance: z.number().optional(), + rotate: z.number().optional(), + offset: z.array(z.number()).optional(), + formatter: z.string().optional(), // Only string formatters allowed, not functions + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + lineHeight: z.number().optional(), +}); + +// ============================================================================= +// Title Schema +// ============================================================================= + +export const titleSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + text: z.string().optional(), + link: z.string().optional(), + target: z.enum(['self', 'blank']).optional(), + textStyle: textStyleSchema.optional(), + subtext: z.string().optional(), + sublink: z.string().optional(), + subtarget: z.enum(['self', 'blank']).optional(), + subtextStyle: textStyleSchema.optional(), + textAlign: z.enum(['left', 'center', 'right']).optional(), + textVerticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), + triggerEvent: z.boolean().optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + itemGap: z.number().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), +}); + +// ============================================================================= +// Legend Schema +// ============================================================================= + +export const legendSchema = z.object({ + id: z.string().optional(), + type: z.enum(['plain', 'scroll']).optional(), + show: z.boolean().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + align: z.enum(['auto', 'left', 'right']).optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + itemGap: z.number().optional(), + itemWidth: z.number().optional(), + itemHeight: z.number().optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + textStyle: textStyleSchema.optional(), + icon: symbolTypeSchema.optional(), + selectedMode: z + .union([z.boolean(), z.enum(['single', 'multiple', 'series'])]) + .optional(), + inactiveColor: colorSchema.optional(), + inactiveBorderColor: colorSchema.optional(), + inactiveBorderWidth: z.number().optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + pageButtonItemGap: z.number().optional(), + pageButtonGap: z.number().optional(), + pageButtonPosition: z.enum(['start', 'end']).optional(), + pageIconColor: colorSchema.optional(), + pageIconInactiveColor: colorSchema.optional(), + pageIconSize: z.union([z.number(), z.array(z.number())]).optional(), + pageTextStyle: textStyleSchema.optional(), +}); + +// ============================================================================= +// Grid Schema +// ============================================================================= + +export const gridSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + containLabel: z.boolean().optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), +}); + +// ============================================================================= +// Axis Schemas +// ============================================================================= + +const axisLineSchema = z.object({ + show: z.boolean().optional(), + onZero: z.boolean().optional(), + onZeroAxisIndex: z.number().optional(), + symbol: z.union([z.string(), z.array(z.string())]).optional(), + symbolSize: z.array(z.number()).optional(), + symbolOffset: z.union([z.number(), z.array(z.number())]).optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const axisTickSchema = z.object({ + show: z.boolean().optional(), + alignWithLabel: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + inside: z.boolean().optional(), + length: z.number().optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const axisLabelSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + inside: z.boolean().optional(), + rotate: z.number().optional(), + margin: z.number().optional(), + formatter: z.string().optional(), // Only string formatters + showMinLabel: z.boolean().optional(), + showMaxLabel: z.boolean().optional(), + hideOverlap: z.boolean().optional(), + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + align: z.enum(['left', 'center', 'right']).optional(), + verticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), +}); + +const splitLineSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const splitAreaSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + areaStyle: areaStyleSchema.optional(), +}); + +export const axisSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + gridIndex: z.number().optional(), + alignTicks: z.boolean().optional(), + position: z.enum(['top', 'bottom', 'left', 'right']).optional(), + offset: z.number().optional(), + type: z.enum(['value', 'category', 'time', 'log']).optional(), + name: z.string().optional(), + nameLocation: z.enum(['start', 'middle', 'center', 'end']).optional(), + nameTextStyle: textStyleSchema.optional(), + nameGap: z.number().optional(), + nameRotate: z.number().optional(), + inverse: z.boolean().optional(), + boundaryGap: z + .union([z.boolean(), z.array(z.union([z.string(), z.number()]))]) + .optional(), + min: z.union([z.number(), z.string(), z.literal('dataMin')]).optional(), + max: z.union([z.number(), z.string(), z.literal('dataMax')]).optional(), + scale: z.boolean().optional(), + splitNumber: z.number().optional(), + minInterval: z.number().optional(), + maxInterval: z.number().optional(), + interval: z.number().optional(), + logBase: z.number().optional(), + silent: z.boolean().optional(), + triggerEvent: z.boolean().optional(), + axisLine: axisLineSchema.optional(), + axisTick: axisTickSchema.optional(), + minorTick: axisTickSchema.optional(), + axisLabel: axisLabelSchema.optional(), + splitLine: splitLineSchema.optional(), + minorSplitLine: splitLineSchema.optional(), + splitArea: splitAreaSchema.optional(), + zlevel: z.number().optional(), + z: z.number().optional(), +}); + +// ============================================================================= +// Tooltip Schema +// ============================================================================= + +export const tooltipSchema = z.object({ + show: z.boolean().optional(), + trigger: z.enum(['item', 'axis', 'none']).optional(), + triggerOn: z + .enum(['mousemove', 'click', 'mousemove|click', 'none']) + .optional(), + alwaysShowContent: z.boolean().optional(), + showDelay: z.number().optional(), + hideDelay: z.number().optional(), + enterable: z.boolean().optional(), + renderMode: z.enum(['html', 'richText']).optional(), + confine: z.boolean().optional(), + appendToBody: z.boolean().optional(), + transitionDuration: z.number().optional(), + position: z + .union([ + z.enum(['inside', 'top', 'left', 'right', 'bottom']), + z.array(z.union([z.number(), z.string()])), + ]) + .optional(), + formatter: z.string().optional(), // Only string formatters + padding: z.union([z.number(), z.array(z.number())]).optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + textStyle: textStyleSchema.optional(), + extraCssText: z.string().optional(), + order: z + .enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc']) + .optional(), +}); + +// ============================================================================= +// DataZoom Schema +// ============================================================================= + +export const dataZoomSchema = z.object({ + type: z.enum(['slider', 'inside']).optional(), + id: z.string().optional(), + show: z.boolean().optional(), + disabled: z.boolean().optional(), + xAxisIndex: z.union([z.number(), z.array(z.number())]).optional(), + yAxisIndex: z.union([z.number(), z.array(z.number())]).optional(), + filterMode: z.enum(['filter', 'weakFilter', 'empty', 'none']).optional(), + start: z.number().optional(), + end: z.number().optional(), + startValue: z.union([z.number(), z.string()]).optional(), + endValue: z.union([z.number(), z.string()]).optional(), + minSpan: z.number().optional(), + maxSpan: z.number().optional(), + minValueSpan: z.union([z.number(), z.string()]).optional(), + maxValueSpan: z.union([z.number(), z.string()]).optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + zoomLock: z.boolean().optional(), + throttle: z.number().optional(), + rangeMode: z.array(z.enum(['value', 'percent'])).optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderRadius: z.number().optional(), + fillerColor: colorSchema.optional(), + handleSize: numberOrPercentSchema.optional(), + handleStyle: itemStyleSchema.optional(), + moveHandleSize: z.number().optional(), + moveHandleStyle: itemStyleSchema.optional(), + labelPrecision: z.union([z.number(), z.literal('auto')]).optional(), + textStyle: textStyleSchema.optional(), + realtime: z.boolean().optional(), + showDetail: z.boolean().optional(), + showDataShadow: z.union([z.boolean(), z.literal('auto')]).optional(), + zoomOnMouseWheel: z + .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) + .optional(), + moveOnMouseMove: z + .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) + .optional(), + moveOnMouseWheel: z + .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) + .optional(), + preventDefaultMouseMove: z.boolean().optional(), +}); + +// ============================================================================= +// Toolbox Schema +// ============================================================================= + +export const toolboxSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + itemSize: z.number().optional(), + itemGap: z.number().optional(), + showTitle: z.boolean().optional(), + feature: z.record(z.string(), z.unknown()).optional(), + iconStyle: itemStyleSchema.optional(), + emphasis: z + .object({ + iconStyle: itemStyleSchema.optional(), + }) + .optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), +}); + +// ============================================================================= +// VisualMap Schema +// ============================================================================= + +export const visualMapSchema = z.object({ + type: z.enum(['continuous', 'piecewise']).optional(), + id: z.string().optional(), + min: z.number().optional(), + max: z.number().optional(), + range: z.array(z.number()).optional(), + calculable: z.boolean().optional(), + realtime: z.boolean().optional(), + inverse: z.boolean().optional(), + precision: z.number().optional(), + itemWidth: z.number().optional(), + itemHeight: z.number().optional(), + align: z.enum(['auto', 'left', 'right', 'top', 'bottom']).optional(), + text: z.array(z.string()).optional(), + textGap: z.number().optional(), + show: z.boolean().optional(), + dimension: z.union([z.number(), z.string()]).optional(), + seriesIndex: z.union([z.number(), z.array(z.number())]).optional(), + hoverLink: z.boolean().optional(), + inRange: z.record(z.string(), z.unknown()).optional(), + outOfRange: z.record(z.string(), z.unknown()).optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + color: z.array(colorSchema).optional(), + textStyle: textStyleSchema.optional(), + splitNumber: z.number().optional(), + pieces: z.array(z.record(z.string(), z.unknown())).optional(), + categories: z.array(z.string()).optional(), + minOpen: z.boolean().optional(), + maxOpen: z.boolean().optional(), + selectedMode: z + .union([z.boolean(), z.enum(['single', 'multiple'])]) + .optional(), + showLabel: z.boolean().optional(), + itemGap: z.number().optional(), + itemSymbol: symbolTypeSchema.optional(), +}); + +// ============================================================================= +// Series Schema +// ============================================================================= + +const emphasisSchema = z.object({ + disabled: z.boolean().optional(), + focus: z + .enum(['none', 'self', 'series', 'ancestor', 'descendant', 'relative']) + .optional(), + blurScope: z.enum(['coordinateSystem', 'series', 'global']).optional(), + scale: z.union([z.boolean(), z.number()]).optional(), + label: labelSchema.optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + areaStyle: areaStyleSchema.optional(), +}); + +const stateSchema = z.object({ + label: labelSchema.optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + areaStyle: areaStyleSchema.optional(), +}); + +export const seriesSchema = z.object({ + type: z.string().optional(), + id: z.string().optional(), + name: z.string().optional(), + colorBy: z.enum(['series', 'data']).optional(), + legendHoverLink: z.boolean().optional(), + coordinateSystem: z.string().optional(), + xAxisIndex: z.number().optional(), + yAxisIndex: z.number().optional(), + polarIndex: z.number().optional(), + geoIndex: z.number().optional(), + calendarIndex: z.number().optional(), + label: labelSchema.optional(), + labelLine: z + .object({ + show: z.boolean().optional(), + showAbove: z.boolean().optional(), + length: z.number().optional(), + length2: z.number().optional(), + smooth: z.union([z.boolean(), z.number()]).optional(), + minTurnAngle: z.number().optional(), + lineStyle: lineStyleSchema.optional(), + }) + .optional(), + labelLayout: z + .object({ + hideOverlap: z.boolean().optional(), + moveOverlap: z.enum(['shiftX', 'shiftY']).optional(), + }) + .optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + areaStyle: areaStyleSchema.optional(), + emphasis: emphasisSchema.optional(), + blur: stateSchema.optional(), + select: stateSchema.optional(), + selectedMode: z + .union([z.boolean(), z.enum(['single', 'multiple', 'series'])]) + .optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + silent: z.boolean().optional(), + cursor: z.string().optional(), + animation: z.boolean().optional(), + animationThreshold: z.number().optional(), + animationDuration: z.number().optional(), + animationEasing: z.string().optional(), + animationDelay: z.number().optional(), + animationDurationUpdate: z.number().optional(), + animationEasingUpdate: z.string().optional(), + animationDelayUpdate: z.number().optional(), +}); + +// ============================================================================= +// Graphic Schema +// ============================================================================= + +export const graphicElementSchema = z.object({ + type: z + .enum([ + 'group', + 'image', + 'text', + 'rect', + 'circle', + 'ring', + 'sector', + 'arc', + 'polygon', + 'polyline', + 'line', + 'bezierCurve', + ]) + .optional(), + id: z.string().optional(), + $action: z.enum(['merge', 'replace', 'remove']).optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + bounding: z.enum(['all', 'raw']).optional(), + z: z.number().optional(), + zlevel: z.number().optional(), + silent: z.boolean().optional(), + invisible: z.boolean().optional(), + cursor: z.string().optional(), + draggable: z + .union([z.boolean(), z.enum(['horizontal', 'vertical'])]) + .optional(), + progressive: z.boolean().optional(), + width: z.number().optional(), + height: z.number().optional(), + shape: z.record(z.string(), z.unknown()).optional(), + style: z.record(z.string(), z.unknown()).optional(), + rotation: z.number().optional(), + scaleX: z.number().optional(), + scaleY: z.number().optional(), + originX: z.number().optional(), + originY: z.number().optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), +}); + +// ============================================================================= +// AxisPointer Schema +// ============================================================================= + +export const axisPointerSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + type: z.enum(['line', 'shadow', 'none']).optional(), + axis: z.enum(['x', 'y']).optional(), + snap: z.boolean().optional(), Review Comment: **Suggestion:** The axisPointer axis enum only allows 'x' and 'y', so valid ECharts configurations using radial axes ('radius' or 'angle') will be treated as invalid and cause parseEChartOptions to throw a validation error. [logic error] <details> <summary><b>Severity Level:</b> Major ⚠️</summary> ```mdx - ⚠️ Polar/radial axisPointer configs rejected during validation. - ⚠️ Custom ECharts polar charts lose axisPointer settings. ``` </details> ```suggestion axis: z.enum(['x', 'y', 'radius', 'angle']).optional(), ``` <details> <summary><b>Steps of Reproduction ✅ </b></summary> ```mdx 1. Import the validator from `superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts`: `import { validateEChartOptions } from './echartOptionsSchema';`. 2. Call `validateEChartOptions` with options for a polar chart using a radial axis pointer: `validateEChartOptions({ axisPointer: { type: 'line', axis: 'radius' } });`. 3. The root schema `customEChartOptionsSchema` (lines 740–776) validates the `axisPointer` property using `axisPointerSchema` defined at line 673. 4. In `axisPointerSchema`, the `axis` field at line 677 is `z.enum(['x', 'y'])`, which rejects `'radius'` and `'angle'`, so validation fails and any higher-level usage (including `parseEChartOptionsStrict` at lines 818–824 or the custom options editor that relies on this validation) will treat the entire options object as invalid. ``` </details> <details> <summary><b>Prompt for AI Agent 🤖 </b></summary> ```mdx This is a comment left during a code review. **Path:** superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts **Line:** 677:677 **Comment:** *Logic Error: The axisPointer axis enum only allows 'x' and 'y', so valid ECharts configurations using radial axes ('radius' or 'angle') will be treated as invalid and cause parseEChartOptions to throw a validation error. Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise. ``` </details> <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=e0a5e87e105455fe410239dc0ac51e5c6856b573d2910eaa949c565845db7263&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=e0a5e87e105455fe410239dc0ac51e5c6856b573d2910eaa949c565845db7263&reaction=dislike'>👎</a> ########## superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts: ########## @@ -0,0 +1,827 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Unified ECharts Options Schema + * + * This file serves as the single source of truth for: + * 1. Runtime validation (Zod schema) + * 2. TypeScript types (inferred from Zod) + * + * Reference: https://echarts.apache.org/en/option.html + */ + +import { z } from 'zod'; + +// ============================================================================= +// Common Schemas +// ============================================================================= + +/** Color value - hex, rgb, rgba, or named color */ +const colorSchema = z.string(); + +/** Numeric or percentage string (e.g., '50%') */ +const numberOrPercentSchema = z.union([z.number(), z.string()]); + +/** Line type */ +const lineTypeSchema = z.union([ + z.enum(['solid', 'dashed', 'dotted']), + z.array(z.number()), +]); + +/** Font weight */ +const fontWeightSchema = z.union([ + z.enum(['normal', 'bold', 'bolder', 'lighter']), + z.number().min(100).max(900), +]); + +/** Font style */ +const fontStyleSchema = z.enum(['normal', 'italic', 'oblique']); + +/** Symbol type */ +const symbolTypeSchema = z.string(); + +// ============================================================================= +// Text Style Schema +// ============================================================================= + +export const textStyleSchema = z.object({ + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + lineHeight: z.number().optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + textBorderColor: colorSchema.optional(), + textBorderWidth: z.number().optional(), + textBorderType: lineTypeSchema.optional(), + textBorderDashOffset: z.number().optional(), + textShadowColor: colorSchema.optional(), + textShadowBlur: z.number().optional(), + textShadowOffsetX: z.number().optional(), + textShadowOffsetY: z.number().optional(), + overflow: z.enum(['none', 'truncate', 'break', 'breakAll']).optional(), + ellipsis: z.string().optional(), +}); + +// ============================================================================= +// Style Schemas +// ============================================================================= + +export const lineStyleSchema = z.object({ + color: colorSchema.optional(), + width: z.number().optional(), + type: lineTypeSchema.optional(), + dashOffset: z.number().optional(), + cap: z.enum(['butt', 'round', 'square']).optional(), + join: z.enum(['bevel', 'round', 'miter']).optional(), + miterLimit: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +export const areaStyleSchema = z.object({ + color: z.union([colorSchema, z.array(colorSchema)]).optional(), + origin: z.union([z.enum(['auto', 'start', 'end']), z.number()]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +export const itemStyleSchema = z.object({ + color: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderType: lineTypeSchema.optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +// ============================================================================= +// Label Schema +// ============================================================================= + +export const labelSchema = z.object({ + show: z.boolean().optional(), + position: z + .enum([ + 'top', + 'left', + 'right', + 'bottom', + 'inside', + 'insideLeft', + 'insideRight', + 'insideTop', + 'insideBottom', + 'insideTopLeft', + 'insideBottomLeft', + 'insideTopRight', + 'insideBottomRight', + 'outside', + ]) + .optional(), + distance: z.number().optional(), + rotate: z.number().optional(), + offset: z.array(z.number()).optional(), + formatter: z.string().optional(), // Only string formatters allowed, not functions + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + lineHeight: z.number().optional(), +}); + +// ============================================================================= +// Title Schema +// ============================================================================= + +export const titleSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + text: z.string().optional(), + link: z.string().optional(), + target: z.enum(['self', 'blank']).optional(), + textStyle: textStyleSchema.optional(), + subtext: z.string().optional(), + sublink: z.string().optional(), + subtarget: z.enum(['self', 'blank']).optional(), + subtextStyle: textStyleSchema.optional(), + textAlign: z.enum(['left', 'center', 'right']).optional(), + textVerticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), + triggerEvent: z.boolean().optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + itemGap: z.number().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), +}); + +// ============================================================================= +// Legend Schema +// ============================================================================= + +export const legendSchema = z.object({ + id: z.string().optional(), + type: z.enum(['plain', 'scroll']).optional(), + show: z.boolean().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + align: z.enum(['auto', 'left', 'right']).optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + itemGap: z.number().optional(), + itemWidth: z.number().optional(), + itemHeight: z.number().optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + textStyle: textStyleSchema.optional(), + icon: symbolTypeSchema.optional(), + selectedMode: z + .union([z.boolean(), z.enum(['single', 'multiple', 'series'])]) + .optional(), + inactiveColor: colorSchema.optional(), + inactiveBorderColor: colorSchema.optional(), + inactiveBorderWidth: z.number().optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.union([z.number(), z.array(z.number())]).optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + pageButtonItemGap: z.number().optional(), + pageButtonGap: z.number().optional(), + pageButtonPosition: z.enum(['start', 'end']).optional(), + pageIconColor: colorSchema.optional(), + pageIconInactiveColor: colorSchema.optional(), + pageIconSize: z.union([z.number(), z.array(z.number())]).optional(), + pageTextStyle: textStyleSchema.optional(), +}); + +// ============================================================================= +// Grid Schema +// ============================================================================= + +export const gridSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + containLabel: z.boolean().optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), +}); + +// ============================================================================= +// Axis Schemas +// ============================================================================= + +const axisLineSchema = z.object({ + show: z.boolean().optional(), + onZero: z.boolean().optional(), + onZeroAxisIndex: z.number().optional(), + symbol: z.union([z.string(), z.array(z.string())]).optional(), + symbolSize: z.array(z.number()).optional(), + symbolOffset: z.union([z.number(), z.array(z.number())]).optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const axisTickSchema = z.object({ + show: z.boolean().optional(), + alignWithLabel: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + inside: z.boolean().optional(), + length: z.number().optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const axisLabelSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + inside: z.boolean().optional(), + rotate: z.number().optional(), + margin: z.number().optional(), + formatter: z.string().optional(), // Only string formatters + showMinLabel: z.boolean().optional(), + showMaxLabel: z.boolean().optional(), + hideOverlap: z.boolean().optional(), + color: colorSchema.optional(), + fontStyle: fontStyleSchema.optional(), + fontWeight: fontWeightSchema.optional(), + fontFamily: z.string().optional(), + fontSize: z.number().optional(), + align: z.enum(['left', 'center', 'right']).optional(), + verticalAlign: z.enum(['top', 'middle', 'bottom']).optional(), +}); + +const splitLineSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + lineStyle: lineStyleSchema.optional(), +}); + +const splitAreaSchema = z.object({ + show: z.boolean().optional(), + interval: z.union([z.number(), z.literal('auto')]).optional(), + areaStyle: areaStyleSchema.optional(), +}); + +export const axisSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + gridIndex: z.number().optional(), + alignTicks: z.boolean().optional(), + position: z.enum(['top', 'bottom', 'left', 'right']).optional(), + offset: z.number().optional(), + type: z.enum(['value', 'category', 'time', 'log']).optional(), + name: z.string().optional(), + nameLocation: z.enum(['start', 'middle', 'center', 'end']).optional(), + nameTextStyle: textStyleSchema.optional(), + nameGap: z.number().optional(), + nameRotate: z.number().optional(), + inverse: z.boolean().optional(), + boundaryGap: z + .union([z.boolean(), z.array(z.union([z.string(), z.number()]))]) + .optional(), + min: z.union([z.number(), z.string(), z.literal('dataMin')]).optional(), + max: z.union([z.number(), z.string(), z.literal('dataMax')]).optional(), + scale: z.boolean().optional(), + splitNumber: z.number().optional(), + minInterval: z.number().optional(), + maxInterval: z.number().optional(), + interval: z.number().optional(), + logBase: z.number().optional(), + silent: z.boolean().optional(), + triggerEvent: z.boolean().optional(), + axisLine: axisLineSchema.optional(), + axisTick: axisTickSchema.optional(), + minorTick: axisTickSchema.optional(), + axisLabel: axisLabelSchema.optional(), + splitLine: splitLineSchema.optional(), + minorSplitLine: splitLineSchema.optional(), + splitArea: splitAreaSchema.optional(), + zlevel: z.number().optional(), + z: z.number().optional(), +}); + +// ============================================================================= +// Tooltip Schema +// ============================================================================= + +export const tooltipSchema = z.object({ + show: z.boolean().optional(), + trigger: z.enum(['item', 'axis', 'none']).optional(), + triggerOn: z + .enum(['mousemove', 'click', 'mousemove|click', 'none']) + .optional(), + alwaysShowContent: z.boolean().optional(), + showDelay: z.number().optional(), + hideDelay: z.number().optional(), + enterable: z.boolean().optional(), + renderMode: z.enum(['html', 'richText']).optional(), + confine: z.boolean().optional(), + appendToBody: z.boolean().optional(), + transitionDuration: z.number().optional(), + position: z + .union([ + z.enum(['inside', 'top', 'left', 'right', 'bottom']), + z.array(z.union([z.number(), z.string()])), + ]) + .optional(), + formatter: z.string().optional(), // Only string formatters + padding: z.union([z.number(), z.array(z.number())]).optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + borderRadius: z.number().optional(), + shadowBlur: z.number().optional(), + shadowColor: colorSchema.optional(), + shadowOffsetX: z.number().optional(), + shadowOffsetY: z.number().optional(), + textStyle: textStyleSchema.optional(), + extraCssText: z.string().optional(), + order: z + .enum(['seriesAsc', 'seriesDesc', 'valueAsc', 'valueDesc']) + .optional(), +}); + +// ============================================================================= +// DataZoom Schema +// ============================================================================= + +export const dataZoomSchema = z.object({ + type: z.enum(['slider', 'inside']).optional(), + id: z.string().optional(), + show: z.boolean().optional(), + disabled: z.boolean().optional(), + xAxisIndex: z.union([z.number(), z.array(z.number())]).optional(), + yAxisIndex: z.union([z.number(), z.array(z.number())]).optional(), + filterMode: z.enum(['filter', 'weakFilter', 'empty', 'none']).optional(), + start: z.number().optional(), + end: z.number().optional(), + startValue: z.union([z.number(), z.string()]).optional(), + endValue: z.union([z.number(), z.string()]).optional(), + minSpan: z.number().optional(), + maxSpan: z.number().optional(), + minValueSpan: z.union([z.number(), z.string()]).optional(), + maxValueSpan: z.union([z.number(), z.string()]).optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + zoomLock: z.boolean().optional(), + throttle: z.number().optional(), + rangeMode: z.array(z.enum(['value', 'percent'])).optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderRadius: z.number().optional(), + fillerColor: colorSchema.optional(), + handleSize: numberOrPercentSchema.optional(), + handleStyle: itemStyleSchema.optional(), + moveHandleSize: z.number().optional(), + moveHandleStyle: itemStyleSchema.optional(), + labelPrecision: z.union([z.number(), z.literal('auto')]).optional(), + textStyle: textStyleSchema.optional(), + realtime: z.boolean().optional(), + showDetail: z.boolean().optional(), + showDataShadow: z.union([z.boolean(), z.literal('auto')]).optional(), + zoomOnMouseWheel: z + .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) + .optional(), + moveOnMouseMove: z + .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) + .optional(), + moveOnMouseWheel: z + .union([z.boolean(), z.enum(['shift', 'ctrl', 'alt'])]) + .optional(), + preventDefaultMouseMove: z.boolean().optional(), +}); + +// ============================================================================= +// Toolbox Schema +// ============================================================================= + +export const toolboxSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + itemSize: z.number().optional(), + itemGap: z.number().optional(), + showTitle: z.boolean().optional(), + feature: z.record(z.string(), z.unknown()).optional(), + iconStyle: itemStyleSchema.optional(), + emphasis: z + .object({ + iconStyle: itemStyleSchema.optional(), + }) + .optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + width: numberOrPercentSchema.optional(), + height: numberOrPercentSchema.optional(), +}); + +// ============================================================================= +// VisualMap Schema +// ============================================================================= + +export const visualMapSchema = z.object({ + type: z.enum(['continuous', 'piecewise']).optional(), + id: z.string().optional(), + min: z.number().optional(), + max: z.number().optional(), + range: z.array(z.number()).optional(), + calculable: z.boolean().optional(), + realtime: z.boolean().optional(), + inverse: z.boolean().optional(), + precision: z.number().optional(), + itemWidth: z.number().optional(), + itemHeight: z.number().optional(), + align: z.enum(['auto', 'left', 'right', 'top', 'bottom']).optional(), + text: z.array(z.string()).optional(), + textGap: z.number().optional(), + show: z.boolean().optional(), + dimension: z.union([z.number(), z.string()]).optional(), + seriesIndex: z.union([z.number(), z.array(z.number())]).optional(), + hoverLink: z.boolean().optional(), + inRange: z.record(z.string(), z.unknown()).optional(), + outOfRange: z.record(z.string(), z.unknown()).optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + orient: z.enum(['horizontal', 'vertical']).optional(), + padding: z.union([z.number(), z.array(z.number())]).optional(), + backgroundColor: colorSchema.optional(), + borderColor: colorSchema.optional(), + borderWidth: z.number().optional(), + color: z.array(colorSchema).optional(), + textStyle: textStyleSchema.optional(), + splitNumber: z.number().optional(), + pieces: z.array(z.record(z.string(), z.unknown())).optional(), + categories: z.array(z.string()).optional(), + minOpen: z.boolean().optional(), + maxOpen: z.boolean().optional(), + selectedMode: z + .union([z.boolean(), z.enum(['single', 'multiple'])]) + .optional(), + showLabel: z.boolean().optional(), + itemGap: z.number().optional(), + itemSymbol: symbolTypeSchema.optional(), +}); + +// ============================================================================= +// Series Schema +// ============================================================================= + +const emphasisSchema = z.object({ + disabled: z.boolean().optional(), + focus: z + .enum(['none', 'self', 'series', 'ancestor', 'descendant', 'relative']) + .optional(), + blurScope: z.enum(['coordinateSystem', 'series', 'global']).optional(), + scale: z.union([z.boolean(), z.number()]).optional(), + label: labelSchema.optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + areaStyle: areaStyleSchema.optional(), +}); + +const stateSchema = z.object({ + label: labelSchema.optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + areaStyle: areaStyleSchema.optional(), +}); + +export const seriesSchema = z.object({ + type: z.string().optional(), + id: z.string().optional(), + name: z.string().optional(), + colorBy: z.enum(['series', 'data']).optional(), + legendHoverLink: z.boolean().optional(), + coordinateSystem: z.string().optional(), + xAxisIndex: z.number().optional(), + yAxisIndex: z.number().optional(), + polarIndex: z.number().optional(), + geoIndex: z.number().optional(), + calendarIndex: z.number().optional(), + label: labelSchema.optional(), + labelLine: z + .object({ + show: z.boolean().optional(), + showAbove: z.boolean().optional(), + length: z.number().optional(), + length2: z.number().optional(), + smooth: z.union([z.boolean(), z.number()]).optional(), + minTurnAngle: z.number().optional(), + lineStyle: lineStyleSchema.optional(), + }) + .optional(), + labelLayout: z + .object({ + hideOverlap: z.boolean().optional(), + moveOverlap: z.enum(['shiftX', 'shiftY']).optional(), + }) + .optional(), + itemStyle: itemStyleSchema.optional(), + lineStyle: lineStyleSchema.optional(), + areaStyle: areaStyleSchema.optional(), + emphasis: emphasisSchema.optional(), + blur: stateSchema.optional(), + select: stateSchema.optional(), + selectedMode: z + .union([z.boolean(), z.enum(['single', 'multiple', 'series'])]) + .optional(), + zlevel: z.number().optional(), + z: z.number().optional(), + silent: z.boolean().optional(), + cursor: z.string().optional(), + animation: z.boolean().optional(), + animationThreshold: z.number().optional(), + animationDuration: z.number().optional(), + animationEasing: z.string().optional(), + animationDelay: z.number().optional(), + animationDurationUpdate: z.number().optional(), + animationEasingUpdate: z.string().optional(), + animationDelayUpdate: z.number().optional(), +}); + +// ============================================================================= +// Graphic Schema +// ============================================================================= + +export const graphicElementSchema = z.object({ + type: z + .enum([ + 'group', + 'image', + 'text', + 'rect', + 'circle', + 'ring', + 'sector', + 'arc', + 'polygon', + 'polyline', + 'line', + 'bezierCurve', + ]) + .optional(), + id: z.string().optional(), + $action: z.enum(['merge', 'replace', 'remove']).optional(), + left: numberOrPercentSchema.optional(), + top: numberOrPercentSchema.optional(), + right: numberOrPercentSchema.optional(), + bottom: numberOrPercentSchema.optional(), + bounding: z.enum(['all', 'raw']).optional(), + z: z.number().optional(), + zlevel: z.number().optional(), + silent: z.boolean().optional(), + invisible: z.boolean().optional(), + cursor: z.string().optional(), + draggable: z + .union([z.boolean(), z.enum(['horizontal', 'vertical'])]) + .optional(), + progressive: z.boolean().optional(), + width: z.number().optional(), + height: z.number().optional(), + shape: z.record(z.string(), z.unknown()).optional(), + style: z.record(z.string(), z.unknown()).optional(), + rotation: z.number().optional(), + scaleX: z.number().optional(), + scaleY: z.number().optional(), + originX: z.number().optional(), + originY: z.number().optional(), + children: z.array(z.record(z.string(), z.unknown())).optional(), +}); + +// ============================================================================= +// AxisPointer Schema +// ============================================================================= + +export const axisPointerSchema = z.object({ + id: z.string().optional(), + show: z.boolean().optional(), + type: z.enum(['line', 'shadow', 'none']).optional(), + axis: z.enum(['x', 'y']).optional(), Review Comment: **Suggestion:** The schema for axis pointer type omits the valid 'cross' value used by ECharts, so any custom options specifying 'cross' will fail validation and be rejected by the parser, breaking legitimate configurations. [logic error] <details> <summary><b>Severity Level:</b> Major ⚠️</summary> ```mdx - ⚠️ Valid ECharts crosshair axisPointer rejected by schema. - ⚠️ Custom axisPointer configs lost in parseEChartOptionsStrict. ``` </details> ```suggestion type: z.enum(['line', 'shadow', 'cross', 'none']).optional(), ``` <details> <summary><b>Steps of Reproduction ✅ </b></summary> ```mdx 1. Import the validator from `superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts`: `import { validateEChartOptions } from './echartOptionsSchema';`. 2. Call `validateEChartOptions` with an options object that uses a crosshair axis pointer: `validateEChartOptions({ axisPointer: { type: 'cross' } });`. 3. The root `customEChartOptionsSchema` (defined around lines 740–776) includes `axisPointer: axisPointerSchema.optional()`, so validation of `axisPointer` is delegated to `axisPointerSchema` starting at line 673. 4. Inside `axisPointerSchema` at line 676, `type` is constrained to `z.enum(['line', 'shadow', 'none'])`, so the call returns `{ success: false, error: ... }` and any wrapper such as `parseEChartOptionsStrict` (lines 818–824) will drop the configuration and return `{}` instead of preserving the valid `type: 'cross'` setting. ``` </details> <details> <summary><b>Prompt for AI Agent 🤖 </b></summary> ```mdx This is a comment left during a code review. **Path:** superset-frontend/packages/superset-ui-chart-controls/src/utils/echartOptionsSchema.ts **Line:** 676:676 **Comment:** *Logic Error: The schema for axis pointer type omits the valid 'cross' value used by ECharts, so any custom options specifying 'cross' will fail validation and be rejected by the parser, breaking legitimate configurations. Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise. ``` </details> <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=440941fdf137fdbb26241492907e3cdadb46487c3b6d23e97dec1aee330d1485&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=440941fdf137fdbb26241492907e3cdadb46487c3b6d23e97dec1aee330d1485&reaction=dislike'>👎</a> ########## superset-frontend/packages/superset-ui-chart-controls/src/utils/safeEchartOptionsParser.ts: ########## @@ -0,0 +1,477 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { parse } from 'acorn'; +import type { Node } from 'acorn'; +import type { z } from 'zod'; +import { + customEChartOptionsSchema, + titleSchema, + legendSchema, + gridSchema, + axisSchema, + tooltipSchema, + dataZoomSchema, + toolboxSchema, + visualMapSchema, + seriesSchema, + graphicElementSchema, + axisPointerSchema, + textStyleSchema, + type CustomEChartOptions, +} from './echartOptionsSchema'; + +// ============================================================================= +// Custom Error Class +// ============================================================================= + +/** + * Custom error class for EChart options parsing errors + */ +export class EChartOptionsParseError extends Error { + public readonly errorType: + | 'parse_error' + | 'security_error' + | 'validation_error'; + + public readonly validationErrors: string[]; + + public readonly location?: { line: number; column: number }; + + constructor( + message: string, + errorType: + | 'parse_error' + | 'security_error' + | 'validation_error' = 'parse_error', + validationErrors: string[] = [], + location?: { line: number; column: number }, + ) { + super(message); + this.name = 'EChartOptionsParseError'; + this.errorType = errorType; + this.validationErrors = validationErrors; + this.location = location; + } +} + +// ============================================================================= +// Partial Validation Helper +// ============================================================================= + +/** + * Maps top-level property names to their Zod schemas for partial validation + */ +const propertySchemas: Record<string, z.ZodTypeAny> = { + title: titleSchema, + legend: legendSchema, + grid: gridSchema, + xAxis: axisSchema, + yAxis: axisSchema, + tooltip: tooltipSchema, + dataZoom: dataZoomSchema, + toolbox: toolboxSchema, + visualMap: visualMapSchema, + series: seriesSchema, + graphic: graphicElementSchema, + axisPointer: axisPointerSchema, + textStyle: textStyleSchema, +}; + +/** + * Validates each property individually and returns only valid properties. + * This allows partial validation where invalid properties are filtered out + * while valid ones are kept. Throws an error if any validation issues are found. + */ +function validatePartial(data: Record<string, unknown>): CustomEChartOptions { + const result: Record<string, unknown> = {}; + const validationErrors: string[] = []; + + for (const [key, value] of Object.entries(data)) { + if (value === undefined) continue; + + const schema = propertySchemas[key]; + + if (schema) { + // For properties with known schemas, validate them + if (Array.isArray(value)) { + // Validate array items individually + const validItems = value + .map((item, index) => { + const itemResult = schema.safeParse(item); + if (itemResult.success) { + return itemResult.data; + } + validationErrors.push( + `Invalid array item in "${key}[${index}]": ${itemResult.error.issues.map(e => e.message).join(', ')}`, + ); + return null; + }) + .filter(item => item !== null); + + if (validItems.length > 0) { + result[key] = validItems; + } + } else { + // Validate single object + const propResult = schema.safeParse(value); + if (propResult.success) { + result[key] = propResult.data; + } else { + validationErrors.push( + `Invalid property "${key}": ${propResult.error.issues.map(e => e.message).join(', ')}`, + ); + } + } + } else { + // For primitive properties (animation, backgroundColor, etc.), validate with full schema + const primitiveResult = + customEChartOptionsSchema.shape[ + key as keyof typeof customEChartOptionsSchema.shape + ]?.safeParse(value); + + if (primitiveResult?.success) { + result[key] = primitiveResult.data; + } else if (primitiveResult) { + validationErrors.push( + `Invalid property "${key}": ${primitiveResult.error?.issues.map(e => e.message).join(', ') ?? 'Invalid value'}`, + ); + } + // Unknown properties are silently ignored + } + } + + if (validationErrors.length > 0) { + throw new EChartOptionsParseError( + 'EChart options validation failed', + 'validation_error', + validationErrors, + ); + } + + return result as CustomEChartOptions; +} + +// ============================================================================= +// AST Safety Validation +// ============================================================================= + +/** + * Safe AST node types that are allowed in EChart options. + * These represent static data structures without executable code. + */ +const SAFE_NODE_TYPES = new Set([ + 'ObjectExpression', + 'ArrayExpression', + 'Literal', + 'Property', + 'Identifier', + 'UnaryExpression', + 'TemplateLiteral', + 'TemplateElement', +]); + +const ALLOWED_UNARY_OPERATORS = new Set(['-', '+']); + +const DANGEROUS_IDENTIFIERS = new Set([ + 'eval', + 'Function', + 'constructor', + 'prototype', + '__proto__', + 'window', + 'document', + 'globalThis', + 'process', + 'require', + 'import', + 'module', + 'exports', +]); + +/** + * Recursively validates that an AST node contains only safe constructs. + * Throws an error if any unsafe patterns are detected. + */ +function validateNode(node: Node, path: string[] = []): void { + if (!node || typeof node !== 'object') { + return; + } + + const nodeType = node.type; + + if (!SAFE_NODE_TYPES.has(nodeType)) { + throw new Error( + `Unsafe node type "${nodeType}" at path: ${path.join('.')}. ` + + `Only static data structures are allowed.`, + ); + } + + switch (nodeType) { + case 'Identifier': { + const identNode = node as Node & { name: string }; + if (DANGEROUS_IDENTIFIERS.has(identNode.name)) { + throw new Error( + `Dangerous identifier "${identNode.name}" detected at path: ${path.join('.')}`, + ); + } + break; + } + + case 'UnaryExpression': { + const unaryNode = node as Node & { operator: string; argument: Node }; + if (!ALLOWED_UNARY_OPERATORS.has(unaryNode.operator)) { + throw new Error( + `Unsafe unary operator "${unaryNode.operator}" at path: ${path.join('.')}`, + ); + } + validateNode(unaryNode.argument, [...path, 'argument']); + break; + } + + case 'ObjectExpression': { + const objNode = node as Node & { properties: Node[] }; + objNode.properties.forEach((prop, index) => { + validateNode(prop, [...path, `property[${index}]`]); + }); + break; + } + + case 'ArrayExpression': { + const arrNode = node as Node & { elements: (Node | null)[] }; + arrNode.elements.forEach((elem, index) => { + if (elem) { + validateNode(elem, [...path, `element[${index}]`]); + } + }); + break; + } + + case 'Property': { + const propNode = node as Node & { + key: Node; + value: Node; + computed: boolean; + }; + if (propNode.computed) { + throw new Error( + `Computed properties are not allowed at path: ${path.join('.')}`, + ); + } + validateNode(propNode.key, [...path, 'key']); + validateNode(propNode.value, [...path, 'value']); + break; + } + + case 'TemplateLiteral': { + const templateNode = node as Node & { + expressions: Node[]; + quasis: Node[]; + }; + if (templateNode.expressions.length > 0) { + throw new Error( + `Template literals with expressions are not allowed at path: ${path.join('.')}`, + ); + } + templateNode.quasis.forEach((quasi, index) => { + validateNode(quasi, [...path, `quasi[${index}]`]); + }); + break; + } + + case 'Literal': + case 'TemplateElement': + break; + + default: + throw new Error(`Unhandled node type: ${nodeType}`); + } +} + +/** + * Converts a validated AST node to a JavaScript value. + */ +function astToValue(node: Node): unknown { + switch (node.type) { + case 'Literal': { + const litNode = node as Node & { value: unknown }; + return litNode.value; + } + + case 'UnaryExpression': { + const unaryNode = node as Node & { operator: string; argument: Node }; + const argValue = astToValue(unaryNode.argument) as number; + return unaryNode.operator === '-' ? -argValue : +argValue; + } + + case 'Identifier': { + const identNode = node as Node & { name: string }; + if (identNode.name === 'undefined') return undefined; + if (identNode.name === 'null') return null; + if (identNode.name === 'true') return true; + if (identNode.name === 'false') return false; + if (identNode.name === 'NaN') return NaN; + if (identNode.name === 'Infinity') return Infinity; + return identNode.name; + } + + case 'ObjectExpression': { + const objNode = node as Node & { properties: Node[] }; + const objResult: Record<string, unknown> = {}; + objNode.properties.forEach(prop => { + const propNode = prop as Node & { key: Node; value: Node }; Review Comment: **Suggestion:** The AST security checks only block dangerous names when they appear as identifiers, but not when they are used as string/literal object keys, so a user can still supply keys like "__proto__", "constructor", or "prototype" in object literals; this bypasses the intended guard and can enable prototype-pollution-style attacks when these parsed options are later merged or consumed by other libraries. You should reject such dangerous property names during AST-to-value conversion so that both identifier and key-based uses are consistently blocked and produce a structured security error. [security] <details> <summary><b>Severity Level:</b> Critical 🚨</summary> ```mdx - ❌ Parser accepts "__proto__"/constructor/prototype as string keys. - ⚠️ Enables prototype-pollution risk when merging parsed options. - ⚠️ Undermines secure parsing guarantees advertised in this PR. ``` </details> ```suggestion const rawKey = astToValue(propNode.key); const key = String(rawKey); if (DANGEROUS_IDENTIFIERS.has(key)) { throw new EChartOptionsParseError( `Dangerous property name "${key}" is not allowed in EChart options`, 'security_error', ); } ``` <details> <summary><b>Steps of Reproduction ✅ </b></summary> ```mdx 1. In the frontend code, call `safeParseEChartOptions` exported from `superset-frontend/packages/superset-ui-chart-controls/src/utils/safeEchartOptionsParser.ts:470` with a string containing a dangerous key, for example: `safeParseEChartOptions('{ \"series\": [], \"__proto__\": { \"polluted\": true } }');`. 2. Inside `safeParseEChartOptions`, the call is delegated to `parseEChartOptions` at `safeEchartOptionsParser.ts:400`, which parses the wrapped input into an AST and then calls `validateNode(expression)` (lines 413–450). The `__proto__` key is represented as a `Literal` node inside a `Property`, not an `Identifier`, so the `DANGEROUS_IDENTIFIERS` check in `validateNode`'s `'Identifier'` case at `safeEchartOptionsParser.ts:227–235` is never triggered. 3. After validation, `parseEChartOptions` converts the AST back to a JavaScript value via `astToValue(expression)` defined at `safeEchartOptionsParser.ts:310–360`. For the outer object, the `'ObjectExpression'` case at lines 333–343 runs: `astToValue(propNode.key)` returns the string `"__proto__"` for the `Literal` key, and the current implementation assigns `objResult[key] = value` without any additional security checks on `key`. 4. `parseEChartOptions` returns `{ success: true, data: { series: [], __proto__: { polluted: true } } }` to `safeParseEChartOptions`, which forwards `result.data` to its caller (line 473). Any downstream code that merges this parsed options object into other objects (e.g., using object spread or `Object.assign`) can have its prototypes influenced by the attacker-controlled `"__proto__"` / `"constructor"` / `"prototype"` keys, bypassing the intended guard that only checked `DANGEROUS_IDENTIFIERS` when they appear as `Identifier` nodes. ``` </details> <details> <summary><b>Prompt for AI Agent 🤖 </b></summary> ```mdx This is a comment left during a code review. **Path:** superset-frontend/packages/superset-ui-chart-controls/src/utils/safeEchartOptionsParser.ts **Line:** 338:338 **Comment:** *Security: The AST security checks only block dangerous names when they appear as identifiers, but not when they are used as string/literal object keys, so a user can still supply keys like "__proto__", "constructor", or "prototype" in object literals; this bypasses the intended guard and can enable prototype-pollution-style attacks when these parsed options are later merged or consumed by other libraries. You should reject such dangerous property names during AST-to-value conversion so that both identifier and key-based uses are consistently blocked and produce a structured security error. Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise. ``` </details> <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=298d6f23bfe7b713b6ad0aa2828bf15767b47a25e8cc3bf1281e8fac67fe2106&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37868&comment_hash=298d6f23bfe7b713b6ad0aa2828bf15767b47a25e8cc3bf1281e8fac67fe2106&reaction=dislike'>👎</a> -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
