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"
>