Skip to content

Commit 1df0e28

Browse files
committed
Fix middlewares might not be applied in optimistic and undoable modes when they are unregistered before the actual mutation is called
1 parent b125279 commit 1df0e28

File tree

7 files changed

+490
-25
lines changed

7 files changed

+490
-25
lines changed

packages/ra-core/src/dataProvider/useCreate.spec.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
WithMiddlewaresSuccess as WithMiddlewaresSuccessUndoable,
2727
WithMiddlewaresError as WithMiddlewaresErrorUndoable,
2828
} from './useCreate.undoable.stories';
29-
import { MutationMode, Params } from './useCreate.stories';
29+
import { Middleware, MutationMode, Params } from './useCreate.stories';
3030

3131
describe('useCreate', () => {
3232
it('returns a callback that can be used with create arguments', async () => {
@@ -574,5 +574,57 @@ describe('useCreate', () => {
574574
).toBeNull();
575575
expect(screen.queryByText('mutating')).toBeNull();
576576
}, 6000);
577+
578+
it(`it calls the middlewares in undoable mode even when they got unregistered`, async () => {
579+
const middlewareSpy = jest.fn();
580+
render(
581+
<Middleware
582+
mutationMode="undoable"
583+
timeout={0}
584+
middleware={middlewareSpy}
585+
/>
586+
);
587+
588+
fireEvent.change(screen.getByLabelText('title'), {
589+
target: { value: 'Bazinga' },
590+
});
591+
fireEvent.click(screen.getByText('Save'));
592+
await screen.findByText('resources.posts.notifications.created');
593+
expect(middlewareSpy).not.toHaveBeenCalled();
594+
fireEvent.click(screen.getByText('Refresh'));
595+
expect(screen.queryByText('Bazinga')).toBeNull();
596+
fireEvent.click(screen.getByText('Close'));
597+
await waitFor(() => {
598+
expect(middlewareSpy).toHaveBeenCalledWith('posts', {
599+
data: { id: 2, title: 'Bazinga' },
600+
meta: undefined,
601+
});
602+
});
603+
fireEvent.click(screen.getByText('Refresh'));
604+
await screen.findByText('Bazinga');
605+
});
606+
it(`it calls the middlewares in optimistic mode even when they got unregistered`, async () => {
607+
const middlewareSpy = jest.fn();
608+
render(
609+
<Middleware
610+
mutationMode="optimistic"
611+
timeout={0}
612+
middleware={middlewareSpy}
613+
/>
614+
);
615+
616+
fireEvent.change(screen.getByLabelText('title'), {
617+
target: { value: 'Bazinga' },
618+
});
619+
fireEvent.click(screen.getByText('Save'));
620+
await screen.findByText('resources.posts.notifications.created');
621+
fireEvent.click(screen.getByText('Close'));
622+
expect(middlewareSpy).toHaveBeenCalledWith('posts', {
623+
data: { id: 2, title: 'Bazinga' },
624+
meta: undefined,
625+
});
626+
fireEvent.click(screen.getByText('Refresh'));
627+
await screen.findByText('Bazinga');
628+
});
577629
});
578630
});

packages/ra-core/src/dataProvider/useCreate.stories.tsx

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
import * as React from 'react';
22
import { QueryClient, useIsMutating } from '@tanstack/react-query';
33

4-
import { CoreAdminContext } from '../core';
4+
import { CoreAdmin, CoreAdminContext, Resource } from '../core';
55
import { useCreate } from './useCreate';
66
import { useGetOne } from './useGetOne';
77
import type { MutationMode as MutationModeType } from '../types';
8+
import {
9+
CreateBase,
10+
ListBase,
11+
RecordsIterator,
12+
useRegisterMutationMiddleware,
13+
} from '../controller';
14+
import { useNotificationContext } from '../notification';
15+
import { useTakeUndoableMutation } from './undo';
16+
import { Form, InputProps, useInput } from '../form';
17+
import { TestMemoryRouter } from '../routing';
18+
import { Link } from 'react-router-dom';
19+
import { testDataProvider } from './testDataProvider';
20+
import { useRefresh } from './useRefresh';
821

922
export default { title: 'ra-core/dataProvider/useCreate' };
1023

