From c7e0fe8492d09a3d151c608f50da844f421362ed Mon Sep 17 00:00:00 2001 From: Erez Rokah Date: Mon, 6 Jul 2020 14:05:01 +0300 Subject: [PATCH] fix: relation widget performance (#3975) --- .../src/__tests__/backend.spec.js | 246 +++++++++++++++++- .../netlify-cms-core/src/actions/search.ts | 29 ++- packages/netlify-cms-core/src/backend.ts | 141 ++++++++-- packages/netlify-cms-lib-widgets/package.json | 1 + .../src/__tests__/stringTemplate.spec.js | 63 +++++ .../src/stringTemplate.ts | 36 +++ .../netlify-cms-widget-relation/package.json | 3 +- .../src/RelationControl.js | 90 +++---- .../src/__tests__/relation.spec.js | 123 +++------ 9 files changed, 566 insertions(+), 166 deletions(-) diff --git a/packages/netlify-cms-core/src/__tests__/backend.spec.js b/packages/netlify-cms-core/src/__tests__/backend.spec.js index c49a09c3..35445848 100644 --- a/packages/netlify-cms-core/src/__tests__/backend.spec.js +++ b/packages/netlify-cms-core/src/__tests__/backend.spec.js @@ -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], + }, + }, + ]); + }); + }); }); diff --git a/packages/netlify-cms-core/src/actions/search.ts b/packages/netlify-cms-core/src/actions/search.ts index 4ecf7ff1..25db3eac 100644 --- a/packages/netlify-cms-core/src/actions/search.ts +++ b/packages/netlify-cms-core/src/actions/search.ts @@ -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, getState: () => State) => { + return async (dispatch: ThunkDispatch, 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)); + } }; } diff --git a/packages/netlify-cms-core/src/backend.ts b/packages/netlify-cms-core/src/backend.ts index ca21cdd2..00598c69 100644 --- a/packages/netlify-cms-core/src/backend.ts +++ b/packages/netlify-cms-core/src/backend.ts @@ -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> = {}; + + 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); + + // 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, b: fuzzy.FilterResult) => { 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) { diff --git a/packages/netlify-cms-lib-widgets/package.json b/packages/netlify-cms-lib-widgets/package.json index 24a5d756..e30c0ea5 100644 --- a/packages/netlify-cms-lib-widgets/package.json +++ b/packages/netlify-cms-lib-widgets/package.json @@ -18,6 +18,7 @@ }, "peerDependencies": { "immutable": "^3.7.6", + "lodash": "^4.17.11", "moment": "^2.24.0" } } diff --git a/packages/netlify-cms-lib-widgets/src/__tests__/stringTemplate.spec.js b/packages/netlify-cms-lib-widgets/src/__tests__/stringTemplate.spec.js index 52b42889..f96d09e1 100644 --- a/packages/netlify-cms-lib-widgets/src/__tests__/stringTemplate.spec.js +++ b/packages/netlify-cms-lib-widgets/src/__tests__/stringTemplate.spec.js @@ -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']); + }); + }); }); diff --git a/packages/netlify-cms-lib-widgets/src/stringTemplate.ts b/packages/netlify-cms-lib-widgets/src/stringTemplate.ts index d0dbbdf5..a6ba7e4e 100644 --- a/packages/netlify-cms-lib-widgets/src/stringTemplate.ts +++ b/packages/netlify-cms-lib-widgets/src/stringTemplate.ts @@ -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; + 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) { diff --git a/packages/netlify-cms-widget-relation/package.json b/packages/netlify-cms-widget-relation/package.json index a2a4995b..88af38f1 100644 --- a/packages/netlify-cms-widget-relation/package.json +++ b/packages/netlify-cms-widget-relation/package.json @@ -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", diff --git a/packages/netlify-cms-widget-relation/src/RelationControl.js b/packages/netlify-cms-widget-relation/src/RelationControl.js index 58a0fcd2..3f730497 100644 --- a/packages/netlify-cms-widget-relation/src/RelationControl.js +++ b/packages/netlify-cms-widget-relation/src/RelationControl.js @@ -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 }) =>
{data.options[index]}
; + +const MenuList = props => { + if (props.isLoading || props.options.length <= 0) { + return props.children; + } + const rows = props.children; + return ( + + {Option} + + ); +}; 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 ( { + 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);