diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx index cc9c1c0b9dc..7d2d1f030d0 100644 --- a/packages/ra-core/src/controller/list/useListParams.spec.tsx +++ b/packages/ra-core/src/controller/list/useListParams.spec.tsx @@ -9,6 +9,7 @@ import { useStore } from '../../store/useStore'; import { useListParams, getQuery, getNumberOrDefault } from './useListParams'; import { SORT_DESC, SORT_ASC } from './queryReducer'; import { TestMemoryRouter } from '../../routing'; +import { memoryStore } from '../../store'; describe('useListParams', () => { describe('getQuery', () => { @@ -495,6 +496,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 +606,171 @@ describe('useListParams', () => { expect(storeValue).toBeUndefined(); }); + it('should not synchronize location with store if the location already contains parameters', 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: 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 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 eca5484aed5..1ae50586515 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -1,6 +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 { useNavigate, useLocation } from 'react-router-dom'; import { useStore } from '../../store'; @@ -133,6 +134,62 @@ export const useListParams = ({ } }, [location.search]); // eslint-disable-line + const currentStoreKey = useRef(storeKey); + // if the location includes params (for example from a link like + // the categories products on the demo), we need to persist them in the + // store as well so that we don't lose them after a redirection back + // to the list + 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, + }; + + 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 different from the location params + isEqual(query, queryFromLocation) || + // or the stored params are not different from the default params (to keep the URL simple when possible) + isEqual(query, defaultParams) + ) { + return; + } + navigate({ + search: `?${stringify({ + ...query, + filter: JSON.stringify(query.filter), + displayedFilters: JSON.stringify(query.displayedFilters), + })}`, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + navigate, + disableSyncWithLocation, + filterDefaultValues, + perPage, + sort, + query, + location.search, + params, + ] + ); + const changeParams = useCallback( action => { // do not change params if the component is already unmounted