diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 2a64d9c8b..7020d99ea 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -69,6 +69,7 @@ You can customize the way that the Admin panel behaves on a collection-by-collec | `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. | | `preview` | Function to generate preview URLS within the Admin panel that can point to your app. [More](#preview). | | `components` | Swap in your own React components to be used within this collection. [More](/docs/admin/components#collections) | +| `listSearchableFields ` | Specify which fields should be searched in the List search view. [More](/docs/configuration/collections#list-searchable-fields) | ### Preview @@ -119,6 +120,17 @@ Hooks are a powerful way to extend collection functionality and execute your own Collections support all field types that Payload has to offer—including simple fields like text and checkboxes all the way to more complicated layout-building field groups like Blocks. [Click here](/docs/fields/overview) to learn more about field types. +#### List Searchable Fields + +In the List view, there is a "search" box that allows you to quickly find a document with a search. By default, it searches on the ID field. If you have `admin.useAsTitle` defined, the list search will use that field. However, you can define more than one field to search to make it easier on your admin editors to find the data they need. + +For example, let's say you have a Posts collection with `title`, `metaDescription`, and `tags` fields - and you want all three of those fields to be searchable in the List view. You can simply add `admin.listSearchableFields: ['title', 'metaDescription', 'tags']` - and the admin UI will automatically search on those three fields plus the ID field. + + + Note:
+ If you are adding listSearchableFields, make sure you index each of these fields so your admin queries can remain performant. +
+ ### TypeScript You can import collection types as follows: diff --git a/src/admin/components/elements/ListControls/index.tsx b/src/admin/components/elements/ListControls/index.tsx index 35842b133..6448e641c 100644 --- a/src/admin/components/elements/ListControls/index.tsx +++ b/src/admin/components/elements/ListControls/index.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import AnimateHeight from 'react-animate-height'; -import { fieldAffectsData } from '../../../../fields/config/types'; +import { FieldAffectingData, fieldAffectsData } from '../../../../fields/config/types'; import SearchFilter from '../SearchFilter'; import ColumnSelector from '../ColumnSelector'; import WhereBuilder from '../WhereBuilder'; @@ -29,6 +29,7 @@ const ListControls: React.FC = (props) => { fields, admin: { useAsTitle, + listSearchableFields, }, }, } = props; @@ -37,6 +38,7 @@ const ListControls: React.FC = (props) => { const shouldInitializeWhereOpened = validateWhereQuery(params?.where); const [titleField] = useState(() => fields.find((field) => fieldAffectsData(field) && field.name === useAsTitle)); + const [textFieldsToBeSearched] = useState(listSearchableFields ? () => fields.filter((field) => fieldAffectsData(field) && listSearchableFields.includes(field.name)) as FieldAffectingData[] : null); const [visibleDrawer, setVisibleDrawer] = useState<'where' | 'sort' | 'columns'>(shouldInitializeWhereOpened ? 'where' : undefined); return ( @@ -47,6 +49,7 @@ const ListControls: React.FC = (props) => { handleChange={handleWhereChange} modifySearchQuery={modifySearchQuery} fieldLabel={titleField && titleField.label ? titleField.label : undefined} + listSearchableFields={textFieldsToBeSearched} />
diff --git a/src/admin/components/elements/SearchFilter/index.tsx b/src/admin/components/elements/SearchFilter/index.tsx index b1eb64229..dfb5bb702 100644 --- a/src/admin/components/elements/SearchFilter/index.tsx +++ b/src/admin/components/elements/SearchFilter/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import queryString from 'qs'; import { Props } from './types'; @@ -16,6 +16,7 @@ const SearchFilter: React.FC = (props) => { fieldName = 'id', fieldLabel = 'ID', modifySearchQuery = true, + listSearchableFields, handleChange, } = props; @@ -24,11 +25,32 @@ const SearchFilter: React.FC = (props) => { const [search, setSearch] = useState(() => params?.where?.[fieldName]?.like || ''); + const placeholder = useRef(`Search by ${fieldLabel}`); + const debouncedSearch = useDebounce(search, 300); useEffect(() => { - if (debouncedSearch !== params?.where?.[fieldName]?.like) { - const newWhere = { + if (debouncedSearch || params?.where) { + let newWhere = listSearchableFields?.length > 0 ? { + ...(typeof params?.where === 'object' ? params.where : {}), + or: [ + { + [fieldName]: { + like: debouncedSearch, + }, + }, + ...listSearchableFields.reduce[]>((prev, curr) => { + return [ + ...prev, + { + [curr.name]: { + like: debouncedSearch, + }, + }, + ]; + }, []), + ], + } : { ...(typeof params?.where === 'object' ? params.where : {}), [fieldName]: { like: debouncedSearch, @@ -36,12 +58,12 @@ const SearchFilter: React.FC = (props) => { }; if (!debouncedSearch) { - delete newWhere[fieldName]; + newWhere = undefined; } if (handleChange) handleChange(newWhere as Where); - if (modifySearchQuery && params?.where?.[fieldName]?.like !== newWhere?.[fieldName]?.like) { + if (modifySearchQuery && queryString.stringify(params?.where) !== queryString.stringify(newWhere)) { history.replace({ search: queryString.stringify({ ...params, @@ -51,13 +73,24 @@ const SearchFilter: React.FC = (props) => { }); } } - }, [debouncedSearch, history, fieldName, params, handleChange, modifySearchQuery]); + }, [debouncedSearch, history, fieldName, params, handleChange, modifySearchQuery, listSearchableFields]); + + useEffect(() => { + if (listSearchableFields?.length > 0) { + placeholder.current = listSearchableFields.reduce((prev, curr, i) => { + if (i === listSearchableFields.length - 1) { + return `${prev} or ${curr.label || curr.name}`; + } + return `${prev}, ${curr.label || curr.name}`; + }, placeholder.current); + } + }, [listSearchableFields]); return (
setSearch(e.target.value)} diff --git a/src/admin/components/elements/SearchFilter/types.ts b/src/admin/components/elements/SearchFilter/types.ts index 611116219..50e963816 100644 --- a/src/admin/components/elements/SearchFilter/types.ts +++ b/src/admin/components/elements/SearchFilter/types.ts @@ -1,8 +1,10 @@ +import { FieldAffectingData } from '../../../../fields/config/types'; import { Where } from '../../../../types'; export type Props = { fieldName?: string, fieldLabel?: string, modifySearchQuery?: boolean + listSearchableFields?: FieldAffectingData[] handleChange?: (where: Where) => void } diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 58e7d7363..825cf6a68 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -26,6 +26,7 @@ const collectionSchema = joi.object().keys({ admin: joi.object({ useAsTitle: joi.string(), defaultColumns: joi.array().items(joi.string()), + listSearchableFields: joi.array().items(joi.string()), group: joi.string(), description: joi.alternatives().try( joi.string(), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index 80c2541ac..c8d4ec15b 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -27,14 +27,14 @@ export interface AuthCollectionModel extends CollectionModel { } export type HookOperationType = -| 'create' -| 'autosave' -| 'read' -| 'update' -| 'delete' -| 'refresh' -| 'login' -| 'forgotPassword'; + | 'create' + | 'autosave' + | 'read' + | 'update' + | 'delete' + | 'refresh' + | 'login' + | 'forgotPassword'; type CreateOrUpdateOperation = Extract; @@ -153,8 +153,12 @@ export type CollectionAdminOptions = { */ defaultColumns?: string[]; /** - * Place collections into a navigational group + * Additional fields to be searched via the full text search */ + listSearchableFields?: string[]; + /** + * Place collections into a navigational group + * */ group?: string; /** * Custom description for collection diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index b1e90bed4..fcff2a451 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -121,7 +121,9 @@ class ParamParser { // If the operation is properly formatted as an object if (typeof condition === 'object') { const result = await this.parsePathOrRelation(condition); - completedConditions.push(result); + if (Object.keys(result).length > 0) { + completedConditions.push(result); + } } } return completedConditions; diff --git a/test/admin/config.ts b/test/admin/config.ts index 318bf1586..b0823537a 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -56,6 +56,7 @@ export default buildConfig({ { slug, admin: { + listSearchableFields: ['title', 'description', 'number'], group: 'One', }, fields: [ @@ -67,6 +68,10 @@ export default buildConfig({ name: 'description', type: 'text', }, + { + name: 'number', + type: 'number', + }, ], }, { diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index ad619884e..420a18385 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -184,10 +184,26 @@ describe('admin', () => { test('search by id', async () => { const { id } = await createPost(); await page.locator('.search-filter__input').fill(id); + await wait(250); const tableItems = page.locator(tableRowLocator); await expect(tableItems).toHaveCount(1); }); + test('search by title or description', async () => { + await createPost({ + title: 'find me', + description: 'this is fun', + }); + + await page.locator('.search-filter__input').fill('find me'); + await wait(250); + await expect(page.locator(tableRowLocator)).toHaveCount(1); + + await page.locator('.search-filter__input').fill('this is fun'); + await wait(250); + await expect(page.locator(tableRowLocator)).toHaveCount(1); + }); + test('toggle columns', async () => { const columnCountLocator = 'table >> thead >> tr >> th'; await createPost(); diff --git a/test/devServer.ts b/test/devServer.ts index 4926c5c10..933ff6c91 100644 --- a/test/devServer.ts +++ b/test/devServer.ts @@ -19,6 +19,11 @@ const init = async () => { }, }); + // Redirect root to Admin panel + expressApp.get('/', (_, res) => { + res.redirect('/admin'); + }); + const externalRouter = express.Router(); externalRouter.use(payload.authenticate);