From 8f555d7ed94f14d8672b58cf7e791dfd8f28eebf Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Tue, 29 Aug 2023 16:31:19 -0400 Subject: [PATCH] fix: properly handle search within a single entry (#855) --- packages/core/src/backend.ts | 26 +++-- .../lib/util/__tests__/search.util.spec.ts | 76 +++++++++++++++ packages/core/src/lib/util/search.util.ts | 94 +++++++++++++++++++ .../src/widgets/relation/RelationControl.tsx | 40 +++++--- 4 files changed, 210 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/lib/util/__tests__/search.util.spec.ts create mode 100644 packages/core/src/lib/util/search.util.ts diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 12a6078b..e4708542 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -45,6 +45,7 @@ import { DRAFT_MEDIA_FILES, selectMediaFilePublicPath } from './lib/util/media.u import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util'; import { isNullish } from './lib/util/null.util'; import { set } from './lib/util/object.util'; +import { fileSearch, sortByScore } from './lib/util/search.util'; import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate'; import createEntry from './valueObjects/createEntry'; @@ -251,18 +252,6 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]): En return Object.values(merged); } -export function sortByScore(a: fuzzy.FilterResult, b: fuzzy.FilterResult) { - if (a.score > b.score) { - return -1; - } - - if (a.score < b.score) { - return 1; - } - - return 0; -} - interface AuthStore { retrieve: () => User; store: (user: User) => void; @@ -605,9 +594,18 @@ export class Backend { - let entries = await this.listAllEntries(collection as Collection); + const entries = await this.listAllEntries(collection as Collection); if (file) { - entries = entries.filter(e => e.slug === file); + let hits = fileSearch( + entries.find(e => e.slug === file), + searchFields, + searchTerm, + ); + if (limit !== undefined && limit > 0) { + hits = hits.slice(0, limit); + } + + return { query: searchTerm, hits }; } const expandedEntries = expandSearchEntries(entries, searchFields); diff --git a/packages/core/src/lib/util/__tests__/search.util.spec.ts b/packages/core/src/lib/util/__tests__/search.util.spec.ts new file mode 100644 index 00000000..ce8e2c0e --- /dev/null +++ b/packages/core/src/lib/util/__tests__/search.util.spec.ts @@ -0,0 +1,76 @@ +import { createMockEntry } from '@staticcms/test/data/entry.mock'; +import { fileSearch } from '../search.util'; + +const rightIcon = { + name: 'right', + type: 'control', + icon: '/src/icons/right.svg', +}; + +const leftIcon = { + name: 'left', + type: 'control', + icon: '/src/icons/left.svg', +}; + +const zoomIcon = { + name: 'zoom', + type: 'action', + icon: '/src/icons/zoom.svg', +}; + +const downloadIcon = { + name: 'download', + type: 'action', + icon: '/src/icons/download.svg', +}; + +const nestedList = createMockEntry({ + data: { + icons: [rightIcon, leftIcon, downloadIcon, zoomIcon], + }, +}); + +describe('search.util', () => { + describe('fileSearch', () => { + it('filters nested array', () => { + expect(fileSearch(nestedList, ['icons.*.name'], 'zoom')).toEqual([ + createMockEntry({ + data: { + icons: [zoomIcon], + }, + raw: nestedList.raw, + }), + ]); + + expect(fileSearch(nestedList, ['icons.*.name'], 'bad input')).toEqual([ + createMockEntry({ + data: { + icons: [], + }, + raw: nestedList.raw, + }), + ]); + }); + + it('filters nested array on multiple search fields', () => { + expect(fileSearch(nestedList, ['icons.*.name', 'icons.*.type'], 'action')).toEqual([ + createMockEntry({ + data: { + icons: [downloadIcon, zoomIcon], + }, + raw: nestedList.raw, + }), + ]); + + expect(fileSearch(nestedList, ['icons.*.name', 'icons.*.type'], 'down')).toEqual([ + createMockEntry({ + data: { + icons: [downloadIcon], + }, + raw: nestedList.raw, + }), + ]); + }); + }); +}); diff --git a/packages/core/src/lib/util/search.util.ts b/packages/core/src/lib/util/search.util.ts new file mode 100644 index 00000000..dc7188de --- /dev/null +++ b/packages/core/src/lib/util/search.util.ts @@ -0,0 +1,94 @@ +import deepmerge from 'deepmerge'; +import * as fuzzy from 'fuzzy'; + +import type { Entry, ObjectValue, ValueOrNestedValue } from '@staticcms/core/interface'; + +function filter( + value: ValueOrNestedValue, + remainingPath: string[], + searchTerm: string, +): ValueOrNestedValue { + if (Array.isArray(value)) { + const [nextPath, ...rest] = remainingPath; + if (nextPath !== '*') { + return value; + } + + if (rest.length === 1) { + const validOptions = value.filter(e => { + if (!e || Array.isArray(e) || typeof e !== 'object' || e instanceof Date) { + return false; + } + + return true; + }) as ObjectValue[]; + + return validOptions.length > 0 + ? fuzzy + .filter(searchTerm, validOptions, { + extract: input => { + return String(input[rest[0]]); + }, + }) + .sort(sortByScore) + .map(f => f.original) + : []; + } + + return value.map(childValue => { + return filter(childValue, rest, searchTerm); + }); + } + + if (value && typeof value === 'object' && !(value instanceof Date)) { + const newValue = { ...value }; + + const [nextPath, ...rest] = remainingPath; + + const childValue = newValue[nextPath]; + if ( + childValue && + (Array.isArray(childValue) || + (typeof childValue === 'object' && !(childValue instanceof Date))) + ) { + newValue[nextPath] = filter(childValue, rest, searchTerm); + } + + return newValue; + } + + return value; +} + +export function fileSearch( + entry: Entry | undefined, + searchFields: string[], + searchTerm: string, +): Entry[] { + if (!entry) { + return []; + } + + return [ + { + ...entry, + data: searchFields.reduce( + (acc, searchField) => + deepmerge(acc, filter(entry.data, searchField.split('.'), searchTerm) as ObjectValue), + {}, + ), + }, + ]; +} + +export function sortByScore(a: fuzzy.FilterResult, b: fuzzy.FilterResult) { + if (a.score > b.score) { + return -1; + } + + if (a.score < b.score) { + return 1; + } + + return 0; +} diff --git a/packages/core/src/widgets/relation/RelationControl.tsx b/packages/core/src/widgets/relation/RelationControl.tsx index ea9338ad..5c751990 100644 --- a/packages/core/src/widgets/relation/RelationControl.tsx +++ b/packages/core/src/widgets/relation/RelationControl.tsx @@ -8,13 +8,13 @@ import { expandSearchEntries, getEntryField, mergeExpandedEntries, - sortByScore, } from '@staticcms/core/backend'; import Autocomplete from '@staticcms/core/components/common/autocomplete/Autocomplete'; import Field from '@staticcms/core/components/common/field/Field'; import Pill from '@staticcms/core/components/common/pill/Pill'; import CircularProgress from '@staticcms/core/components/common/progress/CircularProgress'; import { isNullish } from '@staticcms/core/lib/util/null.util'; +import { fileSearch, sortByScore } from '@staticcms/core/lib/util/search.util'; import { isEmpty } from '@staticcms/core/lib/util/string.util'; import { addFileTemplateFields, @@ -28,6 +28,7 @@ import { useAppSelector } from '@staticcms/core/store/hooks'; import type { Entry, EntryData, + ObjectValue, RelationField, WidgetControlProps, } from '@staticcms/core/interface'; @@ -189,18 +190,33 @@ const RelationControl: FC> } const searchFields = field.search_fields; + const file = field.file; const limit = field.options_length || DEFAULT_OPTIONS_LIMIT; - const expandedEntries = expandSearchEntries(entries, searchFields); - const hits = fuzzy - .filter(inputValue, expandedEntries, { - extract: entry => { - return getEntryField(entry.field, entry); - }, - }) - .sort(sortByScore) - .map(f => f.original); - let options = uniqBy(parseHitOptions(mergeExpandedEntries(hits)), o => o.value); + let hits: Entry[]; + + if (file) { + hits = fileSearch( + entries.find(e => e.slug === file), + searchFields, + inputValue, + ); + console.log('file', file, 'hits', hits); + } else { + const expandedEntries = expandSearchEntries(entries, searchFields); + hits = mergeExpandedEntries( + fuzzy + .filter(inputValue, expandedEntries, { + extract: entry => { + return getEntryField(entry.field, entry); + }, + }) + .sort(sortByScore) + .map(f => f.original), + ); + } + + let options = uniqBy(parseHitOptions(hits), o => o.value); if (limit !== undefined && limit > 0) { options = options.slice(0, limit); @@ -208,7 +224,7 @@ const RelationControl: FC> setOptions(options); }, - [entries, field.options_length, field.search_fields, parseHitOptions], + [entries, field.file, field.options_length, field.search_fields, parseHitOptions], ); useEffect(() => {