This is an automated email from the ASF dual-hosted git repository.
sadpandajoe 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 d595becba71 test(explore): cover column config, conditional
formatting, and contour controls (#44843)
d595becba71 is described below
commit d595becba718dc78484811acbd6fa9a5d7fe58c0
Author: Joe Li <[email protected]>
AuthorDate: Thu Oct 22 16:18:51 2026 -0700
test(explore): cover column config, conditional formatting, and contour
controls (#44843)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../ColumnConfigControl.popover.test.tsx | 293 ++++++++++++++++++++
.../ControlForm/ControlForm.test.tsx | 158 +++++++++++
.../ConditionalFormattingControl.test.tsx | 297 ++++++++++++++++++---
.../controls/ContourControl/ContourOption.test.tsx | 111 ++++++++
.../ContourControl/ContourPopoverControl.test.tsx | 277 +++++++++++++++++++
.../ContourControl/ContourPopoverTrigger.test.tsx | 133 +++++++++
.../controls/ContourControl/index.test.tsx | 212 +++++++++++++++
7 files changed, 1443 insertions(+), 38 deletions(-)
diff --git
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.popover.test.tsx
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.popover.test.tsx
new file mode 100644
index 00000000000..b340955c175
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.popover.test.tsx
@@ -0,0 +1,293 @@
+/**
+ * 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 {
+ render,
+ screen,
+ userEvent,
+ waitFor,
+ within,
+} from 'spec/helpers/testing-library';
+import { GenericDataType } from '@apache-superset/core/common';
+import ColumnConfigControl from './ColumnConfigControl';
+
+type ControlProps = Parameters<typeof ColumnConfigControl>[0];
+
+const makeColumns = (count: number) => {
+ const colnames = Array.from({ length: count }, (_, i) => `col_${i + 1}`);
+ return {
+ colnames,
+ coltypes: colnames.map(() => GenericDataType.String),
+ };
+};
+
+const renderControl = (props: Partial<ControlProps> = {}) => {
+ const onChange = jest.fn();
+ const utils = render(
+ <ColumnConfigControl
+ name="column_config"
+ label="Customize columns"
+ columnsPropsObject={makeColumns(3)}
+ value={{}}
+ onChange={onChange}
+ {...props}
+ />,
+ );
+ return { onChange, ...utils };
+};
+
+// The checkbox control renders its label in the header next to the input
+// without associating them, so resolve the input through the header.
+const findCheckbox = async (label: string) => {
+ const header = (await screen.findByText(label)).closest(
+ '.ControlHeader',
+ ) as HTMLElement;
+ return within(header).getByRole('checkbox');
+};
+
+const openColumn = async (name: string) => {
+ await userEvent.click(screen.getByText(name));
+ return findCheckbox('Truncate Cells');
+};
+
+test('renders one row per column when no columns are applied', () => {
+ renderControl();
+ expect(screen.getByText('col_1')).toBeInTheDocument();
+ expect(screen.getByText('col_2')).toBeInTheDocument();
+ expect(screen.getByText('col_3')).toBeInTheDocument();
+});
+
+test('renders only the rows listed in appliedColumnNames', () => {
+ renderControl({ appliedColumnNames: ['col_1', 'col_3'] });
+ expect(screen.getByText('col_1')).toBeInTheDocument();
+ expect(screen.queryByText('col_2')).not.toBeInTheDocument();
+ expect(screen.getByText('col_3')).toBeInTheDocument();
+});
+
+test('renders nothing when there are no columns', () => {
+ const { container } = renderControl({
+ columnsPropsObject: { colnames: [], coltypes: [] },
+ });
+ expect(container).toBeEmptyDOMElement();
+});
+
+test('does not offer to show more columns at the 12 column limit', () => {
+ renderControl({ columnsPropsObject: makeColumns(12) });
+ expect(screen.getByText('col_12')).toBeInTheDocument();
+ expect(screen.queryByText('Show all columns')).not.toBeInTheDocument();
+});
+
+test('truncates to 10 rows and offers to show all columns above 12', () => {
+ renderControl({ columnsPropsObject: makeColumns(13) });
+ expect(screen.getByText('col_10')).toBeInTheDocument();
+ expect(screen.queryByText('col_11')).not.toBeInTheDocument();
+ expect(screen.getByText('Show all columns')).toBeInTheDocument();
+});
+
+test('expands and collapses the full column list via the toggle', async () => {
+ renderControl({ columnsPropsObject: makeColumns(13) });
+
+ await userEvent.click(screen.getByText('Show all columns'));
+ expect(screen.getByText('col_13')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('Show less columns'));
+ expect(screen.queryByText('col_13')).not.toBeInTheDocument();
+ expect(screen.getByText('Show all columns')).toBeInTheDocument();
+});
+
+test('counts only applied columns toward the show more threshold', () => {
+ renderControl({
+ columnsPropsObject: makeColumns(20),
+ appliedColumnNames: ['col_1', 'col_2'],
+ });
+ expect(screen.queryByText('Show all columns')).not.toBeInTheDocument();
+});
+
+test('opens the popover with the column config form on click', async () => {
+ renderControl();
+ expect(screen.queryByText('Truncate Cells')).not.toBeInTheDocument();
+ expect(await openColumn('col_2')).toBeInTheDocument();
+});
+
+test('shows the stored config for the column in the popover', async () => {
+ renderControl({ value: { col_2: { truncateLongCells: true } } });
+ expect(await openColumn('col_2')).toBeChecked();
+});
+
+test('calls onChange after the debounce with the edited column config', async
() => {
+ const { onChange } = renderControl();
+ const checkbox = await openColumn('col_2');
+
+ await userEvent.click(checkbox);
+ expect(onChange).not.toHaveBeenCalled();
+
+ await waitFor(() =>
+ expect(onChange).toHaveBeenCalledWith({
+ col_2: { truncateLongCells: true },
+ }),
+ );
+});
+
+test('merges the edit into existing configs of other known columns', async ()
=> {
+ const { onChange } = renderControl({
+ value: { col_1: { columnWidth: 50 } },
+ });
+ await userEvent.click(await openColumn('col_2'));
+
+ await waitFor(() =>
+ expect(onChange).toHaveBeenCalledWith({
+ col_1: { columnWidth: 50 },
+ col_2: { truncateLongCells: true },
+ }),
+ );
+});
+
+test('keeps existing keys of the edited column alongside the new one', async
() => {
+ const { onChange } = renderControl({
+ value: { col_2: { columnWidth: 80 } },
+ });
+ await userEvent.click(await openColumn('col_2'));
+
+ await waitFor(() =>
+ expect(onChange).toHaveBeenCalledWith({
+ col_2: { columnWidth: 80, truncateLongCells: true },
+ }),
+ );
+});
+
+test('drops stored configs for columns that are no longer known', async () => {
+ const { onChange } = renderControl({
+ value: {
+ col_1: { columnWidth: 50 },
+ removed_col: { columnWidth: 99 },
+ },
+ });
+ await userEvent.click(await openColumn('col_2'));
+
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
+ const saved = onChange.mock.calls[0][0];
+ expect(saved).not.toHaveProperty('removed_col');
+ expect(saved).toHaveProperty('col_1', { columnWidth: 50 });
+});
+
+test('drops configs of columns hidden by appliedColumnNames on edit', async ()
=> {
+ const { onChange } = renderControl({
+ appliedColumnNames: ['col_1', 'col_2'],
+ value: { col_3: { columnWidth: 30 } },
+ });
+ await userEvent.click(await openColumn('col_1'));
+
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
+ expect(onChange.mock.calls[0][0]).toEqual({
+ col_1: { truncateLongCells: true },
+ });
+});
+
+const timeComparisonProps = (
+ timeComparisonColumnMap: Record<string, boolean>,
+): Partial<ControlProps> => ({
+ columnsPropsObject: {
+ colnames: ['metric_a', 'metric_b'],
+ coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
+ timeComparisonColumnMap,
+ },
+});
+
+test('adds a General tab for time comparison columns', async () => {
+ renderControl(timeComparisonProps({ metric_a: true }));
+ await userEvent.click(screen.getByText('metric_a'));
+
+ const tabs = await screen.findAllByRole('tab');
+ expect(tabs.map(tab => tab.textContent)).toEqual([
+ 'General',
+ 'Column Settings',
+ 'Number formatting',
+ ]);
+ expect(await findCheckbox('Display column in the
chart')).toBeInTheDocument();
+});
+
+test('omits the General tab for columns that are not time comparison columns',
async () => {
+ renderControl(timeComparisonProps({ metric_a: true }));
+ await userEvent.click(screen.getByText('metric_b'));
+
+ const tabs = await screen.findAllByRole('tab');
+ expect(tabs.map(tab => tab.textContent)).toEqual([
+ 'Column Settings',
+ 'Number formatting',
+ ]);
+});
+
+test('saves General tab edits under the time comparison column name', async ()
=> {
+ const { onChange } = renderControl(timeComparisonProps({ metric_a: true }));
+ await userEvent.click(screen.getByText('metric_a'));
+ await userEvent.click(await findCheckbox('Display column in the chart'));
+
+ await waitFor(() =>
+ expect(onChange).toHaveBeenCalledWith({ metric_a: { visible: false } }),
+ );
+});
+
+test('shows a hidden-column icon on child columns configured as not visible',
() => {
+ renderControl({
+ columnsPropsObject: {
+ ...makeColumns(2),
+ childColumnMap: { col_2: true },
+ },
+ value: { col_1: { visible: false }, col_2: { visible: false } },
+ });
+ // col_1 is hidden too but is not a child column, so only col_2 shows the
icon.
+ expect(screen.getAllByRole('img', { name: 'eye-invisible'
})).toHaveLength(1);
+});
+
+// The time column is reported as `__timestamp` by the query but displayed as
+// "Time". The table plugins look a column's config up by the raw query
+// column name.
+const timestampProps = (
+ value: ControlProps['value'] = {},
+): Partial<ControlProps> => ({
+ columnsPropsObject: {
+ colnames: ['__timestamp', 'name'],
+ coltypes: [GenericDataType.Temporal, GenericDataType.String],
+ },
+ value,
+});
+
+test('labels the __timestamp column with its alias', () => {
+ renderControl(timestampProps());
+ expect(screen.getByText('Time')).toBeInTheDocument();
+ expect(screen.queryByText('__timestamp')).not.toBeInTheDocument();
+});
+
+test('does not alias columns without an alias entry', () => {
+ renderControl(timestampProps());
+ expect(screen.getByText('name')).toBeInTheDocument();
+});
+
+test('reads the stored config of an aliased column by its raw column name',
async () => {
+ renderControl(timestampProps({ __timestamp: { horizontalAlign: 'right' } }));
+ await userEvent.click(screen.getByText('Time'));
+ const right = await screen.findByRole('radio', { name: /align-right/ });
+ expect(right).toBeChecked();
+});
+
+test('does not read back a config stored under the alias', async () => {
+ renderControl(timestampProps({ Time: { horizontalAlign: 'right' } }));
+ await userEvent.click(screen.getByText('Time'));
+ const left = await screen.findByRole('radio', { name: /align-left/ });
+ expect(left).toBeChecked();
+});
diff --git
a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ControlForm/ControlForm.test.tsx
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ControlForm/ControlForm.test.tsx
new file mode 100644
index 00000000000..363770bd3af
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ControlForm/ControlForm.test.tsx
@@ -0,0 +1,158 @@
+/**
+ * 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 { useCallback, useState } from 'react';
+import { JsonObject } from '@superset-ui/core';
+import { Constants } from '@superset-ui/core/components';
+import {
+ render,
+ screen,
+ sleep,
+ userEvent,
+ within,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import ControlForm, { ControlFormItem, ControlFormRow } from '.';
+
+const noDigits = (value: unknown) =>
+ /\d/.test(String(value)) ? 'Digits are not allowed' : false;
+
+const renderForm = (
+ props: {
+ value?: Record<string, string>;
+ validators?: ((value: unknown) => string | false)[];
+ } = {},
+) => {
+ const onChange = jest.fn();
+ render(
+ <ControlForm onChange={onChange} value={props.value}>
+ <ControlFormRow>
+ <ControlFormItem
+ name="title"
+ controlType="Input"
+ label="Title"
+ description="Title description"
+ validators={props.validators}
+ />
+ <ControlFormItem
+ name="note"
+ controlType="Input"
+ label="Note"
+ description="Note description"
+ />
+ </ControlFormRow>
+ </ControlForm>,
+ );
+ return { onChange };
+};
+
+// Mirrors a real parent: the debounced form change is fed back as the value.
+const renderStatefulForm = () => {
+ const onChange = jest.fn();
+ const Harness = () => {
+ const [value, setValue] = useState<JsonObject>({});
+ const handleChange = useCallback((next: JsonObject) => {
+ setValue(next);
+ onChange(next);
+ }, []);
+ return (
+ <ControlForm onChange={handleChange} value={value}>
+ <ControlFormRow>
+ <ControlFormItem
+ name="title"
+ controlType="Input"
+ label="Title"
+ description="Title description"
+ />
+ <ControlFormItem
+ name="note"
+ controlType="Input"
+ label="Note"
+ description="Note description"
+ />
+ </ControlFormRow>
+ </ControlForm>
+ );
+ };
+ render(<Harness />);
+ return { onChange };
+};
+
+test('seeds each field from the matching key of the form value', () => {
+ renderForm({ value: { title: 'Hello', note: 'World' } });
+ expect(
+ screen
+ .getAllByRole('textbox')
+ .map(input => (input as HTMLInputElement).value),
+ ).toEqual(['Hello', 'World']);
+});
+
+test('propagates an edit to the form merged with the existing value', async ()
=> {
+ const { onChange } = renderForm({ value: { note: 'World' } });
+ await userEvent.type(screen.getAllByRole('textbox')[0], 'Hi');
+
+ await waitFor(() =>
+ expect(onChange).toHaveBeenLastCalledWith({ note: 'World', title: 'Hi' }),
+ );
+});
+
+test('debounces rapid keystrokes into a single form change', async () => {
+ const { onChange } = renderForm();
+ await userEvent.type(screen.getAllByRole('textbox')[0], 'abc');
+
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
+ expect(onChange).toHaveBeenCalledWith({ title: 'abc' });
+});
+
+test('does not propagate a value that fails validation and shows the error',
async () => {
+ const { onChange } = renderForm({ validators: [noDigits] });
+ await userEvent.type(screen.getAllByRole('textbox')[0], '1');
+
+ const errorBadge = await screen.findByTestId('error-tooltip');
+ await userEvent.hover(within(errorBadge).getByRole('img'));
+ expect(await screen.findByText('Digits are not
allowed')).toBeInTheDocument();
+ // Wait past the form's debounce window so a late propagation would surface.
+ await sleep(Constants.FAST_DEBOUNCE + 100);
+ expect(onChange).not.toHaveBeenCalled();
+});
+
+test('skips validation for an emptied field and propagates the empty value',
async () => {
+ const { onChange } = renderForm({
+ value: { title: 'x' },
+ validators: [() => 'always invalid'],
+ });
+ await userEvent.clear(screen.getAllByRole('textbox')[0]);
+
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith({ title: '' }));
+ expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('keeps an earlier field edit when a later field is edited after the form
change flushed', async () => {
+ const { onChange } = renderStatefulForm();
+ const [title, note] = screen.getAllByRole('textbox');
+
+ await userEvent.type(title, 'Hi');
+ await waitFor(() =>
+ expect(onChange).toHaveBeenLastCalledWith({ title: 'Hi' }),
+ );
+ await userEvent.type(note, 'Yo');
+
+ await waitFor(() =>
+ expect(onChange).toHaveBeenLastCalledWith({ title: 'Hi', note: 'Yo' }),
+ );
+});
diff --git
a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
index 36c156d4efc..16971e25231 100644
---
a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
+++
b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
@@ -16,39 +16,18 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import { ComponentProps } from 'react';
+import {
+ render,
+ screen,
+ userEvent,
+ waitFor,
+ within,
+} from 'spec/helpers/testing-library';
import { Comparator } from '@superset-ui/chart-controls';
import { GenericDataType } from '@apache-superset/core/common';
import ConditionalFormattingControl from './ConditionalFormattingControl';
-import { ConditionalFormattingConfig, FormattingPopoverProps } from './types';
-
-// Stand-in popover: renders its trigger and a button that submits a fixed
-// formatter, so tests can add one without driving the full form.
-jest.mock('./FormattingPopover', () => ({
- FormattingPopover: ({
- children,
- onChange,
- title,
- }: Pick<FormattingPopoverProps, 'children' | 'onChange' | 'title'>) => (
- <div>
- {children}
- <button
- type="button"
- onClick={() =>
- onChange({
- column: 'my_col',
- operator: jest.requireActual<
- typeof import('@superset-ui/chart-controls')
- >('@superset-ui/chart-controls').Comparator.IsTrue,
- colorScheme: 'colorSuccess',
- })
- }
- >
- {`submit ${title}`}
- </button>
- </div>
- ),
-}));
+import { ConditionalFormattingConfig } from './types';
const columnOptions = [
{ label: 'My Column', value: 'my_col', dataType: GenericDataType.Boolean },
@@ -130,6 +109,247 @@ test('renders verbose column name when available', () => {
expect(screen.getByText('My Column is false')).toBeInTheDocument();
});
+const numericColumns = [
+ { label: 'Column A', value: 'col_a', dataType: GenericDataType.Numeric },
+ { label: 'Column B', value: 'col_b', dataType: GenericDataType.Numeric },
+];
+
+const greaterThanA: ConditionalFormattingConfig = {
+ column: 'col_a',
+ operator: Comparator.GreaterThan,
+ targetValue: 1,
+ colorScheme: 'colorSuccess',
+};
+
+const lessThanB: ConditionalFormattingConfig = {
+ column: 'col_b',
+ operator: Comparator.LessThan,
+ targetValue: 2,
+ colorScheme: 'colorSuccess',
+};
+
+const renderCrud = (
+ props: Partial<ComponentProps<typeof ConditionalFormattingControl>> = {},
+) => {
+ const onChange = jest.fn();
+ const utils = render(
+ <ConditionalFormattingControl
+ {...defaultProps}
+ columnOptions={numericColumns}
+ onChange={onChange}
+ {...props}
+ />,
+ );
+ return { onChange, ...utils };
+};
+
+const lastChange = (onChange: jest.Mock) => onChange.mock.calls.at(-1)?.[0];
+
+// Opens the edit popover of the formatter with the given label, sets its
+// target value, and applies.
+const editTargetValue = async (formatterLabel: string, value: string) => {
+ await userEvent.click(screen.getByText(formatterLabel));
+ await screen.findByText('Edit formatter');
+ const targetValue = screen.getByLabelText('Target value');
+ await userEvent.clear(targetValue);
+ await userEvent.type(targetValue, value);
+ await userEvent.click(screen.getByText('Apply'));
+};
+
+// The popover Selects render their options inline next to the trigger, so
+// scope the option lookup to the Select that was opened.
+const pickColumn = async (optionLabel: string) => {
+ const trigger = await screen.findByRole('combobox', {
+ name: 'Select column',
+ });
+ const container = trigger.closest('.ant-select')!
+ .parentElement as HTMLElement;
+ await userEvent.click(trigger);
+ await userEvent.click(await within(container).findByText(optionLabel));
+};
+
+test('opens the add formatter popover from the add label', async () => {
+ renderCrud();
+ expect(screen.queryByText('Add new formatter')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('Add new color formatter'));
+
+ expect(await screen.findByText('Add new formatter')).toBeInTheDocument();
+ expect(screen.getByText('Apply')).toBeInTheDocument();
+});
+
+test('adds a formatter and reports it through onChange when the popover is
applied', async () => {
+ const { onChange } = renderCrud();
+
+ await userEvent.click(screen.getByText('Add new color formatter'));
+ await pickColumn('Column B');
+ await userEvent.click(await screen.findByText('Apply'));
+
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({ column: 'col_b' }),
+ ]),
+ );
+ expect(screen.getByText('col_b')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.queryByText('Add new formatter')).not.toBeInTheDocument(),
+ );
+});
+
+test('appends a new formatter after the existing ones', async () => {
+ const { onChange } = renderCrud({ value: [greaterThanA] });
+
+ await userEvent.click(screen.getByText('Add new color formatter'));
+ await pickColumn('Column B');
+ await userEvent.click(await screen.findByText('Apply'));
+
+ await waitFor(() => expect(lastChange(onChange)).toHaveLength(2));
+ expect(lastChange(onChange)[0]).toEqual(greaterThanA);
+ expect(lastChange(onChange)[1]).toEqual(
+ expect.objectContaining({ column: 'col_b' }),
+ );
+});
+
+test('opens the edit popover prefilled from the clicked formatter', async ()
=> {
+ renderCrud({ value: [greaterThanA, lessThanB] });
+
+ await userEvent.click(screen.getByText('col_b < 2'));
+
+ expect(await screen.findByText('Edit formatter')).toBeInTheDocument();
+ expect(screen.getByLabelText('Target value')).toHaveValue('2');
+});
+
+test('replaces the edited formatter at its index and keeps the others', async
() => {
+ const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+ await editTargetValue('col_b < 2', '7');
+
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ greaterThanA,
+ expect.objectContaining({
+ column: 'col_b',
+ operator: Comparator.LessThan,
+ targetValue: 7,
+ }),
+ ]),
+ );
+ expect(screen.getByText('col_b < 7')).toBeInTheDocument();
+ expect(screen.queryByText('col_b < 2')).not.toBeInTheDocument();
+});
+
+test('edits the first formatter without touching later ones', async () => {
+ const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+ await editTargetValue('col_a > 1', '9');
+
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({ column: 'col_a', targetValue: 9 }),
+ lessThanB,
+ ]),
+ );
+});
+
+test('deletes a formatter with its close button', async () => {
+ const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+ const closeButtons = screen.getAllByRole('button', { name: /close/i });
+ expect(closeButtons).toHaveLength(2);
+ await userEvent.click(closeButtons[0]);
+
+ await waitFor(() => expect(lastChange(onChange)).toEqual([lessThanB]));
+ expect(screen.queryByText('col_a > 1')).not.toBeInTheDocument();
+ expect(screen.getByText('col_b < 2')).toBeInTheDocument();
+});
+
+test('edits the remaining formatter at its new index after an earlier one is
deleted', async () => {
+ const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+ await userEvent.click(screen.getAllByRole('button', { name: /close/i })[0]);
+ await waitFor(() => expect(lastChange(onChange)).toEqual([lessThanB]));
+ await editTargetValue('col_b < 2', '8');
+
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({ column: 'col_b', targetValue: 8 }),
+ ]),
+ );
+ expect(screen.queryByText('col_a > 1')).not.toBeInTheDocument();
+ expect(screen.getByText('col_b < 8')).toBeInTheDocument();
+});
+
+test('keeps both edits when two formatters are edited one after the other',
async () => {
+ const { onChange } = renderCrud({ value: [greaterThanA, lessThanB] });
+
+ await editTargetValue('col_a > 1', '5');
+ await waitFor(() =>
+ expect(screen.queryByText('Edit formatter')).not.toBeInTheDocument(),
+ );
+
+ await editTargetValue('col_b < 2', '6');
+
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({ column: 'col_a', targetValue: 5 }),
+ expect.objectContaining({ column: 'col_b', targetValue: 6 }),
+ ]),
+ );
+});
+
+test('deleting the only formatter reports an empty list', async () => {
+ const { onChange } = renderCrud({ value: [greaterThanA] });
+
+ await userEvent.click(screen.getByRole('button', { name: /close/i }));
+
+ await waitFor(() => expect(lastChange(onChange)).toEqual([]));
+});
+
+test('prunes formatters whose column is not among the column options', async
() => {
+ const { onChange } = renderCrud({
+ value: [greaterThanA, lessThanB],
+ columnOptions: [numericColumns[1]],
+ removeIrrelevantConditions: true,
+ });
+
+ await waitFor(() => expect(lastChange(onChange)).toEqual([lessThanB]));
+ expect(screen.queryByText('col_a > 1')).not.toBeInTheDocument();
+ expect(screen.getByText('col_b < 2')).toBeInTheDocument();
+});
+
+test('prunes a formatter once its column leaves the column options', async ()
=> {
+ const { onChange, rerender } = renderCrud({
+ value: [greaterThanA, lessThanB],
+ removeIrrelevantConditions: true,
+ });
+ expect(onChange).not.toHaveBeenCalled();
+
+ rerender(
+ <ConditionalFormattingControl
+ {...defaultProps}
+ columnOptions={[numericColumns[0]]}
+ onChange={onChange}
+ value={[greaterThanA, lessThanB]}
+ removeIrrelevantConditions
+ />,
+ );
+
+ await waitFor(() => expect(lastChange(onChange)).toEqual([greaterThanA]));
+ expect(screen.queryByText('col_b < 2')).not.toBeInTheDocument();
+});
+
+test('keeps formatters for missing columns when pruning is disabled', async ()
=> {
+ const { onChange } = renderCrud({
+ value: [greaterThanA, lessThanB],
+ columnOptions: [numericColumns[1]],
+ removeIrrelevantConditions: false,
+ });
+
+ expect(onChange).not.toHaveBeenCalled();
+ expect(screen.getByText('col_a > 1')).toBeInTheDocument();
+ expect(screen.getByText('col_b < 2')).toBeInTheDocument();
+});
+
// Writing [] for a chart with no formatters made version history record a
// "Set 'conditional formatting'" change the user never made.
test('leaves an unset value unset when nothing is configured', () => {
@@ -198,14 +418,15 @@ test('reports the removal of the last formatter', async
() => {
});
test('reports adding and then removing a formatter on an unset value', async
() => {
- const onChange = jest.fn();
- render(
- <ConditionalFormattingControl {...defaultProps} onChange={onChange} />,
+ const { onChange } = renderCrud();
+ await userEvent.click(screen.getByText('Add new color formatter'));
+ await pickColumn('Column B');
+ await userEvent.click(await screen.findByText('Apply'));
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({ column: 'col_b' }),
+ ]),
);
- await userEvent.click(screen.getByText('submit Add new formatter'));
- expect(onChange).toHaveBeenLastCalledWith([
- expect.objectContaining({ column: 'my_col' }),
- ]);
await userEvent.click(screen.getByLabelText('close'));
- expect(onChange).toHaveBeenLastCalledWith([]);
+ expect(lastChange(onChange)).toEqual([]);
});
diff --git
a/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.test.tsx
b/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.test.tsx
new file mode 100644
index 00000000000..68bc6ee22f5
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/ContourControl/ContourOption.test.tsx
@@ -0,0 +1,111 @@
+/**
+ * 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 { render, screen, userEvent } from 'spec/helpers/testing-library';
+import ContourOption from './ContourOption';
+import { ContourOptionProps, ContourType } from './types';
+
+const color = { r: 10, g: 20, b: 30, a: 100 };
+
+const isoline: ContourType = {
+ lowerThreshold: 4,
+ color,
+ strokeWidth: 3,
+ zIndex: 10,
+};
+
+const isoband: ContourType = {
+ lowerThreshold: 6,
+ upperThreshold: 10,
+ color,
+ zIndex: 20,
+};
+
+const renderOption = (props: Partial<ContourOptionProps> = {}) => {
+ const handlers = {
+ saveContour: jest.fn(),
+ onClose: jest.fn(),
+ onShift: jest.fn(),
+ };
+ render(
+ <ContourOption contour={isoline} index={0} {...handlers} {...props} />,
+ { useDnd: true },
+ );
+ return handlers;
+};
+
+test('labels an isoline with its threshold, color and stroke width', () => {
+ renderOption({ contour: isoline });
+ expect(
+ screen.getByText(
+ 'Threshold: 4, color: rgba(10, 20, 30, 1), stroke width: 3',
+ ),
+ ).toBeInTheDocument();
+});
+
+test('labels an isoband with its threshold range and color but no stroke
width', () => {
+ renderOption({ contour: isoband });
+ expect(
+ screen.getByText('Threshold: [6, 10], color: rgba(10, 20, 30, 1)'),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/stroke width/)).not.toBeInTheDocument();
+});
+
+test('treats a contour with an upper threshold as an isoband', () => {
+ renderOption({ contour: { ...isoline, upperThreshold: 8 } });
+ expect(screen.getByText(/Threshold: \[4, 8\]/)).toBeInTheDocument();
+});
+
+test('treats a contour without an upper threshold as an isoline', () => {
+ renderOption({
+ contour: { ...isoband, upperThreshold: undefined, strokeWidth: 2 },
+ });
+ expect(screen.getByText(/stroke width: 2/)).toBeInTheDocument();
+});
+
+test('calls onClose with the option index when removed', async () => {
+ const { onClose } = renderOption({ index: 2 });
+
+ await userEvent.click(screen.getByTestId('remove-control-button'));
+
+ expect(onClose).toHaveBeenCalledWith(2);
+});
+
+test('opens the contour popover prefilled from the contour when clicked',
async () => {
+ renderOption({ contour: isoband });
+
+ await userEvent.click(screen.getByTestId('option-label'));
+
+ expect(await screen.findByRole('tab', { name: 'Isoband' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ const inputs = screen.getAllByTestId('inline-name') as HTMLInputElement[];
+ expect(inputs.map(input => input.value)).toEqual(['6', '10']);
+});
+
+test('saves the prefilled contour values unchanged through saveContour', async
() => {
+ const { saveContour } = renderOption({ contour: isoline });
+
+ await userEvent.click(screen.getByTestId('option-label'));
+ await userEvent.click(await screen.findByRole('button', { name: 'Save' }));
+
+ expect(saveContour).toHaveBeenCalledWith(
+ expect.objectContaining({ lowerThreshold: 4, strokeWidth: 3, color }),
+ );
+});
diff --git
a/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.test.tsx
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.test.tsx
new file mode 100644
index 00000000000..dd7c4560849
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverControl.test.tsx
@@ -0,0 +1,277 @@
+/**
+ * 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 {
+ render,
+ screen,
+ sleep,
+ userEvent,
+ waitFor,
+ within,
+} from 'spec/helpers/testing-library';
+import { Constants } from '@superset-ui/core/components';
+import ContourPopoverControl from './ContourPopoverControl';
+import { ContourType } from './types';
+
+const color = { r: 255, g: 0, b: 255, a: 100 };
+
+const isoline: ContourType = {
+ lowerThreshold: 4,
+ color,
+ strokeWidth: 2,
+ zIndex: 10,
+};
+
+const isoband: ContourType = {
+ lowerThreshold: 6,
+ upperThreshold: 10,
+ color,
+ zIndex: 20,
+};
+
+const renderControl = (value?: ContourType) => {
+ const onSave = jest.fn();
+ const onClose = jest.fn();
+ render(
+ <ContourPopoverControl value={value} onSave={onSave} onClose={onClose} />,
+ );
+ return { onSave, onClose };
+};
+
+const saveButton = () => screen.getByRole('button', { name: 'Save' });
+
+// Only the active tab's pane is exposed to the accessibility tree.
+const fields = () =>
+ within(screen.getByRole('tabpanel')).getAllByTestId(
+ 'inline-name',
+ ) as HTMLInputElement[];
+
+// The text inputs debounce before updating the contour state, so wait for
+// each edit to land before making the next one.
+const setField = async (index: number, text: string) => {
+ const input = fields()[index];
+ await userEvent.clear(input);
+ if (text) await userEvent.type(input, text);
+ await sleep(Constants.FAST_DEBOUNCE + 100);
+};
+
+test('opens on the Isoline tab for a contour without an upper threshold', ()
=> {
+ renderControl(isoline);
+ expect(screen.getByRole('tab', { name: 'Isoline' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ expect(screen.getByText('Stroke Width')).toBeInTheDocument();
+});
+
+test('opens on the Isoband tab for a contour with an upper threshold', () => {
+ renderControl(isoband);
+ expect(screen.getByRole('tab', { name: 'Isoband' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ expect(screen.getByText('Upper Threshold')).toBeInTheDocument();
+});
+
+test('opens on the Isoline tab when creating a new contour', () => {
+ renderControl();
+ expect(screen.getByRole('tab', { name: 'Isoline' })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+});
+
+test('prefills the fields from the contour being edited', () => {
+ renderControl(isoline);
+ expect(fields().map(input => input.value)).toEqual(['4', '2']);
+});
+
+// An absent field is coerced with Number(), so the saved value is NaN rather
+// than undefined. Consumers only rely on it being falsy.
+test('saves an isoline without an upper threshold', async () => {
+ const { onSave, onClose } = renderControl(isoline);
+
+ await userEvent.click(saveButton());
+
+ const saved = onSave.mock.calls[0][0];
+ expect(saved).toEqual(
+ expect.objectContaining({ color, lowerThreshold: 4, strokeWidth: 2 }),
+ );
+ expect(saved.upperThreshold).toBeFalsy();
+ expect(onClose).toHaveBeenCalled();
+});
+
+test('saves an isoband without a stroke width', async () => {
+ const { onSave, onClose } = renderControl(isoband);
+
+ await userEvent.click(saveButton());
+
+ const saved = onSave.mock.calls[0][0];
+ expect(saved).toEqual(
+ expect.objectContaining({ color, lowerThreshold: 6, upperThreshold: 10 }),
+ );
+ expect(saved.strokeWidth).toBeFalsy();
+ expect(onClose).toHaveBeenCalled();
+});
+
+test('saves the thresholds as numbers after editing', async () => {
+ const { onSave } = renderControl(isoband);
+
+ await setField(0, '7');
+ await setField(1, '12');
+ await userEvent.click(saveButton());
+
+ expect(onSave).toHaveBeenCalledWith(
+ expect.objectContaining({ lowerThreshold: 7, upperThreshold: 12 }),
+ );
+});
+
+test('saves as an isoband when an isoline is switched to the Isoband tab',
async () => {
+ const { onSave } = renderControl(isoline);
+
+ await userEvent.click(screen.getByRole('tab', { name: 'Isoband' }));
+ await setField(1, '9');
+ await waitFor(() => expect(saveButton()).toBeEnabled());
+ await userEvent.click(saveButton());
+
+ const saved = onSave.mock.calls[0][0];
+ expect(saved).toEqual(
+ expect.objectContaining({ lowerThreshold: 4, upperThreshold: 9 }),
+ );
+ expect(saved.strokeWidth).toBeFalsy();
+});
+
+test('saves as an isoline when an isoband is switched to the Isoline tab',
async () => {
+ const { onSave } = renderControl(isoband);
+
+ await userEvent.click(screen.getByRole('tab', { name: 'Isoline' }));
+ await setField(1, '3');
+ await waitFor(() => expect(saveButton()).toBeEnabled());
+ await userEvent.click(saveButton());
+
+ const saved = onSave.mock.calls[0][0];
+ expect(saved).toEqual(
+ expect.objectContaining({ lowerThreshold: 6, strokeWidth: 3 }),
+ );
+ expect(saved.upperThreshold).toBeFalsy();
+});
+
+test('keeps save disabled until every required isoline field is set', () => {
+ renderControl();
+ expect(saveButton()).toBeDisabled();
+});
+
+test('shows an integer error for a fractional isoline threshold and blocks
save', async () => {
+ const { onSave } = renderControl(isoline);
+
+ await setField(0, '1.5');
+
+ await waitFor(() => expect(saveButton()).toBeDisabled());
+ const badge = await screen.findByTestId('error-tooltip');
+ await userEvent.hover(within(badge).getByRole('img'));
+ expect(
+ await screen.findByText('is expected to be an integer'),
+ ).toBeInTheDocument();
+ await userEvent.click(saveButton());
+ expect(onSave).not.toHaveBeenCalled();
+});
+
+test('shows an integer error for a non-numeric stroke width and blocks save',
async () => {
+ renderControl(isoline);
+
+ await setField(1, 'wide');
+
+ await waitFor(() => expect(saveButton()).toBeDisabled());
+ expect(await screen.findAllByTestId('error-tooltip')).toHaveLength(1);
+});
+
+test('clears the integer error once the value is corrected', async () => {
+ renderControl(isoline);
+
+ await setField(0, '1.5');
+ await screen.findByTestId('error-tooltip');
+ await setField(0, '2');
+
+ await waitFor(() =>
+ expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument(),
+ );
+ await waitFor(() => expect(saveButton()).toBeEnabled());
+});
+
+test('flags both isoband thresholds when lower is not below upper', async ()
=> {
+ renderControl(isoband);
+
+ await setField(0, '10');
+
+ await waitFor(() =>
+ expect(screen.getAllByTestId('error-tooltip')).toHaveLength(2),
+ );
+ expect(saveButton()).toBeDisabled();
+});
+
+test('explains which isoband threshold must be lower or greater', async () => {
+ renderControl(isoband);
+
+ await setField(0, '10');
+
+ const badges = await screen.findAllByTestId('error-tooltip');
+ await userEvent.hover(within(badges[0]).getByRole('img'));
+ expect(
+ await screen.findByText(
+ 'Lower threshold must be lower than upper threshold',
+ ),
+ ).toBeInTheDocument();
+ await userEvent.hover(within(badges[1]).getByRole('img'));
+ expect(
+ await screen.findByText(
+ 'Upper threshold must be greater than lower threshold',
+ ),
+ ).toBeInTheDocument();
+});
+
+test('does not allow saving an isoband whose thresholds are equal', async ()
=> {
+ const { onSave } = renderControl(isoband);
+
+ await setField(1, '6');
+
+ await waitFor(() =>
+ expect(screen.getAllByTestId('error-tooltip')).toHaveLength(2),
+ );
+ await userEvent.click(saveButton());
+ expect(onSave).not.toHaveBeenCalled();
+});
+
+test('allows saving an isoband again once lower drops below upper', async ()
=> {
+ renderControl(isoband);
+
+ await setField(0, '10');
+ await waitFor(() => expect(saveButton()).toBeDisabled());
+ await setField(0, '2');
+
+ await waitFor(() => expect(saveButton()).toBeEnabled());
+ expect(screen.queryByTestId('error-tooltip')).not.toBeInTheDocument();
+});
+
+test('closes without saving when Close is clicked', async () => {
+ const { onSave, onClose } = renderControl(isoline);
+
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+
+ expect(onClose).toHaveBeenCalled();
+ expect(onSave).not.toHaveBeenCalled();
+});
diff --git
a/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.test.tsx
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.test.tsx
new file mode 100644
index 00000000000..e275f5b4405
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/ContourControl/ContourPopoverTrigger.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * 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 {
+ render,
+ screen,
+ userEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import ContourPopoverTrigger from './ContourPopoverTrigger';
+import { ContourPopoverTriggerProps, ContourType } from './types';
+
+const contour: ContourType = {
+ lowerThreshold: 4,
+ color: { r: 10, g: 20, b: 30, a: 100 },
+ strokeWidth: 3,
+ zIndex: 10,
+};
+
+const renderTrigger = (props: Partial<ContourPopoverTriggerProps> = {}) => {
+ const saveContour = jest.fn();
+ render(
+ <ContourPopoverTrigger saveContour={saveContour} value={contour}
{...props}>
+ <span>Open contour</span>
+ </ContourPopoverTrigger>,
+ );
+ return { saveContour };
+};
+
+const expectClosed = () =>
+ waitFor(() =>
+ expect(
+ screen.queryByRole('tab', { name: 'Isoline' }),
+ ).not.toBeInTheDocument(),
+ );
+
+test('renders its children and keeps the popover closed initially', () => {
+ renderTrigger();
+ expect(screen.getByText('Open contour')).toBeInTheDocument();
+ expect(
+ screen.queryByRole('tab', { name: 'Isoline' }),
+ ).not.toBeInTheDocument();
+});
+
+test('opens the popover on click when uncontrolled', async () => {
+ renderTrigger();
+
+ await userEvent.click(screen.getByText('Open contour'));
+
+ expect(
+ await screen.findByRole('tab', { name: 'Isoline' }),
+ ).toBeInTheDocument();
+});
+
+test('closes the uncontrolled popover after saving and forwards the contour',
async () => {
+ const { saveContour } = renderTrigger();
+
+ await userEvent.click(screen.getByText('Open contour'));
+ await userEvent.click(await screen.findByRole('button', { name: 'Save' }));
+
+ expect(saveContour).toHaveBeenCalledWith(
+ expect.objectContaining({ lowerThreshold: 4, strokeWidth: 3 }),
+ );
+ await expectClosed();
+});
+
+test('closes the uncontrolled popover with the Close button without saving',
async () => {
+ const { saveContour } = renderTrigger();
+
+ await userEvent.click(screen.getByText('Open contour'));
+ await userEvent.click(await screen.findByRole('button', { name: 'Close' }));
+
+ expect(saveContour).not.toHaveBeenCalled();
+ await expectClosed();
+});
+
+test('follows the visible prop when controlled', async () => {
+ renderTrigger({
+ isControlled: true,
+ visible: true,
+ toggleVisibility: jest.fn(),
+ });
+
+ expect(
+ await screen.findByRole('tab', { name: 'Isoline' }),
+ ).toBeInTheDocument();
+});
+
+test('delegates visibility changes to the controller instead of its own
state', async () => {
+ const toggleVisibility = jest.fn();
+ const { saveContour } = renderTrigger({
+ isControlled: true,
+ visible: false,
+ toggleVisibility,
+ });
+
+ await userEvent.click(screen.getByText('Open contour'));
+
+ expect(toggleVisibility).toHaveBeenCalledWith(true);
+ expect(saveContour).not.toHaveBeenCalled();
+});
+
+test('asks the controller to hide the popover on Close', async () => {
+ const toggleVisibility = jest.fn();
+ renderTrigger({ isControlled: true, visible: true, toggleVisibility });
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Close' }));
+
+ expect(toggleVisibility).toHaveBeenCalledWith(false);
+});
+
+test('opens empty when no contour value is provided', async () => {
+ renderTrigger({ value: undefined });
+
+ await userEvent.click(screen.getByText('Open contour'));
+
+ expect(await screen.findByRole('button', { name: 'Save' })).toBeDisabled();
+});
diff --git
a/superset-frontend/src/explore/components/controls/ContourControl/index.test.tsx
b/superset-frontend/src/explore/components/controls/ContourControl/index.test.tsx
new file mode 100644
index 00000000000..ec9baa3974c
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/ContourControl/index.test.tsx
@@ -0,0 +1,212 @@
+/**
+ * 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 {
+ render,
+ screen,
+ sleep,
+ userEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import { Constants } from '@superset-ui/core/components';
+import ContourControl from '.';
+import { ContourType } from './types';
+
+// Alpha is 100 because ContourPopoverControl forces it on every picked color
+// (see `updateColor`), so fixtures match what the popover emits.
+const magenta = { r: 255, g: 0, b: 255, a: 100 };
+const green = { r: 0, g: 255, b: 0, a: 100 };
+
+const isoline: ContourType = {
+ lowerThreshold: 4,
+ color: magenta,
+ strokeWidth: 1,
+ zIndex: 10,
+};
+
+const isoband: ContourType = {
+ lowerThreshold: 6,
+ upperThreshold: 10,
+ color: green,
+ zIndex: 20,
+};
+
+const isolineLabel =
+ 'Threshold: 4, color: rgba(255, 0, 255, 1), stroke width: 1';
+const isobandLabel = 'Threshold: [6, 10], color: rgba(0, 255, 0, 1)';
+
+const renderControl = (value?: ContourType[]) => {
+ const onChange = jest.fn();
+ render(
+ <ContourControl
+ name="contours"
+ label="Contours"
+ // ContourControl is not a member of the ControlType union; the prop is
+ // required by the shared control props but unused by the component.
+ type="BoundsControl"
+ actions={{ setControlValue: jest.fn() }}
+ value={value}
+ onChange={onChange}
+ />,
+ { useDnd: true },
+ );
+ return { onChange };
+};
+
+const lastChange = (onChange: jest.Mock): ContourType[] =>
+ onChange.mock.calls.at(-1)?.[0] ?? [];
+
+// Lets the popover's debounced field change flush before the next interaction.
+const flushDebounce = () => sleep(Constants.FAST_DEBOUNCE + 100);
+
+const fields = () =>
+ screen.getAllByTestId('inline-name').map(el => {
+ if (!(el instanceof HTMLInputElement)) {
+ throw new Error('expected inline-name to be an input element');
+ }
+ return el;
+ });
+
+const pickColor = async () => {
+ const trigger = await waitFor(() => {
+ const el = document.querySelector('.ant-color-picker-trigger');
+ if (!el) throw new Error('no color picker trigger');
+ return el as HTMLElement;
+ });
+ await userEvent.click(trigger);
+ const preset = await waitFor(() => {
+ const el = document.querySelector('.ant-color-picker-presets-color');
+ if (!el) throw new Error('no color preset');
+ return el as HTMLElement;
+ });
+ await userEvent.click(preset);
+};
+
+test('renders the default contours when no value is provided', () => {
+ renderControl();
+ // ContourControl ships three default contours (DEFAULT_CONTOURS in
index.tsx,
+ // not exported).
+ expect(screen.getAllByTestId('option-label')).toHaveLength(3);
+});
+
+test('renders one option per contour in the provided value', () => {
+ renderControl([isoline, isoband]);
+ expect(screen.getByText(isolineLabel)).toBeInTheDocument();
+ expect(screen.getByText(isobandLabel)).toBeInTheDocument();
+});
+
+test('reports the contours with a z-index that follows their order', () => {
+ const { onChange } = renderControl([isoband, isoline]);
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({
+ lowerThreshold: 6,
+ upperThreshold: 10,
+ zIndex: 10,
+ }),
+ expect.objectContaining({ lowerThreshold: 4, zIndex: 20 }),
+ ]);
+});
+
+test('opens the add popover from the ghost button', async () => {
+ renderControl([]);
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('Click to add a contour'));
+
+ expect(await screen.findByRole('tooltip')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled();
+});
+
+test('adds an isoline and reports it after the existing contours', async () =>
{
+ const { onChange } = renderControl([isoband]);
+
+ await userEvent.click(screen.getByText('Click to add a contour'));
+ await screen.findByRole('tooltip');
+ await userEvent.type(fields()[0], '3');
+ await flushDebounce();
+ await userEvent.type(fields()[1], '2');
+ await flushDebounce();
+ await pickColor();
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() => expect(lastChange(onChange)).toHaveLength(2));
+ expect(lastChange(onChange)[0]).toEqual(
+ expect.objectContaining({ lowerThreshold: 6, upperThreshold: 10 }),
+ );
+ expect(lastChange(onChange)[1]).toEqual(
+ expect.objectContaining({ lowerThreshold: 3, strokeWidth: 2, zIndex: 20 }),
+ );
+ expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
+});
+
+test('edits a contour in place through its popover', async () => {
+ const { onChange } = renderControl([isoline, isoband]);
+
+ await userEvent.click(screen.getByText(isobandLabel));
+ // Hovering the option also opens its summary tooltip (role "tooltip"), so
+ // wait on the edit popover's tab rather than on a tooltip role.
+ await screen.findByRole('tab', { name: 'Isoband' });
+ const input = fields()[0];
+ await userEvent.clear(input);
+ await userEvent.type(input, '5');
+ await flushDebounce();
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({ lowerThreshold: 4, zIndex: 10 }),
+ expect.objectContaining({
+ lowerThreshold: 5,
+ upperThreshold: 10,
+ zIndex: 20,
+ }),
+ ]),
+ );
+ expect(
+ screen.getByText('Threshold: [5, 10], color: rgba(0, 255, 0, 1)'),
+ ).toBeInTheDocument();
+});
+
+test('removes a contour with its remove button', async () => {
+ const { onChange } = renderControl([isoline, isoband]);
+
+ await userEvent.click(screen.getAllByTestId('remove-control-button')[0]);
+
+ await waitFor(() =>
+ expect(lastChange(onChange)).toEqual([
+ expect.objectContaining({
+ lowerThreshold: 6,
+ upperThreshold: 10,
+ zIndex: 10,
+ }),
+ ]),
+ );
+ expect(screen.queryByText(isolineLabel)).not.toBeInTheDocument();
+ expect(screen.getByText(isobandLabel)).toBeInTheDocument();
+});
+
+test('removing the last contour reports an empty list', async () => {
+ const { onChange } = renderControl([isoline]);
+
+ await userEvent.click(screen.getByTestId('remove-control-button'));
+
+ await waitFor(() => expect(lastChange(onChange)).toEqual([]));
+});