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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
31
test/fields/components/AfterNavLinks.tsx
Normal file
31
test/fields/components/AfterNavLinks.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user