You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by "EnxDev (via GitHub)" <gi...@apache.org> on 2026/08/27 14:37:50 UTC
[PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
EnxDev opened a new pull request, #43588:
URL: https://github.com/apache/superset/pull/43588
<!---
Please write the PR title following the conventions at https://www.conventionalcommits.org/en/v1.0.0/
Example:
fix(dashboard): load charts correctly
-->
### SUMMARY
<!--- Describe the change below, including rationale and design decisions -->
This PR builds on the existing Dashboard V2 prototype and expands its widget editing flow.
It adds:
- Searchable dataset selection for data-backed widgets
- Dataset-aware column and metric pickers with keyboard-accessible reordering
- Support for creating and editing simple and custom SQL ad hoc metrics
- Raw JSON fallbacks when values cannot be represented by the structured controls
- Structured ECharts controls for:
- Bar, line, and scatter chart types
- Per-series visibility, colors, and display names
- Chart title, legend, tooltip, and axis configuration
- Backend validation before widget properties are committed
- Reusable composite controls, including the shared metric control
- Dependency-ordered schema enrichment with cycle detection
- An MCP tool for applying validated widget control values
- Editing UI refinements, including a wider Properties panel, consistent form spacing, widget hover states, and a chart-style overflow menu
The Inspector no longer duplicates placement fields or the Delete widget action. Placement remains canvas-driven, while widgets can still be removed from their headers.
### BEFORE/AFTER SCREENSHOTS OR ANIMATED GIF
<!--- Skip this if not applicable -->
<img width="2395" height="1189" alt="Screenshot 2026-08-27 162636" src="https://github.com/user-attachments/assets/b4a1e4ec-8d4c-4485-ab0d-49e5b4be6e89" />
<img width="2401" height="1203" alt="Screenshot 2026-08-27 162736" src="https://github.com/user-attachments/assets/8873561b-d300-4d90-8f97-c69948ec368a" />
<img width="1600" height="1000" alt="distill-expanded" src="https://github.com/user-attachments/assets/4ee1116e-9ee9-456e-8ddb-8051f3ee57d2" />
<img width="1600" height="1000" alt="distill-scrolled" src="https://github.com/user-attachments/assets/bdcf92e6-5933-4e90-b8dd-2f65a8d1cd78" />
<img width="1600" height="1000" alt="distill-two-rows" src="https://github.com/user-attachments/assets/c804ba50-1e97-454b-9a19-6ff9ab85c146" />
<img width="1600" height="1000" alt="assessmentA-08-96-series" src="https://github.com/user-attachments/assets/95af5a24-dd46-40b5-adcd-43c6ecc0b63a" />
<img width="1600" height="1000" alt="assessmentA-09-per-series-entries" src="https://github.com/user-attachments/assets/526b8359-8fd3-48fa-887b-5c0a6d3436dc" />
<img width="1600" height="1000" alt="assessmentA-10-json-tab" src="https://github.com/user-attachments/assets/f607ec65-5636-4b5f-ae45-63fa0e99b448" />
<img width="1600" height="1000" alt="assessmentA-12-nothing-selected" src="https://github.com/user-attachments/assets/a2ef88e4-1e1e-410e-b0b9-1179433df162" />
<img width="1600" height="1000" alt="assessment-b-bound" src="https://github.com/user-attachments/assets/c52279bc-e2d8-4ac6-8433-87e4c512d128" />
<img width="1600" height="1000" alt="audit-reorder" src="https://github.com/user-attachments/assets/dd4c5e60-4234-46bb-83ac-3ef277c8ee5b" />
<img width="1600" height="1000" alt="clarify-tooltip" src="https://github.com/user-attachments/assets/04ce7eba-51d3-45b1-8b48-999e0138dd04" />
<img width="1600" height="1000" alt="distill-after" src="https://github.com/user-attachments/assets/2ff4d7e4-72f0-4e19-a7a5-42d299e5bf4e" />
<img width="1600" height="1000" alt="distill-colorpicker" src="https://github.com/user-attachments/assets/23270a9a-d2a6-4ee7-804f-dd596623e704" />
### TESTING INSTRUCTIONS
<!--- Required! What steps can be taken to manually verify the changes? -->
1. Open `/dashboard/v2/new/`.
2. Add an ECharts, Balloons, Table, or Metric Tile widget.
3. Select the widget and open the **Properties** tab.
4. Select a dataset and verify that its columns and saved metrics are available.
5. Add, remove, and reorder dimensions and metrics.
6. Create and edit both a simple and a custom SQL ad hoc metric.
7. Verify that keyboard controls can reorder column and metric entries.
8. Configure an ECharts widget with:
- Bar, line, and scatter chart types
- Per-series colors, visibility, and display names
- Title, legend, tooltip, and axis settings
9. Confirm that structured settings update the rendered chart without overwriting unrelated raw ECharts options.
10. Enter an invalid control combination and verify that it is rejected without changing the widget.
11. Verify that the Inspector no longer shows Placement or Delete sections.
12. Verify that widgets can still be removed using the header bin and that the overflow menu opens.
### ADDITIONAL INFORMATION
<!--- Check any relevant boxes with "x" -->
<!--- HINT: Include "Fixes #nnn" if you are fixing an existing issue -->
- [ ] Has associated issue:
- [ ] Required feature flags:
- [ ] Changes UI
- [ ] Includes DB Migration (follow approval process in [SIP-59](https://github.com/apache/superset/issues/13351))
- [ ] Migration is atomic, supports rollback & is backwards-compatible
- [ ] Confirm DB migration upgrade and downgrade tested
- [ ] Runtime estimates and downtime expectations provided
- [ ] Introduces new feature or API
- [ ] Removes existing feature or API
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912451894
##########
superset-core/src/superset_core/semantic_layers/config.py:
##########
@@ -17,11 +17,64 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, get_args, get_origin, Iterator
from pydantic import BaseModel
+def _iter_nested_models(
+ annotation: Any, seen: set[type[BaseModel]]
+) -> Iterator[type[BaseModel]]:
+ """Yield every ``BaseModel`` subclass reachable from ``annotation``
+ (through generics like ``list[...]``/``... | None``, and recursively
+ through each found model's own fields), each at most once."""
+ origin = get_origin(annotation)
+ if origin is not None:
+ for arg in get_args(annotation):
+ yield from _iter_nested_models(arg, seen)
+ return
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
+ model_cls: type[BaseModel] = annotation
+ if model_cls not in seen:
+ seen.add(model_cls)
+ yield model_cls
+ for field in model_cls.model_fields.values():
+ yield from _iter_nested_models(field.annotation, seen)
+
+
+def _resolve_field_order(
+ model_cls: type[BaseModel], schema_node: dict[str, Any]
+) -> list[str]:
+ """The order ``schema_node["properties"]`` should render in: an explicit
+ ``field_order: ClassVar[list[str]]`` on ``model_cls`` when declared
+ (validated as an exact permutation of its own properties), else the
+ model's field declaration order (by alias)."""
+ declared_order = getattr(model_cls, "field_order", None)
+ if declared_order is None:
+ return [field.alias or name for name, field in model_cls.model_fields.items()]
+ declared = set(declared_order)
+ if declared != (actual := set(schema_node.get("properties", {}))):
Review Comment:
Fixed in b1b5f3f539 — `_resolve_field_order` now also compares `len(declared_order)` against the deduplicated set's length, so a repeated entry is rejected even when the set of names is otherwise an exact match.
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +164,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
value={value}
onChange={event => handleChange(path, event.target.value)}
/>
- <Typography.Text>{label}</Typography.Text>
- </Flex>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
);
}
-/** Base Semantic-Layer renderers plus the widget-control code/color ones. */
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ key={`${availableSaved.length}-${values.length}`}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={addOptions}
+ disabled={disabled}
+ onChange={next => {
+ if (next === ADD_CUSTOM_METRIC) {
+ setAddingNew(true);
+ } else {
+ onChange([...values, next as string]);
+ }
+ }}
+ />
+ )}
+ {addingNew && (
+ <AdhocMetricEditor
+ value={undefined}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={addingNew}
+ onOpenChange={setAddingNew}
+ onSave={next => {
+ onChange([...values, next]);
+ setAddingNew(false);
+ }}
+ >
+ {/* Zero-size trigger: opening this popover is driven entirely
+ by picking "Custom metric…" above, not by a click here. */}
+ <span />
+ </AdhocMetricEditor>
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "metric-multi"` — an ordered list of metric references. Falls
+ * back to the raw JSON editor (`CodeControl`) whenever no dataset is bound
+ * (or its fetch failed), or an entry is genuinely unrepresentable (see
+ * `isUnrepresentableMetric`); a well-formed mix of saved and ad-hoc metrics
+ * renders as `MetricEntryList`, not raw JSON.
+ */
+function MetricMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasUnrepresentable = values.some(isUnrepresentableMetric);
+
+ if (shouldFallBackToCode(datasetId, error) || hasUnrepresentable) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <MetricEntryList
+ label={props.label}
+ description={props.description}
+ values={values as MetricEntry[]}
+ metadata={metadata}
+ columns={metadata?.columns ?? []}
+ datasourceId={datasetId}
+ datasourceType={metadata?.datasourceType}
+ disallowAdhoc={disallowsAdhocMetrics(metadata)}
+ disabled={!props.enabled || loading}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * The value the dataset picker needs for an already-bound dataset. Bare —
+ * just the id (composite-encoded per below) — while the name is still
+ * resolving: `AsyncSelect` prefers a *labeled* value's own label over a
+ * matching loaded option's, so handing it a stale `String(datasetId)` label
+ * here would overwrite the real name the moment the matching option
+ * arrives, replaying the exact stuck-on-the-numeric-id symptom this control
+ * exists to avoid — instead, letting `AsyncSelect` fall back to the id on
+ * its own leaves it free to prefer a loaded option's label the instant one
+ * matches. Once `tableName` resolves, the explicit label takes over.
+ *
+ * `value` carries the composite `"ds:<id>"` encoding when the Semantic
+ * Layers flag is on, matching what `loadDatasetOptions` encodes its own
+ * options as in that mode (see `resolveDatasetPick` below) — an option and
+ * a bound value in two different encodings never match, which is what
+ * leaves a genuinely-selected dataset showing as unselected.
+ */
+export function toDatasetSelectValue(
+ datasetId: number | undefined,
+ tableName: string | undefined,
+ useSemanticLayers: boolean,
+): { label: string; value: number | string } | number | string | undefined {
+ if (datasetId === undefined) {
+ return undefined;
+ }
+ const value = useSemanticLayers ? toCompositeValue(datasetId) : datasetId;
+ return tableName ? { label: tableName, value } : value;
+}
+
+/**
+ * The numeric dataset id a picked option resolves to, or `undefined` when
+ * the pick should be rejected outright: a semantic view, which
+ * `DataBinding.datasetId` has no way to represent (SIP-182's `kind` is a
+ * connection-level concept, not a per-field one on this schema). Handles
+ * both encodings `loadDatasetOptions` can produce — a plain number when the
+ * Semantic Layers flag is off, or, flag on, a composite `"ds:<id>"` /
+ * `"sv:<id>"` string for every option (not only semantic views: with the
+ * flag on, ordinary datasets are composite-encoded too).
+ */
+export function resolveDatasetPick(
+ value: number | string | undefined,
+): number | undefined {
+ if (value === undefined || typeof value === 'number') {
+ return value;
+ }
+ return kindFromComposite(value) === 'semantic_view'
+ ? undefined
+ : fromCompositeValue(value);
+}
+
+/**
+ * `loadDatasetOptions` filtered down to plain datasets. `DataBinding` has no
+ * way to represent a semantic view (SIP-182's `kind` is a connection-level
+ * concept, not a per-field one on this schema), so rather than let one be
+ * picked and then reject it after the fact — leaving the closed select
+ * showing a value the widget never actually bound — it is never offered.
+ *
+ * Module-level, not a closure defined inside `DatasetControl`: `AsyncSelect`
+ * treats a change in its `options` function's identity as a reason to wipe
+ * its own fetched-options cache, and a fresh arrow function on every render
+ * would do exactly that.
+ *
+ * `totalCount` is passed through unfiltered — it counts datasets and
+ * semantic views together, same as the page `data` was drawn from before
+ * this function's own filter ran. With the Semantic Layers flag on and
+ * enough semantic views sorted ahead of the wanted datasets on the current
+ * search, a page that filters down to nothing still reports more rows
+ * exist, but `AsyncSelect` only requests the next page on scroll — and a
+ * dropdown with nothing to scroll never gets the chance. Narrow (flag off,
+ * the filter is a no-op and this never applies) and not addressed here;
+ * paging forward internally past an empty filtered page, or asking the
+ * backend to exclude semantic views from the query in the first place,
+ * would close it.
+ */
+async function loadDatasetOnlyOptions(
+ search: string,
+ page: number,
+ pageSize: number,
+) {
+ const { data, totalCount } = await loadDatasetOptions(search, page, pageSize);
+ return {
+ data: data.filter(option => option.kind !== 'semantic_view'),
+ totalCount,
Review Comment:
Fixed in b1b5f3f539, then refined in f8743d33e4 — see the reply on 3872820784 above for both parts.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "Copilot (via GitHub)" <gi...@apache.org>.
Copilot commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3893200989
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
Review Comment:
Validating the complete model on every individual field change prevents configuring a newly placed data-backed widget. `placeBlock` creates it without props, while `DataBinding` requires both `datasetId` and `metrics`; choosing the dataset submits only that field and is rejected for the missing metrics, but the metric picker cannot work until a dataset has been accepted. Seed a valid initial binding or support draft/partial edits and validate at an explicit commit boundary.
##########
superset-core/src/superset_core/widgets/enrichment.py:
##########
@@ -0,0 +1,150 @@
+# 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.
+
+"""
+Dependency graph and execution engine for a widget's dynamic (``x-dynamic``)
+control-schema fields.
+
+A widget registers one enricher callable per dynamic field path (see
+``Widget.enrichers``). Each field's existing ``x-dependsOn`` list (already
+used to gate enrichment via ``check_dependencies``) does double duty here:
+an entry naming another dynamic field's path becomes an ordering edge (that
+field's enricher must run first, and this one receives its result); any
+other entry stays a plain truthiness gate against the parsed control values.
+Field paths use ``a/b`` dot-path notation, the same convention
+``schema_tools.py`` uses for drill-in paths.
+
+This module only computes the graph and runs enrichers in order — it has no
+opinion on where the schema or enrichers come from; ``Widget.get_control_schema``
+wires it up.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from pydantic import BaseModel
+
+from superset_core.semantic_layers.config import check_dependencies
+
+# (schema, node, parsed, series, upstream_results) -> Any.
+# `schema` is the full document (for cross-$defs lookups, e.g. a sibling
+# style definition); `node` is this field's own schema fragment, mutated in
+# place. The return value is threaded to enrichers ordered after this one, as
+# `upstream_results[path]`.
+EnricherFn = Callable[
+ [dict[str, Any], dict[str, Any], "BaseModel | None", list[str], dict[str, Any]],
+ Any,
+]
+
+
+def _defs(schema: dict[str, Any]) -> dict[str, Any]:
+ return schema.get("$defs", {}) or {}
+
+
+def _deref(schema: dict[str, Any], node: dict[str, Any]) -> dict[str, Any]:
+ if "$ref" not in node:
+ return node
+ return _defs(schema).get(node["$ref"].split("/")[-1], {})
+
+
+def dynamic_field_paths(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ """Walk a built (pre-enrichment) control schema and return
+ ``{path: schema_node}`` for every field carrying ``x-dynamic: true``,
+ using ``a/b`` dot-path notation. Descends into ``properties`` and
+ resolves ``$ref`` against ``$defs`` along the way; does not descend into
+ a discovered dynamic field itself (a dynamic field's own internals are
+ the enricher's concern, not the graph's)."""
+ fields: dict[str, dict[str, Any]] = {}
+
+ def _walk(node: dict[str, Any], prefix: str) -> None:
+ resolved = _deref(schema, node)
+ for name, prop in resolved.get("properties", {}).items():
+ path = f"{prefix}/{name}" if prefix else name
+ prop_resolved = _deref(schema, prop)
+ if prop_resolved.get("x-dynamic"):
+ fields[path] = prop_resolved
+ else:
+ _walk(prop, path)
+
+ _walk(schema, "")
+ return fields
+
+
+def build_dependency_graph(fields: dict[str, dict[str, Any]]) -> dict[str, list[str]]:
+ """``{path: [ordering-edge paths]}`` for every dynamic field in
+ ``fields``. Only ``x-dependsOn`` entries that name another key of
+ ``fields`` become edges; every other entry is left as a gate for
+ ``check_dependencies`` to evaluate at run time, not an edge here."""
+ return {
+ path: [dep for dep in node.get("x-dependsOn", []) if dep in fields]
+ for path, node in fields.items()
+ }
+
+
+def toposort_or_raise(graph: dict[str, list[str]], widget_type: str) -> list[str]:
+ """Kahn's algorithm over ``graph`` (``path -> [paths it depends on]``).
+ Raises ``ValueError`` naming every field on a cycle when the graph isn't
+ a DAG."""
+ in_degree = dict.fromkeys(graph, 0)
+ dependents: dict[str, list[str]] = {path: [] for path in graph}
+ for path, deps in graph.items():
+ in_degree[path] = len(deps)
+ for dep in deps:
+ dependents[dep].append(path)
+
+ ready = sorted(path for path, degree in in_degree.items() if degree == 0)
+ order: list[str] = []
+ while ready:
+ path = ready.pop(0)
+ order.append(path)
+ for dependent in sorted(dependents[path]):
+ in_degree[dependent] -= 1
+ if in_degree[dependent] == 0:
+ ready.append(dependent)
+
+ if len(order) != len(graph):
+ remaining = sorted(set(graph) - set(order))
+ raise ValueError(
+ f"Cyclic control dependency in widget {widget_type!r} among: "
+ f"{', '.join(remaining)}"
+ )
+ return order
+
+
+def run_enrichers(
+ schema: dict[str, Any],
+ fields: dict[str, dict[str, Any]],
+ order: list[str],
+ enrichers: dict[str, EnricherFn],
+ parsed: BaseModel | None,
+ series: list[str],
+) -> None:
+ """Run each path's registered enricher (if any) in ``order``, skipping
+ one whose non-edge ``x-dependsOn`` gate(s) aren't satisfied, and
+ threading each enricher's return value forward as
+ ``upstream_results[path]`` for anything ordered after it."""
+ upstream_results: dict[str, Any] = {}
+ for path in order:
+ enricher = enrichers.get(path)
+ if enricher is None:
+ continue
+ node = fields[path]
+ if parsed is not None and not check_dependencies(node, parsed):
+ continue
+ result = enricher(schema, node, parsed, series, upstream_results)
Review Comment:
Ordering-edge dependencies are still passed to `check_dependencies`, contrary to this function’s contract that only non-edge dependencies are value gates. For a dynamic dependency path such as `group/options`, attribute lookup can never succeed, so the downstream enricher is skipped even after its upstream enricher ran. Filter dynamic paths out before checking the parsed control values.
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -154,7 +157,18 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
const [series, setSeries] = useState<string[]>([]);
const [schema, setSchema] = useState<JsonSchema | null>(null);
+ const [formKey, setFormKey] = useState(0);
const [error, setError] = useState<string | null>(null);
+ const [validationErrors, setValidationErrors] = useState<
+ ControlValidationError[]
+ >([]);
+ // Guards against an earlier, slower validation round-trip landing after a
+ // later one — e.g. two edits typed close together — and overwriting the
+ // more recent result with a stale one.
+ const validateSeqRef = useRef(0);
+ // A validation error belongs to the widget it was raised for; selecting a
+ // different one shouldn't leave a stale message on screen.
+ useEffect(() => setValidationErrors([]), [nodeId]);
Review Comment:
Changing `nodeId` only clears errors; the schema-loading effect is keyed by `widgetType` and discovered series. Selecting another widget of the same type with the same series key therefore retains the previous widget’s enriched schema—for example, ECharts per-metric override controls from the prior widget. Include `nodeId` in schema invalidation/fetching and invalidate outstanding refreshes on selection changes.
##########
superset-frontend/src/pages/DashboardBuilderV2/DataPanel.tsx:
##########
@@ -0,0 +1,237 @@
+/**
+ * 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 { useState } from 'react';
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { GenericDataType } from '@apache-superset/core/common';
+import { EmptyState, Input } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { ColumnTypeLabel } from '@superset-ui/chart-controls';
+
+interface MockColumn {
+ readonly name: string;
+ readonly type: GenericDataType;
+}
+
+interface MockDataset {
+ readonly id: string;
+ readonly name: string;
+ readonly columns: readonly MockColumn[];
+}
+
+/**
+ * Static placeholder rows. The Data tab does not call the dataset API —
+ * a later change wires this list, and each dataset's columns, to
+ * `/api/v1/dataset/`, the same endpoint `datasetMetadata.ts` already reads a
+ * single bound dataset's columns from.
+ */
+const MOCK_DATASETS: readonly MockDataset[] = [
+ {
+ id: 'sales',
+ name: 'sales',
+ columns: [
+ { name: 'order_id', type: GenericDataType.String },
+ { name: 'order_date', type: GenericDataType.Temporal },
+ { name: 'sales_amount', type: GenericDataType.Numeric },
+ { name: 'region', type: GenericDataType.String },
+ ],
+ },
+ {
+ id: 'coffee_sales',
+ name: 'coffee_sales',
+ columns: [
+ { name: 'product', type: GenericDataType.String },
+ { name: 'roast_date', type: GenericDataType.Temporal },
+ { name: 'unit_price', type: GenericDataType.Numeric },
+ { name: 'is_decaf', type: GenericDataType.Boolean },
+ ],
+ },
+];
+
+const matches = (dataset: MockDataset, query: string): boolean =>
+ query.trim() === '' ||
+ dataset.name.toLowerCase().includes(query.trim().toLowerCase());
+
+/**
+ * The panel's own scroll column, set down from the tab bar and in from the
+ * panel edge — the same step `Palette`'s `Column` and `Outline`'s `Panel`
+ * take from theirs, so the four tabs of one rail start on one line.
+ */
+const Column = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 5}px;
+ min-height: 0;
+ padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit}px 0;
+ `}
+`;
+
+const List = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit}px;
+ overflow-y: auto;
+ min-height: 0;
+ `}
+`;
+
+const DatasetButton = styled.button`
+ ${({ theme }) => css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ width: 100%;
+ padding: ${theme.sizeUnit * 2}px;
+ border: 1px solid ${theme.colorBorder};
+ /* The same radius every other tile in this rail, and the canvas itself,
+ draws its own card at — one rounding language, not one per panel. */
+ border-radius: ${theme.borderRadiusLG}px;
+ background-color: ${theme.colorFillQuaternary};
+ color: ${theme.colorText};
+ font-size: ${theme.fontSizeSM}px;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color ${theme.motionDurationMid};
+
+ .data-panel-chevron {
+ display: flex;
+ flex: 0 0 auto;
+ color: ${theme.colorTextTertiary};
+ }
+
+ &:hover {
+ background-color: ${theme.colorFillTertiary};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colorPrimaryBorder};
+ outline-offset: -2px;
+ }
+ `}
+`;
+
+const ColumnList = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit}px;
+ margin-top: ${theme.sizeUnit}px;
+ margin-left: ${theme.sizeUnit * 2}px;
+ padding-left: ${theme.sizeUnit * 3}px;
+ border-left: 1px solid ${theme.colorBorder};
+ `}
+`;
+
+const ColumnRow = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
+ font-size: ${theme.fontSizeSM}px;
+ color: ${theme.colorText};
+ `}
+`;
+
+/**
+ * Datasets and their columns, to browse rather than to place.
+ *
+ * Building Blocks places widgets onto the canvas; this tab answers "what
+ * data is there to use" without touching any widget's binding — expanding a
+ * row reads its columns and nothing else happens.
+ */
+export default function DataPanel(): ReactElement {
+ const [query, setQuery] = useState('');
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
+
+ const found = MOCK_DATASETS.filter(dataset => matches(dataset, query));
Review Comment:
This production component is unreachable: repository references show it is imported only by `DataPanel.test.tsx`, while `EditorPanel` still renders only Building Blocks, Properties, and Outline. Users can never see this dataset browser. Either wire it into the panel/navigation or remove the dead placeholder until it is usable.
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
Review Comment:
Accepted responses can commit out of order. Two quick form edits start two validations; if the newer response commits first and the older response returns later, this unconditional update restores the older candidate. `SchemaControlPanel` checks its sequence only after `commitWidgetProps` has already performed this write, so it does not prevent the stale commit. Add cancellation or a latest-request/version check before updating the provider.
##########
superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx:
##########
@@ -435,6 +432,7 @@ const PropsEditor = ({
// area above it). `updateProps` merges, so a key absent from the form's data
// is left untouched rather than removed.
const schemaControlledTypes = useSchemaControlledWidgetTypes();
+ const validated = schemaControlledTypes?.has(widgetType) ?? false;
Review Comment:
Validation fails open while the widget-type request is loading or after it fails: `validated` becomes false, yet the JSON tab remains usable and commits directly. A user can therefore bypass the new backend gate simply by applying before `/widgets/types` resolves (or after a transient failure). The JSON path should attempt validation independently and fall back only on a confirmed 404/no-schema result.
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredChrome.ts:
##########
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's second, independent structured layer — chart
+ * chrome (title/legend/tooltip/axis labels) — matching `EchartsChrome`'s
+ * docstring in `superset/widgets/controls.py`: every field is optional and
+ * applies (or not) on its own, regardless of `chartType`/`customize`. A
+ * field left at its default never touches `echartsOptions`; when it does
+ * apply, it merges onto — rather than replaces — the matching section, so
+ * an unmanaged sibling property there (e.g. a hand-authored `legend.orient`)
+ * survives.
+ *
+ * `EchartsChromeValue` is deliberately flat, not grouped into
+ * `title`/`legend`/`tooltip`/`xAxis`/`yAxis` sub-objects — see
+ * `EchartsChrome`'s own docstring: JsonForms' generated control panel only
+ * renders one level of nested-object properties, so a two-level-deep
+ * `chrome.title.text` would render as an empty group with no fields inside.
+ */
+
+export interface EchartsChromeValue {
+ titleText?: string;
+ legendShow?: boolean;
+ legendPosition?: 'top' | 'bottom' | 'left' | 'right' | null;
+ tooltipTrigger?: 'item' | 'axis' | null;
+ xAxisName?: string;
+ xAxisRotate?: number;
+ xAxisFormat?: string;
+ yAxisName?: string;
+ yAxisRotate?: number;
+ yAxisFormat?: string;
+}
+
+// ECharts has no single "position" property on `legend` — placement comes
+// from `top`/`left` (each accepting a keyword or coordinate). This maps the
+// friendlier compass-direction picker onto the pair ECharts actually reads.
+const LEGEND_POSITION: Record<string, { top: string; left: string }> = {
+ top: { top: 'top', left: 'center' },
+ bottom: { top: 'bottom', left: 'center' },
+ left: { top: 'middle', left: 'left' },
+ right: { top: 'middle', left: 'right' },
+};
+
+function asRecord(value: unknown): Record<string, unknown> {
+ return value !== null && typeof value === 'object'
+ ? (value as Record<string, unknown>)
+ : {};
Review Comment:
ECharts permits array-valued `title`, `legend`, `xAxis`, and `yAxis`, but arrays pass this object check and are spread into objects with numeric keys. Applying any structured chrome override to such an existing raw option therefore changes its shape and breaks the promise that unmanaged raw configuration survives. Preserve arrays and merge into the intended entry (typically the first) rather than treating them as records.
##########
superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx:
##########
@@ -309,23 +278,51 @@ const PropsJsonEditor = ({
{error}
</p>
)}
+ {validationErrors.map(err => (
+ <p
+ key={`${err.loc.join('.')}:${err.message}`}
+ data-test="inspector-props-validation-error"
+ style={{
+ margin: `0 0 ${theme.sizeUnit}px`,
+ fontSize: theme.fontSizeSM,
+ color: theme.colorErrorText,
+ }}
+ >
+ {err.loc.length > 0 ? `${err.loc.join('.')}: ` : ''}
+ {err.message}
+ </p>
+ ))}
<div style={{ display: 'flex', gap: theme.sizeUnit }}>
<Button
buttonSize="xsmall"
buttonStyle="primary"
data-test="inspector-props-apply"
- disabled={parsed === undefined || !dirty}
- onClick={() => {
+ disabled={parsed === undefined || !dirty || submitting}
+ onClick={async () => {
if (parsed === undefined) {
return;
}
const removed = Object.keys(props ?? {}).filter(
key => !(key in parsed!),
);
- provider.updateProps(nodeId, {
+ const delta = {
...parsed,
...Object.fromEntries(removed.map(key => [key, undefined])),
- });
+ };
Review Comment:
Applying `{}` removes every key by setting it to `undefined`; JSON serialization then sends an empty `control_values` object. `Widget.validate_control_values` currently returns success for any falsy value, so required fields are never checked and this new delete path commits an invalid widget. The backend must distinguish `None` (“nothing to validate”) from `{}` and validate the latter.
##########
superset-core/src/superset_core/semantic_layers/config.py:
##########
@@ -17,11 +17,64 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, get_args, get_origin, Iterator
from pydantic import BaseModel
+def _iter_nested_models(
+ annotation: Any, seen: set[type[BaseModel]]
+) -> Iterator[type[BaseModel]]:
+ """Yield every ``BaseModel`` subclass reachable from ``annotation``
+ (through generics like ``list[...]``/``... | None``, and recursively
+ through each found model's own fields), each at most once."""
+ origin = get_origin(annotation)
+ if origin is not None:
+ for arg in get_args(annotation):
+ yield from _iter_nested_models(arg, seen)
+ return
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
+ model_cls: type[BaseModel] = annotation
+ if model_cls not in seen:
+ seen.add(model_cls)
+ yield model_cls
+ for field in model_cls.model_fields.values():
+ yield from _iter_nested_models(field.annotation, seen)
+
+
+def _resolve_field_order(
+ model_cls: type[BaseModel], schema_node: dict[str, Any]
+) -> list[str]:
+ """The order ``schema_node["properties"]`` should render in: an explicit
+ ``field_order: ClassVar[list[str]]`` on ``model_cls`` when declared
+ (validated as an exact permutation of its own properties), else the
+ model's field declaration order (by alias)."""
+ declared_order = getattr(model_cls, "field_order", None)
+ if declared_order is None:
+ return [field.alias or name for name, field in model_cls.model_fields.items()]
+ declared = set(declared_order)
+ if declared != (actual := set(schema_node.get("properties", {}))):
Review Comment:
Converting the declared order to a set does not enforce an exact permutation: `['a', 'a', 'b']` passes for properties `a` and `b`. The duplicate is then silently collapsed by `_reorder`, masking an invalid declaration instead of failing registration. Compare lengths as well as set membership.
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +164,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
value={value}
onChange={event => handleChange(path, event.target.value)}
/>
- <Typography.Text>{label}</Typography.Text>
- </Flex>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
);
}
-/** Base Semantic-Layer renderers plus the widget-control code/color ones. */
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ key={`${availableSaved.length}-${values.length}`}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={addOptions}
+ disabled={disabled}
+ onChange={next => {
+ if (next === ADD_CUSTOM_METRIC) {
+ setAddingNew(true);
+ } else {
+ onChange([...values, next as string]);
+ }
+ }}
+ />
+ )}
+ {addingNew && (
+ <AdhocMetricEditor
+ value={undefined}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={addingNew}
+ onOpenChange={setAddingNew}
+ onSave={next => {
+ onChange([...values, next]);
+ setAddingNew(false);
+ }}
+ >
+ {/* Zero-size trigger: opening this popover is driven entirely
+ by picking "Custom metric…" above, not by a click here. */}
+ <span />
+ </AdhocMetricEditor>
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "metric-multi"` — an ordered list of metric references. Falls
+ * back to the raw JSON editor (`CodeControl`) whenever no dataset is bound
+ * (or its fetch failed), or an entry is genuinely unrepresentable (see
+ * `isUnrepresentableMetric`); a well-formed mix of saved and ad-hoc metrics
+ * renders as `MetricEntryList`, not raw JSON.
+ */
+function MetricMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasUnrepresentable = values.some(isUnrepresentableMetric);
+
+ if (shouldFallBackToCode(datasetId, error) || hasUnrepresentable) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <MetricEntryList
+ label={props.label}
+ description={props.description}
+ values={values as MetricEntry[]}
+ metadata={metadata}
+ columns={metadata?.columns ?? []}
+ datasourceId={datasetId}
+ datasourceType={metadata?.datasourceType}
+ disallowAdhoc={disallowsAdhocMetrics(metadata)}
+ disabled={!props.enabled || loading}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * The value the dataset picker needs for an already-bound dataset. Bare —
+ * just the id (composite-encoded per below) — while the name is still
+ * resolving: `AsyncSelect` prefers a *labeled* value's own label over a
+ * matching loaded option's, so handing it a stale `String(datasetId)` label
+ * here would overwrite the real name the moment the matching option
+ * arrives, replaying the exact stuck-on-the-numeric-id symptom this control
+ * exists to avoid — instead, letting `AsyncSelect` fall back to the id on
+ * its own leaves it free to prefer a loaded option's label the instant one
+ * matches. Once `tableName` resolves, the explicit label takes over.
+ *
+ * `value` carries the composite `"ds:<id>"` encoding when the Semantic
+ * Layers flag is on, matching what `loadDatasetOptions` encodes its own
+ * options as in that mode (see `resolveDatasetPick` below) — an option and
+ * a bound value in two different encodings never match, which is what
+ * leaves a genuinely-selected dataset showing as unselected.
+ */
+export function toDatasetSelectValue(
+ datasetId: number | undefined,
+ tableName: string | undefined,
+ useSemanticLayers: boolean,
+): { label: string; value: number | string } | number | string | undefined {
+ if (datasetId === undefined) {
+ return undefined;
+ }
+ const value = useSemanticLayers ? toCompositeValue(datasetId) : datasetId;
+ return tableName ? { label: tableName, value } : value;
+}
+
+/**
+ * The numeric dataset id a picked option resolves to, or `undefined` when
+ * the pick should be rejected outright: a semantic view, which
+ * `DataBinding.datasetId` has no way to represent (SIP-182's `kind` is a
+ * connection-level concept, not a per-field one on this schema). Handles
+ * both encodings `loadDatasetOptions` can produce — a plain number when the
+ * Semantic Layers flag is off, or, flag on, a composite `"ds:<id>"` /
+ * `"sv:<id>"` string for every option (not only semantic views: with the
+ * flag on, ordinary datasets are composite-encoded too).
+ */
+export function resolveDatasetPick(
+ value: number | string | undefined,
+): number | undefined {
+ if (value === undefined || typeof value === 'number') {
+ return value;
+ }
+ return kindFromComposite(value) === 'semantic_view'
+ ? undefined
+ : fromCompositeValue(value);
+}
+
+/**
+ * `loadDatasetOptions` filtered down to plain datasets. `DataBinding` has no
+ * way to represent a semantic view (SIP-182's `kind` is a connection-level
+ * concept, not a per-field one on this schema), so rather than let one be
+ * picked and then reject it after the fact — leaving the closed select
+ * showing a value the widget never actually bound — it is never offered.
+ *
+ * Module-level, not a closure defined inside `DatasetControl`: `AsyncSelect`
+ * treats a change in its `options` function's identity as a reason to wipe
+ * its own fetched-options cache, and a fresh arrow function on every render
+ * would do exactly that.
+ *
+ * `totalCount` is passed through unfiltered — it counts datasets and
+ * semantic views together, same as the page `data` was drawn from before
+ * this function's own filter ran. With the Semantic Layers flag on and
+ * enough semantic views sorted ahead of the wanted datasets on the current
+ * search, a page that filters down to nothing still reports more rows
+ * exist, but `AsyncSelect` only requests the next page on scroll — and a
+ * dropdown with nothing to scroll never gets the chance. Narrow (flag off,
+ * the filter is a no-op and this never applies) and not addressed here;
+ * paging forward internally past an empty filtered page, or asking the
+ * backend to exclude semantic views from the query in the first place,
+ * would close it.
+ */
+async function loadDatasetOnlyOptions(
+ search: string,
+ page: number,
+ pageSize: number,
+) {
+ const { data, totalCount } = await loadDatasetOptions(search, page, pageSize);
+ return {
+ data: data.filter(option => option.kind !== 'semantic_view'),
+ totalCount,
Review Comment:
Filtering semantic views after pagination can return an empty page while later pages still contain accessible datasets; because the dropdown has nothing to scroll, it never requests those pages. The comment above already describes this failure mode, but the picker is shipped with it unresolved. Filter by kind server-side or advance through empty filtered pages before returning.
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
+ return { ok: true, values: candidate };
Review Comment:
The backend validates a Pydantic-normalized value, but this commits the raw candidate. For example, Pydantic accepts `{"datasetId":"1"}` by coercing it to an integer, while the frontend stores the string; `DatasetControl` then treats that accepted value as unrepresentable. Return normalized values from the validation endpoint and commit those, as the MCP write path already does.
##########
superset/mcp_service/widgets/tool/set_widget_control_values.py:
##########
@@ -0,0 +1,107 @@
+# 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.
+
+"""MCP tool: set_widget_control_values"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.node_store import nodes
+from superset.mcp_service.widgets.utils import (
+ resolve_widget,
+ unknown_node_error,
+ unknown_widget_type_error,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _set_widget_control_values_impl(
+ node_id: str,
+ control_values: Dict[str, Any],
+) -> Dict[str, Any]:
+ """Pure logic: validate-then-commit a widget node's control values.
+
+ Builds a candidate (the node's current ``props`` shallow-merged with
+ ``control_values`` -- new keys override, everything else is preserved,
+ mirroring how the frontend's ``DashboardProvider.updateProps`` merges)
+ without touching the stored node. Validates the candidate through
+ ``Widget.validate_control_values`` -- the same commit-time gate the
+ ``/type/<widget_type>/validate`` REST endpoint uses. Only on success is
+ the node's ``props`` replaced with the candidate, a single dict
+ reassignment, so a validation failure leaves the stored node completely
+ unchanged: there is nothing to roll back because nothing was mutated in
+ place.
+ """
+ node = nodes.get(node_id)
+ if node is None:
+ return unknown_node_error(node_id)
Review Comment:
This tool has no usable production target: the process-local `nodes` map is populated only by tests, and there is no create/seed tool or bridge from the frontend dashboard document. Consequently, every normal MCP invocation returns `unknown_node`, so the PR’s advertised widget-value write tool cannot apply any value. Wire it to an addressable document or provide a production population path.
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredSeries.ts:
##########
@@ -0,0 +1,97 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's optional structured layer (`chartType`/`customize`
+ * on `EchartsControls`, see `superset/widgets/controls.py`). Precedence,
+ * matching the backend model's docstring:
+ * 1. `echartsOptions` (already `$bind`-resolved) is the base.
+ * 2. When `chartType` is set, `option.series` — and only `series` — is
+ * replaced with one generated series per `dataBinding` metric.
+ * Everything else the raw option authored (axes, legend, tooltip,
+ * title) survives unmanaged.
+ * 3. `chartType` unset/null ("Custom") leaves the raw option untouched,
+ * including mixed-series or non-Cartesian (e.g. pie) shapes.
+ *
+ * A series is keyed by its metric's `getMetricLabel` — the same label the
+ * `/api/v1/chart/data` result columns are named after, so `data` reads the
+ * right column, and the same label the backend's `_metric_key` computes, so
+ * a stored override matches by stable identity rather than array position.
+ */
+import { getMetricLabel } from '@superset-ui/core';
+import type { QueryFormMetric } from '@superset-ui/core';
+import type { dashboard as dashboardApi } from '@apache-superset/core';
+
+type DataRow = dashboardApi.DataRow;
+
+export type EchartsChartType = 'bar' | 'line' | 'scatter';
+
+export interface SeriesOverrideValue {
+ color?: string;
+ visible?: boolean;
+ displayName?: string;
+}
+
+/**
+ * One ECharts series per metric, in `dataBinding.metrics` order. A metric
+ * overridden with `visible: false` is omitted entirely (there is no ECharts
+ * option for "present but hidden" that also frees its legend/tooltip slot).
+ */
+export function buildStructuredSeries(
+ chartType: EchartsChartType,
+ metrics: QueryFormMetric[],
+ rows: DataRow[],
+ overrides: Record<string, SeriesOverrideValue> | undefined,
+): Record<string, unknown>[] {
+ return metrics
+ .map(metric => {
+ const key = getMetricLabel(metric);
+ const override = overrides?.[key];
+ if (override?.visible === false) return null;
+ const series: Record<string, unknown> = {
+ name: override?.displayName || key,
+ type: chartType,
+ data: rows.map(row => row[key]),
+ };
+ if (override?.color) {
+ series.itemStyle = { color: override.color };
+ }
Review Comment:
The enriched schema assigns a palette default for each series, but the renderer applies a color only when an override is stored. Thus the control can display `Echarts.PALETTE[0]` as the default while the untouched chart uses ECharts’ own palette; merely adding an override without changing the swatch can change the series color. Apply the same structured palette during rendering or stop advertising these values as defaults.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "msyavuz (via GitHub)" <gi...@apache.org>.
msyavuz commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3904690345
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
Review Comment:
Seconding the bot on this one — `updateProps` runs inside the awaited commit, so the caller's `validateSeqRef` guard can't stop a superseded edit, and two quick edits where the first validates slower will land the stale candidate last.
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -354,13 +381,42 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
}
}
- // Our own edit — don't let the resync effect remount the form
- // for it (that would drop input focus mid-typing).
- selfEditRef.current = true;
- provider.updateProps(nodeId, next);
- maybeRefreshSchema(next);
+ validateSeqRef.current += 1;
+ const seq = validateSeqRef.current;
+ commitWidgetProps(nodeId, widgetType, next, {
+ // Only fires once validation has already accepted the
+ // candidate, immediately before the commit that changes
+ // `propsKey` — so the resync effect never remounts the form
+ // and drops input focus for an edit it made itself.
+ onBeforeCommit: () => {
+ selfEditRef.current = true;
+ },
+ })
+ .then(result => {
+ if (validateSeqRef.current !== seq) return;
+ setValidationErrors(result.ok ? [] : result.errors);
Review Comment:
On rejection JsonForms keeps the typed value but `props` doesn't, and `config.formData` (line 344) is the committed props — so picking a dataset that fails validation shows it in the select while the column/metric pickers below still resolve no `datasetId`.
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +164,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
Review Comment:
Raw `<input type="color">` here, while `SeriesEntryPropertyControl` at line 261 uses `ColorPicker` from `@superset-ui/core/components` for the same job — the native swatch ignores the theme.
##########
superset/core/api/core_api_injection.py:
##########
@@ -328,6 +328,14 @@ def decorator(cls: Any) -> Any:
cls.name = name
cls.description = description or ""
registry[key] = cls
+ try:
+ # Validate the static schema and dependency graph at import
+ # time. Runtime enrichers may perform permission-scoped data
+ # lookups, so they must wait until a request user exists.
+ cls.validate_control_schema()
+ except Exception:
+ registry.pop(key, None)
+ raise
Review Comment:
This also runs for extension-registered widgets (line 311) — does one third-party widget with an unbuildable schema now fail the whole extension import rather than just being rejected?
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredChrome.ts:
##########
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's second, independent structured layer — chart
+ * chrome (title/legend/tooltip/axis labels) — matching `EchartsChrome`'s
+ * docstring in `superset/widgets/controls.py`: every field is optional and
+ * applies (or not) on its own, regardless of `chartType`/`customize`. A
+ * field left at its default never touches `echartsOptions`; when it does
+ * apply, it merges onto — rather than replaces — the matching section, so
+ * an unmanaged sibling property there (e.g. a hand-authored `legend.orient`)
+ * survives.
+ *
+ * `EchartsChromeValue` is deliberately flat, not grouped into
+ * `title`/`legend`/`tooltip`/`xAxis`/`yAxis` sub-objects — see
+ * `EchartsChrome`'s own docstring: JsonForms' generated control panel only
+ * renders one level of nested-object properties, so a two-level-deep
+ * `chrome.title.text` would render as an empty group with no fields inside.
+ */
+
+export interface EchartsChromeValue {
+ titleText?: string;
+ legendShow?: boolean;
+ legendPosition?: 'top' | 'bottom' | 'left' | 'right' | null;
+ tooltipTrigger?: 'item' | 'axis' | null;
+ xAxisName?: string;
+ xAxisRotate?: number;
+ xAxisFormat?: string;
+ yAxisName?: string;
+ yAxisRotate?: number;
+ yAxisFormat?: string;
+}
+
+// ECharts has no single "position" property on `legend` — placement comes
+// from `top`/`left` (each accepting a keyword or coordinate). This maps the
+// friendlier compass-direction picker onto the pair ECharts actually reads.
+const LEGEND_POSITION: Record<string, { top: string; left: string }> = {
+ top: { top: 'top', left: 'center' },
+ bottom: { top: 'bottom', left: 'center' },
+ left: { top: 'middle', left: 'left' },
+ right: { top: 'middle', left: 'right' },
+};
+
+function asRecord(value: unknown): Record<string, unknown> {
+ return value !== null && typeof value === 'object'
+ ? (value as Record<string, unknown>)
+ : {};
+}
+
+function applyTitle(existing: unknown, chrome: EchartsChromeValue): unknown {
Review Comment:
`applyTitle`'s merge is always discarded — `ChartWidget.tsx:359-360` deletes `option.title` right after `applyStructuredChrome` returns, and that's its only production caller.
##########
superset-core/src/superset_core/widgets/composites.py:
##########
@@ -0,0 +1,106 @@
+# 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.
+
+"""
+Reusable, discoverable building blocks for Dashboard V2 widget control models.
+
+A composite control is a ``BaseModel`` mixin carrying one or more fields
+(with their ``x-control`` schema extras already set) that a widget's
+``controls_class`` composes in via plain single inheritance — no nesting, so
+the composed field renders exactly where a directly-declared field would.
+The ``@composite_control`` decorator only registers the class for discovery
+(docs generation, MCP tooling); composing one into a widget never touches the
+registry.
+
+Composing more than one composite control into the same model (multiple
+inheritance across two or more registered mixins) is unsupported for now —
+see the design spec's Scope section.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any, Callable, Mapping
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+@dataclass(frozen=True)
+class CompositeControlInfo:
+ name: str
+ title: str
+ description: str
+ model: type[BaseModel]
+
+
+_registry: dict[str, CompositeControlInfo] = {}
+
+
+def composite_control(
+ name: str, title: str, description: str
+) -> Callable[[type[BaseModel]], type[BaseModel]]:
+ """Register a reusable Pydantic mixin as a discoverable composite control.
+
+ Composing one into a widget's ``controls_class`` is plain inheritance —
+ this decorator only makes the class discoverable via
+ ``list_composite_controls()``.
+ """
+
+ def decorator(cls: type[BaseModel]) -> type[BaseModel]:
+ if name in _registry:
+ raise ValueError(f"composite control {name!r} already registered")
+ _registry[name] = CompositeControlInfo(name, title, description, cls)
+ return cls
+
+ return decorator
+
+
+def list_composite_controls() -> Mapping[str, CompositeControlInfo]:
+ """Read-only view of registered composite controls, for docs/MCP
+ discovery. An extension-defined composite appears only once its defining
+ module has been imported (decorator side effect, same as ``@widget``)."""
+ return MappingProxyType(_registry)
+
+
+@composite_control(
+ name="metric",
+ title="Metrics",
+ description=(
+ "Reusable metric-list field (saved-metric names or ad-hoc SIMPLE aggregates)."
+ ),
+)
+class MetricControl(BaseModel):
+ """Mixin providing a ``metrics`` field, extracted verbatim from
+ ``DataBinding`` for reuse outside it."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ metrics: list[Any] = Field(
+ title="Metrics",
+ description=(
+ "Metrics to fetch. Each entry is EITHER a string naming a saved "
+ 'metric on the dataset (e.g. "count"), OR an ad-hoc aggregate '
+ "object of the shape "
+ '{"expressionType": "SIMPLE", "column": {"column_name": "<col>"}, '
Review Comment:
The new Custom SQL tab emits `{expressionType: "SQL", sqlExpression}` via `toPlainMetric` — should this description still say SIMPLE-only and "do not pass a raw SQL string"?
##########
superset-frontend/src/pages/DashboardBuilderV2/AdhocMetricEditor.tsx:
##########
@@ -0,0 +1,263 @@
+/**
+ * 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.
+ */
+
+/**
+ * A popover editor for one ad-hoc metric — Dashboard V2's own, not the
+ * classic Explore control panel's `AdhocMetricEditPopover`. That component
+ * is a fine model for the *shape* of an ad-hoc metric, but not for reuse
+ * verbatim here: it reads `state.explore.compatibleMetrics` via
+ * `useSelector`, which throws outside a react-redux `Provider`, and Dashboard
+ * V2 is deliberately non-Redux; its own "Saved" tab is also redundant with
+ * the picker `MetricMultiControl` already offers for saved metrics, so only
+ * the Simple and Custom SQL tabs represent capability worth reusing.
+ *
+ * What *is* reused: the legacy `AdhocMetric` class (`MetricControl/AdhocMetric.ts`)
+ * as the editing/validation model — the same `expressionType`/`column`/
+ * `aggregate`/`sqlExpression`/`label` shape the backend and the classic
+ * control panel both already agree on, so nothing about "what an ad-hoc
+ * metric is" is redefined here — plus `AGGREGATES_OPTIONS` and
+ * `SQLEditorWithValidation`, both already free of Explore-specific coupling.
+ */
+import { useEffect, useState } from 'react';
+import type { ReactElement, ReactNode } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { useTheme } from '@apache-superset/core/theme';
+import type { AdhocMetric as CoreAdhocMetric } from '@superset-ui/core';
+import {
+ Button,
+ Form,
+ Popover,
+ Select,
+ Tabs,
+} from '@superset-ui/core/components';
+import LegacyAdhocMetric, {
+ EXPRESSION_TYPES,
+ fromCoreAdhocMetric,
+} from 'src/explore/components/controls/MetricControl/AdhocMetric';
+import { AGGREGATES_OPTIONS } from 'src/explore/constants';
+import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
+import type { DatasetColumnMeta } from 'src/core/dashboard/datasetMetadata';
+
+const BLANK_METRIC = { expressionType: EXPRESSION_TYPES.SIMPLE };
+
+/** The plain, storable shape `handleChange` writes back into `node.props` —
+ * only the fields `@superset-ui/core`'s `AdhocMetric` type declares, not the
+ * legacy class's extra runtime-only fields (`datasourceWarning` and the
+ * like). */
+function toPlainMetric(draft: LegacyAdhocMetric): CoreAdhocMetric {
+ const base = {
+ label: draft.label,
+ hasCustomLabel: draft.hasCustomLabel,
+ optionName: draft.optionName,
+ };
+ if (draft.expressionType === EXPRESSION_TYPES.SQL) {
+ return {
+ ...base,
+ expressionType: 'SQL',
+ sqlExpression: draft.sqlExpression ?? '',
+ } as CoreAdhocMetric;
+ }
+ return {
+ ...base,
+ expressionType: 'SIMPLE',
+ column: draft.column!,
+ aggregate: draft.aggregate,
+ } as CoreAdhocMetric;
+}
+
+export interface AdhocMetricEditorProps {
+ /** `undefined` opens the editor on a blank draft (adding a new metric). */
+ value: CoreAdhocMetric | undefined;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (metric: CoreAdhocMetric) => void;
+ children: ReactNode;
+}
+
+/**
+ * Wraps `children` (the row's own clickable label) in a click-triggered
+ * popover editing one ad-hoc metric. Nothing commits until Save — Cancel, an
+ * outside click, or Escape all discard the draft, matching every other
+ * validated-commit surface in this panel where a candidate is built and
+ * discarded freely before it's actually written.
+ */
+export default function AdhocMetricEditor({
Review Comment:
No test file for this one — `schemaControlRenderers.test.tsx` only covers the exported pure helpers, so the SIMPLE/SQL round-trip through `toPlainMetric` and the `isValid()` save gate are untested.
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +164,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
value={value}
onChange={event => handleChange(path, event.target.value)}
/>
- <Typography.Text>{label}</Typography.Text>
- </Flex>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
);
}
-/** Base Semantic-Layer renderers plus the widget-control code/color ones. */
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ key={`${availableSaved.length}-${values.length}`}
Review Comment:
Picking `Custom metric…` changes neither `availableSaved.length` nor `values.length`, so the remount that clears rc-select's echoed label (per your note at line 677) never fires and the box keeps reading "Custom metric…" after Cancel.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912455152
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -354,13 +381,42 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
}
}
- // Our own edit — don't let the resync effect remount the form
- // for it (that would drop input focus mid-typing).
- selfEditRef.current = true;
- provider.updateProps(nodeId, next);
- maybeRefreshSchema(next);
+ validateSeqRef.current += 1;
+ const seq = validateSeqRef.current;
+ commitWidgetProps(nodeId, widgetType, next, {
+ // Only fires once validation has already accepted the
+ // candidate, immediately before the commit that changes
+ // `propsKey` — so the resync effect never remounts the form
+ // and drops input focus for an edit it made itself.
+ onBeforeCommit: () => {
+ selfEditRef.current = true;
+ },
+ })
+ .then(result => {
+ if (validateSeqRef.current !== seq) return;
+ setValidationErrors(result.ok ? [] : result.errors);
Review Comment:
Correct, and my first fix (remounting the form on any rejection) traded this bug for a worse one — see 3893200989 above for what that broke and how it's fixed now in f8743d33e4. `config.formData` tracks a live, not-yet-necessarily-valid `liveData` state updated synchronously on every edit, so the column/metric pickers see a just-picked `datasetId` immediately even while that specific edit is still rejected.
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +164,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
value={value}
onChange={event => handleChange(path, event.target.value)}
/>
- <Typography.Text>{label}</Typography.Text>
- </Flex>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
);
}
-/** Base Semantic-Layer renderers plus the widget-control code/color ones. */
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ key={`${availableSaved.length}-${values.length}`}
Review Comment:
Fixed in b1b5f3f539 — added `addingNew` to the trailing Select's remount key, so picking "Custom metric…" (which changes neither `availableSaved.length` nor `values.length`) still forces a remount and clears rc-select's echoed label.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912456901
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredChrome.ts:
##########
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's second, independent structured layer — chart
+ * chrome (title/legend/tooltip/axis labels) — matching `EchartsChrome`'s
+ * docstring in `superset/widgets/controls.py`: every field is optional and
+ * applies (or not) on its own, regardless of `chartType`/`customize`. A
+ * field left at its default never touches `echartsOptions`; when it does
+ * apply, it merges onto — rather than replaces — the matching section, so
+ * an unmanaged sibling property there (e.g. a hand-authored `legend.orient`)
+ * survives.
+ *
+ * `EchartsChromeValue` is deliberately flat, not grouped into
+ * `title`/`legend`/`tooltip`/`xAxis`/`yAxis` sub-objects — see
+ * `EchartsChrome`'s own docstring: JsonForms' generated control panel only
+ * renders one level of nested-object properties, so a two-level-deep
+ * `chrome.title.text` would render as an empty group with no fields inside.
+ */
+
+export interface EchartsChromeValue {
+ titleText?: string;
+ legendShow?: boolean;
+ legendPosition?: 'top' | 'bottom' | 'left' | 'right' | null;
+ tooltipTrigger?: 'item' | 'axis' | null;
+ xAxisName?: string;
+ xAxisRotate?: number;
+ xAxisFormat?: string;
+ yAxisName?: string;
+ yAxisRotate?: number;
+ yAxisFormat?: string;
+}
+
+// ECharts has no single "position" property on `legend` — placement comes
+// from `top`/`left` (each accepting a keyword or coordinate). This maps the
+// friendlier compass-direction picker onto the pair ECharts actually reads.
+const LEGEND_POSITION: Record<string, { top: string; left: string }> = {
+ top: { top: 'top', left: 'center' },
+ bottom: { top: 'bottom', left: 'center' },
+ left: { top: 'middle', left: 'left' },
+ right: { top: 'middle', left: 'right' },
+};
+
+function asRecord(value: unknown): Record<string, unknown> {
+ return value !== null && typeof value === 'object'
+ ? (value as Record<string, unknown>)
+ : {};
+}
+
+function applyTitle(existing: unknown, chrome: EchartsChromeValue): unknown {
Review Comment:
Fixed in b1b5f3f539 — removed `applyTitle`/title handling from `applyStructuredChrome` entirely (kept the tested merge semantics for legend/tooltip/axis, which are all live paths). `titleText` is handled solely by `widgetLabel.ts`'s `echartsTitle`, which this file's own docstring now points to.
##########
superset-frontend/src/pages/DashboardBuilderV2/AdhocMetricEditor.tsx:
##########
@@ -0,0 +1,263 @@
+/**
+ * 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.
+ */
+
+/**
+ * A popover editor for one ad-hoc metric — Dashboard V2's own, not the
+ * classic Explore control panel's `AdhocMetricEditPopover`. That component
+ * is a fine model for the *shape* of an ad-hoc metric, but not for reuse
+ * verbatim here: it reads `state.explore.compatibleMetrics` via
+ * `useSelector`, which throws outside a react-redux `Provider`, and Dashboard
+ * V2 is deliberately non-Redux; its own "Saved" tab is also redundant with
+ * the picker `MetricMultiControl` already offers for saved metrics, so only
+ * the Simple and Custom SQL tabs represent capability worth reusing.
+ *
+ * What *is* reused: the legacy `AdhocMetric` class (`MetricControl/AdhocMetric.ts`)
+ * as the editing/validation model — the same `expressionType`/`column`/
+ * `aggregate`/`sqlExpression`/`label` shape the backend and the classic
+ * control panel both already agree on, so nothing about "what an ad-hoc
+ * metric is" is redefined here — plus `AGGREGATES_OPTIONS` and
+ * `SQLEditorWithValidation`, both already free of Explore-specific coupling.
+ */
+import { useEffect, useState } from 'react';
+import type { ReactElement, ReactNode } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { useTheme } from '@apache-superset/core/theme';
+import type { AdhocMetric as CoreAdhocMetric } from '@superset-ui/core';
+import {
+ Button,
+ Form,
+ Popover,
+ Select,
+ Tabs,
+} from '@superset-ui/core/components';
+import LegacyAdhocMetric, {
+ EXPRESSION_TYPES,
+ fromCoreAdhocMetric,
+} from 'src/explore/components/controls/MetricControl/AdhocMetric';
+import { AGGREGATES_OPTIONS } from 'src/explore/constants';
+import SQLEditorWithValidation from 'src/components/SQLEditorWithValidation';
+import type { DatasetColumnMeta } from 'src/core/dashboard/datasetMetadata';
+
+const BLANK_METRIC = { expressionType: EXPRESSION_TYPES.SIMPLE };
+
+/** The plain, storable shape `handleChange` writes back into `node.props` —
+ * only the fields `@superset-ui/core`'s `AdhocMetric` type declares, not the
+ * legacy class's extra runtime-only fields (`datasourceWarning` and the
+ * like). */
+function toPlainMetric(draft: LegacyAdhocMetric): CoreAdhocMetric {
+ const base = {
+ label: draft.label,
+ hasCustomLabel: draft.hasCustomLabel,
+ optionName: draft.optionName,
+ };
+ if (draft.expressionType === EXPRESSION_TYPES.SQL) {
+ return {
+ ...base,
+ expressionType: 'SQL',
+ sqlExpression: draft.sqlExpression ?? '',
+ } as CoreAdhocMetric;
+ }
+ return {
+ ...base,
+ expressionType: 'SIMPLE',
+ column: draft.column!,
+ aggregate: draft.aggregate,
+ } as CoreAdhocMetric;
+}
+
+export interface AdhocMetricEditorProps {
+ /** `undefined` opens the editor on a blank draft (adding a new metric). */
+ value: CoreAdhocMetric | undefined;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (metric: CoreAdhocMetric) => void;
+ children: ReactNode;
+}
+
+/**
+ * Wraps `children` (the row's own clickable label) in a click-triggered
+ * popover editing one ad-hoc metric. Nothing commits until Save — Cancel, an
+ * outside click, or Escape all discard the draft, matching every other
+ * validated-commit surface in this panel where a candidate is built and
+ * discarded freely before it's actually written.
+ */
+export default function AdhocMetricEditor({
Review Comment:
Added AdhocMetricEditor.test.tsx in b1b5f3f539 covering the SIMPLE/SQL round-trip through toPlainMetric and the isValid() save gate (mocking SQLEditorWithValidation, since the real editor isn't simulate-able through RTL interactions). Two of those tests then failed in sharded-jest-tests from a selectOption-chaining bug — fixed in f8743d33e4 (see reply on 3907392883).
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912460803
##########
superset/core/api/core_api_injection.py:
##########
@@ -328,6 +328,14 @@ def decorator(cls: Any) -> Any:
cls.name = name
cls.description = description or ""
registry[key] = cls
+ try:
+ # Validate the static schema and dependency graph at import
+ # time. Runtime enrichers may perform permission-scoped data
+ # lookups, so they must wait until a request user exists.
+ cls.validate_control_schema()
+ except Exception:
+ registry.pop(key, None)
+ raise
Review Comment:
Confirmed, and documented rather than code-changed in b1b5f3f539: yes, one third-party widget's unbuildable schema fails that whole extension's backend import — but `init_extensions`'s own per-extension `try/except` (in `superset/initialization/__init__.py`) already contains that to just the one extension, not the rest of Superset or other extensions, the same boundary a registration collision already had. Spelled that out explicitly in `inject_widget_implementations`'s docstring so it's not left implicit.
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -354,13 +381,60 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
}
}
- // Our own edit — don't let the resync effect remount the form
- // for it (that would drop input focus mid-typing).
- selfEditRef.current = true;
- provider.updateProps(nodeId, next);
- maybeRefreshSchema(next);
+ validateSeqRef.current += 1;
+ const seq = validateSeqRef.current;
+ commitWidgetProps(nodeId, widgetType, next, {
+ // Runs immediately before the actual `updateProps` write, so
+ // a superseded validation (a newer edit already bumped
+ // `validateSeqRef`) is vetoed here rather than after the
+ // fact — by the time `.then()` below can check `seq`, a
+ // committed write can no longer be un-committed. Only once
+ // accepted does it set the flag telling the resync effect
+ // "this `props` change was mine", so it never remounts the
+ // form and drops input focus for an edit it made itself.
+ onBeforeCommit: () => {
+ if (validateSeqRef.current !== seq) return false;
+ selfEditRef.current = true;
+ return true;
+ },
+ })
+ .then(result => {
+ if (validateSeqRef.current !== seq) return;
+ setValidationErrors(result.ok ? [] : result.errors);
+ if (result.ok) {
+ maybeRefreshSchema(next);
+ } else {
+ // JsonForms is uncontrolled past mount (see the resync
+ // effect's own comment) — a rejected pick otherwise keeps
+ // showing in its field forever, while `config.formData`
+ // (which sibling controls like the column/metric pickers
+ // read `datasetId` off) is still the last *accepted*
+ // `props`, since nothing was committed. Remounting from
+ // `props` snaps the whole form back to what's actually
+ // stored, so every field agrees again.
+ setFormKey(key => key + 1);
Review Comment:
Fixed in f8743d33e4 — see the reply on 3893200989 above.
##########
tests/unit_tests/widgets/test_registry.py:
##########
@@ -126,3 +129,145 @@ def test_validate_control_values_flags_color_dimension_not_grouped() -> None:
def test_validate_control_values_empty_when_no_values() -> None:
# Nothing to validate (required-field checks live elsewhere).
assert _block("balloons").validate_control_values(None) == []
+
+
+def test_data_binding_declares_column_and_metric_controls() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ data_binding_props = schema["$defs"]["DataBinding"]["properties"]
+ assert data_binding_props["dimensions"]["x-control"] == "column-multi"
+ assert data_binding_props["metrics"]["x-control"] == "metric-multi"
+
+
+def test_color_dimension_declares_column_control() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ assert schema["properties"]["colorDimension"]["x-control"] == "column"
+
+
+def test_data_binding_schema_is_unchanged_after_metric_control_extraction() -> None:
+ # Golden fixture captured before DataBinding composed MetricControl, via
+ # registry.get("metric-tile").get_control_schema(None, None). Guards
+ # against both the MetricControl extraction and the field_order fix
+ # regressing the served schema — checked at the same boundary the
+ # Inspector consumes, not just raw model_json_schema().
+ schema = _block("metric-tile").get_control_schema(None, None)
+ data_binding = schema["$defs"]["DataBinding"]
+
+ assert list(data_binding["properties"]) == [
+ "datasetId",
+ "metrics",
+ "dimensions",
+ "rowLimit",
+ ]
+ assert data_binding["required"] == ["datasetId", "metrics"]
+ assert data_binding["properties"]["metrics"] == {
+ "description": (
+ "Metrics to fetch. Each entry is EITHER a string naming a saved "
Review Comment:
Fixed in f8743d33e4 — thanks, updated the pinned description text to match `composites.py`. Confirmed this was the actual `unit-tests` CI failure.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912440140
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
Review Comment:
Fixed in b1b5f3f539, then hardened further after a follow-up review comment (see the reply on 3904690345/3893201023 below) — the sequence check now runs inside `commitWidgetProps`'s `onBeforeCommit` hook, immediately before `provider.updateProps`, instead of in the caller's `.then()` after the write already landed.
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +161,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
value={value}
onChange={event => handleChange(path, event.target.value)}
/>
- <Typography.Text>{label}</Typography.Text>
- </Flex>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
);
}
-/** Base Semantic-Layer renderers plus the widget-control code/color ones. */
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ key={`${availableSaved.length}-${values.length}`}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={addOptions}
+ disabled={disabled}
+ onChange={next => {
+ if (next === ADD_CUSTOM_METRIC) {
+ setAddingNew(true);
+ } else {
+ onChange([...values, next as string]);
+ }
+ }}
+ />
+ )}
+ {addingNew && (
+ <AdhocMetricEditor
+ value={undefined}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={addingNew}
+ onOpenChange={setAddingNew}
+ onSave={next => {
+ onChange([...values, next]);
+ setAddingNew(false);
+ }}
+ >
+ {/* Zero-size trigger: opening this popover is driven entirely
+ by picking "Custom metric…" above, not by a click here. */}
+ <span />
+ </AdhocMetricEditor>
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "metric-multi"` — an ordered list of metric references. Falls
+ * back to the raw JSON editor (`CodeControl`) whenever no dataset is bound
+ * (or its fetch failed), or an entry is genuinely unrepresentable (see
+ * `isUnrepresentableMetric`); a well-formed mix of saved and ad-hoc metrics
+ * renders as `MetricEntryList`, not raw JSON.
+ */
+function MetricMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasUnrepresentable = values.some(isUnrepresentableMetric);
+
+ if (shouldFallBackToCode(datasetId, error) || hasUnrepresentable) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <MetricEntryList
+ label={props.label}
+ description={props.description}
+ values={values as MetricEntry[]}
+ metadata={metadata}
+ columns={metadata?.columns ?? []}
+ datasourceId={datasetId}
+ datasourceType={metadata?.datasourceType}
+ disallowAdhoc={disallowsAdhocMetrics(metadata)}
+ disabled={!props.enabled || loading}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * The value the dataset picker needs for an already-bound dataset. Bare —
+ * just the id (composite-encoded per below) — while the name is still
+ * resolving: `AsyncSelect` prefers a *labeled* value's own label over a
+ * matching loaded option's, so handing it a stale `String(datasetId)` label
+ * here would overwrite the real name the moment the matching option
+ * arrives, replaying the exact stuck-on-the-numeric-id symptom this control
+ * exists to avoid — instead, letting `AsyncSelect` fall back to the id on
+ * its own leaves it free to prefer a loaded option's label the instant one
+ * matches. Once `tableName` resolves, the explicit label takes over.
+ *
+ * `value` carries the composite `"ds:<id>"` encoding when the Semantic
+ * Layers flag is on, matching what `loadDatasetOptions` encodes its own
+ * options as in that mode (see `resolveDatasetPick` below) — an option and
+ * a bound value in two different encodings never match, which is what
+ * leaves a genuinely-selected dataset showing as unselected.
+ */
+export function toDatasetSelectValue(
+ datasetId: number | undefined,
+ tableName: string | undefined,
+ useSemanticLayers: boolean,
+): { label: string; value: number | string } | number | string | undefined {
+ if (datasetId === undefined) {
+ return undefined;
+ }
+ const value = useSemanticLayers ? toCompositeValue(datasetId) : datasetId;
+ return tableName ? { label: tableName, value } : value;
+}
+
+/**
+ * The numeric dataset id a picked option resolves to, or `undefined` when
+ * the pick should be rejected outright: a semantic view, which
+ * `DataBinding.datasetId` has no way to represent (SIP-182's `kind` is a
+ * connection-level concept, not a per-field one on this schema). Handles
+ * both encodings `loadDatasetOptions` can produce — a plain number when the
+ * Semantic Layers flag is off, or, flag on, a composite `"ds:<id>"` /
+ * `"sv:<id>"` string for every option (not only semantic views: with the
+ * flag on, ordinary datasets are composite-encoded too).
+ */
+export function resolveDatasetPick(
+ value: number | string | undefined,
+): number | undefined {
+ if (value === undefined || typeof value === 'number') {
+ return value;
+ }
+ return kindFromComposite(value) === 'semantic_view'
+ ? undefined
+ : fromCompositeValue(value);
+}
+
+/**
+ * `loadDatasetOptions` filtered down to plain datasets. `DataBinding` has no
+ * way to represent a semantic view (SIP-182's `kind` is a connection-level
+ * concept, not a per-field one on this schema), so rather than let one be
+ * picked and then reject it after the fact — leaving the closed select
+ * showing a value the widget never actually bound — it is never offered.
+ *
+ * Module-level, not a closure defined inside `DatasetControl`: `AsyncSelect`
+ * treats a change in its `options` function's identity as a reason to wipe
+ * its own fetched-options cache, and a fresh arrow function on every render
+ * would do exactly that.
+ *
+ * `totalCount` is passed through unfiltered — it counts datasets and
+ * semantic views together, same as the page `data` was drawn from before
+ * this function's own filter ran. With the Semantic Layers flag on and
+ * enough semantic views sorted ahead of the wanted datasets on the current
+ * search, a page that filters down to nothing still reports more rows
+ * exist, but `AsyncSelect` only requests the next page on scroll — and a
+ * dropdown with nothing to scroll never gets the chance. Narrow (flag off,
+ * the filter is a no-op and this never applies) and not addressed here;
+ * paging forward internally past an empty filtered page, or asking the
+ * backend to exclude semantic views from the query in the first place,
+ * would close it.
+ */
+async function loadDatasetOnlyOptions(
+ search: string,
+ page: number,
+ pageSize: number,
+) {
+ const { data, totalCount } = await loadDatasetOptions(search, page, pageSize);
+ return {
+ data: data.filter(option => option.kind !== 'semantic_view'),
+ totalCount,
+ };
Review Comment:
Fixed in b1b5f3f539 (`loadDatasetOnlyOptions` pages forward internally past a filtered-empty page), then refined in f8743d33e4 after a follow-up comment (3907392893) pointed out that the internal skip-ahead left `AsyncSelect`'s own page counter out of sync — it now tracks the actual backend page reached per search string.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912443922
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
Review Comment:
This was real, and my first fix attempt (reverting the form on any rejection, addressing 3904690353) actually made it worse — it discarded the dataset pick before metrics could be added on top, fully deadlocking new-widget construction. Fixed properly in f8743d33e4: `SchemaControlPanel` now tracks a `liveData` state that sibling controls read via `config.formData` regardless of validation outcome, so a widget can be built up field by field and only the final, complete candidate needs to actually pass. See the new dataset+column test in `SchemaControlPanel.test.tsx`.
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
Review Comment:
Fixed in b1b5f3f539 — the sequence check is now inside `onBeforeCommit`, which runs synchronously right before `provider.updateProps`, so a superseded validation is vetoed before it can write rather than after.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev merged PR #43588:
URL: https://github.com/apache/superset/pull/43588
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912450212
##########
superset/mcp_service/widgets/tool/set_widget_control_values.py:
##########
@@ -0,0 +1,107 @@
+# 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.
+
+"""MCP tool: set_widget_control_values"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.node_store import nodes
+from superset.mcp_service.widgets.utils import (
+ resolve_widget,
+ unknown_node_error,
+ unknown_widget_type_error,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _set_widget_control_values_impl(
+ node_id: str,
+ control_values: Dict[str, Any],
+) -> Dict[str, Any]:
+ """Pure logic: validate-then-commit a widget node's control values.
+
+ Builds a candidate (the node's current ``props`` shallow-merged with
+ ``control_values`` -- new keys override, everything else is preserved,
+ mirroring how the frontend's ``DashboardProvider.updateProps`` merges)
+ without touching the stored node. Validates the candidate through
+ ``Widget.validate_control_values`` -- the same commit-time gate the
+ ``/type/<widget_type>/validate`` REST endpoint uses. Only on success is
+ the node's ``props`` replaced with the candidate, a single dict
+ reassignment, so a validation failure leaves the stored node completely
+ unchanged: there is nothing to roll back because nothing was mutated in
+ place.
+ """
+ node = nodes.get(node_id)
+ if node is None:
+ return unknown_node_error(node_id)
Review Comment:
Acknowledged, not changed — this is accurately documented as an experimental limitation in the tool's own docstring today. Bridging the MCP process-local node store to the real frontend document is a larger feature (needs an addressable backend document Dashboard V2 doesn't have yet) than a review-comment-scoped fix; leaving it as explicitly-documented-WIP rather than attempting a partial bridge here.
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredChrome.ts:
##########
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's second, independent structured layer — chart
+ * chrome (title/legend/tooltip/axis labels) — matching `EchartsChrome`'s
+ * docstring in `superset/widgets/controls.py`: every field is optional and
+ * applies (or not) on its own, regardless of `chartType`/`customize`. A
+ * field left at its default never touches `echartsOptions`; when it does
+ * apply, it merges onto — rather than replaces — the matching section, so
+ * an unmanaged sibling property there (e.g. a hand-authored `legend.orient`)
+ * survives.
+ *
+ * `EchartsChromeValue` is deliberately flat, not grouped into
+ * `title`/`legend`/`tooltip`/`xAxis`/`yAxis` sub-objects — see
+ * `EchartsChrome`'s own docstring: JsonForms' generated control panel only
+ * renders one level of nested-object properties, so a two-level-deep
+ * `chrome.title.text` would render as an empty group with no fields inside.
+ */
+
+export interface EchartsChromeValue {
+ titleText?: string;
+ legendShow?: boolean;
+ legendPosition?: 'top' | 'bottom' | 'left' | 'right' | null;
+ tooltipTrigger?: 'item' | 'axis' | null;
+ xAxisName?: string;
+ xAxisRotate?: number;
+ xAxisFormat?: string;
+ yAxisName?: string;
+ yAxisRotate?: number;
+ yAxisFormat?: string;
+}
+
+// ECharts has no single "position" property on `legend` — placement comes
+// from `top`/`left` (each accepting a keyword or coordinate). This maps the
+// friendlier compass-direction picker onto the pair ECharts actually reads.
+const LEGEND_POSITION: Record<string, { top: string; left: string }> = {
+ top: { top: 'top', left: 'center' },
+ bottom: { top: 'bottom', left: 'center' },
+ left: { top: 'middle', left: 'left' },
+ right: { top: 'middle', left: 'right' },
+};
+
+function asRecord(value: unknown): Record<string, unknown> {
+ return value !== null && typeof value === 'object'
+ ? (value as Record<string, unknown>)
+ : {};
Review Comment:
Fixed in b1b5f3f539 — same fix as 3872820765 above: `asRecord`/the merge helper now preserves arrays and merges onto the first entry instead of spreading into an object.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912453408
##########
superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx:
##########
@@ -309,23 +278,51 @@ const PropsJsonEditor = ({
{error}
</p>
)}
+ {validationErrors.map(err => (
+ <p
+ key={`${err.loc.join('.')}:${err.message}`}
+ data-test="inspector-props-validation-error"
+ style={{
+ margin: `0 0 ${theme.sizeUnit}px`,
+ fontSize: theme.fontSizeSM,
+ color: theme.colorErrorText,
+ }}
+ >
+ {err.loc.length > 0 ? `${err.loc.join('.')}: ` : ''}
+ {err.message}
+ </p>
+ ))}
<div style={{ display: 'flex', gap: theme.sizeUnit }}>
<Button
buttonSize="xsmall"
buttonStyle="primary"
data-test="inspector-props-apply"
- disabled={parsed === undefined || !dirty}
- onClick={() => {
+ disabled={parsed === undefined || !dirty || submitting}
+ onClick={async () => {
if (parsed === undefined) {
return;
}
const removed = Object.keys(props ?? {}).filter(
key => !(key in parsed!),
);
- provider.updateProps(nodeId, {
+ const delta = {
...parsed,
...Object.fromEntries(removed.map(key => [key, undefined])),
- });
+ };
Review Comment:
Fixed in b1b5f3f539 — same root cause as 3872820793 above: `validate_control_values` no longer treats `{}` as "nothing to validate," so the delete-everything path through the JSON tab is now actually checked against required fields.
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
Review Comment:
Fixed in b1b5f3f539 — see the reply on 3872820772/3893201023 above. Confirmed with your framing specifically: `onBeforeCommit` now re-checks `validateSeqRef` right before the write, so it can veto a superseded commit instead of just failing to catch it after the fact.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912463428
##########
superset-frontend/src/pages/DashboardBuilderV2/AdhocMetricEditor.test.tsx:
##########
@@ -0,0 +1,179 @@
+/**
+ * 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 {
+ render,
+ screen,
+ selectOption,
+ userEvent,
+} from 'spec/helpers/testing-library';
+import AdhocMetricEditor from './AdhocMetricEditor';
+
+// The real editor (`SQLEditorWithValidation`) hosts a code-editor extension
+// point that isn't simulate-able through RTL interactions (see that
+// component's own test file, whose "calls onChange" test never actually
+// fires one) — stood in with a plain textbox that calls the same `onChange`
+// contract, so the SQL tab's round-trip through `toPlainMetric` is still
+// exercised end to end.
+jest.mock('src/components/SQLEditorWithValidation', () => ({
+ __esModule: true,
+ default: ({
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (next: string) => void;
+ }) => (
+ <textarea
+ aria-label="SQL Expression"
+ value={value}
+ onChange={event => onChange(event.target.value)}
+ />
+ ),
+}));
+
+const COLUMNS = [{ name: 'sales', verboseName: 'Sales', type: 0 }];
+
+const renderEditor = (
+ value: Parameters<typeof AdhocMetricEditor>[0]['value'],
+ onSave = jest.fn(),
+) => {
+ render(
+ <AdhocMetricEditor
+ value={value}
+ columns={COLUMNS}
+ datasourceId={1}
+ datasourceType="table"
+ open
+ onOpenChange={jest.fn()}
+ onSave={onSave}
+ >
+ <span>trigger</span>
+ </AdhocMetricEditor>,
+ );
+ return onSave;
+};
+
+test('a blank draft cannot be saved until a column and an aggregate are both picked', async () => {
+ renderEditor(undefined);
+
+ const save = screen.getByTestId('adhoc-metric-editor-save');
+ expect(save).toBeDisabled();
+
+ await selectOption('Sales', 'Column');
+ expect(save).toBeDisabled();
+
+ await selectOption('SUM', 'Aggregate');
Review Comment:
Fixed in f8743d33e4 — switched both to the combobox-then-find-by-text pattern already established for exactly this in `SchemaControlPanel.test.tsx` ("Not `selectOption` here: its `.rc-virtual-list` lookup grabs whichever dropdown is first in the DOM..."), which I should have used the first time. Also applied it to the third (Cancel) test for consistency even though you didn't flag it.
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -154,7 +157,18 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
const [series, setSeries] = useState<string[]>([]);
const [schema, setSchema] = useState<JsonSchema | null>(null);
+ const [formKey, setFormKey] = useState(0);
const [error, setError] = useState<string | null>(null);
+ const [validationErrors, setValidationErrors] = useState<
+ ControlValidationError[]
+ >([]);
+ // Guards against an earlier, slower validation round-trip landing after a
+ // later one — e.g. two edits typed close together — and overwriting the
+ // more recent result with a stale one.
+ const validateSeqRef = useRef(0);
+ // A validation error belongs to the widget it was raised for; selecting a
+ // different one shouldn't leave a stale message on screen.
+ useEffect(() => setValidationErrors([]), [nodeId]);
Review Comment:
Fixed in f8743d33e4 — same fix as 3893201091 above (added `nodeId` to the schema-fetch effect's deps).
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912441782
##########
superset/mcp_service/widgets/tool/set_widget_control_values.py:
##########
@@ -0,0 +1,107 @@
+# 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.
+
+"""MCP tool: set_widget_control_values"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.node_store import nodes
+from superset.mcp_service.widgets.utils import (
+ resolve_widget,
+ unknown_node_error,
+ unknown_widget_type_error,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _set_widget_control_values_impl(
+ node_id: str,
+ control_values: Dict[str, Any],
+) -> Dict[str, Any]:
+ """Pure logic: validate-then-commit a widget node's control values.
+
+ Builds a candidate (the node's current ``props`` shallow-merged with
+ ``control_values`` -- new keys override, everything else is preserved,
+ mirroring how the frontend's ``DashboardProvider.updateProps`` merges)
+ without touching the stored node. Validates the candidate through
+ ``Widget.validate_control_values`` -- the same commit-time gate the
+ ``/type/<widget_type>/validate`` REST endpoint uses. Only on success is
+ the node's ``props`` replaced with the candidate, a single dict
+ reassignment, so a validation failure leaves the stored node completely
+ unchanged: there is nothing to roll back because nothing was mutated in
+ place.
+ """
+ node = nodes.get(node_id)
+ if node is None:
+ return unknown_node_error(node_id)
+
+ widget = resolve_widget(node.widget_type)
+ if widget is None:
+ return unknown_widget_type_error(node.widget_type)
+
+ candidate = {**node.props, **control_values}
+ if errors := widget.validate_control_values(candidate):
+ return {"errors": errors}
+
+ # model_validate succeeded once already inside validate_control_values;
+ # this second call is cheap and deterministic on the same input, and is
+ # what gets us the normalized (coerced, alias-keyed) values to return and
+ # store, rather than the raw candidate validate_control_values discards.
+ normalized = widget.controls_class.model_validate(candidate).model_dump(
Review Comment:
Fixed in b1b5f3f539 — `validate_control_values` now only skips validation for `None` (not any falsy value), so `{}` goes through `model_validate` and returns proper structured errors for missing required fields instead of either silently passing or raising unhandled here.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912437823
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredChrome.ts:
##########
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's second, independent structured layer — chart
+ * chrome (title/legend/tooltip/axis labels) — matching `EchartsChrome`'s
+ * docstring in `superset/widgets/controls.py`: every field is optional and
+ * applies (or not) on its own, regardless of `chartType`/`customize`. A
+ * field left at its default never touches `echartsOptions`; when it does
+ * apply, it merges onto — rather than replaces — the matching section, so
+ * an unmanaged sibling property there (e.g. a hand-authored `legend.orient`)
+ * survives.
+ *
+ * `EchartsChromeValue` is deliberately flat, not grouped into
+ * `title`/`legend`/`tooltip`/`xAxis`/`yAxis` sub-objects — see
+ * `EchartsChrome`'s own docstring: JsonForms' generated control panel only
+ * renders one level of nested-object properties, so a two-level-deep
+ * `chrome.title.text` would render as an empty group with no fields inside.
+ */
+
+export interface EchartsChromeValue {
+ titleText?: string;
+ legendShow?: boolean;
+ legendPosition?: 'top' | 'bottom' | 'left' | 'right' | null;
+ tooltipTrigger?: 'item' | 'axis' | null;
+ xAxisName?: string;
+ xAxisRotate?: number;
+ xAxisFormat?: string;
+ yAxisName?: string;
+ yAxisRotate?: number;
+ yAxisFormat?: string;
+}
+
+// ECharts has no single "position" property on `legend` — placement comes
+// from `top`/`left` (each accepting a keyword or coordinate). This maps the
+// friendlier compass-direction picker onto the pair ECharts actually reads.
+const LEGEND_POSITION: Record<string, { top: string; left: string }> = {
+ top: { top: 'top', left: 'center' },
+ bottom: { top: 'bottom', left: 'center' },
+ left: { top: 'middle', left: 'left' },
+ right: { top: 'middle', left: 'right' },
+};
+
+function asRecord(value: unknown): Record<string, unknown> {
+ return value !== null && typeof value === 'object'
+ ? (value as Record<string, unknown>)
+ : {};
Review Comment:
Fixed in b1b5f3f539 — `asRecord` now checks `Array.isArray` and, when the existing value is an array, merges the structured override onto its first entry (ECharts' own convention) instead of spreading the whole array into an object. Added regression tests for a multi-axis `xAxis` array and an array-valued `legend` in `echartsStructuredChrome.test.ts`.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "netlify[bot] (via GitHub)" <gi...@apache.org>.
netlify[bot] commented on PR #43588:
URL: https://github.com/apache/superset/pull/43588#issuecomment-5440757137
### <span aria-hidden="true">✅</span> Deploy Preview for *superset-docs-preview* ready!
| Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | 5b048b61b96129c2477295acb559f284d6bcb7c0 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/projects/superset-docs-preview/deploys/6a904bbf038a660008215974 |
|<span aria-hidden="true">😎</span> Deploy Preview | [https://deploy-preview-43588--superset-docs-preview.netlify.app](https://deploy-preview-43588--superset-docs-preview.netlify.app) |
|<span aria-hidden="true">📱</span> Preview on mobile | <details><summary> Toggle QR Code... </summary><br /><br /><br /><br />_Use your smartphone camera to open QR code link._</details> |
|<span aria-hidden="true">🤖</span> Make changes | [Run an agent on this branch](https://app.netlify.com/projects/superset-docs-preview/agent-runs#DctLDoQgDADQu3QtsTqKnxt4jGJRSZQS_IxmMneX3du8H4QoWzigB8jARPLjkmz9zfbKg4w5074YocjqKpVldzg_q9OpaZVvKmzDKs_AKWnqsDJmwk9LWiNiWxZ111TwfwE) |
---
<!-- [superset-docs-preview Preview](https://deploy-preview-43588--superset-docs-preview.netlify.app) -->
_To edit notification comments on pull requests, go to your [Netlify project configuration](https://app.netlify.com/projects/superset-docs-preview/configuration/notifications#deploy-notifications)._
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "codecov[bot] (via GitHub)" <gi...@apache.org>.
codecov[bot] commented on PR #43588:
URL: https://github.com/apache/superset/pull/43588#issuecomment-5442653440
## [Codecov](https://app.codecov.io/gh/apache/superset/pull/43588?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) Report
:x: Patch coverage is `83.13253%` with `112 lines` in your changes missing coverage. Please review.
:warning: Please [upload](https://docs.codecov.com/docs/codecov-uploader) report for BASE (`dashboard-v2@ae0dc90`). [Learn more](https://docs.codecov.io/docs/error-reference?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#section-missing-base-commit) about missing BASE report.
| [Files with missing lines](https://app.codecov.io/gh/apache/superset/pull/43588?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Patch % | Lines |
|---|---|---|
| [superset/widgets/builtin.py](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset%2Fwidgets%2Fbuiltin.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvd2lkZ2V0cy9idWlsdGluLnB5) | 26.66% | [44 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...ages/DashboardBuilderV2/schemaControlRenderers.tsx](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset-frontend%2Fsrc%2Fpages%2FDashboardBuilderV2%2FschemaControlRenderers.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvc3JjL3BhZ2VzL0Rhc2hib2FyZEJ1aWxkZXJWMi9zY2hlbWFDb250cm9sUmVuZGVyZXJzLnRzeA==) | 90.11% | [26 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...pages/DashboardBuilderV2/controlValueValidation.ts](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset-frontend%2Fsrc%2Fpages%2FDashboardBuilderV2%2FcontrolValueValidation.ts&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvc3JjL3BhZ2VzL0Rhc2hib2FyZEJ1aWxkZXJWMi9jb250cm9sVmFsdWVWYWxpZGF0aW9uLnRz) | 46.15% | [14 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...\_service/widgets/tool/set\_widget\_control\_values.py](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset%2Fmcp_service%2Fwidgets%2Ftool%2Fset_widget_control_values.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvbWNwX3NlcnZpY2Uvd2lkZ2V0cy90b29sL3NldF93aWRnZXRfY29udHJvbF92YWx1ZXMucHk=) | 43.47% | [13 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...src/pages/DashboardBuilderV2/AdhocMetricEditor.tsx](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset-frontend%2Fsrc%2Fpages%2FDashboardBuilderV2%2FAdhocMetricEditor.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvc3JjL3BhZ2VzL0Rhc2hib2FyZEJ1aWxkZXJWMi9BZGhvY01ldHJpY0VkaXRvci50c3g=) | 85.71% | [5 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...rc/pages/DashboardBuilderV2/SchemaControlPanel.tsx](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset-frontend%2Fsrc%2Fpages%2FDashboardBuilderV2%2FSchemaControlPanel.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvc3JjL3BhZ2VzL0Rhc2hib2FyZEJ1aWxkZXJWMi9TY2hlbWFDb250cm9sUGFuZWwudHN4) | 88.57% | [4 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [superset/core/api/core\_api\_injection.py](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset%2Fcore%2Fapi%2Fcore_api_injection.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvY29yZS9hcGkvY29yZV9hcGlfaW5qZWN0aW9uLnB5) | 40.00% | [3 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [...rontend/src/pages/DashboardBuilderV2/Inspector.tsx](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset-frontend%2Fsrc%2Fpages%2FDashboardBuilderV2%2FInspector.tsx&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQtZnJvbnRlbmQvc3JjL3BhZ2VzL0Rhc2hib2FyZEJ1aWxkZXJWMi9JbnNwZWN0b3IudHN4) | 90.00% | [2 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
| [superset/mcp\_service/widgets/utils.py](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&filepath=superset%2Fmcp_service%2Fwidgets%2Futils.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#diff-c3VwZXJzZXQvbWNwX3NlcnZpY2Uvd2lkZ2V0cy91dGlscy5weQ==) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/apache/superset/pull/43588?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) |
<details><summary>Additional details and impacted files</summary>
```diff
@@ Coverage Diff @@
## dashboard-v2 #43588 +/- ##
===============================================
Coverage ? 57.03%
===============================================
Files ? 2854
Lines ? 160781
Branches ? 36878
===============================================
Hits ? 91705
Misses ? 68249
Partials ? 827
```
| [Flag](https://app.codecov.io/gh/apache/superset/pull/43588/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | Coverage Δ | |
|---|---|---|
| [hive](https://app.codecov.io/gh/apache/superset/pull/43588/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `38.46% <50.80%> (?)` | |
| [javascript](https://app.codecov.io/gh/apache/superset/pull/43588/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `71.69% <90.55%> (?)` | |
| [presto](https://app.codecov.io/gh/apache/superset/pull/43588/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `40.37% <50.80%> (?)` | |
| [python](https://app.codecov.io/gh/apache/superset/pull/43588/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `41.60% <50.80%> (?)` | |
| [unit](https://app.codecov.io/gh/apache/superset/pull/43588/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache) | `100.00% <ø> (?)` | |
Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache#carryforward-flags-in-the-pull-request-comment) to find out more.
</details>
[:umbrella: View full report in Codecov by Harness](https://app.codecov.io/gh/apache/superset/pull/43588?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=apache).
<details><summary> :rocket: New features to boost your workflow: </summary>
- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "bito-code-review[bot] (via GitHub)" <gi...@apache.org>.
bito-code-review[bot] commented on PR #43588:
URL: https://github.com/apache/superset/pull/43588#issuecomment-5440721704
AI Code Review is in progress (usually takes 3 to 15 minutes unless it's a very large PR).
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912465271
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -72,45 +144,1165 @@ function CodeControl({
};
return (
- <Flex vertical gap="small">
- <Typography.Text strong>{label}</Typography.Text>
- <textarea
+ <Form.Item label={label} tooltip={description}>
+ <Input.TextArea
value={text}
onChange={onChange}
rows={12}
spellCheck={false}
- style={{ fontFamily: 'monospace', fontSize: 12, width: '100%' }}
+ style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
{error && <Typography.Text type="danger">{error}</Typography.Text>}
- </Flex>
+ </Form.Item>
);
}
-/** Native color picker for an `x-control: color` string field. */
+/** Themed color picker for an `x-control: color` string field — the same
+ * `ColorPicker` `SeriesEntryPropertyControl` uses for a per-series `color`,
+ * so a swatch reads the same way everywhere in the panel and follows the
+ * active theme rather than the browser's native (unthemed) picker. */
function ColorControl({
data,
handleChange,
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
- <input
- type="color"
+ <Form.Item label={label} tooltip={description}>
+ <ColorPicker
+ value={value}
+ onChange={next => handleChange(path, next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', label)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: value,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ // `addingNew` is in the key too: picking "Custom metric…"
+ // changes neither `availableSaved.length` nor `values.length`
+ // (the metric isn't added until the editor's own save), so
+ // without it this wouldn't remount on that pick — leaving
+ // rc-select's echoed "Custom metric…" label on screen even
+ // after canceling out of the editor.
+ key={`${availableSaved.length}-${values.length}-${addingNew}`}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={addOptions}
+ disabled={disabled}
+ onChange={next => {
+ if (next === ADD_CUSTOM_METRIC) {
+ setAddingNew(true);
+ } else {
+ onChange([...values, next as string]);
+ }
+ }}
+ />
+ )}
+ {addingNew && (
+ <AdhocMetricEditor
+ value={undefined}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={addingNew}
+ onOpenChange={setAddingNew}
+ onSave={next => {
+ onChange([...values, next]);
+ setAddingNew(false);
+ }}
+ >
+ {/* Zero-size trigger: opening this popover is driven entirely
+ by picking "Custom metric…" above, not by a click here. */}
+ <span />
+ </AdhocMetricEditor>
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "metric-multi"` — an ordered list of metric references. Falls
+ * back to the raw JSON editor (`CodeControl`) whenever no dataset is bound
+ * (or its fetch failed), or an entry is genuinely unrepresentable (see
+ * `isUnrepresentableMetric`); a well-formed mix of saved and ad-hoc metrics
+ * renders as `MetricEntryList`, not raw JSON.
+ */
+function MetricMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasUnrepresentable = values.some(isUnrepresentableMetric);
+
+ if (shouldFallBackToCode(datasetId, error) || hasUnrepresentable) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <MetricEntryList
+ label={props.label}
+ description={props.description}
+ values={values as MetricEntry[]}
+ metadata={metadata}
+ columns={metadata?.columns ?? []}
+ datasourceId={datasetId}
+ datasourceType={metadata?.datasourceType}
+ disallowAdhoc={disallowsAdhocMetrics(metadata)}
+ disabled={!props.enabled || loading}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * The value the dataset picker needs for an already-bound dataset. Bare —
+ * just the id (composite-encoded per below) — while the name is still
+ * resolving: `AsyncSelect` prefers a *labeled* value's own label over a
+ * matching loaded option's, so handing it a stale `String(datasetId)` label
+ * here would overwrite the real name the moment the matching option
+ * arrives, replaying the exact stuck-on-the-numeric-id symptom this control
+ * exists to avoid — instead, letting `AsyncSelect` fall back to the id on
+ * its own leaves it free to prefer a loaded option's label the instant one
+ * matches. Once `tableName` resolves, the explicit label takes over.
+ *
+ * `value` carries the composite `"ds:<id>"` encoding when the Semantic
+ * Layers flag is on, matching what `loadDatasetOptions` encodes its own
+ * options as in that mode (see `resolveDatasetPick` below) — an option and
+ * a bound value in two different encodings never match, which is what
+ * leaves a genuinely-selected dataset showing as unselected.
+ */
+export function toDatasetSelectValue(
+ datasetId: number | undefined,
+ tableName: string | undefined,
+ useSemanticLayers: boolean,
+): { label: string; value: number | string } | number | string | undefined {
+ if (datasetId === undefined) {
+ return undefined;
+ }
+ const value = useSemanticLayers ? toCompositeValue(datasetId) : datasetId;
+ return tableName ? { label: tableName, value } : value;
+}
+
+/**
+ * The numeric dataset id a picked option resolves to, or `undefined` when
+ * the pick should be rejected outright: a semantic view, which
+ * `DataBinding.datasetId` has no way to represent (SIP-182's `kind` is a
+ * connection-level concept, not a per-field one on this schema). Handles
+ * both encodings `loadDatasetOptions` can produce — a plain number when the
+ * Semantic Layers flag is off, or, flag on, a composite `"ds:<id>"` /
+ * `"sv:<id>"` string for every option (not only semantic views: with the
+ * flag on, ordinary datasets are composite-encoded too).
+ */
+export function resolveDatasetPick(
+ value: number | string | undefined,
+): number | undefined {
+ if (value === undefined || typeof value === 'number') {
+ return value;
+ }
+ return kindFromComposite(value) === 'semantic_view'
+ ? undefined
+ : fromCompositeValue(value);
+}
+
+/**
+ * `loadDatasetOptions` filtered down to plain datasets. `DataBinding` has no
+ * way to represent a semantic view (SIP-182's `kind` is a connection-level
+ * concept, not a per-field one on this schema), so rather than let one be
+ * picked and then reject it after the fact — leaving the closed select
+ * showing a value the widget never actually bound — it is never offered.
+ *
+ * Module-level, not a closure defined inside `DatasetControl`: `AsyncSelect`
+ * treats a change in its `options` function's identity as a reason to wipe
+ * its own fetched-options cache, and a fresh arrow function on every render
+ * would do exactly that.
+ *
+ * `totalCount` counts datasets and semantic views together — the same mix
+ * the backend page was drawn from — so `AsyncSelect`'s own pagination math
+ * (`page * pageSize < totalCount`) still knows there's more to fetch even
+ * once every semantic view is filtered out here. What it can't do on its
+ * own is trigger that next fetch: it only requests a page on scroll, and a
+ * dropdown showing zero options has nothing to scroll. So when a page
+ * filters down to empty while the backend says more rows exist somewhere
+ * ahead — every semantic view sorted before the datasets wanted, on the
+ * current search — this pages forward internally, on the same call,
+ * until either a dataset survives the filter or the underlying result set
+ * itself runs out.
+ */
+export async function loadDatasetOnlyOptions(
+ search: string,
+ page: number,
+ pageSize: number,
+) {
+ let currentPage = page;
+ for (;;) {
+ const { data, totalCount } = await loadDatasetOptions(
+ search,
+ currentPage,
+ pageSize,
+ );
+ const filtered = data.filter(option => option.kind !== 'semantic_view');
+ const isLastPage = (currentPage + 1) * pageSize >= totalCount;
+ if (filtered.length > 0 || isLastPage) {
+ return { data: filtered, totalCount };
+ }
+ currentPage += 1;
Review Comment:
Yes, exactly that — confirmed and fixed in f8743d33e4. `loadDatasetOnlyOptions` now tracks the actual backend page reached per search string (`nextBackendPageBySearch`), so a later call for the same search continues from there instead of from `AsyncSelect`'s own oblivious counter. Added a test reproducing the exact scenario (3 backend pages skipped internally, then a second call simulating AsyncSelect's next scroll) confirming it goes straight to the right page instead of re-fetching the ones already consumed.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "msyavuz (via GitHub)" <gi...@apache.org>.
msyavuz commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3907392886
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -154,7 +157,18 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
const [series, setSeries] = useState<string[]>([]);
const [schema, setSchema] = useState<JsonSchema | null>(null);
+ const [formKey, setFormKey] = useState(0);
const [error, setError] = useState<string | null>(null);
+ const [validationErrors, setValidationErrors] = useState<
+ ControlValidationError[]
+ >([]);
+ // Guards against an earlier, slower validation round-trip landing after a
+ // later one — e.g. two edits typed close together — and overwriting the
+ // more recent result with a stale one.
+ const validateSeqRef = useRef(0);
+ // A validation error belongs to the widget it was raised for; selecting a
+ // different one shouldn't leave a stale message on screen.
+ useEffect(() => setValidationErrors([]), [nodeId]);
Review Comment:
Errors reset on `nodeId` but the schema doesn't — the fetch effect at line 231 deps on `[widgetType, seriesKey]` only, so selecting another widget of the same type keeps the first node's enriched controls whenever both resolve to the same `series` (e.g. neither has a color dimension).
##########
tests/unit_tests/widgets/test_registry.py:
##########
@@ -126,3 +129,145 @@ def test_validate_control_values_flags_color_dimension_not_grouped() -> None:
def test_validate_control_values_empty_when_no_values() -> None:
# Nothing to validate (required-field checks live elsewhere).
assert _block("balloons").validate_control_values(None) == []
+
+
+def test_data_binding_declares_column_and_metric_controls() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ data_binding_props = schema["$defs"]["DataBinding"]["properties"]
+ assert data_binding_props["dimensions"]["x-control"] == "column-multi"
+ assert data_binding_props["metrics"]["x-control"] == "metric-multi"
+
+
+def test_color_dimension_declares_column_control() -> None:
+ schema = _block("balloons").get_control_schema(None, None)
+ assert schema["properties"]["colorDimension"]["x-control"] == "column"
+
+
+def test_data_binding_schema_is_unchanged_after_metric_control_extraction() -> None:
+ # Golden fixture captured before DataBinding composed MetricControl, via
+ # registry.get("metric-tile").get_control_schema(None, None). Guards
+ # against both the MetricControl extraction and the field_order fix
+ # regressing the served schema — checked at the same boundary the
+ # Inspector consumes, not just raw model_json_schema().
+ schema = _block("metric-tile").get_control_schema(None, None)
+ data_binding = schema["$defs"]["DataBinding"]
+
+ assert list(data_binding["properties"]) == [
+ "datasetId",
+ "metrics",
+ "dimensions",
+ "rowLimit",
+ ]
+ assert data_binding["required"] == ["datasetId", "metrics"]
+ assert data_binding["properties"]["metrics"] == {
+ "description": (
+ "Metrics to fetch. Each entry is EITHER a string naming a saved "
Review Comment:
This still pins the pre-`b1b5f3f5` wording verbatim while `composites.py` now documents the SQL shape too — it's what's failing `unit-tests`.
##########
superset-frontend/src/pages/DashboardBuilderV2/AdhocMetricEditor.test.tsx:
##########
@@ -0,0 +1,179 @@
+/**
+ * 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 {
+ render,
+ screen,
+ selectOption,
+ userEvent,
+} from 'spec/helpers/testing-library';
+import AdhocMetricEditor from './AdhocMetricEditor';
+
+// The real editor (`SQLEditorWithValidation`) hosts a code-editor extension
+// point that isn't simulate-able through RTL interactions (see that
+// component's own test file, whose "calls onChange" test never actually
+// fires one) — stood in with a plain textbox that calls the same `onChange`
+// contract, so the SQL tab's round-trip through `toPlainMetric` is still
+// exercised end to end.
+jest.mock('src/components/SQLEditorWithValidation', () => ({
+ __esModule: true,
+ default: ({
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (next: string) => void;
+ }) => (
+ <textarea
+ aria-label="SQL Expression"
+ value={value}
+ onChange={event => onChange(event.target.value)}
+ />
+ ),
+}));
+
+const COLUMNS = [{ name: 'sales', verboseName: 'Sales', type: 0 }];
+
+const renderEditor = (
+ value: Parameters<typeof AdhocMetricEditor>[0]['value'],
+ onSave = jest.fn(),
+) => {
+ render(
+ <AdhocMetricEditor
+ value={value}
+ columns={COLUMNS}
+ datasourceId={1}
+ datasourceType="table"
+ open
+ onOpenChange={jest.fn()}
+ onSave={onSave}
+ >
+ <span>trigger</span>
+ </AdhocMetricEditor>,
+ );
+ return onSave;
+};
+
+test('a blank draft cannot be saved until a column and an aggregate are both picked', async () => {
+ renderEditor(undefined);
+
+ const save = screen.getByTestId('adhoc-metric-editor-save');
+ expect(save).toBeDisabled();
+
+ await selectOption('Sales', 'Column');
+ expect(save).toBeDisabled();
+
+ await selectOption('SUM', 'Aggregate');
Review Comment:
This and line 89 both fail in `sharded-jest-tests (2)` with "Unable to find an element with the text: SUM" — the Column dropdown opened on the line above stays mounted, so `selectOption`'s first-match `.rc-virtual-list` query never reaches the Aggregate list.
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -354,13 +381,60 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
}
}
- // Our own edit — don't let the resync effect remount the form
- // for it (that would drop input focus mid-typing).
- selfEditRef.current = true;
- provider.updateProps(nodeId, next);
- maybeRefreshSchema(next);
+ validateSeqRef.current += 1;
+ const seq = validateSeqRef.current;
+ commitWidgetProps(nodeId, widgetType, next, {
+ // Runs immediately before the actual `updateProps` write, so
+ // a superseded validation (a newer edit already bumped
+ // `validateSeqRef`) is vetoed here rather than after the
+ // fact — by the time `.then()` below can check `seq`, a
+ // committed write can no longer be un-committed. Only once
+ // accepted does it set the flag telling the resync effect
+ // "this `props` change was mine", so it never remounts the
+ // form and drops input focus for an edit it made itself.
+ onBeforeCommit: () => {
+ if (validateSeqRef.current !== seq) return false;
+ selfEditRef.current = true;
+ return true;
+ },
+ })
+ .then(result => {
+ if (validateSeqRef.current !== seq) return;
+ setValidationErrors(result.ok ? [] : result.errors);
+ if (result.ok) {
+ maybeRefreshSchema(next);
+ } else {
+ // JsonForms is uncontrolled past mount (see the resync
+ // effect's own comment) — a rejected pick otherwise keeps
+ // showing in its field forever, while `config.formData`
+ // (which sibling controls like the column/metric pickers
+ // read `datasetId` off) is still the last *accepted*
+ // `props`, since nothing was committed. Remounting from
+ // `props` snaps the whole form back to what's actually
+ // stored, so every field agrees again.
+ setFormKey(key => key + 1);
Review Comment:
`DataBinding` makes both `datasetId` and `metrics` mandatory and `placeBlock` seeds no props, so on a freshly dropped widget the dataset pick alone fails validation and this remount discards it — no field order gets a new widget to a valid state through the Form tab.
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -72,45 +144,1165 @@ function CodeControl({
};
return (
- <Flex vertical gap="small">
- <Typography.Text strong>{label}</Typography.Text>
- <textarea
+ <Form.Item label={label} tooltip={description}>
+ <Input.TextArea
value={text}
onChange={onChange}
rows={12}
spellCheck={false}
- style={{ fontFamily: 'monospace', fontSize: 12, width: '100%' }}
+ style={{ fontFamily: 'monospace', fontSize: 12 }}
/>
{error && <Typography.Text type="danger">{error}</Typography.Text>}
- </Flex>
+ </Form.Item>
);
}
-/** Native color picker for an `x-control: color` string field. */
+/** Themed color picker for an `x-control: color` string field — the same
+ * `ColorPicker` `SeriesEntryPropertyControl` uses for a per-series `color`,
+ * so a swatch reads the same way everywhere in the panel and follows the
+ * active theme rather than the browser's native (unthemed) picker. */
function ColorControl({
data,
handleChange,
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
- <input
- type="color"
+ <Form.Item label={label} tooltip={description}>
+ <ColorPicker
+ value={value}
+ onChange={next => handleChange(path, next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', label)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: value,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ // `addingNew` is in the key too: picking "Custom metric…"
+ // changes neither `availableSaved.length` nor `values.length`
+ // (the metric isn't added until the editor's own save), so
+ // without it this wouldn't remount on that pick — leaving
+ // rc-select's echoed "Custom metric…" label on screen even
+ // after canceling out of the editor.
+ key={`${availableSaved.length}-${values.length}-${addingNew}`}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={addOptions}
+ disabled={disabled}
+ onChange={next => {
+ if (next === ADD_CUSTOM_METRIC) {
+ setAddingNew(true);
+ } else {
+ onChange([...values, next as string]);
+ }
+ }}
+ />
+ )}
+ {addingNew && (
+ <AdhocMetricEditor
+ value={undefined}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={addingNew}
+ onOpenChange={setAddingNew}
+ onSave={next => {
+ onChange([...values, next]);
+ setAddingNew(false);
+ }}
+ >
+ {/* Zero-size trigger: opening this popover is driven entirely
+ by picking "Custom metric…" above, not by a click here. */}
+ <span />
+ </AdhocMetricEditor>
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "metric-multi"` — an ordered list of metric references. Falls
+ * back to the raw JSON editor (`CodeControl`) whenever no dataset is bound
+ * (or its fetch failed), or an entry is genuinely unrepresentable (see
+ * `isUnrepresentableMetric`); a well-formed mix of saved and ad-hoc metrics
+ * renders as `MetricEntryList`, not raw JSON.
+ */
+function MetricMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasUnrepresentable = values.some(isUnrepresentableMetric);
+
+ if (shouldFallBackToCode(datasetId, error) || hasUnrepresentable) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <MetricEntryList
+ label={props.label}
+ description={props.description}
+ values={values as MetricEntry[]}
+ metadata={metadata}
+ columns={metadata?.columns ?? []}
+ datasourceId={datasetId}
+ datasourceType={metadata?.datasourceType}
+ disallowAdhoc={disallowsAdhocMetrics(metadata)}
+ disabled={!props.enabled || loading}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * The value the dataset picker needs for an already-bound dataset. Bare —
+ * just the id (composite-encoded per below) — while the name is still
+ * resolving: `AsyncSelect` prefers a *labeled* value's own label over a
+ * matching loaded option's, so handing it a stale `String(datasetId)` label
+ * here would overwrite the real name the moment the matching option
+ * arrives, replaying the exact stuck-on-the-numeric-id symptom this control
+ * exists to avoid — instead, letting `AsyncSelect` fall back to the id on
+ * its own leaves it free to prefer a loaded option's label the instant one
+ * matches. Once `tableName` resolves, the explicit label takes over.
+ *
+ * `value` carries the composite `"ds:<id>"` encoding when the Semantic
+ * Layers flag is on, matching what `loadDatasetOptions` encodes its own
+ * options as in that mode (see `resolveDatasetPick` below) — an option and
+ * a bound value in two different encodings never match, which is what
+ * leaves a genuinely-selected dataset showing as unselected.
+ */
+export function toDatasetSelectValue(
+ datasetId: number | undefined,
+ tableName: string | undefined,
+ useSemanticLayers: boolean,
+): { label: string; value: number | string } | number | string | undefined {
+ if (datasetId === undefined) {
+ return undefined;
+ }
+ const value = useSemanticLayers ? toCompositeValue(datasetId) : datasetId;
+ return tableName ? { label: tableName, value } : value;
+}
+
+/**
+ * The numeric dataset id a picked option resolves to, or `undefined` when
+ * the pick should be rejected outright: a semantic view, which
+ * `DataBinding.datasetId` has no way to represent (SIP-182's `kind` is a
+ * connection-level concept, not a per-field one on this schema). Handles
+ * both encodings `loadDatasetOptions` can produce — a plain number when the
+ * Semantic Layers flag is off, or, flag on, a composite `"ds:<id>"` /
+ * `"sv:<id>"` string for every option (not only semantic views: with the
+ * flag on, ordinary datasets are composite-encoded too).
+ */
+export function resolveDatasetPick(
+ value: number | string | undefined,
+): number | undefined {
+ if (value === undefined || typeof value === 'number') {
+ return value;
+ }
+ return kindFromComposite(value) === 'semantic_view'
+ ? undefined
+ : fromCompositeValue(value);
+}
+
+/**
+ * `loadDatasetOptions` filtered down to plain datasets. `DataBinding` has no
+ * way to represent a semantic view (SIP-182's `kind` is a connection-level
+ * concept, not a per-field one on this schema), so rather than let one be
+ * picked and then reject it after the fact — leaving the closed select
+ * showing a value the widget never actually bound — it is never offered.
+ *
+ * Module-level, not a closure defined inside `DatasetControl`: `AsyncSelect`
+ * treats a change in its `options` function's identity as a reason to wipe
+ * its own fetched-options cache, and a fresh arrow function on every render
+ * would do exactly that.
+ *
+ * `totalCount` counts datasets and semantic views together — the same mix
+ * the backend page was drawn from — so `AsyncSelect`'s own pagination math
+ * (`page * pageSize < totalCount`) still knows there's more to fetch even
+ * once every semantic view is filtered out here. What it can't do on its
+ * own is trigger that next fetch: it only requests a page on scroll, and a
+ * dropdown showing zero options has nothing to scroll. So when a page
+ * filters down to empty while the backend says more rows exist somewhere
+ * ahead — every semantic view sorted before the datasets wanted, on the
+ * current search — this pages forward internally, on the same call,
+ * until either a dataset survives the filter or the underlying result set
+ * itself runs out.
+ */
+export async function loadDatasetOnlyOptions(
+ search: string,
+ page: number,
+ pageSize: number,
+) {
+ let currentPage = page;
+ for (;;) {
+ const { data, totalCount } = await loadDatasetOptions(
+ search,
+ currentPage,
+ pageSize,
+ );
+ const filtered = data.filter(option => option.kind !== 'semantic_view');
+ const isLastPage = (currentPage + 1) * pageSize >= totalCount;
+ if (filtered.length > 0 || isLastPage) {
+ return { data: filtered, totalCount };
+ }
+ currentPage += 1;
Review Comment:
Jumping the internal page counter 0→3 leaves AsyncSelect's own counter at 0 — do the next three scrolls then just re-return already-merged rows and appear to load nothing?
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "msyavuz (via GitHub)" <gi...@apache.org>.
msyavuz commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3914013246
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,137 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded. What
+ * finally lands in the store is the endpoint's own normalized dump of the
+ * candidate, not the raw one this sent — the same value the MCP write path
+ * stores, so the two routes into `node.props` can't quietly disagree.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<{
+ errors: ControlValidationError[];
+ values: Record<string, unknown> | null;
+}> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (
+ json as {
+ result: {
+ errors: ControlValidationError[];
+ values: Record<string, unknown> | null;
+ };
+ }
+ ).result;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to both veto a commit
+ * that's gone stale during the async validation round-trip (returning
+ * `false` skips `updateProps` entirely — the caller's own sequence check
+ * can't do this after the fact, since by then the write already landed) and
+ * set a flag in the exact tick its own commit lands (e.g. `SchemaControlPanel`
+ * telling its own resync effect "this `props` change was mine").
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => boolean | void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const { errors, values } = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ // The backend's own Pydantic-normalized candidate (coerced types,
+ // alias-keyed) — not the raw one just sent for validation — so this
+ // commits exactly what `set_widget_control_values` (the MCP write path
+ // through the very same gate) would store, rather than something that can
+ // quietly disagree with it (e.g. a numeric field a `Select` stores as a
+ // string, which Pydantic accepts by coercion but a later reader would not
+ // recognize as the same value). Falls back to the raw candidate only if
+ // the backend genuinely had nothing to normalize (`control_values` itself
+ // was absent).
+ const normalized = values ?? candidate;
Review Comment:
Committing the backend dump drops `dataBinding.filters` — it isn't a field on `DataBinding`, so Pydantic's default `extra="ignore"` strips it from `model_dump(by_alias=True)`, and since `updateProps` spreads only at the top level the whole `dataBinding` is replaced, so a widget with authored filters silently refetches unfiltered (`ChartWidget.tsx:254`).
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "justinpark (via GitHub)" <gi...@apache.org>.
justinpark commented on PR #43588:
URL: https://github.com/apache/superset/pull/43588#issuecomment-5528287076
@EnxDev @msyavuz I'm going to apply a fix for the broken CI test by skipping the failing part at filters.spec.ts:216 in dashboard-v2
https://github.com/apache/superset/pull/43487
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912445596
##########
superset-core/src/superset_core/widgets/enrichment.py:
##########
@@ -0,0 +1,150 @@
+# 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.
+
+"""
+Dependency graph and execution engine for a widget's dynamic (``x-dynamic``)
+control-schema fields.
+
+A widget registers one enricher callable per dynamic field path (see
+``Widget.enrichers``). Each field's existing ``x-dependsOn`` list (already
+used to gate enrichment via ``check_dependencies``) does double duty here:
+an entry naming another dynamic field's path becomes an ordering edge (that
+field's enricher must run first, and this one receives its result); any
+other entry stays a plain truthiness gate against the parsed control values.
+Field paths use ``a/b`` dot-path notation, the same convention
+``schema_tools.py`` uses for drill-in paths.
+
+This module only computes the graph and runs enrichers in order — it has no
+opinion on where the schema or enrichers come from; ``Widget.get_control_schema``
+wires it up.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from pydantic import BaseModel
+
+from superset_core.semantic_layers.config import check_dependencies
+
+# (schema, node, parsed, series, upstream_results) -> Any.
+# `schema` is the full document (for cross-$defs lookups, e.g. a sibling
+# style definition); `node` is this field's own schema fragment, mutated in
+# place. The return value is threaded to enrichers ordered after this one, as
+# `upstream_results[path]`.
+EnricherFn = Callable[
+ [dict[str, Any], dict[str, Any], "BaseModel | None", list[str], dict[str, Any]],
+ Any,
+]
+
+
+def _defs(schema: dict[str, Any]) -> dict[str, Any]:
+ return schema.get("$defs", {}) or {}
+
+
+def _deref(schema: dict[str, Any], node: dict[str, Any]) -> dict[str, Any]:
+ if "$ref" not in node:
+ return node
+ return _defs(schema).get(node["$ref"].split("/")[-1], {})
+
+
+def dynamic_field_paths(schema: dict[str, Any]) -> dict[str, dict[str, Any]]:
+ """Walk a built (pre-enrichment) control schema and return
+ ``{path: schema_node}`` for every field carrying ``x-dynamic: true``,
+ using ``a/b`` dot-path notation. Descends into ``properties`` and
+ resolves ``$ref`` against ``$defs`` along the way; does not descend into
+ a discovered dynamic field itself (a dynamic field's own internals are
+ the enricher's concern, not the graph's)."""
+ fields: dict[str, dict[str, Any]] = {}
+
+ def _walk(node: dict[str, Any], prefix: str) -> None:
+ resolved = _deref(schema, node)
+ for name, prop in resolved.get("properties", {}).items():
+ path = f"{prefix}/{name}" if prefix else name
+ prop_resolved = _deref(schema, prop)
+ if prop_resolved.get("x-dynamic"):
+ fields[path] = prop_resolved
+ else:
+ _walk(prop, path)
+
+ _walk(schema, "")
+ return fields
+
+
+def build_dependency_graph(fields: dict[str, dict[str, Any]]) -> dict[str, list[str]]:
+ """``{path: [ordering-edge paths]}`` for every dynamic field in
+ ``fields``. Only ``x-dependsOn`` entries that name another key of
+ ``fields`` become edges; every other entry is left as a gate for
+ ``check_dependencies`` to evaluate at run time, not an edge here."""
+ return {
+ path: [dep for dep in node.get("x-dependsOn", []) if dep in fields]
+ for path, node in fields.items()
+ }
+
+
+def toposort_or_raise(graph: dict[str, list[str]], widget_type: str) -> list[str]:
+ """Kahn's algorithm over ``graph`` (``path -> [paths it depends on]``).
+ Raises ``ValueError`` naming every field on a cycle when the graph isn't
+ a DAG."""
+ in_degree = dict.fromkeys(graph, 0)
+ dependents: dict[str, list[str]] = {path: [] for path in graph}
+ for path, deps in graph.items():
+ in_degree[path] = len(deps)
+ for dep in deps:
+ dependents[dep].append(path)
+
+ ready = sorted(path for path, degree in in_degree.items() if degree == 0)
+ order: list[str] = []
+ while ready:
+ path = ready.pop(0)
+ order.append(path)
+ for dependent in sorted(dependents[path]):
+ in_degree[dependent] -= 1
+ if in_degree[dependent] == 0:
+ ready.append(dependent)
+
+ if len(order) != len(graph):
+ remaining = sorted(set(graph) - set(order))
+ raise ValueError(
+ f"Cyclic control dependency in widget {widget_type!r} among: "
+ f"{', '.join(remaining)}"
+ )
+ return order
+
+
+def run_enrichers(
+ schema: dict[str, Any],
+ fields: dict[str, dict[str, Any]],
+ order: list[str],
+ enrichers: dict[str, EnricherFn],
+ parsed: BaseModel | None,
+ series: list[str],
+) -> None:
+ """Run each path's registered enricher (if any) in ``order``, skipping
+ one whose non-edge ``x-dependsOn`` gate(s) aren't satisfied, and
+ threading each enricher's return value forward as
+ ``upstream_results[path]`` for anything ordered after it."""
+ upstream_results: dict[str, Any] = {}
+ for path in order:
+ enricher = enrichers.get(path)
+ if enricher is None:
+ continue
+ node = fields[path]
+ if parsed is not None and not check_dependencies(node, parsed):
+ continue
+ result = enricher(schema, node, parsed, series, upstream_results)
Review Comment:
Fixed in b1b5f3f539 — `run_enrichers` now filters `x-dependsOn` down to the non-edge subset before calling `check_dependencies`, so an ordering-edge dependency (already enforced by `order` itself) no longer also acts as a plain-attribute gate it can never satisfy. Added regression tests in `test_enrichment.py`.
##########
superset-frontend/src/pages/DashboardBuilderV2/SchemaControlPanel.tsx:
##########
@@ -154,7 +157,18 @@ export default function SchemaControlPanel({ nodeId }: { nodeId: string }) {
const [series, setSeries] = useState<string[]>([]);
const [schema, setSchema] = useState<JsonSchema | null>(null);
+ const [formKey, setFormKey] = useState(0);
const [error, setError] = useState<string | null>(null);
+ const [validationErrors, setValidationErrors] = useState<
+ ControlValidationError[]
+ >([]);
+ // Guards against an earlier, slower validation round-trip landing after a
+ // later one — e.g. two edits typed close together — and overwriting the
+ // more recent result with a stale one.
+ const validateSeqRef = useRef(0);
+ // A validation error belongs to the widget it was raised for; selecting a
+ // different one shouldn't leave a stale message on screen.
+ useEffect(() => setValidationErrors([]), [nodeId]);
Review Comment:
Fixed in f8743d33e4 — added `nodeId` to the schema-fetch effect's dependency array, so switching to a different node of the same widget type no longer skips the fetch just because it happens to resolve to the same discovered series.
##########
superset-frontend/src/pages/DashboardBuilderV2/DataPanel.tsx:
##########
@@ -0,0 +1,237 @@
+/**
+ * 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 { useState } from 'react';
+import type { ReactElement } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { css, styled } from '@apache-superset/core/theme';
+import { GenericDataType } from '@apache-superset/core/common';
+import { EmptyState, Input } from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import { ColumnTypeLabel } from '@superset-ui/chart-controls';
+
+interface MockColumn {
+ readonly name: string;
+ readonly type: GenericDataType;
+}
+
+interface MockDataset {
+ readonly id: string;
+ readonly name: string;
+ readonly columns: readonly MockColumn[];
+}
+
+/**
+ * Static placeholder rows. The Data tab does not call the dataset API —
+ * a later change wires this list, and each dataset's columns, to
+ * `/api/v1/dataset/`, the same endpoint `datasetMetadata.ts` already reads a
+ * single bound dataset's columns from.
+ */
+const MOCK_DATASETS: readonly MockDataset[] = [
+ {
+ id: 'sales',
+ name: 'sales',
+ columns: [
+ { name: 'order_id', type: GenericDataType.String },
+ { name: 'order_date', type: GenericDataType.Temporal },
+ { name: 'sales_amount', type: GenericDataType.Numeric },
+ { name: 'region', type: GenericDataType.String },
+ ],
+ },
+ {
+ id: 'coffee_sales',
+ name: 'coffee_sales',
+ columns: [
+ { name: 'product', type: GenericDataType.String },
+ { name: 'roast_date', type: GenericDataType.Temporal },
+ { name: 'unit_price', type: GenericDataType.Numeric },
+ { name: 'is_decaf', type: GenericDataType.Boolean },
+ ],
+ },
+];
+
+const matches = (dataset: MockDataset, query: string): boolean =>
+ query.trim() === '' ||
+ dataset.name.toLowerCase().includes(query.trim().toLowerCase());
+
+/**
+ * The panel's own scroll column, set down from the tab bar and in from the
+ * panel edge — the same step `Palette`'s `Column` and `Outline`'s `Panel`
+ * take from theirs, so the four tabs of one rail start on one line.
+ */
+const Column = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 5}px;
+ min-height: 0;
+ padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit}px 0;
+ `}
+`;
+
+const List = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit}px;
+ overflow-y: auto;
+ min-height: 0;
+ `}
+`;
+
+const DatasetButton = styled.button`
+ ${({ theme }) => css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ width: 100%;
+ padding: ${theme.sizeUnit * 2}px;
+ border: 1px solid ${theme.colorBorder};
+ /* The same radius every other tile in this rail, and the canvas itself,
+ draws its own card at — one rounding language, not one per panel. */
+ border-radius: ${theme.borderRadiusLG}px;
+ background-color: ${theme.colorFillQuaternary};
+ color: ${theme.colorText};
+ font-size: ${theme.fontSizeSM}px;
+ text-align: left;
+ cursor: pointer;
+ transition: background-color ${theme.motionDurationMid};
+
+ .data-panel-chevron {
+ display: flex;
+ flex: 0 0 auto;
+ color: ${theme.colorTextTertiary};
+ }
+
+ &:hover {
+ background-color: ${theme.colorFillTertiary};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colorPrimaryBorder};
+ outline-offset: -2px;
+ }
+ `}
+`;
+
+const ColumnList = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit}px;
+ margin-top: ${theme.sizeUnit}px;
+ margin-left: ${theme.sizeUnit * 2}px;
+ padding-left: ${theme.sizeUnit * 3}px;
+ border-left: 1px solid ${theme.colorBorder};
+ `}
+`;
+
+const ColumnRow = styled.div`
+ ${({ theme }) => css`
+ display: flex;
+ align-items: center;
+ gap: ${theme.sizeUnit * 2}px;
+ padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
+ font-size: ${theme.fontSizeSM}px;
+ color: ${theme.colorText};
+ `}
+`;
+
+/**
+ * Datasets and their columns, to browse rather than to place.
+ *
+ * Building Blocks places widgets onto the canvas; this tab answers "what
+ * data is there to use" without touching any widget's binding — expanding a
+ * row reads its columns and nothing else happens.
+ */
+export default function DataPanel(): ReactElement {
+ const [query, setQuery] = useState('');
+ const [expanded, setExpanded] = useState<ReadonlySet<string>>(new Set());
+
+ const found = MOCK_DATASETS.filter(dataset => matches(dataset, query));
Review Comment:
Acknowledged, not changed in this pass — correct that it's unreachable today, but whether to wire it into `EditorPanel`'s navigation or drop it is a product decision about the Data panel's scope, not something I want to guess at as a drive-by fix. Left as-is; happy to follow up in a dedicated PR once that's decided.
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912447829
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredSeries.ts:
##########
@@ -0,0 +1,97 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's optional structured layer (`chartType`/`customize`
+ * on `EchartsControls`, see `superset/widgets/controls.py`). Precedence,
+ * matching the backend model's docstring:
+ * 1. `echartsOptions` (already `$bind`-resolved) is the base.
+ * 2. When `chartType` is set, `option.series` — and only `series` — is
+ * replaced with one generated series per `dataBinding` metric.
+ * Everything else the raw option authored (axes, legend, tooltip,
+ * title) survives unmanaged.
+ * 3. `chartType` unset/null ("Custom") leaves the raw option untouched,
+ * including mixed-series or non-Cartesian (e.g. pie) shapes.
+ *
+ * A series is keyed by its metric's `getMetricLabel` — the same label the
+ * `/api/v1/chart/data` result columns are named after, so `data` reads the
+ * right column, and the same label the backend's `_metric_key` computes, so
+ * a stored override matches by stable identity rather than array position.
+ */
+import { getMetricLabel } from '@superset-ui/core';
+import type { QueryFormMetric } from '@superset-ui/core';
+import type { dashboard as dashboardApi } from '@apache-superset/core';
+
+type DataRow = dashboardApi.DataRow;
+
+export type EchartsChartType = 'bar' | 'line' | 'scatter';
+
+export interface SeriesOverrideValue {
+ color?: string;
+ visible?: boolean;
+ displayName?: string;
+}
+
+/**
+ * One ECharts series per metric, in `dataBinding.metrics` order. A metric
+ * overridden with `visible: false` is omitted entirely (there is no ECharts
+ * option for "present but hidden" that also frees its legend/tooltip slot).
+ */
+export function buildStructuredSeries(
+ chartType: EchartsChartType,
+ metrics: QueryFormMetric[],
+ rows: DataRow[],
+ overrides: Record<string, SeriesOverrideValue> | undefined,
+): Record<string, unknown>[] {
+ return metrics
+ .map(metric => {
+ const key = getMetricLabel(metric);
+ const override = overrides?.[key];
+ if (override?.visible === false) return null;
+ const series: Record<string, unknown> = {
+ name: override?.displayName || key,
+ type: chartType,
+ data: rows.map(row => row[key]),
+ };
+ if (override?.color) {
+ series.itemStyle = { color: override.color };
+ }
Review Comment:
Fixed in b1b5f3f539 — `buildStructuredSeries` now falls back to the same `Echarts.PALETTE[index]` the backend schema advertises as that series' default color, matching `BalloonsWidget`'s existing convention, instead of leaving `itemStyle` unset until an override is stored.
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
+ return { ok: true, values: candidate };
Review Comment:
Fixed in be0eddd3c3 — `/type/<widget_type>/validate` now also returns the Pydantic-normalized candidate (`values`) on success, and `commitWidgetProps` commits that instead of the raw one, mirroring what `set_widget_control_values` already stores through the same gate. Falls back to the raw candidate only if the backend sends no `values` back.
##########
superset-frontend/src/pages/DashboardBuilderV2/Inspector.tsx:
##########
@@ -435,6 +432,7 @@ const PropsEditor = ({
// area above it). `updateProps` merges, so a key absent from the form's data
// is left untouched rather than removed.
const schemaControlledTypes = useSchemaControlledWidgetTypes();
+ const validated = schemaControlledTypes?.has(widgetType) ?? false;
Review Comment:
Fixed in b1b5f3f539 — the JSON tab now always attempts `commitWidgetProps`/`/validate` and only falls back to a raw commit on a confirmed 404, rather than gating on the client-cached `useSchemaControlledWidgetTypes` list (which is `null` while loading and empty on a fetch failure by design, so gating on it silently skipped validation for a widget that does have a schema).
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "EnxDev (via GitHub)" <gi...@apache.org>.
EnxDev commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3912458761
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +164,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
Review Comment:
Fixed in b1b5f3f539 — `ColorControl` now uses the same `ColorPicker` from `@superset-ui/core/components` that `SeriesEntryPropertyControl` uses, instead of a native `<input type="color">`.
##########
superset-core/src/superset_core/widgets/composites.py:
##########
@@ -0,0 +1,106 @@
+# 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.
+
+"""
+Reusable, discoverable building blocks for Dashboard V2 widget control models.
+
+A composite control is a ``BaseModel`` mixin carrying one or more fields
+(with their ``x-control`` schema extras already set) that a widget's
+``controls_class`` composes in via plain single inheritance — no nesting, so
+the composed field renders exactly where a directly-declared field would.
+The ``@composite_control`` decorator only registers the class for discovery
+(docs generation, MCP tooling); composing one into a widget never touches the
+registry.
+
+Composing more than one composite control into the same model (multiple
+inheritance across two or more registered mixins) is unsupported for now —
+see the design spec's Scope section.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Any, Callable, Mapping
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+@dataclass(frozen=True)
+class CompositeControlInfo:
+ name: str
+ title: str
+ description: str
+ model: type[BaseModel]
+
+
+_registry: dict[str, CompositeControlInfo] = {}
+
+
+def composite_control(
+ name: str, title: str, description: str
+) -> Callable[[type[BaseModel]], type[BaseModel]]:
+ """Register a reusable Pydantic mixin as a discoverable composite control.
+
+ Composing one into a widget's ``controls_class`` is plain inheritance —
+ this decorator only makes the class discoverable via
+ ``list_composite_controls()``.
+ """
+
+ def decorator(cls: type[BaseModel]) -> type[BaseModel]:
+ if name in _registry:
+ raise ValueError(f"composite control {name!r} already registered")
+ _registry[name] = CompositeControlInfo(name, title, description, cls)
+ return cls
+
+ return decorator
+
+
+def list_composite_controls() -> Mapping[str, CompositeControlInfo]:
+ """Read-only view of registered composite controls, for docs/MCP
+ discovery. An extension-defined composite appears only once its defining
+ module has been imported (decorator side effect, same as ``@widget``)."""
+ return MappingProxyType(_registry)
+
+
+@composite_control(
+ name="metric",
+ title="Metrics",
+ description=(
+ "Reusable metric-list field (saved-metric names or ad-hoc SIMPLE aggregates)."
+ ),
+)
+class MetricControl(BaseModel):
+ """Mixin providing a ``metrics`` field, extracted verbatim from
+ ``DataBinding`` for reuse outside it."""
+
+ model_config = ConfigDict(populate_by_name=True)
+
+ metrics: list[Any] = Field(
+ title="Metrics",
+ description=(
+ "Metrics to fetch. Each entry is EITHER a string naming a saved "
+ 'metric on the dataset (e.g. "count"), OR an ad-hoc aggregate '
+ "object of the shape "
+ '{"expressionType": "SIMPLE", "column": {"column_name": "<col>"}, '
Review Comment:
Fixed in b1b5f3f539 — updated the `metrics` field description to document both the SIMPLE and SQL shapes. That description is also asserted verbatim in `test_registry.py`, which I missed updating and which then broke unit-tests in CI — fixed in f8743d33e4 (thanks for catching that one).
--
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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org
Re: [PR] feat(dashboard-v2): add dataset-aware widget editing controls [superset]
Posted by "codeant-ai-for-open-source[bot] (via GitHub)" <gi...@apache.org>.
codeant-ai-for-open-source[bot] commented on code in PR #43588:
URL: https://github.com/apache/superset/pull/43588#discussion_r3872820772
##########
superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts:
##########
@@ -0,0 +1,111 @@
+/**
+ * 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.
+ */
+
+/**
+ * The one path either half of the Properties panel (the schema-driven form
+ * and the JSON editor) writes a schema-controlled widget's control values
+ * through.
+ *
+ * Both representations edit the same `node.props`, but neither may write to
+ * it directly: a candidate is merged, sent to the backend's
+ * `Widget.validate_control_values` gate — the same gate the
+ * `set_widget_control_values` MCP tool commits through, reached here via its
+ * REST wrapper rather than a second, frontend-authored copy of its rules —
+ * and only committed to the store once that gate accepts it. A rejected
+ * candidate returns its errors and touches nothing: `node.props` is read
+ * here, never written to, until validation has already succeeded.
+ */
+import { SupersetClient } from '@superset-ui/core';
+import { provider } from 'src/core/dashboard/store';
+
+export type ControlValidationError = {
+ loc: (string | number)[];
+ message: string;
+};
+
+export type CommitPropsResult =
+ | { ok: true; values: Record<string, unknown> }
+ | { ok: false; errors: ControlValidationError[] };
+
+/**
+ * SupersetClient rejects a non-2xx response with the raw, unparsed `Response`
+ * object rather than an `Error`, so a plain `String(e)` yields the useless
+ * "[object Response]". Pull the actual `{message}`/`{errors:[...]}` body
+ * Superset sends back (same shape `chartData.ts` handles).
+ */
+export async function describeError(e: unknown): Promise<string> {
+ if (typeof Response !== 'undefined' && e instanceof Response) {
+ try {
+ const body = await e.clone().json();
+ const detail =
+ body?.message ??
+ (Array.isArray(body?.errors)
+ ? body.errors
+ .map((err: { message?: string }) => err.message)
+ .join('; ')
+ : undefined);
+ return detail
+ ? `${e.status} ${e.statusText}: ${detail}`
+ : `${e.status} ${e.statusText}`;
+ } catch {
+ return `${e.status} ${e.statusText}`;
+ }
+ }
+ return e instanceof Error ? e.message : String(e);
+}
+
+async function validateControlValues(
+ widgetType: string,
+ controlValues: Record<string, unknown>,
+): Promise<ControlValidationError[]> {
+ const { json } = await SupersetClient.post({
+ endpoint: `/api/v1/widgets/type/${widgetType}/validate`,
+ jsonPayload: { control_values: controlValues },
+ });
+ return (json as { result: { errors: ControlValidationError[] } }).result
+ .errors;
+}
+
+/**
+ * Merges `delta` onto the node's current props, validates the merged
+ * candidate, and commits it to `node.props` only if the backend accepts it.
+ *
+ * `onBeforeCommit`, if given, runs synchronously immediately before the
+ * `provider.updateProps` call — not part of the merge/validate/commit
+ * contract itself, but the one hook a caller needs to set a flag in the
+ * exact tick its own commit lands (e.g. `SchemaControlPanel` telling its
+ * own resync effect "this `props` change was mine"), without racing the
+ * async validation round-trip to do it.
+ */
+export async function commitWidgetProps(
+ nodeId: string,
+ widgetType: string,
+ delta: Record<string, unknown>,
+ options?: { onBeforeCommit?: () => void },
+): Promise<CommitPropsResult> {
+ const node = provider.getNode(nodeId);
+ const candidate = { ...node?.props, ...delta };
+ const errors = await validateControlValues(widgetType, candidate);
+ if (errors.length > 0) {
+ return { ok: false, errors };
+ }
+ options?.onBeforeCommit?.();
+ provider.updateProps(nodeId, candidate);
Review Comment:
**Suggestion:** Each invocation snapshots `node.props` before awaiting validation, then commits that snapshot later. If two edits are validated concurrently, a slower earlier request can call `updateProps` after a newer request and overwrite the newer change. Serialize commits or reject stale validation results before updating the provider, not only when displaying errors. [race condition]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Rapid schema edits can lose newer widget properties.
- ⚠️ Validation completion order controls final state.
- ❌ Dashboard Builder edits become nondeterministic.
```
</details>
[](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=8c93d7e4af4a48948796de37d36a631f&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=8c93d7e4af4a48948796de37d36a631f&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/src/pages/DashboardBuilderV2/controlValueValidation.ts
**Line:** 96:109
**Comment:**
*Race Condition: Each invocation snapshots `node.props` before awaiting validation, then commits that snapshot later. If two edits are validated concurrently, a slower earlier request can call `updateProps` after a newer request and overwrite the newer change. Serialize commits or reject stale validation results before updating the provider, not only when displaying errors.
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.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=dc8cb944e7f45eed9ee6a65b8ae058b2a0f8bff6db011702f19171997dd38f9e&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=dc8cb944e7f45eed9ee6a65b8ae058b2a0f8bff6db011702f19171997dd38f9e&reaction=dislike'>👎</a>
##########
superset-frontend/src/core/dashboard/widgets/echartsStructuredChrome.ts:
##########
@@ -0,0 +1,144 @@
+/**
+ * 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.
+ */
+
+/**
+ * The `echarts` widget's second, independent structured layer — chart
+ * chrome (title/legend/tooltip/axis labels) — matching `EchartsChrome`'s
+ * docstring in `superset/widgets/controls.py`: every field is optional and
+ * applies (or not) on its own, regardless of `chartType`/`customize`. A
+ * field left at its default never touches `echartsOptions`; when it does
+ * apply, it merges onto — rather than replaces — the matching section, so
+ * an unmanaged sibling property there (e.g. a hand-authored `legend.orient`)
+ * survives.
+ *
+ * `EchartsChromeValue` is deliberately flat, not grouped into
+ * `title`/`legend`/`tooltip`/`xAxis`/`yAxis` sub-objects — see
+ * `EchartsChrome`'s own docstring: JsonForms' generated control panel only
+ * renders one level of nested-object properties, so a two-level-deep
+ * `chrome.title.text` would render as an empty group with no fields inside.
+ */
+
+export interface EchartsChromeValue {
+ titleText?: string;
+ legendShow?: boolean;
+ legendPosition?: 'top' | 'bottom' | 'left' | 'right' | null;
+ tooltipTrigger?: 'item' | 'axis' | null;
+ xAxisName?: string;
+ xAxisRotate?: number;
+ xAxisFormat?: string;
+ yAxisName?: string;
+ yAxisRotate?: number;
+ yAxisFormat?: string;
+}
+
+// ECharts has no single "position" property on `legend` — placement comes
+// from `top`/`left` (each accepting a keyword or coordinate). This maps the
+// friendlier compass-direction picker onto the pair ECharts actually reads.
+const LEGEND_POSITION: Record<string, { top: string; left: string }> = {
+ top: { top: 'top', left: 'center' },
+ bottom: { top: 'bottom', left: 'center' },
+ left: { top: 'middle', left: 'left' },
+ right: { top: 'middle', left: 'right' },
+};
+
+function asRecord(value: unknown): Record<string, unknown> {
+ return value !== null && typeof value === 'object'
+ ? (value as Record<string, unknown>)
+ : {};
Review Comment:
**Suggestion:** `asRecord` treats arrays as records. When a raw ECharts option uses the valid multi-axis form, applying an axis name, rotation, or format spreads the array indexes into an object and returns an object-shaped axis, changing the option's meaning and potentially breaking multi-axis rendering. Preserve array axes or apply the structured override to the appropriate axis entries. [type error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Multi-axis ECharts options lose their array representation.
- ❌ Structured axis edits can break chart rendering.
- ⚠️ Raw custom ECharts options commonly support axis arrays.
```
</details>
[](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=4a9afe10f3ca42e78718fbd0ec3789b5&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=4a9afe10f3ca42e78718fbd0ec3789b5&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/src/core/dashboard/widgets/echartsStructuredChrome.ts
**Line:** 60:63
**Comment:**
*Type Error: `asRecord` treats arrays as records. When a raw ECharts option uses the valid multi-axis form, applying an axis name, rotation, or format spreads the array indexes into an object and returns an object-shaped axis, changing the option's meaning and potentially breaking multi-axis rendering. Preserve array axes or apply the structured override to the appropriate axis entries.
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.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=1aa01275737cbe3ea8c8090f73c4f0015c88b03e7845ae29ad81f1ab9439b3a1&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=1aa01275737cbe3ea8c8090f73c4f0015c88b03e7845ae29ad81f1ab9439b3a1&reaction=dislike'>👎</a>
##########
superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx:
##########
@@ -93,24 +161,1114 @@ function ColorControl({
path,
schema,
label,
+ description,
}: ControlProps) {
const theme = useTheme();
const value = (data ??
(schema as Record<string, unknown>).default ??
theme.colorText) as string;
return (
- <Flex align="center" gap="small">
+ <Form.Item label={label} tooltip={description}>
<input
type="color"
value={value}
onChange={event => handleChange(path, event.target.value)}
/>
- <Typography.Text>{label}</Typography.Text>
- </Flex>
+ </Form.Item>
+ );
+}
+
+interface SeriesEntryPropertySchema {
+ type?: string;
+ title?: string;
+ default?: unknown;
+ minimum?: number;
+ maximum?: number;
+ 'x-control'?: string;
+ 'x-step'?: number;
+}
+
+interface SeriesEntrySchema {
+ properties?: Record<string, SeriesEntryPropertySchema>;
+}
+
+interface SeriesMapSchema {
+ properties?: Record<string, SeriesEntrySchema>;
+}
+
+type SeriesOverrideValue = Record<string, unknown>;
+
+/** Turns a camelCase field key into the lowercase, space-separated phrase
+ * an aria-label reads naturally with (`sizeScale` → `size scale`). */
+function humanizeFieldKey(key: string): string {
+ return key
+ .replace(/([A-Z])/g, ' $1')
+ .toLowerCase()
+ .trim();
+}
+
+/** The value a not-yet-customized series entry starts from, read purely off
+ * its own enriched sub-schema — every widget's per-series entry shape
+ * (Balloons' `{color, sizeScale}`, echarts' `{color, visible, displayName}`,
+ * or any future one) declares its own field defaults; this has no
+ * hard-coded opinion of what those fields are. `fallbackColor` only backs a
+ * `color` field the backend never defaulted — not expected to happen, but a
+ * theme token beats a literal black. */
+export function seriesDefaults(
+ entrySchema: SeriesEntrySchema | undefined,
+ fallbackColor: string,
+): SeriesOverrideValue {
+ const properties = entrySchema?.properties ?? {};
+ const value: SeriesOverrideValue = Object.fromEntries(
+ Object.entries(properties).map(([key, prop]) => [key, prop.default]),
+ );
+ if (value.color === undefined && 'color' in properties) {
+ value.color = fallbackColor;
+ }
+ return value;
+}
+
+/** One property of a customized series entry, rendered by its schema shape
+ * — not by a hard-coded field name — so a new per-series entry model (e.g.
+ * echarts' `{color, visible, displayName}`) needs no renderer of its own:
+ * - `x-control: "color"` (or the property key `color`) → a color swatch.
+ * - `type: "boolean"` → a toggle.
+ * - `type: "number"` / `"integer"` → a bounded numeric input.
+ * - anything else → a text input.
+ */
+function SeriesEntryPropertyControl({
+ seriesKey,
+ propKey,
+ propSchema,
+ value,
+ onChange,
+}: {
+ seriesKey: string;
+ propKey: string;
+ propSchema: SeriesEntryPropertySchema | undefined;
+ value: unknown;
+ onChange: (next: unknown) => void;
+}): ReactElement {
+ const theme = useTheme();
+ const fieldLabel = `${seriesKey} ${humanizeFieldKey(propKey)}`;
+
+ if (propSchema?.['x-control'] === 'color' || propKey === 'color') {
+ const color = (value as string) || theme.colorText;
+ return (
+ <ColorPicker
+ value={color}
+ onChange={next => onChange(next.toHexString())}
+ >
+ <button
+ type="button"
+ aria-label={t('%s color', seriesKey)}
+ style={{
+ width: 20,
+ height: 20,
+ borderRadius: 4,
+ border: '1px solid rgba(0, 0, 0, 0.15)',
+ background: color,
+ cursor: 'pointer',
+ padding: 0,
+ }}
+ />
+ </ColorPicker>
+ );
+ }
+ if (propSchema?.type === 'boolean') {
+ return (
+ <Switch
+ aria-label={fieldLabel}
+ checked={value !== false}
+ onChange={onChange}
+ />
+ );
+ }
+ if (propSchema?.type === 'number' || propSchema?.type === 'integer') {
+ return (
+ <InputNumber
+ size="small"
+ aria-label={fieldLabel}
+ value={value as number}
+ min={propSchema?.minimum}
+ max={propSchema?.maximum}
+ step={propSchema?.['x-step'] ?? 1}
+ style={{ width: 64 }}
+ onChange={next => onChange(typeof next === 'number' ? next : value)}
+ />
+ );
+ }
+ return (
+ <Input
+ size="small"
+ aria-label={fieldLabel}
+ value={(value as string) ?? ''}
+ onChange={event => onChange(event.target.value)}
+ style={{ width: 140 }}
+ />
+ );
+}
+
+/**
+ * `x-dynamic: true` on a dict-of-objects field (e.g. Balloons'
+ * `customize.series`, one entry per distinct color-dimension value, or
+ * echarts' `customize.series`, one entry per `dataBinding` metric): an
+ * overrides list, collapsed by default, rather than the upstream renderer's
+ * one always-expanded group per possible entry — which turns a real
+ * grouping column into thousands of pixels of identical, unstyled controls.
+ *
+ * The backend enriches this field's schema with one inlined per-key
+ * sub-schema (a title and, where the entry shape has one, a palette-defaulted
+ * `color`), but leaves the *data* untouched until an author actually edits a
+ * value — so "has an entry in `data`" already means "has been customized",
+ * with no comparison against the schema's own defaults needed. Which fields
+ * an entry has, and how each renders, comes entirely from that per-key
+ * sub-schema (see `SeriesEntryPropertyControl`) — this component has no
+ * opinion of its own on the entry shape.
+ */
+function SeriesOverridesControl(props: ControlProps): ReactElement {
+ const { data, handleChange, path, schema, label, description } = props;
+ const theme = useTheme();
+ const seriesSchema = schema as SeriesMapSchema;
+ const keys = useMemo(
+ () => Object.keys(seriesSchema.properties ?? {}),
+ [seriesSchema],
+ );
+ const values = (data ?? {}) as Record<string, SeriesOverrideValue>;
+ const customizedKeys = keys.filter(key => values[key] !== undefined);
+ const availableKeys = keys.filter(key => values[key] === undefined);
+
+ if (keys.length === 0) {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Typography.Text type="secondary">
+ {t('No series available to customize yet.')}
+ </Typography.Text>
+ </Form.Item>
+ );
+ }
+
+ const write = (next: Record<string, SeriesOverrideValue>) =>
+ handleChange(path, next);
+
+ const removeOverride = (key: string) => {
+ const next = { ...values };
+ delete next[key];
+ write(next);
+ };
+
+ const addOverride = (key: string) => {
+ write({
+ ...values,
+ [key]: seriesDefaults(seriesSchema.properties?.[key], theme.colorText),
+ });
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Collapse
+ ghost
+ size="small"
+ items={[
+ {
+ key: 'series-overrides',
+ label: t(
+ '%s series · %s customized',
+ keys.length,
+ customizedKeys.length,
+ ),
+ children: (
+ <Flex vertical gap="small">
+ {customizedKeys.map(key => {
+ const value = values[key];
+ const entryProperties =
+ seriesSchema.properties?.[key]?.properties ?? {};
+ const otherPropKeys = Object.keys(entryProperties).filter(
+ propKey => propKey !== 'color',
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ {'color' in entryProperties && (
+ <SeriesEntryPropertyControl
+ seriesKey={key}
+ propKey="color"
+ propSchema={entryProperties.color}
+ value={value.color}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, color: next },
+ })
+ }
+ />
+ )}
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ }}
+ >
+ {key}
+ </div>
+ {otherPropKeys.map(propKey => (
+ <SeriesEntryPropertyControl
+ key={propKey}
+ seriesKey={key}
+ propKey={propKey}
+ propSchema={entryProperties[propKey]}
+ value={value[propKey]}
+ onChange={next =>
+ write({
+ ...values,
+ [key]: { ...value, [propKey]: next },
+ })
+ }
+ />
+ ))}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Reset %s to default', key)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => removeOverride(key)}
+ />
+ </Flex>
+ );
+ })}
+ {availableKeys.length > 0 && (
+ // Remounted on every pick: rc-select otherwise keeps
+ // showing the just-picked option's label internally even
+ // once it's gone from `options` (a controlled `value` of
+ // `null`/`undefined` doesn't clear that cache on its own —
+ // see `ReferenceMultiList`'s identical, pre-existing gap).
+ <Select
+ key={availableKeys.length}
+ value={null}
+ placeholder={t('Add a series override…')}
+ ariaLabel={t('Add %s override', label)}
+ options={availableKeys.map(key => ({
+ value: key,
+ label: key,
+ }))}
+ onChange={next => addOverride(next as string)}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ ),
+ },
+ ]}
+ />
+ </Form.Item>
+ );
+}
+
+/**
+ * The dataset id a column/metric-reference control resolves options
+ * against. A JsonForms control only sees its own field's data by default —
+ * the whole node's props reach it through `config.formData`, which
+ * `SchemaControlPanel` populates for exactly this reason (mirroring
+ * `SemanticLayerModal`'s own `config={{ formData }}`).
+ */
+function useBoundDatasetId(props: ControlProps): number | undefined {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { datasetId?: number } | undefined;
+ return dataBinding?.datasetId;
+}
+
+/** The sibling `dataBinding.dimensions` list, read the same way. */
+function useBoundDimensions(props: ControlProps): string[] {
+ const formData = (
+ props.config as { formData?: Record<string, unknown> } | undefined
+ )?.formData;
+ const dataBinding = formData?.dataBinding as
+ { dimensions?: string[] } | undefined;
+ return dataBinding?.dimensions ?? [];
+}
+
+/**
+ * True when a column/metric-reference control should fail open to the raw
+ * JSON editor (`CodeControl`) rather than render its picker: no dataset is
+ * bound yet, or the dataset fetch failed. Deliberately does NOT cover the
+ * in-flight loading state (`metadata` still `null`, no `error` yet) — that's
+ * the normal case while a bound dataset's metadata is fetched, and the
+ * picker renders as usual with its own `loading` flag set.
+ */
+function shouldFallBackToCode(
+ datasetId: number | undefined,
+ error: string | null,
+): boolean {
+ return datasetId === undefined || error !== null;
+}
+
+interface ReferenceOption {
+ value: string;
+ label: ReactNode;
+}
+
+const COLUMN_TYPE_BY_HINT: Record<string, number> = {
+ numeric: 0,
+ string: 1,
+ temporal: 2,
+ boolean: 3,
+};
+
+/**
+ * Column options for a `column`/`column-multi` control, filtered by the
+ * field's `x-column-types` hint (omitted means any column type).
+ */
+export function columnOptions(
+ metadata: DatasetMetadata | null,
+ allowedTypes: string[] | undefined,
+): ReferenceOption[] {
+ const allowed = allowedTypes?.map(hint => COLUMN_TYPE_BY_HINT[hint]);
+ return (metadata?.columns ?? [])
+ .filter(
+ column =>
+ !allowed || (column.type !== null && allowed.includes(column.type)),
+ )
+ .map(column => ({
+ value: column.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type={column.type ?? undefined} />
+ {column.name}
+ </Flex>
+ ),
+ }));
+}
+
+/** A single reference value (column or metric), rendered as a Select. */
+function ReferenceSelect({
+ label,
+ description,
+ value,
+ options,
+ loading,
+ disabled,
+ placeholder,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ value: string | undefined;
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ placeholder?: string;
+ onChange: (next: string | undefined) => void;
+}): ReactElement {
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Select
+ ariaLabel={label}
+ value={value}
+ onChange={next => onChange((next as string | undefined) ?? undefined)}
+ options={options}
+ loading={loading}
+ disabled={disabled}
+ placeholder={placeholder}
+ allowClear
+ css={{ width: '100%' }}
+ />
+ </Form.Item>
);
}
-/** Base Semantic-Layer renderers plus the widget-control code/color ones. */
+/**
+ * An ordered list of reference values (columns or metrics): each entry can
+ * be removed or dragged to reorder, and a trailing Select adds one more from
+ * whatever isn't already picked.
+ */
+function ReferenceMultiList({
+ label,
+ description,
+ values,
+ options,
+ loading,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: string[];
+ options: ReferenceOption[];
+ loading: boolean;
+ disabled: boolean;
+ onChange: (next: string[]) => void;
+}): ReactElement {
+ const dragIndexRef = useRef<number | null>(null);
+ const available = options.filter(option => !values.includes(option.value));
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const option = options.find(candidate => candidate.value === value);
+ return (
+ <Flex
+ key={value}
+ align="center"
+ gap="small"
+ draggable
+ onDragStart={() => {
+ dragIndexRef.current = index;
+ }}
+ onDragOver={event => event.preventDefault()}
+ onDrop={() => {
+ if (
+ dragIndexRef.current !== null &&
+ dragIndexRef.current !== index
+ ) {
+ move(dragIndexRef.current, index);
+ }
+ dragIndexRef.current = null;
+ }}
+ >
+ <Icons.HolderOutlined iconSize="s" />
+ <div style={{ flex: 1 }}>{option?.label ?? value}</div>
+ {/* Keyboard-operable equivalent of the drag handle above: that
+ handle isn't itself focusable, so reordering — which
+ decides e.g. the default color dimension — had no
+ non-mouse path at all. */}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s up', value)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move %s down', value)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove %s', value)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {available.length > 0 && (
+ // Remounted on every pick — see `SeriesOverridesControl`'s
+ // identical picker for why a controlled `value` of `undefined`
+ // alone doesn't stop rc-select echoing the just-picked label.
+ <Select
+ key={available.length}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={available}
+ loading={loading}
+ disabled={disabled}
+ onChange={next => onChange([...values, next as string])}
+ css={{ width: '100%' }}
+ />
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "column"` — a single column reference. Falls back to the raw
+ * JSON editor when no dataset is bound (or its fetch failed), or when the
+ * existing value isn't a string — e.g. an object hand-authored into the
+ * field through the Inspector's JSON tab, which `Select` can't render as a
+ * `value` and JsonForms would otherwise crash on.
+ */
+function ColumnControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * The `colorDimension` field specifically: a column reference, but not to
+ * any column — the widget only colors by a dimension it already groups by
+ * (Balloons' `_color_dimension_must_be_grouped` validator rejects anything
+ * else). Offering all of a dataset's columns, most of which the backend
+ * will reject, taught nothing about which one was actually valid; this
+ * intersects the picker's options with the sibling `dataBinding.dimensions`
+ * instead, and disables it with an explanatory placeholder when there's
+ * nothing grouped yet to color by.
+ */
+function ColorDimensionControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const dimensions = useBoundDimensions(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+
+ if (
+ shouldFallBackToCode(datasetId, error) ||
+ (props.data !== undefined && typeof props.data !== 'string')
+ ) {
+ return <CodeControl {...props} />;
+ }
+
+ const options = columnOptions(metadata, undefined).filter(option =>
+ dimensions.includes(option.value),
+ );
+
+ return (
+ <ReferenceSelect
+ label={props.label}
+ description={props.description}
+ value={props.data as string | undefined}
+ options={options}
+ loading={loading}
+ disabled={!props.enabled || dimensions.length === 0}
+ placeholder={
+ dimensions.length === 0 ? t('Group a dimension first') : undefined
+ }
+ onChange={value => props.handleChange(props.path, value)}
+ />
+ );
+}
+
+/**
+ * `x-control: "column-multi"` — an ordered list of column references. Falls
+ * back to the raw JSON editor when no dataset is bound (or its fetch
+ * failed), or when an existing entry isn't a string — e.g. an object
+ * hand-authored into the field through the Inspector's JSON tab, which
+ * `ReferenceMultiList` can't render as a list entry.
+ */
+function ColumnMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const allowedTypes = (props.schema as Record<string, unknown>)[
+ 'x-column-types'
+ ] as string[] | undefined;
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasNonStringEntry = values.some(value => typeof value !== 'string');
+
+ if (shouldFallBackToCode(datasetId, error) || hasNonStringEntry) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <ReferenceMultiList
+ label={props.label}
+ description={props.description}
+ values={values as string[]}
+ options={columnOptions(metadata, allowedTypes)}
+ loading={loading}
+ disabled={!props.enabled}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * Metric options for a `metric-multi` control: the dataset's saved metrics,
+ * shown with the same Sigma icon Explore's metric picker uses.
+ */
+export function metricOptions(
+ metadata: DatasetMetadata | null,
+): ReferenceOption[] {
+ return (metadata?.metrics ?? []).map(metric => ({
+ value: metric.name,
+ label: (
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metric.verboseName}
+ </Flex>
+ ),
+ }));
+}
+
+/**
+ * True for a metric entry this control has no row to draw at all: neither a
+ * plain saved-metric-name string nor a structurally valid ad-hoc metric
+ * object — e.g. malformed data hand-authored through the JSON tab. Only
+ * this case still drops the *whole* field to the raw JSON editor; a
+ * well-formed mix of saved and ad-hoc entries renders as a mixed list
+ * instead (see `MetricEntryList`).
+ */
+export function isUnrepresentableMetric(value: unknown): boolean {
+ return typeof value !== 'string' && !isDictionaryForAdhocMetric(value);
+}
+
+/** Whether the bound dataset's own settings forbid ad-hoc metrics entirely
+ * (a dataset-level admin setting, not a per-field one) — read the same raw
+ * `extra` JSON the legacy metric editor reads it from. */
+export function disallowsAdhocMetrics(
+ metadata: DatasetMetadata | null,
+): boolean {
+ if (!metadata?.extra) return false;
+ try {
+ return Boolean(
+ (JSON.parse(metadata.extra) as { disallow_adhoc_metrics?: boolean })
+ .disallow_adhoc_metrics,
+ );
+ } catch {
+ return false;
+ }
+}
+
+type MetricEntry = string | CoreAdhocMetric;
+
+/** A metric entry's own display label: a saved metric's verbose name (or
+ * its raw name if the dataset's metric list hasn't loaded/matched yet), or
+ * an ad-hoc metric's own label — computed the same way the legacy editor
+ * derives one (`(AVG)(price)`, etc.) when the author hasn't set a custom one. */
+function metricEntryLabel(
+ entry: MetricEntry,
+ metadata: DatasetMetadata | null,
+): ReactNode {
+ if (typeof entry === 'string') {
+ const known = metadata?.metrics.find(metric => metric.name === entry);
+ return known?.verboseName ?? entry;
+ }
+ return fromCoreAdhocMetric(entry).label;
+}
+
+/** Sentinel option value picked from the "Add field" select to start a new
+ * ad-hoc metric, distinct from any real saved-metric name. */
+const ADD_CUSTOM_METRIC = '__custom_metric__';
+
+/**
+ * An ordered list of metric references, each entry rendered by its own
+ * kind: a saved metric as a plain row (unchanged from `ReferenceMultiList`),
+ * an ad-hoc metric (SIMPLE or SQL) as a row whose label opens
+ * `AdhocMetricEditor` for just that entry. "Add field" offers both a saved
+ * metric to pick and, unless the dataset disallows it, a blank ad-hoc draft.
+ */
+function MetricEntryList({
+ label,
+ description,
+ values,
+ metadata,
+ columns,
+ datasourceId,
+ datasourceType,
+ disallowAdhoc,
+ disabled,
+ onChange,
+}: {
+ label: string;
+ description: string | undefined;
+ values: MetricEntry[];
+ metadata: DatasetMetadata | null;
+ columns: DatasetColumnMeta[];
+ datasourceId: number | undefined;
+ datasourceType: string | undefined;
+ disallowAdhoc: boolean;
+ disabled: boolean;
+ onChange: (next: MetricEntry[]) => void;
+}): ReactElement {
+ const [editingIndex, setEditingIndex] = useState<number | null>(null);
+ const [addingNew, setAddingNew] = useState(false);
+
+ const pickedSavedNames = values.filter(
+ (value): value is string => typeof value === 'string',
+ );
+ const availableSaved = metricOptions(metadata).filter(
+ option => !pickedSavedNames.includes(option.value),
+ );
+ const addOptions = [
+ ...availableSaved,
+ ...(disallowAdhoc
+ ? []
+ : [{ value: ADD_CUSTOM_METRIC, label: t('Custom metric…') }]),
+ ];
+
+ const move = (from: number, to: number) => {
+ const next = [...values];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ onChange(next);
+ };
+
+ return (
+ <Form.Item label={label} tooltip={description}>
+ <Flex vertical gap="small">
+ {values.map((value, index) => {
+ const isAdhoc = typeof value !== 'string';
+ const canEdit = isAdhoc && !disallowAdhoc;
+ const key =
+ typeof value === 'string'
+ ? value
+ : (value.optionName ?? `adhoc-${index}`);
+ // Event handlers only attached at all when `canEdit` — a static
+ // div with an onClick/onKeyDown regardless of role is what the
+ // a11y linter (rightly) objects to, not just a style choice.
+ const interactiveProps = canEdit
+ ? {
+ role: 'button' as const,
+ tabIndex: 0,
+ onClick: () => setEditingIndex(index),
+ onKeyDown: (event: KeyboardEvent<HTMLDivElement>) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ setEditingIndex(index);
+ }
+ },
+ }
+ : {};
+ const rowLabel = (
+ <div
+ style={{
+ flex: 1,
+ minWidth: 0,
+ cursor: canEdit ? 'pointer' : 'default',
+ }}
+ {...interactiveProps}
+ >
+ <Flex align="center" gap="small">
+ <ColumnTypeLabel type="metric" />
+ {metricEntryLabel(value, metadata)}
+ </Flex>
+ </div>
+ );
+ return (
+ <Flex key={key} align="center" gap="small">
+ <Icons.HolderOutlined iconSize="s" />
+ {isAdhoc ? (
+ <AdhocMetricEditor
+ value={value}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={editingIndex === index}
+ onOpenChange={open => {
+ // `Popover`'s own `trigger="click"` opens on any click to
+ // its children regardless of what the row's own onClick
+ // does — `canEdit` has to gate here too, or a disallowed
+ // dataset's rows would still open on click.
+ if (canEdit) setEditingIndex(open ? index : null);
+ }}
+ onSave={next => {
+ const updated = [...values];
+ updated[index] = next;
+ onChange(updated);
+ }}
+ >
+ {rowLabel}
+ </AdhocMetricEditor>
+ ) : (
+ rowLabel
+ )}
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s up', index + 1)}
+ disabled={index === 0}
+ icon={<Icons.UpOutlined iconSize="s" />}
+ onClick={() => move(index, index - 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Move metric %s down', index + 1)}
+ disabled={index === values.length - 1}
+ icon={<Icons.DownOutlined iconSize="s" />}
+ onClick={() => move(index, index + 1)}
+ />
+ <Button
+ buttonSize="xsmall"
+ buttonStyle="link"
+ aria-label={t('Remove metric %s', index + 1)}
+ icon={<Icons.CloseOutlined iconSize="s" />}
+ onClick={() => onChange(values.filter((_, i) => i !== index))}
+ />
+ </Flex>
+ );
+ })}
+ {addOptions.length > 0 && (
+ <Select
+ key={`${availableSaved.length}-${values.length}`}
+ value={null}
+ placeholder={t('Add field')}
+ ariaLabel={t('Add %s', label)}
+ options={addOptions}
+ disabled={disabled}
+ onChange={next => {
+ if (next === ADD_CUSTOM_METRIC) {
+ setAddingNew(true);
+ } else {
+ onChange([...values, next as string]);
+ }
+ }}
+ />
+ )}
+ {addingNew && (
+ <AdhocMetricEditor
+ value={undefined}
+ columns={columns}
+ datasourceId={datasourceId}
+ datasourceType={datasourceType}
+ open={addingNew}
+ onOpenChange={setAddingNew}
+ onSave={next => {
+ onChange([...values, next]);
+ setAddingNew(false);
+ }}
+ >
+ {/* Zero-size trigger: opening this popover is driven entirely
+ by picking "Custom metric…" above, not by a click here. */}
+ <span />
+ </AdhocMetricEditor>
+ )}
+ </Flex>
+ </Form.Item>
+ );
+}
+
+/**
+ * `x-control: "metric-multi"` — an ordered list of metric references. Falls
+ * back to the raw JSON editor (`CodeControl`) whenever no dataset is bound
+ * (or its fetch failed), or an entry is genuinely unrepresentable (see
+ * `isUnrepresentableMetric`); a well-formed mix of saved and ad-hoc metrics
+ * renders as `MetricEntryList`, not raw JSON.
+ */
+function MetricMultiControl(props: ControlProps): ReactElement {
+ const datasetId = useBoundDatasetId(props);
+ const { metadata, loading, error } = useDatasetMetadata(datasetId);
+ const values = Array.isArray(props.data) ? (props.data as unknown[]) : [];
+ const hasUnrepresentable = values.some(isUnrepresentableMetric);
+
+ if (shouldFallBackToCode(datasetId, error) || hasUnrepresentable) {
+ return <CodeControl {...props} />;
+ }
+
+ return (
+ <MetricEntryList
+ label={props.label}
+ description={props.description}
+ values={values as MetricEntry[]}
+ metadata={metadata}
+ columns={metadata?.columns ?? []}
+ datasourceId={datasetId}
+ datasourceType={metadata?.datasourceType}
+ disallowAdhoc={disallowsAdhocMetrics(metadata)}
+ disabled={!props.enabled || loading}
+ onChange={next => props.handleChange(props.path, next)}
+ />
+ );
+}
+
+/**
+ * The value the dataset picker needs for an already-bound dataset. Bare —
+ * just the id (composite-encoded per below) — while the name is still
+ * resolving: `AsyncSelect` prefers a *labeled* value's own label over a
+ * matching loaded option's, so handing it a stale `String(datasetId)` label
+ * here would overwrite the real name the moment the matching option
+ * arrives, replaying the exact stuck-on-the-numeric-id symptom this control
+ * exists to avoid — instead, letting `AsyncSelect` fall back to the id on
+ * its own leaves it free to prefer a loaded option's label the instant one
+ * matches. Once `tableName` resolves, the explicit label takes over.
+ *
+ * `value` carries the composite `"ds:<id>"` encoding when the Semantic
+ * Layers flag is on, matching what `loadDatasetOptions` encodes its own
+ * options as in that mode (see `resolveDatasetPick` below) — an option and
+ * a bound value in two different encodings never match, which is what
+ * leaves a genuinely-selected dataset showing as unselected.
+ */
+export function toDatasetSelectValue(
+ datasetId: number | undefined,
+ tableName: string | undefined,
+ useSemanticLayers: boolean,
+): { label: string; value: number | string } | number | string | undefined {
+ if (datasetId === undefined) {
+ return undefined;
+ }
+ const value = useSemanticLayers ? toCompositeValue(datasetId) : datasetId;
+ return tableName ? { label: tableName, value } : value;
+}
+
+/**
+ * The numeric dataset id a picked option resolves to, or `undefined` when
+ * the pick should be rejected outright: a semantic view, which
+ * `DataBinding.datasetId` has no way to represent (SIP-182's `kind` is a
+ * connection-level concept, not a per-field one on this schema). Handles
+ * both encodings `loadDatasetOptions` can produce — a plain number when the
+ * Semantic Layers flag is off, or, flag on, a composite `"ds:<id>"` /
+ * `"sv:<id>"` string for every option (not only semantic views: with the
+ * flag on, ordinary datasets are composite-encoded too).
+ */
+export function resolveDatasetPick(
+ value: number | string | undefined,
+): number | undefined {
+ if (value === undefined || typeof value === 'number') {
+ return value;
+ }
+ return kindFromComposite(value) === 'semantic_view'
+ ? undefined
+ : fromCompositeValue(value);
+}
+
+/**
+ * `loadDatasetOptions` filtered down to plain datasets. `DataBinding` has no
+ * way to represent a semantic view (SIP-182's `kind` is a connection-level
+ * concept, not a per-field one on this schema), so rather than let one be
+ * picked and then reject it after the fact — leaving the closed select
+ * showing a value the widget never actually bound — it is never offered.
+ *
+ * Module-level, not a closure defined inside `DatasetControl`: `AsyncSelect`
+ * treats a change in its `options` function's identity as a reason to wipe
+ * its own fetched-options cache, and a fresh arrow function on every render
+ * would do exactly that.
+ *
+ * `totalCount` is passed through unfiltered — it counts datasets and
+ * semantic views together, same as the page `data` was drawn from before
+ * this function's own filter ran. With the Semantic Layers flag on and
+ * enough semantic views sorted ahead of the wanted datasets on the current
+ * search, a page that filters down to nothing still reports more rows
+ * exist, but `AsyncSelect` only requests the next page on scroll — and a
+ * dropdown with nothing to scroll never gets the chance. Narrow (flag off,
+ * the filter is a no-op and this never applies) and not addressed here;
+ * paging forward internally past an empty filtered page, or asking the
+ * backend to exclude semantic views from the query in the first place,
+ * would close it.
+ */
+async function loadDatasetOnlyOptions(
+ search: string,
+ page: number,
+ pageSize: number,
+) {
+ const { data, totalCount } = await loadDatasetOptions(search, page, pageSize);
+ return {
+ data: data.filter(option => option.kind !== 'semantic_view'),
+ totalCount,
+ };
Review Comment:
**Suggestion:** The loader removes semantic views from each page but returns the combined endpoint's unfiltered `totalCount`. If the first page contains only semantic views, `AsyncSelect` receives no options and cannot scroll to trigger the next page even though the count says more results exist, so valid datasets later in the result set are permanently unavailable. Continue fetching pages until a non-empty filtered page is found or query the backend with semantic views excluded. [api mismatch]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Valid datasets can disappear from Dashboard V2 selection.
- ⚠️ Semantic Layers pagination produces empty first pages.
- ❌ Dataset-aware column and metric controls cannot initialize.
```
</details>
[](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=5581d45af3794b2a94a6f86b5aef64d0&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=5581d45af3794b2a94a6f86b5aef64d0&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/src/pages/DashboardBuilderV2/schemaControlRenderers.tsx
**Line:** 1166:1175
**Comment:**
*Api Mismatch: The loader removes semantic views from each page but returns the combined endpoint's unfiltered `totalCount`. If the first page contains only semantic views, `AsyncSelect` receives no options and cannot scroll to trigger the next page even though the count says more results exist, so valid datasets later in the result set are permanently unavailable. Continue fetching pages until a non-empty filtered page is found or query the backend with semantic views excluded.
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.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=da03b8e5b04f6c6f9e3558cba0b5c4b0a33acf5e3c7178f0d6d164a7e7a3ae0e&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=da03b8e5b04f6c6f9e3558cba0b5c4b0a33acf5e3c7178f0d6d164a7e7a3ae0e&reaction=dislike'>👎</a>
##########
superset/mcp_service/widgets/tool/set_widget_control_values.py:
##########
@@ -0,0 +1,107 @@
+# 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.
+
+"""MCP tool: set_widget_control_values"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any, Dict
+
+from superset_core.mcp.decorators import tool, ToolAnnotations
+
+from superset.mcp_service.widgets.node_store import nodes
+from superset.mcp_service.widgets.utils import (
+ resolve_widget,
+ unknown_node_error,
+ unknown_widget_type_error,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _set_widget_control_values_impl(
+ node_id: str,
+ control_values: Dict[str, Any],
+) -> Dict[str, Any]:
+ """Pure logic: validate-then-commit a widget node's control values.
+
+ Builds a candidate (the node's current ``props`` shallow-merged with
+ ``control_values`` -- new keys override, everything else is preserved,
+ mirroring how the frontend's ``DashboardProvider.updateProps`` merges)
+ without touching the stored node. Validates the candidate through
+ ``Widget.validate_control_values`` -- the same commit-time gate the
+ ``/type/<widget_type>/validate`` REST endpoint uses. Only on success is
+ the node's ``props`` replaced with the candidate, a single dict
+ reassignment, so a validation failure leaves the stored node completely
+ unchanged: there is nothing to roll back because nothing was mutated in
+ place.
+ """
+ node = nodes.get(node_id)
+ if node is None:
+ return unknown_node_error(node_id)
+
+ widget = resolve_widget(node.widget_type)
+ if widget is None:
+ return unknown_widget_type_error(node.widget_type)
+
+ candidate = {**node.props, **control_values}
+ if errors := widget.validate_control_values(candidate):
+ return {"errors": errors}
+
+ # model_validate succeeded once already inside validate_control_values;
+ # this second call is cheap and deterministic on the same input, and is
+ # what gets us the normalized (coerced, alias-keyed) values to return and
+ # store, rather than the raw candidate validate_control_values discards.
+ normalized = widget.controls_class.model_validate(candidate).model_dump(
Review Comment:
**Suggestion:** `validate_control_values` deliberately returns no errors for a falsy candidate, but this code then unconditionally performs strict model validation. For an empty node receiving `{}`, `model_validate` raises for missing required fields instead of returning the documented structured error response. Normalize only after a successful strict validation or handle the validation exception and return its errors. [api mismatch]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ MCP mutation tool can raise on empty node updates.
- ⚠️ Callers receive an exception instead of structured errors.
- ❌ Tool reliability differs from its documented contract.
```
</details>
[](https://docs.codeant.ai/cli/resolve-pr-comments-skill) [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=c5cd97bf56f64def84b21937cf09d9fd&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=c5cd97bf56f64def84b21937cf09d9fd&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset)
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset/mcp_service/widgets/tool/set_widget_control_values.py
**Line:** 62:70
**Comment:**
*Api Mismatch: `validate_control_values` deliberately returns no errors for a falsy candidate, but this code then unconditionally performs strict model validation. For an empty node receiving `{}`, `model_validate` raises for missing required fields instead of returning the documented structured error response. Normalize only after a successful strict validation or handle the validation exception and return its errors.
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.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
```
</details>
<a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=422a3acbab3fdd22c62b482fe68126827cbebd710fd6e0d40360d406845fe2b2&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F43588&comment_hash=422a3acbab3fdd22c62b482fe68126827cbebd710fd6e0d40360d406845fe2b2&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: notifications-unsubscribe@superset.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org