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

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new d5c5dbb3bf refactor(word-cloud): convert rotation and color controls 
to React components (POC) (#36275)
d5c5dbb3bf is described below

commit d5c5dbb3bf94dd1d1ba5fc637a88d56a47895e42
Author: OrhanBC <[email protected]>
AuthorDate: Fri Nov 28 15:28:31 2025 -0500

    refactor(word-cloud): convert rotation and color controls to React 
components (POC) (#36275)
    
    Co-authored-by: BrandanBurgess <[email protected]>
---
 .../plugin/{controlPanel.ts => controlPanel.tsx}   |  24 +-
 .../src/plugin/controls/ColorSchemeControl.tsx     |  62 ++++
 .../ColorSchemeControl/ColorSchemeLabel.tsx        | 126 ++++++++
 .../plugin/controls/ColorSchemeControl/index.tsx   | 333 +++++++++++++++++++++
 .../src/plugin/controls/RotationControl.tsx        |  76 +++++
 .../src/plugin/controls/index.ts                   |  20 ++
 .../test/ColorSchemeControl.test.tsx               |  83 +++++
 .../test/RotationControl.test.tsx                  |  59 ++++
 .../test/controlPanel.test.ts                      |  47 +++
 .../plugin-chart-word-cloud/test/tsconfig.json     |   3 +-
 .../explore/components/ControlPanelsContainer.tsx  |  30 +-
 .../src/explore/controlUtils/getControlState.ts    |  10 +-
 .../src/utils/getControlsForVizType.ts             |  12 +
 13 files changed, 863 insertions(+), 22 deletions(-)

diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controlPanel.ts 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controlPanel.tsx
similarity index 81%
rename from 
superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controlPanel.ts
rename to 
superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controlPanel.tsx
index 9425f12117..a4d7d689e7 100644
--- 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controlPanel.ts
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controlPanel.tsx
@@ -21,6 +21,7 @@ import {
   ControlPanelConfig,
   getStandardizedControls,
 } from '@superset-ui/chart-controls';
+import { RotationControl, ColorSchemeControl } from './controls';
 
 const config: ControlPanelConfig = {
   controlPanelSections: [
@@ -63,25 +64,14 @@ const config: ControlPanelConfig = {
             },
           },
         ],
+        [<RotationControl name="rotation" key="rotation" renderTrigger />],
         [
-          {
-            name: 'rotation',
-            config: {
-              type: 'SelectControl',
-              label: t('Word Rotation'),
-              choices: [
-                ['random', t('random')],
-                ['flat', t('flat')],
-                ['square', t('square')],
-              ],
-              renderTrigger: true,
-              default: 'square',
-              clearable: false,
-              description: t('Rotation to apply to words in the cloud'),
-            },
-          },
+          <ColorSchemeControl
+            name="color_scheme"
+            key="color_scheme"
+            renderTrigger
+          />,
         ],
-        ['color_scheme'],
       ],
     },
   ],
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl.tsx
 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl.tsx
