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

Optimistic mode #5799

Merged
merged 14 commits into from
Jan 30, 2021
11 changes: 4 additions & 7 deletions examples/simple/src/posts/ResetViewsButton.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,9 @@ const ResetViewsButton = ({ resource, selectedIds }) => {
{
action: CRUD_UPDATE_MANY,
onSuccess: () => {
notify(
'ra.notification.updated',
'info',
{ smart_count: selectedIds.length },
true
);
notify('ra.notification.updated', 'info', {
smart_count: selectedIds.length,
});
unselectAll(resource);
},
onFailure: error =>
Expand All @@ -34,7 +31,7 @@ const ResetViewsButton = ({ resource, selectedIds }) => {
: error.message || 'ra.notification.http_error',
'warning'
),
undoable: true,
mode: 'optimistic',
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const useDeleteWithUndoController = (
);
refresh();
},
undoable: true,
mutationMode: 'undoable',
});
const handleDelete = useCallback(
event => {
Expand Down
14 changes: 9 additions & 5 deletions packages/ra-core/src/controller/details/useEditController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import inflection from 'inflection';

import useVersion from '../useVersion';
import { useCheckMinimumRequiredProps } from '../checkMinimumRequiredProps';
import { Record, Identifier } from '../../types';
import { Record, Identifier, MutationMode } from '../../types';
import {
useNotify,
useRedirect,
Expand Down Expand Up @@ -32,7 +32,9 @@ export interface EditProps {
hasList?: boolean;
id?: Identifier;
resource?: string;
/** @deprecated use mutationMode: undoable instead */
undoable?: boolean;
mutationMode?: MutationMode;
onSuccess?: OnSuccess;
onFailure?: OnFailure;
transform?: TransformData;
Expand Down Expand Up @@ -103,9 +105,11 @@ export const useEditController = <RecordType extends Record = Record>(
hasShow,
id,
successMessage,
// @deprecated use mutationMode: undoable instaed
undoable = true,
onSuccess,
onFailure,
mutationMode = undoable ? 'undoable' : undefined,
transform,
} = props;
const resource = useResourceContext(props);
Expand Down Expand Up @@ -193,7 +197,7 @@ export const useEditController = <RecordType extends Record = Record>(
{
smart_count: 1,
},
undoable
mutationMode === 'undoable'
);
redirect(redirectTo, basePath, data.id, data);
},
Expand All @@ -217,11 +221,11 @@ export const useEditController = <RecordType extends Record = Record>(
: undefined,
}
);
if (undoable) {
if (mutationMode === 'undoable') {
refresh();
}
},
undoable,
mutationMode,
}
)
),
Expand All @@ -230,12 +234,12 @@ export const useEditController = <RecordType extends Record = Record>(
update,
onSuccessRef,
onFailureRef,
undoable,
notify,
successMessage,
redirect,
basePath,
refresh,
mutationMode,
]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const OptionsProperties = [
'onFailure',
'onSuccess',
'undoable',
'mode',
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
];

