This is an automated email from the ASF dual-hosted git repository.
sadpandajoe pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new bc3698b5d64 test(explore): port remaining deleted Cypress explore
specs to RTL (#44838)
bc3698b5d64 is described below
commit bc3698b5d64b973d249d59f584a8dc3c48c83263
Author: Joe Li <[email protected]>
AuthorDate: Fri Oct 2 11:25:34 2026 -0700
test(explore): port remaining deleted Cypress explore specs to RTL (#44838)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../controls/AnnotationLayerControl/index.test.tsx | 84 ++++++++++++++++-
.../DndMetricSelect.test.tsx | 90 ++++++++++++++++++
.../AdhocFilterControl/AdhocFilterControl.test.tsx | 102 ++++++++++++++++++++-
.../AdhocFilterEditPopover.test.tsx | 55 ++++++++---
.../components/controls/SelectControl.test.tsx | 55 +++++++++++
5 files changed, 369 insertions(+), 17 deletions(-)
diff --git
a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.test.tsx
b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.test.tsx
index 0e5ed6bfa48..50173ac525f 100644
---
a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.test.tsx
+++
b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.test.tsx
@@ -16,7 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { mapStateToProps } from './index';
+import {
+ ChartMetadata,
+ getChartMetadataRegistry,
+ VizType,
+} from '@superset-ui/core';
+import {
+ render,
+ screen,
+ userEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import AnnotationLayerControl, { mapStateToProps } from './index';
+import { ANNOTATION_TYPES } from './AnnotationTypes';
type State = Parameters<typeof mapStateToProps>[0];
@@ -50,3 +62,73 @@ test('denies canReadAnnotation when the user has no roles',
() => {
expect(mapStateToProps(buildState({})).canReadAnnotation).toBe(false);
expect(mapStateToProps(buildState(undefined)).canReadAnnotation).toBe(false);
});
+
+afterEach(() => {
+ getChartMetadataRegistry().remove(VizType.Line);
+});
+
+test('adds a formula annotation layer through the control and lists it by
name', async () => {
+ getChartMetadataRegistry().registerValue(
+ VizType.Line,
+ new ChartMetadata({
+ name: 'Line',
+ thumbnail: '',
+ supportedAnnotationTypes: [
+ ANNOTATION_TYPES.FORMULA,
+ ANNOTATION_TYPES.TIME_SERIES,
+ ],
+ }),
+ );
+ const onChange = jest.fn();
+ const props = {
+ name: 'annotation_layers',
+ value: [],
+ validationErrors: [],
+ actions: { setControlValue: jest.fn() },
+ onChange,
+ };
+ const initialState = {
+ charts: { 1: { latestQueryFormData: {} } },
+ common: { conf: {} },
+ explore: {
+ form_data: { slice_id: 1 },
+ controls: {
+ viz_type: { value: VizType.Line },
+ color_scheme: { value: 'supersetColors' },
+ },
+ },
+ user: {},
+ };
+ const { rerender } = render(<AnnotationLayerControl {...props} />, {
+ useRedux: true,
+ initialState,
+ });
+
+ await userEvent.click(screen.getByText('Add annotation layer'));
+ await userEvent.type(
+ screen.getByRole('textbox', { name: 'Name' }),
+ 'Goal line',
+ );
+ await userEvent.type(
+ screen.getByRole('textbox', { name: 'Formula' }),
+ 'y=140000',
+ );
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled(),
+ );
+ await userEvent.click(screen.getByRole('button', { name: 'Confirm' }));
+
+ await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
+ expect(onChange).toHaveBeenCalledWith([
+ expect.objectContaining({
+ name: 'Goal line',
+ annotationType: ANNOTATION_TYPES.FORMULA,
+ value: 'y=140000',
+ }),
+ ]);
+
+ rerender(
+ <AnnotationLayerControl {...props} value={onChange.mock.calls[0][0]} />,
+ );
+ expect(screen.getByText('Goal line')).toBeInTheDocument();
+});
diff --git
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
index aceff19bd79..0a61c6e4961 100644
---
a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
+++
b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndMetricSelect.test.tsx
@@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { useState } from 'react';
import {
fireEvent,
render,
screen,
+ selectOption,
userEvent,
waitFor,
within,
@@ -589,6 +591,94 @@ test('title changes on custom SQL text change', async ()
=> {
);
});
+// Mirrors the parent control, which feeds committed values back in as `value`.
+const ControlledDndMetricSelect = ({
+ onChange,
+}: {
+ onChange: (val: unknown) => void;
+}) => {
+ const [value, setValue] = useState<unknown>(undefined);
+ return (
+ <DndMetricSelect
+ {...defaultProps}
+ value={value}
+ onChange={(val: unknown) => {
+ setValue(val);
+ onChange(val);
+ }}
+ multi
+ />
+ );
+};
+
+test('saves a new simple adhoc metric with a custom title from an empty drop
zone', async () => {
+ const onChange = jest.fn();
+ render(<ControlledDndMetricSelect onChange={onChange} />, {
+ useDndKit: true,
+ useRedux: true,
+ });
+
+ await userEvent.click(screen.getByText('Drop columns/metrics here or
click'));
+ await userEvent.click(await screen.findByRole('tab', { name: 'Simple' }));
+
+ await userEvent.click(screen.getByTestId('AdhocMetricEditTitle#trigger'));
+ const titleInput = await screen.findByTestId('AdhocMetricEditTitle#input');
+ await userEvent.clear(titleInput);
+ await userEvent.type(titleInput, 'Total revenue');
+ await userEvent.keyboard('{Enter}');
+
+ await selectOption('column_a', 'Select column');
+ // selectOption reads the first dropdown in the document, which stays mounted
+ // after the column pick, so the aggregate is chosen by option role instead.
+ await userEvent.click(
+ screen.getByRole('combobox', { name: 'Select aggregate options' }),
+ );
+ await userEvent.click(await screen.findByRole('option', { name: 'SUM' }));
+
+ await userEvent.click(screen.getByRole('button', { name: /save/i }));
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ const [[committed]] = onChange.mock.calls;
+ expect(committed).toHaveLength(1);
+ expect(committed[0]).toEqual(
+ expect.objectContaining({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ column: expect.objectContaining({ column_name: 'column_a' }),
+ aggregate: AGGREGATES.SUM,
+ label: 'Total revenue',
+ hasCustomLabel: true,
+ }),
+ );
+ expect(await screen.findByText('Total revenue')).toBeInTheDocument();
+});
+
+test('removes only the clicked metric when its remove control is used', async
() => {
+ const onChange = jest.fn();
+ render(
+ <DndMetricSelect
+ {...defaultProps}
+ value={['metric_a', 'metric_b', adhocMetricB]}
+ onChange={onChange}
+ multi
+ />,
+ { useDndKit: true, useRedux: true },
+ );
+
+ // Remove controls render in value order: metric_a, metric_b, adhocMetricB.
+ const removeButtons = screen.getAllByTestId('remove-control-button');
+ expect(removeButtons).toHaveLength(3);
+ await userEvent.click(removeButtons[1]);
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ const [[committed]] = onChange.mock.calls;
+ expect(committed).toHaveLength(2);
+ expect(committed[0]).toBe('metric_a');
+ expect(committed[1]).toEqual(
+ expect.objectContaining({ optionName: adhocMetricB.optionName }),
+ );
+ expect(screen.queryByText('Metric B')).not.toBeInTheDocument();
+});
+
// --- folder drops -----------------------------------------------------
// Dragging a whole folder from the DatasourcePanel expands into its
// columns/metrics, handled in bulk by onDropFolder: saved metrics are added
diff --git
a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx
b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx
index f7de33068e2..6cafd8ae583 100644
---
a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx
+++
b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/AdhocFilterControl.test.tsx
@@ -16,12 +16,40 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { render, screen } from 'spec/helpers/testing-library';
-import userEvent from '@testing-library/user-event';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import { Operators } from 'src/explore/constants';
import AdhocFilterControl from '.';
import AdhocFilter from '../AdhocFilter';
import { Clauses, ExpressionTypes } from '../types';
+// The real editor is an Ace instance that loads asynchronously and exposes no
+// DOM input, so a plain textarea stands in for it.
+jest.mock('src/core/editors', () => {
+ const React = require('react');
+ return {
+ EditorHost: React.forwardRef(
+ (
+ {
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (v: string) => void;
+ },
+ ref: React.Ref<{ resize: () => void }>,
+ ) => {
+ React.useImperativeHandle(ref, () => ({ resize: jest.fn() }));
+ return (
+ <textarea
+ defaultValue={value}
+ onChange={e => onChange?.(e.target.value)}
+ />
+ );
+ },
+ ),
+ };
+});
+
interface TestProps {
name: string;
label: string;
@@ -71,6 +99,7 @@ const renderComponent = (props: Partial<TestProps> = {}) =>
/>,
{
useDnd: true,
+ useRedux: true,
},
);
@@ -153,4 +182,73 @@ describe('AdhocFilterControl', () => {
const component = screen.getByTestId('adhoc-filter-control');
expect(component).toBeInTheDocument();
});
+
+ test('should save a new simple filter built in the popover', async () => {
+ const onChange = jest.fn();
+ renderComponent({
+ onChange,
+ operators: [Operators.Equals, Operators.GreaterThan],
+ });
+
+ await userEvent.click(screen.getByTestId('add-filter-button'));
+ expect(screen.getByRole('tab', { name: /simple/i })).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+
+ await userEvent.click(await screen.findByTestId('select-element'));
+ await userEvent.click(
+ await screen.findByRole('option', { name: 'column1' }),
+ );
+ await userEvent.click(await screen.findByLabelText('Select operator'));
+ await userEvent.click(
+ await screen.findByRole('option', { name: /Equal to/ }),
+ );
+ await userEvent.type(
+ screen.getByTestId('adhoc-filter-simple-value'),
+ 'abc',
+ );
+ await userEvent.click(
+ screen.getByTestId('adhoc-filter-edit-popover-save-button'),
+ );
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ const [[savedFilters]] = onChange.mock.calls;
+ expect(savedFilters).toHaveLength(1);
+ expect(savedFilters[0]).toEqual(
+ expect.objectContaining({
+ expressionType: ExpressionTypes.Simple,
+ subject: 'column1',
+ operator: '==',
+ comparator: 'abc',
+ }),
+ );
+ expect(await screen.findByText("column1 = 'abc'")).toBeInTheDocument();
+ });
+
+ test('should save a new custom SQL filter built in the popover', async () =>
{
+ const onChange = jest.fn();
+ renderComponent({ onChange });
+
+ await userEvent.click(screen.getByTestId('add-filter-button'));
+ await userEvent.click(screen.getByRole('tab', { name: /custom sql/i }));
+
+ await userEvent.click(screen.getByRole('textbox'));
+ await userEvent.paste('column2 > 5');
+ await userEvent.click(
+ screen.getByTestId('adhoc-filter-edit-popover-save-button'),
+ );
+
+ expect(onChange).toHaveBeenCalledTimes(1);
+ const [[savedFilters]] = onChange.mock.calls;
+ expect(savedFilters).toHaveLength(1);
+ expect(savedFilters[0]).toEqual(
+ expect.objectContaining({
+ expressionType: ExpressionTypes.Sql,
+ sqlExpression: 'column2 > 5',
+ clause: Clauses.Where,
+ }),
+ );
+ expect(await screen.findByText('column2 > 5')).toBeInTheDocument();
+ });
});
diff --git
a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.tsx
b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.tsx
index 8ac471681b3..a69d9ebdf97 100644
---
a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.tsx
+++
b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/AdhocFilterEditPopover.test.tsx
@@ -17,14 +17,47 @@
* under the License.
*/
import type React from 'react';
-import { render, screen, fireEvent } from 'spec/helpers/testing-library';
-import userEvent from '@testing-library/user-event';
+import {
+ render,
+ screen,
+ fireEvent,
+ userEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
import { AGGREGATES } from 'src/explore/constants';
import AdhocMetric from
'src/explore/components/controls/MetricControl/AdhocMetric';
import AdhocFilterEditPopover from '.';
import AdhocFilter from '../AdhocFilter';
import { Clauses, ExpressionTypes } from '../types';
+// The real editor is an Ace instance that loads asynchronously and exposes no
+// DOM input, so a plain textarea stands in for it.
+jest.mock('src/core/editors', () => {
+ const React = require('react');
+ return {
+ EditorHost: React.forwardRef(
+ (
+ {
+ value,
+ onChange,
+ }: {
+ value: string;
+ onChange: (v: string) => void;
+ },
+ ref: React.Ref<{ resize: () => void }>,
+ ) => {
+ React.useImperativeHandle(ref, () => ({ resize: jest.fn() }));
+ return (
+ <textarea
+ defaultValue={value}
+ onChange={e => onChange?.(e.target.value)}
+ />
+ );
+ },
+ ),
+ };
+});
+
const simpleAdhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'value',
@@ -120,26 +153,20 @@ describe('AdhocFilterEditPopover', () => {
).toBeDisabled();
});
- /* oxlint-disable-next-line jest/no-disabled-tests */
- test.skip('updates the filter when changes are made', async () => {
+ test('updates the filter when changes are made', async () => {
const onChange = jest.fn();
renderPopover({
onChange,
adhocFilter: sqlAdhocFilter,
});
- // Switch to SQL tab
- await userEvent.click(screen.getByRole('tab', { name: /custom sql/i }));
-
- // Find and update the SQL editor
- const sqlInput = screen.getByTestId('sql-input');
- fireEvent.change(sqlInput, { target: { value: 'COUNT(*) > 0' } });
-
- // Wait for validation to complete
- await screen.findByRole('button', { name: /save/i });
+ // The SQL tab is preselected for a SQL filter; edit its expression
+ const sqlInput = screen.getByRole('textbox');
+ await userEvent.clear(sqlInput);
+ await userEvent.paste('COUNT(*) > 0');
- // Click save button
const saveButton = screen.getByRole('button', { name: /save/i });
+ await waitFor(() => expect(saveButton).toBeEnabled());
await userEvent.click(saveButton);
expect(onChange).toHaveBeenCalledWith(
diff --git
a/superset-frontend/src/explore/components/controls/SelectControl.test.tsx
b/superset-frontend/src/explore/components/controls/SelectControl.test.tsx
index 6255a96bee0..c2701cebbcc 100644
--- a/superset-frontend/src/explore/components/controls/SelectControl.test.tsx
+++ b/superset-frontend/src/explore/components/controls/SelectControl.test.tsx
@@ -24,6 +24,7 @@ import {
screen,
userEvent,
} from 'spec/helpers/testing-library';
+import { isCustomControlItem, sections } from '@superset-ui/chart-controls';
import SelectControl, {
innerGetOptions,
areAllValuesNumbers,
@@ -513,3 +514,57 @@ test('selecting a string "none" option round-trips through
onChange', async () =
expect(onChange).toHaveBeenCalledWith('none', expect.anything());
});
+
+// Renders the real `time_compare` control config from the Advanced Analytics
+// section, so a change to its `multi`/`freeForm` flags is caught here.
+const getTimeCompareConfig = () => {
+ const control = sections.advancedAnalyticsControls.controlSetRows
+ .flat()
+ .filter(isCustomControlItem)
+ .find(item => item.name === 'time_compare');
+ if (!control) {
+ throw new Error('time_compare control not found in advanced analytics');
+ }
+ return control.config;
+};
+
+// rc-select does not reliably pick up userEvent's `{Enter}` under jsdom, so
the
+// key events are dispatched directly. rc-select locks Enter after a keydown
+// until the matching keyup, so both events are needed per press.
+const pressEnter = (element: HTMLElement) => {
+ const key = { key: 'Enter', code: 'Enter', keyCode: 13 };
+ fireEvent.keyDown(element, key);
+ fireEvent.keyUp(element, key);
+};
+
+test('time_compare accepts custom free-form time shifts and shows them as
tags', async () => {
+ const onChange = jest.fn();
+ // Only the props that drive selection behavior; explore resolves the
+ // config's label/description before they reach SelectControl.
+ const { multi, freeForm, choices } = getTimeCompareConfig();
+ const props = { multi, freeForm, choices, name: 'time_compare', onChange };
+ const { unmount } = render(<SelectControl {...props} />);
+
+ const selectorInput = screen.getByRole('combobox');
+ await user.click(selectorInput);
+ await user.type(selectorInput, '28 days');
+ act(() => jest.runAllTimers());
+ pressEnter(selectorInput);
+ act(() => jest.runAllTimers());
+ await user.type(selectorInput, '1 year');
+ act(() => jest.runAllTimers());
+ pressEnter(selectorInput);
+ act(() => jest.runAllTimers());
+
+ expect(onChange).toHaveBeenLastCalledWith(
+ ['28 days', '1 year'],
+ expect.anything(),
+ );
+
+ // A fresh mount with the emitted value, free of the open dropdown's
+ // duplicate option text, shows each custom shift as a tag.
+ unmount();
+ render(<SelectControl {...props} value={['28 days', '1 year']} />);
+ expect(screen.getByText('28 days')).toBeInTheDocument();
+ expect(screen.getByText('1 year')).toBeInTheDocument();
+});