fix: relation widget performance (#3975)

This commit is contained in:
Erez Rokah
2020-07-06 14:05:01 +03:00
committed by GitHub
parent 126b86cdc4
commit c7e0fe8492
9 changed files with 566 additions and 166 deletions

View File

@ -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",

View File

@ -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}

View File

@ -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);