new file mode 100644
index 0000000000..f9f1859b1f
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl.tsx
@@ -0,0 +1,62 @@
+/**
+ * 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 { getCategoricalSchemeRegistry } from '@superset-ui/core';
+import InternalColorSchemeControl from './ColorSchemeControl/index';
+import { ColorSchemes } from './ColorSchemeControl/index';
+// NOTE: We copied the Explore ColorSchemeControl into this plugin to avoid
+// pulling the entire frontend src tree into this package’s tsconfig (importing
+// from src/ was dragging in fixtures, tests, and other plugins). Keep this 
copy
+// in sync with upstream changes, and consider moving it into a shared package
+// once the control-panel refactor settles so all consumers can reuse it.
+import { ControlComponentProps } from '@superset-ui/chart-controls';
+
+type ColorSchemeControlWrapperProps = ControlComponentProps<string> & {
+  clearable?: boolean;
+};
+
+export default function ColorSchemeControlWrapper({
+  name = 'color_scheme',
+  value,
+  onChange,
+  clearable = true,
+  label,
+  description,
+  ...rest
+}: ColorSchemeControlWrapperProps) {
+  const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
+  const choices = categoricalSchemeRegistry.keys().map(s => [s, s]);
+  const schemes = categoricalSchemeRegistry.getMap() as ColorSchemes;
+
+  return (
+    <InternalColorSchemeControl
+      name={name}
+      value={value ?? ''}
+      onChange={onChange}
+      clearable={clearable}
+      choices={choices}
+      schemes={schemes}
+      hasCustomLabelsColor={false}
+      label={typeof label === 'string' ? label : undefined}
+      description={typeof description === 'string' ? description : undefined}
+      {...rest}
+    />
+  );
+}
+
+ColorSchemeControlWrapper.displayName = 'ColorSchemeControlWrapper';
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl/ColorSchemeLabel.tsx
 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl/ColorSchemeLabel.tsx
new file mode 100644
index 0000000000..135d7d1134
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl/ColorSchemeLabel.tsx
@@ -0,0 +1,126 @@
+/**
+ * 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 { css, SupersetTheme } from '@apache-superset/core/ui';
+import { useRef, useState } from 'react';
+import { Tooltip } from '@superset-ui/core/components';
+
+type ColorSchemeLabelProps = {
+  colors: string[];
+  id: string;
+  label: string;
+};
+
+export default function ColorSchemeLabel(props: ColorSchemeLabelProps) {
+  const { id, label, colors } = props;
+  const [showTooltip, setShowTooltip] = useState<boolean>(false);
+  const labelNameRef = useRef<HTMLElement>(null);
+  const labelsColorRef = useRef<HTMLElement>(null);
+  const handleShowTooltip = () => {
+    const labelNameElement = labelNameRef.current;
+    const labelsColorElement = labelsColorRef.current;
+    if (
+      labelNameElement &&
+      labelsColorElement &&
+      (labelNameElement.scrollWidth > labelNameElement.offsetWidth ||
+        labelNameElement.scrollHeight > labelNameElement.offsetHeight ||
+        labelsColorElement.scrollWidth > labelsColorElement.offsetWidth ||
+        labelsColorElement.scrollHeight > labelsColorElement.offsetHeight)
+    ) {
+      setShowTooltip(true);
+    }
+  };
+  const handleHideTooltip = () => {
+    setShowTooltip(false);
+  };
+
+  const colorsList = () =>
+    colors.map((color: string, i: number) => (
+      <span
+        data-test="color"
+        key={`${id}-${i}`}
+        css={(theme: { sizeUnit: number }) => css`
+          padding-left: ${theme.sizeUnit / 2}px;
+          :before {
+            content: '';
+            display: inline-block;
+            background-color: ${color};
+            border: 1px solid ${color === 'white' ? 'black' : color};
+            width: 9px;
+            height: 10px;
+          }
+        `}
+      />
+    ));
+
+  const tooltipContent = () => (
+    <>
+      <span>{label}</span>
+      <div>{colorsList()}</div>
+    </>
+  );
+
+  return (
+    <Tooltip
+      data-testid="tooltip"
+      overlayClassName="color-scheme-tooltip"
+      title={tooltipContent()}
+      key={id}
+      open={showTooltip}
+    >
+      <span
+        className="color-scheme-option"
+        onMouseEnter={handleShowTooltip}
+        onMouseLeave={handleHideTooltip}
+        css={css`
+          display: flex;
+          align-items: center;
+          justify-content: flex-start;
+        `}
+        data-test={id}
+      >
+        <span
+          className="color-scheme-label"
+          ref={labelNameRef}
+          css={(theme: SupersetTheme) => css`
+            min-width: 125px;
+            padding-right: ${theme.sizeUnit * 2}px;
+            text-overflow: ellipsis;
+            overflow: hidden;
+            white-space: nowrap;
+          `}
+        >
+          {label}
+        </span>
+        <span
+          ref={labelsColorRef}
+          css={(theme: SupersetTheme) => css`
+            flex: 100%;
+            text-overflow: ellipsis;
+            overflow: hidden;
+            white-space: nowrap;
+            padding-right: ${theme.sizeUnit}px;
+          `}
+        >
+          {colorsList()}
+        </span>
+      </span>
+    </Tooltip>
+  );
+}
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl/index.tsx
 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl/index.tsx
new file mode 100644
index 0000000000..22983ca286
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/ColorSchemeControl/index.tsx
@@ -0,0 +1,333 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useMemo, ReactNode } from 'react';
+
+import {
+  ColorScheme,
+  ColorSchemeGroup,
+  SequentialScheme,
+  t,
+  getLabelsColorMap,
+  CategoricalColorNamespace,
+} from '@superset-ui/core';
+import { css, useTheme } from '@apache-superset/core/ui';
+import { sortBy } from 'lodash';
+import { ControlHeader } from '@superset-ui/chart-controls';
+import {
+  Tooltip,
+  Select,
+  type SelectOptionsType,
+} from '@superset-ui/core/components';
+import { Icons } from '@superset-ui/core/components/Icons';
+import ColorSchemeLabel from './ColorSchemeLabel';
+
+const getColorNamespace = (namespace?: string) => namespace || undefined;
+
+export type OptionData = SelectOptionsType[number]['options'][number] & {
+  searchText?: string;
+};
+
+export interface ColorSchemes {
+  [key: string]: ColorScheme;
+}
+
+export interface ColorSchemeControlProps {
+  hasCustomLabelsColor: boolean;
+  hasDashboardColorScheme?: boolean;
+  hasSharedLabelsColor?: boolean;
+  sharedLabelsColors?: string[];
+  mapLabelsColors?: Record<string, any>;
+  colorNamespace?: string;
+  chartId?: number;
+  dashboardId?: number;
+  label?: string;
+  name: string;
+  onChange?: (value: string) => void;
+  value: string;
+  clearable: boolean;
+  defaultScheme?: string;
+  choices: string[][] | (() => string[][]);
+  schemes: ColorSchemes | (() => ColorSchemes);
+  isLinear?: boolean;
+  description?: string;
+  hovered?: boolean;
+}
+
+const CUSTOM_LABEL_ALERT = t(
+  `The colors of this chart might be overridden by custom label colors of the 
related dashboard.
+    Check the JSON metadata in the Advanced settings.`,
+);
+
+const DASHBOARD_ALERT = t(
+  `The color scheme is determined by the related dashboard.
+        Edit the color scheme in the dashboard properties.`,
+);
+
+const DASHBOARD_CONTEXT_ALERT = t(
+  `You are viewing this chart in a dashboard context with labels shared across 
multiple charts.
+        The color scheme selection is disabled.`,
+);
+
+const DASHBOARD_CONTEXT_TOOLTIP = t(
+  `You are viewing this chart in the context of a dashboard that is directly 
affecting its colors.
+        To edit the color scheme, open this chart outside of the dashboard.`,
+);
+
+const Label = ({
+  label,
+  dashboardId,
+  hasSharedLabelsColor,
+  hasCustomLabelsColor,
+  hasDashboardColorScheme,
+}: Pick<
+  ColorSchemeControlProps,
+  | 'label'
+  | 'dashboardId'
+  | 'hasCustomLabelsColor'
+  | 'hasSharedLabelsColor'
+  | 'hasDashboardColorScheme'
+>) => {
+  const theme = useTheme();
+  if (hasSharedLabelsColor || hasCustomLabelsColor || hasDashboardColorScheme) 
{
+    const alertTitle =
+      hasCustomLabelsColor && !hasSharedLabelsColor
+        ? CUSTOM_LABEL_ALERT
+        : dashboardId && hasDashboardColorScheme
+          ? DASHBOARD_ALERT
+          : DASHBOARD_CONTEXT_ALERT;
+    return (
+      <>
+        {label}{' '}
+        <Tooltip title={alertTitle}>
+          <Icons.WarningOutlined
+            iconColor={theme.colorWarning}
+            css={css`
+              vertical-align: baseline;
+            `}
+            iconSize="s"
+          />
+        </Tooltip>
+      </>
+    );
+  }
+  return <>{label}</>;
+};
+
+const ColorSchemeControl = ({
+  hasCustomLabelsColor = false,
+  hasDashboardColorScheme = false,
+  mapLabelsColors = {},
+  sharedLabelsColors = [],
+  dashboardId,
+  colorNamespace,
+  chartId,
+  label = t('Color scheme'),
+  onChange = () => {},
+  value,
+  clearable = false,
+  defaultScheme,
+  choices = [],
+  schemes = {},
+  isLinear,
+  ...rest
+}: ColorSchemeControlProps) => {
+  const countSharedLabelsColor = sharedLabelsColors.length;
+  const colorMapInstance = getLabelsColorMap();
+  const chartLabels = chartId
+    ? colorMapInstance.chartsLabelsMap.get(chartId)?.labels || []
+    : [];
+  const hasSharedLabelsColor = !!(
+    dashboardId &&
+    countSharedLabelsColor > 0 &&
+    chartLabels.some(label => sharedLabelsColors.includes(label))
+  );
+  const hasDashboardScheme = dashboardId && hasDashboardColorScheme;
+  const showDashboardLockedOption = hasDashboardScheme || hasSharedLabelsColor;
+  const theme = useTheme();
+  const currentScheme = useMemo(() => {
+    if (showDashboardLockedOption) {
+      return 'dashboard';
+    }
+    let result = value || defaultScheme;
+    if (result === 'SUPERSET_DEFAULT') {
+      const schemesObject = typeof schemes === 'function' ? schemes() : 
schemes;
+      result = schemesObject?.SUPERSET_DEFAULT?.id;
+    }
+    return result;
+  }, [defaultScheme, schemes, showDashboardLockedOption, value]);
+
+  const options = useMemo(() => {
+    if (showDashboardLockedOption) {
+      return [
+        {
+          value: 'dashboard',
+          label: (
+            <Tooltip title={DASHBOARD_CONTEXT_TOOLTIP}>
+              {t('Dashboard scheme')}
+            </Tooltip>
+          ),
+        },
+      ];
+    }
+    const schemesObject = typeof schemes === 'function' ? schemes() : schemes;
+    const controlChoices = typeof choices === 'function' ? choices() : choices;
+    const allColorOptions: string[] = [];
+    const filteredColorOptions = controlChoices.filter(o => {
+      const option = o[0];
+      const isValidColorOption =
+        option !== 'SUPERSET_DEFAULT' && !allColorOptions.includes(option);
+      allColorOptions.push(option);
+      return isValidColorOption;
+    });
+
+    const groups = filteredColorOptions.reduce(
+      (acc, [value]) => {
+        const currentScheme = schemesObject[value];
+
+        // For categorical scheme, display all the colors
+        // For sequential scheme, show 10 or interpolate to 10.
+        // Sequential schemes usually have at most 10 colors.
+        let colors: string[] = [];
+        if (currentScheme) {
+          colors = isLinear
+            ? (currentScheme as SequentialScheme).getColors(10)
+            : currentScheme.colors;
+        }
+        const option = {
+          label: (
+            <ColorSchemeLabel
+              id={currentScheme.id}
+              label={currentScheme.label}
+              colors={colors}
+            />
+          ) as ReactNode,
+          value,
+          searchText: currentScheme.label,
+        };
+        acc[currentScheme.group ?? 
ColorSchemeGroup.Other].options.push(option);
+        return acc;
+      },
+      {
+        [ColorSchemeGroup.Custom]: {
+          title: ColorSchemeGroup.Custom,
+          label: t('Custom color palettes'),
+          options: [] as OptionData[],
+        },
+        [ColorSchemeGroup.Featured]: {
+          title: ColorSchemeGroup.Featured,
+          label: t('Featured color palettes'),
+          options: [] as OptionData[],
+        },
+        [ColorSchemeGroup.Other]: {
+          title: ColorSchemeGroup.Other,
+          label: t('Other color palettes'),
+          options: [] as OptionData[],
+        },
+      },
+    );
+    const nonEmptyGroups = Object.values(groups)
+      .filter(group => group.options.length > 0)
+      .map(group => ({
+        ...group,
+        options: sortBy(group.options, opt => opt.label),
+      }));
+
+    // if there are no featured or custom color schemes, return the ungrouped 
options
+    if (
+      nonEmptyGroups.length === 1 &&
+      nonEmptyGroups[0].title === ColorSchemeGroup.Other
+    ) {
+      return nonEmptyGroups[0].options.map(opt => ({
+        value: opt.value,
+        label: opt.customLabel || opt.label,
+      }));
+    }
+    return nonEmptyGroups.map(group => ({
+      label: group.label,
+      options: group.options.map(opt => ({
+        value: opt.value,
+        label: opt.customLabel || opt.label,
+        searchText: opt.searchText,
+      })),
+    }));
+  }, [choices, hasDashboardScheme, hasSharedLabelsColor, isLinear, schemes]);
+
+  // We can't pass on change directly because it receives a second
+  // parameter and it would be interpreted as the error parameter
+  const handleOnChange = (value: string) => {
+    if (chartId) {
+      colorMapInstance.setOwnColorScheme(chartId, value);
+      if (dashboardId) {
+        const colorNameSpace = getColorNamespace(colorNamespace);
+        const categoricalNamespace =
+          CategoricalColorNamespace.getNamespace(colorNameSpace);
+
+        const sharedLabelsSet = new Set(sharedLabelsColors);
+        // reset colors except shared and custom labels to keep dashboard 
consistency
+        const resettableLabels = Object.keys(mapLabelsColors).filter(
+          l => !sharedLabelsSet.has(l),
+        );
+        categoricalNamespace.resetColorsForLabels(resettableLabels);
+      }
+    }
+
+    onChange(value);
+  };
+
+  return (
+    <>
+      <ControlHeader
+        {...rest}
+        label={
+          <Label
+            label={label}
+            dashboardId={dashboardId}
+            hasCustomLabelsColor={hasCustomLabelsColor}
+            hasDashboardColorScheme={hasDashboardColorScheme}
+            hasSharedLabelsColor={hasSharedLabelsColor}
+          />
+        }
+      />
+      <Select
+        css={css`
+          width: 100%;
+          & .ant-select-item.ant-select-item-group {
+            padding-left: ${theme.sizeUnit}px;
+            font-size: ${theme.fontSize}px;
+          }
+          & .ant-select-item-option-grouped {
+            padding-left: ${theme.sizeUnit * 3}px;
+          }
+        `}
+        aria-label={t('Select color scheme')}
+        allowClear={clearable}
+        disabled={hasDashboardScheme || hasSharedLabelsColor}
+        onChange={handleOnChange}
+        placeholder={t('Select scheme')}
+        value={currentScheme}
+        showSearch
+        getPopupContainer={triggerNode => triggerNode.parentNode}
+        options={options}
+        optionFilterProps={['label', 'value', 'searchText']}
+      />
+    </>
+  );
+};
+
+export default ColorSchemeControl;
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/RotationControl.tsx
 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/RotationControl.tsx
new file mode 100644
index 0000000000..5c2ea55577
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/RotationControl.tsx
@@ -0,0 +1,76 @@
+/**
+ * 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 { t } from '@superset-ui/core';
+import { Select, SelectValue } from '@superset-ui/core/components';
+import { ControlHeader } from '@superset-ui/chart-controls';
+import { ControlComponentProps } from '@superset-ui/chart-controls';
+
+type RotationControlProps = ControlComponentProps<string> & {
+  choices?: [string, string][];
+  clearable?: boolean;
+};
+
+export default function RotationControl({
+  name = 'rotation',
+  value,
+  onChange,
+  choices = [
+    ['random', t('random')],
+    ['flat', t('flat')],
+    ['square', t('square')],
+  ],
+  label = t('Word Rotation'),
+  description = t('Rotation to apply to words in the cloud'),
+  renderTrigger = true,
+  clearable = false,
+}: RotationControlProps) {
+  return (
+    <div className="Control" data-test={name}>
+      <ControlHeader
+        name={name}
+        label={label}
+        description={description}
+        renderTrigger={renderTrigger}
+      />
+      <Select
+        value={value ?? 'square'}
+        options={choices.map(([key, text]) => ({ label: text, value: key }))}
+        onChange={(val: SelectValue) => {
+          if (val === null || val === undefined) {
+            return;
+          }
+          // Handle LabeledValue object
+          if (
+            typeof val === 'object' &&
+            'value' in val &&
+            val.value !== undefined
+          ) {
+            onChange?.(val.value as string);
+          } else if (typeof val === 'string' || typeof val === 'number') {
+            // Handle raw value
+            onChange?.(String(val));
+          }
+        }}
+        allowClear={clearable}
+      />
+    </div>
+  );
+}
+
+RotationControl.displayName = 'RotationControl';
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/index.ts
 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/index.ts
new file mode 100644
index 0000000000..ef7ad329b1
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/controls/index.ts
@@ -0,0 +1,20 @@
+/**
+ * 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.
+ */
+export { default as RotationControl } from './RotationControl';
+export { default as ColorSchemeControl } from './ColorSchemeControl';
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/test/ColorSchemeControl.test.tsx
 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/ColorSchemeControl.test.tsx
