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 <[email protected]> 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 <[email protected]> --- .../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([])); +});
