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',
+ };
+ }
}
});
}