new file mode 100644
index 0000000000..014895cf8c
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/ColorSchemeControl.test.tsx
@@ -0,0 +1,83 @@
+/**
+ * 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 { ColorSchemeControl } from '../src/plugin/controls';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+
+const setup = (props = {}) => {
+  const defaultProps = {
+    name: 'color_scheme',
+    value: '',
+    onChange: jest.fn(),
+  };
+  return render(<ColorSchemeControl {...defaultProps} {...props} />);
+};
+
+test('renders color scheme control', () => {
+  setup();
+  // The Select component has an aria-label - use role to find the input 
specifically
+  expect(
+    screen.getByRole('combobox', { name: 'Select color scheme' }),
+  ).toBeInTheDocument();
+});
+
+test('renders select with value', () => {
+  // Get a color scheme from the registry to use as a test value
+  const { getCategoricalSchemeRegistry } = require('@superset-ui/core');
+  const registry = getCategoricalSchemeRegistry();
+  const firstScheme = registry.keys()[0];
+
+  setup({ value: firstScheme });
+  // Use role to find the input specifically
+  const select = screen.getByRole('combobox', { name: 'Select color scheme' });
+  expect(select).toBeInTheDocument();
+});
+
+test('calls onChange when value changes', async () => {
+  const onChange = jest.fn();
+  const { getCategoricalSchemeRegistry } = require('@superset-ui/core');
+  const registry = getCategoricalSchemeRegistry();
+  const schemes = registry.keys();
+
+  if (schemes.length < 2) {
+    // Skip if there aren't enough schemes to test
+    return;
+  }
+
+  const initialScheme = schemes[0];
+  const newScheme = schemes[1];
+
+  setup({ onChange, value: initialScheme });
+
+  // Find the select input using role
+  const selectInput = screen.getByRole('combobox', {
+    name: 'Select color scheme',
+  });
+
+  userEvent.click(selectInput);
+
+  // Wait for and select a different color scheme
+  // The scheme name should be visible in the dropdown
+  const newSchemeOption = await screen.findByText(newScheme, { exact: false });
+  userEvent.click(newSchemeOption);
+
+  // Verify onChange was called with the new scheme value
+  expect(onChange).toHaveBeenCalledWith(newScheme);
+  expect(onChange).toHaveBeenCalledTimes(1);
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/test/RotationControl.test.tsx
 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/RotationControl.test.tsx
new file mode 100644
index 0000000000..b10999a56d
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/RotationControl.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * 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 { RotationControl } from '../src/plugin/controls';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+
+const setup = (props = {}) => {
+  const defaultProps = {
+    name: 'rotation',
+    value: 'square',
+    onChange: jest.fn(),
+  };
+  return render(<RotationControl {...defaultProps} {...props} />);
+};
+
+test('renders rotation control with label', () => {
+  setup();
+  expect(screen.getByText('Word Rotation')).toBeInTheDocument();
+});
+
+test('renders select with default value', () => {
+  setup({ value: 'flat' });
+  // Check that the select is rendered (implementation depends on Select 
component)
+  expect(screen.getByRole('combobox')).toBeInTheDocument();
+});
+
+test('calls onChange when value changes', async () => {
+  const onChange = jest.fn();
+  setup({ onChange, value: 'square' });
+
+  // Find the select input and open the dropdown
+  const selectInput = screen.getByRole('combobox');
+
+  await userEvent.click(selectInput);
+
+  // Wait for and select a different option
+  const flatOption = await screen.findByText('flat', { exact: false });
+  await userEvent.click(flatOption);
+
+  // Verify onChange was called with the string value
+  expect(onChange).toHaveBeenCalledWith('flat');
+  expect(onChange).toHaveBeenCalledTimes(1);
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/test/controlPanel.test.ts 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/controlPanel.test.ts
new file mode 100644
index 0000000000..56794dd5f2
--- /dev/null
+++ 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/controlPanel.test.ts
@@ -0,0 +1,47 @@
+/**
+ * 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 controlPanel from '../src/plugin/controlPanel';
+import React, { ReactElement } from 'react';
+
+const isNameControl = (
+  item: unknown,
+  name: string,
+): item is ReactElement<{ name: string }> =>
+  React.isValidElement<{ name: string }>(item) && item.props.name === name;
+
+test('control panel has rotation and color_scheme controls', () => {
+  const optionsSection = controlPanel.controlPanelSections.find(
+    (section): section is NonNullable<typeof section> =>
+      Boolean(section && section.label === 'Options'),
+  );
+  expect(optionsSection).toBeDefined();
+  if (!optionsSection) {
+    throw new Error('Options section missing');
+  }
+
+  const rotationRow = optionsSection.controlSetRows.find(row =>
+    row.some(item => isNameControl(item, 'rotation')),
+  );
+  expect(rotationRow).toBeDefined();
+
+  const colorSchemeRow = optionsSection.controlSetRows.find(row =>
+    row.some(item => isNameControl(item, 'color_scheme')),
+  );
+  expect(colorSchemeRow).toBeDefined();
+});
diff --git 
a/superset-frontend/plugins/plugin-chart-word-cloud/test/tsconfig.json 
b/superset-frontend/plugins/plugin-chart-word-cloud/test/tsconfig.json
index 42fa49ba51..be84a09330 100644
--- a/superset-frontend/plugins/plugin-chart-word-cloud/test/tsconfig.json
+++ b/superset-frontend/plugins/plugin-chart-word-cloud/test/tsconfig.json
@@ -1,8 +1,7 @@
 {
   "compilerOptions": {
     "composite": false,
-    "emitDeclarationOnly": false,
-    "rootDir": "."
+    "emitDeclarationOnly": false
   },
   "extends": "../../../tsconfig.json",
   "include": ["**/*", "../types/**/*", "../../../types/**/*"]
