fix: implement the same word boundary search as the like query (#1038)

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Wesley
2022-09-01 19:03:21 +02:00
committed by GitHub
parent 32a4e8e9b9
commit c3a0bd8625
7 changed files with 76 additions and 14 deletions

View File

@@ -61,6 +61,7 @@ const ReactSelect: React.FC<Props> = (props) => {
isClearable, isClearable,
isMulti, isMulti,
isSortable, isSortable,
filterOption = undefined,
} = props; } = props;
const classes = [ const classes = [
@@ -108,6 +109,7 @@ const ReactSelect: React.FC<Props> = (props) => {
MultiValueLabel: SortableMultiValueLabel, MultiValueLabel: SortableMultiValueLabel,
DropdownIndicator: Chevron, DropdownIndicator: Chevron,
}} }}
filterOption={filterOption}
/> />
); );
} }
@@ -125,6 +127,7 @@ const ReactSelect: React.FC<Props> = (props) => {
options={options} options={options}
isSearchable={isSearchable} isSearchable={isSearchable}
isClearable={isClearable} isClearable={isClearable}
filterOption={filterOption}
/> />
); );
}; };

View File

@@ -2,6 +2,11 @@ import { OptionsType, GroupedOptionsType } from 'react-select';
export type Options = OptionsType<Value> | GroupedOptionsType<Value>; export type Options = OptionsType<Value> | GroupedOptionsType<Value>;
export type OptionType = {
[key: string]: any,
};
export type Value = { export type Value = {
label: string label: string
value: string | null value: string | null
@@ -23,4 +28,7 @@ export type Props = {
placeholder?: string placeholder?: string
isSearchable?: boolean isSearchable?: boolean
isClearable?: boolean isClearable?: boolean
filterOption?:
| (({ label, value, data }: { label: string, value: string, data: OptionType }, search: string) => boolean)
| undefined,
} }

View File

@@ -22,6 +22,7 @@ import { createRelationMap } from './createRelationMap';
import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback'; import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback';
import { useDocumentInfo } from '../../../utilities/DocumentInfo'; import { useDocumentInfo } from '../../../utilities/DocumentInfo';
import { getFilterOptionsQuery } from '../getFilterOptionsQuery'; import { getFilterOptionsQuery } from '../getFilterOptionsQuery';
import wordBoundariesRegex from '../../../../../utilities/wordBoundariesRegex';
import './index.scss'; import './index.scss';
@@ -70,6 +71,7 @@ const Relationship: React.FC<Props> = (props) => {
const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>(); const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>();
const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false); const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false);
const memoizedValidate = useCallback((value, validationOptions) => { const memoizedValidate = useCallback((value, validationOptions) => {
return validate(value, { ...validationOptions, required }); return validate(value, { ...validationOptions, required });
@@ -322,6 +324,17 @@ const Relationship: React.FC<Props> = (props) => {
} }
}, [initialValue, getResults, optionFilters, filterOptions]); }, [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 = [ const classes = [
'field-type', 'field-type',
baseClass, baseClass,
@@ -392,6 +405,10 @@ const Relationship: React.FC<Props> = (props) => {
options={options} options={options}
isMulti={hasMany} isMulti={hasMany}
isSortable={isSortable} isSortable={isSortable}
filterOption={enableWordBoundarySearch ? (item, searchFilter) => {
const r = wordBoundariesRegex(searchFilter || '');
return r.test(item.label);
} : undefined}
/> />
)} )}
{errorLoading && ( {errorLoading && (

View File

@@ -1,6 +1,7 @@
import mongoose, { SchemaType } from 'mongoose'; import mongoose, { SchemaType } from 'mongoose';
import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated'; import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated';
import { getSchemaTypeOptions } from './getSchemaTypeOptions'; import { getSchemaTypeOptions } from './getSchemaTypeOptions';
import wordBoundariesRegex from '../utilities/wordBoundariesRegex';
export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => { export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => {
let formattedValue = val; let formattedValue = val;
@@ -96,12 +97,8 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato
} }
if (operator === 'like' && typeof formattedValue === 'string') { if (operator === 'like' && typeof formattedValue === 'string') {
const words = formattedValue.split(' '); const $regex = wordBoundariesRegex(formattedValue)
const regex = words.reduce((pattern, word, i) => { formattedValue = { $regex };
return `${pattern}(?=.*\\b${word}.*\\b)${i + 1 === words.length ? '.+' : ''}`;
}, '');
formattedValue = { $regex: new RegExp(regex), $options: 'i' };
} }
} }

View File

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

View File

@@ -156,18 +156,22 @@ export default buildConfig({
name: 'relation-restricted', name: 'relation-restricted',
}, },
}); });
const { id: relationWithTitleDocId } = await payload.create<RelationWithTitle>({ const relationsWithTitle = [];
collection: relationWithTitleSlug, await mapAsync(['relation-title', 'word boundary search'], async (title) => {
data: { const { id } = await payload.create<RelationWithTitle>({
name: 'relation-title', collection: relationWithTitleSlug,
}, data: {
name: title,
},
});
relationsWithTitle.push(id);
}); });
await payload.create<FieldsRelationship>({ await payload.create<FieldsRelationship>({
collection: slug, collection: slug,
data: { data: {
relationship: relationOneDocId, relationship: relationOneDocId,
relationshipRestricted: restrictedDocId, relationshipRestricted: restrictedDocId,
relationshipWithTitle: relationWithTitleDocId, relationshipWithTitle: relationsWithTitle[0],
}, },
}); });
await mapAsync([...Array(11)], async () => { await mapAsync([...Array(11)], async () => {

View File

@@ -81,6 +81,14 @@ describe('fields - relationship', () => {
}, },
}); });
// Doc with useAsTitle for word boundary test
await payload.create<RelationWithTitle>({
collection: relationWithTitleSlug,
data: {
name: 'word boundary search',
},
});
// Add restricted doc as relation // Add restricted doc as relation
docWithExistingRelations = await payload.create<CollectionWithRelationships>({ docWithExistingRelations = await payload.create<CollectionWithRelationships>({
collection: slug, collection: slug,
@@ -190,7 +198,25 @@ describe('fields - relationship', () => {
}); });
// test.todo('should paginate within the dropdown'); // 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 () => { test('should show useAsTitle on relation', async () => {
await page.goto(url.edit(docWithExistingRelations.id)); await page.goto(url.edit(docWithExistingRelations.id));
@@ -203,7 +229,7 @@ describe('fields - relationship', () => {
await field.click({ delay: 100 }); await field.click({ delay: 100 });
const options = page.locator('.rs__option'); 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 () => { test('should show id on relation in list view', async () => {