Skip to content

Commit 0bb605b

Browse files
authored
Merge pull request #10924 from marmelab/use-update-controller
Introduce `useUpdateController`
2 parents 9a5757c + 3bf385e commit 0bb605b

File tree

5 files changed

+314
-218
lines changed

5 files changed

+314
-218
lines changed

packages/ra-core/src/controller/button/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ import useDeleteWithConfirmController from './useDeleteWithConfirmController';
44
export { useDeleteWithUndoController, useDeleteWithConfirmController };
55
export * from './useDeleteController';
66
export * from './useBulkDeleteController';
7+
export * from './useUpdateController';
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { useCallback, useMemo } from 'react';
2+
import { useUpdate, UseUpdateOptions } from '../../dataProvider/useUpdate';
3+
import { useRedirect, RedirectionSideEffect } from '../../routing/useRedirect';
4+
import { useNotify } from '../../notification/useNotify';
5+
import { RaRecord, MutationMode } from '../../types';
6+
import { useRecordContext } from '../record/useRecordContext';
7+
import { useResourceContext } from '../../core/useResourceContext';
8+
import { useTranslate } from '../../i18n/useTranslate';
9+
10+
/**
11+
* Prepare a set of callbacks for an update button
12+
*
13+
* @example
14+
* const UpdateButton = ({
15+
* resource,
16+
* record,
17+
* redirect,
18+
* ...rest
19+
* }) => {
20+
* const {
21+
* isPending,
22+
* handleUpdate,
23+
* } = useUpdateController({
24+
* mutationMode: 'pessimistic',
25+
* resource,
26+
* record,
27+
* redirect,
28+
* });
29+
*
30+
* const [open, setOpen] = useState(false);
31+
*
32+
* return (
33+
* <Fragment>
34+
* <Button
35+
* onClick={() => setOpen(true)}
36+
* label="ra.action.update"
37+
* {...rest}
38+
* >
39+
* {icon}
40+
* </Button>
41+
* <Confirm
42+
* isOpen={open}
43+
* loading={isPending}
44+
* title="ra.message.update_title"
45+
* content="ra.message.update_content"
46+
* titleTranslateOptions={{
47+
* name: resource,
48+
* id: record.id,
49+
* }}
50+
* contentTranslateOptions={{
51+
* name: resource,
52+
* id: record.id,
53+
* }}
54+
* onConfirm={() => handleUpdate()}
55+
* onClose={() => setOpen(false)}
56+
* />
57+
* </Fragment>
58+
* );
59+
* };
60+
*/
61+
export const useUpdateController = <
62+
RecordType extends RaRecord = any,
63+
ErrorType = Error,
64+
>(
65+
props: UseUpdateControllerParams<RecordType, ErrorType>
66+
): UseUpdateControllerReturn<RecordType> => {
67+
const {
68+
redirect: redirectTo = false,
69+
mutationMode = 'undoable',
70+
mutationOptions = {},
71+
successMessage,
72+
} = props;
73+
const { meta: mutationMeta, ...otherMutationOptions } = mutationOptions;
74+
const record = useRecordContext(props);
75+
const resource = useResourceContext(props);
76+
const notify = useNotify();
77+
const redirect = useRedirect();
78+
const translate = useTranslate();
79+
80+
const [updateOne, { isPending }] = useUpdate<RecordType, ErrorType>(
81+
resource,
82+
undefined,
83+
{
84+
onSuccess: () => {
85+
notify(
86+
successMessage ??
87+
`resources.${resource}.notifications.updated`,
88+
{
89+
type: 'info',
90+
messageArgs: {
91+
smart_count: 1,
92+
_: translate('ra.notification.updated', {
93+
smart_count: 1,
94+
}),
95+
},
96+
undoable: mutationMode === 'undoable',
97+
}
98+
);
99+
redirect(redirectTo, resource);
100+
},
101+
onError: (error: any) => {
102+
notify(
103+
typeof error === 'string'
104+
? error
105+
: error?.message || 'ra.notification.http_error',
106+
{
107+
type: 'error',
108+
messageArgs: {
109+
_:
110+
typeof error === 'string'
111+
? error
112+
: (error as Error)?.message,
113+
},
114+
}
115+
);
116+
},
117+
mutationMode,
118+
...otherMutationOptions,
119+
}
120+
);
121+
122+
const handleUpdate = useCallback(
123+
(data: Partial<RecordType>) => {
124+
if (!record) {
125+
throw new Error(
126+
'The record cannot be updated because no record has been passed'
127+
);
128+
}
129+
updateOne(resource, {
130+
id: record.id,
131+
data,
132+
previousData: record,
133+
meta: mutationMeta,
134+
});
135+
},
136+
[updateOne, mutationMeta, record, resource]
137+
);
138+
139+
return useMemo(
140+
() => ({
141+
isPending,
142+
isLoading: isPending,
143+
handleUpdate,
144+
}),
145+
[isPending, handleUpdate]
146+
);
147+
};
148+
149+
export interface UseUpdateControllerParams<
150+
RecordType extends RaRecord = any,
151+
MutationOptionsError = unknown,
152+
> {
153+
mutationMode?: MutationMode;
154+
mutationOptions?: UseUpdateOptions<RecordType, MutationOptionsError>;
155+
record?: RecordType;
156+
redirect?: RedirectionSideEffect;
157+
resource?: string;
158+
successMessage?: string;
159+
}
160+
161+
export interface UseUpdateControllerReturn<RecordType extends RaRecord = any> {
162+
isLoading: boolean;
163+
isPending: boolean;
164+
handleUpdate: (data: Partial<RecordType>) => void;
165+
}

