diff --git a/UPGRADE.md b/UPGRADE.md index f349a80eb50..f118a06f2dd 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -108,6 +108,203 @@ This should be mostly transparent for you unless: - you used `useHistory` to navigate: see [https://reactrouter.com/docs/en/v6/upgrading/v5#use-usenavigate-instead-of-usehistory](https://reactrouter.com/docs/en/v6/upgrading/v5#use-usenavigate-instead-of-usehistory) to upgrade. - you had custom components similar to our `TabbedForm` or `TabbedShowLayout` (declaring multiple sub routes): see [https://reactrouter.com/docs/en/v6/upgrading/v5](https://reactrouter.com/docs/en/v6/upgrading/v5) to upgrade. +## `useQuery`, `useMutation`, and `useQueryWithStore` Have Been Removed + +React-admin v4 uses react-query rather than Redux for data fetching. The base react-query data fetching hooks (`useQuery`, `useMutation`, and `useQueryWithStore`) are no longer necessary as their functionality is provided by react-query. + +If your application code uses these hooks, you have 2 ways to upgrade. + +If you're using `useQuery` or `useMutation` to call a regular dataProvider method (like `useGetOne`), then you can use the specialized dataProvider hooks instead: + +```diff +import * as React from "react"; +-import { useQuery } from 'react-admin'; ++import { useGetOne } from 'react-admin'; +import { Loading, Error } from '../ui'; +const UserProfile = ({ record }) => { +- const { loaded, error, data } = useQuery({ +- type: 'getOne', +- resource: 'users', +- payload: { id: record.id } +- }); ++ const { data, isLoading, error } = useGetOne( ++ 'users', ++ { id: record.id } ++ ); +- if (!loaded) { return ; } ++ if (isLoading) { return ; } + if (error) { return ; } + return
User {data.username}
; +}; +``` + +If you're calling a custom dataProvider method, then you can use react-query's `useQuery` or `useMutation` instead: + +```diff +-import { useMutation } from 'react-admin'; ++import { useDataProvider } from 'react-admin'; ++import { useMutation } from 'react-query'; +const BanUserButton = ({ userId }) => { +- const [mutate, { loading, error, data }] = useMutation({ +- type: 'banUser', +- payload: userId +- }); ++ const dataProvider = useDataProvider(); ++ const { mutate, isLoading } = useMutation( ++ ['banUser', userId], ++ () => dataProvider.banUser(userId) ++ ); +- return ; }; -// set params when calling the hook +// set params when calling the create callback import { useCreate } from 'react-admin'; const LikeButton = ({ record }) => { const like = { postId: record.id }; - const [create, { isLoading, error }] = useCreate('likes', { data: like }); + const [create, { isLoading, error }] = useCreate(); const handleClick = () => { - create() + create('likes', { data: like }) } if (error) { return

ERROR

; } return ; }; ``` -### `useUpdate` +## `useUpdate` + +This hook allows to call `dataProvider.update()` when the callback is executed, and update a single record based on its `id` and a `data` argument. ```jsx // syntax -const [update, { data, isLoading, error }] = useUpdate(resource, { id, data, previousData }, options); +const [update, { data, isLoading, error }] = useUpdate( + resource, + { id, data, previousData }, + options +); ``` The `update()` method can be called with the same parameters as the hook: ```jsx -update(resource, { id, data, previousData }, options); +update( + resource, + { id, data, previousData }, + options +); ``` -This means the parameters can be passed either when calling the hook, or when calling the callback. +This means the parameters can be passed either when calling the hook, or when calling the callback. It's up to you to pick the syntax that best suits your component. If you have the choice, we recommend passing the parameters when calling the hook (second example below). ```jsx -// set params when calling the update callback +// set params when calling the hook import { useUpdate } from 'react-admin'; const IncreaseLikeButton = ({ record }) => { const diff = { likes: record.likes + 1 }; - const [update, { isLoading, error }] = useUpdate(); + const [update, { isLoading, error }] = useUpdate( + 'likes', + { id: record.id, data: diff, previousData: record } + ); const handleClick = () => { - update('likes', { id: record.id, data: diff, previousData: record }) + update() } if (error) { return

ERROR

; } return ; }; -// or set params when calling the hook +// set params when calling the update callback import { useUpdate } from 'react-admin'; const IncreaseLikeButton = ({ record }) => { const diff = { likes: record.likes + 1 }; - const [update, { isLoading, error }] = useUpdate('likes', { id: record.id, data: diff, previousData: record }); + const [update, { isLoading, error }] = useUpdate(); const handleClick = () => { - update() + update( + 'likes', + { id: record.id, data: diff, previousData: record } + ) } if (error) { return

ERROR

; } return ; }; ``` -### `useUpdateMany` +## `useUpdateMany` + +This hook allows to call `dataProvider.updateMany()` when the callback is executed, and update an array of records based on their `ids` and a `data` argument. + ```jsx // syntax -const [updateMany, { data, isFetching, isLoading, error }] = useUpdateMany(resource, { ids, data }, options); +const [updateMany, { data, isLoading, error }] = useUpdateMany( + resource, + { ids, data }, + options +); ``` The `updateMany()` method can be called with the same parameters as the hook: ```jsx -updateMany(resource, { ids, data }, options); +updateMany( + resource, + { ids, data }, + options +); ``` +So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the hook (second example below). + ```jsx -// set params when calling the updateMany callback +// set params when calling the hook import { useUpdateMany } from 'react-admin'; const BulkResetViewsButton = ({ selectedIds }) => { - const [updateMany, { isLoading, error }] = useUpdateMany(); + const [updateMany, { isLoading, error }] = useUpdateMany( + 'posts', + { ids: selectedIds, data: { views: 0 } } + ); const handleClick = () => { - updateMany('posts', { ids: selectedIds, data: { views: 0 } }); + updateMany(); } if (error) { return

ERROR

; } return ; }; -// set params when calling the hook +// set params when calling the updateMany callback import { useUpdateMany } from 'react-admin'; const BulkResetViewsButton = ({ selectedIds }) => { - const [updateMany, { isLoading, error }] = useUpdateMany('posts', { ids: selectedIds, data: { views: 0 } }); + const [updateMany, { isLoading, error }] = useUpdateMany(); const handleClick = () => { - updateMany(); + updateMany( + 'posts', + { ids: selectedIds, data: { views: 0 } } + ); } if (error) { return

ERROR

; } return ; }; ``` -### `useDelete` +## `useDelete` + +This hook allows calling `dataProvider.delete()` when the callback is executed and deleting a single record based on its `id`. ```jsx // syntax -const [deleteOne, { data, isFetching, isLoading, error }] = useDelete(resource, { id, previousData }, options); +const [deleteOne, { data, isLoading, error }] = useDelete( + resource, + { id, previousData }, + options +); ``` The `deleteOne()` method can be called with the same parameters as the hook: ```jsx -deleteOne(resource, { id, previousData }, options); +deleteOne( + resource, + { id, previousData }, + options +); ``` +So, should you pass the parameters when calling the hook, or when executing the callback? It's up to you; but if you have the choice, we recommend passing the parameters when calling the hook (second example below). + ```jsx -// set params when calling the deleteOne callback +// set params when calling the hook import { useDelete } from 'react-admin'; const DeleteButton = ({ record }) => { - const [deleteOne, { isLoading, error }] = useDelete(); + const [deleteOne, { isLoading, error }] = useDelete( + 'likes', + { id: record.id, previousData: record } + ); const handleClick = () => { - deleteOne('likes', { id: record.id , previousData: record }) + deleteOne(); } if (error) { return

ERROR

; } - return ; }; -// set params when calling the hook +// set params when calling the deleteOne callback import { useDelete } from 'react-admin'; const DeleteButton = ({ record }) => { - const [deleteOne, { isLoading, error }] = useDelete('likes', { id: record.id, previousData: record }); + const [deleteOne, { isLoading, error }] = useDelete(); const handleClick = () => { - deleteOne() + deleteOne( + 'likes', + { id: record.id , previousData: record } + ); } if (error) { return

ERROR

; } - return ; + return ; }; -// set params when calling the hook +// set params when calling the deleteMany callback import { useDeleteMany } from 'react-admin'; const BulkDeletePostsButton = ({ selectedIds }) => { - const [deleteMany, { isLoading, error }] = useDeleteMany('posts', { ids: selectedIds }); + const [deleteMany, { isLoading, error }] = useDeleteMany(); const handleClick = () => { - deleteMany() + deleteMany( + 'posts', + { ids: selectedIds } + ) } if (error) { return

ERROR

