diff --git a/packages/next/src/views/List/Default/index.tsx b/packages/next/src/views/List/Default/index.tsx index 03ed2bf32..5b81d2c57 100644 --- a/packages/next/src/views/List/Default/index.tsx +++ b/packages/next/src/views/List/Default/index.tsx @@ -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 = () => { 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} /> diff --git a/packages/ui/src/elements/ListControls/index.tsx b/packages/ui/src/elements/ListControls/index.tsx index 892f75f91..d22b66c8e 100644 --- a/packages/ui/src/elements/ListControls/index.tsx +++ b/packages/ui/src/elements/ListControls/index.tsx @@ -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 = (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 = (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 = (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} />
@@ -216,7 +210,7 @@ export const ListControls: React.FC = (props) => { collectionPluralLabel={collectionConfig?.labels?.plural} collectionSlug={collectionConfig.slug} fields={fields} - key={String(hasWhereParam.current && !searchParams?.where)} + key={String(hasWhereParam.current && !params?.where)} /> {enableSort && ( diff --git a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx index 1389fd154..7714b9a58 100644 --- a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx @@ -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 = ({ }) => { const { i18n, t } = useTranslation() const { permissions } = useAuth() - const { setPreference } = usePreferences() + const { getPreference, setPreference } = usePreferences() const { closeModal, isModalOpen } = useModal() const [limit, setLimit] = useState() + // Track the page limit so we can reset the page number when it changes + const previousLimit = useRef(limit || null) const [sort, setSort] = useState(null) const [page, setPage] = useState(1) const [where, setWhere] = useState(null) const [search, setSearch] = useState('') + const [showLoadingOverlay, setShowLoadingOverlay] = useState(true) + const hasInitialised = useRef(false) + + const params = { + limit, + page, + search, + sort, + where, + } const { config: { @@ -94,12 +107,6 @@ export const ListDrawerContent: React.FC = ({ : undefined, ) - // const [fields, setFields] = useState(() => 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 = ({ } }, [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 = ({ admin: { listSearchableFields, useAsTitle } = {}, versions, } = selectedCollectionConfig + const params: { cacheBust?: number depth?: number @@ -194,6 +202,17 @@ export const ListDrawerContent: React.FC = ({ 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 = ({ } 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 = ({ 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,108 +303,111 @@ export const ListDrawerContent: React.FC = ({ return null } - if (isLoadingList) { - return - } - return ( - ] : undefined - } - collectionConfig={selectedCollectionConfig} - collectionSlug={selectedCollectionConfig.slug} - disableBulkDelete - disableBulkEdit - hasCreatePermission={hasCreatePermission} - Header={ -
-
-
-

- {!customHeader - ? getTranslation(selectedCollectionConfig?.labels?.plural, i18n) - : customHeader} -

- {hasCreatePermission && ( - - {t('general:createNew')} - - )} + <> + {showLoadingOverlay && } + ] + : undefined + } + collectionConfig={selectedCollectionConfig} + collectionSlug={selectedCollectionConfig.slug} + disableBulkDelete + disableBulkEdit + hasCreatePermission={hasCreatePermission} + Header={ +
+
+
+

+ {!customHeader + ? getTranslation(selectedCollectionConfig?.labels?.plural, i18n) + : customHeader} +

+ {hasCreatePermission && ( + + {t('general:createNew')} + + )} +
+
- -
- {(selectedCollectionConfig?.admin?.description || - selectedCollectionConfig?.admin?.components?.Description) && ( -
- -
- )} - {moreThanOneAvailableCollection && ( -
- - ({ - label: getTranslation(coll.labels.singular, i18n), - value: coll.slug, - }))} - value={selectedOption} - /> -
- )} -
- } - newDocumentURL={null} - > - + +
+ )} + {moreThanOneAvailableCollection && ( +
+ + ({ + label: getTranslation(coll.labels.singular, i18n), + value: coll.slug, + }))} + value={selectedOption} + /> +
+ )} + + } + newDocumentURL={null} > - { - if (typeof onSelect === 'function') { - onSelect({ - collectionSlug: rowColl, - docID: rowData.id as string, - }) - } - }, - }, - ]} - collectionSlug={selectedCollectionConfig.slug} - enableRowSelections={enableRowSelections} - preferenceKey={preferenceKey} + - - - - - + { + if (typeof onSelect === 'function') { + onSelect({ + collectionSlug: rowColl, + docID: rowData.id as string, + }) + } + }, + }, + ]} + collectionSlug={selectedCollectionConfig.slug} + enableRowSelections={enableRowSelections} + preferenceKey={preferencesKey} + > + + + + + + ) } diff --git a/packages/ui/src/elements/SearchFilter/index.tsx b/packages/ui/src/elements/SearchFilter/index.tsx index 01a9535cd..26735f4c5 100644 --- a/packages/ui/src/elements/SearchFilter/index.tsx +++ b/packages/ui/src/elements/SearchFilter/index.tsx @@ -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 = (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 (
@@ -45,10 +66,13 @@ export const SearchFilter: React.FC = (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 || ''} />
) diff --git a/packages/ui/src/elements/SortColumn/index.tsx b/packages/ui/src/elements/SortColumn/index.tsx index 3194a7e72..3702d52b8 100644 --- a/packages/ui/src/elements/SortColumn/index.tsx +++ b/packages/ui/src/elements/SortColumn/index.tsx @@ -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 = (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 diff --git a/packages/ui/src/elements/WhereBuilder/index.tsx b/packages/ui/src/elements/WhereBuilder/index.tsx index 275d9858f..6735c6f29 100644 --- a/packages/ui/src/elements/WhereBuilder/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/index.tsx @@ -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 = (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 = (props) => { */ const [conditions, setConditions] = React.useState(() => { - const whereFromSearch = searchParams.where + const whereFromSearch = params.where if (whereFromSearch) { if (validateWhereQuery(whereFromSearch)) { return whereFromSearch.or diff --git a/packages/ui/src/hooks/useThrottledEffect.ts b/packages/ui/src/hooks/useThrottledEffect.ts index a0a68474d..283a0986a 100644 --- a/packages/ui/src/hooks/useThrottledEffect.ts +++ b/packages/ui/src/hooks/useThrottledEffect.ts @@ -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()) diff --git a/packages/ui/src/providers/ListQuery/index.tsx b/packages/ui/src/providers/ListQuery/index.tsx index 438b07067..224f96dbb 100644 --- a/packages/ui/src/providers/ListQuery/index.tsx +++ b/packages/ui/src/providers/ListQuery/index.tsx @@ -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 handleSortChange?: (sort: string) => Promise handleWhereChange?: (where: Where) => Promise + 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 = ({ 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 = ({ 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 = ({ 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 = ({ [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 = ({ [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 = ({ 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 ( = ({ handleSearchChange, handleSortChange, handleWhereChange, + params, refineListData, }} > diff --git a/test/fields/components/AfterNavLinks.tsx b/test/fields/components/AfterNavLinks.tsx new file mode 100644 index 000000000..c3ea7a618 --- /dev/null +++ b/test/fields/components/AfterNavLinks.tsx @@ -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 ( + + {/* Open link to payload admin url */} + {/* Internal Payload Admin Link */} + {/* Open link to payload admin url with prefiltered query */} + + Prefiltered Media + + + ) +} diff --git a/test/fields/config.ts b/test/fields/config.ts index 12b8507b8..18fe77c88 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -105,6 +105,9 @@ export default buildConfigWithDefaults({ importMap: { baseDir: path.resolve(dirname), }, + components: { + afterNavLinks: ['/components/AfterNavLinks.js#AfterNavLinks'], + }, custom: { client: { 'new-value': 'client available',