feat(widget-relation): target file collections (#3754)

This commit is contained in:
Derek Nguyen 2020-05-19 16:17:57 +09:00 committed by GitHub
parent 5f99a9132b
commit 2f435f875b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 181 additions and 18 deletions

View File

@ -2,7 +2,7 @@ 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 } from 'lodash';
import { find, isEmpty, last, debounce, get, trimEnd } from 'lodash';
import { List, Map, fromJS } from 'immutable';
import { reactSelectStyles } from 'netlify-cms-ui-default';
import { stringTemplate } from 'netlify-cms-lib-widgets';
@ -35,6 +35,33 @@ 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;
@ -128,24 +155,26 @@ export default class RelationControl extends React.Component {
parseHitOptions = hits => {
const { field } = this.props;
const valueField = field.get('valueField');
const displayField = field.get('displayFields') || field.get('valueField');
const displayField = field.get('displayFields') || List(field.get('valueField'));
return hits.map(hit => {
let labelReturn;
if (List.isList(displayField)) {
labelReturn = displayField
const options = hits.reduce((acc, hit) => {
const valuesPaths = expandPath({ data: hit.data, path: valueField });
for (let i = 0; i < valuesPaths.length; i++) {
const label = displayField
.toJS()
.map(key => this.parseNestedFields(hit, key))
.map(key => {
const displayPaths = expandPath({ data: hit.data, path: key });
return this.parseNestedFields(hit, displayPaths[i] || displayPaths[0]);
})
.join(' ');
} else {
labelReturn = this.parseNestedFields(hit, displayField);
const value = this.parseNestedFields(hit, valuesPaths[i]);
acc.push({ data: hit.data, value, label });
}
return {
data: hit.data,
value: this.parseNestedFields(hit, valueField),
label: labelReturn,
};
});
return acc;
}, []);
return options;
};
loadOptions = debounce((term, callback) => {
@ -154,8 +183,13 @@ export default class RelationControl extends React.Component {
const searchFields = field.get('searchFields');
const optionsLength = field.get('optionsLength') || 20;
const searchFieldsArray = List.isList(searchFields) ? searchFields.toJS() : [searchFields];
const file = field.get('file');
query(forID, collection, searchFieldsArray, term).then(({ payload }) => {
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)

View File

@ -3,6 +3,7 @@ import { fromJS, Map } from 'immutable';
import { last } from 'lodash';
import { render, fireEvent, wait } from '@testing-library/react';
import { NetlifyCmsWidgetRelation } from '../';
import { expandPath } from '../RelationControl';
const RelationControl = NetlifyCmsWidgetRelation.controlComponent;
@ -81,6 +82,27 @@ const generateHits = length => {
];
};
const simpleFileCollectionHits = [{ data: { categories: ['category 1', 'category 2'] } }];
const nestedFileCollectionHits = [
{
data: {
nested: {
categories: [
{
name: 'category 1',
id: 'cat1',
},
{
name: 'category 2',
id: 'cat2',
},
],
},
},
},
];
class RelationController extends React.Component {
state = {
value: this.props.value,
@ -98,7 +120,12 @@ class RelationController extends React.Component {
query = jest.fn((...args) => {
const queryHits = generateHits(25);
if (last(args) === 'YAML') {
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({
@ -160,6 +187,68 @@ 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);
@ -319,4 +408,43 @@ describe('Relation widget', () => {
});
});
});
describe('with file collection', () => {
const fileFieldConfig = {
name: 'categories',
collection: 'file',
file: 'simple_file',
valueField: 'categories.*',
displayFields: ['categories.*'],
};
it('should handle simple list', async () => {
const field = fromJS(fileFieldConfig);
const { getAllByText, input, getByText } = setup({ field });
fireEvent.keyDown(input, { key: 'ArrowDown' });
await wait(() => {
expect(getAllByText(/category/)).toHaveLength(2);
expect(getByText('category 1')).toBeInTheDocument();
expect(getByText('category 2')).toBeInTheDocument();
});
});
it('should handle nested list', async () => {
const field = fromJS({
...fileFieldConfig,
file: 'nested_file',
valueField: 'nested.categories.*.id',
displayFields: ['nested.categories.*.name'],
});
const { getAllByText, input, getByText } = setup({ field });
fireEvent.keyDown(input, { key: 'ArrowDown' });
await wait(() => {
expect(getAllByText(/category/)).toHaveLength(2);
expect(getByText('category 1')).toBeInTheDocument();
expect(getByText('category 2')).toBeInTheDocument();
});
});
});
});

View File

@ -10,8 +10,9 @@ The relation widget allows you to reference items from another collection. It pr
- **Data type:** data type of the value pulled from the related collection item
- **Options:**
- `collection`: (**required**) name of the collection being referenced (string)
- `valueField`: (**required**) name of the field from the referenced collection whose value will be stored for the relation. For nested fields, separate each subfield with a `.` (E.g. `name.first`).
- `valueField`: (**required**) name of the field from the referenced collection whose value will be stored for the relation. For nested fields, separate each subfield with a `.` (e.g. `name.first`). For list fields use index notation to target a list item (e.g `categories.0`) or a wildcard `*` to target all list items (e.g. `categories.*`).
- `searchFields`: (**required**) list of one or more names of fields in the referenced collection to search for the typed value. Syntax to reference nested fields is similar to that of *valueField*.
- `file`: allows referencing a specific file when the collection being referenced is a files collection (string)
- `displayFields`: list of one or more names of fields in the referenced collection that will render in the autocomplete menu of the control. Defaults to `valueField`. Syntax to reference nested fields is similar to that of *valueField*.
- `default`: accepts any widget data type; defaults to an empty string
- `multiple` : accepts a boolean, defaults to `false`