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 { 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);
|
||||||
|
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,
|
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;
|
||||||
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) {
|
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(() => {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user