@@ -208,3 +221,166 @@ const ParamsCore = () => {
208221
</>
209222
);
210223
};
224+
225+
export const Middleware = ({
226+
middleware = (resource: string, params: any) => {
227+
console.log(
228+
`Creating resource ${resource} with params:`,
229+
JSON.stringify(params)
230+
);
231+
},
232+
mutationMode = 'undoable',
233+
timeout = 1000,
234+
}: {
235+
mutationMode?: MutationModeType;
236+
timeout?: number;
237+
middleware?: (resource: string, params: any) => void;
238+
}) => {
239+
const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }];
240+
const dataProvider = testDataProvider({
241+
// @ts-ignore
242+
getList: (resource, params) => {
243+
return Promise.resolve({
244+
data: posts,
245+
total: posts.length,
246+
});
247+
},
248+
create: (resource, params) => {
249+
return new Promise(resolve => {
250+
setTimeout(() => {
251+
const post = { id: posts.length + 1, ...params.data };
252+
// @ts-ignore
253+
posts.push(post);
254+
// @ts-ignore
255+
resolve({ data: post });
256+
}, timeout);
257+
});
258+
},
259+
});
260+
return (
261+
<TestMemoryRouter initialEntries={['/posts/create']}>
262+
<CoreAdmin
263+
queryClient={new QueryClient()}
264+
dataProvider={dataProvider}
265+
layout={({ children }) => (
266+
<>
267+
{children}
268+
<Notification />
269+
</>
270+
)}
271+
>
272+
<Resource
273+
name="posts"
274+
list={
275+
<ListBase>
276+
<ul>
277+
<RecordsIterator
278+
render={record => <li>{record.title}</li>}
279+
/>
280+
</ul>
281+
<RefreshButton />
282+
</ListBase>
283+
}
284+
create={
285+
<CreateBase
286+
mutationMode={mutationMode}
287+
redirect="list"
288+
transform={data => ({
289+
id:
290+
mutationMode === 'pessimistic'
291+
? undefined
292+
: posts.length + 1,
293+
...data,
294+
})}
295+
>
296+
<Form>
297+
<TextInput source="title" />
298+
<CreateMiddleware middleware={middleware} />
299+
<button type="submit">Save</button>
300+
</Form>
301+
</CreateBase>
302+
}
303+
/>
304+
</CoreAdmin>
305+
</TestMemoryRouter>
306+
);
307+
};
308+
309+
Middleware.args = {
310+
timeout: 1000,
311+
mutationMode: 'optimistic',
312+
};
313+
314+
Middleware.argTypes = {
315+
timeout: {
316+
control: {
317+
type: 'number',
318+
},
319+
},
320+
mutationMode: {
321+
control: {
322+
type: 'select',
323+
},
324+
options: ['pessimistic', 'optimistic', 'undoable'],
325+
},
326+
};
327+
328+
const CreateMiddleware = ({
329+
middleware,
330+
}: {
331+
middleware: (resource: string, params: any) => void;
332+
}) => {
333+
useRegisterMutationMiddleware((resource, params, next) => {
334+
middleware(resource, params);
335+
return next(resource, params);
336+
});
337+
338+
return null;
339+
};
340+
341+
const Notification = () => {
342+
const { notifications, resetNotifications } = useNotificationContext();
343+
const takeMutation = useTakeUndoableMutation();
344+
345+
return notifications.length > 0 ? (
346+
<>
347+
<div>{notifications[0].message}</div>
348+
<div style={{ display: 'flex', gap: '16px' }}>
349+
<button
350+
onClick={() => {
351+
if (notifications[0].notificationOptions.undoable) {
352+
const mutation = takeMutation();
353+
if (mutation) {
354+
mutation({ isUndo: false });
355+
}
356+
}
357+
resetNotifications();
358+
}}
359+
>
360+
Close
361+
</button>
362+
</div>
363+
</>
364+
) : null;
365+
};
366+
367+
const TextInput = (props: InputProps) => {
368+
const { field, id } = useInput(props);
369+
370+
return (
371+
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
372+
<label htmlFor={id}>{props.label || field.name}</label>
373+
<input id={id} {...field} />
374+
</div>
375+
);
376+
};
377+
378+
const RefreshButton = () => {
379+
const refresh = useRefresh();
380+
381+
return (
382+
<button type="button" onClick={() => refresh()}>
383+
Refresh
384+
</button>
385+
);
386+
};

