This is an automated email from the ASF dual-hosted git repository. diegopucci 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 057218d87f feat: Add confirmation modal for unsaved changes (#33809) 057218d87f is described below commit 057218d87ff3f2c8d2eb4a89d31a2b50e8a5c606 Author: Gabriel Torres Ruiz <gabo2...@gmail.com> AuthorDate: Tue Jul 1 13:38:51 2025 -0300 feat: Add confirmation modal for unsaved changes (#33809) --- .../src/components/Modal/Modal.tsx | 9 +- .../UnsavedChangesModal.stories.tsx} | 57 ++-- .../UnsavedChangesModal.test.tsx | 96 +++++++ .../src/components/UnsavedChangesModal/index.tsx | 129 +++++++++ .../superset-ui-core/src/components/index.ts | 1 + .../AlteredSliceTag/AlteredSliceTag.test.jsx | 279 -------------------- .../AlteredSliceTag/AlteredSliceTag.test.tsx | 97 +++++++ .../AlteredSliceTag/AlteredSliceTagMocks.ts | 18 +- .../src/components/AlteredSliceTag/index.tsx | 128 +-------- .../src/components/AlteredSliceTag/types.ts | 26 +- .../src/components/AlteredSliceTag/utils/index.ts | 107 ++++++++ .../components/AlteredSliceTag/utils/utils.test.ts | 291 +++++++++++++++++++++ .../dashboard/components/Header/Header.test.tsx | 103 +++++++- .../src/dashboard/components/Header/index.jsx | 27 +- .../FiltersConfigModal/FiltersConfigModal.test.tsx | 13 +- .../ExploreChartHeader/ExploreChartHeader.test.tsx | 190 +++++++++++++- .../components/ExploreChartHeader/index.jsx | 89 +++++-- .../components/ExploreViewContainer/index.jsx | 1 + .../src/explore/exploreUtils/formData.test.ts | 88 ++++++- .../src/explore/exploreUtils/formData.ts | 31 +-- .../src/hooks/useUnsavedChangesPrompt/index.ts | 124 +++++++++ .../useUnsavedChangesPrompt.test.tsx | 106 ++++++++ superset-frontend/src/pages/Chart/Chart.test.tsx | 16 +- .../AlteredSliceTag/types.ts => types/DiffType.ts} | 20 -- .../getChartFormDiffs/getChartFormDiffs.test.ts | 115 ++++++++ .../src/utils/getChartFormDiffs/index.ts | 66 +++++ .../sanitizeFormData/index.ts} | 15 +- .../sanitizeFormData/sanitizeFormData.test.ts} | 2 +- 28 files changed, 1689 insertions(+), 555 deletions(-) diff --git a/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx b/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx index 9be6a15dd1..4bd716f1f1 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Modal/Modal.tsx @@ -228,13 +228,16 @@ const CustomModal = ({ }; let FooterComponent; - if (isValidElement(footer)) { + + // This safely avoids injecting "closeModal" into native elements like <div> or <span> + if (isValidElement(footer) && typeof footer.type === 'function') // If a footer component is provided inject a closeModal function // so the footer can provide a "close" button if desired FooterComponent = cloneElement(footer, { closeModal: handleOnHide, } as Partial<unknown>); - } + else FooterComponent = footer; + const modalFooter = isNil(FooterComponent) ? [ <Button @@ -309,7 +312,7 @@ const CustomModal = ({ open={show} title={<ModalTitle />} closeIcon={ - <span className="close" aria-hidden="true"> + <span data-test="close-modal-btn" className="close" aria-hidden="true"> × </span> } diff --git a/superset-frontend/src/components/AlteredSliceTag/types.ts b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.stories.tsx similarity index 52% copy from superset-frontend/src/components/AlteredSliceTag/types.ts copy to superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.stories.tsx index e1f6108608..4742c4da8f 100644 --- a/superset-frontend/src/components/AlteredSliceTag/types.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.stories.tsx @@ -16,45 +16,32 @@ * specific language governing permissions and limitations * under the License. */ -import type { QueryFormData } from '@superset-ui/core'; +import { ReactElement } from 'react'; +import { UnsavedChangesModal, type UnsavedChangesModalProps } from '.'; -export interface AlteredSliceTagProps { - origFormData: QueryFormData; - currentFormData: QueryFormData; -} - -export interface ControlMap { - [key: string]: { - label?: string; - type?: string; - }; -} - -export type FilterItemType = { - comparator?: string | string[]; - subject: string; - operator: string; - label?: string; +export default { + title: 'Components/UnsavedChangesModal', + component: UnsavedChangesModal, }; -export type DiffItemType< - T = FilterItemType | number | string | Record<string | number, any>, -> = - | T[] - | boolean - | number - | string - | Record<string | number, any> - | null - | undefined; +export const InteractiveUnsavedChangesModal = ( + props: UnsavedChangesModalProps, +): ReactElement => ( + <UnsavedChangesModal {...props}> + If you don't save, changes will be lost. + </UnsavedChangesModal> +); -export type DiffType = { - before: DiffItemType; - after: DiffItemType; +InteractiveUnsavedChangesModal.args = { + showModal: true, + onHide: () => {}, + handleSave: () => {}, + onConfirmNavigation: () => {}, + title: 'Unsaved Changes', }; -export type RowType = { - before: string | number; - after: string | number; - control: string; +InteractiveUnsavedChangesModal.argTypes = { + onHide: { action: 'onHide' }, + handleSave: { action: 'handleSave' }, + onConfirmNavigation: { action: 'onConfirmNavigation' }, }; diff --git a/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.test.tsx new file mode 100644 index 0000000000..995bd704d6 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/UnsavedChangesModal.test.tsx @@ -0,0 +1,96 @@ +/** + * 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 '@superset-ui/core/spec'; +import { UnsavedChangesModal } from '.'; + +test('should render nothing if showModal is false', () => { + const { queryByRole } = render( + <UnsavedChangesModal + showModal={false} + onHide={() => {}} + handleSave={() => {}} + onConfirmNavigation={() => {}} + />, + ); + + expect(queryByRole('dialog')).not.toBeInTheDocument(); +}); + +test('should render the UnsavedChangesModal component if showModal is true', async () => { + const { queryByRole } = render( + <UnsavedChangesModal + showModal + onHide={() => {}} + handleSave={() => {}} + onConfirmNavigation={() => {}} + />, + ); + + expect(queryByRole('dialog')).toBeInTheDocument(); +}); + +test('should only call onConfirmNavigation when clicking the Discard button', async () => { + const mockOnHide = jest.fn(); + const mockHandleSave = jest.fn(); + const mockOnConfirmNavigation = jest.fn(); + + render( + <UnsavedChangesModal + showModal + onHide={mockOnHide} + handleSave={mockHandleSave} + onConfirmNavigation={mockOnConfirmNavigation} + />, + ); + + const discardButton: HTMLElement = await screen.findByRole('button', { + name: /discard/i, + }); + + userEvent.click(discardButton); + + expect(mockOnConfirmNavigation).toHaveBeenCalled(); + expect(mockHandleSave).not.toHaveBeenCalled(); + expect(mockOnHide).not.toHaveBeenCalled(); +}); + +test('should only call handleSave when clicking the Save button', async () => { + const mockOnHide = jest.fn(); + const mockHandleSave = jest.fn(); + const mockOnConfirmNavigation = jest.fn(); + + render( + <UnsavedChangesModal + showModal + onHide={mockOnHide} + handleSave={mockHandleSave} + onConfirmNavigation={mockOnConfirmNavigation} + />, + ); + + const saveButton: HTMLElement = await screen.findByRole('button', { + name: /save/i, + }); + + userEvent.click(saveButton); + + expect(mockHandleSave).toHaveBeenCalled(); + expect(mockOnHide).not.toHaveBeenCalled(); + expect(mockOnConfirmNavigation).not.toHaveBeenCalled(); +}); diff --git a/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/index.tsx new file mode 100644 index 0000000000..fcab6f3263 --- /dev/null +++ b/superset-frontend/packages/superset-ui-core/src/components/UnsavedChangesModal/index.tsx @@ -0,0 +1,129 @@ +/** + * 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 { t, styled, css } from '@superset-ui/core'; +import { Icons, Modal, Typography } from '@superset-ui/core/components'; +import { Button } from '@superset-ui/core/components/Button'; +import type { FC, ReactElement } from 'react'; + +const StyledModalTitle = styled(Typography.Title)` + && { + font-weight: 600; + margin: 0; + } +`; + +const StyledModalBody = styled(Typography.Text)` + ${({ theme }) => css` + padding: 0 ${theme.sizeUnit * 2}px; + + && { + margin: 0; + } + `} +`; + +const StyledDiscardBtn = styled(Button)` + ${({ theme }) => css` + min-width: ${theme.sizeUnit * 22}px; + height: ${theme.sizeUnit * 8}px; + `} +`; + +const StyledSaveBtn = styled(Button)` + ${({ theme }) => css` + min-width: ${theme.sizeUnit * 17}px; + height: ${theme.sizeUnit * 8}px; + span > :first-of-type { + margin-right: 0; + } + `} +`; + +const StyledWarningIcon = styled(Icons.WarningOutlined)` + ${({ theme }) => css` + color: ${theme.colors.warning.base}; + margin-right: ${theme.sizeUnit * 4}px; + `} +`; + +export type UnsavedChangesModalProps = { + showModal: boolean; + onHide: () => void; + handleSave: () => void; + onConfirmNavigation: () => void; + title?: string; + body?: string; +}; + +export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({ + showModal, + onHide, + handleSave, + onConfirmNavigation, + title = 'Unsaved Changes', + body = "If you don't save, changes will be lost.", +}: UnsavedChangesModalProps): ReactElement => ( + <Modal + centered + responsive + onHide={onHide} + show={showModal} + width="444px" + title={ + <div + css={css` + align-items: center; + display: flex; + `} + > + <StyledWarningIcon iconSize="xl" /> + <StyledModalTitle type="secondary" level={5}> + {title} + </StyledModalTitle> + </div> + } + footer={ + <div + css={css` + display: flex; + justify-content: flex-end; + width: 100%; + `} + > + <StyledDiscardBtn + htmlType="button" + buttonSize="small" + onClick={onConfirmNavigation} + > + {t('Discard')} + </StyledDiscardBtn> + <StyledSaveBtn + htmlType="button" + buttonSize="small" + buttonStyle="primary" + onClick={handleSave} + > + {t('Save')} + </StyledSaveBtn> + </div> + } + > + <StyledModalBody type="secondary">{body}</StyledModalBody> + </Modal> +); diff --git a/superset-frontend/packages/superset-ui-core/src/components/index.ts b/superset-frontend/packages/superset-ui-core/src/components/index.ts index 6e5348f320..f7eda5e333 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/index.ts +++ b/superset-frontend/packages/superset-ui-core/src/components/index.ts @@ -163,5 +163,6 @@ export * from './Steps'; export * from './Table'; export * from './TableView'; export * from './Tag'; +export * from './UnsavedChangesModal'; export * from './constants'; export * from './Result'; diff --git a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx deleted file mode 100644 index 267b50291c..0000000000 --- a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.jsx +++ /dev/null @@ -1,279 +0,0 @@ -/** - * 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 { - AlteredSliceTag, - alterForComparison, - formatValueHandler, - isEqualish, -} from '.'; -import { defaultProps } from './AlteredSliceTagMocks'; - -const controlsMap = { - b: { type: 'BoundsControl', label: 'Bounds' }, - column_collection: { type: 'CollectionControl', label: 'Collection' }, - metrics: { type: 'MetricsControl', label: 'Metrics' }, - adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' }, - other_control: { type: 'OtherControl', label: 'Other' }, -}; - -test('renders the "Altered" label', () => { - render( - <AlteredSliceTag - origFormData={defaultProps.origFormData} - currentFormData={defaultProps.currentFormData} - />, - ); - - const alteredLabel = screen.getByText('Altered'); - expect(alteredLabel).toBeInTheDocument(); -}); - -test('opens the modal on click', () => { - render( - <AlteredSliceTag - origFormData={defaultProps.origFormData} - currentFormData={defaultProps.currentFormData} - />, - ); - - const alteredLabel = screen.getByText('Altered'); - userEvent.click(alteredLabel); - - const modalTitle = screen.getByText('Chart changes'); - expect(modalTitle).toBeInTheDocument(); -}); - -test('displays the differences in the modal', () => { - render( - <AlteredSliceTag - origFormData={defaultProps.origFormData} - currentFormData={defaultProps.currentFormData} - />, - ); - - const alteredLabel = screen.getByText('Altered'); - userEvent.click(alteredLabel); - - const beforeValue = screen.getByText('1, 2, 3, 4'); - const afterValue = screen.getByText('a, b, c, d'); - expect(beforeValue).toBeInTheDocument(); - expect(afterValue).toBeInTheDocument(); -}); - -test('does not render anything if there are no differences', () => { - render( - <AlteredSliceTag - origFormData={defaultProps.origFormData} - currentFormData={defaultProps.origFormData} - />, - ); - - const alteredLabel = screen.queryByText('Altered'); - expect(alteredLabel).not.toBeInTheDocument(); -}); - -test('alterForComparison returns null for undefined value', () => { - expect(alterForComparison(undefined)).toBeNull(); -}); - -test('alterForComparison returns null for null value', () => { - expect(alterForComparison(null)).toBeNull(); -}); - -test('alterForComparison returns null for empty string value', () => { - expect(alterForComparison('')).toBeNull(); -}); - -test('alterForComparison returns null for empty array value', () => { - expect(alterForComparison([])).toBeNull(); -}); - -test('alterForComparison returns null for empty object value', () => { - expect(alterForComparison({})).toBeNull(); -}); - -test('alterForComparison returns value for non-empty array', () => { - const value = [1, 2, 3]; - expect(alterForComparison(value)).toEqual(value); -}); - -test('alterForComparison returns value for non-empty object', () => { - const value = { key: 'value' }; - expect(alterForComparison(value)).toEqual(value); -}); - -test('formatValueHandler handles undefined value', () => { - const value = undefined; - const key = 'b'; - const formattedValue = formatValueHandler(value, key, controlsMap); - expect(formattedValue).toBe('N/A'); -}); - -test('formatValueHandler handles null value', () => { - const value = null; - const key = 'b'; - const formattedValue = formatValueHandler(value, key, controlsMap); - expect(formattedValue).toBe('null'); -}); - -test('formatValueHandler returns "[]" for empty filters', () => { - const value = []; - const key = 'adhoc_filters'; - const formattedValue = formatValueHandler(value, key, controlsMap); - expect(formattedValue).toBe('[]'); -}); - -test('formatValueHandler formats filters with array values', () => { - const filters = [ - { - clause: 'WHERE', - comparator: ['1', 'g', '7', 'ho'], - expressionType: 'SIMPLE', - operator: 'IN', - subject: 'a', - }, - { - clause: 'WHERE', - comparator: ['hu', 'ho', 'ha'], - expressionType: 'SIMPLE', - operator: 'NOT IN', - subject: 'b', - }, - ]; - const key = 'adhoc_filters'; - const formattedValue = formatValueHandler(filters, key, controlsMap); - const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]'; - expect(formattedValue).toBe(expected); -}); - -test('formatValueHandler formats filters with string values', () => { - const filters = [ - { - clause: 'WHERE', - comparator: 'gucci', - expressionType: 'SIMPLE', - operator: '==', - subject: 'a', - }, - { - clause: 'WHERE', - comparator: 'moshi moshi', - expressionType: 'SIMPLE', - operator: 'LIKE', - subject: 'b', - }, - ]; - const key = 'adhoc_filters'; - const expected = 'a == gucci, b LIKE moshi moshi'; - const formattedValue = formatValueHandler(filters, key, controlsMap); - expect(formattedValue).toBe(expected); -}); - -test('formatValueHandler formats "Min" and "Max" for BoundsControl', () => { - const value = [1, 2]; - const key = 'b'; - const result = formatValueHandler(value, key, controlsMap); - expect(result).toEqual('Min: 1, Max: 2'); -}); - -test('formatValueHandler formats stringified objects for CollectionControl', () => { - const value = [{ a: 1 }, { b: 2 }]; - const key = 'column_collection'; - const result = formatValueHandler(value, key, controlsMap); - expect(result).toEqual( - `${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`, - ); -}); - -test('formatValueHandler formats MetricsControl values correctly', () => { - const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }]; - const key = 'metrics'; - const result = formatValueHandler(value, key, controlsMap); - expect(result).toEqual('SUM(Sales), Metric2'); -}); - -test('formatValueHandler formats boolean values as string', () => { - const value1 = true; - const value2 = false; - const key = 'b'; - const formattedValue1 = formatValueHandler(value1, key, controlsMap); - const formattedValue2 = formatValueHandler(value2, key, controlsMap); - expect(formattedValue1).toBe('true'); - expect(formattedValue2).toBe('false'); -}); - -test('formatValueHandler formats array values correctly', () => { - const value = [ - { label: 'Label1' }, - { label: 'Label2' }, - 5, - 6, - 7, - 8, - 'hello', - 'goodbye', - ]; - const result = formatValueHandler(value, undefined, controlsMap); - const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye'; - expect(result).toEqual(expected); -}); - -test('formatValueHandler formats string values correctly', () => { - const value = 'test'; - const key = 'other_control'; - const result = formatValueHandler(value, key, controlsMap); - expect(result).toEqual('test'); -}); - -test('formatValueHandler formats number values correctly', () => { - const value = 123; - const key = 'other_control'; - const result = formatValueHandler(value, key, controlsMap); - expect(result).toEqual(123); -}); - -test('formatValueHandler formats object values correctly', () => { - const value = { 1: 2, alpha: 'bravo' }; - const key = 'other_control'; - const expected = '{"1":2,"alpha":"bravo"}'; - const result = formatValueHandler(value, key, controlsMap); - expect(result).toEqual(expected); -}); - -test('isEqualish considers null, undefined, {} and [] as equal', () => { - expect(isEqualish(null, undefined)).toBe(true); - expect(isEqualish(null, [])).toBe(true); - expect(isEqualish(null, {})).toBe(true); - expect(isEqualish(undefined, {})).toBe(true); -}); - -test('isEqualish considers empty strings equal to null', () => { - expect(isEqualish(undefined, '')).toBe(true); - expect(isEqualish(null, '')).toBe(true); -}); - -test('isEqualish considers deeply equal objects equal', () => { - const obj1 = { a: { b: { c: 1 } } }; - const obj2 = { a: { b: { c: 1 } } }; - expect(isEqualish('', '')).toBe(true); - expect(isEqualish(obj1, obj2)).toBe(true); - // Actually not equal - expect(isEqualish({ a: 1, b: 2, z: 9 }, { a: 1, b: 2, c: 3 })).toBe(false); -}); diff --git a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.tsx b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.tsx new file mode 100644 index 0000000000..515b6d9fc0 --- /dev/null +++ b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTag.test.tsx @@ -0,0 +1,97 @@ +/** + * 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 { QueryFormData } from '@superset-ui/core'; +import { AlteredSliceTag } from '.'; +import { defaultProps, expectedDiffs } from './AlteredSliceTagMocks'; + +test('renders the "Altered" label', () => { + render( + <AlteredSliceTag + origFormData={defaultProps.origFormData} + currentFormData={defaultProps.currentFormData} + diffs={expectedDiffs} + />, + ); + + const alteredLabel: HTMLElement = screen.getByText('Altered'); + expect(alteredLabel).toBeInTheDocument(); +}); + +test('opens the modal on click', () => { + render( + <AlteredSliceTag + origFormData={defaultProps.origFormData} + currentFormData={defaultProps.currentFormData} + diffs={expectedDiffs} + />, + ); + + const alteredLabel: HTMLElement = screen.getByText('Altered'); + userEvent.click(alteredLabel); + + const modalTitle: HTMLElement = screen.getByText('Chart changes'); + + expect(modalTitle).toBeInTheDocument(); +}); + +test('displays the differences in the modal', () => { + render( + <AlteredSliceTag + origFormData={defaultProps.origFormData} + currentFormData={defaultProps.currentFormData} + diffs={expectedDiffs} + />, + ); + + const alteredLabel: HTMLElement = screen.getByText('Altered'); + userEvent.click(alteredLabel); + + const beforeValue: HTMLElement = screen.getByText('1, 2, 3, 4'); + const afterValue: HTMLElement = screen.getByText('a, b, c, d'); + + expect(beforeValue).toBeInTheDocument(); + expect(afterValue).toBeInTheDocument(); +}); + +test('does not render anything if there are no differences', () => { + render( + <AlteredSliceTag + origFormData={defaultProps.origFormData} + currentFormData={defaultProps.origFormData} + diffs={{}} + />, + ); + + const alteredLabel: HTMLElement | null = screen.queryByText('Altered'); + + expect(alteredLabel).not.toBeInTheDocument(); +}); + +test('does not render the altered label if diffs is empty', () => { + render( + <AlteredSliceTag + origFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData} + currentFormData={{ viz_type: 'altered_slice_tag_spec' } as QueryFormData} + diffs={{}} + />, + ); + + expect(screen.queryByText('Altered')).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts index c307eace57..1f54bdfbde 100644 --- a/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts +++ b/superset-frontend/src/components/AlteredSliceTag/AlteredSliceTagMocks.ts @@ -18,10 +18,16 @@ */ import { QueryFormData } from '@superset-ui/core'; import { ControlPanelConfig } from '@superset-ui/chart-controls'; -import type { DiffType, RowType } from './types'; +import type { DiffType } from 'src/types/DiffType'; +import { getChartFormDiffs } from 'src/utils/getChartFormDiffs'; +import type { RowType } from './types'; -export const defaultProps: Record<string, Partial<QueryFormData>> = { +export const defaultProps: { + origFormData: QueryFormData; + currentFormData: QueryFormData; +} = { origFormData: { + datasource: '123__table', viz_type: 'altered_slice_tag_spec', adhoc_filters: [ { @@ -40,7 +46,10 @@ export const defaultProps: Record<string, Partial<QueryFormData>> = { never: 5, ever: { a: 'b', c: 'd' }, }, + currentFormData: { + datasource: '123__table', + viz_type: 'altered_slice_tag_spec', adhoc_filters: [ { clause: 'WHERE', @@ -106,6 +115,7 @@ export const expectedDiffs: Record<string, DiffType> = { after: { x: 'y', z: 'z' }, }, }; + export const expectedRows: RowType[] = [ { control: 'Fake Filters', @@ -131,6 +141,7 @@ export const expectedRows: RowType[] = [ after: '{"x":"y","z":"z"}', }, ]; + export const fakePluginControls: ControlPanelConfig = { controlPanelSections: [ { @@ -175,3 +186,6 @@ export const fakePluginControls: ControlPanelConfig = { }, ], }; + +export const createDiffs = (original: QueryFormData, current: QueryFormData) => + getChartFormDiffs(original, current); diff --git a/superset-frontend/src/components/AlteredSliceTag/index.tsx b/superset-frontend/src/components/AlteredSliceTag/index.tsx index 43fd0d7a4f..b9025fd2d2 100644 --- a/superset-frontend/src/components/AlteredSliceTag/index.tsx +++ b/superset-frontend/src/components/AlteredSliceTag/index.tsx @@ -16,13 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { useCallback, useEffect, useMemo, useState, FC } from 'react'; - -import { isEqual, isEmpty } from 'lodash'; +import { useEffect, useMemo, useState, FC } from 'react'; +import { isEmpty } from 'lodash'; import { t } from '@superset-ui/core'; -import { sanitizeFormData } from 'src/explore/exploreUtils/formData'; import getControlsForVizType from 'src/utils/getControlsForVizType'; -import { safeStringify } from 'src/utils/safeStringify'; import { Label, Icons, @@ -30,130 +27,21 @@ import { ModalTrigger, TableView, } from '@superset-ui/core/components'; -import type { - AlteredSliceTagProps, - ControlMap, - DiffItemType, - DiffType, - FilterItemType, - RowType, -} from './types'; - -export const alterForComparison = ( - value?: string | null | [], -): string | null => { - // Treat `null`, `undefined`, and empty strings as equivalent - if (value === undefined || value === null || value === '') { - return null; - } - // Treat empty arrays and objects as equivalent to null - if (Array.isArray(value) && value.length === 0) { - return null; - } - if (typeof value === 'object' && Object.keys(value).length === 0) { - return null; - } - return value; -}; - -export const formatValueHandler = ( - value: DiffItemType, - key: string, - controlsMap: ControlMap, -): string | number => { - if (value === undefined) { - return 'N/A'; - } - if (value === null) { - return 'null'; - } - if (typeof value === 'boolean') { - return value ? 'true' : 'false'; - } - if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) { - if (!value.length) { - return '[]'; - } - return value - .map((v: FilterItemType) => { - const filterVal = - v.comparator && v.comparator.constructor === Array - ? `[${v.comparator.join(', ')}]` - : v.comparator; - return `${v.subject} ${v.operator} ${filterVal}`; - }) - .join(', '); - } - if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value)) { - return `Min: ${value[0]}, Max: ${value[1]}`; - } - if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value)) { - return value.map((v: FilterItemType) => safeStringify(v)).join(', '); - } - if ( - controlsMap[key]?.type === 'MetricsControl' && - value.constructor === Array - ) { - const formattedValue = value.map((v: FilterItemType) => v?.label ?? v); - return formattedValue.length ? formattedValue.join(', ') : '[]'; - } - if (Array.isArray(value)) { - const formattedValue = value.map((v: FilterItemType) => v?.label ?? v); - return formattedValue.length ? formattedValue.join(', ') : '[]'; - } - if (typeof value === 'string' || typeof value === 'number') { - return value; - } - return safeStringify(value); -}; - -export const getRowsFromDiffs = ( - diffs: { [key: string]: DiffType }, - controlsMap: ControlMap, -): RowType[] => - Object.entries(diffs).map(([key, diff]) => ({ - control: controlsMap[key]?.label || key, - before: formatValueHandler(diff.before, key, controlsMap), - after: formatValueHandler(diff.after, key, controlsMap), - })); - -export const isEqualish = (val1: string, val2: string): boolean => - isEqual(alterForComparison(val1), alterForComparison(val2)); +import type { AlteredSliceTagProps, ControlMap, RowType } from './types'; +import { getRowsFromDiffs } from './utils'; export const AlteredSliceTag: FC<AlteredSliceTagProps> = props => { const [rows, setRows] = useState<RowType[]>([]); const [hasDiffs, setHasDiffs] = useState<boolean>(false); - const getDiffs = useCallback(() => { - // Returns all properties that differ in the - // current form data and the saved form data - const ofd = sanitizeFormData(props.origFormData); - const cfd = sanitizeFormData(props.currentFormData); - - const fdKeys = Object.keys(cfd); - const diffs: { [key: string]: DiffType } = {}; - fdKeys.forEach(fdKey => { - if (!ofd[fdKey] && !cfd[fdKey]) { - return; - } - if (['filters', 'having', 'where'].includes(fdKey)) { - return; - } - if (!isEqualish(ofd[fdKey], cfd[fdKey])) { - diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] }; - } - }); - return diffs; - }, [props.currentFormData, props.origFormData]); - useEffect(() => { - const diffs = getDiffs(); const controlsMap = getControlsForVizType( props.origFormData?.viz_type, ) as ControlMap; - setRows(getRowsFromDiffs(diffs, controlsMap)); - setHasDiffs(!isEmpty(diffs)); - }, [getDiffs, props.origFormData?.viz_type]); + + setRows(getRowsFromDiffs(props.diffs, controlsMap)); + setHasDiffs(!isEmpty(props.diffs)); + }, [props.diffs, props.origFormData?.viz_type]); const modalBody = useMemo(() => { const columns = [ diff --git a/superset-frontend/src/components/AlteredSliceTag/types.ts b/superset-frontend/src/components/AlteredSliceTag/types.ts index e1f6108608..44c6796e36 100644 --- a/superset-frontend/src/components/AlteredSliceTag/types.ts +++ b/superset-frontend/src/components/AlteredSliceTag/types.ts @@ -17,8 +17,11 @@ * under the License. */ import type { QueryFormData } from '@superset-ui/core'; +import { DiffType } from 'src/types/DiffType'; export interface AlteredSliceTagProps { + className?: string; + diffs: Record<string, DiffType>; origFormData: QueryFormData; currentFormData: QueryFormData; } @@ -30,29 +33,6 @@ export interface ControlMap { }; } -export type FilterItemType = { - comparator?: string | string[]; - subject: string; - operator: string; - label?: string; -}; - -export type DiffItemType< - T = FilterItemType | number | string | Record<string | number, any>, -> = - | T[] - | boolean - | number - | string - | Record<string | number, any> - | null - | undefined; - -export type DiffType = { - before: DiffItemType; - after: DiffItemType; -}; - export type RowType = { before: string | number; after: string | number; diff --git a/superset-frontend/src/components/AlteredSliceTag/utils/index.ts b/superset-frontend/src/components/AlteredSliceTag/utils/index.ts new file mode 100644 index 0000000000..482a433262 --- /dev/null +++ b/superset-frontend/src/components/AlteredSliceTag/utils/index.ts @@ -0,0 +1,107 @@ +/** + * 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 { DiffItemType, DiffType, FilterItemType } from 'src/types/DiffType'; +import { safeStringify } from 'src/utils/safeStringify'; +import { ControlMap, RowType } from '../types'; + +export const alterForComparison = (value?: unknown): unknown => { + // Treat `null`, `undefined`, and empty strings as equivalent + if (value === undefined || value === null || value === '') return null; + + // Treat empty arrays and objects as equivalent to null + if (Array.isArray(value) && value.length === 0) return null; + + if (typeof value === 'object' && Object.keys(value).length === 0) return null; + + return value; +}; + +export const formatValueHandler = ( + value: DiffItemType, + key = '', + controlsMap: ControlMap, +): string | number => { + if (value === undefined) return 'N/A'; + + if (value === null) return 'null'; + + if (typeof value === 'boolean') return value ? 'true' : 'false'; + + if (controlsMap[key]?.type === 'AdhocFilterControl' && Array.isArray(value)) { + if (!value.length) return '[]'; + + return value + .map((v: FilterItemType): string => { + const filterVal: string | string[] | undefined = + v.comparator && v.comparator.constructor === Array + ? `[${v.comparator.join(', ')}]` + : v.comparator; + return `${v.subject} ${v.operator} ${filterVal}`; + }) + .join(', '); + } + + if (controlsMap[key]?.type === 'BoundsControl' && Array.isArray(value)) + return `Min: ${value[0]}, Max: ${value[1]}`; + + if (controlsMap[key]?.type === 'CollectionControl' && Array.isArray(value)) + return value + .map((v: FilterItemType): string => safeStringify(v)) + .join(', '); + + if ( + controlsMap[key]?.type === 'MetricsControl' && + value.constructor === Array + ) { + const formattedValue: (string | FilterItemType)[] = value.map( + (v: FilterItemType): string | FilterItemType => v?.label ?? v, + ); + return formattedValue.length ? formattedValue.join(', ') : '[]'; + } + + if (Array.isArray(value)) { + const formattedValue = value.map((v: any) => { + if ( + typeof v === 'object' && + v !== null && + 'label' in v && + typeof v.label === 'string' + ) + return v.label; + + return String(v); + }); + + return formattedValue.length ? formattedValue.join(', ') : '[]'; + } + + if (typeof value === 'string' || typeof value === 'number') return value; + + return safeStringify(value); +}; + +export const getRowsFromDiffs = ( + diffs: { [key: string]: DiffType }, + controlsMap: ControlMap, +): RowType[] => + Object.entries(diffs).map(([key, diff]: [string, DiffType]) => ({ + control: controlsMap[key]?.label || key, + before: formatValueHandler(diff.before, key, controlsMap), + after: formatValueHandler(diff.after, key, controlsMap), + })); diff --git a/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts b/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts new file mode 100644 index 0000000000..861686e7c4 --- /dev/null +++ b/superset-frontend/src/components/AlteredSliceTag/utils/utils.test.ts @@ -0,0 +1,291 @@ +/** + * 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 { alterForComparison, formatValueHandler, getRowsFromDiffs } from '.'; +import { RowType } from '../types'; + +describe('alterForComparison', () => { + it('returns null for undefined value', () => { + expect(alterForComparison(undefined)).toBeNull(); + }); + + it('returns null for null value', () => { + expect(alterForComparison(null)).toBeNull(); + }); + + it('returns null for empty string value', () => { + expect(alterForComparison('')).toBeNull(); + }); + + it('returns null for empty array value', () => { + expect(alterForComparison([])).toBeNull(); + }); + + it('returns null for empty object value', () => { + expect(alterForComparison({})).toBeNull(); + }); + + it('returns value for non-empty array', () => { + const value = [1, 2, 3]; + expect(alterForComparison(value)).toEqual(value); + }); + + it('returns value for non-empty object', () => { + const value = { key: 'value' }; + expect(alterForComparison(value)).toEqual(value); + }); +}); + +describe('formatValueHandler', () => { + const controlsMap = { + b: { type: 'BoundsControl', label: 'Bounds' }, + column_collection: { type: 'CollectionControl', label: 'Collection' }, + metrics: { type: 'MetricsControl', label: 'Metrics' }, + adhoc_filters: { type: 'AdhocFilterControl', label: 'Adhoc' }, + other_control: { type: 'OtherControl', label: 'Other' }, + }; + + it('handles undefined value', () => { + const value = undefined; + const key = 'b'; + + const formattedValue: string | number = formatValueHandler( + value, + key, + controlsMap, + ); + + expect(formattedValue).toBe('N/A'); + }); + + it('handles null value', () => { + const value = null; + const key = 'b'; + + const formattedValue: string | number = formatValueHandler( + value, + key, + controlsMap, + ); + + expect(formattedValue).toBe('null'); + }); + + it('returns "[]" for empty filters', () => { + const value: unknown[] = []; + const key = 'adhoc_filters'; + + const formattedValue: string | number = formatValueHandler( + value, + key, + controlsMap, + ); + + expect(formattedValue).toBe('[]'); + }); + + it('formats filters with array values', () => { + const filters = [ + { + clause: 'WHERE', + comparator: ['1', 'g', '7', 'ho'], + expressionType: 'SIMPLE', + operator: 'IN', + subject: 'a', + }, + { + clause: 'WHERE', + comparator: ['hu', 'ho', 'ha'], + expressionType: 'SIMPLE', + operator: 'NOT IN', + subject: 'b', + }, + ]; + const key = 'adhoc_filters'; + + const expected = 'a IN [1, g, 7, ho], b NOT IN [hu, ho, ha]'; + const formattedValue: string | number = formatValueHandler( + filters, + key, + controlsMap, + ); + + expect(formattedValue).toBe(expected); + }); + + it('formats filters with string values', () => { + const filters = [ + { + clause: 'WHERE', + comparator: 'gucci', + expressionType: 'SIMPLE', + operator: '==', + subject: 'a', + }, + { + clause: 'WHERE', + comparator: 'moshi moshi', + expressionType: 'SIMPLE', + operator: 'LIKE', + subject: 'b', + }, + ]; + + const key = 'adhoc_filters'; + const expected = 'a == gucci, b LIKE moshi moshi'; + const formattedValue: string | number = formatValueHandler( + filters, + key, + controlsMap, + ); + + expect(formattedValue).toBe(expected); + }); + + it('formats "Min" and "Max" for BoundsControl', () => { + const value: number[] = [1, 2]; + const key = 'b'; + + const result: string | number = formatValueHandler(value, key, controlsMap); + + expect(result).toEqual('Min: 1, Max: 2'); + }); + + it('formats stringified objects for CollectionControl', () => { + const value = [{ a: 1 }, { b: 2 }]; + const key = 'column_collection'; + + const result: string | number = formatValueHandler(value, key, controlsMap); + + expect(result).toEqual( + `${JSON.stringify(value[0])}, ${JSON.stringify(value[1])}`, + ); + }); + + it('formats MetricsControl values correctly', () => { + const value = [{ label: 'SUM(Sales)' }, { label: 'Metric2' }]; + const key = 'metrics'; + + const result: string | number = formatValueHandler(value, key, controlsMap); + + expect(result).toEqual('SUM(Sales), Metric2'); + }); + + it('formats boolean values as string', () => { + const value1 = true; + const value2 = false; + const key = 'b'; + + const formattedValue1: string | number = formatValueHandler( + value1, + key, + controlsMap, + ); + const formattedValue2: string | number = formatValueHandler( + value2, + key, + controlsMap, + ); + + expect(formattedValue1).toBe('true'); + expect(formattedValue2).toBe('false'); + }); + + it('formats array values correctly', () => { + const value = [ + { label: 'Label1' }, + { label: 'Label2' }, + 5, + 6, + 7, + 8, + 'hello', + 'goodbye', + ]; + + const expected = 'Label1, Label2, 5, 6, 7, 8, hello, goodbye'; + const result: string | number = formatValueHandler( + value, + undefined, + controlsMap, + ); + + expect(result).toEqual(expected); + }); + + it('formats string values correctly', () => { + const value = 'test'; + const key = 'other_control'; + + const result: string | number = formatValueHandler(value, key, controlsMap); + + expect(result).toEqual('test'); + }); + + it('formats number values correctly', () => { + const value = 123; + const key = 'other_control'; + + const result: string | number = formatValueHandler(value, key, controlsMap); + + expect(result).toEqual(123); + }); + + it('formats object values correctly', () => { + const value = { 1: 2, alpha: 'bravo' }; + const key = 'other_control'; + const expected = '{"1":2,"alpha":"bravo"}'; + + const result: string | number = formatValueHandler(value, key, controlsMap); + + expect(result).toEqual(expected); + }); +}); + +describe('getRowsFromDiffs', () => { + it('returns formatted rows for diffs', () => { + const diffs = { + metric: { before: [{ label: 'old' }], after: [{ label: 'new' }] }, + limit: { before: 10, after: 20 }, + }; + + const controlsMap = { + metric: { label: 'Metric', type: 'MetricsControl' }, + limit: { label: 'Row Limit', type: 'TextControl' }, + }; + + const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap); + + expect(rows).toEqual([ + { control: 'Metric', before: 'old', after: 'new' }, + { control: 'Row Limit', before: 10, after: 20 }, + ]); + }); + + it('falls back to key if label is missing', () => { + const diffs = { + unknown: { before: 'a', after: 'b' }, + }; + + const controlsMap = {}; + const rows: RowType[] = getRowsFromDiffs(diffs, controlsMap); + + expect(rows).toEqual([{ control: 'unknown', before: 'a', after: 'b' }]); + }); +}); diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/Header/Header.test.tsx index ac567ad6fc..36ee0c7696 100644 --- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx @@ -17,11 +17,13 @@ * under the License. */ import * as redux from 'redux'; +import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt'; import { render, screen, fireEvent, userEvent, + within, } from 'spec/helpers/testing-library'; import fetchMock from 'fetch-mock'; import { getExtensionsRegistry, JsonObject } from '@superset-ui/core'; @@ -166,6 +168,10 @@ const onRefresh = jest.fn(); const dashboardInfoChanged = jest.fn(); const dashboardTitleChanged = jest.fn(); +jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({ + useUnsavedChangesPrompt: jest.fn(), +})); + beforeAll(() => { jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({ addSuccessToast, @@ -195,9 +201,14 @@ beforeAll(() => { beforeEach(() => { jest.clearAllMocks(); -}); -beforeEach(() => { + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: false, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + }); + window.history.pushState({}, 'Test page', '/dashboard?standalone=1'); }); @@ -484,3 +495,91 @@ test('should render MetadataBar when not in edit mode and not embedded', () => { screen.getByText(state.dashboardInfo.changed_on_delta_humanized), ).toBeInTheDocument(); }); + +test('should show UnsavedChangesModal when there are unsaved changes and user tries to navigate', async () => { + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + }); + + setup({ ...editableState }); + + const modalTitle: HTMLElement = await screen.findByText( + 'Save changes to your dashboard?', + ); + + const modalBody: HTMLElement = await screen.findByText( + "If you don't save, changes will be lost.", + ); + + expect(modalTitle).toBeInTheDocument(); + expect(modalBody).toBeInTheDocument(); +}); + +test('should call handleSaveAndCloseModal when Save is clicked in UnsavedChangesModal', async () => { + const handleSaveAndCloseModal = jest.fn(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal, + }); + + setup({ ...editableState }); + + const modal: HTMLElement = await screen.findByRole('dialog'); + const saveButton: HTMLElement = within(modal).getByRole('button', { + name: /save/i, + }); + + userEvent.click(saveButton); + + expect(handleSaveAndCloseModal).toHaveBeenCalled(); +}); + +test('should call handleConfirmNavigation when user confirms navigation in UnsavedChangesModal', async () => { + const handleConfirmNavigation = jest.fn(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal: jest.fn(), + handleConfirmNavigation, + handleSaveAndCloseModal: jest.fn(), + }); + + setup({ ...editableState }); + + const modal: HTMLElement = await screen.findByRole('dialog'); + const discardButton: HTMLElement = within(modal).getByRole('button', { + name: /discard/i, + }); + + userEvent.click(discardButton); + + expect(handleConfirmNavigation).toHaveBeenCalled(); +}); + +test('should call setShowUnsavedChangesModal(false) on cancel', async () => { + const setShowModal = jest.fn(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal, + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + }); + + setup({ ...editableState }); + + const modal: HTMLElement = await screen.findByRole('dialog'); + const closeButton: HTMLElement = within(modal).getByRole('button', { + name: /close/i, + }); + + userEvent.click(closeButton); + + expect(setShowModal).toHaveBeenCalledWith(false); +}); diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index b677a8c5ec..f4535c4de3 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -36,7 +36,12 @@ import { LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD, } from 'src/logger/LogUtils'; import { Icons } from '@superset-ui/core/components/Icons'; -import { Button, Tooltip, DeleteModal } from '@superset-ui/core/components'; +import { + Button, + Tooltip, + DeleteModal, + UnsavedChangesModal, +} from '@superset-ui/core/components'; import { findPermission } from 'src/utils/findPermission'; import { safeStringify } from 'src/utils/safeStringify'; import PublishedStatus from 'src/dashboard/components/PublishedStatus'; @@ -54,6 +59,7 @@ import setPeriodicRunner, { import ReportModal from 'src/features/reports/ReportModal'; import { deleteActiveReport } from 'src/features/reports/ReportModal/actions'; import { PageHeaderWithActions } from '@superset-ui/core/components/PageHeaderWithActions'; +import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt'; import DashboardEmbedModal from '../EmbeddedModal'; import OverwriteConfirm from '../OverwriteConfirm'; import { @@ -460,6 +466,16 @@ const Header = () => { slug, ]); + const { + showModal: showUnsavedChangesModal, + setShowModal: setShowUnsavedChangesModal, + handleConfirmNavigation, + handleSaveAndCloseModal, + } = useUnsavedChangesPrompt({ + hasUnsavedChanges, + onSave: overwriteDashboard, + }); + const showPropertiesModal = useCallback(() => { setShowingPropertiesModal(true); }, []); @@ -816,6 +832,15 @@ const Header = () => { } `} /> + + <UnsavedChangesModal + title={t('Save changes to your dashboard?')} + body={t("If you don't save, changes will be lost.")} + showModal={showUnsavedChangesModal} + onHide={() => setShowUnsavedChangesModal(false)} + onConfirmNavigation={handleConfirmNavigation} + handleSave={handleSaveAndCloseModal} + /> </div> ); }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx index 4f993d0158..886aee28d6 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx @@ -305,12 +305,19 @@ test.skip('validates the default value', async () => { }); test('validates the pre-filter value', async () => { + jest.useFakeTimers(); + defaultRender(); + userEvent.click(screen.getByText(FILTER_SETTINGS_REGEX)); userEvent.click(getCheckbox(PRE_FILTER_REGEX)); - expect( - await screen.findByText(PRE_FILTER_REQUIRED_REGEX), - ).toBeInTheDocument(); + + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + + await waitFor(() => { + expect(screen.getByText(PRE_FILTER_REQUIRED_REGEX)).toBeInTheDocument(); + }); }); // eslint-disable-next-line jest/no-disabled-tests diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx index 9888493cf9..6f083fc9bf 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/ExploreChartHeader.test.tsx @@ -23,6 +23,7 @@ import { screen, userEvent, waitFor, + within, } from 'spec/helpers/testing-library'; import fetchMock from 'fetch-mock'; import * as chartAction from 'src/components/Chart/chartAction'; @@ -30,6 +31,7 @@ import * as saveModalActions from 'src/explore/actions/saveModalActions'; import * as downloadAsImage from 'src/utils/downloadAsImage'; import * as exploreUtils from 'src/explore/exploreUtils'; import { FeatureFlag, VizType } from '@superset-ui/core'; +import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt'; import ExploreHeader from '.'; const chartEndpoint = 'glob:*api/v1/chart/*'; @@ -40,6 +42,10 @@ window.featureFlags = { [FeatureFlag.EmbeddableCharts]: true, }; +jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({ + useUnsavedChangesPrompt: jest.fn(), +})); + const createProps = (additionalProps = {}) => ({ chart: { id: 1, @@ -134,6 +140,18 @@ fetchMock.post( describe('ExploreChartHeader', () => { jest.setTimeout(15000); // ✅ Applies to all tests in this suite + beforeEach(() => { + jest.clearAllMocks(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: false, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + triggerManualSave: jest.fn(), + }); + }); + test('Cancelling changes to the properties should reset previous properties', async () => { const props = createProps(); render(<ExploreHeader {...props} />, { useRedux: true }); @@ -201,35 +219,173 @@ describe('ExploreChartHeader', () => { }); test('Save chart', async () => { - const setSaveChartModalVisibility = jest.spyOn( + const setSaveChartModalVisibilitySpy = jest.spyOn( saveModalActions, 'setSaveChartModalVisibility', ); + + const setSaveChartModalVisibilityMock = + setSaveChartModalVisibilitySpy as jest.Mock; + + const triggerManualSave = jest.fn(() => { + setSaveChartModalVisibilityMock(true); + }); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: false, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + triggerManualSave, + }); + const props = createProps(); render(<ExploreHeader {...props} />, { useRedux: true }); - expect(await screen.findByText('Save')).toBeInTheDocument(); - userEvent.click(screen.getByText('Save')); - expect(setSaveChartModalVisibility).toHaveBeenCalledWith(true); - setSaveChartModalVisibility.mockClear(); + + const saveButton: HTMLElement = await screen.findByRole('button', { + name: /save/i, + }); + + userEvent.click(saveButton); + + expect(triggerManualSave).toHaveBeenCalled(); + expect(setSaveChartModalVisibilityMock).toHaveBeenCalledWith(true); + + setSaveChartModalVisibilityMock.mockClear(); }); test('Save disabled', async () => { - const setSaveChartModalVisibility = jest.spyOn( - saveModalActions, - 'setSaveChartModalVisibility', - ); + const triggerManualSave = jest.fn(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: false, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + triggerManualSave, + }); + const props = createProps(); render(<ExploreHeader {...props} saveDisabled />, { useRedux: true }); - expect(await screen.findByText('Save')).toBeInTheDocument(); - userEvent.click(screen.getByText('Save')); - expect(setSaveChartModalVisibility).not.toHaveBeenCalled(); - setSaveChartModalVisibility.mockClear(); + + const saveButton: HTMLElement = await screen.findByRole('button', { + name: /save/i, + }); + + expect(saveButton).toBeDisabled(); + + userEvent.click(saveButton); + + expect(triggerManualSave).not.toHaveBeenCalled(); + }); + + test('should render UnsavedChangesModal when showModal is true', async () => { + const props = createProps(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + triggerManualSave: jest.fn(), + }); + + render(<ExploreHeader {...props} />, { useRedux: true }); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect( + await screen.findByText('Save changes to your chart?'), + ).toBeInTheDocument(); + expect( + await screen.findByText("If you don't save, changes will be lost."), + ).toBeInTheDocument(); + }); + + test('should call handleSaveAndCloseModal when clicking Save in UnsavedChangesModal', async () => { + const handleSaveAndCloseModal = jest.fn(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal, + triggerManualSave: jest.fn(), + }); + + const props = createProps(); + render(<ExploreHeader {...props} />, { useRedux: true }); + + const modal: HTMLElement = await screen.findByRole('dialog'); + const saveButton: HTMLElement = within(modal).getByRole('button', { + name: /save/i, + }); + + userEvent.click(saveButton); + + expect(handleSaveAndCloseModal).toHaveBeenCalled(); + }); + + test('should call handleConfirmNavigation when clicking Discard in UnsavedChangesModal', async () => { + const handleConfirmNavigation = jest.fn(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal: jest.fn(), + handleConfirmNavigation, + handleSaveAndCloseModal: jest.fn(), + triggerManualSave: jest.fn(), + }); + + const props = createProps(); + render(<ExploreHeader {...props} />, { useRedux: true }); + + const modal: HTMLElement = await screen.findByRole('dialog'); + const discardButton: HTMLElement = within(modal).getByRole('button', { + name: /discard/i, + }); + + userEvent.click(discardButton); + + expect(handleConfirmNavigation).toHaveBeenCalled(); + }); + + test('should call setShowModal(false) when clicking close button in UnsavedChangesModal', async () => { + const setShowModal = jest.fn(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: true, + setShowModal, + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + triggerManualSave: jest.fn(), + }); + + const props = createProps(); + render(<ExploreHeader {...props} />, { useRedux: true }); + + const closeButton: HTMLElement = await screen.findByRole('button', { + name: /close/i, + }); + + userEvent.click(closeButton); + + expect(setShowModal).toHaveBeenCalledWith(false); }); }); describe('Additional actions tests', () => { jest.setTimeout(15000); // ✅ Applies to all tests in this suite + beforeEach(() => { + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: false, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + triggerManualSave: jest.fn(), + }); + }); + test('Should render a button', async () => { const props = createProps(); render(<ExploreHeader {...props} />, { useRedux: true }); @@ -356,6 +512,14 @@ describe('Additional actions tests', () => { beforeEach(() => { spyDownloadAsImage = sinon.spy(downloadAsImage, 'default'); spyExportChart = sinon.spy(exploreUtils, 'exportChart'); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: false, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + triggerManualSave: jest.fn(), + }); }); afterEach(async () => { diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx index 2492d94a2e..f90f37c49c 100644 --- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx +++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx @@ -16,11 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; -import { Tooltip, Button, DeleteModal } from '@superset-ui/core/components'; +import { + Tooltip, + Button, + DeleteModal, + UnsavedChangesModal, +} from '@superset-ui/core/components'; import { AlteredSliceTag } from 'src/components'; import { css, logging, SupersetClient, t } from '@superset-ui/core'; import { chartPropShape } from 'src/dashboard/util/propShapes'; @@ -32,6 +37,8 @@ import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalAction import { applyColors, resetColors } from 'src/utils/colorScheme'; import ReportModal from 'src/features/reports/ReportModal'; import { deleteActiveReport } from 'src/features/reports/ReportModal/actions'; +import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt'; +import { getChartFormDiffs } from 'src/utils/getChartFormDiffs'; import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu'; import { useExploreMetadataBar } from './useExploreMetadataBar'; @@ -50,6 +57,7 @@ const propTypes = { timeout: PropTypes.number, chart: chartPropShape, saveDisabled: PropTypes.bool, + isSaveModalVisible: PropTypes.bool, }; const saveButtonStyles = theme => css` @@ -83,13 +91,16 @@ export const ExploreChartHeader = ({ sliceName, saveDisabled, metadata, + isSaveModalVisible, }) => { const dispatch = useDispatch(); const { latestQueryFormData, sliceFormData } = chart; const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false); const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [currentReportDeleting, setCurrentReportDeleting] = useState(null); - const updateCategoricalNamespace = async () => { + const [shouldForceCloseModal, setShouldForceCloseModal] = useState(false); + + const updateCategoricalNamespace = useCallback(async () => { const { dashboards } = metadata || {}; const dashboard = dashboardId && dashboards && dashboards.find(d => d.id === dashboardId); @@ -117,11 +128,11 @@ export const ExploreChartHeader = ({ logging.info(t('Unable to retrieve dashboard colors')); } } - }; + }, [dashboardColorScheme, dashboardId, metadata]); useEffect(() => { updateCategoricalNamespace(); - }, []); + }, [updateCategoricalNamespace]); const openPropertiesModal = () => { setIsPropertiesModalOpen(true); @@ -139,10 +150,6 @@ export const ExploreChartHeader = ({ setIsReportModalOpen(false); }; - const showModal = useCallback(() => { - dispatch(setSaveChartModalVisibility(true)); - }, [dispatch]); - const updateSlice = useCallback( slice => { dispatch(sliceUpdated(slice)); @@ -179,8 +186,53 @@ export const ExploreChartHeader = ({ ); const metadataBar = useExploreMetadataBar(metadata, slice); - const oldSliceName = slice?.slice_name; + + const originalFormData = useMemo(() => { + if (!sliceFormData) return {}; + return { + ...sliceFormData, + chartTitle: oldSliceName, + }; + }, [sliceFormData, oldSliceName]); + + const currentFormData = useMemo( + () => ({ ...formData, chartTitle: sliceName }), + [formData, sliceName], + ); + + const formDiffs = useMemo( + () => getChartFormDiffs(originalFormData, currentFormData), + [originalFormData, currentFormData], + ); + + const { + showModal: showUnsavedChangesModal, + setShowModal: setShowUnsavedChangesModal, + handleConfirmNavigation, + handleSaveAndCloseModal, + triggerManualSave, + } = useUnsavedChangesPrompt({ + hasUnsavedChanges: Object.keys(formDiffs).length > 0, + onSave: () => dispatch(setSaveChartModalVisibility(true)), + isSaveModalVisible, + manualSaveOnUnsavedChanges: true, + }); + + const showModal = useCallback(() => { + triggerManualSave(); + }, [triggerManualSave]); + + useEffect(() => { + if (!isSaveModalVisible) setShouldForceCloseModal(true); + }, [isSaveModalVisible, setShowUnsavedChangesModal]); + + useEffect(() => { + if (!showUnsavedChangesModal && shouldForceCloseModal) { + setShouldForceCloseModal(false); + } + }, [showUnsavedChangesModal, shouldForceCloseModal]); + return ( <> <PageHeaderWithActions @@ -212,11 +264,9 @@ export const ExploreChartHeader = ({ {sliceFormData ? ( <AlteredSliceTag className="altered" - origFormData={{ - ...sliceFormData, - chartTitle: oldSliceName, - }} - currentFormData={{ ...formData, chartTitle: sliceName }} + diffs={formDiffs} + origFormData={originalFormData} + currentFormData={currentFormData} /> ) : null} {metadataBar} @@ -286,6 +336,15 @@ export const ExploreChartHeader = ({ title={t('Delete Report?')} /> )} + + <UnsavedChangesModal + title={t('Save changes to your chart?')} + body={t("If you don't save, changes will be lost.")} + showModal={showUnsavedChangesModal} + onHide={() => setShowUnsavedChangesModal(false)} + onConfirmNavigation={handleConfirmNavigation} + handleSave={handleSaveAndCloseModal} + /> </> ); }; diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 497cb48730..6f4b01c2cb 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -593,6 +593,7 @@ function ExploreViewContainer(props) { reports={props.reports} saveDisabled={errorMessage || props.chart.chartStatus === 'loading'} metadata={props.metadata} + isSaveModalVisible={props.isSaveModalVisible} /> <ExplorePanelContainer id="explore-container"> <Global diff --git a/superset-frontend/src/explore/exploreUtils/formData.test.ts b/superset-frontend/src/explore/exploreUtils/formData.test.ts index f14455b51b..c61b437037 100644 --- a/superset-frontend/src/explore/exploreUtils/formData.test.ts +++ b/superset-frontend/src/explore/exploreUtils/formData.test.ts @@ -16,13 +16,83 @@ * specific language governing permissions and limitations * under the License. */ -import { sanitizeFormData } from './formData'; - -test('sanitizeFormData removes temporary control values', () => { - expect( - sanitizeFormData({ - url_params: { foo: 'bar' }, - metrics: ['foo', 'bar'], - }), - ).toEqual({ metrics: ['foo', 'bar'] }); +import { SupersetClient } from '@superset-ui/core'; +import { postFormData, putFormData } from './formData'; + +jest.mock('@superset-ui/core', () => ({ + SupersetClient: { + post: jest.fn(), + put: jest.fn(), + }, +})); + +test('postFormData should call SupersetClient.post with correct payload and return key', async () => { + const mockKey = '123abc'; + const mockResponse = { json: { key: mockKey } }; + + (SupersetClient.post as jest.Mock).mockResolvedValue(mockResponse); + + const result = await postFormData( + 1, + 'table', + { some: 'form', data: true }, + 42, + 'tab-7', + ); + + expect(SupersetClient.post).toHaveBeenCalledWith({ + endpoint: 'api/v1/explore/form_data?tab_id=tab-7', + jsonPayload: { + datasource_id: 1, + datasource_type: 'table', + form_data: JSON.stringify({ some: 'form', data: true }), // assuming sanitizeFormData is a passthrough + chart_id: 42, + }, + }); + + expect(result).toBe(mockKey); +}); + +test('putFormData should call SupersetClient.put with correct payload and return message', async () => { + const mockMessage = 'Form data updated'; + const mockResponse = { json: { message: mockMessage } }; + + (SupersetClient.put as jest.Mock).mockResolvedValue(mockResponse); + + const result = await putFormData( + 10, + 'druid', + 'abc123', + { another: 'value' }, + undefined, + 'tab-5', + ); + + expect(SupersetClient.put).toHaveBeenCalledWith({ + endpoint: 'api/v1/explore/form_data/abc123?tab_id=tab-5', + jsonPayload: { + datasource_id: 10, + datasource_type: 'druid', + form_data: JSON.stringify({ another: 'value' }), // again, assuming sanitizeFormData is passthrough + }, + }); + + expect(result).toBe(mockMessage); +}); + +test('postFormData without optional params should work', async () => { + (SupersetClient.post as jest.Mock).mockResolvedValue({ + json: { key: 'abc' }, + }); + + await postFormData(2, 'table', { foo: 'bar' }); + + expect(SupersetClient.post).toHaveBeenCalledWith({ + endpoint: 'api/v1/explore/form_data', + jsonPayload: { + datasource_id: 2, + datasource_type: 'table', + form_data: JSON.stringify({ foo: 'bar' }), + }, + }); }); diff --git a/superset-frontend/src/explore/exploreUtils/formData.ts b/superset-frontend/src/explore/exploreUtils/formData.ts index 36de6640a5..9a83d8fd8d 100644 --- a/superset-frontend/src/explore/exploreUtils/formData.ts +++ b/superset-frontend/src/explore/exploreUtils/formData.ts @@ -16,8 +16,8 @@ * specific language governing permissions and limitations * under the License. */ -import { omit } from 'lodash'; -import { SupersetClient, JsonObject } from '@superset-ui/core'; +import { SupersetClient, JsonObject, JsonResponse } from '@superset-ui/core'; +import { sanitizeFormData } from 'src/utils/sanitizeFormData'; type Payload = { datasource_id: number; @@ -26,19 +26,12 @@ type Payload = { chart_id?: number; }; -const TEMPORARY_CONTROLS = ['url_params']; - -export const sanitizeFormData = (formData: JsonObject): JsonObject => - omit(formData, TEMPORARY_CONTROLS); - const assembleEndpoint = (key?: string, tabId?: string) => { let endpoint = 'api/v1/explore/form_data'; - if (key) { - endpoint = endpoint.concat(`/${key}`); - } - if (tabId) { - endpoint = endpoint.concat(`?tab_id=${tabId}`); - } + + if (key) endpoint = endpoint.concat(`/${key}`); + if (tabId) endpoint = endpoint.concat(`?tab_id=${tabId}`); + return endpoint; }; @@ -47,15 +40,15 @@ const assemblePayload = ( datasourceType: string, formData: JsonObject, chartId?: number, -) => { +): Payload => { const payload: Payload = { datasource_id: datasourceId, datasource_type: datasourceType, form_data: JSON.stringify(sanitizeFormData(formData)), }; - if (chartId) { - payload.chart_id = chartId; - } + + if (chartId) payload.chart_id = chartId; + return payload; }; @@ -74,7 +67,7 @@ export const postFormData = ( formData, chartId, ), - }).then(r => r.json.key); + }).then((r: JsonResponse) => r.json.key); export const putFormData = ( datasourceId: number, @@ -92,4 +85,4 @@ export const putFormData = ( formData, chartId, ), - }).then(r => r.json.message); + }).then((r: JsonResponse) => r.json.message); diff --git a/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts new file mode 100644 index 0000000000..b09adece6a --- /dev/null +++ b/superset-frontend/src/hooks/useUnsavedChangesPrompt/index.ts @@ -0,0 +1,124 @@ +/** + * 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 { getClientErrorObject, t } from '@superset-ui/core'; +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useHistory } from 'react-router-dom'; + +type UseUnsavedChangesPromptProps = { + hasUnsavedChanges: boolean; + onSave: () => Promise<void> | void; + isSaveModalVisible?: boolean; + manualSaveOnUnsavedChanges?: boolean; +}; + +export const useUnsavedChangesPrompt = ({ + hasUnsavedChanges, + onSave, + isSaveModalVisible = false, + manualSaveOnUnsavedChanges = false, +}: UseUnsavedChangesPromptProps) => { + const history = useHistory(); + const [showModal, setShowModal] = useState(false); + + const confirmNavigationRef = useRef<(() => void) | null>(null); + const unblockRef = useRef<() => void>(() => {}); + const manualSaveRef = useRef(false); // Track if save was user-initiated (not via navigation) + + const handleConfirmNavigation = useCallback(() => { + confirmNavigationRef.current?.(); + }, []); + + const handleSaveAndCloseModal = useCallback(async () => { + try { + if (manualSaveOnUnsavedChanges) manualSaveRef.current = true; + + await onSave(); + setShowModal(false); + } catch (err) { + const clientError = await getClientErrorObject(err); + throw new Error( + clientError.message || + clientError.error || + t('Sorry, an error occurred'), + { cause: err }, + ); + } + }, [manualSaveOnUnsavedChanges, onSave]); + + const triggerManualSave = useCallback(() => { + manualSaveRef.current = true; + onSave(); + }, [onSave]); + + const blockCallback = useCallback( + ({ pathname }: { pathname: string }) => { + if (manualSaveRef.current) { + manualSaveRef.current = false; + return undefined; + } + + confirmNavigationRef.current = () => { + unblockRef.current?.(); + history.push(pathname); + }; + + setShowModal(true); + return false; + }, + [history], + ); + + useEffect(() => { + if (!hasUnsavedChanges) return undefined; + + const unblock = history.block(blockCallback); + unblockRef.current = unblock; + + return () => unblock(); + }, [blockCallback, hasUnsavedChanges, history]); + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasUnsavedChanges) return; + event.preventDefault(); + + // Most browsers require a "returnValue" set to empty string + const evt = event as any; + evt.returnValue = ''; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [hasUnsavedChanges]); + + useEffect(() => { + if (!isSaveModalVisible && manualSaveRef.current) { + setShowModal(false); + manualSaveRef.current = false; + } + }, [isSaveModalVisible]); + + return { + showModal, + setShowModal, + handleConfirmNavigation, + handleSaveAndCloseModal, + triggerManualSave, + }; +}; diff --git a/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx new file mode 100644 index 0000000000..bc42cdcfc9 --- /dev/null +++ b/superset-frontend/src/hooks/useUnsavedChangesPrompt/useUnsavedChangesPrompt.test.tsx @@ -0,0 +1,106 @@ +/** + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import { act } from 'spec/helpers/testing-library'; + +const history = createMemoryHistory({ + initialEntries: ['/dashboard'], +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + <Router history={history}>{children}</Router> +); + +describe('useUnsavedChangesPrompt', () => { + it('should not show modal initially', () => { + const { result } = renderHook( + () => + useUnsavedChangesPrompt({ + hasUnsavedChanges: true, + onSave: jest.fn(), + }), + { wrapper }, + ); + + expect(result.current.showModal).toBe(false); + }); + + it('should block navigation and show modal if there are unsaved changes', () => { + const { result } = renderHook( + () => + useUnsavedChangesPrompt({ + hasUnsavedChanges: true, + onSave: jest.fn(), + }), + { wrapper }, + ); + + // Simulate blocked navigation + act(() => { + const unblock = history.block((tx: any) => tx); + unblock(); + history.push('/another-page'); + }); + + expect(result.current.showModal).toBe(true); + }); + + it('should trigger onSave and hide modal on handleSaveAndCloseModal', async () => { + const onSave = jest.fn().mockResolvedValue(undefined); + + const { result } = renderHook( + () => + useUnsavedChangesPrompt({ + hasUnsavedChanges: true, + onSave, + }), + { wrapper }, + ); + + await act(async () => { + await result.current.handleSaveAndCloseModal(); + }); + + expect(onSave).toHaveBeenCalled(); + expect(result.current.showModal).toBe(false); + }); + + it('should trigger manual save and not show modal again', async () => { + const onSave = jest.fn().mockResolvedValue(undefined); + + const { result } = renderHook( + () => + useUnsavedChangesPrompt({ + hasUnsavedChanges: true, + onSave, + }), + { wrapper }, + ); + + act(() => { + result.current.triggerManualSave(); + }); + + expect(onSave).toHaveBeenCalled(); + expect(result.current.showModal).toBe(false); + }); +}); diff --git a/superset-frontend/src/pages/Chart/Chart.test.tsx b/superset-frontend/src/pages/Chart/Chart.test.tsx index 2fe7599211..6b0e9f820a 100644 --- a/superset-frontend/src/pages/Chart/Chart.test.tsx +++ b/superset-frontend/src/pages/Chart/Chart.test.tsx @@ -30,9 +30,12 @@ import { LocalStorageKeys } from 'src/utils/localStorageHelpers'; import getFormDataWithExtraFilters from 'src/dashboard/util/charts/getFormDataWithExtraFilters'; import { URL_PARAMS } from 'src/constants'; import { JsonObject, VizType } from '@superset-ui/core'; - +import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt'; import ChartPage from '.'; +jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({ + useUnsavedChangesPrompt: jest.fn(), +})); jest.mock('re-resizable', () => ({ Resizable: () => <div data-test="mock-re-resizable" />, })); @@ -48,6 +51,17 @@ jest.mock( jest.mock('src/dashboard/util/charts/getFormDataWithExtraFilters'); describe('ChartPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useUnsavedChangesPrompt as jest.Mock).mockReturnValue({ + showModal: false, + setShowModal: jest.fn(), + handleConfirmNavigation: jest.fn(), + handleSaveAndCloseModal: jest.fn(), + }); + }); + afterEach(() => { fetchMock.reset(); }); diff --git a/superset-frontend/src/components/AlteredSliceTag/types.ts b/superset-frontend/src/types/DiffType.ts similarity index 77% copy from superset-frontend/src/components/AlteredSliceTag/types.ts copy to superset-frontend/src/types/DiffType.ts index e1f6108608..e146df35be 100644 --- a/superset-frontend/src/components/AlteredSliceTag/types.ts +++ b/superset-frontend/src/types/DiffType.ts @@ -16,20 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import type { QueryFormData } from '@superset-ui/core'; - -export interface AlteredSliceTagProps { - origFormData: QueryFormData; - currentFormData: QueryFormData; -} - -export interface ControlMap { - [key: string]: { - label?: string; - type?: string; - }; -} - export type FilterItemType = { comparator?: string | string[]; subject: string; @@ -52,9 +38,3 @@ export type DiffType = { before: DiffItemType; after: DiffItemType; }; - -export type RowType = { - before: string | number; - after: string | number; - control: string; -}; diff --git a/superset-frontend/src/utils/getChartFormDiffs/getChartFormDiffs.test.ts b/superset-frontend/src/utils/getChartFormDiffs/getChartFormDiffs.test.ts new file mode 100644 index 0000000000..6b7680ec1c --- /dev/null +++ b/superset-frontend/src/utils/getChartFormDiffs/getChartFormDiffs.test.ts @@ -0,0 +1,115 @@ +/** + * 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 { JsonObject } from '@superset-ui/core'; +import { alterForComparison, getChartFormDiffs, isEqualish } from '.'; + +jest.mock('../sanitizeFormData', () => ({ + sanitizeFormData: (fd: JsonObject): JsonObject => ({ + ...fd, + _sanitized: true, + }), +})); + +describe('alterForComparison', () => { + it.each([ + [null, null], + ['', null], + [[], null], + [{}, null], + [ + [1, 2], + [1, 2], + ], + [{ a: 1 }, { a: 1 }], + ['foo', 'foo'], + ])('normalizes %p to %p', (input, expected) => { + expect(alterForComparison(input)).toEqual(expected); + }); +}); + +describe('isEqualish', () => { + it('returns true for semantically equal values with different formats', () => { + expect(isEqualish('', null)).toBe(true); + expect(isEqualish([], null)).toBe(true); + expect(isEqualish({}, null)).toBe(true); + expect(isEqualish([1], [1])).toBe(true); + }); + + it('returns false for clearly different values', () => { + expect(isEqualish([1], [2])).toBe(false); + expect(isEqualish({ a: 1 }, { a: 2 })).toBe(false); + expect(isEqualish('foo', 'bar')).toBe(false); + }); +}); + +describe('getChartFormDiffs', () => { + it('returns diffs for changed values', () => { + const original = { metric: 'count', adhoc_filters: [] }; + const current = { metric: 'sum__num', adhoc_filters: [] }; + + const diffs = getChartFormDiffs(original, current); + + expect(diffs).toHaveProperty('metric'); + expect(diffs.metric).toEqual({ + before: 'count', + after: 'sum__num', + }); + }); + + it('ignores noisy keys', () => { + const original = { where: 'a = 1', metric: 'count' }; + const current = { where: 'a = 2', metric: 'sum__num' }; + + const diffs = getChartFormDiffs(original, current); + + expect(diffs).not.toHaveProperty('where'); + expect(diffs).toHaveProperty('metric'); + }); + + it('does not include values that are equalish', () => { + const original = { filters: [], metric: 'count' }; + const current = { filters: null, metric: 'count' }; + + const diffs = getChartFormDiffs(original, current); + + expect(diffs).toEqual({}); + }); + + it('handles missing keys in original or current gracefully', () => { + const original = { metric: 'count' }; + const current = { metric: 'count', new_field: 'value' }; + + const diffs = getChartFormDiffs(original, current); + + expect(diffs).toHaveProperty('new_field'); + expect(diffs.new_field).toEqual({ + before: undefined, + after: 'value', + }); + }); + + it('ignores keys that are missing in current and not explicitly changed', () => { + const original = { metric: 'count', removed_field: 'gone' }; + const current = { metric: 'count' }; + + const diffs = getChartFormDiffs(original, current); + + expect(diffs).not.toHaveProperty('removed_field'); + }); +}); diff --git a/superset-frontend/src/utils/getChartFormDiffs/index.ts b/superset-frontend/src/utils/getChartFormDiffs/index.ts new file mode 100644 index 0000000000..6c89b4fef3 --- /dev/null +++ b/superset-frontend/src/utils/getChartFormDiffs/index.ts @@ -0,0 +1,66 @@ +/** + * 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 { isEqual } from 'lodash'; +import { DiffType } from 'src/types/DiffType'; +import { JsonObject } from '@superset-ui/core'; +import { sanitizeFormData } from '../sanitizeFormData'; + +export const noisyKeys = new Set(['filters', 'having', 'where']); + +export const alterForComparison = (value: unknown): unknown => { + if (value == null || value === '') return null; + if (Array.isArray(value) && value.length === 0) return null; + if (typeof value === 'object' && value && Object.keys(value).length === 0) + return null; + + return value; +}; + +export const isEqualish = (a: unknown, b: unknown): boolean => + isEqual(alterForComparison(a), alterForComparison(b)); + +export const getChartFormDiffs = ( + originalFormData: Record<string, unknown>, + currentFormData: Record<string, unknown>, +): Record<string, DiffType> => { + const ofd: JsonObject = sanitizeFormData(originalFormData); + const cfd: JsonObject = sanitizeFormData(currentFormData); + + const keys = new Set([...Object.keys(ofd), ...Object.keys(cfd)]); + const diffs: Record<string, DiffType> = {}; + + keys.forEach((key: string) => { + if (noisyKeys.has(key)) return; + + const original = ofd[key]; + const current = cfd[key]; + + const currentHasKey = Object.prototype.hasOwnProperty.call(cfd, key); + const originalHasKey = Object.prototype.hasOwnProperty.call(ofd, key); + + const bothExplicit = currentHasKey && originalHasKey; + + if (!bothExplicit && !currentHasKey) return; + + if (!isEqualish(original, current)) + diffs[key] = { before: original, after: current }; + }); + + return diffs; +}; diff --git a/superset-frontend/src/explore/exploreUtils/formData.test.ts b/superset-frontend/src/utils/sanitizeFormData/index.ts similarity index 75% copy from superset-frontend/src/explore/exploreUtils/formData.test.ts copy to superset-frontend/src/utils/sanitizeFormData/index.ts index f14455b51b..9c55cfc6cf 100644 --- a/superset-frontend/src/explore/exploreUtils/formData.test.ts +++ b/superset-frontend/src/utils/sanitizeFormData/index.ts @@ -16,13 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { sanitizeFormData } from './formData'; +import { JsonObject } from '@superset-ui/core'; +import { omit } from 'lodash'; -test('sanitizeFormData removes temporary control values', () => { - expect( - sanitizeFormData({ - url_params: { foo: 'bar' }, - metrics: ['foo', 'bar'], - }), - ).toEqual({ metrics: ['foo', 'bar'] }); -}); +const TEMPORARY_CONTROLS: string[] = ['url_params']; + +export const sanitizeFormData = (formData: JsonObject): JsonObject => + omit(formData, TEMPORARY_CONTROLS); diff --git a/superset-frontend/src/explore/exploreUtils/formData.test.ts b/superset-frontend/src/utils/sanitizeFormData/sanitizeFormData.test.ts similarity index 95% copy from superset-frontend/src/explore/exploreUtils/formData.test.ts copy to superset-frontend/src/utils/sanitizeFormData/sanitizeFormData.test.ts index f14455b51b..98da5c8698 100644 --- a/superset-frontend/src/explore/exploreUtils/formData.test.ts +++ b/superset-frontend/src/utils/sanitizeFormData/sanitizeFormData.test.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { sanitizeFormData } from './formData'; +import { sanitizeFormData } from '.'; test('sanitizeFormData removes temporary control values', () => { expect(