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:
James Mikrut
2022-09-12 16:38:02 -07:00
committed by GitHub
parent d5ccd45b53
commit a1083727ef
10 changed files with 101 additions and 18 deletions

View File

@@ -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:

View File

@@ -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`}>

View File

@@ -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)}

View File

@@ -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
}

View File

@@ -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(),

View File

@@ -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

View File

@@ -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;

View File

@@ -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',
},
],
},
{

View File

@@ -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();

View File

@@ -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);