diff --git a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx index e0753934628..d3f552f4865 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx +++ b/superset-frontend/spec/javascripts/dashboard/components/nativeFilters/NativeFiltersModal_spec.tsx @@ -19,7 +19,9 @@ import React from 'react'; import { styledMount as mount } from 'spec/helpers/theming'; import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; import { Provider } from 'react-redux'; +import Alert from 'react-bootstrap/lib/Alert'; import { FilterConfigModal } from 'src/dashboard/components/nativeFilters/FilterConfigModal'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import { mockStore } from 'spec/fixtures/mockStore'; @@ -74,4 +76,51 @@ describe('FiltersConfigModal', () => { await waitForComponentToPaint(wrapper); expect(onSave.mock.calls).toHaveLength(0); }); + + describe('when click cancel', () => { + let onCancel: jest.Mock; + let wrapper: ReactWrapper; + + beforeEach(() => { + onCancel = jest.fn(); + wrapper = setup({ onCancel, createNewOnOpen: false }); + }); + + async function clickCancel() { + act(() => { + wrapper.find('.ant-modal-footer button').at(0).simulate('click'); + }); + await waitForComponentToPaint(wrapper); + } + + function addFilter() { + act(() => { + wrapper.find('button[aria-label="Add tab"]').at(0).simulate('click'); + }); + } + + it('does not show alert when there is no unsaved filters', async () => { + await clickCancel(); + expect(onCancel.mock.calls).toHaveLength(1); + }); + + it('shows correct alert message for an unsaved filter', async () => { + addFilter(); + await clickCancel(); + expect(onCancel.mock.calls).toHaveLength(0); + expect(wrapper.find(Alert).text()).toContain( + 'Are you sure you want to cancel? "New Filter" will not be saved.', + ); + }); + + it('shows correct alert message for 2 unsaved filters', async () => { + addFilter(); + addFilter(); + await clickCancel(); + expect(onCancel.mock.calls).toHaveLength(0); + expect(wrapper.find(Alert).text()).toContain( + 'Are you sure you want to cancel? "New Filter" and "New Filter" will not be saved.', + ); + }); + }); }); diff --git a/superset-frontend/src/dashboard/components/nativeFilters/CancelConfirmationAlert.tsx b/superset-frontend/src/dashboard/components/nativeFilters/CancelConfirmationAlert.tsx new file mode 100644 index 00000000000..96d1307f30b --- /dev/null +++ b/superset-frontend/src/dashboard/components/nativeFilters/CancelConfirmationAlert.tsx @@ -0,0 +1,105 @@ +/** + * 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 React from 'react'; +import { styled, t } from '@superset-ui/core'; +import Alert from 'react-bootstrap/lib/Alert'; +import Button from 'src/components/Button'; +import Icon from 'src/components/Icon'; + +const StyledAlert = styled(Alert)` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: ${({ theme }) => theme.gridUnit * 2}px; + padding: ${({ theme }) => theme.gridUnit * 2}px; +`; + +const StyledTextContainer = styled.div` + display: flex; + flex-direction: column; + text-align: left; + margin-right: ${({ theme }) => theme.gridUnit}px; +`; + +const StyledTitleBox = styled.div` + display: flex; + align-items: center; +`; + +const StyledAlertTitle = styled.span` + font-weight: ${({ theme }) => theme.typography.weights.bold}; +`; + +const StyledAlertText = styled.p` + margin-left: ${({ theme }) => theme.gridUnit * 9}px; +`; + +const StyledButtonsContainer = styled.div` + display: flex; + flex-direction: row; +`; + +const StyledAlertIcon = styled(Icon)` + color: ${({ theme }) => theme.colors.alert.base}; + margin-right: ${({ theme }) => theme.gridUnit * 3}px; +`; + +export interface ConfirmationAlertProps { + title: string; + children: React.ReactNode; + onConfirm: () => void; + onDismiss: () => void; +} + +export function CancelConfirmationAlert({ + title, + onConfirm, + onDismiss, + children, +}: ConfirmationAlertProps) { + return ( + + + + + {title} + + {children} + + + + + + + ); +} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx index f93e5425203..a7d178eac5c 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx @@ -30,6 +30,7 @@ import ErrorBoundary from 'src/components/ErrorBoundary'; import { useFilterConfigMap, useFilterConfiguration } from './state'; import FilterConfigForm from './FilterConfigForm'; import { FilterConfiguration, NativeFiltersForm } from './types'; +import { CancelConfirmationAlert } from './CancelConfirmationAlert'; // how long to show the "undo" button when removing a filter const REMOVAL_DELAY_SECS = 5; @@ -174,6 +175,8 @@ export function FilterConfigModal({ Record >({}); + const [saveAlertVisible, setSaveAlertVisible] = useState(false); + // brings back a filter that was previously removed ("Undo") const restoreFilter = useCallback( (id: string) => { @@ -231,6 +234,7 @@ export function FilterConfigModal({ const newFilterId = generateFilterId(); setNewFilterIds([...newFilterIds, newFilterId]); setCurrentFilterId(newFilterId); + setSaveAlertVisible(false); }, [newFilterIds, setCurrentFilterId]); // if this is a "create" modal rather than an "edit" modal, @@ -248,6 +252,7 @@ export function FilterConfigModal({ setNewFilterIds([]); setCurrentFilterId(getInitialCurrentFilterId()); setRemovedFilters({}); + setSaveAlertVisible(false); }, [form, getInitialCurrentFilterId]); const completeFilterRemoval = (filterId: string) => { @@ -272,6 +277,7 @@ export function FilterConfigModal({ ...removedFilters, [filterId]: { isPending: true, timerId }, })); + setSaveAlertVisible(false); } else if (action === 'add') { addFilter(); } @@ -414,11 +420,63 @@ export function FilterConfigModal({ validateForm, ]); - const handleCancel = () => { + const confirmCancel = () => { resetForm(); onCancel(); }; + const unsavedFiltersIds = newFilterIds.filter(id => !removedFilters[id]); + + const getUnsavedFilterNames = (): string => { + const unsavedFiltersNames = unsavedFiltersIds.map( + id => `"${getFilterTitle(id)}"`, + ); + + if (unsavedFiltersNames.length === 0) { + return ''; + } + + if (unsavedFiltersNames.length === 1) { + return unsavedFiltersNames[0]; + } + + const lastFilter = unsavedFiltersNames.pop(); + + return `${unsavedFiltersNames.join(', ')} ${t('and')} ${lastFilter}`; + }; + + const handleCancel = () => { + if (unsavedFiltersIds.length > 0) { + setSaveAlertVisible(true); + } else { + confirmCancel(); + } + }; + + const renderFooterElements = (): React.ReactNode[] => { + if (saveAlertVisible) { + return [ + setSaveAlertVisible(false)} + > + {t(`Are you sure you want to cancel?`)} {getUnsavedFilterNames()}{' '} + {t(`will not be saved.`)} + , + ]; + } + + return [ + , + , + ]; + }; + return ( - {t('Cancel')} - , - , - ]} + footer={renderFooterElements()} > @@ -451,6 +502,7 @@ export function FilterConfigModal({ // we only need to set this if a name changed setFormValues(values); } + setSaveAlertVisible(false); }} layout="vertical" >