From c3a0bd86254dfc3f49e46d4e41bdf717424ea342 Mon Sep 17 00:00:00 2001 From: Wesley Date: Thu, 1 Sep 2022 19:03:21 +0200 Subject: [PATCH] fix: implement the same word boundary search as the like query (#1038) Co-authored-by: Dan Ribbens --- .../components/elements/ReactSelect/index.tsx | 3 ++ .../components/elements/ReactSelect/types.ts | 8 +++++ .../forms/field-types/Relationship/index.tsx | 17 +++++++++++ src/mongoose/sanitizeFormattedValue.ts | 9 ++---- src/utilities/wordBoundariesRegex.ts | 7 +++++ test/fields-relationship/config.ts | 16 ++++++---- test/fields-relationship/e2e.spec.ts | 30 +++++++++++++++++-- 7 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 src/utilities/wordBoundariesRegex.ts diff --git a/src/admin/components/elements/ReactSelect/index.tsx b/src/admin/components/elements/ReactSelect/index.tsx index 6d98c83be..7d879a4f5 100644 --- a/src/admin/components/elements/ReactSelect/index.tsx +++ b/src/admin/components/elements/ReactSelect/index.tsx @@ -61,6 +61,7 @@ const ReactSelect: React.FC = (props) => { isClearable, isMulti, isSortable, + filterOption = undefined, } = props; const classes = [ @@ -108,6 +109,7 @@ const ReactSelect: React.FC = (props) => { MultiValueLabel: SortableMultiValueLabel, DropdownIndicator: Chevron, }} + filterOption={filterOption} /> ); } @@ -125,6 +127,7 @@ const ReactSelect: React.FC = (props) => { options={options} isSearchable={isSearchable} isClearable={isClearable} + filterOption={filterOption} /> ); }; diff --git a/src/admin/components/elements/ReactSelect/types.ts b/src/admin/components/elements/ReactSelect/types.ts index 0d0a7649f..d4ac8e099 100644 --- a/src/admin/components/elements/ReactSelect/types.ts +++ b/src/admin/components/elements/ReactSelect/types.ts @@ -2,6 +2,11 @@ import { OptionsType, GroupedOptionsType } from 'react-select'; export type Options = OptionsType | GroupedOptionsType; +export type OptionType = { + [key: string]: any, +}; + + export type Value = { label: string value: string | null @@ -23,4 +28,7 @@ export type Props = { placeholder?: string isSearchable?: boolean isClearable?: boolean + filterOption?: + | (({ label, value, data }: { label: string, value: string, data: OptionType }, search: string) => boolean) + | undefined, } diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index 81f6b203c..22b58ee3e 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -22,6 +22,7 @@ import { createRelationMap } from './createRelationMap'; import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback'; import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import { getFilterOptionsQuery } from '../getFilterOptionsQuery'; +import wordBoundariesRegex from '../../../../../utilities/wordBoundariesRegex'; import './index.scss'; @@ -70,6 +71,7 @@ const Relationship: React.FC = (props) => { const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>(); const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [search, setSearch] = useState(''); + const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false); const memoizedValidate = useCallback((value, validationOptions) => { return validate(value, { ...validationOptions, required }); @@ -322,6 +324,17 @@ const Relationship: React.FC = (props) => { } }, [initialValue, getResults, optionFilters, filterOptions]); + // Determine if we should switch to word boundary search + useEffect(() => { + const relations = Array.isArray(relationTo) ? relationTo : [relationTo]; + const isIdOnly = relations.reduce((idOnly, relation) => { + const collection = collections.find((coll) => coll.slug === relation); + const fieldToSearch = collection?.admin?.useAsTitle || 'id'; + return fieldToSearch === 'id' && idOnly; + }, true); + setEnableWordBoundarySearch(!isIdOnly); + }, [relationTo, collections]); + const classes = [ 'field-type', baseClass, @@ -392,6 +405,10 @@ const Relationship: React.FC = (props) => { options={options} isMulti={hasMany} isSortable={isSortable} + filterOption={enableWordBoundarySearch ? (item, searchFilter) => { + const r = wordBoundariesRegex(searchFilter || ''); + return r.test(item.label); + } : undefined} /> )} {errorLoading && ( diff --git a/src/mongoose/sanitizeFormattedValue.ts b/src/mongoose/sanitizeFormattedValue.ts index 102ba11be..2bd9ba586 100644 --- a/src/mongoose/sanitizeFormattedValue.ts +++ b/src/mongoose/sanitizeFormattedValue.ts @@ -1,6 +1,7 @@ import mongoose, { SchemaType } from 'mongoose'; import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated'; import { getSchemaTypeOptions } from './getSchemaTypeOptions'; +import wordBoundariesRegex from '../utilities/wordBoundariesRegex'; export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => { let formattedValue = val; @@ -96,12 +97,8 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato } if (operator === 'like' && typeof formattedValue === 'string') { - const words = formattedValue.split(' '); - const regex = words.reduce((pattern, word, i) => { - return `${pattern}(?=.*\\b${word}.*\\b)${i + 1 === words.length ? '.+' : ''}`; - }, ''); - - formattedValue = { $regex: new RegExp(regex), $options: 'i' }; + const $regex = wordBoundariesRegex(formattedValue) + formattedValue = { $regex }; } } diff --git a/src/utilities/wordBoundariesRegex.ts b/src/utilities/wordBoundariesRegex.ts new file mode 100644 index 000000000..ff6d7cee5 --- /dev/null +++ b/src/utilities/wordBoundariesRegex.ts @@ -0,0 +1,7 @@ +export default (input: string): RegExp => { + const words = input.split(' '); + const regex = words.reduce((pattern, word, i) => { + return `${pattern}(?=.*\\b${word}.*\\b)${i + 1 === words.length ? '.+' : ''}`; + }, ''); + return new RegExp(regex, 'i'); +}; diff --git a/test/fields-relationship/config.ts b/test/fields-relationship/config.ts index 1a12ef7ff..1ad43455e 100644 --- a/test/fields-relationship/config.ts +++ b/test/fields-relationship/config.ts @@ -156,18 +156,22 @@ export default buildConfig({ name: 'relation-restricted', }, }); - const { id: relationWithTitleDocId } = await payload.create({ - collection: relationWithTitleSlug, - data: { - name: 'relation-title', - }, + const relationsWithTitle = []; + await mapAsync(['relation-title', 'word boundary search'], async (title) => { + const { id } = await payload.create({ + collection: relationWithTitleSlug, + data: { + name: title, + }, + }); + relationsWithTitle.push(id); }); await payload.create({ collection: slug, data: { relationship: relationOneDocId, relationshipRestricted: restrictedDocId, - relationshipWithTitle: relationWithTitleDocId, + relationshipWithTitle: relationsWithTitle[0], }, }); await mapAsync([...Array(11)], async () => { diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index 45ac25bc1..8c096a55b 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -81,6 +81,14 @@ describe('fields - relationship', () => { }, }); + // Doc with useAsTitle for word boundary test + await payload.create({ + collection: relationWithTitleSlug, + data: { + name: 'word boundary search', + }, + }); + // Add restricted doc as relation docWithExistingRelations = await payload.create({ collection: slug, @@ -190,7 +198,25 @@ describe('fields - relationship', () => { }); // test.todo('should paginate within the dropdown'); - // test.todo('should search within the relationship field'); + + test('should search within the relationship field', async () => { + await page.goto(url.edit(docWithExistingRelations.id)); + const input = page.locator('#field-relationshipWithTitle input'); + await input.fill('title'); + const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option'); + await expect(options).toHaveCount(1); + + await input.fill('non-occuring-string'); + await expect(options).toHaveCount(0); + }); + + test('should search using word boundaries within the relationship field', async () => { + await page.goto(url.edit(docWithExistingRelations.id)); + const input = page.locator('#field-relationshipWithTitle input'); + await input.fill('word search'); + const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option'); + await expect(options).toHaveCount(1); + }); test('should show useAsTitle on relation', async () => { await page.goto(url.edit(docWithExistingRelations.id)); @@ -203,7 +229,7 @@ describe('fields - relationship', () => { await field.click({ delay: 100 }); const options = page.locator('.rs__option'); - await expect(options).toHaveCount(2); // None + 1 Doc + await expect(options).toHaveCount(3); // None + 2 Doc }); test('should show id on relation in list view', async () => {