You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by jl...@apache.org on 2026/10/01 00:17:13 UTC
(superset) 01/02: test(explore): cover column config, conditional formatting, and contour controls
This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch explore-rtl-crud-controls
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 6aa62bba103d125af8c6bc11403fa49c0f62e6bb
Author: Joe Li <jo...@preset.io>
AuthorDate: Wed Sep 30 17:11:25 2026 -0700
test(explore): cover column config, conditional formatting, and contour controls
Add React Testing Library coverage for the CRUD-style controls:
column config rows, popover edits and pruning, conditional formatting
add/edit/remove flows, and the contour list, popover, and option.
Co-Authored-By: Claude Opus 5.5 <no...@anthropic.com>
---
.../ColumnConfigControl.test.tsx | 293 +++++++++++++++++++++
.../ControlForm/ControlForm.test.tsx | 108 ++++++++
.../ConditionalFormattingControl.test.tsx | 216 ++++++++++++++-
.../controls/ContourControl/ContourOption.test.tsx | 111 ++++++++
.../ContourControl/ContourPopoverControl.test.tsx | 277 +++++++++++++++++++
.../ContourControl/ContourPopoverTrigger.test.tsx | 133 ++++++++++
.../controls/ContourControl/index.test.tsx | 195 ++++++++++++++
7 files changed, 1332 insertions(+), 1 deletion(-)
diff --git a/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.test.tsx
new file mode 100644
index 00000000000..e752c36d280
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ColumnConfigControl.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 control currently keys edits by the displayed name, while 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..4722ab5713f
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ColumnConfigControl/ControlForm/ControlForm.test.tsx
@@ -0,0 +1,108 @@
+/**
+ * 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,
+ 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)[];
+ debounceDelay?: number;
+ } = {},
+) => {
+ 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}
+ debounceDelay={props.debounceDelay}
+ />
+ <ControlFormItem
+ name="note"
+ controlType="Input"
+ label="Note"
+ description="Note description"
+ />
+ </ControlFormRow>
+ </ControlForm>,
+ );
+ 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();
+ await new Promise(resolve => setTimeout(resolve, 400));
+ 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();
+});
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 b40806bbe71..0662d0738b2 100644
--- a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
+++ b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/ConditionalFormattingControl.test.tsx
@@ -16,7 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, screen } 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';
@@ -101,3 +108,210 @@ 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[onChange.mock.calls.length - 1][0];
+
+// 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 userEvent.click(screen.getByText('col_b < 2'));
+ await screen.findByText('Edit formatter');
+ const targetValue = screen.getByLabelText('Target value');
+ await userEvent.clear(targetValue);
+ await userEvent.type(targetValue, '7');
+ await userEvent.click(screen.getByText('Apply'));
+
+ 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 userEvent.click(screen.getByText('col_a > 1'));
+ await screen.findByText('Edit formatter');
+ const targetValue = screen.getByLabelText('Target value');
+ await userEvent.clear(targetValue);
+ await userEvent.type(targetValue, '9');
+ await userEvent.click(screen.getByText('Apply'));
+
+ 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('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(lastChange(onChange)).toEqual([greaterThanA, lessThanB]);
+
+ 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,
+ });
+
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
+ expect(lastChange(onChange)).toEqual([greaterThanA, lessThanB]);
+ expect(screen.getByText('col_a > 1')).toBeInTheDocument();
+});
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..8172abccfce
--- /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 an edited contour 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..a93eec5bf1d
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/ContourControl/index.test.tsx
@@ -0,0 +1,195 @@
+/**
+ * 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';
+
+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[onChange.mock.calls.length - 1][0];
+
+const fields = () => screen.getAllByTestId('inline-name') as HTMLInputElement[];
+
+const pickColor = async () => {
+ const trigger = document.querySelector(
+ '.ant-color-picker-trigger',
+ ) 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();
+ 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 sleep(Constants.FAST_DEBOUNCE + 100);
+ await userEvent.type(fields()[1], '2');
+ await sleep(Constants.FAST_DEBOUNCE + 100);
+ 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));
+ await screen.findByRole('tooltip');
+ const input = fields()[0];
+ await userEvent.clear(input);
+ await userEvent.type(input, '5');
+ await sleep(Constants.FAST_DEBOUNCE + 100);
+ 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([]));
+});