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();
+});

Reply via email to