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, useListQuery,
useModal, useModal,
useRouteCache, useRouteCache,
useSearchParams,
useStepNav, useStepNav,
useTranslation, useTranslation,
useWindowInfo, useWindowInfo,
@@ -54,8 +53,7 @@ export const DefaultListView: React.FC = () => {
newDocumentURL, newDocumentURL,
} = useListInfo() } = useListInfo()
const { data, defaultLimit, handlePageChange, handlePerPageChange } = useListQuery() const { data, defaultLimit, handlePageChange, handlePerPageChange, params } = useListQuery()
const { searchParams } = useSearchParams()
const { openModal } = useModal() const { openModal } = useModal()
const { clearRouteCache } = useRouteCache() const { clearRouteCache } = useRouteCache()
const { setCollectionSlug, setOnSuccess } = useBulkUpload() const { setCollectionSlug, setOnSuccess } = useBulkUpload()
@@ -226,9 +224,7 @@ export const DefaultListView: React.FC = () => {
</div> </div>
<PerPage <PerPage
handleChange={(limit) => void handlePerPageChange(limit)} handleChange={(limit) => void handlePerPageChange(limit)}
limit={ limit={isNumber(params?.limit) ? Number(params.limit) : defaultLimit}
isNumber(searchParams?.limit) ? Number(searchParams.limit) : defaultLimit
}
limits={collectionConfig?.admin?.pagination?.limits} limits={collectionConfig?.admin?.pagination?.limits}
resetPage={data.totalDocs <= data.pagingCounter} 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 { SearchIcon } from '../../icons/Search/index.js'
import { useListInfo } from '../../providers/ListInfo/index.js' import { useListInfo } from '../../providers/ListInfo/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js' import { useListQuery } from '../../providers/ListQuery/index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js' import { useTranslation } from '../../providers/Translation/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js' import { ColumnSelector } from '../ColumnSelector/index.js'
import { DeleteMany } from '../DeleteMany/index.js' import { DeleteMany } from '../DeleteMany/index.js'
@@ -48,17 +47,13 @@ export type ListControlsProps = {
export const ListControls: React.FC<ListControlsProps> = (props) => { export const ListControls: React.FC<ListControlsProps> = (props) => {
const { collectionConfig, enableColumns = true, enableSort = false, fields } = props const { collectionConfig, enableColumns = true, enableSort = false, fields } = props
const { handleSearchChange } = useListQuery() const { handleSearchChange, params } = useListQuery()
const { beforeActions, collectionSlug, disableBulkDelete, disableBulkEdit } = useListInfo() const { beforeActions, collectionSlug, disableBulkDelete, disableBulkEdit } = useListInfo()
const { searchParams } = useSearchParams()
const titleField = useUseTitleField(collectionConfig, fields) const titleField = useUseTitleField(collectionConfig, fields)
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { const {
breakpoints: { s: smallBreak }, breakpoints: { s: smallBreak },
} = useWindowInfo() } = useWindowInfo()
const [search, setSearch] = useState(
typeof searchParams?.search === 'string' ? searchParams?.search : '',
)
const searchLabel = const searchLabel =
(titleField && (titleField &&
@@ -81,21 +76,21 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
t('general:searchBy', { label: getTranslation(searchLabel, i18n) }), 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'>( const [visibleDrawer, setVisibleDrawer] = useState<'columns' | 'sort' | 'where'>(
shouldInitializeWhereOpened ? 'where' : undefined, shouldInitializeWhereOpened ? 'where' : undefined,
) )
useEffect(() => { useEffect(() => {
if (hasWhereParam.current && !searchParams?.where) { if (hasWhereParam.current && !params?.where) {
setVisibleDrawer(undefined) setVisibleDrawer(undefined)
hasWhereParam.current = false hasWhereParam.current = false
} else if (searchParams?.where) { } else if (params?.where) {
hasWhereParam.current = true hasWhereParam.current = true
} }
}, [setVisibleDrawer, searchParams?.where]) }, [setVisibleDrawer, params?.where])
useEffect(() => { useEffect(() => {
if (listSearchableFields?.length > 0) { if (listSearchableFields?.length > 0) {
@@ -134,11 +129,10 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
handleChange={(search) => { handleChange={(search) => {
return void handleSearchChange(search) return void handleSearchChange(search)
}} }}
initialParams={searchParams} // @ts-expect-error @todo: fix types
initialParams={params}
key={collectionSlug} key={collectionSlug}
label={searchLabelTranslated.current} label={searchLabelTranslated.current}
setValue={setSearch}
value={search}
/> />
<div className={`${baseClass}__buttons`}> <div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}> <div className={`${baseClass}__buttons-wrap`}>
@@ -216,7 +210,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
collectionPluralLabel={collectionConfig?.labels?.plural} collectionPluralLabel={collectionConfig?.labels?.plural}
collectionSlug={collectionConfig.slug} collectionSlug={collectionConfig.slug}
fields={fields} fields={fields}
key={String(hasWhereParam.current && !searchParams?.where)} key={String(hasWhereParam.current && !params?.where)}
/> />
</AnimateHeight> </AnimateHeight>
{enableSort && ( {enableSort && (

View File

@@ -3,13 +3,14 @@ import type { ClientCollectionConfig, Where } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' 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 type { ListDrawerProps } from './types.js'
import { SelectMany } from '../../elements/SelectMany/index.js' import { SelectMany } from '../../elements/SelectMany/index.js'
import { FieldLabel } from '../../fields/FieldLabel/index.js' import { FieldLabel } from '../../fields/FieldLabel/index.js'
import { usePayloadAPI } from '../../hooks/usePayloadAPI.js' import { usePayloadAPI } from '../../hooks/usePayloadAPI.js'
import { useThrottledEffect } from '../../hooks/useThrottledEffect.js'
import { XIcon } from '../../icons/X/index.js' import { XIcon } from '../../icons/X/index.js'
import { useAuth } from '../../providers/Auth/index.js' import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js' import { useConfig } from '../../providers/Config/index.js'
@@ -54,13 +55,25 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
}) => { }) => {
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { permissions } = useAuth() const { permissions } = useAuth()
const { setPreference } = usePreferences() const { getPreference, setPreference } = usePreferences()
const { closeModal, isModalOpen } = useModal() const { closeModal, isModalOpen } = useModal()
const [limit, setLimit] = useState<number>() 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 [sort, setSort] = useState<string>(null)
const [page, setPage] = useState<number>(1) const [page, setPage] = useState<number>(1)
const [where, setWhere] = useState<Where>(null) const [where, setWhere] = useState<Where>(null)
const [search, setSearch] = useState<string>('') const [search, setSearch] = useState<string>('')
const [showLoadingOverlay, setShowLoadingOverlay] = useState<boolean>(true)
const hasInitialised = useRef(false)
const params = {
limit,
page,
search,
sort,
where,
}
const { const {
config: { config: {
@@ -94,12 +107,6 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
: undefined, : 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 // allow external control of selected collection, same as the initial state logic above
useEffect(() => { useEffect(() => {
if (selectedCollection) { if (selectedCollection) {
@@ -111,7 +118,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
} }
}, [selectedCollection, enabledCollectionConfigs, onSelect, t]) }, [selectedCollection, enabledCollectionConfigs, onSelect, t])
const preferenceKey = `${selectedCollectionConfig.slug}-list` const preferencesKey = `${selectedCollectionConfig.slug}-list`
// this is the 'create new' drawer // this is the 'create new' drawer
const [DocumentDrawer, DocumentDrawerToggler, { drawerSlug: documentDrawerSlug }] = const [DocumentDrawer, DocumentDrawerToggler, { drawerSlug: documentDrawerSlug }] =
@@ -147,6 +154,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
admin: { listSearchableFields, useAsTitle } = {}, admin: { listSearchableFields, useAsTitle } = {},
versions, versions,
} = selectedCollectionConfig } = selectedCollectionConfig
const params: { const params: {
cacheBust?: number cacheBust?: number
depth?: number depth?: number
@@ -194,6 +202,17 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
if (cacheBust) { if (cacheBust) {
params.cacheBust = 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) { if (copyOfWhere) {
params.where = copyOfWhere params.where = copyOfWhere
} }
@@ -202,7 +221,18 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
} }
setParams(params) setParams(params)
}, [page, sort, where, search, cacheBust, filterOptions, selectedCollectionConfig, t, setParams]) }, [
page,
sort,
where,
search,
limit,
cacheBust,
filterOptions,
selectedCollectionConfig,
t,
setParams,
])
useEffect(() => { useEffect(() => {
const newPreferences = { const newPreferences = {
@@ -210,8 +240,49 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
sort, sort,
} }
void setPreference(preferenceKey, newPreferences, true) if (limit || sort) {
}, [sort, limit, setPreference, preferenceKey]) 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( const onCreateNew = useCallback(
({ doc }) => { ({ doc }) => {
@@ -232,108 +303,111 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
return null return null
} }
if (isLoadingList) {
return <LoadingOverlay />
}
return ( return (
<ListInfoProvider <>
beforeActions={ {showLoadingOverlay && <LoadingOverlay />}
enableRowSelections ? [<SelectMany key="select-many" onClick={onBulkSelect} />] : undefined <ListInfoProvider
} beforeActions={
collectionConfig={selectedCollectionConfig} enableRowSelections
collectionSlug={selectedCollectionConfig.slug} ? [<SelectMany key="select-many" onClick={onBulkSelect} />]
disableBulkDelete : undefined
disableBulkEdit }
hasCreatePermission={hasCreatePermission} collectionConfig={selectedCollectionConfig}
Header={ collectionSlug={selectedCollectionConfig.slug}
<header className={`${baseClass}__header`}> disableBulkDelete
<div className={`${baseClass}__header-wrap`}> disableBulkEdit
<div className={`${baseClass}__header-content`}> hasCreatePermission={hasCreatePermission}
<h2 className={`${baseClass}__header-text`}> Header={
{!customHeader <header className={`${baseClass}__header`}>
? getTranslation(selectedCollectionConfig?.labels?.plural, i18n) <div className={`${baseClass}__header-wrap`}>
: customHeader} <div className={`${baseClass}__header-content`}>
</h2> <h2 className={`${baseClass}__header-text`}>
{hasCreatePermission && ( {!customHeader
<DocumentDrawerToggler className={`${baseClass}__create-new-button`}> ? getTranslation(selectedCollectionConfig?.labels?.plural, i18n)
<Pill>{t('general:createNew')}</Pill> : customHeader}
</DocumentDrawerToggler> </h2>
)} {hasCreatePermission && (
<DocumentDrawerToggler className={`${baseClass}__create-new-button`}>
<Pill>{t('general:createNew')}</Pill>
</DocumentDrawerToggler>
)}
</div>
<button
aria-label={t('general:close')}
className={`${baseClass}__header-close`}
onClick={() => {
closeModal(drawerSlug)
}}
type="button"
>
<XIcon />
</button>
</div> </div>
<button {(selectedCollectionConfig?.admin?.description ||
aria-label={t('general:close')} selectedCollectionConfig?.admin?.components?.Description) && (
className={`${baseClass}__header-close`} <div className={`${baseClass}__sub-header`}>
onClick={() => { <ViewDescription
closeModal(drawerSlug) Description={selectedCollectionConfig.admin?.components?.Description}
}} description={selectedCollectionConfig.admin?.description}
type="button" />
> </div>
<XIcon /> )}
</button> {moreThanOneAvailableCollection && (
</div> <div className={`${baseClass}__select-collection-wrap`}>
{(selectedCollectionConfig?.admin?.description || <FieldLabel field={null} label={t('upload:selectCollectionToBrowse')} />
selectedCollectionConfig?.admin?.components?.Description) && ( <ReactSelect
<div className={`${baseClass}__sub-header`}> className={`${baseClass}__select-collection`}
<ViewDescription onChange={setSelectedOption} // this is only changing the options which is not rerunning my effect
Description={selectedCollectionConfig.admin?.components?.Description} options={enabledCollectionConfigs.map((coll) => ({
description={selectedCollectionConfig.admin?.description} label: getTranslation(coll.labels.singular, i18n),
/> value: coll.slug,
</div> }))}
)} value={selectedOption}
{moreThanOneAvailableCollection && ( />
<div className={`${baseClass}__select-collection-wrap`}> </div>
<FieldLabel field={null} label={t('upload:selectCollectionToBrowse')} /> )}
<ReactSelect </header>
className={`${baseClass}__select-collection`} }
onChange={setSelectedOption} // this is only changing the options which is not rerunning my effect newDocumentURL={null}
options={enabledCollectionConfigs.map((coll) => ({
label: getTranslation(coll.labels.singular, i18n),
value: coll.slug,
}))}
value={selectedOption}
/>
</div>
)}
</header>
}
newDocumentURL={null}
>
<ListQueryProvider
data={data}
defaultLimit={limit || selectedCollectionConfig?.admin?.pagination?.defaultLimit}
defaultSort={sort}
handlePageChange={setPage}
handlePerPageChange={setLimit}
handleSearchChange={setSearch}
handleSortChange={setSort}
handleWhereChange={setWhere}
modifySearchParams={false}
preferenceKey={preferenceKey}
> >
<TableColumnsProvider <ListQueryProvider
cellProps={[ data={data}
{ defaultLimit={limit || selectedCollectionConfig?.admin?.pagination?.defaultLimit}
className: `${baseClass}__first-cell`, defaultSort={sort}
link: false, handlePageChange={setPage}
onClick: ({ collectionSlug: rowColl, rowData }) => { handlePerPageChange={setLimit}
if (typeof onSelect === 'function') { handleSearchChange={setSearch}
onSelect({ handleSortChange={setSort}
collectionSlug: rowColl, handleWhereChange={setWhere}
docID: rowData.id as string, modifySearchParams={false}
}) // @ts-expect-error todo: fix types
} params={params}
}, preferenceKey={preferencesKey}
},
]}
collectionSlug={selectedCollectionConfig.slug}
enableRowSelections={enableRowSelections}
preferenceKey={preferenceKey}
> >
<RenderComponent mappedComponent={List} /> <TableColumnsProvider
<DocumentDrawer onSave={onCreateNew} /> cellProps={[
</TableColumnsProvider> {
</ListQueryProvider> className: `${baseClass}__first-cell`,
</ListInfoProvider> link: false,
onClick: ({ collectionSlug: rowColl, rowData }) => {
if (typeof onSelect === 'function') {
onSelect({
collectionSlug: rowColl,
docID: rowData.id as string,
})
}
},
},
]}
collectionSlug={selectedCollectionConfig.slug}
enableRowSelections={enableRowSelections}
preferenceKey={preferencesKey}
>
<RenderComponent mappedComponent={List} />
<DocumentDrawer onSave={onCreateNew} />
</TableColumnsProvider>
</ListQueryProvider>
</ListInfoProvider>
</>
) )
} }

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef, useState } from 'react'
export type SearchFilterProps = { export type SearchFilterProps = {
fieldName?: string fieldName?: string
@@ -12,32 +12,53 @@ export type SearchFilterProps = {
import type { ParsedQs } from 'qs-esm' import type { ParsedQs } from 'qs-esm'
import { usePathname } from 'next/navigation.js'
import { useDebounce } from '../../hooks/useDebounce.js' import { useDebounce } from '../../hooks/useDebounce.js'
import './index.scss' import './index.scss'
const baseClass = 'search-filter' const baseClass = 'search-filter'
export const SearchFilter: React.FC<SearchFilterProps> = (props) => { export const SearchFilter: React.FC<SearchFilterProps> = (props) => {
const { handleChange, initialParams, label, setValue, value } = props const { handleChange, initialParams, label } = props
const pathname = usePathname()
const previousSearch = useRef( const [search, setSearch] = useState(
typeof initialParams?.search === 'string' ? initialParams?.search : '', 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(() => { 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) { if (handleChange) {
handleChange(debouncedSearch) handleChange(debouncedSearch)
} }
previousSearch.current = debouncedSearch previousSearch.current = debouncedSearch
} }
}, [debouncedSearch, previousSearch, handleChange]) }, [debouncedSearch, handleChange])
// Cleans up the search input when the component is unmounted
useEffect(() => () => setValue(''), [])
return ( return (
<div className={baseClass}> <div className={baseClass}>
@@ -45,10 +66,13 @@ export const SearchFilter: React.FC<SearchFilterProps> = (props) => {
aria-label={label} aria-label={label}
className={`${baseClass}__input`} className={`${baseClass}__input`}
id="search-filter-input" id="search-filter-input"
onChange={(e) => setValue(e.target.value)} onChange={(e) => {
shouldUpdateState.current = true
setSearch(e.target.value)
}}
placeholder={label} placeholder={label}
type="text" type="text"
value={value || ''} value={search || ''}
/> />
</div> </div>
) )

View File

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

View File

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

View File

@@ -9,6 +9,13 @@ type useThrottledEffect = (
deps: React.DependencyList, deps: React.DependencyList,
) => void ) => 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 = []) => { export const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => {
const lastRan = useRef(Date.now()) const lastRan = useRef(Date.now())

View File

@@ -4,7 +4,7 @@ import type { PaginatedDocs, Where } from 'payload'
import { useRouter } from 'next/navigation.js' import { useRouter } from 'next/navigation.js'
import { isNumber } from 'payload/shared' import { isNumber } from 'payload/shared'
import * as qs from 'qs-esm' 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' import type { Column } from '../../elements/Table/index.js'
@@ -27,6 +27,7 @@ type ContextHandlers = {
handleSearchChange?: (search: string) => Promise<void> handleSearchChange?: (search: string) => Promise<void>
handleSortChange?: (sort: string) => Promise<void> handleSortChange?: (sort: string) => Promise<void>
handleWhereChange?: (where: Where) => Promise<void> handleWhereChange?: (where: Where) => Promise<void>
params: RefineOverrides
} }
export type ListQueryProps = { export type ListQueryProps = {
@@ -35,6 +36,11 @@ export type ListQueryProps = {
readonly defaultLimit?: number readonly defaultLimit?: number
readonly defaultSort?: string readonly defaultSort?: string
readonly modifySearchParams?: boolean 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 readonly preferenceKey?: string
} & PropHandlers } & PropHandlers
@@ -68,14 +74,15 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSortChange: handleSortChangeFromProps, handleSortChange: handleSortChangeFromProps,
handleWhereChange: handleWhereChangeFromProps, handleWhereChange: handleWhereChangeFromProps,
modifySearchParams, modifySearchParams,
params: paramsFromProps,
preferenceKey, preferenceKey,
}) => { }) => {
const router = useRouter() const router = useRouter()
const { setPreference } = usePreferences() const { setPreference } = usePreferences()
const hasSetInitialParams = React.useRef(false)
const { searchParams: currentQuery } = useSearchParams() const { searchParams: currentQuery } = useSearchParams()
const [params, setParams] = useState(paramsFromProps || currentQuery)
const refineListData = React.useCallback( const refineListData = useCallback(
async (query: RefineOverrides) => { async (query: RefineOverrides) => {
if (!modifySearchParams) { if (!modifySearchParams) {
return return
@@ -114,10 +121,20 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
router.replace(`${qs.stringify(params, { addQueryPrefix: true })}`) 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) => { async (arg: number) => {
if (typeof handlePageChangeFromProps === 'function') { if (typeof handlePageChangeFromProps === 'function') {
await handlePageChangeFromProps(arg) await handlePageChangeFromProps(arg)
@@ -134,23 +151,25 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
await handlePerPageChangeFromProps(arg) await handlePerPageChangeFromProps(arg)
} }
await refineListData({ limit: String(arg) }) await refineListData({ limit: String(arg), page: '1' })
}, },
[refineListData, handlePerPageChangeFromProps], [refineListData, handlePerPageChangeFromProps],
) )
const handleSearchChange = React.useCallback( const handleSearchChange = useCallback(
async (arg: string) => { async (arg: string) => {
const search = arg === '' ? undefined : arg
if (typeof handleSearchChangeFromProps === 'function') { 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) => { async (arg: string) => {
if (typeof handleSortChangeFromProps === 'function') { if (typeof handleSortChangeFromProps === 'function') {
await handleSortChangeFromProps(arg) await handleSortChangeFromProps(arg)
@@ -161,7 +180,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
[refineListData, handleSortChangeFromProps], [refineListData, handleSortChangeFromProps],
) )
const handleWhereChange = React.useCallback( const handleWhereChange = useCallback(
async (arg: Where) => { async (arg: Where) => {
if (typeof handleWhereChangeFromProps === 'function') { if (typeof handleWhereChangeFromProps === 'function') {
await handleWhereChangeFromProps(arg) await handleWhereChangeFromProps(arg)
@@ -172,8 +191,11 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
[refineListData, handleWhereChangeFromProps], [refineListData, handleWhereChangeFromProps],
) )
React.useEffect(() => { useEffect(() => {
if (!hasSetInitialParams.current) { if (paramsFromProps) {
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setParams(paramsFromProps)
} else {
if (modifySearchParams) { if (modifySearchParams) {
let shouldUpdateQueryString = false let shouldUpdateQueryString = false
@@ -187,14 +209,15 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
shouldUpdateQueryString = true shouldUpdateQueryString = true
} }
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
setParams(currentQuery)
if (shouldUpdateQueryString) { if (shouldUpdateQueryString) {
router.replace(`?${qs.stringify(currentQuery)}`) router.replace(`?${qs.stringify(currentQuery)}`)
} }
} }
hasSetInitialParams.current = true
} }
}, [defaultSort, defaultLimit, router, modifySearchParams, currentQuery]) }, [defaultSort, defaultLimit, router, modifySearchParams, currentQuery, paramsFromProps, params])
return ( return (
<Context.Provider <Context.Provider
@@ -205,6 +228,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSearchChange, handleSearchChange,
handleSortChange, handleSortChange,
handleWhereChange, handleWhereChange,
params,
refineListData, 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: { importMap: {
baseDir: path.resolve(dirname), baseDir: path.resolve(dirname),
}, },
components: {
afterNavLinks: ['/components/AfterNavLinks.js#AfterNavLinks'],
},
custom: { custom: {
client: { client: {
'new-value': 'client available', 'new-value': 'client available',