diff --git a/packages/ra-core/src/controller/button/useDeleteController.tsx b/packages/ra-core/src/controller/button/useDeleteController.tsx index 486178d3f99..9a3d400f3e8 100644 --- a/packages/ra-core/src/controller/button/useDeleteController.tsx +++ b/packages/ra-core/src/controller/button/useDeleteController.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useDelete, UseDeleteOptions } from '../../dataProvider/useDelete'; import { useUnselect } from '../list/useUnselect'; @@ -75,6 +76,8 @@ export const useDeleteController = < const unselect = useUnselect(resource); const redirect = useRedirect(); const translate = useTranslate(); + // null when the delete button is used outside a form (e.g. in a list) + const form = useFormContext(); const [deleteOne, { isPending }] = useDelete( resource, @@ -96,6 +99,11 @@ export const useDeleteController = < } ); record && unselect([record.id], true); + // Clear the form dirty state so warnWhenUnsavedChanges doesn't + // block the redirect: the record is gone, so the unsaved + // changes are moot. keepValues avoids a flash of reverted + // fields before the redirect commits. + form?.reset(undefined, { keepValues: true }); redirect(redirectTo, resource); }, onError: (error: any) => { diff --git a/packages/ra-core/src/form/Form.spec.tsx b/packages/ra-core/src/form/Form.spec.tsx index 325c09df18e..ca632ee7ede 100644 --- a/packages/ra-core/src/form/Form.spec.tsx +++ b/packages/ra-core/src/form/Form.spec.tsx @@ -22,6 +22,7 @@ import { InNonDataRouter, ServerSideValidation, MultiRoutesForm, + WarnWhenUnsavedChangesWithDelete, } from './Form.stories'; import { mergeTranslations } from '../i18n'; @@ -1008,4 +1009,37 @@ describe('Form', () => { ).toEqual(false); } ); + + describe('warnWhenUnsavedChanges with a delete button', () => { + it.each(['undoable', 'pessimistic', 'optimistic'] as const)( + 'should not warn about unsaved changes after deleting a dirty record (%s)', + async mutationMode => { + // spy on "cancel": if the dialog were shown the navigation + // would be cancelled, failing the redirect assertion + const confirmSpy = jest + .spyOn(window, 'confirm') + .mockReturnValue(false); + render( + + ); + // wait for the record to load + const input = + await screen.findByDisplayValue('Hello'); + // make the form dirty + fireEvent.change(input, { + target: { value: 'Hello modified' }, + }); + fireEvent.blur(input); + // delete the record (the headless DeleteButton has no + // confirmation dialog and always uses the controller onSuccess) + fireEvent.click(screen.getByText('Delete')); + // the app should redirect to the list without warning + await screen.findByText('Post list'); + expect(confirmSpy).not.toHaveBeenCalled(); + confirmSpy.mockRestore(); + } + ); + }); }); diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index 46e79f87c6c..dcc1ba93d51 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -10,6 +10,7 @@ import { yupResolver } from '@hookform/resolvers/yup'; import * as yup from 'yup'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; +import fakeRestDataProvider from 'ra-data-fakerest'; import { Route, Routes, @@ -20,7 +21,12 @@ import { } from 'react-router-dom'; import { CoreAdminContext } from '../core'; -import { RecordContextProvider, SaveContextProvider } from '../controller'; +import { + EditBase, + RecordContextProvider, + SaveContextProvider, +} from '../controller'; +import { DeleteButton } from '../test-ui/DeleteButton'; import { Form, FormProps } from './Form'; import { useInput } from './useInput'; import { required, ValidationError } from './validation'; @@ -415,6 +421,69 @@ export const InNonDataRouter = ({ ); +const PostEditWithDelete = ({ + mutationMode, +}: { + mutationMode: 'undoable' | 'optimistic' | 'pessimistic'; +}) => ( + +
+ + + + + +
+); + +/** + * Edit a field (making the form dirty), then click Delete. The record is + * deleted and the app redirects to the list, but the unsaved-changes blocker + * pops a spurious "unsaved changes" confirm dialog even though the record is + * already gone. + */ +export const WarnWhenUnsavedChangesWithDelete = ({ + i18nProvider = defaultI18nProvider, + mutationMode = 'undoable', +}: { + i18nProvider?: I18nProvider; + mutationMode?: 'undoable' | 'optimistic' | 'pessimistic'; +}) => ( + + + + } + /> + Post list

} /> +
+
+
+); + +WarnWhenUnsavedChangesWithDelete.argTypes = { + mutationMode: { + options: ['undoable', 'optimistic', 'pessimistic'], + control: { type: 'select' }, + }, +}; + const Notifications = () => { const { notifications } = useNotificationContext(); return ( diff --git a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx index 243cecdf977..deb117850ab 100644 --- a/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx +++ b/packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx @@ -40,21 +40,24 @@ export const useWarnWhenUnsavedChanges = ( }); useEffect(() => { - if (blocker.state === 'blocked') { - // Corner case: the blocker might be triggered by a redirect in the onSuccess side effect, - // happening during the same tick the form is reset after a successful save. - // In that case, the blocker will block but shouldNotBlock will be true one tick after. - // If we are in that case, we can proceed immediately. - if (shouldNotBlock) { - blocker.proceed(); - return; - } + if (blocker.state !== 'blocked') return; - setShouldNotify(true); + // Proceed automatically when the form becomes safe to leave while the + // navigation is blocked. This covers two cases where the redirect and + // the form reset happen during the same tick (and the reset may only be + // reflected one tick after the block): + // - a successful save resets the form; + // - a successful delete resets the form (the record no longer exists, + // so the unsaved changes are moot). + if (shouldNotBlock) { + blocker.proceed && blocker.proceed(); + return; } - // This effect should only run when the blocker state changes, not when shouldNotBlock changes. + + setShouldNotify(true); + // Can't use blocker in the dependency array because it is not stable across rerenders // eslint-disable-next-line react-hooks/exhaustive-deps - }, [blocker.state]); + }, [blocker.state, shouldNotBlock]); useEffect(() => { if (shouldNotify) { diff --git a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.spec.tsx b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.spec.tsx index d4cd250adce..a40bc94e396 100644 --- a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.spec.tsx @@ -11,6 +11,7 @@ import { CoreAdminContext, MutationMode, testDataProvider, + TestMemoryRouter, useNotificationContext, } from 'ra-core'; import { createTheme, ThemeProvider } from '@mui/material/styles'; @@ -453,4 +454,109 @@ describe('', () => { const buttons = await screen.findAllByTestId('themed'); expect(buttons[0].classList).toContain('MuiButton-outlined'); }); + + describe('with warnWhenUnsavedChanges and a dirty form', () => { + // Use the router-agnostic TestMemoryRouter + locationCallback (instead of + // react-router's Routes) so the test does not depend on the router adapter. + const renderDirtyEditForm = ( + mutationMode: MutationMode, + locationCallback: (l: any) => void + ) => { + const dataProvider = testDataProvider({ + getOne: () => + // @ts-ignore + Promise.resolve({ data: { id: 123, title: 'lorem' } }), + delete: jest.fn().mockResolvedValue({ data: { id: 123 } }), + }); + const EditToolbar = props => ( + + + + ); + render( + + + + + } + > + + + + + + + ); + return dataProvider; + }; + + const makeFormDirty = async () => { + const input = + await screen.findByDisplayValue('lorem'); + fireEvent.change(input, { target: { value: 'lorem modified' } }); + fireEvent.blur(input); + }; + + it('should delete the record and redirect without warning when confirming the dialog', async () => { + // spy on "cancel": if the unsaved-changes dialog were shown, the + // navigation would be cancelled and the redirect assertion would fail + const confirmSpy = jest + .spyOn(window, 'confirm') + .mockReturnValue(false); + let location; + const dataProvider = renderDirtyEditForm('pessimistic', l => { + location = l; + }); + await makeFormDirty(); + // open the confirmation dialog and confirm + fireEvent.click( + await screen.findByLabelText('resources.posts.action.delete') + ); + fireEvent.click(screen.getByText('ra.action.confirm')); + // the record is deleted and the app redirects to the list + await waitFor(() => { + expect(location.pathname).toEqual('/posts'); + }); + expect(dataProvider.delete).toHaveBeenCalledWith('posts', { + id: 123, + previousData: { id: 123, title: 'lorem' }, + }); + // no spurious unsaved-changes warning + expect(confirmSpy).not.toHaveBeenCalled(); + confirmSpy.mockRestore(); + }); + + it('should not delete the record nor leave the form when cancelling the dialog (optimistic)', async () => { + const confirmSpy = jest + .spyOn(window, 'confirm') + .mockReturnValue(false); + let location; + const dataProvider = renderDirtyEditForm('optimistic', l => { + location = l; + }); + await makeFormDirty(); + // open the confirmation dialog and cancel + fireEvent.click( + await screen.findByLabelText('resources.posts.action.delete') + ); + fireEvent.click(screen.getByText('ra.action.cancel')); + // the dialog closes + await waitFor(() => { + expect(screen.queryByText('ra.action.confirm')).toBeNull(); + }); + // the deletion never happened (no optimistic removal to roll back) + expect(dataProvider.delete).not.toHaveBeenCalled(); + // the user stays on the dirty form, edits preserved + expect(location.pathname).toEqual('/posts/123'); + expect(screen.getByDisplayValue('lorem modified')).not.toBeNull(); + // no unsaved-changes warning was triggered either + expect(confirmSpy).not.toHaveBeenCalled(); + confirmSpy.mockRestore(); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx index 403c8ad2219..42551645a35 100644 --- a/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx +++ b/packages/ra-ui-materialui/src/button/DeleteWithConfirmButton.tsx @@ -1,4 +1,5 @@ import React, { Fragment, isValidElement, ReactEventHandler } from 'react'; +import { useFormContext } from 'react-hook-form'; import ActionDelete from '@mui/icons-material/Delete'; import { ComponentsOverrides, @@ -59,6 +60,8 @@ export const DeleteWithConfirmButton = React.forwardRef( const notify = useNotify(); const unselect = useUnselect(resource); const redirect = useRedirect(); + // null when the delete button is used outside a form (e.g. in a list) + const form = useFormContext(); const [open, setOpen] = React.useState(false); if (!resource) { throw new Error( @@ -95,6 +98,10 @@ export const DeleteWithConfirmButton = React.forwardRef( } ); record && unselect([record.id]); + // Clear the form dirty state so warnWhenUnsavedChanges + // doesn't block the redirect: the record is gone, so the + // unsaved changes are moot. + form?.reset(undefined, { keepValues: true }); redirect(redirectTo, resource); } }, diff --git a/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.spec.tsx b/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.spec.tsx index 38d414fc6ac..f7061bd9762 100644 --- a/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/DeleteWithUndoButton.spec.tsx @@ -5,6 +5,7 @@ import { MutationMode, CoreAdminContext, testDataProvider, + TestMemoryRouter, useNotificationContext, } from 'ra-core'; import { createTheme, ThemeProvider } from '@mui/material/styles'; @@ -182,4 +183,62 @@ describe('', () => { const buttons = await screen.findAllByTestId('themed'); expect(buttons[0].classList).toContain('MuiButton-outlined'); }); + + it('should not warn about unsaved changes after deleting a dirty record', async () => { + // spy on "cancel": if the unsaved-changes dialog were shown, the + // navigation would be cancelled and the redirect assertion would fail + const confirmSpy = jest.spyOn(window, 'confirm').mockReturnValue(false); + const dataProvider = testDataProvider({ + getOne: () => + // @ts-ignore + Promise.resolve({ data: { id: 123, title: 'lorem' } }), + delete: jest.fn().mockResolvedValue({ data: { id: 123 } }), + }); + const EditToolbar = props => ( + + + + ); + // Use the router-agnostic TestMemoryRouter + locationCallback (instead of + // react-router's Routes) so the test does not depend on the router adapter. + let location; + render( + { + location = l; + }} + > + + + + } + > + + + + + + + ); + // wait for the record to load + const input = + await screen.findByDisplayValue('lorem'); + // make the form dirty + fireEvent.change(input, { target: { value: 'lorem modified' } }); + fireEvent.blur(input); + // the undoable delete button has no confirmation dialog: clicking it + // deletes (optimistically) and redirects right away + fireEvent.click( + await screen.findByLabelText('resources.posts.action.delete') + ); + // the app should redirect to the list without warning + await waitFor(() => { + expect(location.pathname).toEqual('/posts'); + }); + expect(confirmSpy).not.toHaveBeenCalled(); + confirmSpy.mockRestore(); + }); });