fix: relation widget performance (#3975)
This commit is contained in:
parent
126b86cdc4
commit
c7e0fe8492
@ -1,4 +1,10 @@
|
||||
import { resolveBackend, Backend, extractSearchFields } from '../backend';
|
||||
import {
|
||||
resolveBackend,
|
||||
Backend,
|
||||
extractSearchFields,
|
||||
expandSearchEntries,
|
||||
mergeExpandedEntries,
|
||||
} from '../backend';
|
||||
import registry from 'Lib/registry';
|
||||
import { FOLDER } from 'Constants/collectionTypes';
|
||||
import { Map, List, fromJS } from 'immutable';
|
||||
@ -696,4 +702,242 @@ describe('Backend', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandSearchEntries', () => {
|
||||
it('should expand entry with list to multiple entries', () => {
|
||||
const entry = {
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
};
|
||||
|
||||
expect(expandSearchEntries([entry], ['list.*', 'field.nested.list.*.name'])).toEqual([
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'list.0',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'list.1',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'field.nested.list.0.name',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'field.nested.list.1.name',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeExpandedEntries', () => {
|
||||
it('should merge entries and filter data', () => {
|
||||
const expanded = [
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
{ id: 3, name: '3' },
|
||||
{ id: 4, name: '4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'field.nested.list.0.name',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
{ id: 3, name: '3' },
|
||||
{ id: 4, name: '4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'field.nested.list.3.name',
|
||||
},
|
||||
];
|
||||
|
||||
expect(mergeExpandedEntries(expanded)).toEqual([
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 4, name: '4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge entries and filter data based on different fields', () => {
|
||||
const expanded = [
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
{ id: 3, name: '3' },
|
||||
{ id: 4, name: '4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'field.nested.list.0.name',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
{ id: 3, name: '3' },
|
||||
{ id: 4, name: '4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'field.nested.list.3.name',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 2, name: '2' },
|
||||
{ id: 3, name: '3' },
|
||||
{ id: 4, name: '4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [1, 2],
|
||||
},
|
||||
field: 'list.1',
|
||||
},
|
||||
];
|
||||
|
||||
expect(mergeExpandedEntries(expanded)).toEqual([
|
||||
{
|
||||
data: {
|
||||
field: {
|
||||
nested: {
|
||||
list: [
|
||||
{ id: 1, name: '1' },
|
||||
{ id: 4, name: '4' },
|
||||
],
|
||||
},
|
||||
},
|
||||
list: [2],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge entries and keep sort by entry index', () => {
|
||||
const expanded = [
|
||||
{
|
||||
data: {
|
||||
list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
},
|
||||
field: 'list.5',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
},
|
||||
field: 'list.0',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
},
|
||||
field: 'list.11',
|
||||
},
|
||||
{
|
||||
data: {
|
||||
list: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
},
|
||||
field: 'list.1',
|
||||
},
|
||||
];
|
||||
|
||||
expect(mergeExpandedEntries(expanded)).toEqual([
|
||||
{
|
||||
data: {
|
||||
list: [5, 0, 11, 1],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -75,17 +75,22 @@ export function querying(
|
||||
};
|
||||
}
|
||||
|
||||
type Response = {
|
||||
type SearchResponse = {
|
||||
entries: EntryValue[];
|
||||
pagination: number;
|
||||
};
|
||||
|
||||
type QueryResponse = {
|
||||
hits: EntryValue[];
|
||||
query: string;
|
||||
};
|
||||
|
||||
export function querySuccess(
|
||||
namespace: string,
|
||||
collection: string,
|
||||
searchFields: string[],
|
||||
searchTerm: string,
|
||||
response: Response,
|
||||
response: QueryResponse,
|
||||
) {
|
||||
return {
|
||||
type: QUERY_SUCCESS,
|
||||
@ -174,7 +179,7 @@ export function searchEntries(
|
||||
);
|
||||
|
||||
return searchPromise.then(
|
||||
(response: Response) =>
|
||||
(response: SearchResponse) =>
|
||||
dispatch(
|
||||
searchSuccess(
|
||||
searchTerm,
|
||||
@ -195,8 +200,10 @@ export function query(
|
||||
collectionName: string,
|
||||
searchFields: string[],
|
||||
searchTerm: string,
|
||||
file?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
return (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
return async (dispatch: ThunkDispatch<State, {}, AnyAction>, getState: () => State) => {
|
||||
dispatch(querying(namespace, collectionName, searchFields, searchTerm));
|
||||
|
||||
const state = getState();
|
||||
@ -212,13 +219,13 @@ export function query(
|
||||
collectionName,
|
||||
searchTerm,
|
||||
)
|
||||
: backend.query(collection, searchFields, searchTerm);
|
||||
: backend.query(collection, searchFields, searchTerm, file, limit);
|
||||
|
||||
return queryPromise.then(
|
||||
(response: Response) =>
|
||||
dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response)),
|
||||
(error: Error) =>
|
||||
dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error)),
|
||||
);
|
||||
try {
|
||||
const response: QueryResponse = await queryPromise;
|
||||
return dispatch(querySuccess(namespace, collectionName, searchFields, searchTerm, response));
|
||||
} catch (error) {
|
||||
return dispatch(queryFailure(namespace, collectionName, searchFields, searchTerm, error));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { attempt, flatten, isError, uniq, trim, sortBy } from 'lodash';
|
||||
import { List, Map, fromJS } from 'immutable';
|
||||
import { attempt, flatten, isError, uniq, trim, sortBy, get, set } from 'lodash';
|
||||
import { List, Map, fromJS, Set } from 'immutable';
|
||||
import * as fuzzy from 'fuzzy';
|
||||
import { resolveFormat } from './formats/formats';
|
||||
import { selectUseWorkflow } from './reducers/config';
|
||||
@ -56,7 +56,7 @@ import AssetProxy from './valueObjects/AssetProxy';
|
||||
import { FOLDER, FILES } from './constants/collectionTypes';
|
||||
import { selectCustomPath } from './reducers/entryDraft';
|
||||
|
||||
const { extractTemplateVars, dateParsers } = stringTemplate;
|
||||
const { extractTemplateVars, dateParsers, expandPath } = stringTemplate;
|
||||
|
||||
export class LocalStorageAuthStore {
|
||||
storageKey = 'netlify-cms-user';
|
||||
@ -84,25 +84,104 @@ function getEntryBackupKey(collectionName?: string, slug?: string) {
|
||||
return `${baseKey}.${collectionName}${suffix}`;
|
||||
}
|
||||
|
||||
const getEntryField = (field: string, entry: EntryValue) => {
|
||||
const value = get(entry.data, field);
|
||||
if (value) {
|
||||
return String(value);
|
||||
} else {
|
||||
const firstFieldPart = field.split('.')[0];
|
||||
if (entry[firstFieldPart as keyof EntryValue]) {
|
||||
// allows searching using entry.slug/entry.path etc.
|
||||
return entry[firstFieldPart as keyof EntryValue];
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const extractSearchFields = (searchFields: string[]) => (entry: EntryValue) =>
|
||||
searchFields.reduce((acc, field) => {
|
||||
const nestedFields = field.split('.');
|
||||
let f = entry.data;
|
||||
for (let i = 0; i < nestedFields.length; i++) {
|
||||
f = f[nestedFields[i]];
|
||||
if (!f) break;
|
||||
}
|
||||
|
||||
if (f) {
|
||||
return `${acc} ${f}`;
|
||||
} else if (entry[nestedFields[0] as keyof EntryValue]) {
|
||||
// allows searching using entry.slug/entry.path etc.
|
||||
return `${acc} ${entry[nestedFields[0] as keyof EntryValue]}`;
|
||||
const value = getEntryField(field, entry);
|
||||
if (value) {
|
||||
return `${acc} ${value}`;
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, '');
|
||||
|
||||
export const expandSearchEntries = (entries: EntryValue[], searchFields: string[]) => {
|
||||
// expand the entries for the purpose of the search
|
||||
const expandedEntries = entries.reduce((acc, e) => {
|
||||
const expandedFields = searchFields.reduce((acc, f) => {
|
||||
const fields = expandPath({ data: e.data, path: f });
|
||||
acc.push(...fields);
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
|
||||
for (let i = 0; i < expandedFields.length; i++) {
|
||||
acc.push({ ...e, field: expandedFields[i] });
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, [] as (EntryValue & { field: string })[]);
|
||||
|
||||
return expandedEntries;
|
||||
};
|
||||
|
||||
export const mergeExpandedEntries = (entries: (EntryValue & { field: string })[]) => {
|
||||
// merge the search results by slug and only keep data that matched the search
|
||||
const fields = entries.map(f => f.field);
|
||||
const arrayPaths: Record<string, Set<string>> = {};
|
||||
|
||||
const merged = entries.reduce((acc, e) => {
|
||||
if (!acc[e.slug]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { field, ...rest } = e;
|
||||
acc[e.slug] = rest;
|
||||
arrayPaths[e.slug] = Set();
|
||||
}
|
||||
|
||||
const nestedFields = e.field.split('.');
|
||||
let value = acc[e.slug].data;
|
||||
for (let i = 0; i < nestedFields.length; i++) {
|
||||
value = value[nestedFields[i]];
|
||||
if (Array.isArray(value)) {
|
||||
const path = nestedFields.slice(0, i + 1).join('.');
|
||||
arrayPaths[e.slug] = arrayPaths[e.slug].add(path);
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, EntryValue>);
|
||||
|
||||
// this keeps the search score sorting order designated by the order in entries
|
||||
// and filters non matching items
|
||||
Object.keys(merged).forEach(slug => {
|
||||
const data = merged[slug].data;
|
||||
for (const path of arrayPaths[slug].toArray()) {
|
||||
const array = get(data, path) as unknown[];
|
||||
const filtered = array.filter((_, index) => {
|
||||
return fields.some(f => `${f}.`.startsWith(`${path}.${index}.`));
|
||||
});
|
||||
filtered.sort((a, b) => {
|
||||
const indexOfA = array.indexOf(a);
|
||||
const indexOfB = array.indexOf(b);
|
||||
const pathOfA = `${path}.${indexOfA}.`;
|
||||
const pathOfB = `${path}.${indexOfB}.`;
|
||||
|
||||
const matchingFieldIndexA = fields.findIndex(f => `${f}.`.startsWith(pathOfA));
|
||||
const matchingFieldIndexB = fields.findIndex(f => `${f}.`.startsWith(pathOfB));
|
||||
|
||||
return matchingFieldIndexA - matchingFieldIndexB;
|
||||
});
|
||||
|
||||
set(data, path, filtered);
|
||||
}
|
||||
});
|
||||
|
||||
return Object.values(merged);
|
||||
};
|
||||
|
||||
const sortByScore = (a: fuzzy.FilterResult<EntryValue>, b: fuzzy.FilterResult<EntryValue>) => {
|
||||
if (a.score > b.score) return -1;
|
||||
if (a.score < b.score) return 1;
|
||||
@ -497,13 +576,35 @@ export class Backend {
|
||||
return { entries: hits };
|
||||
}
|
||||
|
||||
async query(collection: Collection, searchFields: string[], searchTerm: string) {
|
||||
const entries = await this.listAllEntries(collection);
|
||||
const hits = fuzzy
|
||||
.filter(searchTerm, entries, { extract: extractSearchFields(searchFields) })
|
||||
async query(
|
||||
collection: Collection,
|
||||
searchFields: string[],
|
||||
searchTerm: string,
|
||||
file?: string,
|
||||
limit?: number,
|
||||
) {
|
||||
let entries = await this.listAllEntries(collection);
|
||||
if (file) {
|
||||
entries = entries.filter(e => e.slug === file);
|
||||
}
|
||||
|
||||
const expandedEntries = expandSearchEntries(entries, searchFields);
|
||||
|
||||
let hits = fuzzy
|
||||
.filter(searchTerm, expandedEntries, {
|
||||
extract: entry => {
|
||||
return getEntryField(entry.field, entry);
|
||||
},
|
||||
})
|
||||
.sort(sortByScore)
|
||||
.map(f => f.original);
|
||||
return { query: searchTerm, hits };
|
||||
|
||||
if (limit !== undefined && limit > 0) {
|
||||
hits = hits.slice(0, limit);
|
||||
}
|
||||
|
||||
const merged = mergeExpandedEntries(hits);
|
||||
return { query: searchTerm, hits: merged };
|
||||
}
|
||||
|
||||
traverseCursor(cursor: Cursor, action: string) {
|
||||
|
@ -18,6 +18,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"immutable": "^3.7.6",
|
||||
"lodash": "^4.17.11",
|
||||
"moment": "^2.24.0"
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import {
|
||||
compileStringTemplate,
|
||||
parseDateFromEntry,
|
||||
extractTemplateVars,
|
||||
expandPath,
|
||||
} from '../stringTemplate';
|
||||
describe('stringTemplate', () => {
|
||||
describe('keyToPathArray', () => {
|
||||
@ -107,4 +108,66 @@ describe('stringTemplate', () => {
|
||||
).toBe('SLUG');
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandPath', () => {
|
||||
it('should expand wildcard paths', () => {
|
||||
const data = {
|
||||
categories: [
|
||||
{
|
||||
name: 'category 1',
|
||||
},
|
||||
{
|
||||
name: 'category 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(expandPath({ data, path: 'categories.*.name' })).toEqual([
|
||||
'categories.0.name',
|
||||
'categories.1.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle wildcard at the end of the path', () => {
|
||||
const data = {
|
||||
nested: {
|
||||
otherNested: {
|
||||
list: [
|
||||
{
|
||||
title: 'title 1',
|
||||
nestedList: [{ description: 'description 1' }, { description: 'description 2' }],
|
||||
},
|
||||
{
|
||||
title: 'title 2',
|
||||
nestedList: [{ description: 'description 2' }, { description: 'description 2' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(expandPath({ data, path: 'nested.otherNested.list.*.nestedList.*' })).toEqual([
|
||||
'nested.otherNested.list.0.nestedList.0',
|
||||
'nested.otherNested.list.0.nestedList.1',
|
||||
'nested.otherNested.list.1.nestedList.0',
|
||||
'nested.otherNested.list.1.nestedList.1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non wildcard index', () => {
|
||||
const data = {
|
||||
categories: [
|
||||
{
|
||||
name: 'category 1',
|
||||
},
|
||||
{
|
||||
name: 'category 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
const path = 'categories.0.name';
|
||||
|
||||
expect(expandPath({ data, path })).toEqual(['categories.0.name']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import moment from 'moment';
|
||||
import { Map } from 'immutable';
|
||||
import { basename, extname } from 'path';
|
||||
import { get, trimEnd } from 'lodash';
|
||||
|
||||
const FIELD_PREFIX = 'fields.';
|
||||
const templateContentPattern = '[^}{]+';
|
||||
@ -60,6 +61,41 @@ export const keyToPathArray = (key?: string) => {
|
||||
return parts;
|
||||
};
|
||||
|
||||
export const expandPath = ({
|
||||
data,
|
||||
path,
|
||||
paths = [],
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
path: string;
|
||||
paths?: string[];
|
||||
}) => {
|
||||
if (path.endsWith('.*')) {
|
||||
path = path + '.';
|
||||
}
|
||||
|
||||
const sep = '.*.';
|
||||
const parts = path.split(sep);
|
||||
if (parts.length === 1) {
|
||||
paths.push(path);
|
||||
} else {
|
||||
const partialPath = parts[0];
|
||||
const value = get(data, partialPath);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((_, index) => {
|
||||
expandPath({
|
||||
data,
|
||||
path: trimEnd(`${partialPath}.${index}.${parts.slice(1).join(sep)}`, '.'),
|
||||
paths,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
// Allow `fields.` prefix in placeholder to override built in replacements
|
||||
// like "slug" and "year" with values from fields of the same name.
|
||||
function getExplicitFieldReplacement(key: string, data: Map<string, unknown>) {
|
||||
|
@ -22,7 +22,8 @@
|
||||
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward"
|
||||
},
|
||||
"dependencies": {
|
||||
"react-select": "^2.4.2"
|
||||
"react-select": "^2.4.2",
|
||||
"react-window": "^1.8.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/core": "^10.0.9",
|
||||
|
@ -2,10 +2,32 @@ import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { Async as AsyncSelect } from 'react-select';
|
||||
import { find, isEmpty, last, debounce, get, trimEnd } from 'lodash';
|
||||
import { find, isEmpty, last, debounce, get } from 'lodash';
|
||||
import { List, Map, fromJS } from 'immutable';
|
||||
import { reactSelectStyles } from 'netlify-cms-ui-default';
|
||||
import { stringTemplate } from 'netlify-cms-lib-widgets';
|
||||
import { FixedSizeList } from 'react-window';
|
||||
|
||||
const Option = ({ index, style, data }) => <div style={style}>{data.options[index]}</div>;
|
||||
|
||||
const MenuList = props => {
|
||||
if (props.isLoading || props.options.length <= 0) {
|
||||
return props.children;
|
||||
}
|
||||
const rows = props.children;
|
||||
return (
|
||||
<FixedSizeList
|
||||
style={{ width: '100%' }}
|
||||
width={300}
|
||||
height={300}
|
||||
itemCount={rows.length}
|
||||
itemSize={30}
|
||||
itemData={{ options: rows }}
|
||||
>
|
||||
{Option}
|
||||
</FixedSizeList>
|
||||
);
|
||||
};
|
||||
|
||||
function optionToString(option) {
|
||||
return option && option.value ? option.value : '';
|
||||
@ -35,33 +57,6 @@ function getSelectedValue({ value, options, isMultiple }) {
|
||||
}
|
||||
}
|
||||
|
||||
export const expandPath = ({ data, path, paths = [] }) => {
|
||||
if (path.endsWith('.*')) {
|
||||
path = path + '.';
|
||||
}
|
||||
|
||||
const sep = '.*.';
|
||||
const parts = path.split(sep);
|
||||
if (parts.length === 1) {
|
||||
paths.push(path);
|
||||
} else {
|
||||
const partialPath = parts[0];
|
||||
const value = get(data, partialPath);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v, index) => {
|
||||
expandPath({
|
||||
data,
|
||||
path: trimEnd(`${partialPath}.${index}.${parts.slice(1).join(sep)}`, '.'),
|
||||
paths,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
export default class RelationControl extends React.Component {
|
||||
didInitialSearch = false;
|
||||
|
||||
@ -156,14 +151,13 @@ export default class RelationControl extends React.Component {
|
||||
const { field } = this.props;
|
||||
const valueField = field.get('valueField');
|
||||
const displayField = field.get('displayFields') || List([field.get('valueField')]);
|
||||
|
||||
const options = hits.reduce((acc, hit) => {
|
||||
const valuesPaths = expandPath({ data: hit.data, path: valueField });
|
||||
const valuesPaths = stringTemplate.expandPath({ data: hit.data, path: valueField });
|
||||
for (let i = 0; i < valuesPaths.length; i++) {
|
||||
const label = displayField
|
||||
.toJS()
|
||||
.map(key => {
|
||||
const displayPaths = expandPath({ data: hit.data, path: key });
|
||||
const displayPaths = stringTemplate.expandPath({ data: hit.data, path: key });
|
||||
return this.parseNestedFields(hit, displayPaths[i] || displayPaths[0]);
|
||||
})
|
||||
.join(' ');
|
||||
@ -178,31 +172,23 @@ export default class RelationControl extends React.Component {
|
||||
};
|
||||
|
||||
loadOptions = debounce((term, callback) => {
|
||||
const { field, query, forID } = this.props;
|
||||
const { field, query, forID, value } = this.props;
|
||||
const collection = field.get('collection');
|
||||
const searchFields = field.get('searchFields');
|
||||
const optionsLength = field.get('optionsLength') || 20;
|
||||
const searchFieldsArray = List.isList(searchFields) ? searchFields.toJS() : [searchFields];
|
||||
let searchFieldsArray = List.isList(searchFields) ? searchFields.toJS() : [searchFields];
|
||||
const file = field.get('file');
|
||||
|
||||
const queryPromise = file
|
||||
? query(forID, collection, ['slug'], file)
|
||||
: query(forID, collection, searchFieldsArray, term);
|
||||
|
||||
queryPromise.then(({ payload }) => {
|
||||
let options =
|
||||
payload.response && payload.response.hits
|
||||
? this.parseHitOptions(payload.response.hits)
|
||||
: [];
|
||||
|
||||
if (!this.allOptions && !term) {
|
||||
this.allOptions = options;
|
||||
}
|
||||
|
||||
if (!term) {
|
||||
options = options.slice(0, optionsLength);
|
||||
}
|
||||
// if the field has a previous value perform the initial search based on the value field
|
||||
// this is needed since search results are limited to optionsLength
|
||||
if (!this.didInitialSearch && value && !term) {
|
||||
searchFieldsArray = [field.get('valueField')];
|
||||
term = value;
|
||||
}
|
||||
|
||||
query(forID, collection, searchFieldsArray, term, file, optionsLength).then(({ payload }) => {
|
||||
const hits = payload.response?.hits || [];
|
||||
const options = this.parseHitOptions(hits);
|
||||
callback(options);
|
||||
});
|
||||
}, 500);
|
||||
@ -221,7 +207,7 @@ export default class RelationControl extends React.Component {
|
||||
const isClearable = !field.get('required', true) || isMultiple;
|
||||
|
||||
const hits = queryHits.get(forID, []);
|
||||
const options = this.allOptions || this.parseHitOptions(hits);
|
||||
const options = this.parseHitOptions(hits);
|
||||
const selectedValue = getSelectedValue({
|
||||
options,
|
||||
value,
|
||||
@ -230,8 +216,10 @@ export default class RelationControl extends React.Component {
|
||||
|
||||
return (
|
||||
<AsyncSelect
|
||||
components={{ MenuList }}
|
||||
value={selectedValue}
|
||||
inputId={forID}
|
||||
cacheOptions
|
||||
defaultOptions
|
||||
loadOptions={this.loadOptions}
|
||||
onChange={this.handleChange}
|
||||
|
@ -3,7 +3,13 @@ import { fromJS, Map } from 'immutable';
|
||||
import { last } from 'lodash';
|
||||
import { render, fireEvent, wait } from '@testing-library/react';
|
||||
import { NetlifyCmsWidgetRelation } from '../';
|
||||
import { expandPath } from '../RelationControl';
|
||||
|
||||
jest.mock('react-window', () => {
|
||||
const FixedSizeList = props => props.itemData.options;
|
||||
return {
|
||||
FixedSizeList,
|
||||
};
|
||||
});
|
||||
|
||||
const RelationControl = NetlifyCmsWidgetRelation.controlComponent;
|
||||
|
||||
@ -128,36 +134,51 @@ class RelationController extends React.Component {
|
||||
queryHits: Map(),
|
||||
};
|
||||
|
||||
mounted = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.mounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.mounted = false;
|
||||
}
|
||||
|
||||
handleOnChange = jest.fn(value => {
|
||||
this.setState({ ...this.state, value });
|
||||
});
|
||||
|
||||
setQueryHits = jest.fn(hits => {
|
||||
const queryHits = Map().set('relation-field', hits);
|
||||
this.setState({ ...this.state, queryHits });
|
||||
if (this.mounted) {
|
||||
const queryHits = Map().set('relation-field', hits);
|
||||
this.setState({ ...this.state, queryHits });
|
||||
}
|
||||
});
|
||||
|
||||
query = jest.fn((...args) => {
|
||||
const queryHits = generateHits(25);
|
||||
|
||||
if (args[1] === 'numbers_collection') {
|
||||
return Promise.resolve({ payload: { response: { hits: numberFieldsHits } } });
|
||||
} else if (last(args) === 'nested_file') {
|
||||
return Promise.resolve({ payload: { response: { hits: nestedFileCollectionHits } } });
|
||||
} else if (last(args) === 'simple_file') {
|
||||
return Promise.resolve({ payload: { response: { hits: simpleFileCollectionHits } } });
|
||||
} else if (last(args) === 'YAML') {
|
||||
return Promise.resolve({ payload: { response: { hits: [last(queryHits)] } } });
|
||||
} else if (last(args) === 'Nested') {
|
||||
return Promise.resolve({
|
||||
payload: { response: { hits: [queryHits[queryHits.length - 2]] } },
|
||||
});
|
||||
} else if (last(args) === 'Deeply nested') {
|
||||
return Promise.resolve({
|
||||
payload: { response: { hits: [queryHits[queryHits.length - 3]] } },
|
||||
});
|
||||
const [, collection, , term, file, optionsLength] = args;
|
||||
let hits = queryHits;
|
||||
if (collection === 'numbers_collection') {
|
||||
hits = numberFieldsHits;
|
||||
} else if (file === 'nested_file') {
|
||||
hits = nestedFileCollectionHits;
|
||||
} else if (file === 'simple_file') {
|
||||
hits = simpleFileCollectionHits;
|
||||
} else if (term === 'YAML') {
|
||||
hits = [last(queryHits)];
|
||||
} else if (term === 'Nested') {
|
||||
hits = [queryHits[queryHits.length - 2]];
|
||||
} else if (term === 'Deeply nested') {
|
||||
hits = [queryHits[queryHits.length - 3]];
|
||||
}
|
||||
return Promise.resolve({ payload: { response: { hits: queryHits } } });
|
||||
|
||||
hits = hits.slice(0, optionsLength);
|
||||
|
||||
this.setQueryHits(hits);
|
||||
|
||||
return Promise.resolve({ payload: { response: { hits } } });
|
||||
});
|
||||
|
||||
render() {
|
||||
@ -208,68 +229,6 @@ function setup({ field, value }) {
|
||||
};
|
||||
}
|
||||
|
||||
describe('expandPath', () => {
|
||||
it('should expand wildcard paths', () => {
|
||||
const data = {
|
||||
categories: [
|
||||
{
|
||||
name: 'category 1',
|
||||
},
|
||||
{
|
||||
name: 'category 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(expandPath({ data, path: 'categories.*.name' })).toEqual([
|
||||
'categories.0.name',
|
||||
'categories.1.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle wildcard at the end of the path', () => {
|
||||
const data = {
|
||||
nested: {
|
||||
otherNested: {
|
||||
list: [
|
||||
{
|
||||
title: 'title 1',
|
||||
nestedList: [{ description: 'description 1' }, { description: 'description 2' }],
|
||||
},
|
||||
{
|
||||
title: 'title 2',
|
||||
nestedList: [{ description: 'description 2' }, { description: 'description 2' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(expandPath({ data, path: 'nested.otherNested.list.*.nestedList.*' })).toEqual([
|
||||
'nested.otherNested.list.0.nestedList.0',
|
||||
'nested.otherNested.list.0.nestedList.1',
|
||||
'nested.otherNested.list.1.nestedList.0',
|
||||
'nested.otherNested.list.1.nestedList.1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non wildcard index', () => {
|
||||
const data = {
|
||||
categories: [
|
||||
{
|
||||
name: 'category 1',
|
||||
},
|
||||
{
|
||||
name: 'category 2',
|
||||
},
|
||||
],
|
||||
};
|
||||
const path = 'categories.0.name';
|
||||
|
||||
expect(expandPath({ data, path })).toEqual(['categories.0.name']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Relation widget', () => {
|
||||
it('should list the first 20 option hits on initial load', async () => {
|
||||
const field = fromJS(fieldConfig);
|
||||
|
Loading…
x
Reference in New Issue
Block a user