This is an automated email from the ASF dual-hosted git repository.

justinpark pushed a commit to branch feat--add-explore-dashboard-to-core-api-6.2
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to 
refs/heads/feat--add-explore-dashboard-to-core-api-6.2 by this push:
     new 3c3f99fe4a7 feat(extensions): validate control name/value in Explore 
core API's setControlValues
3c3f99fe4a7 is described below

commit 3c3f99fe4a76bf93a920300f8d772d7b82f402a0
Author: justinpark <[email protected]>
AuthorDate: Wed Sep 30 16:35:14 2026 -0700

    feat(extensions): validate control name/value in Explore core API's 
setControlValues
    
    Throws a descriptive error naming the valid controls for the current viz
    type when a control name doesn't exist, and runs each control's own
    validators against the given value before applying it. When an unknown
    name matches a raw ECharts option not exposed as a discrete control, the
    error points to the echart_options control instead. All entries are
    validated before any are dispatched, so a rejected call never applies a
    partial set of changes.
    
    Co-Authored-By: Claude Sonnet 5 <[email protected]>
---
 .../packages/superset-core/src/explore/index.ts    | 10 +++
 superset-frontend/src/core/explore/index.test.ts   | 77 +++++++++++++++++-
 superset-frontend/src/core/explore/index.ts        | 95 ++++++++++++++++++++++
 3 files changed, 181 insertions(+), 1 deletion(-)

diff --git a/superset-frontend/packages/superset-core/src/explore/index.ts 
b/superset-frontend/packages/superset-core/src/explore/index.ts
index e2b6f9939c9..1da9bd60317 100644
--- a/superset-frontend/packages/superset-core/src/explore/index.ts
+++ b/superset-frontend/packages/superset-core/src/explore/index.ts
@@ -83,9 +83,19 @@ export declare function getControlValue(name: string): 
unknown;
  * `renderTrigger` (e.g. `echart_options`) re-render an instant preview in
  * place — no save and no new data query required.
  *
+ * Every entry is validated before any of them is applied, so a rejected
+ * call never applies a partial set of changes.
+ *
  * @param values A map of control name to the value it should be set to.
  * @returns Promise that resolves once the values have been applied.
  *
