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 e0483898728 test(explore): cover time range frames, comparison labels,
and metric popover state (#44847)
e0483898728 is described below
commit e0483898728a79e5cd517d9048676431163eafe6
Author: Joe Li <[email protected]>
AuthorDate: Fri Oct 2 09:10:45 2026 -0700
test(explore): cover time range frames, comparison labels, and metric
popover state (#44847)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../controls/ComparisonRangeLabel.test.tsx | 252 +++++++++++++++++++
.../DateFilterControl/tests/CommonFrame.test.tsx | 72 ++++++
.../tests/DateFilterLabel.test.tsx | 193 ++++++++++++++
.../DateFilterControl/tests/DateLabel.test.tsx | 73 ++++++
.../AdhocMetricPopoverTrigger.test.tsx | 278 +++++++++++++++++++++
5 files changed, 868 insertions(+)
diff --git
a/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.test.tsx
b/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.test.tsx
new file mode 100644
index 00000000000..853f370501f
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/ComparisonRangeLabel.test.tsx
@@ -0,0 +1,252 @@
+/**
+ * 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 { act, render, screen, waitFor } from 'spec/helpers/testing-library';
+import { fetchTimeRange } from '@superset-ui/core';
+import { ComparisonRangeLabel } from './ComparisonRangeLabel';
+
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ fetchTimeRange: jest.fn(),
+}));
+
+const mockedFetchTimeRange = fetchTimeRange as jest.MockedFunction<
+ typeof fetchTimeRange
+>;
+
+const HEADER = 'Actual range for comparison';
+const CURRENT_RANGE_LABEL = '2024-03-01 ≤ col < 2024-03-31';
+const COMPARISON_LABEL = '2023-03-01 ≤ col < 2023-03-31';
+
+const temporalRangeFilter = {
+ expressionType: 'SIMPLE',
+ subject: 'order_date',
+ operator: 'TEMPORAL_RANGE',
+ comparator: '2024-03-01 : 2024-03-31',
+ clause: 'WHERE',
+};
+
+const nonTemporalFilter = {
+ expressionType: 'SIMPLE',
+ subject: 'country',
+ operator: '==',
+ comparator: 'US',
+ clause: 'WHERE',
+};
+
+function renderLabel(formData: Record<string, unknown>) {
+ return render(<ComparisonRangeLabel name="time_compare" />, {
+ useRedux: true,
+ initialState: {
+ explore: {
+ form_data: { adhoc_filters: [temporalRangeFilter], ...formData },
+ },
+ },
+ });
+}
+
+// Lets the component's promise chain settle so "nothing rendered" assertions
+// are made after the async label computation, not before it starts.
+const settle = () =>
+ act(async () => {
+ await new Promise(resolve => {
+ setTimeout(resolve, 0);
+ });
+ });
+
+beforeEach(() => {
+ mockedFetchTimeRange.mockReset();
+ // The first call resolves the current range, calls with shifts resolve the
+ // comparison range.
+ mockedFetchTimeRange.mockImplementation(async (_range, _col, shifts) => ({
+ value: shifts?.length ? COMPARISON_LABEL : CURRENT_RANGE_LABEL,
+ }));
+});
+
+test('renders nothing when there is no TEMPORAL_RANGE filter', async () => {
+ const { container } = renderLabel({
+ adhoc_filters: [nonTemporalFilter],
+ time_compare: ['1 year ago'],
+ });
+
+ await settle();
+ expect(container).toBeEmptyDOMElement();
+ expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+});
+
+test('renders nothing when a TEMPORAL_RANGE filter has no comparison
configured', async () => {
+ const { container } = renderLabel({});
+
+ await settle();
+ expect(container).toBeEmptyDOMElement();
+ expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+});
+
+test('renders the comparison range for an explicit time_compare shift', async
() => {
+ renderLabel({ time_compare: ['1 month ago'] });
+
+ expect(await screen.findByText(HEADER)).toBeInTheDocument();
+ expect(screen.getByText(COMPARISON_LABEL)).toBeInTheDocument();
+ expect(mockedFetchTimeRange).toHaveBeenCalledWith(
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ ['1 month ago'],
+ );
+});
+
+test.each([
+ ['y', '1 year ago'],
+ ['m', '1 month ago'],
+ ['w', '1 week ago'],
+])(
+ 'maps the legacy time_comparison shorthand "%s" to "%s"',
+ async (shorthand, expectedShift) => {
+ renderLabel({ time_comparison: shorthand });
+
+ expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+ expect(mockedFetchTimeRange).toHaveBeenCalledWith(
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ [expectedShift],
+ );
+ },
+);
+
+test('ignores an unknown legacy time_comparison shorthand', async () => {
+ const { container } = renderLabel({ time_comparison: 'zzz' });
+
+ await settle();
+ expect(container).toBeEmptyDOMElement();
+ expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+});
+
+test('prefers time_compare over the legacy time_comparison shorthand', async
() => {
+ renderLabel({ time_compare: ['1 week ago'], time_comparison: 'y' });
+
+ expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+ expect(mockedFetchTimeRange).toHaveBeenCalledWith(
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ ['1 week ago'],
+ );
+ expect(mockedFetchTimeRange).not.toHaveBeenCalledWith(
+ expect.anything(),
+ expect.anything(),
+ ['1 year ago'],
+ );
+});
+
+test('custom shift resolves the offset from start_date_offset to the current
range start', async () => {
+ renderLabel({
+ time_compare: ['custom'],
+ start_date_offset: '2024-01-01',
+ });
+
+ expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+ // First the current range is resolved without shifts, then the comparison
+ // is requested with the day offset derived from start_date_offset.
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 1,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ );
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 2,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ ['60 days ago'],
+ );
+});
+
+test('maps the legacy time_comparison shorthand "c" to a custom shift resolved
from start_date_offset', async () => {
+ renderLabel({ time_comparison: 'c', start_date_offset: '2024-01-01' });
+
+ expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 1,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ );
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 2,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ ['60 days ago'],
+ );
+});
+
+test('custom shift without start_date_offset never requests a comparison
range', async () => {
+ renderLabel({ time_compare: ['custom'] });
+
+ await settle();
+ expect(mockedFetchTimeRange).not.toHaveBeenCalled();
+ expect(screen.queryByText(COMPARISON_LABEL)).not.toBeInTheDocument();
+});
+
+test('custom shift does not request a comparison when start_date_offset is
after the current range start', async () => {
+ renderLabel({
+ time_compare: ['custom'],
+ start_date_offset: '2024-06-01',
+ });
+
+ await waitFor(() => expect(mockedFetchTimeRange).toHaveBeenCalledTimes(1));
+ await settle();
+ expect(mockedFetchTimeRange).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText(COMPARISON_LABEL)).not.toBeInTheDocument();
+});
+
+test('inherit shift shifts back by the length of the current range', async ()
=> {
+ renderLabel({ time_compare: ['inherit'] });
+
+ expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 1,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ );
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 2,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ ['30 days ago'],
+ );
+});
+
+test('legacy "r" shorthand behaves like an inherit shift', async () => {
+ renderLabel({ time_comparison: 'r' });
+
+ expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 2,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ ['30 days ago'],
+ );
+});
+
+test('inherit is combined with regular shifts in one comparison request',
async () => {
+ renderLabel({ time_compare: ['inherit', '1 year ago'] });
+
+ expect(await screen.findByText(COMPARISON_LABEL)).toBeInTheDocument();
+ expect(mockedFetchTimeRange).toHaveBeenNthCalledWith(
+ 2,
+ temporalRangeFilter.comparator,
+ temporalRangeFilter.subject,
+ ['30 days ago', '1 year ago'],
+ );
+});
diff --git
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CommonFrame.test.tsx
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CommonFrame.test.tsx
new file mode 100644
index 00000000000..990b023dc46
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/CommonFrame.test.tsx
@@ -0,0 +1,72 @@
+/**
+ * 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 { DateFilterTestKey } from '../utils';
+import { CommonFrame } from '../components';
+
+test('renders the title and one radio per common range', () => {
+ render(<CommonFrame onChange={jest.fn()} value="Last month" />);
+
+ expect(screen.getByTestId(DateFilterTestKey.CommonFrame)).toHaveTextContent(
+ 'Configure Time Range: Last...',
+ );
+ const radios = screen.getAllByRole('radio');
+ const expectedNames = [
+ 'Last day',
+ 'Last week',
+ 'Last month',
+ 'Last quarter',
+ 'Last year',
+ ];
+ expect(radios).toHaveLength(expectedNames.length);
+ expectedNames.forEach((name, index) => {
+ expect(radios[index]).toHaveAccessibleName(name);
+ });
+});
+
+test('checks the radio matching the value', () => {
+ render(<CommonFrame onChange={jest.fn()} value="Last quarter" />);
+
+ expect(screen.getByLabelText('Last quarter')).toBeChecked();
+ expect(screen.getByLabelText('Last week')).not.toBeChecked();
+});
+
+test('does not call onChange when the value is a common range', () => {
+ const onChange = jest.fn();
+ render(<CommonFrame onChange={onChange} value="Last year" />);
+
+ expect(onChange).not.toHaveBeenCalled();
+});
+
+test('falls back to Last week when the value is not a common range', () => {
+ const onChange = jest.fn();
+ render(<CommonFrame onChange={onChange} value="previous calendar week" />);
+
+ expect(onChange).toHaveBeenCalledWith('Last week');
+ expect(screen.getByLabelText('Last week')).toBeChecked();
+});
+
+test('calls onChange with the selected range', async () => {
+ const onChange = jest.fn();
+ render(<CommonFrame onChange={onChange} value="Last week" />);
+
+ await userEvent.click(screen.getByLabelText('Last day'));
+
+ expect(onChange).toHaveBeenCalledWith('Last day');
+});
diff --git
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
index 8e28ce658b2..0aa914d2038 100644
---
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
+++
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateFilterLabel.test.tsx
@@ -27,6 +27,8 @@ import {
userEvent,
waitFor,
fireEvent,
+ selectOption,
+ within,
} from 'spec/helpers/testing-library';
import { NO_TIME_RANGE, fetchTimeRange } from '@superset-ui/core';
@@ -77,6 +79,10 @@ beforeEach(() => {
mockPopoverProps.length = 0;
});
+afterEach(() => {
+ jest.useRealTimers();
+});
+
function setup(
props: Omit<DateFilterControlProps, 'name'> = defaultProps,
store: any = mockStore({}),
@@ -368,3 +374,190 @@ test('hovering the description icon does not show the
date range tooltip', async
fireEvent.keyDown(descriptionIcon, { key: 'Enter' });
expect(tooltipOnClick).toHaveBeenCalled();
});
+
+const RANGE_TYPE_LABEL = 'Range type';
+const INVALID_EXPRESSION_ERROR = 'Invalid time range expression';
+
+const FRAME_MARKERS = {
+ Common: () => screen.queryByTestId(DateFilterTestKey.CommonFrame),
+ Calendar: () => screen.queryByText('Configure Time Range: Previous...'),
+ Current: () => screen.queryByText('Configure Time Range: Current...'),
+ Custom: () => screen.queryByText('Configure custom time range'),
+ Advanced: () => screen.queryByText('Configure Advanced Time Range'),
+ 'No filter': () => screen.queryByTestId(DateFilterTestKey.NoFilter),
+};
+
+type FrameName = keyof typeof FRAME_MARKERS;
+
+async function expectOnlyFrame(frame: FrameName) {
+ await waitFor(() => {
+ expect(FRAME_MARKERS[frame]()).toBeInTheDocument();
+ });
+ (Object.keys(FRAME_MARKERS) as FrameName[])
+ .filter(other => other !== frame)
+ .forEach(other => {
+ expect(FRAME_MARKERS[other]()).not.toBeInTheDocument();
+ });
+}
+
+test.each<[string, FrameName, string]>([
+ ['No filter', 'No filter', 'No filter'],
+ ['Last week', 'Common', 'Last'],
+ ['previous calendar month', 'Calendar', 'Previous'],
+ ['Current quarter', 'Current', 'Current'],
+ ['2021-03-16T00:00:00 : 2021-03-17T00:00:00', 'Custom', 'Custom'],
+ ['Last week : tomorrow', 'Advanced', 'Advanced'],
+])(
+ 'opens on the frame that matches the value "%s"',
+ async (value, expectedFrame, expectedSelectLabel) => {
+ render(setup({ ...defaultProps, value }));
+
+ await userEvent.click(await screen.findByRole('button'));
+
+ await expectOnlyFrame(expectedFrame);
+ expect(screen.getByTitle(expectedSelectLabel)).toBeInTheDocument();
+ },
+);
+
+test.each<[string, FrameName]>([
+ ['Previous', 'Calendar'],
+ ['Current', 'Current'],
+ ['Custom', 'Custom'],
+ ['Advanced', 'Advanced'],
+ ['No filter', 'No filter'],
+])(
+ 'range type select switches from the Last frame to the %s frame',
+ async (optionLabel, expectedFrame) => {
+ render(setup({ ...defaultProps, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+ await expectOnlyFrame('Common');
+
+ await selectOption(optionLabel, RANGE_TYPE_LABEL);
+
+ await expectOnlyFrame(expectedFrame);
+ },
+);
+
+test('range type select switches back to the Last frame from another frame',
async () => {
+ render(setup({ ...defaultProps, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+ await selectOption('Advanced', RANGE_TYPE_LABEL);
+ await expectOnlyFrame('Advanced');
+
+ await selectOption('Last', RANGE_TYPE_LABEL);
+
+ await expectOnlyFrame('Common');
+});
+
+test('selecting the No filter frame previews No filter as the actual time
range', async () => {
+ render(setup({ ...defaultProps, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+
+ await selectOption('No filter', RANGE_TYPE_LABEL);
+
+ await expectOnlyFrame('No filter');
+ await waitFor(
+ () => {
+ expect(
+ within(screen.getByText('Actual time range').parentElement!).getByText(
+ 'No filter',
+ ),
+ ).toBeInTheDocument();
+ },
+ { timeout: 3000 },
+ );
+});
+
+test('Apply after selecting the No filter frame saves the No filter range',
async () => {
+ const onChange = jest.fn();
+ render(setup({ ...defaultProps, onChange, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+ await selectOption('No filter', RANGE_TYPE_LABEL);
+
+ await userEvent.click(screen.getByTestId(DateFilterTestKey.ApplyButton));
+
+ expect(onChange).toHaveBeenCalledWith(NO_TIME_RANGE);
+});
+
+test('an invalid Advanced expression shows the error and disables Apply',
async () => {
+ mockedFetchTimeRange.mockImplementation(async value =>
+ value.includes('bogus')
+ ? { error: INVALID_EXPRESSION_ERROR }
+ : { value: FIELD_TOOLTIP },
+ );
+ const onChange = jest.fn();
+ render(setup({ ...defaultProps, onChange, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+ await selectOption('Advanced', RANGE_TYPE_LABEL);
+ await waitFor(() => {
+ expect(screen.getByTestId(DateFilterTestKey.ApplyButton)).toBeEnabled();
+ });
+
+ await userEvent.type(screen.getAllByRole('textbox')[0], 'bogus');
+
+ expect(
+ await screen.findByText(INVALID_EXPRESSION_ERROR, {}, { timeout: 3000 }),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId(DateFilterTestKey.ApplyButton)).toBeDisabled();
+ await userEvent.click(screen.getByTestId(DateFilterTestKey.ApplyButton));
+ expect(onChange).not.toHaveBeenCalled();
+});
+
+test('correcting an invalid Advanced expression clears the error and
re-enables Apply', async () => {
+ mockedFetchTimeRange.mockImplementation(async value =>
+ value.includes('bogus')
+ ? { error: INVALID_EXPRESSION_ERROR }
+ : { value: FIELD_TOOLTIP },
+ );
+ render(setup({ ...defaultProps, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+ await selectOption('Advanced', RANGE_TYPE_LABEL);
+ const sinceInput = screen.getAllByRole('textbox')[0];
+ await userEvent.type(sinceInput, 'bogus');
+ await screen.findByText(INVALID_EXPRESSION_ERROR, {}, { timeout: 3000 });
+
+ await userEvent.clear(sinceInput);
+ await userEvent.type(sinceInput, 'today');
+
+ await waitFor(
+ () => {
+ expect(
+ screen.queryByText(INVALID_EXPRESSION_ERROR),
+ ).not.toBeInTheDocument();
+ expect(screen.getByTestId(DateFilterTestKey.ApplyButton)).toBeEnabled();
+ },
+ { timeout: 3000 },
+ );
+});
+
+test('Cancel restores the frame guessed from the saved value', async () => {
+ const onChange = jest.fn();
+ render(setup({ ...defaultProps, onChange, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+ await selectOption('Advanced', RANGE_TYPE_LABEL);
+ await expectOnlyFrame('Advanced');
+
+ await userEvent.click(screen.getByTestId(DateFilterTestKey.CancelButton));
+ await waitFor(() => {
+ expect(screen.queryByText('Edit time range')).not.toBeInTheDocument();
+ });
+ await userEvent.click(screen.getByText('Last week'));
+
+ await expectOnlyFrame('Common');
+ expect(onChange).not.toHaveBeenCalled();
+});
+
+test('Cancel discards an unsaved range selection', async () => {
+ render(setup({ ...defaultProps, value: 'Last week' }));
+ await userEvent.click(screen.getByText('Last week'));
+ await userEvent.click(screen.getByLabelText('Last month'));
+
+ await userEvent.click(screen.getByTestId(DateFilterTestKey.CancelButton));
+ await waitFor(() => {
+ expect(screen.queryByText('Edit time range')).not.toBeInTheDocument();
+ });
+ await userEvent.click(screen.getByText('Last week'));
+
+ expect(await screen.findByLabelText('Last week')).toBeChecked();
+ expect(screen.getByLabelText('Last month')).not.toBeChecked();
+});
diff --git
a/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateLabel.test.tsx
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateLabel.test.tsx
new file mode 100644
index 00000000000..e4f6846b962
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/DateFilterControl/tests/DateLabel.test.tsx
@@ -0,0 +1,73 @@
+/**
+ * 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 { createRef } from 'react';
+import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import { DateLabel } from '../components';
+
+test('renders a string label inside a button', () => {
+ render(<DateLabel name="time_range" label="Last week" />);
+
+ expect(screen.getByRole('button')).toHaveTextContent('Last week');
+});
+
+test('renders a node label as provided', () => {
+ render(
+ <DateLabel name="time_range" label={<em data-test="custom">Custom</em>} />,
+ );
+
+ expect(screen.getByTestId('custom')).toHaveTextContent('Custom');
+});
+
+test('identifies the label content by the control name', () => {
+ render(<DateLabel name="time_range" label="Last week" />);
+
+ expect(screen.getByText('Last week')).toHaveAttribute(
+ 'id',
+ 'date-label-time_range',
+ );
+});
+
+test('forwards the ref to the label content element', () => {
+ const ref = createRef<HTMLSpanElement>();
+ render(<DateLabel name="time_range" label="Last week" ref={ref} />);
+
+ expect(ref.current).toBe(screen.getByText('Last week'));
+});
+
+test('calls onClick when clicked', async () => {
+ const onClick = jest.fn();
+ render(<DateLabel name="time_range" label="Last week" onClick={onClick} />);
+
+ await userEvent.click(screen.getByRole('button'));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+});
+
+test('does not submit a surrounding form', async () => {
+ const onSubmit = jest.fn(e => e.preventDefault());
+ render(
+ <form onSubmit={onSubmit}>
+ <DateLabel name="time_range" label="Last week" />
+ </form>,
+ );
+
+ await userEvent.click(screen.getByRole('button'));
+
+ expect(onSubmit).not.toHaveBeenCalled();
+});
diff --git
a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.test.tsx
b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.test.tsx
new file mode 100644
index 00000000000..d8ba48b6adf
--- /dev/null
+++
b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.test.tsx
@@ -0,0 +1,278 @@
+/**
+ * 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 {
+ fireEvent,
+ render,
+ screen,
+ userEvent,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import { AGGREGATES } from 'src/explore/constants';
+import AdhocMetric from './AdhocMetric';
+import AdhocMetricPopoverTrigger, {
+ AdhocMetricPopoverTriggerProps,
+} from './AdhocMetricPopoverTrigger';
+
+const columns = [
+ { type: 'VARCHAR(255)', column_name: 'source' },
+ { type: 'DOUBLE', column_name: 'value' },
+];
+
+const savedMetricsOptions = [
+ { id: 1, metric_name: 'count', expression: 'COUNT(*)' },
+ { id: 2, metric_name: 'sum_value', expression: 'SUM(value)' },
+];
+
+const createAdhocMetric = (column = columns[1]) =>
+ new AdhocMetric({ column, aggregate: AGGREGATES.SUM });
+
+const TRIGGER_TEXT = 'open metric popover';
+
+const createProps = (
+ overrides: Partial<AdhocMetricPopoverTriggerProps> = {},
+): AdhocMetricPopoverTriggerProps => ({
+ adhocMetric: createAdhocMetric(),
+ onMetricEdit: jest.fn(),
+ columns,
+ savedMetricsOptions,
+ savedMetric: {},
+ datasource: {
+ type: 'table',
+ id: 1,
+ uid: '1__table',
+ columnFormats: {},
+ verboseMap: {},
+ } as unknown as AdhocMetricPopoverTriggerProps['datasource'],
+ children: <button type="button">{TRIGGER_TEXT}</button>,
+ ...overrides,
+});
+
+const renderOptions = {
+ useRedux: true,
+ initialState: { explore: {} },
+};
+
+const renderTrigger = (
+ overrides: Partial<AdhocMetricPopoverTriggerProps> = {},
+) => {
+ const props = createProps(overrides);
+ const utils = render(<AdhocMetricPopoverTrigger {...props} />,
renderOptions);
+ return {
+ ...utils,
+ props,
+ rerenderWith: (next: Partial<AdhocMetricPopoverTriggerProps>) =>
+ utils.rerender(<AdhocMetricPopoverTrigger {...props} {...next} />),
+ };
+};
+
+const popover = () => screen.queryByTestId('metrics-edit-popover');
+const titleTrigger = () =>
screen.queryByTestId('AdhocMetricEditTitle#trigger');
+const readOnlyTitle = () => screen.queryByTestId('AdhocMetricTitle');
+
+async function renameMetricTo(label: string) {
+ await userEvent.click(screen.getByTestId('AdhocMetricEditTitle#trigger'));
+ const input = await screen.findByTestId('AdhocMetricEditTitle#input');
+ await userEvent.clear(input);
+ await userEvent.type(input, label);
+ fireEvent.keyPress(input, { key: 'Enter', charCode: 13 });
+}
+
+test('uncontrolled: clicking the child opens the popover and Close dismisses
it', async () => {
+ renderTrigger();
+ expect(popover()).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+ expect(await
screen.findByTestId('metrics-edit-popover')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('button', { name: 'Close' }));
+ await waitFor(() => expect(popover()).not.toBeInTheDocument());
+});
+
+test('controlled: visible=true shows the popover without any interaction',
async () => {
+ renderTrigger({ isControlledComponent: true, visible: true });
+
+ expect(await
screen.findByTestId('metrics-edit-popover')).toBeInTheDocument();
+});
+
+test('controlled: visible=false keeps the popover closed', () => {
+ renderTrigger({ isControlledComponent: true, visible: false });
+
+ expect(popover()).not.toBeInTheDocument();
+});
+
+test('controlled: clicking the child reports the request through
togglePopover', async () => {
+ const togglePopover = jest.fn();
+ renderTrigger({
+ isControlledComponent: true,
+ visible: false,
+ togglePopover,
+ });
+
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+
+ expect(togglePopover).toHaveBeenCalledWith(true);
+});
+
+test('controlled: Close calls the closePopover prop', async () => {
+ const closePopover = jest.fn();
+ renderTrigger({
+ isControlledComponent: true,
+ visible: true,
+ togglePopover: jest.fn(),
+ closePopover,
+ });
+
+ await userEvent.click(await screen.findByRole('button', { name: 'Close' }));
+
+ expect(closePopover).toHaveBeenCalledTimes(1);
+});
+
+test('controlled: the visible prop drives the popover across rerenders', async
() => {
+ const { rerenderWith } = renderTrigger({
+ isControlledComponent: true,
+ visible: false,
+ togglePopover: jest.fn(),
+ });
+ expect(popover()).not.toBeInTheDocument();
+
+ rerenderWith({ visible: true });
+ expect(await
screen.findByTestId('metrics-edit-popover')).toBeInTheDocument();
+
+ rerenderWith({ visible: false });
+ await waitFor(() => expect(popover()).not.toBeInTheDocument());
+});
+
+test('title editing is disabled while the Saved tab is active', async () => {
+ renderTrigger({
+ adhocMetric: new AdhocMetric({}),
+ savedMetric: savedMetricsOptions[0],
+ });
+
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+
+ expect(await screen.findByTestId('AdhocMetricTitle')).toHaveTextContent(
+ 'count',
+ );
+ expect(titleTrigger()).not.toBeInTheDocument();
+});
+
+test('title editing is enabled on the Simple tab', async () => {
+ renderTrigger();
+
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+
+ expect(
+ await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+ ).toBeInTheDocument();
+ expect(readOnlyTitle()).not.toBeInTheDocument();
+});
+
+test('title editing follows the active tab when switching between Saved and
Simple', async () => {
+ renderTrigger({
+ adhocMetric: new AdhocMetric({}),
+ savedMetric: savedMetricsOptions[0],
+ });
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+ expect(await screen.findByTestId('AdhocMetricTitle')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('tab', { name: 'Simple' }));
+ expect(
+ await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+ ).toBeInTheDocument();
+ expect(readOnlyTitle()).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole('tab', { name: 'Saved' }));
+ expect(await screen.findByTestId('AdhocMetricTitle')).toBeInTheDocument();
+ expect(titleTrigger()).not.toBeInTheDocument();
+});
+
+test('a custom title survives a rerender with the same optionName', async ()
=> {
+ const { props, rerenderWith } = renderTrigger();
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+ await renameMetricTo('my custom label');
+ expect(
+ await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+ ).toHaveTextContent('my custom label');
+
+ rerenderWith({
+ adhocMetric: new AdhocMetric({
+ ...props.adhocMetric,
+ optionName: props.adhocMetric.optionName,
+ }),
+ });
+
+ expect(screen.getByTestId('AdhocMetricEditTitle#trigger')).toHaveTextContent(
+ 'my custom label',
+ );
+});
+
+test('an external optionName change resets the edited title to the new metric
label', async () => {
+ const { rerenderWith } = renderTrigger();
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+ await renameMetricTo('my custom label');
+ expect(
+ await screen.findByTestId('AdhocMetricEditTitle#trigger'),
+ ).toHaveTextContent('my custom label');
+
+ rerenderWith({ adhocMetric: createAdhocMetric(columns[0]) });
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('AdhocMetricEditTitle#trigger'),
+ ).toHaveTextContent('SUM(source)');
+ });
+ expect(screen.queryByText('my custom label')).not.toBeInTheDocument();
+});
+
+test('an external optionName change adopts the custom label of the new
metric', async () => {
+ const { rerenderWith } = renderTrigger();
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+ await renameMetricTo('my custom label');
+
+ rerenderWith({
+ adhocMetric: new AdhocMetric({
+ column: columns[0],
+ aggregate: AGGREGATES.MAX,
+ hasCustomLabel: true,
+ label: 'label from elsewhere',
+ }),
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('AdhocMetricEditTitle#trigger'),
+ ).toHaveTextContent('label from elsewhere');
+ });
+});
+
+test('saving passes the edited title to onMetricEdit', async () => {
+ const { props } = renderTrigger();
+ await userEvent.click(screen.getByText(TRIGGER_TEXT));
+ await renameMetricTo('my custom label');
+
+ await userEvent.click(screen.getByRole('button', { name: 'Save' }));
+
+ expect(props.onMetricEdit).toHaveBeenCalledWith(
+ expect.objectContaining({
+ label: 'my custom label',
+ hasCustomLabel: true,
+ }),
+ props.adhocMetric,
+ );
+});