feat: Add confirmation modal for unsaved changes (#33809)

This commit is contained in:
Gabriel Torres Ruiz
2025-07-01 13:38:51 -03:00
committed by GitHub
parent 050ccdcb3d
commit 057218d87f
28 changed files with 1799 additions and 489 deletions

View File

@@ -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,
};
};

View File

@@ -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);
});
});