+ * @throws If `values` names a control that doesn't exist on the chart's
+ * current viz type. When the name matches a setting only reachable through
+ * the raw ECharts override (e.g. `title`, `legend`, `tooltip`), the error
+ * points to `echart_options` instead.
+ * @throws If a value fails the target control's own validators (e.g. a
+ * non-numeric `row_limit`). The error includes each validator's message.
+ *
  * @example
  * ```typescript
  * await explore.setControlValues({
diff --git a/superset-frontend/src/core/explore/index.test.ts 
b/superset-frontend/src/core/explore/index.test.ts
index 0601f1e4a03..8906f276807 100644
--- a/superset-frontend/src/core/explore/index.test.ts
+++ b/superset-frontend/src/core/explore/index.test.ts
@@ -55,6 +55,10 @@ const mockGetState = store.getState as jest.Mock;
 const mockDispatch = store.dispatch as jest.Mock;
 const mockRequestChartDataResolved = requestChartDataResolved as jest.Mock;
 
+const mockRowLimitValidator = jest.fn((value: unknown) =>
+  Number.isInteger(value) ? false : 'is expected to be an integer',
+);
+
 // Mirrors the current controls Explore would be querying with, matching the
 // pre-normalization `form_data` slice used elsewhere in these tests.
 const defaultControls = {
@@ -62,6 +66,15 @@ const defaultControls = {
   viz_type: { value: 'echarts_timeseries_bar' },
 };
 
+// A real viz type's `controls` slice (see getAllControlsState) has one
+// entry per control the control panel defines, which is what lets
+// setControlValues tell an unknown control name from a known one.
+const controlsWithEchartOptionsAndRowLimit = {
+  ...defaultControls,
+  echart_options: { value: '{}', validators: [] },
+  row_limit: { value: 10000, validators: [mockRowLimitValidator] },
+};
+
 function activateExplore(overrides: Record<string, unknown> = {}) {
   mockGetPage.mockReturnValue('explore');
   mockGetState.mockReturnValue({
@@ -133,7 +146,7 @@ test('setControlValues throws when active but no chart is 
loaded (no datasource)
 });
 
 test('setControlValues dispatches a programmatic setControlValue per entry', 
async () => {
-  activateExplore();
+  activateExplore({ controls: controlsWithEchartOptionsAndRowLimit });
 
   await explore.setControlValues({
     echart_options: '{ "title": "Revenue" }',
@@ -152,6 +165,68 @@ test('setControlValues dispatches a programmatic 
setControlValue per entry', asy
   expect(mockDispatch).toHaveBeenCalledTimes(2);
 });
 
+test('setControlValues throws on an unknown control name, listing the valid 
ones', async () => {
+  activateExplore();
+
+  // The valid-controls list must come from `controls` (every control the
+  // current viz type's panel defines), not from getControlValues()/form_data,
+  // which only reflects currently-applied values and can omit hidden or
+  // untouched controls.
+  await expect(
+    explore.setControlValues({ not_a_real_control: 1 }),
+  ).rejects.toThrow(
+    '"not_a_real_control" is not a valid control for the chart currently ' +
+      'loaded in Explore. Valid controls for this chart type are: ' +
+      'datasource, viz_type.',
+  );
+  expect(mockDispatch).not.toHaveBeenCalled();
+});
+
+test('setControlValues hints at echart_options for an unknown ECharts-shaped 
name', async () => {
+  activateExplore({ controls: controlsWithEchartOptionsAndRowLimit });
+
+  await expect(
+    explore.setControlValues({ tooltip_trigger: 'item' }),
+  ).rejects.toThrow('"tooltip_trigger" is not a control on this chart');
+  await expect(
+    explore.setControlValues({ tooltip_trigger: 'item' }),
+  ).rejects.toThrow('echart_options');
+  expect(mockDispatch).not.toHaveBeenCalled();
+});
+
+test('setControlValues does not hint at echart_options when the chart has no 
such control', async () => {
+  activateExplore({
+    controls: {
+      datasource: { value: '1__table' },
+      viz_type: { value: 'table' },
+    },
+  });
+
+  await expect(explore.setControlValues({ title: 'x' })).rejects.toThrow(
+    '"title" is not a valid control',
+  );
+});
+
+test("setControlValues throws when a value fails the control's own validator", 
async () => {
+  activateExplore({ controls: controlsWithEchartOptionsAndRowLimit });
+
+  await expect(
+    explore.setControlValues({ row_limit: 'not-a-number' }),
+  ).rejects.toThrow(
+    'Invalid value for control "row_limit": is expected to be an integer',
+  );
+  expect(mockDispatch).not.toHaveBeenCalled();
+});
+
+test('setControlValues validates every entry before dispatching any of them', 
async () => {
+  activateExplore({ controls: controlsWithEchartOptionsAndRowLimit });
+
+  await expect(
+    explore.setControlValues({ row_limit: 100, not_a_real_control: 1 }),
+  ).rejects.toThrow('"not_a_real_control" is not a valid control');
+  expect(mockDispatch).not.toHaveBeenCalled();
+});
+
 test('getQuery throws when Explore is not the active surface', async () => {
   await expect(explore.getQuery()).rejects.toThrow(
     'No chart is currently loaded in Explore',
diff --git a/superset-frontend/src/core/explore/index.ts 
b/superset-frontend/src/core/explore/index.ts
index 8a8afc398da..d5705b814d7 100644
--- a/superset-frontend/src/core/explore/index.ts
+++ b/superset-frontend/src/core/explore/index.ts
@@ -22,6 +22,11 @@ import type {
   JsonObject,
   QueryFormData,
 } from '@superset-ui/core';
+import type {
+  ControlState,
+  ControlStateMapping,
+  ControlValueValidator,
+} from '@superset-ui/chart-controls';
 import { setControlValue } from 'src/explore/actions/exploreActions';
 import { getVisibleFormDataFromControls } from 'src/explore/controlUtils';
 import { requestChartDataResolved } from 'src/components/Chart/chartAction';
@@ -77,10 +82,100 @@ const requireFormData = (): QueryFormData => {
   return formData;
 };
 
+// Top-level ECharts option keys Superset never exposes as a discrete
+// control (see sharedControls.tsx's `echart_options`). A caller trying to
+// set one of these by name isn't naming a typo — they're reaching for a
+// setting that only exists through that JS-override control, so the error
+// should point there instead of just saying "unknown control".
+const ECHART_OPTIONS_HINT_KEYS = new Set([
+  'title',
+  'legend',
+  'grid',
+  'tooltip',
+  'toolbox',
+  'datazoom',
+  'visualmap',
+  'animation',
+  'backgroundcolor',
+  'textstyle',
+  'graphic',
+  'polar',
+  'radiusaxis',
+  'angleaxis',
+  'radar',
+  'geo',
+  'parallel',
+  'parallelaxis',
+  'singleaxis',
+  'timeline',
+  'calendar',
+  'dataset',
+  'aria',
+  'brush',
+]);
+
+const matchesEchartOptionsKey = (controlName: string): boolean => {
+  const [firstSegment] = controlName.split(/[._]/);
+  return (
+    ECHART_OPTIONS_HINT_KEYS.has(controlName.toLowerCase()) ||
+    ECHART_OPTIONS_HINT_KEYS.has(firstSegment.toLowerCase())
+  );
+};
+
+const buildUnknownControlMessage = (
+  controlName: string,
+  controls: ControlStateMapping,
+): string => {
+  if (controls.echart_options && matchesEchartOptionsKey(controlName)) {
+    return (
+      `"${controlName}" is not a control on this chart, but it looks like an 
ECharts ` +
+      `option. Try setting it through the "echart_options" control instead, 
e.g. ` +
+      `setControlValues({ echart_options: '{ 
"${controlName.split(/[._]/)[0]}": { ... } }' }).`
+    );
+  }
+  // getControlValues() only reflects form_data — the currently-applied
+  // values, missing controls that are hidden or untouched — so it can't
+  // stand in for the full set of controls this viz type supports. `controls`
+  // (unlike form_data) has one entry per control the control panel defines,
+  // so list those names directly instead of pointing at the wrong API.
+  const validNames = Object.keys(controls).sort().join(', ');
+  return (
+    `"${controlName}" is not a valid control for the chart currently loaded in 
` +
+    `Explore. Valid controls for this chart type are: ${validNames}.`
+  );
+};
+
+const validateControlValue = (
+  controlName: string,
+  value: unknown,
+  control: ControlState,
+): void => {
+  const validators = (control.validators ?? []) as ControlValueValidator[];
+  const processedState = { ...control, value } as ControlState;
+  const errors = validators
+    .map(validator => validator.call(control, value, processedState))
+    .filter((error): error is string => typeof error === 'string' && !!error);
+  if (errors.length > 0) {
+    throw new Error(
+      `Invalid value for control "${controlName}": ${errors.join('; ')}`,
+    );
+  }
+};
+
 const setControlValues: typeof exploreApi.setControlValues = async (
   values: Record<string, unknown>,
 ) => {
   requireFormData();
+  const controls = getExploreState().controls ?? ({} as ControlStateMapping);
+  // Validate every entry before dispatching any of them, so a bad entry
+  // later in the object can't leave earlier ones applied.
+  Object.entries(values).forEach(([controlName, value]) => {
+    const control = controls[controlName];
+    if (!control) {
+      throw new Error(buildUnknownControlMessage(controlName, controls));
+    }
+    validateControlValue(controlName, value, control);
+  });
   Object.entries(values).forEach(([controlName, value]) => {
     store.dispatch(
       setControlValue(controlName, value, undefined, { programmatic: true }),

Reply via email to