fix: properly handle search within a single entry (#855)
This commit is contained in:
parent
9854afe0f2
commit
8f555d7ed9
@ -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<Entry>, b: fuzzy.FilterResult<Entry>) {
|
||||
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<EF extends BaseField = UnknownField, BC extends BackendClas
|
||||
file?: string,
|
||||
limit?: number,
|
||||
): Promise<SearchQueryResponse> {
|
||||
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);
|
||||
|
76
packages/core/src/lib/util/__tests__/search.util.spec.ts
Normal file
76
packages/core/src/lib/util/__tests__/search.util.spec.ts
Normal 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,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
94
packages/core/src/lib/util/search.util.ts
Normal file
94
packages/core/src/lib/util/search.util.ts
Normal 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;
|
||||
}
|
@ -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<WidgetControlProps<string | string[], RelationField>>
|
||||
}
|
||||
|
||||
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<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);
|
||||
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<WidgetControlProps<string | string[], RelationField>>
|
||||
|
||||
setOptions(options);
|
||||
},
|
||||
[entries, field.options_length, field.search_fields, parseHitOptions],
|
||||
[entries, field.file, field.options_length, field.search_fields, parseHitOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user