diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx index bd2e32e136fc..95ca7645f04f 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/components/RadioButtonControl.tsx @@ -19,14 +19,20 @@ import { ReactNode } from 'react'; import { t } from '@apache-superset/core'; import { JsonValue } from '@superset-ui/core'; -import { Radio } from '@superset-ui/core/components'; +import { Radio, Tooltip, TooltipPlacement } from '@superset-ui/core/components'; import { ControlHeader } from '../../components/ControlHeader'; -// [value, label] -export type RadioButtonOption = [ - JsonValue, - Exclude, -]; +export interface RadioButtonOptionObject { + value: JsonValue; + label: Exclude; + disabled?: boolean; + tooltip?: string; + tooltipPlacement?: TooltipPlacement; +} + +export type RadioButtonOption = + | [JsonValue, Exclude] + | RadioButtonOptionObject; export interface RadioButtonControlProps { label?: ReactNode; @@ -34,7 +40,17 @@ export interface RadioButtonControlProps { options: RadioButtonOption[]; hovered?: boolean; value?: JsonValue; - onChange: (opt: RadioButtonOption[0]) => void; + onChange: (opt: JsonValue) => void; +} + +function normalizeOption(option: RadioButtonOption): RadioButtonOptionObject { + if (Array.isArray(option)) { + return { + value: option[0], + label: option[1], + }; + } + return option; } export default function RadioButtonControl({ @@ -43,7 +59,9 @@ export default function RadioButtonControl({ onChange, ...props }: RadioButtonControlProps) { - const currentValue = initialValue || options[0][0]; + const normalizedOptions = options.map(normalizeOption); + const currentValue = initialValue || normalizedOptions[0].value; + return (
onChange(e.target.value)} > - {options.map(([val, label]) => ( - { - e.currentTarget?.focus(); - onChange(val); - }} - > - {label} - - ))} + {normalizedOptions.map( + ({ + value: val, + label, + disabled = false, + tooltip, + tooltipPlacement = 'top', + }) => { + const button = ( + { + e.currentTarget?.focus(); + onChange(val); + }} + > + {label} + + ); + + if (tooltip) { + return ( + + {button} + + ); + } + + return button; + }, + )}
- {/* accessibility begin */}
{t( '%s tab selected', - options.find(([val]) => val === currentValue)?.[1], + normalizedOptions.find(({ value: val }) => val === currentValue) + ?.label, )}
- {/* accessibility end */}
); } diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx index ab77740d4281..274da0f7f263 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx +++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/matrixifyControls.tsx @@ -62,18 +62,41 @@ const matrixifyControls: Record> = {}; // Dynamically add axis-specific controls (rows and columns) (['columns', 'rows'] as const).forEach(axisParam => { const axis: 'rows' | 'columns' = axisParam; + const otherAxis: 'rows' | 'columns' = axis === 'rows' ? 'columns' : 'rows'; matrixifyControls[`matrixify_mode_${axis}`] = { type: 'RadioButtonControl', label: t(`Metrics / Dimensions`), - default: 'metrics', - options: [ - ['metrics', t('Metrics')], - ['dimensions', t('Dimension members')], - ], + default: axis === 'columns' ? 'metrics' : 'dimensions', renderTrigger: true, tabOverride: 'matrixify', visibility: ({ controls }) => isMatrixifyVisible(controls, axis), + mapStateToProps: ({ controls }) => { + const otherAxisControlName = `matrixify_mode_${otherAxis}`; + + const otherAxisValue = + controls?.[otherAxisControlName]?.value ?? + (otherAxis === 'columns' ? 'metrics' : 'dimensions'); + + const isMetricsDisabled = otherAxisValue === 'metrics'; + + return { + options: [ + { + value: 'metrics', + label: t('Metrics'), + disabled: isMetricsDisabled, + tooltip: isMetricsDisabled + ? t( + "Metrics can't be used for both rows and columns at the same time", + ) + : undefined, + }, + { value: 'dimensions', label: t('Dimension members') }, + ], + }; + }, + rerender: [`matrixify_mode_${otherAxis}`, `matrixify_dimension_${axis}`], }; matrixifyControls[`matrixify_${axis}`] = { diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/components/RadioButtonControl.test.tsx b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/components/RadioButtonControl.test.tsx new file mode 100644 index 000000000000..6c9597590d8f --- /dev/null +++ b/superset-frontend/packages/superset-ui-chart-controls/test/shared-controls/components/RadioButtonControl.test.tsx @@ -0,0 +1,420 @@ +/** + * 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 '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@superset-ui/core/spec'; +import userEvent from '@testing-library/user-event'; +import RadioButtonControl, { + RadioButtonControlProps, + RadioButtonOption, +} from '../../../src/shared-controls/components/RadioButtonControl'; + +const defaultProps: RadioButtonControlProps = { + label: 'Test Radio Control', + options: [ + ['option1', 'Option 1'], + ['option2', 'Option 2'], + ['option3', 'Option 3'], + ], + onChange: jest.fn(), +}; + +const setup = (props: Partial = {}) => + render(); + +test('renders with array-based options (legacy format)', () => { + const { container } = setup(); + + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); + expect(container.querySelector('[role="tablist"]')).toBeInTheDocument(); +}); + +test('renders with object-based options (new format)', () => { + const objectOptions: RadioButtonOption[] = [ + { value: 'opt1', label: 'Object Option 1' }, + { value: 'opt2', label: 'Object Option 2' }, + { value: 'opt3', label: 'Object Option 3' }, + ]; + + setup({ options: objectOptions }); + + expect(screen.getByText('Object Option 1')).toBeInTheDocument(); + expect(screen.getByText('Object Option 2')).toBeInTheDocument(); + expect(screen.getByText('Object Option 3')).toBeInTheDocument(); +}); + +test('renders mixed array and object options', () => { + const mixedOptions: RadioButtonOption[] = [ + ['array1', 'Array Option'], + { value: 'obj1', label: 'Object Option' }, + ]; + + setup({ options: mixedOptions }); + + expect(screen.getByText('Array Option')).toBeInTheDocument(); + expect(screen.getByText('Object Option')).toBeInTheDocument(); +}); + +test('defaults to first option when no value provided', () => { + const { container } = setup(); + + const firstButton = container.querySelector('#tab-option1'); + expect(firstButton).toBeInTheDocument(); + expect(firstButton).toHaveAttribute('aria-selected', 'true'); +}); + +test('respects initial value prop', () => { + const { container } = setup({ value: 'option2' }); + + const secondButton = container.querySelector('#tab-option2'); + expect(secondButton).toBeInTheDocument(); + expect(secondButton).toHaveAttribute('aria-selected', 'true'); +}); + +test('calls onChange when radio button is clicked', () => { + const onChange = jest.fn(); + setup({ onChange }); + + const secondOption = screen.getByText('Option 2'); + fireEvent.click(secondOption); + + expect(onChange).toHaveBeenCalledWith('option2'); + expect(onChange).toHaveBeenCalled(); +}); + +test('handles multiple clicks correctly', () => { + const onChange = jest.fn(); + setup({ onChange }); + + fireEvent.click(screen.getByText('Option 2')); + fireEvent.click(screen.getByText('Option 3')); + fireEvent.click(screen.getByText('Option 1')); + + expect(onChange).toHaveBeenCalledWith('option2'); + expect(onChange).toHaveBeenCalledWith('option3'); + expect(onChange).toHaveBeenCalledWith('option1'); + expect(onChange.mock.calls.length).toBeGreaterThanOrEqual(3); +}); + +test('disables specific options when disabled flag is set', () => { + const optionsWithDisabled: RadioButtonOption[] = [ + { value: 'opt1', label: 'Enabled Option' }, + { value: 'opt2', label: 'Disabled Option', disabled: true }, + { value: 'opt3', label: 'Another Enabled' }, + ]; + + const { container } = setup({ options: optionsWithDisabled }); + + const disabledButton = container.querySelector('#tab-opt2'); + const enabledButton = container.querySelector('#tab-opt1'); + + expect(disabledButton).toHaveAttribute('disabled'); + expect(enabledButton).not.toHaveAttribute('disabled'); +}); + +test('disabled options do not trigger onChange when clicked', () => { + const onChange = jest.fn(); + const optionsWithDisabled: RadioButtonOption[] = [ + { value: 'opt1', label: 'Enabled' }, + { value: 'opt2', label: 'Disabled', disabled: true }, + ]; + + const { container } = setup({ options: optionsWithDisabled, onChange }); + + const disabledButton = container.querySelector('#tab-opt2'); + if (disabledButton) { + fireEvent.click(disabledButton); + } + + expect(onChange).not.toHaveBeenCalled(); +}); + +test('renders ControlHeader with label and description', () => { + const { container } = setup({ + label: 'My Radio Control', + description: 'This is a helpful description', + }); + + const header = container.querySelector('.ControlHeader'); + expect(header).toBeInTheDocument(); + expect(screen.getByText('My Radio Control')).toBeInTheDocument(); +}); + +test('aria-live region updates with current selection', () => { + const { container } = setup({ value: 'option1' }); + + const ariaLiveRegion = container.querySelector('[aria-live="polite"]'); + expect(ariaLiveRegion).toBeInTheDocument(); + expect(ariaLiveRegion?.textContent).toContain('Option 1'); +}); + +test('aria-live region updates when selection changes', () => { + const { container, rerender } = setup({ value: 'option1' }); + + let ariaLiveRegion = container.querySelector('[aria-live="polite"]'); + expect(ariaLiveRegion?.textContent).toContain('Option 1'); + + rerender(); + + ariaLiveRegion = container.querySelector('[aria-live="polite"]'); + expect(ariaLiveRegion?.textContent).toContain('Option 2'); +}); + +test('aria-live region is visually hidden but accessible', () => { + const { container } = setup(); + + const ariaLiveRegion = container.querySelector( + '[aria-live="polite"]', + ) as HTMLElement; + + expect(ariaLiveRegion).toBeInTheDocument(); + expect(ariaLiveRegion?.style.position).toBe('absolute'); + expect(ariaLiveRegion?.style.left).toBe('-9999px'); + expect(ariaLiveRegion?.style.height).toBe('1px'); + expect(ariaLiveRegion?.style.width).toBe('1px'); + expect(ariaLiveRegion?.style.overflow).toBe('hidden'); +}); + +test('renders tablist with correct aria-label when label is string', () => { + const { container } = setup({ label: 'String Label' }); + + const tablist = container.querySelector('[role="tablist"]'); + expect(tablist).toHaveAttribute('aria-label', 'String Label'); +}); + +test('tablist has no aria-label when label is not string', () => { + const { container } = setup({ label:
JSX Label
}); + + const tablist = container.querySelector('[role="tablist"]'); + expect(tablist).not.toHaveAttribute('aria-label'); +}); + +test('each radio button has correct aria-selected state', () => { + const { container } = setup({ value: 'option2' }); + + expect(container.querySelector('#tab-option1')).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(container.querySelector('#tab-option2')).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(container.querySelector('#tab-option3')).toHaveAttribute( + 'aria-selected', + 'false', + ); +}); + +test('radio buttons have correct aria-label when label is string', () => { + setup(); + + const option1Button = screen.getByLabelText('Option 1'); + expect(option1Button).toBeInTheDocument(); +}); + +test('focuses button when clicked', () => { + const { container } = setup(); + + const button = container.querySelector('#tab-option2') as HTMLElement; + fireEvent.click(button); + + expect(document.activeElement).toBe(button); +}); + +test('handles numeric values in options', () => { + const onChange = jest.fn(); + const numericOptions: RadioButtonOption[] = [ + [1, 'One'], + [2, 'Two'], + [3, 'Three'], + ]; + + setup({ options: numericOptions, onChange }); + + fireEvent.click(screen.getByText('Two')); + expect(onChange).toHaveBeenCalledWith(2); +}); + +test('handles boolean values in options', () => { + const onChange = jest.fn(); + const booleanOptions: RadioButtonOption[] = [ + [true, 'True'], + [false, 'False'], + ]; + + setup({ options: booleanOptions, onChange }); + + fireEvent.click(screen.getByText('False')); + expect(onChange).toHaveBeenCalledWith(false); +}); + +test('handles null values in options', () => { + const onChange = jest.fn(); + const nullOptions: RadioButtonOption[] = [ + [null, 'None'], + ['value', 'Value'], + ]; + + setup({ options: nullOptions, onChange }); + + fireEvent.click(screen.getByText('None')); + expect(onChange).toHaveBeenCalledWith(null); +}); + +test('generates unique IDs for options', () => { + const { container } = setup(); + + const button1 = container.querySelector('#tab-option1'); + const button2 = container.querySelector('#tab-option2'); + const button3 = container.querySelector('#tab-option3'); + + expect(button1).toBeInTheDocument(); + expect(button2).toBeInTheDocument(); + expect(button3).toBeInTheDocument(); +}); + +test('applies active class to selected button', () => { + const { container } = setup({ value: 'option2' }); + + const activeButton = container.querySelector('#tab-option2'); + expect(activeButton).toBeInTheDocument(); + expect(activeButton).toHaveAttribute('aria-selected', 'true'); +}); + +test('does not set aria-selected to true for unselected buttons', () => { + const { container } = setup({ value: 'option2' }); + + const inactiveButton1 = container.querySelector('#tab-option1'); + const inactiveButton3 = container.querySelector('#tab-option3'); + + expect(inactiveButton1).toHaveAttribute('aria-selected', 'false'); + expect(inactiveButton3).toHaveAttribute('aria-selected', 'false'); +}); + +test('backward compatibility with legacy array format', () => { + const onChange = jest.fn(); + const legacyOptions: RadioButtonOption[] = [ + ['val1', 'Label 1'], + ['val2', 'Label 2'], + ]; + + setup({ options: legacyOptions, onChange }); + + expect(screen.getByText('Label 1')).toBeInTheDocument(); + expect(screen.getByText('Label 2')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Label 2')); + expect(onChange).toHaveBeenCalledWith('val2'); +}); + +test('normalizeOption handles array format correctly', () => { + const arrayOption: RadioButtonOption = ['value', 'Label']; + const onChange = jest.fn(); + + setup({ options: [arrayOption], onChange }); + + expect(screen.getByText('Label')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Label')); + expect(onChange).toHaveBeenCalledWith('value'); +}); + +test('normalizeOption handles object format correctly', () => { + const objectOption: RadioButtonOption = { + value: 'value', + label: 'Label', + disabled: false, + }; + const onChange = jest.fn(); + + setup({ options: [objectOption], onChange }); + + expect(screen.getByText('Label')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Label')); + expect(onChange).toHaveBeenCalledWith('value'); +}); + +test('handles empty options array gracefully', () => { + const { container } = setup({ options: [], value: 'default' }); + + expect(container.querySelector('[role="tablist"]')).toBeInTheDocument(); +}); + +test('renders with hovered prop', () => { + const { container } = setup({ + label: 'Test', + description: 'Test description', + hovered: true, + }); + + expect( + container.querySelector('[data-test="info-tooltip-icon"]'), + ).toBeInTheDocument(); +}); + +test('renders tooltips for options with tooltip property', async () => { + const optionsWithTooltips: RadioButtonOption[] = [ + { value: 'opt1', label: 'Option 1', tooltip: 'Tooltip for option 1' }, + { value: 'opt2', label: 'Option 2' }, + { value: 'opt3', label: 'Option 3', tooltip: 'Tooltip for option 3' }, + ]; + + setup({ options: optionsWithTooltips }); + + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); + + const option1 = screen.getByText('Option 1'); + userEvent.hover(option1); + + await waitFor(() => { + expect(screen.getByText('Tooltip for option 1')).toBeInTheDocument(); + }); + + userEvent.unhover(option1); + + const option3 = screen.getByText('Option 3'); + userEvent.hover(option3); + + await waitFor(() => { + expect(screen.getByText('Tooltip for option 3')).toBeInTheDocument(); + }); +}); + +test('wraps disabled buttons with tooltip in span', () => { + const optionsWithDisabledTooltip: RadioButtonOption[] = [ + { value: 'opt1', label: 'Enabled with tooltip', tooltip: 'Tooltip text' }, + { + value: 'opt2', + label: 'Disabled with tooltip', + disabled: true, + tooltip: 'Disabled tooltip', + }, + ]; + + const { container } = setup({ options: optionsWithDisabledTooltip }); + + const disabledButton = container.querySelector('#tab-opt2'); + expect(disabledButton).toHaveAttribute('disabled'); + expect(disabledButton?.parentElement?.tagName).toBe('SPAN'); +}); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/ExtraControls.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/ExtraControls.tsx index 2e997b855830..5bf1700d2b90 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/components/ExtraControls.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/ExtraControls.tsx @@ -19,10 +19,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { HandlerFunction, JsonValue } from '@superset-ui/core'; import { styled } from '@apache-superset/core/ui'; -import { - RadioButtonOption, - sharedControlComponents, -} from '@superset-ui/chart-controls'; +import { sharedControlComponents } from '@superset-ui/chart-controls'; import { AreaChartStackControlOptions } from '../constants'; const { RadioButtonControl } = sharedControlComponents; @@ -60,7 +57,7 @@ export function useExtraControl< }, [area]); const extraControlsHandler = useCallback( - (value: RadioButtonOption[0]) => { + (value: JsonValue) => { if (area) { if (setControlValue) { setControlValue('stack', value);