This is an automated email from the ASF dual-hosted git repository. sadpandajoe pushed a commit to branch test/dashboard-header-modals in repository https://gitbox.apache.org/repos/asf/superset.git
commit 78585a55b809d703945edc79f18df8cee724d5e3 Author: Joe Li <[email protected]> AuthorDate: Fri Sep 25 15:37:52 2026 -0700 test(dashboard): cover save and refresh interval modals - SaveModal: overwrite vs save-as payloads, the default "[copy]" name, editing the name switching to save-as, duplicate_slices, the empty name guard, overwrite disabled without permission, and cancel - RefreshIntervalModal: persisted vs session-only save, the minimum refresh limit blocking save, and cancel restoring the original frequency Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../components/RefreshIntervalModal.test.tsx | 114 +++++++++++++ .../src/dashboard/components/SaveModal.test.tsx | 178 +++++++++++++++++++++ 2 files changed, 292 insertions(+) diff --git a/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx new file mode 100644 index 00000000000..54423d55777 --- /dev/null +++ b/superset-frontend/src/dashboard/components/RefreshIntervalModal.test.tsx @@ -0,0 +1,114 @@ +/** + * 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 RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal'; + +const defaultProps = { + show: true, + onHide: jest.fn(), + refreshFrequency: 60, + onChange: jest.fn(), + editMode: true, + addSuccessToast: jest.fn(), + pauseOnInactiveTab: false, + onPauseOnInactiveTabChange: jest.fn(), +}; + +const setup = ( + props: Partial<typeof defaultProps> = {}, + refreshLimitConf: Record<string, unknown> = {}, +) => + render(<RefreshIntervalModal {...defaultProps} {...props} />, { + useRedux: true, + initialState: { + dashboardInfo: { common: { conf: refreshLimitConf } }, + }, + }); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('selecting an interval and saving persists it in edit mode', async () => { + setup(); + + await userEvent.click(screen.getByRole('radio', { name: '5 minutes' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(defaultProps.onChange).toHaveBeenCalledWith(300, true); + expect(defaultProps.onPauseOnInactiveTabChange).toHaveBeenCalledWith(false); + expect(defaultProps.onHide).toHaveBeenCalledTimes(1); + expect(defaultProps.addSuccessToast).toHaveBeenCalledWith( + 'Refresh interval saved', + ); +}); + +test('saving outside edit mode reports a session-only save', async () => { + setup({ editMode: false }); + + await userEvent.click( + screen.getByRole('button', { name: 'Save for this session' }), + ); + + expect(defaultProps.onChange).toHaveBeenCalledWith(60, false); + expect(defaultProps.addSuccessToast).toHaveBeenCalledWith( + 'Refresh interval set for this session', + ); +}); + +test('an interval below the configured limit blocks save with an error', async () => { + setup({}, { SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT: 60 }); + + await userEvent.click(screen.getByRole('radio', { name: '10 seconds' })); + + expect( + screen.getByText('Refresh frequency must be at least 60 seconds'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + expect(defaultProps.onChange).not.toHaveBeenCalled(); +}); + +test('an interval at or above the configured limit does not block save', async () => { + setup({}, { SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT: 60 }); + + await userEvent.click(screen.getByRole('radio', { name: '5 minutes' })); + + expect( + screen.queryByText(/Refresh frequency must be at least/), + ).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); +}); + +test('cancel resets the selection back to the original frequency and does not save', async () => { + // defaultProps.refreshFrequency (60) maps to the "1 minute" preset. + setup(); + expect(screen.getByRole('radio', { name: '1 minute' })).toBeChecked(); + + await userEvent.click(screen.getByRole('radio', { name: '1 hour' })); + expect(screen.getByRole('radio', { name: '1 hour' })).toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(defaultProps.onChange).not.toHaveBeenCalled(); + expect(defaultProps.onHide).toHaveBeenCalledTimes(1); + expect(screen.getByRole('radio', { name: '1 minute' })).toBeChecked(); + expect(screen.getByRole('radio', { name: '1 hour' })).not.toBeChecked(); +}); diff --git a/superset-frontend/src/dashboard/components/SaveModal.test.tsx b/superset-frontend/src/dashboard/components/SaveModal.test.tsx new file mode 100644 index 00000000000..1621728dcca --- /dev/null +++ b/superset-frontend/src/dashboard/components/SaveModal.test.tsx @@ -0,0 +1,178 @@ +/** + * 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 SaveModal from 'src/dashboard/components/SaveModal'; +import { + SAVE_TYPE_OVERWRITE, + SAVE_TYPE_NEWDASHBOARD, +} from 'src/dashboard/util/constants'; + +const dashboardTitle = 'Sales Dashboard'; + +const defaultProps = { + addSuccessToast: jest.fn(), + addDangerToast: jest.fn(), + dashboardId: 1, + dashboardTitle, + dashboardInfo: { + certified_by: '', + certification_details: '', + editors: [], + metadata: { refresh_frequency: 0 }, + }, + expandedSlices: {}, + layout: { ROOT_ID: {} }, + // SaveModal does not export its SaveType union; the inline cast (rather + // than a `const` alias, which TS would narrow back to the literal type at + // every use) keeps this assignable to both save types in prop overrides. + saveType: SAVE_TYPE_OVERWRITE as + | typeof SAVE_TYPE_OVERWRITE + | typeof SAVE_TYPE_NEWDASHBOARD, + triggerNode: <span>Open save modal</span>, + customCss: '', + onSave: jest.fn(), + canOverwrite: true, + shouldPersistRefreshFrequency: false, + refreshFrequency: 30, + lastModifiedTime: 1700000000, +}; + +const setup = (props: Partial<typeof defaultProps> = {}) => + render(<SaveModal {...defaultProps} {...props} />); + +const openModal = async (props: Partial<typeof defaultProps> = {}) => { + setup(props); + await userEvent.click(screen.getByText('Open save modal')); + return screen.findByRole('dialog'); +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +test('saving in overwrite mode calls onSave with the overwrite save type', async () => { + await openModal(); + + await userEvent.click(screen.getByTestId('modal-save-dashboard-button')); + + expect(defaultProps.onSave).toHaveBeenCalledTimes(1); + const [data, id, saveType] = defaultProps.onSave.mock.calls[0]; + expect(saveType).toBe(SAVE_TYPE_OVERWRITE); + expect(id).toBe(defaultProps.dashboardId); + expect(data.dashboard_title).toBe(dashboardTitle); +}); + +test('defaults the new dashboard name to "<title> [copy]"', async () => { + await openModal(); + + expect(screen.getByPlaceholderText('[dashboard name]')).toHaveValue( + `${dashboardTitle} [copy]`, + ); +}); + +test('saving in save-as mode without editing the name uses the default copy name', async () => { + await openModal({ saveType: SAVE_TYPE_NEWDASHBOARD }); + + await userEvent.click(screen.getByTestId('modal-save-dashboard-button')); + + expect(defaultProps.onSave).toHaveBeenCalledTimes(1); + const [data, , saveType] = defaultProps.onSave.mock.calls[0]; + expect(saveType).toBe(SAVE_TYPE_NEWDASHBOARD); + expect(data.dashboard_title).toMatch(/\[copy\]$/); +}); + +test('editing the new dashboard name switches to save-as and is used in the payload', async () => { + await openModal(); + + const nameInput = screen.getByPlaceholderText('[dashboard name]'); + await userEvent.clear(nameInput); + await userEvent.type(nameInput, 'Copied Dashboard'); + + await userEvent.click(screen.getByTestId('modal-save-dashboard-button')); + + expect(defaultProps.onSave).toHaveBeenCalledTimes(1); + const [data, , saveType] = defaultProps.onSave.mock.calls[0]; + expect(saveType).toBe(SAVE_TYPE_NEWDASHBOARD); + expect(data.dashboard_title).toBe('Copied Dashboard'); +}); + +test('save-as payload includes duplicate_slices when the checkbox is checked', async () => { + await openModal(); + + await userEvent.click(screen.getByRole('radio', { name: /Save as:/ })); + await userEvent.click( + screen.getByRole('checkbox', { name: /also copy \(duplicate\) charts/ }), + ); + + await userEvent.click(screen.getByTestId('modal-save-dashboard-button')); + + const [data] = defaultProps.onSave.mock.calls[0]; + expect(data.duplicate_slices).toBe(true); +}); + +test('overwrite payload does not duplicate slices by default', async () => { + await openModal(); + + await userEvent.click(screen.getByTestId('modal-save-dashboard-button')); + + const [data] = defaultProps.onSave.mock.calls[0]; + expect(data.duplicate_slices).toBe(false); +}); + +test('blocks save and warns when the new dashboard name is empty', async () => { + await openModal(); + + const nameInput = screen.getByPlaceholderText('[dashboard name]'); + await userEvent.clear(nameInput); + + await userEvent.click(screen.getByTestId('modal-save-dashboard-button')); + + expect(defaultProps.addDangerToast).toHaveBeenCalledWith( + 'You must pick a name for the new dashboard', + ); + expect(defaultProps.onSave).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); +}); + +test('disables overwrite and saves as a new dashboard when the user cannot overwrite', async () => { + await openModal({ + canOverwrite: false, + saveType: SAVE_TYPE_NEWDASHBOARD, + }); + + expect( + screen.getByRole('radio', { + name: new RegExp(`Overwrite Dashboard`), + }), + ).toBeDisabled(); + + await userEvent.click(screen.getByTestId('modal-save-dashboard-button')); + + const [, , saveType] = defaultProps.onSave.mock.calls[0]; + expect(saveType).toBe(SAVE_TYPE_NEWDASHBOARD); +}); + +test('cancel closes the modal without calling onSave', async () => { + await openModal(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(defaultProps.onSave).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +});
