Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add warning when leaving form with unsaved changes #4570

Merged
merged 15 commits into from
Mar 26, 2020
Merged
47 changes: 47 additions & 0 deletions docs/CreateEdit.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ Here are all the props you can set on the `<SimpleForm>` component:
* [`toolbar`](#toolbar)
* [`variant`](#variant)
* [`margin`](#margin)
* [`warnWhenUnsavedChanges`](#warning-about-unsaved-changes)

```jsx
export const PostCreate = (props) => (
Expand Down Expand Up @@ -431,6 +432,7 @@ Here are all the props accepted by the `<TabbedForm>` component:
* [`margin`](#margin)
* `save`: The function invoked when the form is submitted. This is passed automatically by `react-admin` when the form component is used inside `Create` and `Edit` components.
* `saving`: A boolean indicating whether a save operation is ongoing. This is passed automatically by `react-admin` when the form component is used inside `Create` and `Edit` components.
* [`warnWhenUnsavedChanges`](#warning-about-unsaved-changes)

{% raw %}
```jsx
Expand Down Expand Up @@ -1178,6 +1180,51 @@ const defaultSubscription = {
```
{% endraw %}

## Warning About Unsaved Changes

React-admin keeps track of the form state, so it can detect when the user leaves an Edit or Create page with unsaved changes. To avoid data loss, you can use this ability to ask the user to confirm before leaving a page with unsaved changes.

![Warn About Unsaved Changes](./img/warn_when_unsaved_changes.png)

Warning about unsaved changes is an opt-in feature: you must set the `warnWhenUnsavedChanges` prop in the form component to enable it:

```jsx
export const TagEdit = props => (
<Edit {...props}>
<SimpleForm warnWhenUnsavedChanges>
<TextField source="id" />
<TextInput source="name" />
...
</SimpleForm>
</Edit>
);
```

And that's all. `warnWhenUnsavedChanges` works for both `<SimpleForm>` and `<TabbedForm>`. In fact, this feature is provided by a custom hook called `useWarnWhenUnsavedChanges()`, which you can use in your own react-final-form forms.

```jsx
import { Form, Field } from 'react-final-form';
import { useWarnWhenUnsavedChanges } from 'react-admin';

const MyForm = () => (
<Form onSubmit={() => { /*...*/}} component={FormBody} />
);

const FormBody = ({ handleSubmit }) => {
// enable the warn when unsaved changes feature
useWarnWhenUnsavedChanges(true);
return (
<form onSubmit={handleSubmit}>
<label id="firstname-label">First Name</label>
<Field name="firstName" aria-labelledby="firstname-label" component="input" />
<button type="submit">Submit</button>
</form>
);
};
```

**Tip**: You can customize the message displayed in the confirm dialog by setting the `ra.message.unsaved_changes` message in your i18nProvider.

## Displaying Fields or Inputs depending on the user permissions

You might want to display some fields, inputs or filters only to users with specific permissions.
Expand Down
1 change: 1 addition & 0 deletions docs/Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ title: "Reference"
* `useUpdateLoading`
* [`useUpdateMany`](./Actions.md#specialized-hooks)
* [`useUnselectAll`](./Actions.md#handling-side-effects-in-usedataprovider)
* [`useWarnWhenUnsavedChanges`](./CreateEdit.md#warning-about-unsaved-changes)
* `useVersion`
* [`withDataProvider`](./Actions.md#legacy-components-query-mutation-and-withdataprovider)
* [`withTranslate`](./Translation.md#withtranslate-hoc)
Expand Down
Binary file added docs/img/warn_when_unsaved_changes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion examples/simple/src/posts/PostEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const EditActions = ({ basePath, data, hasShow }) => (

const PostEdit = ({ permissions, ...props }) => (
<Edit title={<PostTitle />} actions={<EditActions />} {...props}>
<TabbedForm initialValues={{ average_note: 0 }}>
<TabbedForm initialValues={{ average_note: 0 }} warnWhenUnsavedChanges>
<FormTab label="post.form.summary">
<TextInput disabled source="id" />
<TextInput source="title" validate={required()} resettable />
Expand Down
6 changes: 5 additions & 1 deletion packages/ra-core/src/form/FormWithRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Form } from 'react-final-form';
import arrayMutators from 'final-form-arrays';

import useInitializeFormWithRecord from './useInitializeFormWithRecord';
import useWarnWhenUnsavedChanges from './useWarnWhenUnsavedChanges';
import sanitizeEmptyValues from './sanitizeEmptyValues';
import getFormInitialValues from './getFormInitialValues';
import FormContext from './FormContext';
Expand Down Expand Up @@ -48,6 +49,7 @@ const FormWithRedirect = ({
validate,
validateOnBlur,
version,
warnWhenUnsavedChanges,
...props
}) => {
let redirect = useRef(props.redirect);
Expand Down Expand Up @@ -121,6 +123,7 @@ const FormWithRedirect = ({
saving={formProps.submitting || saving}
render={render}
save={save}
warnWhenUnsavedChanges={warnWhenUnsavedChanges}
/>
)}
</Form>
Expand All @@ -135,9 +138,10 @@ const defaultSubscription = {
invalid: true,
};

const FormView = ({ render, ...props }) => {
const FormView = ({ render, warnWhenUnsavedChanges, ...props }) => {
// if record changes (after a getOne success or a refresh), the form must be updated
useInitializeFormWithRecord(props.record);
useWarnWhenUnsavedChanges(warnWhenUnsavedChanges);

const { redirect, setRedirect, handleSubmit } = props;

Expand Down
2 changes: 2 additions & 0 deletions packages/ra-core/src/form/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import useChoices, {
OptionText,
} from './useChoices';
import useSuggestions from './useSuggestions';
import useWarnWhenUnsavedChanges from './useWarnWhenUnsavedChanges';

export {
addField,
Expand All @@ -30,6 +31,7 @@ export {
useSuggestions,
ValidationError,
FormContext,
useWarnWhenUnsavedChanges,
};
export { isRequired } from './FormField';
export * from './validate';
Expand Down
105 changes: 105 additions & 0 deletions packages/ra-core/src/form/useWarnWhenUnsavedChanges.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from 'react';
import expect from 'expect';
import { render, cleanup, fireEvent } from '@testing-library/react';
import { Form, Field } from 'react-final-form';
import { Route, MemoryRouter, useHistory } from 'react-router-dom';

import useWarnWhenUnsavedChanges from './useWarnWhenUnsavedChanges';

const FormBody = ({ handleSubmit }) => {
useWarnWhenUnsavedChanges(true);
const history = useHistory();
const onLeave = () => {
history.push('/somewhere');
};
return (
<form onSubmit={handleSubmit}>
<label id="firstname-label">First Name</label>
<Field
name="firstName"
aria-labelledby="firstname-label"
component="input"
/>
<button type="button" onClick={onLeave}>
Leave
</button>
<button type="submit">Submit</button>
</form>
);
};

const FormUnderTest = ({ initialValues = {} }) => {
const history = useHistory();
const onSubmit = () => {
history.push('/submitted');
};
return (
<Form
onSubmit={onSubmit}
initialValues={initialValues}
component={FormBody}
/>
);
};

const App = () => (
<MemoryRouter initialEntries={['/form']} initialIndex={0}>
<Route path="/form">
<FormUnderTest />
</Route>
<Route path="/submitted" render={() => <span>Submitted</span>} />
<Route path="/somewhere" render={() => <span>Somewhere</span>} />
</MemoryRouter>
);

describe('useWarnWhenUnsavedChanges', () => {
afterEach(cleanup);

it('should not warn when leaving form with no changes', () => {
const { getByText } = render(<App />);
fireEvent.click(getByText('Submit'));
getByText('Submitted');
});

it('should not warn when leaving form with submit button', () => {
const { getByLabelText, getByText } = render(<App />);
const input = getByLabelText('First Name') as HTMLInputElement;
input.value = 'John Doe';
fireEvent.click(getByText('Submit'));
getByText('Submitted');
});

it('should warn when leaving form with no unsaved changes', () => {
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
// mock click on "cancel" in the confirm dialog
window.confirm = jest.fn().mockReturnValue(false);
const { getByLabelText, getByText, queryByText } = render(<App />);
const input = getByLabelText('First Name') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'John Doe' } });
fireEvent.click(getByText('Leave'));
expect(window.confirm).toHaveBeenCalledWith(
'ra.message.unsaved_changes'
);
// check that we're still in the form and that the unsaved changes are here
expect((getByLabelText('First Name') as HTMLInputElement).value).toBe(
'John Doe'
);
expect(queryByText('Somewhere')).toBeNull();
});

it('should warn when leaving form with no unsaved changes but accept override', () => {
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
// mock click on "OK" in the confirm dialog
window.confirm = jest.fn().mockReturnValue(true);
const { getByLabelText, getByText, queryByText } = render(<App />);
const input = getByLabelText('First Name') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'John Doe' } });
fireEvent.click(getByText('Leave'));
expect(window.confirm).toHaveBeenCalledWith(
'ra.message.unsaved_changes'
);
// check that we're no longer in the form
expect(queryByText('First Name')).toBeNull();
getByText('Somewhere');
});

afterAll(() => delete window.confirm);
});
82 changes: 82 additions & 0 deletions packages/ra-core/src/form/useWarnWhenUnsavedChanges.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useEffect, useRef } from 'react';
import { useForm } from 'react-final-form';
import { useHistory } from 'react-router-dom';

import { useTranslate } from '../i18n';

/**
* Display a confirmation dialog if the form has unsaved changes.
* - If the user confirms, the navigation continues and the changes are lost.
* - If the user cancels, the navigation is reverted and the changes are kept.
*
* We can't use history.block() here because forms have routes, too (for
* instance TabbedForm), and the confirm dialog would show up when navigating
* inside the form. So instead of relying on route change detection, we rely
* on unmount detection. The resulting UI isn't perfect, because when they
* click the cancel buttun, users briefly see the page they asked before
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
* seeing the form page again. But that's the best we can do.
*
* @see history.block()
*/
const useWarnWhenUnsavedChanges = (enable: boolean) => {
const form = useForm();
const history = useHistory();
const translate = useTranslate();

// Keep track of the current location inside the form (e.g. active tab)
const formLocation = useRef(history.location);
useEffect(() => {
formLocation.current = history.location;
}, [history.location]);

useEffect(() => {
if (!enable) {
window.sessionStorage.removeItem('unsavedChanges');
return;
}

// on mount: apply unsaved changes
const unsavedChanges = JSON.parse(
window.sessionStorage.getItem('unsavedChanges')
);
if (unsavedChanges) {
Object.keys(unsavedChanges).forEach(key =>
form.change(key, unsavedChanges[key])
);
window.sessionStorage.removeItem('unsavedChanges');
}

// on unmount : check and save unsaved changes, then cancel navigation
return () => {
const formState = form.getState();
if (
formState.dirty &&
(!formState.submitSucceeded ||
(formState.submitSucceeded &&
formState.dirtySinceLastSubmit))
) {
if (!window.confirm(translate('ra.message.unsaved_changes'))) {
const dirtyFields = formState.submitSucceeded
? formState.dirtySinceLastSubmit
: formState.dirtyFields;
const dirtyFieldValues = Object.keys(dirtyFields).reduce(
(acc, key) => {
acc[key] = formState.values[key];
return acc;
},
{}
);
window.sessionStorage.setItem(
'unsavedChanges',
JSON.stringify(dirtyFieldValues)
);
history.push(formLocation.current);
}
} else {
window.sessionStorage.removeItem('unsavedChanges');
}
};
}, [translate]); // eslint-disable-line react-hooks/exhaustive-deps
};

export default useWarnWhenUnsavedChanges;
2 changes: 2 additions & 0 deletions packages/ra-language-english/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const englishMessages: TranslationMessages = {
not_found:
'Either you typed a wrong URL, or you followed a bad link.',
yes: 'Yes',
unsaved_changes:
"Some of your changes weren't saved. Are you sure you want to ignore them?",
},
navigation: {
no_results: 'No results found',
Expand Down
2 changes: 2 additions & 0 deletions packages/ra-language-french/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ const frenchMessages: TranslationMessages = {
not_found:
"L'URL saisie est incorrecte, ou vous avez suivi un mauvais lien.",
yes: 'Oui',
unsaved_changes:
"Certains changements n'ont pas été enregistrés. Etes-vous sûr(e) de vouloir quitter cette page ?",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Certains changements n'ont pas été enregistrés. Etes-vous sûr(e) de vouloir quitter cette page ?",
"Certains changements n'ont pas été enregistrés. Êtes-vous sûr·e de vouloir quitter cette page ?",

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have "sûr(e)" already in other translations, let's keep parentheses for this one.

},
navigation: {
no_results: 'Aucun résultat',
Expand Down
8 changes: 8 additions & 0 deletions packages/ra-ui-materialui/src/form/SimpleFormIterator.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { ArrayInput } from '../input';
import SimpleForm from './SimpleForm';

describe('<SimpleFormIterator />', () => {
// bypass confirm leave form with unsaved changes
let confirmSpy;
beforeAll(() => {
confirmSpy = jest.spyOn(window, 'confirm');
confirmSpy.mockImplementation(jest.fn(() => true));
});
afterAll(() => confirmSpy.mockRestore());

afterEach(cleanup);

it('should display an add item button at least', () => {
Expand Down