fix: properly handle search within a single entry (#855)

This commit is contained in:
Daniel Lautzenheiser 2023-08-29 16:31:19 -04:00 committed by GitHub
parent 9854afe0f2
commit 8f555d7ed9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 210 additions and 26 deletions

View File

@ -45,6 +45,7 @@ import { DRAFT_MEDIA_FILES, selectMediaFilePublicPath } from './lib/util/media.u
import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util'; import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util';
import { isNullish } from './lib/util/null.util'; import { isNullish } from './lib/util/null.util';
import { set } from './lib/util/object.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 { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate';
import createEntry from './valueObjects/createEntry'; import createEntry from './valueObjects/createEntry';
@ -251,18 +252,6 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]): En
return Object.values(merged); return Object.values(merged);
} }
export function sortByScore(a: fuzzy.FilterResult<Entry>, b: fuzzy.FilterResult<Entry>) {
if (a.score > b.score) {
return -1;
}
if (a.score < b.score) {
return 1;
}
return 0;
}
interface AuthStore { interface AuthStore {
retrieve: () => User; retrieve: () => User;
store: (user: User) => void; store: (user: User) => void;
@ -605,9 +594,18 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
file?: string, file?: string,
limit?: number, limit?: number,
): Promise<SearchQueryResponse> { ): Promise<SearchQueryResponse> {
let entries = await this.listAllEntries(collection as Collection); const entries = await this.listAllEntries(collection as Collection);
if (file) { 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); const expandedEntries = expandSearchEntries(entries, searchFields);

View File

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

View File

@ -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<T>(a: fuzzy.FilterResult<T>, b: fuzzy.FilterResult<T>) {
if (a.score > b.score) {
return -1;
}
if (a.score < b.score) {
return 1;
}
return 0;
}

View File

@ -8,13 +8,13 @@ import {
expandSearchEntries, expandSearchEntries,
getEntryField, getEntryField,
mergeExpandedEntries, mergeExpandedEntries,
sortByScore,
} from '@staticcms/core/backend'; } from '@staticcms/core/backend';
import Autocomplete from '@staticcms/core/components/common/autocomplete/Autocomplete'; import Autocomplete from '@staticcms/core/components/common/autocomplete/Autocomplete';
import Field from '@staticcms/core/components/common/field/Field'; import Field from '@staticcms/core/components/common/field/Field';
import Pill from '@staticcms/core/components/common/pill/Pill'; import Pill from '@staticcms/core/components/common/pill/Pill';
import CircularProgress from '@staticcms/core/components/common/progress/CircularProgress'; import CircularProgress from '@staticcms/core/components/common/progress/CircularProgress';
import { isNullish } from '@staticcms/core/lib/util/null.util'; 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 { isEmpty } from '@staticcms/core/lib/util/string.util';
import { import {
addFileTemplateFields, addFileTemplateFields,
@ -28,6 +28,7 @@ import { useAppSelector } from '@staticcms/core/store/hooks';
import type { import type {
Entry, Entry,
EntryData, EntryData,
ObjectValue,
RelationField, RelationField,
WidgetControlProps, WidgetControlProps,
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
@ -189,18 +190,33 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
} }
const searchFields = field.search_fields; const searchFields = field.search_fields;
const file = field.file;
const limit = field.options_length || DEFAULT_OPTIONS_LIMIT; const limit = field.options_length || DEFAULT_OPTIONS_LIMIT;
let hits: Entry<ObjectValue>[];
if (file) {
hits = fileSearch(
entries.find(e => e.slug === file),
searchFields,
inputValue,
);
console.log('file', file, 'hits', hits);
} else {
const expandedEntries = expandSearchEntries(entries, searchFields); const expandedEntries = expandSearchEntries(entries, searchFields);
const hits = fuzzy hits = mergeExpandedEntries(
fuzzy
.filter(inputValue, expandedEntries, { .filter(inputValue, expandedEntries, {
extract: entry => { extract: entry => {
return getEntryField(entry.field, entry); return getEntryField(entry.field, entry);
}, },
}) })
.sort(sortByScore) .sort(sortByScore)
.map(f => f.original); .map(f => f.original),
);
}
let options = uniqBy(parseHitOptions(mergeExpandedEntries(hits)), o => o.value); let options = uniqBy(parseHitOptions(hits), o => o.value);
if (limit !== undefined && limit > 0) { if (limit !== undefined && limit > 0) {
options = options.slice(0, limit); options = options.slice(0, limit);
@ -208,7 +224,7 @@ const RelationControl: FC<WidgetControlProps<string | string[], RelationField>>
setOptions(options); setOptions(options);
}, },
[entries, field.options_length, field.search_fields, parseHitOptions], [entries, field.file, field.options_length, field.search_fields, parseHitOptions],
); );
useEffect(() => { useEffect(() => {