feat(widget-relation): target file collections (#3754)
This commit is contained in:
parent
5f99a9132b
commit
2f435f875b
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { Async as AsyncSelect } from 'react-select';
|
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 { List, Map, fromJS } from 'immutable';
|
||||||
import { reactSelectStyles } from 'netlify-cms-ui-default';
|
import { reactSelectStyles } from 'netlify-cms-ui-default';
|
||||||
import { stringTemplate } from 'netlify-cms-lib-widgets';
|
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 {
|
export default class RelationControl extends React.Component {
|
||||||
didInitialSearch = false;
|
didInitialSearch = false;
|
||||||
|
|
||||||
@ -128,24 +155,26 @@ export default class RelationControl extends React.Component {
|
|||||||
parseHitOptions = hits => {
|
parseHitOptions = hits => {
|
||||||
const { field } = this.props;
|
const { field } = this.props;
|
||||||
const valueField = field.get('valueField');
|
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 => {
|
const options = hits.reduce((acc, hit) => {
|
||||||
let labelReturn;
|
const valuesPaths = expandPath({ data: hit.data, path: valueField });
|
||||||
if (List.isList(displayField)) {
|
for (let i = 0; i < valuesPaths.length; i++) {
|
||||||
labelReturn = displayField
|
const label = displayField
|
||||||
.toJS()
|
.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(' ');
|
.join(' ');
|
||||||
} else {
|
const value = this.parseNestedFields(hit, valuesPaths[i]);
|
||||||
labelReturn = this.parseNestedFields(hit, displayField);
|
acc.push({ data: hit.data, value, label });
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
data: hit.data,
|
return acc;
|
||||||
value: this.parseNestedFields(hit, valueField),
|
}, []);
|
||||||
label: labelReturn,
|
|
||||||
};
|
return options;
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
loadOptions = debounce((term, callback) => {
|
loadOptions = debounce((term, callback) => {
|
||||||
@ -154,8 +183,13 @@ export default class RelationControl extends React.Component {
|
|||||||
const searchFields = field.get('searchFields');
|
const searchFields = field.get('searchFields');
|
||||||
const optionsLength = field.get('optionsLength') || 20;
|
const optionsLength = field.get('optionsLength') || 20;
|
||||||
const searchFieldsArray = List.isList(searchFields) ? searchFields.toJS() : [searchFields];
|
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 =
|
let options =
|
||||||
payload.response && payload.response.hits
|
payload.response && payload.response.hits
|
||||||
? this.parseHitOptions(payload.response.hits)
|
? this.parseHitOptions(payload.response.hits)
|
||||||
|
@ -3,6 +3,7 @@ import { fromJS, Map } from 'immutable';
|
|||||||
import { last } from 'lodash';
|
import { last } from 'lodash';
|
||||||
import { render, fireEvent, wait } from '@testing-library/react';
|
import { render, fireEvent, wait } from '@testing-library/react';
|
||||||
import { NetlifyCmsWidgetRelation } from '../';
|
import { NetlifyCmsWidgetRelation } from '../';
|
||||||
|
import { expandPath } from '../RelationControl';
|
||||||
|
|
||||||
const RelationControl = NetlifyCmsWidgetRelation.controlComponent;
|
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 {
|
class RelationController extends React.Component {
|
||||||
state = {
|
state = {
|
||||||
value: this.props.value,
|
value: this.props.value,
|
||||||
@ -98,7 +120,12 @@ class RelationController extends React.Component {
|
|||||||
|
|
||||||
query = jest.fn((...args) => {
|
query = jest.fn((...args) => {
|
||||||
const queryHits = generateHits(25);
|
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)] } } });
|
return Promise.resolve({ payload: { response: { hits: [last(queryHits)] } } });
|
||||||
} else if (last(args) === 'Nested') {
|
} else if (last(args) === 'Nested') {
|
||||||
return Promise.resolve({
|
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', () => {
|
describe('Relation widget', () => {
|
||||||
it('should list the first 20 option hits on initial load', async () => {
|
it('should list the first 20 option hits on initial load', async () => {
|
||||||
const field = fromJS(fieldConfig);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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
|
- **Data type:** data type of the value pulled from the related collection item
|
||||||
- **Options:**
|
- **Options:**
|
||||||
- `collection`: (**required**) name of the collection being referenced (string)
|
- `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*.
|
- `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*.
|
- `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
|
- `default`: accepts any widget data type; defaults to an empty string
|
||||||
- `multiple` : accepts a boolean, defaults to `false`
|
- `multiple` : accepts a boolean, defaults to `false`
|
||||||
|
Loading…
x
Reference in New Issue
Block a user