diff --git a/packages/ra-core/src/controller/useListController.spec.tsx b/packages/ra-core/src/controller/useListController.spec.tsx index fa0c6c7f230..ea60619364d 100644 --- a/packages/ra-core/src/controller/useListController.spec.tsx +++ b/packages/ra-core/src/controller/useListController.spec.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import expect from 'expect'; -import { fireEvent, waitFor } from '@testing-library/react'; +import { fireEvent, waitFor, act } from '@testing-library/react'; import lolex from 'lolex'; import TextField from '@material-ui/core/TextField/TextField'; +import { DataProviderContext } from '../dataProvider'; import ListController from './ListController'; import { getListControllerProps, @@ -41,6 +42,73 @@ describe('useListController', () => { debounce: 200, }; + describe('data', () => { + it('should be synchronized with ids after delete', async () => { + const FooField = ({ record }) => {record.foo}; + const dataProvider = { + getList: () => + Promise.resolve({ + data: [ + { id: 1, foo: 'foo1' }, + { id: 2, foo: 'foo2' }, + ], + total: 2, + }), + }; + const { dispatch, queryByText } = renderWithRedux( + + + {({ data, ids }) => ( + <> + {ids.map(id => ( + + ))} + + )} + + , + { + admin: { + resources: { + comments: { + list: { + params: {}, + cachedRequests: {}, + ids: [], + selectedIds: [], + total: null, + }, + data: {}, + }, + }, + }, + } + ); + await act(async () => await new Promise(r => setTimeout(r))); + + // delete one post + act(() => { + dispatch({ + type: 'RA/CRUD_DELETE_OPTIMISTIC', + payload: { id: 1 }, + meta: { + resource: 'comments', + fetch: 'DELETE', + optimistic: true, + }, + }); + }); + await act(async () => await new Promise(r => setTimeout(r))); + + expect(queryByText('foo1')).toBeNull(); + expect(queryByText('foo2')).not.toBeNull(); + }); + }); + describe('setFilters', () => { let clock; let fakeComponent = ({ setFilters, filterValues }) => ( @@ -153,7 +221,6 @@ describe('useListController', () => { const props = { ...defaultProps, debounce: 200, - crudGetList: jest.fn(), children, }; @@ -171,13 +238,14 @@ describe('useListController', () => { params: {}, cachedRequests: {}, ids: [], + selectedIds: [], + total: null, }, }, }, }, } ); - const crudGetListCalls = dispatch.mock.calls.filter( call => call[0].type === 'RA/CRUD_GET_LIST' ); diff --git a/packages/ra-core/src/controller/useListController.ts b/packages/ra-core/src/controller/useListController.ts index 9eff724477e..c2bab23e8d0 100644 --- a/packages/ra-core/src/controller/useListController.ts +++ b/packages/ra-core/src/controller/useListController.ts @@ -1,15 +1,13 @@ import { isValidElement, ReactElement, useEffect, useMemo } from 'react'; import inflection from 'inflection'; import { Location } from 'history'; -import { useSelector } from 'react-redux'; -import get from 'lodash/get'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; import useListParams from './useListParams'; import useRecordSelection from './useRecordSelection'; import useTranslate from '../i18n/useTranslate'; import useNotify from '../sideEffect/useNotify'; -import useGetList from '../dataProvider/useGetList'; +import { useGetMainList } from '../dataProvider/useGetMainList'; import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; import { CRUD_GET_LIST } from '../actions'; import defaultExporter from '../export/defaultExporter'; @@ -18,7 +16,6 @@ import { SortPayload, RecordMap, Identifier, - ReduxState, Record, Exporter, } from '../types'; @@ -53,8 +50,6 @@ const defaultSort = { order: SORT_ASC, }; -const defaultData = {}; - export interface ListControllerProps { basePath: string; currentSort: SortPayload; @@ -153,7 +148,9 @@ const useListController = ( * We want the list of ids to be always available for optimistic rendering, * and therefore we need a custom action (CRUD_GET_LIST) that will be used. */ - const { ids, total, error, loading, loaded } = useGetList( + const { ids, data, total, error, loading, loaded } = useGetMainList< + RecordType + >( resource, { page: query.page, @@ -181,41 +178,12 @@ const useListController = ( } ); - const data = useSelector( - (state: ReduxState): RecordMap => - get( - state.admin.resources, - [resource, 'data'], - defaultData - ) as RecordMap - ); - - // When the user changes the page/sort/filter, this controller runs the - // useGetList hook again. While the result of this new call is loading, - // the ids and total are empty. To avoid rendering an empty list at that - // moment, we override the ids and total with the latest loaded ones. - const defaultIds = useSelector((state: ReduxState): Identifier[] => - get(state.admin.resources, [resource, 'list', 'ids'], []) - ); - const defaultTotal = useSelector((state: ReduxState): number => - get(state.admin.resources, [resource, 'list', 'total']) - ); - - // Since the total can be empty during the loading phase - // We need to override that total with the latest loaded one - // This way, the useEffect bellow won't reset the page to 1 - const finalTotal = typeof total === 'undefined' ? defaultTotal : total; - - const finalIds = typeof total === 'undefined' ? defaultIds : ids; - - const totalPages = useMemo(() => { - return Math.ceil(finalTotal / query.perPage) || 1; - }, [query.perPage, finalTotal]); + const totalPages = Math.ceil(total / query.perPage) || 1; useEffect(() => { if ( query.page <= 0 || - (!loading && query.page > 1 && (finalIds || []).length === 0) + (!loading && query.page > 1 && ids.length === 0) ) { // Query for a page that doesn't exist, set page to 1 queryModifiers.setPage(1); @@ -224,15 +192,7 @@ const useListController = ( // It occurs when deleting the last element of the last page queryModifiers.setPage(totalPages); } - }, [ - loading, - query.page, - finalIds, - queryModifiers, - total, - totalPages, - defaultIds, - ]); + }, [loading, query.page, ids, queryModifiers, total, totalPages]); const currentSort = useMemo( () => ({ @@ -262,8 +222,8 @@ const useListController = ( filterValues: query.filterValues, hasCreate, hideFilter: queryModifiers.hideFilter, - ids: finalIds, - loaded: loaded || defaultIds.length > 0, + ids, + loaded: loaded || ids.length > 0, loading, onSelect: selectionModifiers.select, onToggleItem: selectionModifiers.toggle, @@ -277,7 +237,7 @@ const useListController = ( setPerPage: queryModifiers.setPerPage, setSort: queryModifiers.setSort, showFilter: queryModifiers.showFilter, - total: finalTotal, + total: total, }; }; diff --git a/packages/ra-core/src/dataProvider/index.ts b/packages/ra-core/src/dataProvider/index.ts index e55780331d3..697e61c8f61 100644 --- a/packages/ra-core/src/dataProvider/index.ts +++ b/packages/ra-core/src/dataProvider/index.ts @@ -13,6 +13,7 @@ import useQueryWithStore, { QueryOptions } from './useQueryWithStore'; import withDataProvider from './withDataProvider'; import useGetOne, { UseGetOneHookValue } from './useGetOne'; import useGetList from './useGetList'; +import { useGetMainList } from './useGetMainList'; import useGetMany from './useGetMany'; import useGetManyReference from './useGetManyReference'; import useGetMatching from './useGetMatching'; @@ -45,6 +46,7 @@ export { useQuery, useGetOne, useGetList, + useGetMainList, useGetMany, useGetManyReference, useGetMatching, diff --git a/packages/ra-core/src/dataProvider/useGetMainList.tsx b/packages/ra-core/src/dataProvider/useGetMainList.tsx new file mode 100644 index 00000000000..251094c8d15 --- /dev/null +++ b/packages/ra-core/src/dataProvider/useGetMainList.tsx @@ -0,0 +1,183 @@ +import { useMemo, useRef } from 'react'; +import get from 'lodash/get'; + +import { + PaginationPayload, + SortPayload, + ReduxState, + Identifier, + Record, + RecordMap, +} from '../types'; +import useQueryWithStore from './useQueryWithStore'; + +const defaultIds = []; +const defaultData = {}; + +/** + * Call the dataProvider.getList() method and return the resolved result + * as well as the loading state. + * + * Uses a special cache to avoid showing an empty list while re-fetching the + * list after changing params. + * + * The return value updates according to the request state: + * + * - start: { loading: true, loaded: false } + * - success: { data: [data from store], ids: [ids from response], total: [total from response], loading: false, loaded: true } + * - error: { error: [error from response], loading: false, loaded: true } + * + * This hook will return the cached result when called a second time + * with the same parameters, until the response arrives. + * + * @param {string} resource The resource name, e.g. 'posts' + * @param {Object} pagination The request pagination { page, perPage }, e.g. { page: 1, perPage: 10 } + * @param {Object} sort The request sort { field, order }, e.g. { field: 'id', order: 'DESC' } + * @param {Object} filter The request filters, e.g. { title: 'hello, world' } + * @param {Object} options Options object to pass to the dataProvider. May include side effects to be executed upon success or failure, e.g. { onSuccess: { refresh: true } } + * + * @returns The current request state. Destructure as { data, total, ids, error, loading, loaded }. + * + * @example + * + * import { useGetMainList } from 'react-admin'; + * + * const LatestNews = () => { + * const { data, ids, loading, error } = useGetMainList( + * 'posts', + * { page: 1, perPage: 10 }, + * { field: 'published_at', order: 'DESC' } + * ); + * if (loading) { return ; } + * if (error) { return

ERROR

; } + * return
    {ids.map(id => + *
  • {data[id].title}
  • + * )}
; + * }; + */ +export const useGetMainList = ( + resource: string, + pagination: PaginationPayload, + sort: SortPayload, + filter: object, + options?: any +): { + data?: RecordMap; + ids?: Identifier[]; + total?: number; + error?: any; + loading: boolean; + loaded: boolean; +} => { + const requestSignature = JSON.stringify({ pagination, sort, filter }); + const memo = useRef>({}); + const { + data: { finalIds, finalTotal, allRecords }, + error, + loading, + loaded, + } = useQueryWithStore( + { type: 'getList', resource, payload: { pagination, sort, filter } }, + options, + // ids and data selector + (state: ReduxState): DataSelectorResult => { + const ids = get(state.admin.resources, [ + resource, + 'list', + 'cachedRequests', + requestSignature, + 'ids', + ]); // default value undefined + const total = get(state.admin.resources, [ + resource, + 'list', + 'cachedRequests', + requestSignature, + 'total', + ]); // default value undefined + + // When the user changes the page/sort/filter, the list of ids from + // the cached requests is empty. To avoid rendering an empty list + // at that moment, we override the ids and total with the latest + // loaded ones. + const mainIds = get(state.admin.resources, [ + resource, + 'list', + 'ids', + ]); // default value [] (see list.ids reducer) + + // Since the total can be empty during the loading phase + // We need to override that total with the latest loaded one + const mainTotal = get(state.admin.resources, [ + resource, + 'list', + 'total', + ]); // default value null (see list.total reducer) + + // Is [] for a page that was never loaded + const finalIds = typeof ids === 'undefined' ? mainIds : ids; + // Is null for a page that was never loaded. + const finalTotal = typeof total === 'undefined' ? mainTotal : total; + + const allRecords = get( + state.admin.resources, + [resource, 'data'], + defaultData + ); + // poor man's useMemo inside a hook using a ref + if ( + memo.current.finalIds !== finalIds || + memo.current.finalTotal !== finalTotal || + memo.current.allRecords !== allRecords + ) { + const result = { + finalIds, + finalTotal, + allRecords, + }; + memo.current = { finalIds, finalTotal, allRecords, result }; + } + return memo.current.result; + }, + () => null, + isDataLoaded + ); + + const data = useMemo( + () => + typeof finalIds === 'undefined' + ? defaultData + : finalIds + .map(id => allRecords[id]) + .reduce((acc, record) => { + if (!record) return acc; + acc[record.id] = record; + return acc; + }, {}), + [finalIds, allRecords] + ); + + return { + data, + ids: typeof finalIds === 'undefined' ? defaultIds : finalIds, + total: finalTotal, + error, + loading, + loaded, + }; +}; + +interface DataSelectorResult { + finalIds?: Identifier[]; + finalTotal: number; + allRecords: RecordMap; +} + +interface Memo { + finalIds?: Identifier[]; + finalTotal?: number; + allRecords?: RecordMap; + result?: DataSelectorResult; +} + +const isDataLoaded = (data: DataSelectorResult) => data.finalTotal != null; // null or undefined diff --git a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts index fb550b19d8c..01b99efcb66 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/cachedRequests.ts @@ -35,6 +35,19 @@ const cachedRequestsReducer: Reducer = ( // force refresh return initialState; } + 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; diff --git a/packages/ra-core/src/reducer/admin/resource/list/ids.ts b/packages/ra-core/src/reducer/admin/resource/list/ids.ts index 86f5bea5431..83927cda3fe 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/ids.ts +++ b/packages/ra-core/src/reducer/admin/resource/list/ids.ts @@ -22,6 +22,8 @@ type ActionTypes = meta: any; }; +const initialState = []; + /** * List of the ids of the latest loaded page, regardless of params * @@ -35,7 +37,7 @@ type ActionTypes = * */ const idsReducer: Reducer = ( - previousState = [], + previousState = initialState, action: ActionTypes ) => { if (action.meta && action.meta.optimistic) {