const isDataProviderOptions = (value: any) => {
Expand All @@ -20,7 +21,14 @@ const isDataProviderOptions = (value: any) => {
// As all dataProvider methods do not have the same signature, we must differentiate
// standard methods which have the (resource, params, options) signature
// from the custom ones
export const getDataProviderCallArguments = (args: any[]) => {
export const getDataProviderCallArguments = (
args: any[]
): {
resource: string;
payload: any;
options: UseDataProviderOptions;
allArguments: any[];
} => {
const lastArg = args[args.length - 1];
let allArguments = [...args];

Expand Down
9 changes: 6 additions & 3 deletions packages/ra-core/src/dataProvider/useCreate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import useMutation from './useMutation';
import useMutation, { MutationOptions } from './useMutation';

/**
* Get a callback to call the dataProvider.create() method, the result and the loading state.
Expand Down Expand Up @@ -26,7 +26,10 @@ import useMutation from './useMutation';
* return <button disabled={loading} onClick={create}>Like</button>;
* };
*/
const useCreate = (resource: string, data: any = {}, options?: any) =>
useMutation({ type: 'create', resource, payload: { data } }, options);
const useCreate = (
resource: string,
data: any = {},
options?: MutationOptions
) => useMutation({ type: 'create', resource, payload: { data } }, options);

export default useCreate;
152 changes: 152 additions & 0 deletions packages/ra-core/src/dataProvider/useDataProvider.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useDataProvider from './useDataProvider';
import useUpdate from './useUpdate';
import { DataProviderContext } from '../dataProvider';
import { useRefresh } from '../sideEffect';
import undoableEventEmitter from './undoableEventEmitter';

const UseGetOne = () => {
const [data, setData] = useState();
Expand Down Expand Up @@ -317,6 +318,157 @@ describe('useDataProvider', () => {
expect(onFailure.mock.calls).toHaveLength(1);
expect(onFailure.mock.calls[0][0]).toEqual(new Error('foo'));
});

describe('mutationMode', () => {
it('should wait for response to dispatch side effects in pessimistic mode', async () => {
let resolveUpdate;
const update = jest.fn(() =>
new Promise(resolve => {
resolveUpdate = resolve;
}).then(() => ({ data: { id: 1, updated: true } }))
);
const dataProvider = { update };
const UpdateButton = () => {
const [updated, setUpdated] = useState(false);
const dataProvider = useDataProvider();
return (
<button
onClick={() =>
dataProvider.update(
'foo',
{},
{
onSuccess: () => {
setUpdated(true);
},
mutationMode: 'pessimistic',
}
)
}
>
{updated ? '(updated)' : 'update'}
</button>
);
};
const { getByText, queryByText } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UpdateButton />
</DataProviderContext.Provider>,
{ admin: { resources: { posts: { data: {}, list: {} } } } }
);
// click on the update button
await act(async () => {
fireEvent.click(getByText('update'));
await new Promise(r => setTimeout(r));
});
expect(update).toBeCalledTimes(1);
// make sure the side effect hasn't been applied yet
expect(queryByText('(updated)')).toBeNull();
await act(() => {
resolveUpdate();
});
// side effects should be applied now
expect(queryByText('(updated)')).not.toBeNull();
});

it('should not wait for response to dispatch side effects in optimistic mode', async () => {
let resolveUpdate;
const update = jest.fn(() =>
new Promise(resolve => {
resolveUpdate = resolve;
}).then(() => ({ data: { id: 1, updated: true } }))
);
const dataProvider = { update };
const UpdateButton = () => {
const [updated, setUpdated] = useState(false);
const dataProvider = useDataProvider();
return (
<button
onClick={() =>
dataProvider.update(
'foo',
{},
{
onSuccess: () => {
setUpdated(true);
},
mutationMode: 'optimistic',
}
)
}
>
{updated ? '(updated)' : 'update'}
</button>
);
};
const { getByText, queryByText } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UpdateButton />
</DataProviderContext.Provider>,
{ admin: { resources: { posts: { data: {}, list: {} } } } }
);
// click on the update button
await act(async () => {
fireEvent.click(getByText('update'));
await new Promise(r => setTimeout(r));
});
// side effects should be applied now
expect(queryByText('(updated)')).not.toBeNull();
expect(update).toBeCalledTimes(1);
await act(() => {
resolveUpdate();
});
});

it('should not wait for response to dispatch side effects in undoable mode', async () => {
const update = jest.fn({
apply: () =>
Promise.resolve({ data: { id: 1, updated: true } }),
});
const dataProvider = { update };
const UpdateButton = () => {
const [updated, setUpdated] = useState(false);
const dataProvider = useDataProvider();
return (
<button
onClick={() =>
dataProvider.update(
'foo',
{},
{
onSuccess: () => {
setUpdated(true);
},
mutationMode: 'undoable',
}
)
}
>
{updated ? '(updated)' : 'update'}
</button>
);
};
const { getByText, queryByText } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UpdateButton />
</DataProviderContext.Provider>,
{ admin: { resources: { posts: { data: {}, list: {} } } } }
);
// click on the update button
await act(async () => {
fireEvent.click(getByText('update'));
await new Promise(r => setTimeout(r));
});
// side effects should be applied now
expect(queryByText('(updated)')).not.toBeNull();
// update shouldn't be called at all
expect(update).toBeCalledTimes(0);
await act(() => {
undoableEventEmitter.emit('end', {});
});
expect(update).toBeCalledTimes(1);
});
});
});

describe('cache', () => {
Expand Down
Loading