packages/ra-ui-materialui/src/button/UpdateWithConfirmButton.stories.tsx

Lines changed: 103 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
22
import polyglotI18nProvider from 'ra-i18n-polyglot';
33
import englishMessages from 'ra-language-english';
44
import frenchMessages from 'ra-language-french';
5-
import { Resource, TestMemoryRouter } from 'ra-core';
5+
import { MutationMode, Resource, TestMemoryRouter } from 'ra-core';
66
import fakeRestDataProvider from 'ra-data-fakerest';
77
import { Alert } from '@mui/material';
88
import { UpdateWithConfirmButton } from './UpdateWithConfirmButton';
@@ -82,89 +82,93 @@ const i18nProviderDefault = polyglotI18nProvider(
8282
]
8383
);
8484

85-
const dataProvider = fakeRestDataProvider({
86-
books: [
87-
{
88-
id: 1,
89-
title: 'War and Peace',
90-
author: 'Leo Tolstoy',
91-
year: 1869,
92-
},
93-
{
94-
id: 2,
95-
title: 'Pride and Predjudice',
96-
author: 'Jane Austen',
97-
year: 1813,
98-
},
99-
{
100-
id: 3,
101-
title: 'The Picture of Dorian Gray',
102-
author: 'Oscar Wilde',
103-
year: 1890,
104-
},
105-
{
106-
id: 4,
107-
title: 'Le Petit Prince',
108-
author: 'Antoine de Saint-Exupéry',
109-
year: 1943,
110-
},
111-
{
112-
id: 5,
113-
title: "Alice's Adventures in Wonderland",
114-
author: 'Lewis Carroll',
115-
year: 1865,
116-
},
117-
{
118-
id: 6,
119-
title: 'Madame Bovary',
120-
author: 'Gustave Flaubert',
121-
year: 1856,
122-
},
123-
{
124-
id: 7,
125-
title: 'The Lord of the Rings',
126-
author: 'J. R. R. Tolkien',
127-
year: 1954,
128-
},
129-
{
130-
id: 8,
131-
title: "Harry Potter and the Philosopher's Stone",
132-
author: 'J. K. Rowling',
133-
year: 1997,
134-
},
135-
{
136-
id: 9,
137-
title: 'The Alchemist',
138-
author: 'Paulo Coelho',
139-
year: 1988,
140-
},
141-
{
142-
id: 10,
143-
title: 'A Catcher in the Rye',
144-
author: 'J. D. Salinger',
145-
year: 1951,
146-
},
147-
{
148-
id: 11,
149-
title: 'Ulysses',
150-
author: 'James Joyce',
151-
year: 1922,
152-
},
153-
],
154-
authors: [
155-
{ id: 1, fullName: 'Leo Tolstoy' },
156-
{ id: 2, fullName: 'Jane Austen' },
157-
{ id: 3, fullName: 'Oscar Wilde' },
158-
{ id: 4, fullName: 'Antoine de Saint-Exupéry' },
159-
{ id: 5, fullName: 'Lewis Carroll' },
160-
{ id: 6, fullName: 'Gustave Flaubert' },
161-
{ id: 7, fullName: 'J. R. R. Tolkien' },
162-
{ id: 8, fullName: 'J. K. Rowling' },
163-
{ id: 9, fullName: 'Paulo Coelho' },
164-
{ id: 10, fullName: 'J. D. Salinger' },
165-
{ id: 11, fullName: 'James Joyce' },
166-
],
167-
});
85+
const dataProvider = fakeRestDataProvider(
86+
{
87+
books: [
88+
{
89+
id: 1,
90+
title: 'War and Peace',
91+
author: 'Leo Tolstoy',
92+
year: 1869,
93+
},
94+
{
95+
id: 2,
96+
title: 'Pride and Predjudice',
97+
author: 'Jane Austen',
98+
year: 1813,
99+
},
100+
{
101+
id: 3,
102+
title: 'The Picture of Dorian Gray',
103+
author: 'Oscar Wilde',
104+
year: 1890,
105+
},
106+
{
107+
id: 4,
108+
title: 'Le Petit Prince',
109+
author: 'Antoine de Saint-Exupéry',
110+
year: 1943,
111+
},
112+
{
113+
id: 5,
114+
title: "Alice's Adventures in Wonderland",
115+
author: 'Lewis Carroll',
116+
year: 1865,
117+
},
118+
{
119+
id: 6,
120+
title: 'Madame Bovary',
121+
author: 'Gustave Flaubert',
122+
year: 1856,
123+
},
124+
{
125+
id: 7,
126+
title: 'The Lord of the Rings',
127+
author: 'J. R. R. Tolkien',
128+
year: 1954,
129+
},
130+
{
131+
id: 8,
132+
title: "Harry Potter and the Philosopher's Stone",
133+
author: 'J. K. Rowling',
134+
year: 1997,
135+
},
136+
{
137+
id: 9,
138+
title: 'The Alchemist',
139+
author: 'Paulo Coelho',
140+
year: 1988,
141+
},
142+
{
143+
id: 10,
144+
title: 'A Catcher in the Rye',
145+
author: 'J. D. Salinger',
146+
year: 1951,
147+
},
148+
{
149+
id: 11,
150+
title: 'Ulysses',
151+
author: 'James Joyce',
152+
year: 1922,
153+
},
154+
],
155+
authors: [
156+
{ id: 1, fullName: 'Leo Tolstoy' },
157+
{ id: 2, fullName: 'Jane Austen' },
158+
{ id: 3, fullName: 'Oscar Wilde' },
159+
{ id: 4, fullName: 'Antoine de Saint-Exupéry' },
160+
{ id: 5, fullName: 'Lewis Carroll' },
161+
{ id: 6, fullName: 'Gustave Flaubert' },
162+
{ id: 7, fullName: 'J. R. R. Tolkien' },
163+
{ id: 8, fullName: 'J. K. Rowling' },
164+
{ id: 9, fullName: 'Paulo Coelho' },
165+
{ id: 10, fullName: 'J. D. Salinger' },
166+
{ id: 11, fullName: 'James Joyce' },
167+
],
168+
},
169+
process.env.NODE_ENV !== 'test',
170+
process.env.NODE_ENV !== 'test' ? 300 : 0
171+
);
168172

