refactor(word-cloud): convert rotation and color controls to React components (POC) (#36275)

Co-authored-by: BrandanBurgess <brandanbb13@gmail.com>
This commit is contained in:
OrhanBC
2025-11-28 15:28:31 -05:00
committed by GitHub
parent c9a7a85159
commit d5c5dbb3bf
13 changed files with 863 additions and 22 deletions

View File

@@ -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'],
],
},
],

View File

@@ -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 packages 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';

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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';