; } return ; }; ``` -## Synchronizing Dependant Queries +## React-query + +Internally, react-admin uses [react-query](https://react-query.tanstack.com/) to call the dataProvider. When fetching data from the dataProvider in your components, if you can't use any of the dataProvider method hooks, you should use that library, too. It brings several benefits: + +1. It triggers the loader in the AppBar when the query is running. +2. It reduces the boilerplate code since you don't need to use `useState`. +3. It supports a vast array of options +3. It displays stale data while fetching up-to-date data, leading to a snappier UI + +React-query offers 2 main hooks to interact with the dataProvider: + +* [`useQuery`](https://react-query.tanstack.com/reference/useQuery): fetches the dataProvider on mount. This is for *read* queries. +* [`useMutation`](https://react-query.tanstack.com/reference/useMutation): fetches the dataProvider when you call a callback. This is for *write* queries, and *read* queries that execute on user interaction. + +Both these hooks accept a query *key* (identifying the query in the cache), and a query *function* (executing the query and returning a Promise). Internally, react-admin uses an array of arguments as the query key. + +For instance, the initial code snippet of this chapter can be rewritten with `useQuery` as follows: + +```jsx +import * as React from "react"; +import { useQuery } from 'react-query'; +import { useDataProvider, Loading, Error } from 'react-admin'; + +const UserProfile = ({ userId }) => { + const dataProvider = useDataProvider(); + const { data, isLoading, error } = useQuery( + ['user', 'getOne', userId], + () => dataProvider.getOne('users', { id: userId }) + ); + + if (isLoading) return ; + if (error) return ; + if (!data) return null; + + return ( +
    +
  • Name: {data.name}
  • +
  • Email: {data.email}
  • +
+ ) +}; +``` + +To illustrate the usage of `useMutation`, here is an implementation of an "Approve" button for a comment: + +```jsx +import * as React from "react"; +import { useMutation } from 'react-query'; +import { useDataProvider, Button } from 'react-admin'; + +const ApproveButton = ({ record }) => { + const dataProvider = useDataProvider(); + const { mutate, isLoading } = useMutation( + ['comments', 'update', { id: record.id, data: { isApproved: true } }], + () => dataProvider.update('comments', { id: record.id, data: { isApproved: true } }) + ); + return - )} - - ); - }; - let getByTestId; - act(() => { - const res = render( - - - {({ store }) => { - dispatchSpy = jest.spyOn(store, 'dispatch'); - return ; - }} - - - ); - getByTestId = res.getByTestId; - }); - - const testElement = getByTestId('test'); - fireEvent.click(testElement); - await waitFor(() => { - expect(dispatchSpy).toHaveBeenCalledWith( - showNotification('Youhou!', 'info') - ); - }); - }); - - it('supports onFailure side effects using hooks', async () => { - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - let dispatchSpy; - const dataProvider = { - mytype: jest.fn(() => - Promise.reject({ message: 'provider error' }) - ), - }; - - const Foo = () => { - const notify = useNotify(); - return ( - { - notify('Damn!', { type: 'warning' }); - }, - }} - > - {(mutate, { error }) => ( - - )} - - ); - }; - let getByTestId; - act(() => { - const res = render( - - - {({ store }) => { - dispatchSpy = jest.spyOn(store, 'dispatch'); - return ; - }} - - - ); - getByTestId = res.getByTestId; - }); - - const testElement = getByTestId('test'); - fireEvent.click(testElement); - await waitFor(() => { - expect(dispatchSpy).toHaveBeenCalledWith( - showNotification('Damn!', 'warning') - ); - }); - }); -}); diff --git a/packages/ra-core/src/dataProvider/Mutation.tsx b/packages/ra-core/src/dataProvider/Mutation.tsx deleted file mode 100644 index 2a0ea1ac508..00000000000 --- a/packages/ra-core/src/dataProvider/Mutation.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import useMutation from './useMutation'; - -interface ChildrenFuncParams { - data?: any; - total?: number; - error?: any; - loading: boolean; - loaded: boolean; -} - -export interface MutationProps { - children: ( - mutate: ( - event?: any, - callTimePayload?: any, - callTimeOptions?: any - ) => void | Promise, - params: ChildrenFuncParams - ) => JSX.Element; - type: string; - resource?: string; - payload?: any; - options?: any; -} - -/** - * Get a callback to call the data provider and pass the result to a child function - * - * @param {Function} children Must be a function which will be called with the mutate callback - * @param {string} type The method called on the data provider, e.g. 'update', 'delete'. Can also be a custom method if the dataProvider supports is. - * @param {string} resource A resource name, e.g. 'posts', 'comments' - * @param {Object} payload The payload object, e.g; { id: 12 } - * @param {Object} options - * @param {string} options.action Redux action type - * @param {boolean} options.mutationMode Either 'optimistic', 'pessimistic' or 'undoable' - * @param {boolean} options.returnPromise Set to true to return the result promise of the mutation - * @param {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() } - * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } - * - * @example - * - * const ApproveButton = ({ record }) => ( - * - * {approve => ( - * - ); - }; - - render( - - - - {() => - - ); - }; - const getOne = jest - .fn() - .mockResolvedValue({ data: { id: 1, title: 'foo' } }); - const dataProvider = { getOne }; - const { queryByTestId, getByRole } = renderWithRedux( - - - - ); - expect(queryByTestId('loading')).not.toBeNull(); - await act(async () => { - await new Promise(resolve => setTimeout(resolve)); - }); - expect(getOne).not.toBeCalled(); - expect(queryByTestId('loading')).not.toBeNull(); - - // enable the query - fireEvent.click(getByRole('button', { name: 'toggle' })); - - await act(async () => { - await new Promise(resolve => setTimeout(resolve)); - }); - expect(getOne).toBeCalledTimes(1); - expect(queryByTestId('loading')).toBeNull(); - expect(queryByTestId('data').textContent).toBe( - '{"id":1,"title":"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 ( - - ); - }; - const { getByText, queryByText } = renderWithRedux( - - - , - { 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(async () => { - 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 ( - - ); - }; - const { getByText, queryByText } = renderWithRedux( - - - , - { 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); - 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 ( - - ); - }; - const { getByText, queryByText } = renderWithRedux( - - - , - { 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); - act(() => { - undoableEventEmitter.emit('end', {}); - }); - expect(update).toBeCalledTimes(1); - }); - }); - }); - - describe('cache', () => { - it('should not skip the dataProvider call if there is no cache', async () => { - const getOne = jest.fn(() => Promise.resolve({ data: { id: 1 } })); - const dataProvider = { getOne }; - const { rerender } = renderWithRedux( - - - , - { admin: { resources: { posts: { data: {}, list: {} } } } } - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(1); - rerender( - - - - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(2); - }); - - // to be revisited once we reimplement caching via react-query - it.skip('should skip the dataProvider call if there is a valid cache', async () => { - const getOne = jest.fn(() => { - const validUntil = new Date(); - validUntil.setTime(validUntil.getTime() + 1000); - return Promise.resolve({ data: { id: 1 }, validUntil }); - }); - const dataProvider = { getOne }; - const { rerender } = renderWithRedux( - - - , - { admin: { resources: { posts: { data: {}, list: {} } } } } - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(1); - rerender( - - - - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(1); - }); - - it('should not skip the dataProvider call if there is an invalid cache', async () => { - const getOne = jest.fn(() => { - const validUntil = new Date(); - validUntil.setTime(validUntil.getTime() - 1000); - return Promise.resolve({ data: { id: 1 }, validUntil }); - }); - const dataProvider = { getOne }; - const { rerender } = renderWithRedux( - - - , - { admin: { resources: { posts: { data: {}, list: {} } } } } - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(1); - rerender( - - - - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(2); - }); - - it('should not use the cache after a refresh', async () => { - const getOne = jest.fn(() => { - const validUntil = new Date(); - validUntil.setTime(validUntil.getTime() + 1000); - return Promise.resolve({ data: { id: 1 }, validUntil }); - }); - const dataProvider = { getOne }; - const Refresh = () => { - const refresh = useRefresh(); - return ; - }; - const { getByText, rerender } = renderWithRedux( - - - - , - { admin: { resources: { posts: { data: {}, list: {} } } } } - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - // click on the refresh button - expect(getOne).toBeCalledTimes(1); - await act(async () => { - fireEvent.click(getByText('refresh')); - await new Promise(r => setTimeout(r)); - }); - rerender( - - - - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(2); - }); - - it('should not use the cache after a hard refresh', async () => { - const getOne = jest.fn(() => { - const validUntil = new Date(); - validUntil.setTime(validUntil.getTime() + 1000); - return Promise.resolve({ data: { id: 1 }, validUntil }); - }); - const dataProvider = { getOne }; - const Refresh = () => { - const refresh = useRefresh(); - return ; - }; - const { getByText, rerender } = renderWithRedux( - - - - , - { admin: { resources: { posts: { data: {}, list: {} } } } } - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - // click on the refresh button - expect(getOne).toBeCalledTimes(1); - await act(async () => { - fireEvent.click(getByText('refresh')); - await new Promise(r => setTimeout(r)); - }); - rerender( - - - - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(2); - }); - - it('should not use the cache after an update', async () => { - const getOne = jest.fn(() => { - const validUntil = new Date(); - validUntil.setTime(validUntil.getTime() + 1000); - return Promise.resolve({ data: { id: 1 }, validUntil }); - }); - const dataProvider = { - getOne, - update: () => Promise.resolve({ data: { id: 1, foo: 'bar' } }), - }; - const Update = () => { - const [update] = useUpdate('posts', { - id: 1, - data: { foo: 'bar ' }, - }); - return ; - }; - const { getByText, rerender } = renderWithRedux( - - - - - - , - { admin: { resources: { posts: { data: {}, list: {} } } } } - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(1); - // click on the update button - await act(async () => { - fireEvent.click(getByText('update')); - await new Promise(r => setTimeout(r)); - }); - rerender( - - - - ); - // waitFor for the dataProvider to return - await act(async () => await new Promise(r => setTimeout(r))); - expect(getOne).toBeCalledTimes(2); - }); }); }); diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index 52e598ecf81..f4681e3995a 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -1,27 +1,18 @@ import { useContext, useMemo } from 'react'; -import { Dispatch } from 'redux'; -import { useDispatch, useStore } from 'react-redux'; import DataProviderContext from './DataProviderContext'; import defaultDataProvider from './defaultDataProvider'; -import { ReduxState, DataProvider, DataProviderProxy } from '../types'; +import validateResponseFormat from './validateResponseFormat'; +import { DataProvider } from '../types'; import useLogoutIfAccessDenied from '../auth/useLogoutIfAccessDenied'; -import { getDataProviderCallArguments } from './getDataProviderCallArguments'; -import { doQuery } from './performQuery'; /** * Hook for getting a dataProvider * * Gets a dataProvider object, which behaves just like the real dataProvider - * (same methods returning a Promise). But it's actually a Proxy object, which - * dispatches Redux actions along the process. The benefit is that react-admin - * tracks the loading state when using this hook, and stores results in the - * Redux store for future use. - * - * In addition to the 2 usual parameters of the dataProvider methods (resource, - * payload), the Proxy supports a third parameter for every call. It's an - * object literal which may contain side effects, or make the action optimistic - * (with mutationMode: optimistic) or undoable (with mutationMode: undoable). + * (same methods returning a Promise). But it's actually a Proxy object, + * which validates the response format, and logs the user out upon error + * if authProvider.checkError() rejects. * * @return dataProvider * @@ -79,36 +70,13 @@ import { doQuery } from './performQuery'; * * ) * } - * - * @example Action customization - * - * dataProvider.getOne('users', { id: 123 }); - * // will dispatch the following actions: - * // - CUSTOM_FETCH - * // - CUSTOM_FETCH_LOADING - * // - FETCH_START - * // - CUSTOM_FETCH_SUCCESS - * // - FETCH_END - * - * dataProvider.getOne('users', { id: 123 }, { action: CRUD_GET_ONE }); - * // will dispatch the following actions: - * // - CRUD_GET_ONE - * // - CRUD_GET_ONE_LOADING - * // - FETCH_START - * // - CRUD_GET_ONE_SUCCESS - * // - FETCH_END */ -const useDataProvider = < - TDataProvider extends DataProvider = DataProvider, - TDataProviderProxy extends DataProviderProxy< - TDataProvider - > = DataProviderProxy ->(): TDataProviderProxy => { - const dispatch = useDispatch() as Dispatch; +export const useDataProvider = < + TDataProvider extends DataProvider = DataProvider +>(): TDataProvider => { const dataProvider = ((useContext(DataProviderContext) || defaultDataProvider) as unknown) as TDataProvider; - const store = useStore(); const logoutIfAccessDenied = useLogoutIfAccessDenied(); const dataProviderProxy = useMemo(() => { @@ -118,74 +86,46 @@ const useDataProvider = < return; } return (...args) => { - const { - resource, - payload, - allArguments, - options, - } = getDataProviderCallArguments(args); - const type = name.toString(); - const { - action = 'CUSTOM_FETCH', - onSuccess = undefined, - onFailure = undefined, - mutationMode = 'pessimistic', - enabled = true, - ...rest - } = options || {}; if (typeof dataProvider[type] !== 'function') { throw new Error( `Unknown dataProvider function: ${type}` ); } - if (onSuccess && typeof onSuccess !== 'function') { - throw new Error( - 'The onSuccess option must be a function' - ); - } - if (onFailure && typeof onFailure !== 'function') { - throw new Error( - 'The onFailure option must be a function' - ); - } - if (mutationMode === 'undoable' && !onSuccess) { + + try { + return dataProvider[type] + .apply(dataProvider, args) + .then(response => { + if (process.env.NODE_ENV !== 'production') { + validateResponseFormat(response, type); + } + return response; + }) + .catch(error => { + if (process.env.NODE_ENV !== 'production') { + console.error(error); + } + return logoutIfAccessDenied(error).then( + loggedOut => { + if (loggedOut) return; + throw error; + } + ); + }); + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + console.error(e); + } throw new Error( - 'You must pass an onSuccess callback calling notify() to use the undoable mode' + 'The dataProvider threw an error. It should return a rejected Promise instead.' ); } - if (typeof enabled !== 'boolean') { - throw new Error('The enabled option must be a boolean'); - } - - if (enabled === false) { - return Promise.resolve({}); - } - - const params = { - resource, - type, - payload, - action, - onFailure, - onSuccess, - rest, - mutationMode, - // these ones are passed down because of the rules of hooks - dataProvider, - store, - dispatch, - logoutIfAccessDenied, - allArguments, - }; - return doQuery(params); }; }, }); - }, [dataProvider, dispatch, logoutIfAccessDenied, store]); + }, [dataProvider, logoutIfAccessDenied]); - return (dataProviderProxy as unknown) as TDataProviderProxy; + return dataProviderProxy; }; - -export default useDataProvider; diff --git a/packages/ra-core/src/dataProvider/useDelete.ts b/packages/ra-core/src/dataProvider/useDelete.ts index ee2296ca25f..5ca6b12bac5 100644 --- a/packages/ra-core/src/dataProvider/useDelete.ts +++ b/packages/ra-core/src/dataProvider/useDelete.ts @@ -8,7 +8,7 @@ import { QueryKey, } from 'react-query'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; import undoableEventEmitter from './undoableEventEmitter'; import { Record, DeleteParams, MutationMode } from '../types'; @@ -18,7 +18,7 @@ import { Record, DeleteParams, MutationMode } from '../types'; * @param {string} resource * @param {Params} params The delete parameters { id, previousData } * @param {Object} options Options object to pass to the queryClient. - * May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } * May include a mutation mode (optimistic/pessimistic/undoable), e.g. { mutationMode: 'undoable' } * * @typedef Params diff --git a/packages/ra-core/src/dataProvider/useDeleteMany.ts b/packages/ra-core/src/dataProvider/useDeleteMany.ts index b3698e49301..7e25a53aefe 100644 --- a/packages/ra-core/src/dataProvider/useDeleteMany.ts +++ b/packages/ra-core/src/dataProvider/useDeleteMany.ts @@ -8,7 +8,7 @@ import { QueryKey, } from 'react-query'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; import undoableEventEmitter from './undoableEventEmitter'; import { Record, DeleteManyParams, MutationMode } from '../types'; @@ -18,7 +18,7 @@ import { Record, DeleteManyParams, MutationMode } from '../types'; * @param {string} resource * @param {Params} params The delete parameters { ids } * @param {Object} options Options object to pass to the queryClient. - * May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } * May include a mutation mode (optimistic/pessimistic/undoable), e.g. { mutationMode: 'undoable' } * * @typedef Params diff --git a/packages/ra-core/src/dataProvider/useGetList.ts b/packages/ra-core/src/dataProvider/useGetList.ts index 3601031fab5..6024793f588 100644 --- a/packages/ra-core/src/dataProvider/useGetList.ts +++ b/packages/ra-core/src/dataProvider/useGetList.ts @@ -6,7 +6,7 @@ import { } from 'react-query'; import { Record, GetListParams } from '../types'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; /** * Call the dataProvider.getList() method and return the resolved result @@ -24,7 +24,7 @@ import useDataProvider from './useDataProvider'; * @param {string} resource The resource name, e.g. 'posts' * @param {Params} params The getList parameters { pagination, sort, filter } * @param {Object} options Options object to pass to the queryClient. - * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh() } } + * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } * * @typedef Params * @prop params.pagination The request pagination { page, perPage }, e.g. { page: 1, perPage: 10 } diff --git a/packages/ra-core/src/dataProvider/useGetMany.ts b/packages/ra-core/src/dataProvider/useGetMany.ts index c20c1476060..9b7d0c21187 100644 --- a/packages/ra-core/src/dataProvider/useGetMany.ts +++ b/packages/ra-core/src/dataProvider/useGetMany.ts @@ -7,7 +7,7 @@ import { } from 'react-query'; import { Record, GetManyParams } from '../types'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; /** * Call the dataProvider.getMany() method and return the resolved result @@ -25,7 +25,7 @@ import useDataProvider from './useDataProvider'; * @param {string} resource The resource name, e.g. 'posts' * @param {Params} params The getMany parameters { ids } * @param {Object} options Options object to pass to the queryClient. - * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh() } } + * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } * * @typedef Params * @prop params.ids The ids to get, e.g. [123, 456, 789] diff --git a/packages/ra-core/src/dataProvider/useGetManyAggregate.ts b/packages/ra-core/src/dataProvider/useGetManyAggregate.ts index 4600353cb69..c53f003b66e 100644 --- a/packages/ra-core/src/dataProvider/useGetManyAggregate.ts +++ b/packages/ra-core/src/dataProvider/useGetManyAggregate.ts @@ -9,8 +9,8 @@ import { import union from 'lodash/union'; import { UseGetManyHookValue } from './useGetMany'; -import { Identifier, Record, GetManyParams, DataProviderProxy } from '../types'; -import useDataProvider from './useDataProvider'; +import { Identifier, Record, GetManyParams, DataProvider } from '../types'; +import { useDataProvider } from './useDataProvider'; /** * Call the dataProvider.getMany() method and return the resolved result @@ -143,7 +143,7 @@ interface GetManyCallArgs { ids: Identifier[]; resolve: (data: any[]) => void; reject: (error?: any) => void; - dataProvider: DataProviderProxy; + dataProvider: DataProvider; queryClient: QueryClient; } diff --git a/packages/ra-core/src/dataProvider/useGetManyReference.ts b/packages/ra-core/src/dataProvider/useGetManyReference.ts index ce745ea5b26..efc13f92081 100644 --- a/packages/ra-core/src/dataProvider/useGetManyReference.ts +++ b/packages/ra-core/src/dataProvider/useGetManyReference.ts @@ -6,7 +6,7 @@ import { } from 'react-query'; import { Record, GetManyReferenceParams } from '../types'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; /** * Call the dataProvider.getManyReference() method and return the resolved result @@ -24,7 +24,7 @@ import useDataProvider from './useDataProvider'; * @param {string} resource The resource name, e.g. 'posts' * @param {Params} params The getManyReference parameters { target, id, pagination, sort, filter } * @param {Object} options Options object to pass to the queryClient. - * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh() } } + * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } * * @typedef Params * @prop params.target The target resource key, e.g. 'post_id' diff --git a/packages/ra-core/src/dataProvider/useGetOne.ts b/packages/ra-core/src/dataProvider/useGetOne.ts index df615cbdd82..a7ba81a9cff 100644 --- a/packages/ra-core/src/dataProvider/useGetOne.ts +++ b/packages/ra-core/src/dataProvider/useGetOne.ts @@ -1,6 +1,6 @@ import { Record, GetOneParams } from '../types'; import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; /** * Call the dataProvider.getOne() method and return the resolved value diff --git a/packages/ra-core/src/dataProvider/useIsAutomaticRefreshEnabled.ts b/packages/ra-core/src/dataProvider/useIsAutomaticRefreshEnabled.ts deleted file mode 100644 index c570f99804e..00000000000 --- a/packages/ra-core/src/dataProvider/useIsAutomaticRefreshEnabled.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useSelector } from 'react-redux'; -import { ReduxState } from '../types'; - -const useIsAutomaticRefreshEnabled = () => { - const automaticRefreshEnabled = useSelector( - state => state.admin.ui.automaticRefreshEnabled - ); - - return automaticRefreshEnabled; -}; - -export default useIsAutomaticRefreshEnabled; diff --git a/packages/ra-core/src/loading/useLoading.ts b/packages/ra-core/src/dataProvider/useLoading.ts similarity index 59% rename from packages/ra-core/src/loading/useLoading.ts rename to packages/ra-core/src/dataProvider/useLoading.ts index c1298bf121e..4f32a444612 100644 --- a/packages/ra-core/src/loading/useLoading.ts +++ b/packages/ra-core/src/dataProvider/useLoading.ts @@ -1,5 +1,4 @@ -import { useSelector } from 'react-redux'; -import { ReduxState } from '../types'; +import { useIsFetching, useIsMutating } from 'react-query'; /** * Get the loading status, i.e. a boolean indicating if at least one request is pending @@ -15,5 +14,8 @@ import { ReduxState } from '../types'; * return loading ? : ; * } */ -export default () => - useSelector((state: ReduxState) => state.admin.loading > 0); +export const useLoading = () => { + const isFetching = useIsFetching(); + const isMutating = useIsMutating(); + return isFetching || isMutating; +}; diff --git a/packages/ra-core/src/dataProvider/useMutation.spec.tsx b/packages/ra-core/src/dataProvider/useMutation.spec.tsx deleted file mode 100644 index d8f36b87e79..00000000000 --- a/packages/ra-core/src/dataProvider/useMutation.spec.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import * as React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import expect from 'expect'; -import { Provider } from 'react-redux'; - -import { createAdminStore, CoreAdminContext, Resource } from '../core'; -import Mutation from './Mutation'; -import { testDataProvider } from '../dataProvider'; -import { renderWithRedux } from 'ra-test'; -import { DataProviderContext } from '.'; -import useMutation from './useMutation'; - -describe('useMutation', () => { - const initialState = { - admin: { - resources: { foo: {} }, - }, - }; - const store = createAdminStore({ initialState }); - - it('should pass a callback to trigger the mutation', () => { - let callback = null; - render( - - - {mutate => { - callback = mutate; - return
Hello
; - }} -
-
- ); - expect(callback).toBeInstanceOf(Function); - }); - - it('should dispatch a fetch action when the mutation callback is triggered', () => { - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - }; - - const myPayload = {}; - const dispatch = jest.spyOn(store, 'dispatch'); - render( - - - - {mutate => } - - - - ); - fireEvent.click(screen.getByText('Hello')); - const action = dispatch.mock.calls[0][0]; - expect(action.type).toEqual('CUSTOM_FETCH'); - expect(action.payload).toEqual(myPayload); - expect(action.meta.resource).toEqual('myresource'); - dispatch.mockRestore(); - }); - - it('should use callTimePayload and callTimeOptions', () => { - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - }; - - const myPayload = { foo: 1 }; - const dispatch = jest.spyOn(store, 'dispatch'); - render( - - - - {mutate => ( - - )} - - - - ); - fireEvent.click(screen.getByText('Hello')); - const action = dispatch.mock.calls[0][0]; - expect(action.payload).toEqual({ foo: 1, bar: 2 }); - expect(action.meta.meta).toEqual('baz'); - dispatch.mockRestore(); - }); - - it('should use callTimeQuery over definition query', () => { - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - callTimeType: jest.fn(() => - Promise.resolve({ data: { foo: 'bar' } }) - ), - }; - - const myPayload = { foo: 1 }; - const dispatch = jest.spyOn(store, 'dispatch'); - render( - - - - {mutate => ( - - )} - - - - ); - fireEvent.click(screen.getByText('Hello')); - const action = dispatch.mock.calls[0][0]; - expect(action.payload).toEqual({ foo: 1, bar: 2 }); - expect(action.meta.resource).toEqual('callTimeResource'); - expect(action.meta.meta).toEqual('baz'); - expect(dataProvider.mytype).not.toHaveBeenCalled(); - expect(dataProvider.callTimeType).toHaveBeenCalled(); - dispatch.mockRestore(); - }); - - it('should update the loading state when the mutation callback is triggered', () => { - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - }; - - const myPayload = {}; - render( - - - {(mutate, { loading }) => ( - - )} - - - ); - expect(screen.getByText('Hello').className).toEqual('idle'); - fireEvent.click(screen.getByText('Hello')); - expect(screen.getByText('Hello').className).toEqual('loading'); - }); - - it('should update the data state after a success response', async () => { - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - }; - - const Foo = () => ( - - {(mutate, { data }) => ( - - )} - - ); - render( - - - - ); - const testElement = screen.getByTestId('test'); - expect(testElement.textContent).toBe('no data'); - fireEvent.click(testElement); - await waitFor(() => { - expect(testElement.textContent).toEqual('bar'); - }); - }); - - it('should update the error state after an error response', async () => { - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - const dataProvider = { - mytype: jest.fn(() => - Promise.reject({ message: 'provider error' }) - ), - }; - const Foo = () => ( - - {(mutate, { error }) => ( - - )} - - ); - render( - - - - ); - const testElement = screen.getByTestId('test'); - expect(testElement.textContent).toBe('no data'); - fireEvent.click(testElement); - await waitFor(() => { - expect(testElement.textContent).toEqual('provider error'); - }); - }); - - it('should allow custom dataProvider methods without resource', () => { - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - }; - - const myPayload = {}; - const dispatch = jest.spyOn(store, 'dispatch'); - render( - - - - {mutate => } - - - - ); - fireEvent.click(screen.getByText('Hello')); - const action = dispatch.mock.calls[0][0]; - expect(action.type).toEqual('CUSTOM_FETCH'); - expect(action.meta.resource).toBeUndefined(); - expect(dataProvider.mytype).toHaveBeenCalledWith(myPayload); - dispatch.mockRestore(); - }); - - it('should return a promise to dispatch a fetch action when returnPromise option is set and the mutation callback is triggered', async () => { - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - }; - - let promise = null; - const myPayload = {}; - const dispatch = jest.spyOn(store, 'dispatch'); - render( - - - - {(mutate, { loading }) => ( - - )} - - - - ); - const buttonElement = screen.getByText('Hello'); - fireEvent.click(buttonElement); - const action = dispatch.mock.calls[0][0]; - expect(action.type).toEqual('CUSTOM_FETCH'); - expect(action.payload).toEqual(myPayload); - expect(action.meta.resource).toEqual('myresource'); - await waitFor(() => { - expect(buttonElement.className).toEqual('idle'); - }); - expect(promise).toBeInstanceOf(Promise); - const result = await promise; - expect(result).toMatchObject({ data: { foo: 'bar' } }); - dispatch.mockRestore(); - }); - - it('should return a response when returnPromise option is set at definition and the query is passed at callTime', async () => { - const MutationComponent = ({ query = undefined, options, children }) => - children(...useMutation(query, options)); - const dataProvider = { - mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })), - }; - - let response = null; - const myPayload = {}; - const { getByText, dispatch } = renderWithRedux( - - - {(mutate, { loading }) => ( - - )} - - - ); - const buttonElement = getByText('Hello'); - fireEvent.click(buttonElement); - const action = dispatch.mock.calls[0][0]; - expect(action.type).toEqual('CUSTOM_FETCH'); - expect(action.payload).toEqual(myPayload); - expect(action.meta.resource).toEqual('myresource'); - await waitFor(() => { - expect(buttonElement.className).toEqual('idle'); - }); - - expect(response).toMatchObject({ data: { foo: 'bar' } }); - }); -}); diff --git a/packages/ra-core/src/dataProvider/useMutation.ts b/packages/ra-core/src/dataProvider/useMutation.ts deleted file mode 100644 index 304ec8f8f05..00000000000 --- a/packages/ra-core/src/dataProvider/useMutation.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { useCallback } from 'react'; -import merge from 'lodash/merge'; - -import { useSafeSetState } from '../util/hooks'; -import { MutationMode, OnSuccess, OnFailure } from '../types'; -import useDataProvider from './useDataProvider'; - -/** - * Get a callback to fetch the data provider through Redux, usually for mutations. - * - * The request starts when the callback is called. - * - * useMutation() parameters can be passed: - * - * - at definition time - * - * const [mutate] = useMutation(query, options); mutate(); - * - * - at call time - * - * const [mutate] = useMutation(); mutate(query, options); - * - * - both, in which case the definition and call time parameters are merged - * - * const [mutate] = useMutation(query1, options1); mutate(query2, options2); - * - * @param {Object} query - * @param {string} query.type The method called on the data provider, e.g. 'getList', 'getOne'. Can also be a custom method if the dataProvider supports is. - * @param {string} query.resource A resource name, e.g. 'posts', 'comments' - * @param {Object} query.payload The payload object, e.g; { post_id: 12 } - * @param {Object} options - * @param {string} options.action Redux action type - * @param {boolean} options.mutationMode Either 'optimistic', 'pessimistic' or 'undoable' - * @param {boolean} options.returnPromise Set to true to return the result promise of the mutation - * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. () => refresh() - * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. (error) => notify(error.message) - * - * @returns A tuple with the mutation callback and the request state. Destructure as [mutate, { data, total, error, loading, loaded }]. - * - * The return value updates according to the request state: - * - * - mount: [mutate, { loading: false, loaded: false }] - * - mutate called: [mutate, { loading: true, loaded: false }] - * - success: [mutate, { data: [data from response], total: [total from response], loading: false, loaded: true }] - * - error: [mutate, { error: [error from response], loading: false, loaded: false }] - * - * The mutate function accepts the following arguments - * - {Object} query - * - {string} query.type The method called on the data provider, e.g. 'update' - * - {string} query.resource A resource name, e.g. 'posts', 'comments' - * - {Object} query.payload The payload object, e.g. { id: 123, data: { isApproved: true } } - * - {Object} options - * - {string} options.action Redux action type - * - {boolean} options.mutationMode Either 'optimistic', 'pessimistic' or 'undoable' - * - {boolean} options.returnPromise Set to true to return the result promise of the mutation - * - {Function} options.onSuccess Side effect function to be executed upon success or failure, e.g. { onSuccess: response => refresh() } - * - {Function} options.onFailure Side effect function to be executed upon failure, e.g. { onFailure: error => notify(error.message) } - * - * @example - * - * // pass parameters at definition time - * // use when all parameters are determined at definition time - * // the mutation callback can be used as an even handler - * // because Event parameters are ignored - * import { useMutation } from 'react-admin'; - * - * const ApproveButton = ({ record }) => { - * const [approve, { loading }] = useMutation({ - * type: 'update', - * resource: 'comments', - * payload: { id: record.id, data: { isApproved: true } } - * }); - * return - * }; - * - * This usage is accepted, and therefore this function checks if the call time - * query is an Event, and discards it in that case. - * - * @param query {Mutation} - * @param callTimeQuery {Mutation} - * @param options {Object} - * @param callTimeOptions {Object} - * - * @return { type, resource, payload, options } The merged parameters - */ -const mergeDefinitionAndCallTimeParameters = ( - query?: Mutation, - callTimeQuery?: Partial | Event, - options?: MutationOptions, - callTimeOptions?: MutationOptions -): { - type: string; - resource: string; - payload?: object; - options: MutationOptions; -} => { - if (!query && (!callTimeQuery || callTimeQuery instanceof Event)) { - throw new Error('Missing query either at definition or at call time'); - } - - const event = callTimeQuery as Event; - if (callTimeQuery instanceof Event || !!event?.preventDefault) - return { - type: query.type, - resource: query.resource, - payload: query.payload, - options: sanitizeOptions(options), - }; - - if (query) { - return { - type: callTimeQuery?.type || query.type, - resource: callTimeQuery?.resource || query.resource, - payload: callTimeQuery - ? merge({}, query.payload, callTimeQuery.payload) - : query.payload, - options: callTimeOptions - ? merge( - {}, - sanitizeOptions(options), - sanitizeOptions(callTimeOptions) - ) - : sanitizeOptions(options), - }; - } - return { - type: callTimeQuery.type, - resource: callTimeQuery.resource, - payload: callTimeQuery.payload, - options: options - ? merge( - {}, - sanitizeOptions(options), - sanitizeOptions(callTimeOptions) - ) - : sanitizeOptions(callTimeOptions), - }; -}; - -const sanitizeOptions = (args?: MutationOptions) => - args ? { onSuccess: undefined, ...args } : { onSuccess: undefined }; - -export default useMutation; diff --git a/packages/ra-core/src/dataProvider/useQuery.ts b/packages/ra-core/src/dataProvider/useQuery.ts deleted file mode 100644 index 1a23742df98..00000000000 --- a/packages/ra-core/src/dataProvider/useQuery.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; - -import { useSafeSetState } from '../util/hooks'; -import { OnSuccess, OnFailure } from '../types'; -import useDataProvider from './useDataProvider'; -import useVersion from '../controller/useVersion'; -import { DataProviderQuery, Refetch } from './useQueryWithStore'; - -/** - * Call the data provider on mount - * - * The return value updates according to the request state: - * - * - start: { loading: true, loaded: false, refetch } - * - success: { data: [data from response], total: [total from response], loading: false, loaded: true, refetch } - * - error: { error: [error from response], loading: false, loaded: false, refetch } - * - * @param {Object} query - * @param {string} query.type The method called on the data provider, e.g. 'getList', 'getOne'. Can also be a custom method if the dataProvider supports is. - * @param {string} query.resource A resource name, e.g. 'posts', 'comments' - * @param {Object} query.payload The payload object, e.g; { post_id: 12 } - * @param {Object} options - * @param {string} options.action Redux action type - * @param {boolean} options.enabled Flag to conditionally run the query. True by default. If it's false, the query will not run - * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. () => refresh() - * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. (error) => notify(error.message) - * - * @returns The current request state. Destructure as { data, total, error, loading, loaded, refetch }. - * - * @example - * - * import { useQuery } from 'react-admin'; - * - * const UserProfile = ({ record }) => { - * const { data, loading, error } = useQuery({ - * type: 'getOne', - * resource: 'users', - * payload: { id: record.id } - * }); - * if (loading) { return ; } - * if (error) { return

ERROR

; } - * return
User {data.username}
; - * }; - * - * @example - * - * import { useQuery } from 'react-admin'; - * - * const payload = { - * pagination: { page: 1, perPage: 10 }, - * sort: { field: 'username', order: 'ASC' }, - * }; - * const UserList = () => { - * const { data, total, loading, error } = useQuery({ - * type: 'getList', - * resource: 'users', - * payload - * }); - * if (loading) { return ; } - * if (error) { return

ERROR

; } - * return ( - *
- *

Total users: {total}

- *
    - * {data.map(user =>
  • {user.username}
  • )} - *
- *
- * ); - * }; - */ -export const useQuery = ( - query: DataProviderQuery, - options: UseQueryOptions = { onSuccess: undefined } -): UseQueryValue => { - const { type, resource, payload } = query; - const version = useVersion(); // used to allow force reload - // used to force a refetch without relying on version - // which might trigger other queries as well - const [innerVersion, setInnerVersion] = useState(0); - - const refetch = useCallback(() => { - setInnerVersion(prevInnerVersion => prevInnerVersion + 1); - }, []); - - const requestSignature = JSON.stringify({ - query, - options, - version, - innerVersion, - }); - const [state, setState] = useSafeSetState({ - data: undefined, - error: null, - total: null, - loading: true, - loaded: false, - refetch, - }); - const dataProvider = useDataProvider(); - - /* eslint-disable react-hooks/exhaustive-deps */ - useEffect(() => { - setState(prevState => ({ ...prevState, loading: true })); - - dataProvider[type] - .apply( - dataProvider, - typeof resource !== 'undefined' - ? [resource, payload, options] - : [payload, options] - ) - .then(({ data, total }) => { - setState({ - data, - total, - loading: false, - loaded: true, - refetch, - }); - }) - .catch(error => { - setState({ - error, - loading: false, - loaded: false, - refetch, - }); - }); - }, [requestSignature, dataProvider, setState]); - /* eslint-enable react-hooks/exhaustive-deps */ - - return state; -}; - -export interface UseQueryOptions { - action?: string; - enabled?: boolean; - onSuccess?: OnSuccess; - onFailure?: OnFailure; -} - -export type UseQueryValue = { - data?: any; - total?: number; - error?: any; - loading: boolean; - loaded: boolean; - refetch: Refetch; -}; diff --git a/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx b/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx deleted file mode 100644 index 1810f267890..00000000000 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.spec.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import * as React from 'react'; -import { fireEvent, waitFor } from '@testing-library/react'; -import expect from 'expect'; - -import { renderWithRedux } from 'ra-test'; -import { useQueryWithStore } from './useQueryWithStore'; -import { DataProviderContext } from '../dataProvider'; - -const UseQueryWithStore = ({ - query = { type: 'getOne', resource: 'posts', payload: { id: 1 } }, - options = {}, - dataSelector = state => state.admin?.resources.posts.data[query.payload.id], - totalSelector = state => null, - callback = null, - ...rest -}) => { - const hookValue = useQueryWithStore( - query, - options, - dataSelector, - totalSelector - ); - if (callback) callback(hookValue); - return ( - <> -
hello
- - - ); -}; - -describe('useQueryWithStore', () => { - it('should not call the dataProvider if options.enabled is set to false and run when it changes to true', async () => { - const dataProvider = { - getOne: jest.fn(() => - Promise.resolve({ - data: { id: 1, title: 'titleFromDataProvider' }, - }) - ), - }; - const callback = jest.fn(); - const { rerender } = renderWithRedux( - - - , - { admin: { resources: { posts: { data: {} } } } } - ); - let callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toBeUndefined(); - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(false); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - - await new Promise(resolve => setImmediate(resolve)); // wait for useEffect - callArgs = callback.mock.calls[1][0]; - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(false); - - callback.mockClear(); - rerender( - - - , - { admin: { resources: { posts: { data: {} } } } } - ); - callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toBeUndefined(); - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(false); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - - callback.mockClear(); - await new Promise(resolve => setImmediate(resolve)); // wait for useEffect - callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toBeUndefined(); - expect(callArgs.loading).toEqual(true); - expect(callArgs.loaded).toEqual(false); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - - callArgs = callback.mock.calls[1][0]; - expect(callArgs.data).toEqual({ - id: 1, - title: 'titleFromDataProvider', - }); - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(true); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - - callback.mockClear(); - rerender( - - - , - { admin: { resources: { posts: { data: {} } } } } - ); - callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toEqual({ - id: 1, - title: 'titleFromDataProvider', - }); - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(true); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - - callback.mockClear(); - await new Promise(resolve => setImmediate(resolve)); // wait for useEffect - callArgs = callback.mock.calls[0][0]; - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(false); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - }); - - it('should return data from dataProvider', async () => { - const dataProvider = { - getOne: jest.fn(() => - Promise.resolve({ - data: { id: 1, title: 'titleFromDataProvider' }, - }) - ), - }; - const callback = jest.fn(); - renderWithRedux( - - - , - { admin: { resources: { posts: { data: {} } } } } - ); - let callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toBeUndefined(); - expect(callArgs.loading).toEqual(true); - expect(callArgs.loaded).toEqual(false); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - callback.mockClear(); - await new Promise(resolve => setImmediate(resolve)); // dataProvider Promise returns result on next tick - callArgs = callback.mock.calls[1][0]; - expect(callArgs.data).toEqual({ - id: 1, - title: 'titleFromDataProvider', - }); - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(true); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - }); - - it('should return data from the store first, then data from dataProvider', async () => { - const dataProvider = { - getOne: jest.fn(() => - Promise.resolve({ - data: { id: 2, title: 'titleFromDataProvider' }, - }) - ), - }; - const callback = jest.fn(); - renderWithRedux( - - - , - { - admin: { - resources: { - posts: { - data: { - 2: { id: 2, title: 'titleFromReduxStore' }, - }, - }, - }, - }, - } - ); - let callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toEqual({ id: 2, title: 'titleFromReduxStore' }); - expect(callArgs.loading).toEqual(true); - expect(callArgs.loaded).toEqual(true); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - callback.mockClear(); - await waitFor(() => { - expect(dataProvider.getOne).toHaveBeenCalled(); - }); - // dataProvider Promise returns result on next tick - await waitFor(() => { - callArgs = callback.mock.calls[1][0]; - expect(callArgs.data).toEqual({ - id: 2, - title: 'titleFromDataProvider', - }); - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(true); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - }); - }); - - it('should return an error when dataProvider returns a rejected Promise', async () => { - jest.spyOn(console, 'error').mockImplementationOnce(() => {}); - const dataProvider = { - getOne: jest.fn(() => - Promise.reject({ - message: 'error', - }) - ), - }; - const callback = jest.fn(); - renderWithRedux( - - - , - { admin: { resources: { posts: { data: {} } } } } - ); - let callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toBeUndefined(); - expect(callArgs.loading).toEqual(true); - expect(callArgs.loaded).toEqual(false); - expect(callArgs.error).toBeNull(); - expect(callArgs.total).toBeNull(); - callback.mockClear(); - await waitFor(() => { - expect(dataProvider.getOne).toHaveBeenCalled(); - }); - callArgs = callback.mock.calls[0][0]; - expect(callArgs.data).toBeUndefined(); - expect(callArgs.loading).toEqual(false); - expect(callArgs.loaded).toEqual(false); - expect(callArgs.error).toEqual({ message: 'error' }); - expect(callArgs.total).toBeNull(); - }); - - it('should refetch the dataProvider on refresh', async () => { - const dataProvider = { - getOne: jest.fn(() => - Promise.resolve({ - data: { id: 3, title: 'titleFromDataProvider' }, - }) - ), - }; - const { dispatch } = renderWithRedux( - - - , - { - admin: { - resources: { - posts: { - data: { - 3: { id: 3, title: 'titleFromReduxStore' }, - }, - }, - }, - }, - } - ); - await waitFor(() => { - expect(dataProvider.getOne).toBeCalledTimes(1); - }); - dispatch({ type: 'RA/REFRESH_VIEW' }); - await waitFor(() => { - expect(dataProvider.getOne).toBeCalledTimes(2); - }); - }); - - it('should refetch the dataProvider when refetch is called', async () => { - const dataProvider = { - getOne: jest.fn(() => - Promise.resolve({ - data: { id: 3, title: 'titleFromDataProvider' }, - }) - ), - }; - const { getByText } = renderWithRedux( - - - , - { - admin: { - resources: { - posts: { - data: { - 3: { id: 3, title: 'titleFromReduxStore' }, - }, - }, - }, - }, - } - ); - await waitFor(() => { - expect(dataProvider.getOne).toBeCalledTimes(1); - }); - fireEvent.click(getByText('refetch')); - await waitFor(() => { - expect(dataProvider.getOne).toBeCalledTimes(2); - }); - }); - - it('should call the dataProvider twice for different requests in the same tick', async () => { - const dataProvider = { - getOne: jest.fn(() => - Promise.resolve({ - data: { id: 1, title: 'titleFromDataProvider' }, - }) - ), - }; - renderWithRedux( - - - - , - { admin: { resources: { posts: { data: {} } } } } - ); - await waitFor(() => { - expect(dataProvider.getOne).toBeCalledTimes(2); - }); - }); - - it('should not call the dataProvider twice for the same request in the same tick', async () => { - const dataProvider = { - getOne: jest.fn(() => - Promise.resolve({ - data: { id: 1, title: 'titleFromDataProvider' }, - }) - ), - }; - renderWithRedux( - - - - , - { admin: { resources: { posts: { data: {} } } } } - ); - await waitFor(() => { - expect(dataProvider.getOne).toBeCalledTimes(1); - }); - }); -}); diff --git a/packages/ra-core/src/dataProvider/useQueryWithStore.ts b/packages/ra-core/src/dataProvider/useQueryWithStore.ts deleted file mode 100644 index 0ba58e09cb3..00000000000 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; -import isEqual from 'lodash/isEqual'; - -import useDataProvider from './useDataProvider'; -import useVersion from '../controller/useVersion'; -import getFetchType from './getFetchType'; -import { useSafeSetState } from '../util/hooks'; -import { ReduxState, OnSuccess, OnFailure, DataProvider } from '../types'; - -export interface DataProviderQuery { - type: string; - resource: string; - payload: object; -} - -export type Refetch = () => void; - -export interface UseQueryWithStoreValue { - data?: any; - total?: number; - error?: any; - loading: boolean; - loaded: boolean; - refetch: Refetch; -} - -export interface QueryOptions { - onSuccess?: OnSuccess; - onFailure?: OnFailure; - action?: string; - enabled?: boolean; - [key: string]: any; -} - -type PartialQueryState = { - error?: any; - loading: boolean; - loaded: boolean; -}; - -const queriesThisTick: { [key: string]: Promise } = {}; - -/** - * Default cache selector. Allows to cache responses by default. - * - * By default, custom queries are dispatched as a CUSTOM_QUERY Redux action. - * The useDataProvider hook dispatches a CUSTOM_QUERY_SUCCESS when the response - * comes, and the customQueries reducer stores the result in the store. - * This selector reads the customQueries store and acts as a response cache. - */ -const defaultDataSelector = query => (state: ReduxState) => { - const key = JSON.stringify({ ...query, type: getFetchType(query.type) }); - return state.admin.customQueries[key] - ? state.admin.customQueries[key].data - : undefined; -}; - -const defaultTotalSelector = query => (state: ReduxState) => { - const key = JSON.stringify({ ...query, type: getFetchType(query.type) }); - return state.admin.customQueries[key] - ? state.admin.customQueries[key].total - : null; -}; - -const defaultIsDataLoaded = (data: any): boolean => data !== undefined; - -/** - * Fetch the data provider through Redux, return the value from the store. - * - * The return value updates according to the request state: - * - * - start: { loading: true, loaded: false, refetch } - * - success: { data: [data from response], total: [total from response], loading: false, loaded: true, refetch } - * - error: { error: [error from response], loading: false, loaded: false, refetch } - * - * This hook will return the cached result when called a second time - * with the same parameters, until the response arrives. - * - * @param {Object} query - * @param {string} query.type The verb passed to th data provider, e.g. 'getList', 'getOne' - * @param {string} query.resource A resource name, e.g. 'posts', 'comments' - * @param {Object} query.payload The payload object, e.g; { post_id: 12 } - * @param {Object} options - * @param {string} options.action Redux action type - * @param {boolean} options.enabled Flag to conditionally run the query. If it's false, the query will not run - * @param {Function} options.onSuccess Side effect function to be executed upon success, e.g. () => refresh() - * @param {Function} options.onFailure Side effect function to be executed upon failure, e.g. (error) => notify(error.message) - * @param {Function} dataSelector Redux selector to get the result. Required. - * @param {Function} totalSelector Redux selector to get the total (optional, only for LIST queries) - * @param {Function} isDataLoaded - * - * @returns The current request state. Destructure as { data, total, error, loading, loaded, refetch }. - * - * @example - * - * import { useQueryWithStore } from 'react-admin'; - * - * const UserProfile = ({ record }) => { - * const { data, loading, error } = useQueryWithStore( - * { - * type: 'getOne', - * resource: 'users', - * payload: { id: record.id } - * }, - * {}, - * state => state.admin.resources.users.data[record.id] - * ); - * if (loading) { return ; } - * if (error) { return

ERROR

; } - * return
User {data.username}
; - * }; - */ -export const useQueryWithStore = < - State extends ReduxState = ReduxState, - TDataProvider extends DataProvider = DataProvider ->( - query: DataProviderQuery, - options: QueryOptions = { action: 'CUSTOM_QUERY' }, - dataSelector: (state: State) => any = defaultDataSelector(query), - totalSelector: (state: State) => number = defaultTotalSelector(query), - isDataLoaded: (data: any) => boolean = defaultIsDataLoaded -): UseQueryWithStoreValue => { - const { type, resource, payload } = query; - const version = useVersion(); // used to allow force reload - // used to force a refetch without relying on version - // which might trigger other queries as well - const [innerVersion, setInnerVersion] = useState(0); - const requestSignature = JSON.stringify({ - query, - options, - version, - innerVersion, - }); - const requestSignatureRef = useRef(requestSignature); - const data = useSelector(dataSelector); - const total = useSelector(totalSelector); - - const refetch = useCallback(() => { - setInnerVersion(prevInnerVersion => prevInnerVersion + 1); - }, []); - - const [state, setState]: [ - UseQueryWithStoreValue, - (StateResult) => void - ] = useSafeSetState({ - data, - total, - error: null, - loading: options?.enabled === false ? false : true, - loaded: options?.enabled === false ? false : isDataLoaded(data), - refetch, - }); - - useEffect(() => { - if (requestSignatureRef.current !== requestSignature) { - // request has changed, reset the loading state - requestSignatureRef.current = requestSignature; - setState({ - data, - total, - error: null, - loading: options?.enabled === false ? false : true, - loaded: options?.enabled === false ? false : isDataLoaded(data), - refetch, - }); - } else if (!isEqual(state.data, data) || state.total !== total) { - // the dataProvider response arrived in the Redux store - if (typeof total !== 'undefined' && isNaN(total)) { - console.error( - 'Total from response is not a number. Please check your dataProvider or the API.' - ); - } else { - setState(prevState => ({ - ...prevState, - data, - total, - loaded: true, - loading: false, - })); - } - } - }, [ - data, - requestSignature, - setState, - state.data, - state.total, - total, - isDataLoaded, - refetch, - options.enabled, - ]); - - const dataProvider = useDataProvider(); - useEffect(() => { - // When several identical queries are issued during the same tick, - // we only pass one query to the dataProvider. - // To achieve that, the closure keeps a list of dataProvider promises - // issued this tick. Before calling the dataProvider, this effect - // checks if another effect has already issued a similar dataProvider - // call. - if (!queriesThisTick.hasOwnProperty(requestSignature)) { - queriesThisTick[requestSignature] = new Promise( - resolve => { - dataProvider[type](resource, payload, options) - // @ts-ignore - .then(() => { - // We don't care about the dataProvider response here, because - // it was already passed to SUCCESS reducers by the dataProvider - // hook, and the result is available from the Redux store - // through the data and total selectors. - // In addition, if the query is optimistic, the response - // will be empty, so it should not be used at all. - if ( - requestSignature !== requestSignatureRef.current - ) { - resolve(undefined); - } - - resolve({ - error: null, - loading: false, - loaded: - options?.enabled === false ? false : true, - }); - }) - .catch(error => { - if ( - requestSignature !== requestSignatureRef.current - ) { - resolve(undefined); - } - resolve({ - error, - loading: false, - loaded: false, - }); - }); - } - ); - // cleanup the list on next tick - setTimeout(() => { - delete queriesThisTick[requestSignature]; - }, 0); - } - (async () => { - const newState = await queriesThisTick[requestSignature]; - if (newState) setState(state => ({ ...state, ...newState })); - })(); - // deep equality, see https://github.com/facebook/react/issues/14476#issuecomment-471199055 - }, [requestSignature]); // eslint-disable-line - - return state; -}; diff --git a/packages/ra-core/src/dataProvider/useRefresh.ts b/packages/ra-core/src/dataProvider/useRefresh.ts new file mode 100644 index 00000000000..bf8e9309692 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useRefresh.ts @@ -0,0 +1,22 @@ +import { useCallback } from 'react'; +import { useQueryClient } from 'react-query'; + +/** + * Hook for triggering a page refresh. Returns a callback function. + * + * The callback invalidates all queries and refetches the active ones. + * Any component depending on react-query data will be re-rendered. + * + * @example + * + * const refresh = useRefresh(); + * const handleClick = () => { + * refresh(); + * }; + */ +export const useRefresh = () => { + const queryClient = useQueryClient(); + return useCallback(() => { + queryClient.invalidateQueries(); + }, [queryClient]); +}; diff --git a/packages/ra-core/src/dataProvider/useRefreshWhenVisible.ts b/packages/ra-core/src/dataProvider/useRefreshWhenVisible.ts deleted file mode 100644 index e65a4a0c60b..00000000000 --- a/packages/ra-core/src/dataProvider/useRefreshWhenVisible.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useEffect } from 'react'; -import { useRefresh } from '../sideEffect'; -import useIsAutomaticRefreshEnabled from './useIsAutomaticRefreshEnabled'; - -/** - * Trigger a refresh of the page when the page comes back from background after a certain delay - * - * @param {number} delay Delay in milliseconds since the time the page was hidden. Defaults to 5 minutes. - */ -const useRefreshWhenVisible = (delay = 1000 * 60 * 5) => { - const refresh = useRefresh(); - const automaticRefreshEnabled = useIsAutomaticRefreshEnabled(); - - useEffect(() => { - if (typeof document === 'undefined') return; - let lastHiddenTime; - const handleVisibilityChange = () => { - if (!automaticRefreshEnabled) { - return; - } - if (document.hidden) { - // tab goes hidden - lastHiddenTime = Date.now(); - } else { - // tab goes visible - if (Date.now() - lastHiddenTime > delay) { - refresh(); - } - lastHiddenTime = null; - } - }; - document.addEventListener('visibilitychange', handleVisibilityChange, { - capture: true, - }); - return () => - document.removeEventListener( - 'visibilitychange', - handleVisibilityChange, - { capture: true } - ); - }, [automaticRefreshEnabled, delay, refresh]); -}; - -export default useRefreshWhenVisible; diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 7b437fe8781..dd755135c2b 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -8,7 +8,7 @@ import { QueryKey, } from 'react-query'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; import undoableEventEmitter from './undoableEventEmitter'; import { Record, UpdateParams, MutationMode } from '../types'; @@ -18,7 +18,7 @@ import { Record, UpdateParams, MutationMode } from '../types'; * @param {string} resource * @param {Params} params The update parameters { id, data, previousData } * @param {Object} options Options object to pass to the queryClient. - * May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } * May include a mutation mode (optimistic/pessimistic/undoable), e.g. { mutationMode: 'undoable' } * * @typedef Params diff --git a/packages/ra-core/src/dataProvider/useUpdateMany.ts b/packages/ra-core/src/dataProvider/useUpdateMany.ts index 16b6ca6a95e..8cf350b3cdb 100644 --- a/packages/ra-core/src/dataProvider/useUpdateMany.ts +++ b/packages/ra-core/src/dataProvider/useUpdateMany.ts @@ -8,7 +8,7 @@ import { QueryKey, } from 'react-query'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; import undoableEventEmitter from './undoableEventEmitter'; import { Record, UpdateManyParams, MutationMode } from '../types'; import { Identifier } from '..'; @@ -19,7 +19,7 @@ import { Identifier } from '..'; * @param {string} resource * @param {Params} params The updateMany parameters { ids, data } * @param {Object} options Options object to pass to the queryClient. - * May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * May include side effects to be executed upon success or failure, e.g. { onSuccess: () => { refresh(); } } * May include a mutation mode (optimistic/pessimistic/undoable), e.g. { mutationMode: 'undoable' } * * @typedef Params @@ -62,7 +62,7 @@ import { Identifier } from '..'; * const BulkResetViewsButton = ({ selectedIds }) => { * const [updateMany, { isLoading, error }] = useUpdateMany('posts', { ids: selectedIds, data: { views: 0 } }); * if (error) { return

ERROR

; } - * return ; + * return ; * }; */ export const useUpdateMany = ( diff --git a/packages/ra-core/src/dataProvider/withDataProvider.tsx b/packages/ra-core/src/dataProvider/withDataProvider.tsx index 0bc3c6be0b3..a51530b2bb0 100644 --- a/packages/ra-core/src/dataProvider/withDataProvider.tsx +++ b/packages/ra-core/src/dataProvider/withDataProvider.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { DataProvider } from '../types'; -import useDataProvider from './useDataProvider'; +import { useDataProvider } from './useDataProvider'; export interface DataProviderProps { dataProvider: DataProvider; diff --git a/packages/ra-core/src/export/fetchRelatedRecords.ts b/packages/ra-core/src/export/fetchRelatedRecords.ts index 7c730d30b6f..067ca49af8d 100644 --- a/packages/ra-core/src/export/fetchRelatedRecords.ts +++ b/packages/ra-core/src/export/fetchRelatedRecords.ts @@ -1,4 +1,4 @@ -import { Record, Identifier, DataProviderProxy } from '../types'; +import { Record, Identifier, DataProvider } from '../types'; /** * Helper function for calling the dataProvider.getMany() method, @@ -12,7 +12,7 @@ import { Record, Identifier, DataProviderProxy } from '../types'; * })) * ); */ -const fetchRelatedRecords = (dataProvider: DataProviderProxy) => ( +const fetchRelatedRecords = (dataProvider: DataProvider) => ( data, field, resource diff --git a/packages/ra-core/src/form/FormWithRedirect.spec.tsx b/packages/ra-core/src/form/FormWithRedirect.spec.tsx index 5a5cae748d1..5ffd29f1b0e 100644 --- a/packages/ra-core/src/form/FormWithRedirect.spec.tsx +++ b/packages/ra-core/src/form/FormWithRedirect.spec.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; +import { screen, render, waitFor } from '@testing-library/react'; -import { renderWithRedux } from 'ra-test'; +import { CoreAdminContext } from '../core'; +import { testDataProvider } from '../dataProvider'; import FormWithRedirect from './FormWithRedirect'; import useInput from './useInput'; -import { waitFor } from '@testing-library/dom'; describe('FormWithRedirect', () => { const Input = props => { @@ -16,31 +17,38 @@ describe('FormWithRedirect', () => { const renderProp = jest.fn(() => ( )); - const { getByDisplayValue, rerender } = renderWithRedux( - + const { rerender } = render( + + + ); - expect(renderProp.mock.calls[0][0].pristine).toEqual(true); - expect(getByDisplayValue('Bar')).not.toBeNull(); + expect(screen.getByDisplayValue('Bar')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: true }) + ); rerender( - + + + ); - expect(renderProp.mock.calls[1][0].pristine).toEqual(true); + expect(screen.getByDisplayValue('Foo')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: true }) + ); expect(renderProp).toHaveBeenCalledTimes(2); }); @@ -48,19 +56,22 @@ describe('FormWithRedirect', () => { const renderProp = jest.fn(() => ( )); - const { getByDisplayValue } = renderWithRedux( - - ); - - expect(renderProp.mock.calls[0][0].pristine).toEqual(true); - expect(getByDisplayValue('Bar')).not.toBeNull(); + render( + + + + ); + + expect(screen.getByDisplayValue('Bar')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: true }) + ); expect(renderProp).toHaveBeenCalledTimes(1); }); @@ -68,20 +79,23 @@ describe('FormWithRedirect', () => { const renderProp = jest.fn(() => ( )); - const { getByDisplayValue } = renderWithRedux( - - ); - - expect(renderProp.mock.calls[1][0].pristine).toEqual(false); - expect(getByDisplayValue('Bar')).not.toBeNull(); - // 4 times because the first initialization with an empty value + render( + + + + ); + + expect(screen.getByDisplayValue('Bar')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: false }) + ); + // twice because the first initialization with an empty value // triggers a change on the input which has a defaultValue // This is expected and identical to what FinalForm does (https://final-form.org/docs/final-form/types/FieldConfig#defaultvalue) expect(renderProp).toHaveBeenCalledTimes(2); @@ -91,33 +105,43 @@ describe('FormWithRedirect', () => { const renderProp = jest.fn(() => ( )); - const { getByDisplayValue, rerender } = renderWithRedux( - + const { rerender } = render( + + + ); - expect(renderProp.mock.calls[0][0].pristine).toEqual(true); - expect(getByDisplayValue('Foo')).not.toBeNull(); + expect(screen.getByDisplayValue('Foo')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: true }) + ); rerender( - - ); - - expect(renderProp.mock.calls[1][0].pristine).toEqual(true); - expect(getByDisplayValue('Foo')).not.toBeNull(); + + + + ); + + expect(screen.getByDisplayValue('Foo')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: true }) + ); expect(renderProp).toHaveBeenCalledTimes(2); }); @@ -125,75 +149,85 @@ describe('FormWithRedirect', () => { const renderProp = jest.fn(() => ( )); - const { getByDisplayValue, rerender } = renderWithRedux( - + const { rerender } = render( + + + ); - expect(renderProp.mock.calls[0][0].pristine).toEqual(true); - expect(getByDisplayValue('Foo')).not.toBeNull(); + expect(screen.getByDisplayValue('Foo')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: true }) + ); rerender( - + + + ); + expect(screen.getByDisplayValue('Bar')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: false }) + ); expect(renderProp).toHaveBeenCalledTimes(3); - expect(renderProp.mock.calls[2][0].pristine).toEqual(false); - expect(getByDisplayValue('Bar')).not.toBeNull(); }); it('Does not make the form dirty when reinitialized from a different record with a missing field and this field has an initialValue', async () => { const renderProp = jest.fn(() => ( )); - const { getByDisplayValue, rerender } = renderWithRedux( - + const { rerender } = render( + + + ); - expect(renderProp.mock.calls[0][0].pristine).toEqual(true); - expect(getByDisplayValue('Foo')).not.toBeNull(); + expect(screen.getByDisplayValue('Foo')).not.toBeNull(); + expect(renderProp).toHaveBeenLastCalledWith( + expect.objectContaining({ pristine: true }) + ); rerender( - + + + ); await waitFor(() => { - expect(getByDisplayValue('Bar')).not.toBeNull(); + expect(screen.getByDisplayValue('Bar')).not.toBeNull(); }); expect( renderProp.mock.calls[renderProp.mock.calls.length - 1][0].pristine diff --git a/packages/ra-core/src/form/FormWithRedirect.tsx b/packages/ra-core/src/form/FormWithRedirect.tsx index eaac0073e1a..0c6ed60f0fc 100644 --- a/packages/ra-core/src/form/FormWithRedirect.tsx +++ b/packages/ra-core/src/form/FormWithRedirect.tsx @@ -1,8 +1,7 @@ import * as React from 'react'; -import { useRef, useCallback, useEffect, useMemo } from 'react'; +import { useRef, useCallback, useMemo } from 'react'; import { Form, FormProps, FormRenderProps } from 'react-final-form'; import arrayMutators from 'final-form-arrays'; -import { useDispatch } from 'react-redux'; import useResetSubmitErrors from './useResetSubmitErrors'; import sanitizeEmptyValues from './sanitizeEmptyValues'; @@ -15,7 +14,6 @@ import { OnFailure, } from '../types'; import { RedirectionSideEffect } from '../sideEffect'; -import { setAutomaticRefresh } from '../actions'; import { useRecordContext, OptionalRecordContextProvider } from '../controller'; import { FormContextProvider } from './FormContextProvider'; import submitErrorsMutators from './submitErrorsMutators'; @@ -63,7 +61,6 @@ const FormWithRedirect = ({ subscription = defaultSubscription, validate, validateOnBlur, - version, warnWhenUnsavedChanges, sanitizeEmptyValues: shouldSanitizeEmptyValues = true, ...props @@ -199,7 +196,7 @@ const FormWithRedirect = ({
{ useResetSubmitErrors(); useWarnWhenUnsavedChanges(warnWhenUnsavedChanges, formRootPathname); - const dispatch = useDispatch(); - const { redirect, handleSubmit, pristine } = props; - - useEffect(() => { - dispatch(setAutomaticRefresh(pristine)); - }, [dispatch, pristine]); + const { redirect, handleSubmit } = props; /** * We want to let developers define the redirection target from inside the form, diff --git a/packages/ra-core/src/i18n/useSetLocale.tsx b/packages/ra-core/src/i18n/useSetLocale.tsx index 2c2cc308610..f906a4cbe01 100644 --- a/packages/ra-core/src/i18n/useSetLocale.tsx +++ b/packages/ra-core/src/i18n/useSetLocale.tsx @@ -1,7 +1,6 @@ import { useContext, useCallback } from 'react'; import { TranslationContext } from './TranslationContext'; -import { useUpdateLoading } from '../loading'; import { useNotify } from '../sideEffect'; /** @@ -32,26 +31,22 @@ import { useNotify } from '../sideEffect'; */ const useSetLocale = (): SetLocale => { const { setLocale, i18nProvider } = useContext(TranslationContext); - const { startLoading, stopLoading } = useUpdateLoading(); const notify = useNotify(); return useCallback( (newLocale: string) => new Promise(resolve => { - startLoading(); // so we systematically return a Promise for the messages // i18nProvider may return a Promise for language changes, resolve(i18nProvider.changeLocale(newLocale)); }) .then(() => { - stopLoading(); setLocale(newLocale); }) .catch(error => { - stopLoading(); notify('ra.notification.i18n_error', { type: 'warning' }); console.error(error); }), - [i18nProvider, notify, setLocale, startLoading, stopLoading] + [i18nProvider, notify, setLocale] ); }; diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 986fd089bfb..26c3103f448 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -10,7 +10,6 @@ export * from './dataProvider'; export * from './export'; export * from './i18n'; export * from './inference'; -export * from './loading'; export * from './util'; export * from './controller'; export * from './form'; diff --git a/packages/ra-core/src/loading/index.ts b/packages/ra-core/src/loading/index.ts deleted file mode 100644 index 2e1c2c09a1b..00000000000 --- a/packages/ra-core/src/loading/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import useLoading from './useLoading'; -import useUpdateLoading from './useUpdateLoading'; - -export { useLoading, useUpdateLoading }; diff --git a/packages/ra-core/src/loading/useUpdateLoading.ts b/packages/ra-core/src/loading/useUpdateLoading.ts deleted file mode 100644 index a568249ca7e..00000000000 --- a/packages/ra-core/src/loading/useUpdateLoading.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; - -import { fetchStart, fetchEnd } from '../actions/fetchActions'; - -/** - * Update the loading count, which starts or stops the loading indicator. - * - * To be used to show the loading indicator when you don't use the dataProvider. - * - * @return {Object} startLoading and stopLoading callbacks - * - * @example - * import { useUpdateLoading } from 'react-admin' - * - * const MyComponent = () => { - * const { startLoading, stopLoading } = useUpdateLoading(); - * useEffect(() => { - * startLoading(); - * fetch('http://my.domain.api/foo') - * .finally(() => stopLoading()); - * }, []); - * return Foo; - * } - */ -export default () => { - const dispatch = useDispatch(); - - const startLoading = useCallback(() => { - dispatch(fetchStart()); - }, [dispatch]); - - const stopLoading = useCallback(() => { - dispatch(fetchEnd()); - }, [dispatch]); - - return { startLoading, stopLoading }; -}; diff --git a/packages/ra-core/src/reducer/admin/customQueries.ts b/packages/ra-core/src/reducer/admin/customQueries.ts deleted file mode 100644 index ee6db94fe5d..00000000000 --- a/packages/ra-core/src/reducer/admin/customQueries.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Reducer } from 'redux'; - -export interface State { - [key: string]: any; -} - -// reducer for queries called via useQueryWithStore and without a custom action name -const customQueriesReducer: Reducer = ( - previousState = {}, - { type, requestPayload, payload, meta } -) => { - if (type !== 'CUSTOM_QUERY_SUCCESS') { - return previousState; - } - const key = JSON.stringify({ - type: meta.fetchResponse, - resource: meta.resource, - payload: requestPayload, - }); - return { - ...previousState, - [key]: payload, - }; -}; - -export default customQueriesReducer; diff --git a/packages/ra-core/src/reducer/admin/index.ts b/packages/ra-core/src/reducer/admin/index.ts index c56acdb5d1f..f5911afcf87 100644 --- a/packages/ra-core/src/reducer/admin/index.ts +++ b/packages/ra-core/src/reducer/admin/index.ts @@ -3,10 +3,8 @@ import resources, { getResources as resourceGetResources, getReferenceResource as resourceGetReferenceResource, } from './resource'; -import loading from './loading'; import notifications from './notifications'; import ui from './ui'; -import customQueries from './customQueries'; const defaultReducer = () => null; @@ -19,8 +17,6 @@ export default combineReducers({ * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests */ resources: resources || defaultReducer, - customQueries: customQueries || defaultReducer, - loading: loading || defaultReducer, notifications: notifications || defaultReducer, ui: ui || defaultReducer, }); diff --git a/packages/ra-core/src/reducer/admin/loading.spec.ts b/packages/ra-core/src/reducer/admin/loading.spec.ts deleted file mode 100644 index 6b59a8f1408..00000000000 --- a/packages/ra-core/src/reducer/admin/loading.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import expect from 'expect'; -import { - FETCH_START, - FETCH_END, - FETCH_ERROR, - FETCH_CANCEL, -} from '../../actions/fetchActions'; - -import reducer from './loading'; - -describe('loading reducer', () => { - it('should return 0 by default', () => { - expect(reducer(undefined, { type: 'ANY' })).toEqual(0); - }); - it('should increase with fetch or auth actions', () => { - expect(reducer(0, { type: FETCH_START })).toEqual(1); - }); - it('should decrease with fetch or auth actions success or failure', () => { - expect(reducer(1, { type: FETCH_END })).toEqual(0); - expect(reducer(1, { type: FETCH_ERROR })).toEqual(0); - expect(reducer(1, { type: FETCH_CANCEL })).toEqual(0); - }); -}); diff --git a/packages/ra-core/src/reducer/admin/loading.ts b/packages/ra-core/src/reducer/admin/loading.ts deleted file mode 100644 index 7a8032be138..00000000000 --- a/packages/ra-core/src/reducer/admin/loading.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Reducer } from 'redux'; -import { - FETCH_START, - FETCH_END, - FETCH_ERROR, - FETCH_CANCEL, -} from '../../actions/fetchActions'; - -type State = number; - -const loadingReducer: Reducer = (previousState = 0, { type }) => { - switch (type) { - case FETCH_START: - return previousState + 1; - case FETCH_END: - case FETCH_ERROR: - case FETCH_CANCEL: - return Math.max(previousState - 1, 0); - default: - return previousState; - } -}; - -export default loadingReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/data.spec.ts b/packages/ra-core/src/reducer/admin/resource/data.spec.ts deleted file mode 100644 index c2cf8094c73..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/data.spec.ts +++ /dev/null @@ -1,524 +0,0 @@ -import expect from 'expect'; - -import { - DELETE, - DELETE_MANY, - UPDATE, - GET_MANY, - GET_MANY_REFERENCE, - CREATE, - GET_ONE, -} from '../../../core'; -import getFetchedAt from '../../../util/getFetchedAt'; -import dataReducer, { - addRecordsAndRemoveOutdated, - addRecords, - addOneRecord, - removeRecords, -} from './data'; -import { FETCH_END } from '../../../actions'; - -jest.mock('../../../util/getFetchedAt'); - -describe('data reducer', () => { - describe('addRecordsAndRemoveOutdated', () => { - it('should call getFetchedAt with newRecords ids and oldRecordFetchedAt and return records returned by getFetchedAt', () => { - const newRecords = [{ id: 'record1' }, { id: 'record2' }]; - const oldFetchedAt = {}; - const date1 = new Date(); - const date2 = new Date(); - const oldRecords = { - fetchedAt: oldFetchedAt, - }; - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - record1: date1, - record2: date2, - })); - - const newState = addRecordsAndRemoveOutdated( - newRecords, - oldRecords - ); - - // @ts-ignore - expect(getFetchedAt.mock.calls[0]).toEqual([ - ['record1', 'record2'], - oldFetchedAt, - ]); - - expect(newState).toEqual({ - record1: { id: 'record1' }, - record2: { id: 'record2' }, - }); - - expect(newState.fetchedAt).toEqual({ - record1: date1, - record2: date2, - }); - }); - - it('should discard record that do not have their ids returned by getFetchedAt', () => { - const newRecords = [{ id: 'record1' }, { id: 'record2' }]; - const oldRecords = { - record3: { id: 'record3' }, - fetchedAt: { record3: new Date() }, - }; - - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - record1: new Date(), - record2: new Date(), - })); - - const newState = addRecordsAndRemoveOutdated( - newRecords, - oldRecords - ); - - expect(newState).toEqual({ - record1: { id: 'record1' }, - record2: { id: 'record2' }, - }); - }); - - it('should keep record that have their ids returned by getFetchedAt', () => { - const newRecords = [{ id: 'record1' }, { id: 'record2' }]; - const oldRecords = { - record3: { id: 'record3' }, - fetchedAt: { record3: new Date() }, - }; - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - record1: new Date(), - record2: new Date(), - record3: new Date(), - })); - - const newState = addRecordsAndRemoveOutdated( - newRecords, - oldRecords - ); - - expect(newState).toEqual({ - record1: { id: 'record1' }, - record2: { id: 'record2' }, - record3: { id: 'record3' }, - }); - }); - - it('should replace oldRecord by new record', () => { - const newRecords = [ - { id: 'record1', title: 'new title' }, - { id: 'record2' }, - ]; - const oldRecords = { - record1: { id: 'record1', title: 'old title' }, - fetchedAt: { record1: new Date() }, - }; - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - record1: new Date(), - record2: new Date(), - })); - - const newState = addRecordsAndRemoveOutdated( - newRecords, - oldRecords - ); - - expect(newState).toEqual({ - record1: { id: 'record1', title: 'new title' }, - record2: { id: 'record2' }, - }); - }); - }); - - describe('addRecords', () => { - it('should add new records without changing the old ones', () => { - const now = new Date(); - const before = new Date(0); - const newRecords = [ - { id: 'new_record1', title: 'new title 1' }, - { id: 'new_record2', title: 'new title 2' }, - ]; - const oldRecords = { - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - fetchedAt: { record1: before, record2: before }, - }; - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - new_record1: now, - new_record2: now, - })); - - const newState = addRecords(newRecords, oldRecords); - - expect(newState).toEqual({ - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - new_record1: { id: 'new_record1', title: 'new title 1' }, - new_record2: { id: 'new_record2', title: 'new title 2' }, - }); - - expect(newState.fetchedAt).toEqual({ - record1: before, - record2: before, - new_record1: now, - new_record2: now, - }); - }); - - it('should update existing records without changing the other ones', () => { - const now = new Date(); - const before = new Date(0); - const newRecords = [ - { id: 'new_record1', title: 'new title 1' }, - { id: 'record2', title: 'updated title 2' }, - ]; - const oldRecords = { - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - fetchedAt: { record1: before, record2: before }, - }; - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - new_record1: now, - record2: now, - })); - - const newState = addRecords(newRecords, oldRecords); - - expect(newState).toEqual({ - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'updated title 2' }, - new_record1: { id: 'new_record1', title: 'new title 1' }, - }); - - expect(newState.fetchedAt).toEqual({ - record1: before, - record2: now, - new_record1: now, - }); - }); - - it('should reuse oldRecord if new record is the same', () => { - const now = new Date(); - const before = new Date(0); - const newRecords = [ - { id: 'record1', title: 'title 1' }, // same as before - ]; - const oldRecords = { - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - fetchedAt: { record1: before, record2: before }, - }; - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - record1: now, - })); - - const newState = addRecords(newRecords, oldRecords); - - expect(newState).toEqual({ - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - }); - expect(newState.record1).toEqual(oldRecords.record1); - - expect(newState.fetchedAt).toEqual({ - record1: now, - record2: before, - }); - }); - }); - - describe('addOneRecord', () => { - it('should add given record without changing the others', () => { - const now = new Date(); - const before = new Date(0); - const newRecord = { id: 'new_record', title: 'new title' }; - const oldRecords = { - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - fetchedAt: { record1: before, record2: before }, - }; - - const newState = addOneRecord(newRecord, oldRecords, now); - - expect(newState).toEqual({ - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - new_record: { id: 'new_record', title: 'new title' }, - }); - - expect(newState.fetchedAt).toEqual({ - record1: before, - record2: before, - new_record: now, - }); - }); - - it('should update given record without changing the others', () => { - const now = new Date(); - const before = new Date(0); - const newRecord = { id: 'record1', title: 'new title' }; - const oldRecords = { - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - fetchedAt: { record1: before, record2: before }, - }; - - const newState = addOneRecord(newRecord, oldRecords, now); - - expect(newState).toEqual({ - record1: { id: 'record1', title: 'new title' }, - record2: { id: 'record2', title: 'title 2' }, - }); - - expect(newState.fetchedAt).toEqual({ - record1: now, - record2: before, - }); - }); - }); - - describe('removeRecords', () => { - it('should remove the records passed as arguments when using integer ids', () => { - const before = new Date(0); - const oldRecords = { - 0: { id: 0, title: 'title 1' }, - 1: { id: 1, title: 'title 2' }, - fetchedAt: { 0: before, 1: before }, - }; - - const newState = removeRecords([1], oldRecords); - - expect(newState).toEqual({ - 0: { id: 0, title: 'title 1' }, - }); - - expect(newState.fetchedAt).toEqual({ - 0: before, - }); - }); - - it('should remove the records passed as arguments when using string ids', () => { - const before = new Date(0); - const oldRecords = { - record1: { id: 'record1', title: 'title 1' }, - record2: { id: 'record2', title: 'title 2' }, - fetchedAt: { record1: before, record2: before }, - }; - - const newState = removeRecords(['record2'], oldRecords); - - expect(newState).toEqual({ - record1: { id: 'record1', title: 'title 1' }, - }); - - expect(newState.fetchedAt).toEqual({ - record1: before, - }); - }); - - it('should remove the records passed as arguments when using mixed ids', () => { - const before = new Date(0); - const oldRecords = { - '0': { id: 0, title: 'title 1' }, - '1': { id: 1, title: 'title 2' }, - fetchedAt: { '0': before, '1': before }, - }; - - const newState = removeRecords(['1'], oldRecords); - - expect(newState).toEqual({ - '0': { id: 0, title: 'title 1' }, - }); - - expect(newState.fetchedAt).toEqual({ - '0': before, - }); - }); - }); - - describe('optimistic DELETE', () => { - it('removes the deleted record', () => { - const now = new Date(); - const state = { - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'value' }, - record3: { id: 'record3', prop: 'value' }, - fetchedAt: { - record1: now, - record2: now, - record3: now, - }, - }; - - const newState = dataReducer(state, { - type: 'FOO', - payload: { id: 'record2' }, - meta: { - fetch: DELETE, - optimistic: true, - }, - }); - expect(newState).toEqual({ - record1: { id: 'record1', prop: 'value' }, - record3: { id: 'record3', prop: 'value' }, - }); - expect(newState.fetchedAt).toEqual({ - record1: now, - record3: now, - }); - }); - }); - describe('optimistic DELETE_MANY', () => { - it('removes the deleted records', () => { - const now = new Date(); - const state = { - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'value' }, - record3: { id: 'record3', prop: 'value' }, - fetchedAt: { - record1: now, - record2: now, - record3: now, - }, - }; - - const newState = dataReducer(state, { - type: 'FOO', - payload: { ids: ['record3', 'record2'] }, - meta: { - fetch: DELETE_MANY, - optimistic: true, - }, - }); - expect(newState).toEqual({ - record1: { id: 'record1', prop: 'value' }, - }); - expect(newState.fetchedAt).toEqual({ - record1: now, - }); - }); - }); - describe('optimistic UPDATE', () => { - it('update the given record without touching the other', () => { - const before = new Date(0); - const state = { - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'value' }, - record3: { id: 'record3', prop: 'value' }, - fetchedAt: { - record1: before, - record2: before, - record3: before, - }, - }; - - const newState = dataReducer(state, { - type: 'FOO', - payload: { id: 'record2', data: { prop: 'new value' } }, - meta: { - fetch: UPDATE, - optimistic: true, - }, - }); - expect(newState).toEqual({ - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'new value' }, - record3: { id: 'record3', prop: 'value' }, - }); - expect(newState.fetchedAt.record1).toEqual(before); - expect(newState.fetchedAt.record3).toEqual(before); - - expect(newState.fetchedAt.record2).not.toEqual(before); - }); - }); - - describe.each([UPDATE, CREATE, GET_ONE])('%s', actionType => { - it('update the given record without touching the other', () => { - const before = new Date(0); - const state = { - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'value' }, - record3: { id: 'record3', prop: 'value' }, - fetchedAt: { - record1: before, - record2: before, - record3: before, - }, - }; - - const newState = dataReducer(state, { - type: 'FOO', - payload: { data: { id: 'record2', prop: 'new value' } }, - meta: { - fetchResponse: actionType, - fetchStatus: FETCH_END, - }, - }); - expect(newState).toEqual({ - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'new value' }, - record3: { id: 'record3', prop: 'value' }, - }); - expect(newState.fetchedAt.record1).toEqual(before); - expect(newState.fetchedAt.record3).toEqual(before); - - expect(newState.fetchedAt.record2).not.toEqual(before); - }); - }); - - describe.each([GET_MANY_REFERENCE, GET_MANY])('%s', actionType => { - it('should add new records to the old one', () => { - const before = new Date(0); - const now = new Date(); - - // @ts-ignore - getFetchedAt.mockImplementationOnce(() => ({ - new_record: now, - record2: now, - })); - - const state = { - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'value' }, - record3: { id: 'record3', prop: 'value' }, - fetchedAt: { - record1: before, - record2: before, - record3: before, - }, - }; - - const newState = dataReducer(state, { - type: actionType, - payload: { - data: [ - { id: 'record2', prop: 'updated value' }, - { id: 'new_record', prop: 'new value' }, - ], - }, - meta: { - fetchResponse: actionType, - fetchStatus: FETCH_END, - }, - }); - expect(newState).toEqual({ - record1: { id: 'record1', prop: 'value' }, - record2: { id: 'record2', prop: 'updated value' }, - record3: { id: 'record3', prop: 'value' }, - new_record: { id: 'new_record', prop: 'new value' }, - }); - expect(newState.fetchedAt.record1).toEqual(before); - expect(newState.fetchedAt.record3).toEqual(before); - - expect(newState.fetchedAt.record2).not.toEqual(before); - expect(newState.fetchedAt.new_record).not.toEqual(before); - }); - }); -}); diff --git a/packages/ra-core/src/reducer/admin/resource/data.ts b/packages/ra-core/src/reducer/admin/resource/data.ts deleted file mode 100644 index 987966f6f61..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/data.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Reducer } from 'redux'; -import isEqual from 'lodash/isEqual'; -import { FETCH_END } from '../../../actions'; -import { - CREATE, - DELETE, - DELETE_MANY, - GET_LIST, - GET_MANY, - GET_MANY_REFERENCE, - GET_ONE, - UPDATE, - UPDATE_MANY, -} from '../../../core'; -import getFetchedAt from '../../../util/getFetchedAt'; -import { Record, Identifier } from '../../../types'; - -/** - * A list of records indexed by id, together with their fetch dates - * - * Note that the fetchedAt property isn't enumerable. - * - * @example - * { - * 12: { id: 12, title: "hello" }, - * 34: { id: 34, title: "world" }, - * fetchedAt: { - * 12: new Date('2019-02-06T21:23:07.049Z'), - * 34: new Date('2019-02-06T21:23:07.049Z'), - * } - * } - */ -interface RecordSetWithDate { - // FIXME: use [key: Identifier] once typeScript accepts any type as index (see https://github.com/Microsoft/TypeScript/pull/26797) - [key: string]: Record | object; - [key: number]: Record; - fetchedAt: { - // FIXME: use [key: Identifier] once typeScript accepts any type as index (see https://github.com/Microsoft/TypeScript/pull/26797) - [key: string]: Date; - [key: number]: Date; - }; -} - -/** - * Make the fetchedAt property non enumerable - */ -export const hideFetchedAt = ( - records: RecordSetWithDate -): RecordSetWithDate => { - Object.defineProperty(records, 'fetchedAt', { - enumerable: false, - configurable: false, - writable: false, - }); - return records; -}; - -/** - * Add new records to the pool, and remove outdated ones. - * - * This is the equivalent of a stale-while-revalidate caching strategy: - * The cached data is displayed before fetching, and stale data is removed - * only once fresh data is fetched. - */ -export const addRecordsAndRemoveOutdated = ( - newRecords: Record[] = [], - oldRecords: RecordSetWithDate -): RecordSetWithDate => { - const newRecordsById = {}; - newRecords.forEach(record => (newRecordsById[record.id] = record)); - - const newFetchedAt = getFetchedAt( - newRecords.map(({ id }) => id), - oldRecords.fetchedAt - ); - - const records = { fetchedAt: newFetchedAt }; - Object.keys(newFetchedAt).forEach( - id => - (records[id] = newRecordsById[id] - ? isEqual(newRecordsById[id], oldRecords[id]) - ? oldRecords[id] // do not change the record to avoid a redraw - : newRecordsById[id] - : oldRecords[id]) - ); - - return hideFetchedAt(records); -}; - -/** - * Add new records to the pool, without touching the other ones. - */ -export const addRecords = ( - newRecords: Record[] = [], - oldRecords: RecordSetWithDate -): RecordSetWithDate => { - const newRecordsById = { ...oldRecords }; - newRecords.forEach(record => { - newRecordsById[record.id] = isEqual(record, oldRecords[record.id]) - ? (oldRecords[record.id] as Record) - : record; - }); - - const updatedFetchedAt = getFetchedAt( - newRecords.map(({ id }) => id), - oldRecords.fetchedAt - ); - - Object.defineProperty(newRecordsById, 'fetchedAt', { - value: { ...oldRecords.fetchedAt, ...updatedFetchedAt }, - enumerable: false, - }); - - return newRecordsById; -}; - -export const addOneRecord = ( - newRecord: Record, - oldRecords: RecordSetWithDate, - date = new Date() -): RecordSetWithDate => { - const newRecordsById = { - ...oldRecords, - [newRecord.id]: isEqual(newRecord, oldRecords[newRecord.id]) - ? oldRecords[newRecord.id] // do not change the record to avoid a redraw - : newRecord, - } as RecordSetWithDate; - - return Object.defineProperty(newRecordsById, 'fetchedAt', { - value: { ...oldRecords.fetchedAt, [newRecord.id]: date }, - enumerable: false, - }); -}; - -const includesNotStrict = (items, element) => - items.some(item => item == element); // eslint-disable-line eqeqeq - -/** - * Remove records from the pool - */ -export const removeRecords = ( - removedRecordIds: Identifier[] = [], - oldRecords: RecordSetWithDate -): RecordSetWithDate => { - const records = Object.entries(oldRecords) - .filter(([key]) => !includesNotStrict(removedRecordIds, key)) - .reduce((obj, [key, val]) => ({ ...obj, [key]: val }), { - fetchedAt: {}, // TypeScript warns later if this is not defined - }); - records.fetchedAt = Object.entries(oldRecords.fetchedAt) - .filter(([key]) => !includesNotStrict(removedRecordIds, key)) - .reduce((obj, [key, val]) => ({ ...obj, [key]: val }), {}); - - return hideFetchedAt(records); -}; - -const initialState = hideFetchedAt({ fetchedAt: {} }); - -const dataReducer: Reducer = ( - previousState = initialState, - { payload, meta } -) => { - if (meta && meta.optimistic) { - if (meta.fetch === UPDATE) { - const updatedRecord = { - ...previousState[payload.id], - ...payload.data, - }; - return addOneRecord(updatedRecord, previousState); - } - if (meta.fetch === UPDATE_MANY) { - const updatedRecords = payload.ids.map(id => ({ - ...previousState[id], - ...payload.data, - })); - return addRecordsAndRemoveOutdated(updatedRecords, previousState); - } - if (meta.fetch === DELETE) { - return removeRecords([payload.id], previousState); - } - if (meta.fetch === DELETE_MANY) { - return removeRecords(payload.ids, previousState); - } - } - if (!meta || !meta.fetchResponse || meta.fetchStatus !== FETCH_END) { - return previousState; - } - - switch (meta.fetchResponse) { - case GET_LIST: - return addRecordsAndRemoveOutdated(payload.data, previousState); - case GET_MANY: - case GET_MANY_REFERENCE: - return addRecords(payload.data, previousState); - case UPDATE: - case CREATE: - case GET_ONE: - return addOneRecord(payload.data, previousState); - default: - return previousState; - } -}; - -export const getRecord = (state, id) => state[id]; - -export default dataReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/index.spec.ts b/packages/ra-core/src/reducer/admin/resource/index.spec.ts index 38c36add3ed..53035d54957 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.spec.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.spec.ts @@ -19,7 +19,6 @@ describe('Resources Reducer', () => { reducer( { posts: { - data: {}, list: { params: { filter: {}, @@ -28,16 +27,12 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'posts' }, }, comments: { - data: {}, list: { params: { filter: {}, @@ -46,12 +41,9 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'comments' }, }, }, @@ -65,7 +57,6 @@ describe('Resources Reducer', () => { ) ).toEqual({ posts: { - data: {}, list: { params: { filter: {}, @@ -74,16 +65,12 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'posts' }, }, comments: { - data: {}, list: { params: { filter: {}, @@ -92,16 +79,12 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'comments' }, }, users: { - data: {}, list: { params: { filter: {}, @@ -110,12 +93,9 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'users', options: 'foo' }, }, }); @@ -126,7 +106,6 @@ describe('Resources Reducer', () => { reducer( { posts: { - data: {}, list: { params: { filter: {}, @@ -135,17 +114,12 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'posts' }, }, comments: { - data: {}, list: { params: { filter: {}, @@ -154,12 +128,9 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'comments' }, }, }, @@ -170,7 +141,6 @@ describe('Resources Reducer', () => { ) ).toEqual({ posts: { - data: {}, list: { params: { filter: {}, @@ -179,12 +149,9 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'posts' }, }, }); @@ -195,7 +162,6 @@ describe('Resources Reducer', () => { reducer( { posts: { - data: {}, list: { params: { filter: {}, @@ -204,16 +170,12 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'posts' }, }, comments: { - data: {}, list: { params: { filter: {}, @@ -222,12 +184,9 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'comments' }, }, }, @@ -246,7 +205,6 @@ describe('Resources Reducer', () => { ) ).toEqual({ posts: { - data: {}, list: { params: { filter: { commentable: true }, @@ -255,16 +213,12 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'posts' }, }, comments: { - data: {}, list: { params: { filter: {}, @@ -273,12 +227,9 @@ describe('Resources Reducer', () => { perPage: null, sort: null, }, - cachedRequests: {}, expanded: [], - total: null, selectedIds: [], }, - validity: {}, props: { name: 'comments' }, }, }); diff --git a/packages/ra-core/src/reducer/admin/resource/index.ts b/packages/ra-core/src/reducer/admin/resource/index.ts index 3cb7045f15d..38176d6a1fb 100644 --- a/packages/ra-core/src/reducer/admin/resource/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/index.ts @@ -3,29 +3,22 @@ import { RegisterResourceAction, UNREGISTER_RESOURCE, UnregisterResourceAction, - REFRESH_VIEW, - RefreshViewAction, } from '../../../actions'; -import data from './data'; import list from './list'; -import validity from './validity'; const initialState = {}; type ActionTypes = | RegisterResourceAction | UnregisterResourceAction - | RefreshViewAction | { type: 'OTHER_ACTION'; payload?: any; meta?: { resource?: string } }; export default (previousState = initialState, action: ActionTypes) => { if (action.type === REGISTER_RESOURCE) { const resourceState = { props: action.payload, - data: data(undefined, action), list: list(undefined, action), - validity: validity(undefined, action), }; return { ...previousState, @@ -43,10 +36,7 @@ export default (previousState = initialState, action: ActionTypes) => { }, {}); } - if ( - action.type !== REFRESH_VIEW && - (!action.meta || !action.meta.resource) - ) { + if (!action.meta || !action.meta.resource) { return previousState; } @@ -55,16 +45,10 @@ export default (previousState = initialState, action: ActionTypes) => { (acc, resource) => ({ ...acc, [resource]: - action.type === REFRESH_VIEW || action.meta.resource === resource ? { props: previousState[resource].props, - data: data(previousState[resource].data, action), list: list(previousState[resource].list, action), - validity: validity( - previousState[resource].validity, - action - ), } : previousState[resource], }), diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts deleted file mode 100644 index 6adf7f88b97..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Reducer } from 'redux'; - -import { Identifier } from '../../../../types'; -import { FETCH_END, REFRESH_VIEW } from '../../../../actions'; -import { - GET_LIST, - CREATE, - DELETE, - DELETE_MANY, - UPDATE, - UPDATE_MANY, -} from '../../../../core'; -import total from './cachedRequests/total'; -import validity from './cachedRequests/validity'; - -interface CachedRequestState { - ids: Identifier[]; - total: number; - validity: Date; -} - -interface State { - [key: string]: CachedRequestState; -} - -const initialState = {}; -const initialSubstate = { total: null, validity: null }; - -const cachedRequestsReducer: Reducer = ( - previousState = initialState, - action -) => { - if (action.type === REFRESH_VIEW) { - if (action.payload?.hard) { - // force refresh - return initialState; - } else { - // remove validity only - const newState = {}; - Object.keys(previousState).forEach(key => { - newState[key] = { - ...previousState[key], - validity: undefined, - }; - }); - return newState; - } - } - if (action.meta && action.meta.optimistic) { - if ( - action.meta.fetch === CREATE || - action.meta.fetch === DELETE || - action.meta.fetch === DELETE_MANY || - action.meta.fetch === UPDATE || - action.meta.fetch === UPDATE_MANY - ) { - // force refresh of all lists because we don't know where the - // new/deleted/updated record(s) will appear in the list - return initialState; - } - } - if (!action.meta || action.meta.fetchStatus !== FETCH_END) { - // not a return from the dataProvider - return previousState; - } - if ( - action.meta.fetchResponse === CREATE || - action.meta.fetchResponse === DELETE || - action.meta.fetchResponse === DELETE_MANY || - action.meta.fetchResponse === UPDATE || - action.meta.fetchResponse === UPDATE_MANY - ) { - // force refresh of all lists because we don't know where the - // new/deleted/updated record(s) will appear in the list - return initialState; - } - if (action.meta.fetchResponse !== GET_LIST || action.meta.fromCache) { - // looks like a GET_MANY, a GET_ONE, or a cached response - return previousState; - } - const requestKey = JSON.stringify(action.requestPayload); - const previousSubState = previousState[requestKey] || initialSubstate; - return { - ...previousState, - [requestKey]: { - total: total(previousSubState.total, action), - validity: validity(previousSubState.validity, action), - }, - }; -}; - -export default cachedRequestsReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/total.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/total.ts deleted file mode 100644 index cfa1edb4af1..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/total.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Reducer } from 'redux'; - -import { GET_LIST } from '../../../../../core'; -import { - CrudGetListSuccessAction, - CrudGetMatchingSuccessAction, -} from '../../../../../actions/dataActions'; - -type ActionTypes = - | CrudGetListSuccessAction - | CrudGetMatchingSuccessAction - | { type: 'OTHER_TYPE'; payload: any; meta: any }; - -type State = number; - -const initialState = null; - -const totalReducer: Reducer = ( - previousState = initialState, - action: ActionTypes -) => { - if (action.meta && action.meta.fetchResponse === GET_LIST) { - return action.payload.total; - } - return previousState; -}; - -export default totalReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/validity.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/validity.ts deleted file mode 100644 index db5833014e4..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests/validity.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Reducer } from 'redux'; -import { GET_LIST } from '../../../../../core'; - -type State = Date; - -const initialState = null; - -const validityReducer: Reducer = ( - previousState = initialState, - { payload, meta } -) => { - switch (meta.fetchResponse) { - case GET_LIST: { - if (payload.validUntil) { - // store the validity date - return payload.validUntil; - } else { - // remove the validity date - return initialState; - } - } - default: - return previousState; - } -}; - -export default validityReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/index.ts b/packages/ra-core/src/reducer/admin/resource/list/index.ts index 6adb742fb2f..a3b52a10a82 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/index.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/index.ts @@ -1,9 +1,7 @@ import { combineReducers } from 'redux'; -import cachedRequests from './cachedRequests'; import expanded from './expanded'; import params from './params'; import selectedIds from './selectedIds'; -import total from './total'; const defaultReducer = () => null; @@ -15,9 +13,7 @@ export default combineReducers({ * * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests */ - cachedRequests: cachedRequests || defaultReducer, expanded: expanded || defaultReducer, params: params || defaultReducer, selectedIds: selectedIds || defaultReducer, - total: total || defaultReducer, }); diff --git a/packages/ra-core/src/reducer/admin/resource/list/total.ts b/packages/ra-core/src/reducer/admin/resource/list/total.ts deleted file mode 100644 index 1d439006d2b..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/total.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Reducer } from 'redux'; -import { - CRUD_GET_LIST_SUCCESS, - CrudGetListSuccessAction, -} from '../../../../actions/dataActions'; -import { DELETE, DELETE_MANY } from '../../../../core'; - -type ActionTypes = - | CrudGetListSuccessAction - | { - type: 'OTHER_TYPE'; - payload?: { ids: string[] }; - meta?: { optimistic?: boolean; fetch?: string }; - }; - -type State = number; - -const totalReducer: Reducer = ( - previousState = null, - action: ActionTypes -) => { - if (action.type === CRUD_GET_LIST_SUCCESS) { - return action.payload.total; - } - if (action.meta && action.meta.optimistic) { - if (action.meta.fetch === DELETE) { - return previousState === null ? null : previousState - 1; - } - if (action.meta.fetch === DELETE_MANY) { - return previousState === null - ? null - : previousState - action.payload.ids.length; - } - } - return previousState; -}; - -export default totalReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/validity.ts b/packages/ra-core/src/reducer/admin/resource/list/validity.ts deleted file mode 100644 index c1dc85284c8..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/validity.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Reducer } from 'redux'; -import { FETCH_END, REFRESH_VIEW } from '../../../../actions'; -import { GET_LIST, CREATE } from '../../../../core'; - -interface ValidityRegistry { - [key: string]: Date; -} - -const initialState = {}; - -const validityReducer: Reducer = ( - previousState = initialState, - { type, payload, requestPayload, meta } -) => { - if (type === REFRESH_VIEW) { - return initialState; - } - if ( - !meta || - !meta.fetchResponse || - meta.fetchStatus !== FETCH_END || - meta.fromCache === true - ) { - return previousState; - } - switch (meta.fetchResponse) { - case GET_LIST: { - if (payload.validUntil) { - // store the validity date - return { - ...previousState, - [JSON.stringify(requestPayload)]: payload.validUntil, - }; - } else { - // remove the validity date - const { - [JSON.stringify(requestPayload)]: value, - ...rest - } = previousState; - return rest; - } - } - case CREATE: - // force refresh of all lists because we don't know where the - // new record will appear in the list - return initialState; - default: - return previousState; - } -}; - -export default validityReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/validity.ts b/packages/ra-core/src/reducer/admin/resource/validity.ts deleted file mode 100644 index b6559965e5d..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/validity.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Reducer } from 'redux'; -import { FETCH_END, REFRESH_VIEW } from '../../../actions'; -import { - CREATE, - DELETE, - DELETE_MANY, - GET_LIST, - GET_MANY, - GET_MANY_REFERENCE, - GET_ONE, - UPDATE, - UPDATE_MANY, -} from '../../../core'; -import { Identifier } from '../../../types'; - -interface ValidityRegistry { - // FIXME: use [key: Identifier] once typeScript accepts any type as index (see https://github.com/Microsoft/TypeScript/pull/26797) - [key: string]: Date; - [key: number]: Date; -} - -const initialState = {}; - -const validityReducer: Reducer = ( - previousState = initialState, - { type, payload, requestPayload, meta } -) => { - if (type === REFRESH_VIEW) { - return initialState; - } - if ( - !meta || - !meta.fetchResponse || - meta.fetchStatus !== FETCH_END || - meta.fromCache === true - ) { - return previousState; - } - if (payload.validUntil) { - // store the validity date - switch (meta.fetchResponse) { - case GET_LIST: - case GET_MANY: - case GET_MANY_REFERENCE: - return addIds( - payload.data.map(record => record.id), - payload.validUntil, - previousState - ); - case UPDATE_MANY: - return addIds(payload.data, payload.validUntil, previousState); - case UPDATE: - case CREATE: - case GET_ONE: - return addIds( - [payload.data.id], - payload.validUntil, - previousState - ); - case DELETE: - case DELETE_MANY: - throw new Error( - 'Responses to dataProvider.delete() or dataProvider.deleteMany() should not contain a validUntil param' - ); - default: - return previousState; - } - } else { - // remove the validity date - switch (meta.fetchResponse) { - case GET_LIST: - case GET_MANY: - case GET_MANY_REFERENCE: - return removeIds( - payload.data.map(record => record.id), - previousState - ); - case UPDATE: - case CREATE: - case GET_ONE: - return removeIds([payload.data.id], previousState); - case UPDATE_MANY: - return removeIds(payload.data, previousState); - case DELETE: - return removeIds([requestPayload.id], previousState); - case DELETE_MANY: - return removeIds(requestPayload.ids, previousState); - default: - return previousState; - } - } -}; - -const addIds = ( - ids: Identifier[] = [], - validUntil: Date, - oldValidityRegistry: ValidityRegistry -): ValidityRegistry => { - const validityRegistry = { ...oldValidityRegistry }; - ids.forEach(id => { - validityRegistry[id] = validUntil; - }); - return validityRegistry; -}; - -const removeIds = ( - ids: Identifier[] = [], - oldValidityRegistry: ValidityRegistry -): ValidityRegistry => { - const validityRegistry = { ...oldValidityRegistry }; - ids.forEach(id => { - delete validityRegistry[id]; - }); - return validityRegistry; -}; - -export default validityReducer; diff --git a/packages/ra-core/src/reducer/admin/ui.spec.ts b/packages/ra-core/src/reducer/admin/ui.spec.ts index 7525d811674..c31c4dd2e8b 100644 --- a/packages/ra-core/src/reducer/admin/ui.spec.ts +++ b/packages/ra-core/src/reducer/admin/ui.spec.ts @@ -1,18 +1,10 @@ import expect from 'expect'; -import { - toggleSidebar, - setSidebarVisibility, - refreshView, - setAutomaticRefresh, -} from '../../actions/uiActions'; +import { toggleSidebar, setSidebarVisibility } from '../../actions/uiActions'; import reducer from './ui'; describe('ui reducer', () => { const defaultState = { - automaticRefreshEnabled: true, sidebarOpen: false, - optimistic: false, - viewVersion: 0, }; it('should return hidden sidebar by default', () => { expect(reducer(undefined, { type: 'foo' })).toEqual(defaultState); @@ -51,41 +43,4 @@ describe('ui reducer', () => { ) ); }); - it('should return activated automatic refresh by default', () => { - expect(reducer(undefined, { type: 'foo' })).toEqual(defaultState); - }); - it('should set sidebar visibility upon SET_AUTOMATIC_REFRESH', () => { - expect({ ...defaultState, automaticRefreshEnabled: false }).toEqual( - reducer( - { ...defaultState, automaticRefreshEnabled: true }, - setAutomaticRefresh(false) - ) - ); - expect({ ...defaultState, automaticRefreshEnabled: true }).toEqual( - reducer( - { ...defaultState, automaticRefreshEnabled: true }, - setAutomaticRefresh(true) - ) - ); - expect({ ...defaultState, automaticRefreshEnabled: false }).toEqual( - reducer( - { ...defaultState, automaticRefreshEnabled: false }, - setAutomaticRefresh(false) - ) - ); - expect({ ...defaultState, automaticRefreshEnabled: true }).toEqual( - reducer( - { ...defaultState, automaticRefreshEnabled: false }, - setAutomaticRefresh(true) - ) - ); - }); - it('should increment the viewVersion upon REFRESH_VIEW', () => { - expect({ - automaticRefreshEnabled: true, - optimistic: false, - sidebarOpen: false, - viewVersion: 1, - }).toEqual(reducer(undefined, refreshView())); - }); }); diff --git a/packages/ra-core/src/reducer/admin/ui.ts b/packages/ra-core/src/reducer/admin/ui.ts index 420dceea818..eece446f830 100644 --- a/packages/ra-core/src/reducer/admin/ui.ts +++ b/packages/ra-core/src/reducer/admin/ui.ts @@ -4,32 +4,15 @@ import { ToggleSidebarAction, SET_SIDEBAR_VISIBILITY, SetSidebarVisibilityAction, - REFRESH_VIEW, - RefreshViewAction, - START_OPTIMISTIC_MODE, - StartOptimisticModeAction, - STOP_OPTIMISTIC_MODE, - StopOptimisticModeAction, } from '../../actions'; -import { - SET_AUTOMATIC_REFRESH, - SetAutomaticRefreshAction, -} from '../../actions/uiActions'; type ActionTypes = | ToggleSidebarAction | SetSidebarVisibilityAction - | RefreshViewAction - | StartOptimisticModeAction - | StopOptimisticModeAction - | SetAutomaticRefreshAction | { type: 'OTHER_ACTION' }; export interface UIState { - readonly automaticRefreshEnabled: boolean; readonly sidebarOpen: boolean; - readonly optimistic: boolean; - readonly viewVersion: number; } // Match the medium breakpoint defined in the material-ui theme @@ -43,10 +26,7 @@ const isDesktop = (): boolean => : false; const defaultState: UIState = { - automaticRefreshEnabled: true, sidebarOpen: isDesktop(), - optimistic: false, - viewVersion: 0, }; const uiReducer: Reducer = ( @@ -60,21 +40,10 @@ const uiReducer: Reducer = ( sidebarOpen: !previousState.sidebarOpen, }; case SET_SIDEBAR_VISIBILITY: - return { ...previousState, sidebarOpen: action.payload }; - case SET_AUTOMATIC_REFRESH: - return { - ...previousState, - automaticRefreshEnabled: action.payload, - }; - case REFRESH_VIEW: return { ...previousState, - viewVersion: previousState.viewVersion + 1, + sidebarOpen: action.payload, }; - case START_OPTIMISTIC_MODE: - return { ...previousState, optimistic: true }; - case STOP_OPTIMISTIC_MODE: - return { ...previousState, optimistic: false }; default: return previousState; } diff --git a/packages/ra-core/src/sideEffect/index.ts b/packages/ra-core/src/sideEffect/index.ts index 85b0718143c..627685ace2d 100644 --- a/packages/ra-core/src/sideEffect/index.ts +++ b/packages/ra-core/src/sideEffect/index.ts @@ -1,9 +1,8 @@ import useRedirect, { RedirectionSideEffect } from './useRedirect'; import useNotify from './useNotify'; -import useRefresh from './useRefresh'; import useUnselectAll from './useUnselectAll'; import useUnselect from './useUnselect'; export type { RedirectionSideEffect }; -export { useRedirect, useNotify, useRefresh, useUnselectAll, useUnselect }; +export { useRedirect, useNotify, useUnselectAll, useUnselect }; diff --git a/packages/ra-core/src/sideEffect/useRedirect.ts b/packages/ra-core/src/sideEffect/useRedirect.ts index bbedfb43643..14eaa0d0a47 100644 --- a/packages/ra-core/src/sideEffect/useRedirect.ts +++ b/packages/ra-core/src/sideEffect/useRedirect.ts @@ -1,11 +1,9 @@ import { useCallback, useEffect, useRef } from 'react'; -import { useDispatch } from 'react-redux'; import { useLocation, useNavigate } from 'react-router-dom'; import { parsePath } from 'history'; import { Identifier, Record } from '../types'; import resolveRedirectTo from '../util/resolveRedirectTo'; -import { refreshView } from '../actions/uiActions'; type RedirectToFunction = ( basePath?: string, @@ -28,13 +26,12 @@ export type RedirectionSideEffect = string | boolean | RedirectToFunction; * redirect('edit', '/posts', 123); * // redirect to edit view with state data * redirect('edit', '/comment', 123, {}, { record: { post_id: record.id } }); - * // do not redirect (resets the record form) + * // do not redirect * redirect(false); * // redirect to the result of a function * redirect((redirectTo, basePath, id, data) => ...) */ const useRedirect = () => { - const dispatch = useDispatch(); const navigate = useNavigate(); // Ensure this doesn't rerender too much const location = useLocation(); @@ -53,20 +50,6 @@ const useRedirect = () => { state: object = {} ) => { if (!redirectTo) { - if (locationRef.current.state || locationRef.current.search) { - navigate( - { - ...locationRef.current, - search: undefined, - }, - { - state, - replace: true, - } - ); - } else { - dispatch(refreshView()); - } return; } @@ -89,7 +72,7 @@ const useRedirect = () => { ); } }, - [dispatch, navigate] + [navigate] ); }; diff --git a/packages/ra-core/src/sideEffect/useRefresh.ts b/packages/ra-core/src/sideEffect/useRefresh.ts deleted file mode 100644 index b35499efbd7..00000000000 --- a/packages/ra-core/src/sideEffect/useRefresh.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { refreshView } from '../actions/uiActions'; - -/** - * Hook for Refresh Side Effect - * - * Returns a callback that triggers a page refresh. The callback causes a - * version increase, which forces a re-execution all queries based on the - * useDataProvider() hook, and a rerender of all components using the version - * as key. - * - * @param hard If true, the callback empties the cache, too - * - * @example - * - * const refresh = useRefresh(); - * // soft refresh - * refresh(); - * // hard refresh - * refresh(true) - */ -const useRefresh = () => { - const dispatch = useDispatch(); - return useCallback( - (hard?: boolean) => { - dispatch(refreshView(hard)); - }, - [dispatch] - ); -}; - -export default useRefresh; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index c597b4c3ca9..0d7cad607db 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -1,6 +1,6 @@ import { ReactNode, ReactElement, ComponentType } from 'react'; import { DeprecatedThemeOptions } from '@mui/material'; -import { Location, History } from 'history'; +import { History } from 'history'; import { QueryClient } from 'react-query'; import { WithPermissionsChildrenParams } from './auth/WithPermissions'; @@ -22,12 +22,6 @@ export interface Record { [key: string]: any; } -export interface RecordMap { - // Accept strings and numbers as identifiers - [id: string]: RecordType; - [id: number]: RecordType; -} - export interface SortPayload { field: string; order: string; @@ -227,82 +221,6 @@ export type DataProviderResult = | UpdateResult | UpdateManyResult; -// This generic function type extracts the parameters of the function passed as its DataProviderMethod generic parameter. -// It returns another function with the same parameters plus an optional options parameter used by the useDataProvider hook to specify side effects. -// The returned function has the same result type as the original -type DataProviderProxyMethod< - TDataProviderMethod -> = TDataProviderMethod extends (...a: any[]) => infer Result - ? ( - // This strange spread usage is required for two reasons - // 1. It keeps the named parameters of the original function - // 2. It allows to add an optional options parameter as the LAST parameter - ...a: [ - ...Args: Parameters, - options?: UseDataProviderOptions - ] - ) => Result - : never; - -export type DataProviderProxy< - TDataProvider extends DataProvider = DataProvider -> = { - [MethodKey in keyof TDataProvider]: DataProviderProxyMethod< - TDataProvider[MethodKey] - >; -} & { - getList: ( - resource: string, - params: GetListParams, - options?: UseDataProviderOptions - ) => Promise>; - - getOne: ( - resource: string, - params: GetOneParams, - options?: UseDataProviderOptions - ) => Promise>; - - getMany: ( - resource: string, - params: GetManyParams, - options?: UseDataProviderOptions - ) => Promise>; - - getManyReference: ( - resource: string, - params: GetManyReferenceParams, - options?: UseDataProviderOptions - ) => Promise>; - - update: ( - resource: string, - params: UpdateParams, - options?: UseDataProviderOptions - ) => Promise>; - - updateMany: ( - resource: string, - params: UpdateManyParams, - options?: UseDataProviderOptions - ) => Promise>; - - create: ( - resource: string, - params: CreateParams - ) => Promise>; - - delete: ( - resource: string, - params: DeleteParams - ) => Promise>; - - deleteMany: ( - resource: string, - params: DeleteManyParams - ) => Promise>; -}; - export type MutationMode = 'pessimistic' | 'optimistic' | 'undoable'; export type OnSuccess = ( response?: any, @@ -343,40 +261,18 @@ export interface ResourceDefinition { export interface ReduxState { admin: { ui: { - automaticRefreshEnabled: boolean; - optimistic: boolean; sidebarOpen: boolean; - viewVersion: number; }; resources: { [name: string]: { props: ResourceDefinition; - data: RecordMap; list: { - cachedRequests?: { - ids: Identifier[]; - total: number; - validity: Date; - }; expanded: Identifier[]; - ids: Identifier[]; params: any; selectedIds: Identifier[]; - total: number; - }; - validity: { - [key: string]: Date; - [key: number]: Date; }; }; }; - loading: number; - customQueries: { - [key: string]: any; - }; - }; - router: { - location: Location; }; // leave space for custom reducers @@ -482,7 +378,7 @@ export type Exporter = ( field: string, resource: string ) => Promise, - dataProvider: DataProviderProxy, + dataProvider: DataProvider, resource?: string ) => void | Promise; diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index e8f33b7ea4d..06da178f310 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -12,7 +12,6 @@ import warning from './warning'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; import { useSafeSetState, useTimeout } from './hooks'; import { getMutationMode } from './getMutationMode'; -export * from './indexById'; export * from './mergeRefs'; export { diff --git a/packages/ra-core/src/util/indexById.ts b/packages/ra-core/src/util/indexById.ts deleted file mode 100644 index cc585cb5aac..00000000000 --- a/packages/ra-core/src/util/indexById.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Record, RecordMap } from '../types'; - -/** - * Create a map of records indexed by their id property from an array of records. - * - * @param {Record[]} records. The array of records - * - * @example - * const records = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }]; - * const map = indexById(records); - * // Map has the following structure: - * { - * 1: { id: 1, name: 'foo' }, - * 2: { id: 2, name: 'bar' }, - * } - */ -export const indexById = (records: Record[] = []): RecordMap => - records - .filter(r => typeof r !== 'undefined') - .reduce((prev, current) => { - prev[current.id] = current; - return prev; - }, {}); diff --git a/packages/ra-test/src/TestContext.spec.tsx b/packages/ra-test/src/TestContext.spec.tsx index d7bb4e502b3..78f2a1038cc 100644 --- a/packages/ra-test/src/TestContext.spec.tsx +++ b/packages/ra-test/src/TestContext.spec.tsx @@ -1,21 +1,16 @@ import * as React from 'react'; import expect from 'expect'; import { render, screen } from '@testing-library/react'; -import { refreshView } from 'ra-core'; +import { showNotification } from 'ra-core'; import TestContext, { defaultStore } from './TestContext'; - import { WithDataProvider } from './TestContext.stories'; const primedStore = { admin: { - loading: 0, notifications: [], resources: {}, - ui: { - viewVersion: 1, - }, - customQueries: {}, + ui: {}, }, }; @@ -82,16 +77,13 @@ describe('TestContext.js', () => { ); expect(testStore.getState()).toEqual(primedStore); - testStore.dispatch(refreshView()); + testStore.dispatch(showNotification('here')); expect(testStore.getState()).toEqual({ ...primedStore, admin: { ...primedStore.admin, - ui: { - ...primedStore.admin.ui, - viewVersion: 2, - }, + notifications: [{ message: 'here', type: 'info' }], }, }); }); @@ -108,7 +100,7 @@ describe('TestContext.js', () => { ); expect(testStore.getState()).toEqual(defaultStore); - testStore.dispatch(refreshView()); + testStore.dispatch(showNotification('here')); expect(testStore.getState()).toEqual(defaultStore); }); diff --git a/packages/ra-test/src/TestContext.tsx b/packages/ra-test/src/TestContext.tsx index fed27a37e8d..a18c4d2e48b 100644 --- a/packages/ra-test/src/TestContext.tsx +++ b/packages/ra-test/src/TestContext.tsx @@ -11,7 +11,7 @@ import { createAdminStore, ReduxState } from 'ra-core'; export const defaultStore = { admin: { resources: {}, - ui: { viewVersion: 1 }, + ui: {}, notifications: [], }, }; diff --git a/packages/ra-ui-materialui/src/button/RefreshButton.tsx b/packages/ra-ui-materialui/src/button/RefreshButton.tsx index 6b6cf2c5858..34cc79f8599 100644 --- a/packages/ra-ui-materialui/src/button/RefreshButton.tsx +++ b/packages/ra-ui-materialui/src/button/RefreshButton.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; import { ReactElement, MouseEvent, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; import NavigationRefresh from '@mui/icons-material/Refresh'; -import { refreshView } from 'ra-core'; +import { useRefresh } from 'ra-core'; import { Button, ButtonProps } from './Button'; @@ -14,16 +13,16 @@ export const RefreshButton = (props: RefreshButtonProps) => { onClick, ...rest } = props; - const dispatch = useDispatch(); + const refresh = useRefresh(); const handleClick = useCallback( event => { event.preventDefault(); - dispatch(refreshView()); + refresh(); if (typeof onClick === 'function') { onClick(event); } }, - [dispatch, onClick] + [refresh, onClick] ); return ( diff --git a/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx b/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx index 1b18cfdda51..a41621ad547 100644 --- a/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx +++ b/packages/ra-ui-materialui/src/button/RefreshIconButton.tsx @@ -1,11 +1,10 @@ import * as React from 'react'; import { useCallback, ReactElement } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; import Tooltip from '@mui/material/Tooltip'; import IconButton, { IconButtonProps } from '@mui/material/IconButton'; import NavigationRefresh from '@mui/icons-material/Refresh'; -import { refreshView, useTranslate } from 'ra-core'; +import { useRefresh, useTranslate } from 'ra-core'; export const RefreshIconButton = (props: RefreshIconButtonProps) => { const { @@ -15,17 +14,17 @@ export const RefreshIconButton = (props: RefreshIconButtonProps) => { className, ...rest } = props; - const dispatch = useDispatch(); + const refresh = useRefresh(); const translate = useTranslate(); const handleClick = useCallback( event => { event.preventDefault(); - dispatch(refreshView()); + refresh(); if (typeof onClick === 'function') { onClick(event); } }, - [dispatch, onClick] + [refresh, onClick] ); return ( diff --git a/packages/ra-ui-materialui/src/detail/Create.tsx b/packages/ra-ui-materialui/src/detail/Create.tsx index 86502ace985..085509642e6 100644 --- a/packages/ra-ui-materialui/src/detail/Create.tsx +++ b/packages/ra-ui-materialui/src/detail/Create.tsx @@ -25,7 +25,6 @@ import { CreateView } from './CreateView'; * - actions * - aside * - component - * - successMessage * - title * * @example @@ -88,7 +87,6 @@ Create.propTypes = { title: PropTypes.node, record: PropTypes.object, hasList: PropTypes.bool, - successMessage: PropTypes.string, mutationOptions: PropTypes.object, transform: PropTypes.func, }; diff --git a/packages/ra-ui-materialui/src/detail/CreateView.tsx b/packages/ra-ui-materialui/src/detail/CreateView.tsx index 055ea1c8ad4..662b1077a84 100644 --- a/packages/ra-ui-materialui/src/detail/CreateView.tsx +++ b/packages/ra-ui-materialui/src/detail/CreateView.tsx @@ -26,7 +26,6 @@ export const CreateView = (props: CreateViewProps) => { resource, save, saving, - version, } = useCreateContext(props); return ( @@ -63,7 +62,6 @@ export const CreateView = (props: CreateViewProps) => { ? save : children.props.save, saving, - version, })} {aside && @@ -75,7 +73,6 @@ export const CreateView = (props: CreateViewProps) => { ? save : children.props.save, saving, - version, })} diff --git a/packages/ra-ui-materialui/src/detail/Edit.tsx b/packages/ra-ui-materialui/src/detail/Edit.tsx index 5e85b21a3ad..560f6b988f7 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.tsx @@ -25,7 +25,6 @@ import { EditView } from './EditView'; * - actions * - aside * - component - * - successMessage * - title * - mutationMode * @@ -91,7 +90,6 @@ Edit.propTypes = { queryOptions: PropTypes.object, mutationOptions: PropTypes.object, resource: PropTypes.string, - successMessage: PropTypes.string, title: PropTypes.node, transform: PropTypes.func, }; diff --git a/packages/ra-ui-materialui/src/detail/EditView.tsx b/packages/ra-ui-materialui/src/detail/EditView.tsx index 1bfd881d955..b3bd7c33132 100644 --- a/packages/ra-ui-materialui/src/detail/EditView.tsx +++ b/packages/ra-ui-materialui/src/detail/EditView.tsx @@ -35,7 +35,6 @@ export const EditView = (props: EditViewProps) => { resource, save, saving, - version, } = useEditContext(props); const finalActions = @@ -86,7 +85,6 @@ export const EditView = (props: EditViewProps) => { : children.props.save, saving, mutationMode, - version, }) ) : (   @@ -96,7 +94,6 @@ export const EditView = (props: EditViewProps) => { React.cloneElement(aside, { record, resource, - version, save: typeof children.props.save === 'undefined' ? save @@ -133,7 +130,6 @@ EditView.propTypes = { resource: PropTypes.string, save: PropTypes.func, title: PropTypes.node, - version: PropTypes.number, onSuccess: PropTypes.func, onFailure: PropTypes.func, setOnSuccess: PropTypes.func, @@ -169,7 +165,6 @@ const sanitizeRestProps = ({ setOnFailure = null, setOnSuccess = null, setTransform = null, - successMessage = null, transform = null, transformRef = null, ...rest diff --git a/packages/ra-ui-materialui/src/detail/ShowView.tsx b/packages/ra-ui-materialui/src/detail/ShowView.tsx index 849080f7daf..6e043ef9d3e 100644 --- a/packages/ra-ui-materialui/src/detail/ShowView.tsx +++ b/packages/ra-ui-materialui/src/detail/ShowView.tsx @@ -23,7 +23,7 @@ export const ShowView = (props: ShowViewProps) => { ...rest } = props; - const { defaultTitle, record, version } = useShowContext(props); + const { defaultTitle, record } = useShowContext(props); const { hasEdit } = useResourceDefinition(props); const finalActions = @@ -35,7 +35,6 @@ export const ShowView = (props: ShowViewProps) => { return ( { const { className, ...rest } = props; - useRefreshWhenVisible(); - const loading = useSelector(state => state.admin.loading > 0); + const loading = useLoading(); const theme = useTheme(); return ( diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx index d8918d64c32..ab490204afe 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -135,7 +135,6 @@ ListView.propTypes = { showFilter: PropTypes.func, title: TitlePropType, total: PropTypes.number, - version: PropTypes.number, }; export interface ListViewProps { diff --git a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx index 51af43a1551..e3f6d24ce5c 100644 --- a/packages/ra-ui-materialui/src/list/SingleFieldList.tsx +++ b/packages/ra-ui-materialui/src/list/SingleFieldList.tsx @@ -15,8 +15,6 @@ import { useListContext, useResourceContext, Record, - RecordMap, - Identifier, RecordContextProvider, ComponentPropType, } from 'ra-core'; @@ -133,8 +131,8 @@ export interface SingleFieldListProps children: React.ReactElement; // can be injected when using the component without context basePath?: string; - data?: RecordMap; - ids?: Identifier[]; + data?: RecordType[]; + total?: number; loaded?: boolean; } diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx index d0f3ad8a3be..fef54162f78 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx @@ -309,7 +309,6 @@ Datagrid.propTypes = { selectedIds: PropTypes.arrayOf(PropTypes.any), setSort: PropTypes.func, total: PropTypes.number, - version: PropTypes.number, isRowSelectable: PropTypes.func, isRowExpandable: PropTypes.func, };