diff --git 
a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx 
b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
index 6526201ac8..1b1e5046b2 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
@@ -18,6 +18,7 @@
  */
 /* eslint camelcase: 0 */
 import {
+  cloneElement,
   isValidElement,
   ReactNode,
   useCallback,
@@ -575,7 +576,7 @@ export const ControlPanelsContainer = (props: 
ControlPanelsContainerProps) => {
   const renderControlPanelSection = (
     section: ExpandedControlPanelSectionConfig,
   ) => {
-    const { controls } = props;
+    const { controls, chart, exploreState, form_data, actions } = props;
     const { label, description, visibility } = section;
 
     // Section label can be a ReactNode but in some places we want to
@@ -657,7 +658,32 @@ export const ControlPanelsContainer = (props: 
ControlPanelsContainerProps) => {
                   }
                   if (isValidElement(controlItem)) {
                     // When the item is a React element
-                    return controlItem;
+                    const element = controlItem as React.ReactElement<
+                      Record<string, unknown>
+                    >;
+
+                    const controlName = (element.props as { name: string })
+                      .name;
+                    if (!controlName) {
+                      return element;
+                    }
+                    const controlState = controls[controlName];
+
+                    return cloneElement(element, {
+                      ...(element.props as Record<string, unknown>),
+                      actions,
+                      controls,
+                      chart,
+                      exploreState,
+                      form_data,
+                      ...(controlState && {
+                        value: controlState.value,
+                        validationErrors: controlState.validationErrors,
+                        default: controlState.default,
+                        onChange: (value: unknown, errors: unknown[]) =>
+                          setControlValue(controlName, value, errors),
+                      }),
+                    });
                   }
                   if (
                     isCustomControlItem(controlItem) &&
diff --git a/superset-frontend/src/explore/controlUtils/getControlState.ts 
b/superset-frontend/src/explore/controlUtils/getControlState.ts
index be80aae1a8..4a9e139ec4 100644
--- a/superset-frontend/src/explore/controlUtils/getControlState.ts
+++ b/superset-frontend/src/explore/controlUtils/getControlState.ts
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { ReactNode } from 'react';
+import React, { ReactNode } from 'react';
 import {
   DatasourceType,
   ensureIsArray,
@@ -182,6 +182,14 @@ export function getAllControlsState(
             state,
             formData[name],
           );
+        } else if (React.isValidElement(field)) {
+          const props = field.props as { name: string; [key: string]: any };
+          const { name, ...configProps } = props;
+          controlsState[name] = getControlStateFromControlConfig(
+            configProps as ControlConfig<any>,
+            state,
+            formData[name],
+          );
         }
       }),
     );
diff --git a/superset-frontend/src/utils/getControlsForVizType.ts 
b/superset-frontend/src/utils/getControlsForVizType.ts
index 4fff9a06f1..44b9045f5d 100644
--- a/superset-frontend/src/utils/getControlsForVizType.ts
+++ b/superset-frontend/src/utils/getControlsForVizType.ts
@@ -18,6 +18,7 @@
  */
 
 import memoizeOne from 'memoize-one';
+import React from 'react';
 import { isControlPanelSectionConfig } from '@superset-ui/chart-controls';
 import { getChartControlPanelRegistry, JsonObject } from '@superset-ui/core';
 import type { ControlMap } from 'src/components/AlteredSliceTag/types';
@@ -56,6 +57,17 @@ const memoizedControls = memoizeOne(
                     config: JsonObject;
                   };
                   controlsMap[controlObj.name] = controlObj.config;
+                } else if (React.isValidElement(control)) {
+                  const { name } = control.props as { name: string };
+                  if (name) {
+                    const ComponentType = control.type as React.ComponentType;
+                    controlsMap[name] = {
+                      type:
+                        ComponentType.displayName ||
+                        ComponentType.name ||
+                        'CustomControl',
+                    };
+                  }
                 }
               });
             }


Reply via email to