packages/ra-core/src/dataProvider/useCreate.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -198,16 +198,22 @@ export const useCreate = <
198198

199199
return snapshot;
200200
},
201-
getMutateWithMiddlewares: mutationFn => args => {
202-
// This is necessary to avoid breaking changes in useCreate:
203-
// The mutation function must have the same signature as before (resource, params) and not ({ resource, params })
201+
getMutateWithMiddlewares: mutationFn => {
204202
if (getMutateWithMiddlewares) {
205-
const { resource, ...params } = args;
206-
return getMutateWithMiddlewares(
203+
// Immediately get the function with middlewares applied so that even if the middlewares gets unregistered (because of a redirect for instance),
204+
// we still have them applied when users have called the mutate function.
205+
const mutateWithMiddlewares = getMutateWithMiddlewares(
207206
dataProviderCreate.bind(dataProvider)
208-
)(resource, params);
207+
);
208+
return args => {
209+
// This is necessary to avoid breaking changes in useUpdate:
210+
// The mutation function must have the same signature as before (resource, params) and not ({ resource, params })
211+
const { resource, ...params } = args;
212+
return mutateWithMiddlewares(resource, params);
213+
};
209214
}
210-
return mutationFn(args);
215+
216+
return args => mutationFn(args);
211217
},
212218
onUndo: ({ resource, data, meta }) => {
213219
queryClient.removeQueries({

packages/ra-core/src/dataProvider/useUpdate.spec.tsx

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
WithMiddlewaresSuccess as WithMiddlewaresSuccessUndoable,
3232
WithMiddlewaresError as WithMiddlewaresErrorUndoable,
3333
} from './useUpdate.undoable.stories';
34-
import { MutationMode, Params } from './useUpdate.stories';
34+
import { Middleware, MutationMode, Params } from './useUpdate.stories';
3535

3636
describe('useUpdate', () => {
3737
describe('mutate', () => {
@@ -1183,6 +1183,58 @@ describe('useUpdate', () => {
11831183
expect(screen.queryByText('mutating')).toBeNull();
11841184
});
11851185
});
1186+
1187+
it(`it calls the middlewares in undoable mode even when they got unregistered`, async () => {
1188+
const middlewareSpy = jest.fn();
1189+
render(
1190+
<Middleware
1191+
mutationMode="undoable"
1192+
timeout={100}
1193+
middleware={middlewareSpy}
1194+
/>
1195+
);
1196+
1197+
fireEvent.change(screen.getByLabelText('title'), {
1198+
target: { value: 'Bazinga' },
1199+
});
1200+
fireEvent.click(screen.getByText('Save'));
1201+
await screen.findByText('resources.posts.notifications.updated');
1202+
expect(middlewareSpy).not.toHaveBeenCalled();
1203+
fireEvent.click(screen.getByText('Close'));
1204+
await waitFor(() => {
1205+
expect(middlewareSpy).toHaveBeenCalledWith('posts', {
1206+
id: '1',
1207+
data: { title: 'Bazinga' },
1208+
meta: undefined,
1209+
previousData: undefined,
1210+
});
1211+
});
1212+
});
1213+
it(`it calls the middlewares in optimistic mode even when they got unregistered`, async () => {
1214+
const middlewareSpy = jest.fn();
1215+
render(
1216+
<Middleware
1217+
mutationMode="optimistic"
1218+
timeout={0}
1219+
middleware={middlewareSpy}
1220+
/>
1221+
);
1222+
1223+
fireEvent.change(screen.getByLabelText('title'), {
1224+
target: { value: 'Bazinga' },
1225+
});
1226+
fireEvent.click(screen.getByText('Save'));
1227+
await screen.findByText('resources.posts.notifications.updated');
1228+
fireEvent.click(screen.getByText('Close'));
1229+
await waitFor(() => {
1230+
expect(middlewareSpy).toHaveBeenCalledWith('posts', {
1231+
id: '1',
1232+
data: { title: 'Bazinga' },
1233+
meta: undefined,
1234+
previousData: undefined,
1235+
});
1236+
});
1237+
});
11861238
});
11871239
});
11881240

0 commit comments

Comments
 (0)