feat: #1001 - builds a way to allow list view to search multiple fields
* make textfields searchable * shorten namings in placeholder function * chore: finishes listSearchableFields Co-authored-by: Christian Reichart <christian.reichart@camperboys.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Note:</strong><br/>
|
||||
If you are adding <strong>listSearchableFields</strong>, make sure you index each of these fields so your admin queries can remain performant.
|
||||
</Banner>
|
||||
|
||||
### TypeScript
|
||||
|
||||
You can import collection types as follows:
|
||||
|
||||
@@ -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> = (props) => {
|
||||
fields,
|
||||
admin: {
|
||||
useAsTitle,
|
||||
listSearchableFields,
|
||||
},
|
||||
},
|
||||
} = props;
|
||||
@@ -37,6 +38,7 @@ const ListControls: React.FC<Props> = (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> = (props) => {
|
||||
handleChange={handleWhereChange}
|
||||
modifySearchQuery={modifySearchQuery}
|
||||
fieldLabel={titleField && titleField.label ? titleField.label : undefined}
|
||||
listSearchableFields={textFieldsToBeSearched}
|
||||
/>
|
||||
<div className={`${baseClass}__buttons`}>
|
||||
<div className={`${baseClass}__buttons-wrap`}>
|
||||
|
||||
@@ -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> = (props) => {
|
||||
fieldName = 'id',
|
||||
fieldLabel = 'ID',
|
||||
modifySearchQuery = true,
|
||||
listSearchableFields,
|
||||
handleChange,
|
||||
} = props;
|
||||
|
||||
@@ -24,11 +25,32 @@ const SearchFilter: React.FC<Props> = (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<Record<string, unknown>[]>((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> = (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> = (props) => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [debouncedSearch, history, fieldName, params, handleChange, modifySearchQuery]);
|
||||
}, [debouncedSearch, history, fieldName, params, handleChange, modifySearchQuery, listSearchableFields]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listSearchableFields?.length > 0) {
|
||||
placeholder.current = listSearchableFields.reduce<string>((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 (
|
||||
<div className={baseClass}>
|
||||
<input
|
||||
className={`${baseClass}__input`}
|
||||
placeholder={`Search by ${fieldLabel}`}
|
||||
placeholder={placeholder.current}
|
||||
type="text"
|
||||
value={search || ''}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<HookOperationType, 'create' | 'update'>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user