diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx index cc9c1c0b9dc..7ff63b87435 100644 --- a/packages/ra-core/src/controller/list/useListParams.spec.tsx +++ b/packages/ra-core/src/controller/list/useListParams.spec.tsx @@ -6,9 +6,15 @@ import { CoreAdminContext } from '../../core'; import { testDataProvider } from '../../dataProvider'; import { useStore } from '../../store/useStore'; -import { useListParams, getQuery, getNumberOrDefault } from './useListParams'; +import { + useListParams, + getQuery, + getNumberOrDefault, + ListParamsOptions, +} from './useListParams'; import { SORT_DESC, SORT_ASC } from './queryReducer'; import { TestMemoryRouter } from '../../routing'; +import { memoryStore } from '../../store'; describe('useListParams', () => { describe('getQuery', () => { @@ -360,11 +366,16 @@ describe('useListParams', () => { }); }); describe('useListParams', () => { - const Component = ({ disableSyncWithLocation = false }) => { - const [{ page }, { setPage }] = useListParams({ - resource: 'posts', - disableSyncWithLocation, - }); + const Component = ({ + disableSyncWithLocation = false, + ...options + }: Partial) => { + const [{ page, perPage, sort, order, filter }, { setPage }] = + useListParams({ + resource: 'posts', + disableSyncWithLocation, + ...options, + }); const handleClick = () => { setPage(10); @@ -373,6 +384,10 @@ describe('useListParams', () => { return ( <>

page: {page}

+

perPage: {perPage}

+

sort: {sort}

+

order: {order}

+

filter: {JSON.stringify(filter)}

); @@ -495,6 +510,71 @@ describe('useListParams', () => { }); }); + it('should synchronize location with store when sync is enabled', async () => { + let location; + let storeValue; + const StoreReader = () => { + const [value] = useStore('posts.listParams'); + React.useEffect(() => { + storeValue = value; + }, [value]); + return null; + }; + render( + { + location = l; + }} + > + + + + + + ); + + await waitFor(() => { + expect(storeValue).toEqual({ + sort: 'id', + order: 'ASC', + page: 10, + perPage: 10, + filter: {}, + }); + }); + + await waitFor(() => { + expect(location).toEqual( + expect.objectContaining({ + hash: '', + key: expect.any(String), + state: null, + pathname: '/', + search: + '?' + + stringify({ + filter: JSON.stringify({}), + sort: 'id', + order: 'ASC', + page: 10, + perPage: 10, + }), + }) + ); + }); + }); + it('should not synchronize parameters with location and store when sync is not enabled', async () => { let location; let storeValue; @@ -540,6 +620,191 @@ describe('useListParams', () => { expect(storeValue).toBeUndefined(); }); + it('should not synchronize location with store if the location already contains parameters', async () => { + let location; + render( + { + location = l; + }} + > + + + + + ); + + await waitFor(() => { + expect(location).toEqual( + expect.objectContaining({ + hash: '', + key: expect.any(String), + state: null, + pathname: '/', + search: + '?' + + stringify({ + filter: JSON.stringify({}), + sort: 'id', + order: 'ASC', + page: 5, + perPage: 10, + }), + }) + ); + }); + }); + + it('should not synchronize location with store if the store parameters are the defaults', async () => { + let location; + render( + { + location = l; + }} + > + + + + + ); + + // Let React do its thing + await new Promise(resolve => setTimeout(resolve, 0)); + + await waitFor(() => { + expect(location).toEqual( + expect.objectContaining({ + hash: '', + key: expect.any(String), + state: null, + pathname: '/', + search: '', + }) + ); + }); + }); + + it('should not synchronize location with store if the store parameters are the custom defaults provided to the hook', async () => { + let location; + render( + { + location = l; + }} + > + + + + + ); + + // Let React do its thing + await new Promise(resolve => setTimeout(resolve, 0)); + + // The list is using the default set on the component + await screen.findByText('perPage: 5'); + await screen.findByText('sort: title'); + await screen.findByText('order: DESC'); + + // The location is the default for the list (no query parameters) + await waitFor(() => { + expect(location).toEqual( + expect.objectContaining({ + hash: '', + key: expect.any(String), + state: null, + pathname: '/', + search: '', + }) + ); + }); + }); + + it('should not synchronize location with store when sync is not enabled', async () => { + let location; + let storeValue; + const StoreReader = () => { + const [value] = useStore('posts.listParams'); + React.useEffect(() => { + storeValue = value; + }, [value]); + return null; + }; + render( + { + location = l; + }} + > + + + + + + ); + + await waitFor(() => { + expect(storeValue).toEqual({ + sort: 'id', + order: 'ASC', + page: 10, + perPage: 10, + filter: {}, + }); + }); + + await waitFor(() => { + expect(location).toEqual( + expect.objectContaining({ + hash: '', + key: expect.any(String), + state: null, + pathname: '/', + search: '', + }) + ); + }); + }); + it('should synchronize parameters with store when sync is not enabled and storeKey is passed', async () => { let storeValue; const Component = ({ diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index d94f9ec06be..3f92cbbd7d8 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useEffect, useState, useRef } from 'react'; import { parse, stringify } from 'query-string'; import lodashDebounce from 'lodash/debounce.js'; - +import isEqual from 'lodash/isEqual.js'; import { useStore } from '../../store'; import { useNavigate, useLocation } from '../../routing'; import queryReducer, { @@ -107,9 +107,10 @@ export const useListParams = ({ disableSyncWithLocation, ]; - const queryFromLocation = disableSyncWithLocation - ? {} - : parseQueryFromLocation(location); + const queryFromLocation = useMemo( + () => (disableSyncWithLocation ? {} : parseQueryFromLocation(location)), + [location, disableSyncWithLocation] + ); const query = useMemo( () => @@ -133,6 +134,66 @@ export const useListParams = ({ } }, [location.search]); // eslint-disable-line + const currentStoreKey = useRef(storeKey); + useEffect(() => { + // If the storeKey has changed, ignore the first effect call. This avoids conflicts between lists sharing + // the same resource but different storeKeys. + if (currentStoreKey.current !== storeKey) { + // storeKey has changed + currentStoreKey.current = storeKey; + return; + } + if (disableSyncWithLocation) { + return; + } + const defaultParams = { + filter: filterDefaultValues || {}, + page: 1, + perPage, + sort: sort.field, + order: sort.order, + }; + + const { + displayedFilters: _displayedFilters, + ...queryWithoutDisplayedFilters + } = query; + + if ( + // The location params are not empty (we don't want to override them if provided) + Object.keys(queryFromLocation).length > 0 || + // or the stored params are the same as the location params + isEqual(queryWithoutDisplayedFilters, queryFromLocation) || + // or the stored params are the same as the default params (to keep the URL simple when possible) + isEqual(queryWithoutDisplayedFilters, defaultParams) + ) { + return; + } + navigate( + { + search: `?${stringify({ + ...query, + filter: JSON.stringify(query.filter), + displayedFilters: JSON.stringify(query.displayedFilters), + })}`, + }, + { + replace: true, + } + ); + }, [ + navigate, + disableSyncWithLocation, + filterDefaultValues, + perPage, + sort, + query, + queryFromLocation, + location.search, + params, + storeKey, + ]); + const changeParams = useCallback( action => { // do not change params if the component is already unmounted @@ -277,7 +338,7 @@ const parseObject = (query, field) => { if (query[field] && typeof query[field] === 'string') { try { query[field] = JSON.parse(query[field]); - } catch (err) { + } catch { delete query[field]; } }