169173
const BookList = ({ children }) => {
170174
return (
@@ -192,7 +196,7 @@ const AuthorList = ({ children }) => {
192196
);
193197
};
194198

195-
export const Basic = () => (
199+
export const Basic = ({ mutationMode }: { mutationMode?: MutationMode }) => (
196200
<TestMemoryRouter initialEntries={['/books']}>
197201
<AdminContext dataProvider={dataProvider} i18nProvider={i18nProvider}>
198202
<AdminUI>
@@ -202,6 +206,7 @@ export const Basic = () => (
202206
<BookList>
203207
<UpdateWithConfirmButton
204208
data={{ title: 'modified' }}
209+
mutationMode={mutationMode}
205210
/>
206211
</BookList>
207212
}
@@ -211,6 +216,19 @@ export const Basic = () => (
211216
</TestMemoryRouter>
212217
);
213218

219+
Basic.args = {
220+
mutationMode: 'pessimistic',
221+
};
222+
223+
Basic.argTypes = {
224+
mutationMode: {
225+
options: ['pessimistic', 'optimistic', 'undoable'],
226+
control: {
227+
type: 'select',
228+
},
229+
},
230+
};
231+
214232
export const WithCustomTitleAndContent = () => (
215233
<TestMemoryRouter initialEntries={['/books']}>
216234
<AdminContext

0 commit comments

Comments
 (0)