Skip to content

Commit caca09e

Browse files
authored
Merge pull request marmelab#10296 from marmelab/undoable-mutation-context
Fix undo logic not working when doing multiple deletions one by one
2 parents 60bae65 + 33b0e53 commit caca09e

20 files changed

+298
-80
lines changed

packages/ra-core/src/controller/edit/useEditController.spec.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
} from '..';
1717
import { CoreAdminContext } from '../../core';
1818
import { testDataProvider } from '../../dataProvider';
19-
import undoableEventEmitter from '../../dataProvider/undoableEventEmitter';
19+
import { useTakeUndoableMutation } from '../../dataProvider/undo/useTakeUndoableMutation';
2020
import { Form, InputProps, useInput } from '../../form';
2121
import { useNotificationContext } from '../../notification';
2222
import { AuthProvider, DataProvider } from '../../types';
@@ -30,6 +30,20 @@ import {
3030
} from './useEditController.security.stories';
3131
import { EncodedId } from './useEditController.stories';
3232

33+
const Confirm = () => {
34+
const takeMutation = useTakeUndoableMutation();
35+
return (
36+
<button
37+
aria-label="confirm"
38+
onClick={() => {
39+
const mutation = takeMutation();
40+
if (!mutation) return;
41+
mutation({ isUndo: false });
42+
}}
43+
/>
44+
);
45+
};
46+
3347
describe('useEditController', () => {
3448
const defaultProps = {
3549
id: 12,
@@ -269,6 +283,7 @@ describe('useEditController', () => {
269283
aria-label="save"
270284
onClick={() => save!({ test: 'updated' })}
271285
/>
286+
<Confirm />
272287
</>
273288
);
274289
}}
@@ -287,7 +302,7 @@ describe('useEditController', () => {
287302
data: { test: 'updated' },
288303
previousData: { id: 12, test: 'previous' },
289304
});
290-
undoableEventEmitter.emit('end', { isUndo: false });
305+
screen.getByLabelText('confirm').click();
291306
await waitFor(() => {
292307
screen.getByText('updated');
293308
});
@@ -878,14 +893,14 @@ describe('useEditController', () => {
878893
<EditController {...defaultProps} mutationMode="undoable">
879894
{({ save }) => {
880895
saveCallback = save;
881-
return <div />;
896+
return <Confirm />;
882897
}}
883898
</EditController>
884899
</CoreAdminContext>
885900
);
886901
await act(async () => saveCallback({ foo: 'bar' }));
887902
await new Promise(resolve => setTimeout(resolve, 10));
888-
undoableEventEmitter.emit('end', { isUndo: false });
903+
screen.getByLabelText('confirm').click();
889904
await new Promise(resolve => setTimeout(resolve, 10));
890905
expect(notificationsSpy).toContainEqual({
891906
message: 'ra.notification.http_error',

packages/ra-core/src/core/CoreAdminContext.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { AdminRouter } from '../routing';
66
import { AuthContext, convertLegacyAuthProvider } from '../auth';
77
import {
88
DataProviderContext,
9+
UndoableMutationsContextProvider,
910
convertLegacyDataProvider,
1011
defaultDataProvider,
1112
} from '../dataProvider';
@@ -211,9 +212,11 @@ React-admin requires a valid dataProvider function to work.`);
211212
<AdminRouter basename={basename}>
212213
<I18nContextProvider value={i18nProvider}>
213214
<NotificationContextProvider>
214-
<ResourceDefinitionContextProvider>
215-
{children}
216-
</ResourceDefinitionContextProvider>
215+
<UndoableMutationsContextProvider>
216+
<ResourceDefinitionContextProvider>
217+
{children}
218+
</ResourceDefinitionContextProvider>
219+
</UndoableMutationsContextProvider>
217220
</NotificationContextProvider>
218221
</I18nContextProvider>
219222
</AdminRouter>

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export * from './useUpdateMany';
2525
export * from './useDelete';
2626
export * from './useDeleteMany';
2727
export * from './useInfiniteGetList';
28+
export * from './undo/';
2829

2930
export type { Options } from './fetch';
3031

@@ -33,5 +34,8 @@ export {
3334
DataProviderContext,
3435
fetchUtils,
3536
HttpError,
37+
/**
38+
* @deprecated use the useTakeUndoableMutation hook instead
39+
*/
3640
undoableEventEmitter,
3741
};
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createContext } from 'react';
2+
3+
import type { UndoableMutation } from './types';
4+
5+
export const AddUndoableMutationContext = createContext<
6+
(mutation: UndoableMutation) => void
7+
>(() => {});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createContext } from 'react';
2+
3+
import type { UndoableMutation } from './types';
4+
5+
export const TakeUndoableMutationContext = createContext<
6+
() => UndoableMutation | void
7+
>(() => {});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as React from 'react';
2+
import { useState, useCallback } from 'react';
3+
4+
import { AddUndoableMutationContext } from './AddUndoableMutationContext';
5+
import { TakeUndoableMutationContext } from './TakeUndoableMutationContext';
6+
import type { UndoableMutation } from './types';
7+
8+
/**
9+
* Exposes and manages a queue of undoable mutations
10+
*
11+
* This context is used in CoreAdminContext so that every react-admin app
12+
* can use the useAddUndoableMutation and useTakeUndoableMutation hooks.
13+
*
14+
* Note: We need a separate queue for mutations (instead of using the
15+
* notifications queue) because the mutations are not dequeued when the
16+
* notification is displayed, but when it is dismissed.
17+
*/
18+
export const UndoableMutationsContextProvider = ({ children }) => {
19+
const [mutations, setMutations] = useState<UndoableMutation[]>([]);
20+
21+
/**
22+
* Add a new mutation (pushes a new mutation to the queue).
23+
*
24+
* Used by optimistic data provider hooks, e.g., useDelete
25+
*/
26+
const addMutation = useCallback((mutation: UndoableMutation) => {
27+
setMutations(mutations => [...mutations, mutation]);
28+
}, []);
29+
30+
/**
31+
* Get the next mutation to execute (shifts the first mutation from the queue) and returns it.
32+
*
33+
* Used by the Notification component to process or undo the mutation
34+
*/
35+
const takeMutation = useCallback(() => {
36+
if (mutations.length === 0) return;
37+
const [mutation, ...rest] = mutations;
38+
setMutations(rest);
39+
return mutation;
40+
}, [mutations]);
41+
42+
return (
43+
<TakeUndoableMutationContext.Provider value={takeMutation}>
44+
<AddUndoableMutationContext.Provider value={addMutation}>
45+
{children}
46+
</AddUndoableMutationContext.Provider>
47+
</TakeUndoableMutationContext.Provider>
48+
);
49+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export * from './AddUndoableMutationContext';
2+
export * from './TakeUndoableMutationContext';
3+
export * from './UndoableMutationsContextProvider';
4+
export * from './types';
5+
export * from './useAddUndoableMutation';
6+
export * from './useTakeUndoableMutation';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type UndoableMutation = (params: { isUndo: boolean }) => void;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { useContext } from 'react';
2+
import { AddUndoableMutationContext } from './AddUndoableMutationContext';
3+
4+
export const useAddUndoableMutation = () =>
5+
useContext(AddUndoableMutationContext);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { useContext } from 'react';
2+
import { TakeUndoableMutationContext } from './TakeUndoableMutationContext';
3+
4+
export const useTakeUndoableMutation = () =>
5+
useContext(TakeUndoableMutationContext);

0 commit comments

Comments
 (0)