fix(ui): various issues around documents lists, listQuery provider and search params (#8081)

This PR fixes and improves:
- ListQuery provider is now the source of truth for searchParams instead
of having components use the `useSearchParams` hook
- Various issues with search params and filters sticking around when
navigating between collections
- Pagination and limits not working inside DocumentDrawer
- Searching and filtering causing a flash of overlay in DocumentDrawer,
this now only shows for the first load and on slow networks
- Preferences are now respected in DocumentDrawer
- Changing the limit now resets your page back to 1 in case the current
page no longer exists

Fixes https://github.com/payloadcms/payload/issues/7085
Fixes https://github.com/payloadcms/payload/pull/8081
Fixes https://github.com/payloadcms/payload/issues/8086
This commit is contained in:
Paul
2024-09-06 15:51:09 -06:00
committed by GitHub
parent 32cc1a5761
commit b27e42c484
10 changed files with 320 additions and 170 deletions

View File

@@ -28,7 +28,6 @@ import {
useListQuery,
useModal,
useRouteCache,
useSearchParams,
useStepNav,
useTranslation,
useWindowInfo,
@@ -54,8 +53,7 @@ export const DefaultListView: React.FC = () => {
newDocumentURL,
} = useListInfo()
const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery()
const { searchParams } = useSearchParams()
const { data, defaultLimit, handlePageChange, handlePerPageChange, params } = useListQuery()
const { openModal } = useModal()
const { clearRouteCache } = useRouteCache()
const { setCollectionSlug, setOnSuccess } = useBulkUpload()
@@ -226,9 +224,7 @@ export const DefaultListView: React.FC = () => {
</div>
<PerPage
handleChange={(limit) => void handlePerPageChange(limit)}
limit={
isNumber(searchParams?.limit) ? Number(searchParams.limit) : defaultLimit
}
limit={isNumber(params?.limit) ? Number(params.limit) : defaultLimit}
limits={collectionConfig?.admin?.pagination?.limits}
resetPage={data.totalDocs <= data.pagingCounter}
/>

View File

@@ -14,7 +14,6 @@ import { ChevronIcon } from '../../icons/Chevron/index.js'
import { SearchIcon } from '../../icons/Search/index.js'
import { useListInfo } from '../../providers/ListInfo/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js'
import { DeleteMany } from '../DeleteMany/index.js'
@@ -48,17 +47,13 @@ export type ListControlsProps = {
export const ListControls: React.FC<ListControlsProps> = (props) => {
const { collectionConfig, enableColumns = true, enableSort = false, fields } = props
const { handleSearchChange } = useListQuery()
const { handleSearchChange, params } = useListQuery()
const { beforeActions, collectionSlug, disableBulkDelete, disableBulkEdit } = useListInfo()
const { searchParams } = useSearchParams()
const titleField = useUseTitleField(collectionConfig, fields)
const { i18n, t } = useTranslation()
const {
breakpoints: { s: smallBreak },
} = useWindowInfo()
const [search, setSearch] = useState(
typeof searchParams?.search === 'string' ? searchParams?.search : '',
)
const searchLabel =
(titleField &&
@@ -81,21 +76,21 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
t('general:searchBy', { label: getTranslation(searchLabel, i18n) }),
)
const hasWhereParam = useRef(Boolean(searchParams?.where))
const hasWhereParam = useRef(Boolean(params?.where))
const shouldInitializeWhereOpened = validateWhereQuery(searchParams?.where)
const shouldInitializeWhereOpened = validateWhereQuery(params?.where)
const [visibleDrawer, setVisibleDrawer] = useState<'columns' | 'sort' | 'where'>(
shouldInitializeWhereOpened ? 'where' : undefined,
)
useEffect(() => {
if (hasWhereParam.current && !searchParams?.where) {
if (hasWhereParam.current && !params?.where) {
setVisibleDrawer(undefined)
hasWhereParam.current = false
} else if (searchParams?.where) {
} else if (params?.where) {
hasWhereParam.current = true
}
}, [setVisibleDrawer, searchParams?.where])
}, [setVisibleDrawer, params?.where])
useEffect(() => {
if (listSearchableFields?.length > 0) {
@@ -134,11 +129,10 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
handleChange={(search) => {
return void handleSearchChange(search)
}}
initialParams={searchParams}
// @ts-expect-error @todo: fix types
initialParams={params}
key={collectionSlug}
label={searchLabelTranslated.current}
setValue={setSearch}
value={search}
/>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>
@@ -216,7 +210,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
collectionPluralLabel={collectionConfig?.labels?.plural}
collectionSlug={collectionConfig.slug}
fields={fields}
key={String(hasWhereParam.current && !searchParams?.where)}
key={String(hasWhereParam.current && !params?.where)}
/>
</AnimateHeight>
{enableSort && (

View File

@@ -3,13 +3,14 @@ import type { ClientCollectionConfig, Where } from 'payload'
import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback, useEffect, useReducer, useState } from 'react'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import type { ListDrawerProps } from './types.js'
import { SelectMany } from '../../elements/SelectMany/index.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { usePayloadAPI } from '../../hooks/usePayloadAPI.js'
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
import { XIcon } from '../../icons/X/index.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
@@ -54,13 +55,25 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}) => {
const { i18n, t } = useTranslation()
const { permissions } = useAuth()
const { setPreference } = usePreferences()
const { getPreference, setPreference } = usePreferences()
const { closeModal, isModalOpen } = useModal()
const [limit, setLimit] = useState<number>()
// Track the page limit so we can reset the page number when it changes
const previousLimit = useRef<number>(limit || null)
const [sort, setSort] = useState<string>(null)
const [page, setPage] = useState<number>(1)
const [where, setWhere] = useState<Where>(null)
const [search, setSearch] = useState<string>('')
const [showLoadingOverlay, setShowLoadingOverlay] = useState<boolean>(true)
const hasInitialised = useRef(false)
const params = {
limit,
page,
search,
sort,
where,
}
const {
config: {
@@ -94,12 +107,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
: undefined,
)
// const [fields, setFields] = useState<Field[]>(() => formatFields(selectedCollectionConfig))
useEffect(() => {
// setFields(formatFields(selectedCollectionConfig))
}, [selectedCollectionConfig])
// allow external control of selected collection, same as the initial state logic above
useEffect(() => {
if (selectedCollection) {
@@ -111,7 +118,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}
}, [selectedCollection, enabledCollectionConfigs, onSelect, t])
const preferenceKey = `${selectedCollectionConfig.slug}-list`
const preferencesKey = `${selectedCollectionConfig.slug}-list`
// this is the 'create new' drawer
const [DocumentDrawer, DocumentDrawerToggler, { drawerSlug: documentDrawerSlug }] =
@@ -147,6 +154,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
admin: { listSearchableFields, useAsTitle } = {},
versions,
} = selectedCollectionConfig
const params: {
cacheBust?: number
depth?: number
@@ -194,6 +202,17 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
if (cacheBust) {
params.cacheBust = cacheBust
}
if (limit) {
params.limit = limit
if (limit !== previousLimit.current) {
previousLimit.current = limit
// Reset page if limit changes
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setPage(1)
}
}
if (copyOfWhere) {
params.where = copyOfWhere
}
@@ -202,7 +221,18 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}
setParams(params)
}, [page, sort, where, search, cacheBust, filterOptions, selectedCollectionConfig, t, setParams])
}, [
page,
sort,
where,
search,
limit,
cacheBust,
filterOptions,
selectedCollectionConfig,
t,
setParams,
])
useEffect(() => {
const newPreferences = {
@@ -210,8 +240,49 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
sort,
}
void setPreference(preferenceKey, newPreferences, true)
}, [sort, limit, setPreference, preferenceKey])
if (limit || sort) {
void setPreference(preferencesKey, newPreferences, true)
}
}, [sort, limit, setPreference, preferencesKey])
// Get existing preferences if they exist
useEffect(() => {
if (preferencesKey && !limit) {
const getInitialPref = async () => {
const existingPreferences = await getPreference<{ limit?: number }>(preferencesKey)
if (existingPreferences?.limit) {
setLimit(existingPreferences?.limit)
}
}
void getInitialPref()
}
}, [getPreference, limit, preferencesKey])
useThrottledEffect(
() => {
if (isLoadingList) {
setShowLoadingOverlay(true)
}
},
1750,
[isLoadingList, setShowLoadingOverlay],
)
useEffect(() => {
if (isOpen) {
hasInitialised.current = true
} else {
hasInitialised.current = false
}
}, [isOpen])
useEffect(() => {
if (!isLoadingList && showLoadingOverlay) {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setShowLoadingOverlay(false)
}
}, [isLoadingList, showLoadingOverlay])
const onCreateNew = useCallback(
({ doc }) => {
@@ -232,14 +303,14 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
return null
}
if (isLoadingList) {
return <LoadingOverlay />
}
return (
<>
{showLoadingOverlay && <LoadingOverlay />}
<ListInfoProvider
beforeActions={
enableRowSelections ? [<SelectMany key="select-many" onClick={onBulkSelect} />] : undefined
enableRowSelections
? [<SelectMany key="select-many" onClick={onBulkSelect} />]
: undefined
}
collectionConfig={selectedCollectionConfig}
collectionSlug={selectedCollectionConfig.slug}
@@ -309,7 +380,9 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
handleSortChange={setSort}
handleWhereChange={setWhere}
modifySearchParams={false}
preferenceKey={preferenceKey}
// @ts-expect-error todo: fix types
params={params}
preferenceKey={preferencesKey}
>
<TableColumnsProvider
cellProps={[
@@ -328,12 +401,13 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
]}
collectionSlug={selectedCollectionConfig.slug}
enableRowSelections={enableRowSelections}
preferenceKey={preferenceKey}
preferenceKey={preferencesKey}
>
<RenderComponent mappedComponent={List} />
<DocumentDrawer onSave={onCreateNew} />
</TableColumnsProvider>
</ListQueryProvider>
</ListInfoProvider>
</>
)
}

View File

@@ -1,5 +1,5 @@
'use client'
import React, { useEffect, useRef } from 'react'
import React, { useEffect, useRef, useState } from 'react'
export type SearchFilterProps = {
fieldName?: string
@@ -12,32 +12,53 @@ export type SearchFilterProps = {
import type { ParsedQs } from 'qs-esm'
import { usePathname } from 'next/navigation.js'
import { useDebounce } from '../../hooks/useDebounce.js'
import './index.scss'
const baseClass = 'search-filter'
export const SearchFilter: React.FC<SearchFilterProps> = (props) => {
const { handleChange, initialParams, label, setValue, value } = props
const previousSearch = useRef(
typeof initialParams?.search === 'string' ? initialParams?.search : '',
const { handleChange, initialParams, label } = props
const pathname = usePathname()
const [search, setSearch] = useState(
typeof initialParams?.search === 'string' ? initialParams?.search : undefined,
)
const debouncedSearch = useDebounce(value, 300)
/**
* Tracks whether the state should be updated based on the search value.
* If the value is updated from the URL, we don't want to update the state as it causes additional renders.
*/
const shouldUpdateState = useRef(true)
/**
* Tracks the previous search value to compare with the current debounced search value.
*/
const previousSearch = useRef(
typeof initialParams?.search === 'string' ? initialParams?.search : undefined,
)
const debouncedSearch = useDebounce(search, 300)
useEffect(() => {
if (debouncedSearch !== previousSearch.current) {
if (initialParams?.search !== previousSearch.current) {
shouldUpdateState.current = false
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setSearch(initialParams?.search as string)
previousSearch.current = initialParams?.search as string
}
}, [initialParams?.search, pathname])
useEffect(() => {
if (debouncedSearch !== previousSearch.current && shouldUpdateState.current) {
if (handleChange) {
handleChange(debouncedSearch)
}
previousSearch.current = debouncedSearch
}
}, [debouncedSearch, previousSearch, handleChange])
// Cleans up the search input when the component is unmounted
useEffect(() => () => setValue(''), [])
}, [debouncedSearch, handleChange])
return (
<div className={baseClass}>
@@ -45,10 +66,13 @@ export const SearchFilter: React.FC<SearchFilterProps> = (props) => {
aria-label={label}
className={`${baseClass}__input`}
id="search-filter-input"
onChange={(e) => setValue(e.target.value)}
onChange={(e) => {
shouldUpdateState.current = true
setSearch(e.target.value)
}}
placeholder={label}
type="text"
value={value || ''}
value={search || ''}
/>
</div>
)

View File

@@ -5,7 +5,6 @@ import React from 'react'
import { ChevronIcon } from '../../icons/Chevron/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
@@ -20,11 +19,10 @@ const baseClass = 'sort-column'
export const SortColumn: React.FC<SortColumnProps> = (props) => {
const { name, disable = false, Label, label } = props
const { searchParams } = useSearchParams()
const { handleSortChange } = useListQuery()
const { handleSortChange, params } = useListQuery()
const { t } = useTranslation()
const { sort } = searchParams
const { sort } = params
const desc = `-${name}`
const asc = name

View File

@@ -7,7 +7,6 @@ import React, { useEffect, useState } from 'react'
import type { WhereBuilderProps } from './types.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Button } from '../Button/index.js'
import { Condition } from './Condition/index.js'
@@ -31,11 +30,11 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
const [reducedFields, setReducedColumns] = useState(() => reduceClientFields({ fields, i18n }))
useEffect(() => {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setReducedColumns(reduceClientFields({ fields, i18n }))
}, [fields, i18n])
const { searchParams } = useSearchParams()
const { handleWhereChange } = useListQuery()
const { handleWhereChange, params } = useListQuery()
const [shouldUpdateQuery, setShouldUpdateQuery] = React.useState(false)
// This handles initializing the where conditions from the search query (URL). That way, if you pass in
@@ -67,7 +66,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
*/
const [conditions, setConditions] = React.useState(() => {
const whereFromSearch = searchParams.where
const whereFromSearch = params.where
if (whereFromSearch) {
if (validateWhereQuery(whereFromSearch)) {
return whereFromSearch.or

View File

@@ -9,6 +9,13 @@ type useThrottledEffect = (
deps: React.DependencyList,
) => void
/**
* A hook that will throttle the execution of a callback function inside a useEffect.
* This is useful for things like throttling loading states or other UI updates.
* @param callback The callback function to be executed.
* @param delay The delay in milliseconds to throttle the callback.
* @param deps The dependencies to watch for changes.
*/
export const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => {
const lastRan = useRef(Date.now())

View File

@@ -4,7 +4,7 @@ import type { PaginatedDocs, Where } from 'payload'
import { useRouter } from 'next/navigation.js'
import { isNumber } from 'payload/shared'
import * as qs from 'qs-esm'
import React, { createContext, useContext } from 'react'
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'
import type { Column } from '../../elements/Table/index.js'
@@ -27,6 +27,7 @@ type ContextHandlers = {
handleSearchChange?: (search: string) => Promise<void>
handleSortChange?: (sort: string) => Promise<void>
handleWhereChange?: (where: Where) => Promise<void>
params: RefineOverrides
}
export type ListQueryProps = {
@@ -35,6 +36,11 @@ export type ListQueryProps = {
readonly defaultLimit?: number
readonly defaultSort?: string
readonly modifySearchParams?: boolean
/**
* Used to manage the query params manually. If you pass this prop, the provider will not manage the query params from the searchParams.
* Useful for modals or other components that need to manage the query params themselves.
*/
readonly params?: RefineOverrides
readonly preferenceKey?: string
} & PropHandlers
@@ -68,14 +74,15 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSortChange: handleSortChangeFromProps,
handleWhereChange: handleWhereChangeFromProps,
modifySearchParams,
params: paramsFromProps,
preferenceKey,
}) => {
const router = useRouter()
const { setPreference } = usePreferences()
const hasSetInitialParams = React.useRef(false)
const { searchParams: currentQuery } = useSearchParams()
const [params, setParams] = useState(paramsFromProps || currentQuery)
const refineListData = React.useCallback(
const refineListData = useCallback(
async (query: RefineOverrides) => {
if (!modifySearchParams) {
return
@@ -114,10 +121,20 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
router.replace(`${qs.stringify(params, { addQueryPrefix: true })}`)
},
[preferenceKey, modifySearchParams, router, setPreference, currentQuery],
[
modifySearchParams,
currentQuery?.page,
currentQuery?.limit,
currentQuery?.search,
currentQuery?.sort,
currentQuery?.where,
preferenceKey,
router,
setPreference,
],
)
const handlePageChange = React.useCallback(
const handlePageChange = useCallback(
async (arg: number) => {
if (typeof handlePageChangeFromProps === 'function') {
await handlePageChangeFromProps(arg)
@@ -134,23 +151,25 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
await handlePerPageChangeFromProps(arg)
}
await refineListData({ limit: String(arg) })
await refineListData({ limit: String(arg), page: '1' })
},
[refineListData, handlePerPageChangeFromProps],
)
const handleSearchChange = React.useCallback(
const handleSearchChange = useCallback(
async (arg: string) => {
const search = arg === '' ? undefined : arg
if (typeof handleSearchChangeFromProps === 'function') {
await handleSearchChangeFromProps(arg)
await handleSearchChangeFromProps(search)
}
await refineListData({ search: arg })
await refineListData({ search })
},
[refineListData, handleSearchChangeFromProps],
[handleSearchChangeFromProps, refineListData],
)
const handleSortChange = React.useCallback(
const handleSortChange = useCallback(
async (arg: string) => {
if (typeof handleSortChangeFromProps === 'function') {
await handleSortChangeFromProps(arg)
@@ -161,7 +180,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
[refineListData, handleSortChangeFromProps],
)
const handleWhereChange = React.useCallback(
const handleWhereChange = useCallback(
async (arg: Where) => {
if (typeof handleWhereChangeFromProps === 'function') {
await handleWhereChangeFromProps(arg)
@@ -172,8 +191,11 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
[refineListData, handleWhereChangeFromProps],
)
React.useEffect(() => {
if (!hasSetInitialParams.current) {
useEffect(() => {
if (paramsFromProps) {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setParams(paramsFromProps)
} else {
if (modifySearchParams) {
let shouldUpdateQueryString = false
@@ -187,14 +209,15 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
shouldUpdateQueryString = true
}
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setParams(currentQuery)
if (shouldUpdateQueryString) {
router.replace(`?${qs.stringify(currentQuery)}`)
}
}
hasSetInitialParams.current = true
}
}, [defaultSort, defaultLimit, router, modifySearchParams, currentQuery])
}, [defaultSort, defaultLimit, router, modifySearchParams, currentQuery, paramsFromProps, params])
return (
<Context.Provider
@@ -205,6 +228,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSearchChange,
handleSortChange,
handleWhereChange,
params,
refineListData,
}}
>

View File

@@ -0,0 +1,31 @@
'use client'
import type { PayloadClientReactComponent, SanitizedConfig } from 'payload'
import { NavGroup, useConfig } from '@payloadcms/ui'
import LinkImport from 'next/link.js'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
import React from 'react'
const baseClass = 'after-nav-links'
export const AfterNavLinks: PayloadClientReactComponent<
SanitizedConfig['admin']['components']['afterNavLinks'][0]
> = () => {
const {
config: {
routes: { admin: adminRoute },
},
} = useConfig()
return (
<NavGroup key="extra-links" label="Extra Links">
{/* Open link to payload admin url */}
{/* <Link href={`${adminRoute}/collections/uploads`}>Internal Payload Admin Link</Link> */}
{/* Open link to payload admin url with prefiltered query */}
<Link href={`${adminRoute}/collections/uploads?page=1&search=jpg&limit=10`}>
Prefiltered Media
</Link>
</NavGroup>
)
}

View File

@@ -105,6 +105,9 @@ export default buildConfigWithDefaults({
importMap: {
baseDir: path.resolve(dirname),
},
components: {
afterNavLinks: ['/components/AfterNavLinks.js#AfterNavLinks'],
},
custom: {
client: {
'new-value': 'client available',