feat: search filter in list view

This commit is contained in:
Jarrod Flesch
2024-03-19 16:22:10 -04:00
committed by GitHub
parent a3dbe482e2
commit cb180cbbf8
13 changed files with 322 additions and 57 deletions

View File

@@ -71,13 +71,16 @@ export const initPage = async ({
translations,
})
const queryString = `${qs.stringify(searchParams, { addQueryPrefix: true })}`
const req = await createLocalReq(
{
fallbackLocale: null,
locale: locale.code,
req: {
i18n,
url: `${payload.config.serverURL}${route}${searchParams ? `${qs.stringify(searchParams, { addQueryPrefix: true })}` : ''}`,
query: qs.parse(queryString, { ignoreQueryPrefix: true }),
url: `${payload.config.serverURL}${route}${searchParams ? queryString : ''}`,
} as PayloadRequest,
user,
},

View File

@@ -45,9 +45,6 @@ export const DefaultListView: React.FC = () => {
data,
handlePageChange,
handlePerPageChange,
handleSearchChange,
handleSortChange,
handleWhereChange,
hasCreatePermission,
limit,
modifySearchParams,
@@ -130,12 +127,8 @@ export const DefaultListView: React.FC = () => {
</header>
<ListControls
collectionConfig={collectionConfig}
// textFieldsToBeSearched={textFieldsToBeSearched}
// handleSearchChange={handleSearchChange}
// handleSortChange={handleSortChange}
// handleWhereChange={handleWhereChange}
fieldMap={fieldMap}
// modifySearchQuery={modifySearchParams}
modifySearchQuery={modifySearchParams}
titleField={titleField}
/>
{BeforeListTable}

View File

@@ -3,6 +3,7 @@ import type { SanitizedCollectionConfig } from 'payload/types'
export type DefaultListViewProps = {
collectionSlug: SanitizedCollectionConfig['slug']
listSearchableFields: SanitizedCollectionConfig['admin']['listSearchableFields']
}
export type ListIndexProps = {
@@ -12,5 +13,5 @@ export type ListIndexProps = {
export type ListPreferences = {
columns: ColumnPreferences
limit: number
sort: number
sort: string
}

View File

@@ -1,4 +1,4 @@
import type { AdminViewProps } from 'payload/types'
import type { Where } from 'payload/types'
import {
HydrateClientUser,
@@ -7,7 +7,9 @@ import {
TableColumnsProvider,
} from '@payloadcms/ui'
import { notFound } from 'next/navigation.js'
import { isEntityHidden } from 'payload/utilities'
import { createClientCollectionConfig } from 'packages/payload/src/collections/config/client.js'
import { type AdminViewProps } from 'payload/types'
import { isEntityHidden, isNumber, mergeListSearchAndWhere } from 'payload/utilities'
import React, { Fragment } from 'react'
import type { DefaultListViewProps, ListPreferences } from './Default/types.js'
@@ -25,6 +27,7 @@ export const ListView: React.FC<AdminViewProps> = async ({ initPageResult, searc
locale,
payload,
payload: { config },
query,
user,
},
} = initPageResult
@@ -43,6 +46,7 @@ export const ListView: React.FC<AdminViewProps> = async ({ initPageResult, searc
collection: 'payload-preferences',
depth: 0,
limit: 1,
user,
where: {
key: {
equals: `${collectionSlug}-list`,
@@ -73,31 +77,53 @@ export const ListView: React.FC<AdminViewProps> = async ({ initPageResult, searc
CustomListView = CustomList.Component
}
const limit = Number(searchParams?.limit) || collectionConfig.admin.pagination.defaultLimit
const page = isNumber(query?.page) ? query.page : 0
const whereQuery = mergeListSearchAndWhere({
collectionConfig,
query: {
search: typeof query?.search === 'string' ? query.search : undefined,
where: (query?.where as Where) || undefined,
},
})
const limit = isNumber(query?.limit)
? query.limit
: listPreferences?.limit || collectionConfig.admin.pagination.defaultLimit
const sort =
query?.sort && typeof query.sort === 'string'
? query.sort
: listPreferences?.sort || undefined
const data = await payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: null,
limit,
locale,
overrideAccess: false,
page,
sort,
user,
where: whereQuery || {},
})
const viewComponentProps: DefaultListViewProps = {
collectionSlug,
listSearchableFields: collectionConfig.admin.listSearchableFields,
}
return (
<Fragment>
<HydrateClientUser permissions={permissions} user={user} />
<ListInfoProvider
collectionConfig={createClientCollectionConfig(collectionConfig)}
collectionSlug={collectionSlug}
data={data}
hasCreatePermission={permissions?.collections?.[collectionSlug]?.create?.permission}
limit={limit}
listSearchableFields={collectionConfig.admin.listSearchableFields}
newDocumentURL={`${admin}/collections/${collectionSlug}/create`}
page={page}
>
<TableColumnsProvider
collectionSlug={collectionSlug}

View File

@@ -37,8 +37,9 @@ export { isPlainObject } from '../utilities/isPlainObject.js'
export { isValidID } from '../utilities/isValidID.js'
export { default as isolateObjectProperty } from '../utilities/isolateObjectProperty.js'
export { mapAsync } from '../utilities/mapAsync.js'
export { mergeListSearchAndWhere } from '../utilities/mergeListSearchAndWhere.js'
export { setsAreEqual } from '../utilities/setsAreEqual.js'
export { default as toKebabCase } from '../utilities/toKebabCase.js'
export { default as wait } from '../utilities/wait.js'

View File

@@ -0,0 +1,74 @@
import QueryString from 'qs'
import type { SanitizedCollectionConfig } from '../collections/config/types.js'
import type { FieldAffectingData } from '../fields/config/types.js'
import type { Where } from '../types/index.js'
import { fieldAffectsData } from '../fields/config/types.js'
import { default as flattenTopLevelFields } from './flattenTopLevelFields.js'
const hoistQueryParamsToAnd = (where: Where, queryParams: Where) => {
if ('and' in where) {
where.and.push(queryParams)
} else if ('or' in where) {
where = {
and: [where, queryParams],
}
} else {
where = {
and: [where, queryParams],
}
}
return where
}
const getTitleField = (collection: SanitizedCollectionConfig): FieldAffectingData => {
const {
admin: { useAsTitle },
fields,
} = collection
const topLevelFields = flattenTopLevelFields(fields)
return topLevelFields.find(
(field) => fieldAffectsData(field) && field.name === useAsTitle,
) as FieldAffectingData
}
type Args = {
collectionConfig: SanitizedCollectionConfig
query: {
search?: string
where?: Where
}
}
export const mergeListSearchAndWhere = ({ collectionConfig, query }: Args): Where => {
const search = query?.search || undefined
let where = QueryString.parse(typeof query?.where === 'string' ? query.where : '') as Where
if (search) {
let copyOfWhere = { ...(where || {}) }
const searchAsConditions = (
collectionConfig.admin.listSearchableFields || [getTitleField(collectionConfig)?.name || 'id']
).map((fieldName) => {
return {
[fieldName]: {
like: search,
},
}
}, [])
if (searchAsConditions.length > 0) {
const conditionalSearchFields = {
or: [...searchAsConditions],
}
copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, conditionalSearchFields)
}
where = copyOfWhere
}
return where
}

View File

@@ -11,6 +11,7 @@ const AnimateHeight = (AnimateHeightImport.default ||
import type { Props } from './types.js'
import { Chevron } from '../../icons/Chevron/index.js'
import { useListInfo } from '../../index.js'
import { useSearchParams } from '../../providers/SearchParams/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import ColumnSelector from '../ColumnSelector/index.js'
@@ -37,15 +38,13 @@ export const ListControls: React.FC<Props> = (props) => {
enableColumns = true,
enableSort = false,
fieldMap,
handleSearchChange,
handleSortChange,
handleWhereChange,
modifySearchQuery = true,
textFieldsToBeSearched,
titleField,
} = props
const { useWindowInfo } = facelessUIImport
const { handleSearchChange, handleWhereChange } = useListInfo()
const { searchParams } = useSearchParams()
const shouldInitializeWhereOpened = validateWhereQuery(searchParams?.where)
@@ -68,7 +67,6 @@ export const ListControls: React.FC<Props> = (props) => {
fieldName={titleField && fieldAffectsData(titleField) ? titleField.name : undefined}
handleChange={handleSearchChange}
listSearchableFields={textFieldsToBeSearched}
modifySearchQuery={modifySearchQuery}
/>
<div className={`${baseClass}__buttons`}>
<div className={`${baseClass}__buttons-wrap`}>

View File

@@ -247,6 +247,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
)}
</div>
<button
aria-label={t('general:close')}
className={`${baseClass}__header-close`}
onClick={() => {
closeModal(drawerSlug)
@@ -277,6 +278,7 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
)}
</header>
}
collectionConfig={selectedCollectionConfig}
collectionSlug={selectedCollectionConfig.slug}
data={data}
handlePageChange={setPage}

View File

@@ -1,7 +1,6 @@
import { getTranslation } from '@payloadcms/translations'
// TODO: abstract the `next/navigation` dependency out from this component
import { usePathname, useRouter } from 'next/navigation.js'
import queryString from 'qs'
import React, { useEffect, useRef, useState } from 'react'
import type { Props } from './types.js'
@@ -15,13 +14,7 @@ import './index.scss'
const baseClass = 'search-filter'
const SearchFilter: React.FC<Props> = (props) => {
const {
fieldLabel = 'ID',
fieldName = 'id',
handleChange,
listSearchableFields,
modifySearchQuery = true,
} = props
const { fieldLabel = 'ID', fieldName = 'id', handleChange, listSearchableFields } = props
const { searchParams } = useSearchParams()
const router = useRouter()
@@ -40,17 +33,6 @@ const SearchFilter: React.FC<Props> = (props) => {
useEffect(() => {
if (debouncedSearch !== previousSearch) {
if (handleChange) handleChange(debouncedSearch)
if (modifySearchQuery) {
const search = queryString.stringify({
...searchParams,
page: 1,
search: debouncedSearch || undefined,
})
router.replace(`${pathname}?${search}`)
}
setPreviousSearch(debouncedSearch)
}
}, [
@@ -60,24 +42,26 @@ const SearchFilter: React.FC<Props> = (props) => {
fieldName,
searchParams,
handleChange,
modifySearchQuery,
listSearchableFields,
pathname,
])
useEffect(() => {
if (listSearchableFields?.length > 0) {
placeholder.current = listSearchableFields.reduce<string>((prev, curr, i) => {
if (i === 0) {
return `${t('general:searchBy', {
label: getTranslation(curr.label || curr.name, i18n),
})}`
}
if (i === listSearchableFields.length - 1) {
return `${prev} ${t('general:or')} ${getTranslation(curr.label || curr.name, i18n)}`
}
return `${prev}, ${getTranslation(curr.label || curr.name, i18n)}`
}, '')
placeholder.current = listSearchableFields.reduce(
(placeholderText: string, field, i: number) => {
if (i === 0) {
return `${t('general:searchBy', {
label: getTranslation(field.label || field.name, i18n),
})}`
}
if (i === listSearchableFields.length - 1) {
return `${placeholderText} ${t('general:or')} ${getTranslation(field.label || field.name, i18n)}`
}
return `${placeholderText}, ${getTranslation(field?.label || field?.name, i18n)}`
},
'',
)
} else {
placeholder.current = t('general:searchBy', { label: getTranslation(fieldLabel, i18n) })
}

View File

@@ -5,5 +5,4 @@ export type Props = {
fieldName?: string
handleChange?: (search: string) => void
listSearchableFields?: FieldAffectingData[]
modifySearchQuery?: boolean
}

View File

@@ -1,16 +1,178 @@
'use client'
import type { Where } from 'payload/types.js'
import { useRouter } from 'next/navigation.js'
import { isNumber } from 'payload/utilities.js'
import QueryString from 'qs'
import React, { createContext, useContext } from 'react'
import type { ListInfoContext, ListInfoProps } from './types.js'
import { usePreferences } from '../Preferences/index.js'
const Context = createContext({} as ListInfoContext)
export const useListInfo = (): ListInfoContext => useContext(Context)
type RefineOverrides = {
limit?: string
page?: string
search?: string
sort?: string
where?: Where
}
export const ListInfoProvider: React.FC<
ListInfoProps & {
children: React.ReactNode
}
> = ({ children, ...rest }) => {
return <Context.Provider value={{ ...rest }}>{children}</Context.Provider>
> = ({
children,
collectionSlug,
data,
handlePageChange: handlePageChangeFromProps,
handlePerPageChange: handlePerPageChangeFromProps,
handleSearchChange: handleSearchChangeFromProps,
handleSortChange: handleSortChangeFromProps,
handleWhereChange: handleWhereChangeFromProps,
hasCreatePermission,
limit,
listSearchableFields,
modifySearchParams,
newDocumentURL,
sort,
titleField,
}) => {
const router = useRouter()
const { setPreference } = usePreferences()
const hasSetInitialParams = React.useRef(false)
const refineListData = React.useCallback(
async (query: RefineOverrides) => {
if (!modifySearchParams) return
const currentQuery = QueryString.parse(window.location.search, {
ignoreQueryPrefix: true,
}) as QueryString.ParsedQs & {
where: Where
}
const updatedPreferences: Record<string, unknown> = {}
let updatePreferences = false
if ('limit' in query) {
updatedPreferences.limit = query.limit
updatePreferences = true
}
if ('sort' in query) {
updatedPreferences.sort = query.sort
updatePreferences = true
}
if (updatePreferences) {
await setPreference(`${collectionSlug}-list`, updatedPreferences)
}
const params = {
limit: 'limit' in query ? query.limit : currentQuery?.limit,
page: 'page' in query ? query.page : currentQuery?.page,
search: 'search' in query ? query.search : currentQuery?.search,
sort: 'sort' in query ? query.sort : currentQuery?.sort,
where: 'where' in query ? query.where : currentQuery?.where,
}
router.replace(`?${QueryString.stringify(params)}`)
},
[collectionSlug, modifySearchParams, router, setPreference],
)
const handlePageChange = React.useCallback(
async (arg: number) => {
if (typeof handlePageChangeFromProps === 'function') {
handlePageChangeFromProps(arg)
}
await refineListData({ page: String(arg) })
},
[refineListData, handlePageChangeFromProps],
)
const handlePerPageChange = React.useCallback(
async (arg: number) => {
if (typeof handlePerPageChangeFromProps === 'function') {
handlePerPageChangeFromProps(arg)
}
await refineListData({ limit: String(arg) })
},
[refineListData, handlePerPageChangeFromProps],
)
const handleSearchChange = React.useCallback(
async (arg: string) => {
if (typeof handleSearchChangeFromProps === 'function') {
handleSearchChangeFromProps(arg)
}
await refineListData({ search: arg })
},
[refineListData, handleSearchChangeFromProps],
)
const handleSortChange = React.useCallback(
async (arg: string) => {
if (typeof handleSortChangeFromProps === 'function') {
handleSortChangeFromProps(arg)
}
await refineListData({ sort: arg })
},
[refineListData, handleSortChangeFromProps],
)
const handleWhereChange = React.useCallback(
async (arg: Where) => {
if (typeof handleWhereChangeFromProps === 'function') {
handleWhereChangeFromProps(arg)
}
await refineListData({ where: arg })
},
[refineListData, handleWhereChangeFromProps],
)
React.useEffect(() => {
if (!hasSetInitialParams.current) {
const currentQuery = QueryString.parse(window.location.search, {
ignoreQueryPrefix: true,
})
let shouldUpdateQueryString = false
if (isNumber(limit) && !('limit' in currentQuery)) {
currentQuery.limit = String(limit)
shouldUpdateQueryString = true
}
if (sort && !('sort' in currentQuery)) {
currentQuery.sort = sort
shouldUpdateQueryString = true
}
if (shouldUpdateQueryString) {
router.replace(`?${QueryString.stringify(currentQuery)}`)
}
hasSetInitialParams.current = true
}
}, [sort, limit, router])
return (
<Context.Provider
value={{
collectionSlug,
data,
handlePageChange,
handlePerPageChange,
handleSearchChange,
handleSortChange,
handleWhereChange,
hasCreatePermission,
limit,
listSearchableFields,
modifySearchParams,
newDocumentURL,
titleField,
}}
>
{children}
</Context.Provider>
)
}

View File

@@ -1,4 +1,10 @@
import type { Data, FieldAffectingData, SanitizedCollectionConfig, Where } from 'payload/types'
import type {
ClientConfig,
Data,
FieldAffectingData,
SanitizedCollectionConfig,
Where,
} from 'payload/types'
import type React from 'react'
import type { Column } from '../../elements/Table/types.js'
@@ -7,6 +13,7 @@ export type ColumnPreferences = Pick<Column, 'accessor' | 'active'>[]
export type ListInfoProps = {
Header?: React.ReactNode
collectionConfig: ClientConfig['collections'][0]
collectionSlug: SanitizedCollectionConfig['slug']
data: Data
handlePageChange?: (page: number) => void
@@ -16,10 +23,13 @@ export type ListInfoProps = {
handleWhereChange?: (where: Where) => void
hasCreatePermission: boolean
limit: SanitizedCollectionConfig['admin']['pagination']['defaultLimit']
listSearchableFields?: SanitizedCollectionConfig['admin']['listSearchableFields']
modifySearchParams?: false
newDocumentURL: string
page?: number
setLimit?: (limit: number) => void
setSort?: (sort: string) => void
sort?: string
titleField?: FieldAffectingData
}
@@ -28,7 +38,19 @@ export type ListInfo = ListInfoProps & {
// see `DocumentInfo` for an example
}
export type ListInfoContext = ListInfo & {
// add context methods here as needed
// see `DocumentInfoContext` for an example
export type ListInfoContext = {
Header?: React.ReactNode
collectionSlug: string
data: Data
handlePageChange?: (page: number) => void
handlePerPageChange?: (limit: number) => void
handleSearchChange?: (search: string) => void
handleSortChange?: (sort: string) => void
handleWhereChange?: (where: Where) => void
hasCreatePermission: boolean
limit: number
listSearchableFields: SanitizedCollectionConfig['admin']['listSearchableFields']
modifySearchParams: boolean
newDocumentURL: string
titleField?: FieldAffectingData
}