From 2f435f875bc139af345080eb8cca6146d27c10f6 Mon Sep 17 00:00:00 2001 From: Derek Nguyen Date: Tue, 19 May 2020 16:17:57 +0900 Subject: [PATCH] feat(widget-relation): target file collections (#3754) --- .../src/RelationControl.js | 66 ++++++--- .../src/__tests__/relation.spec.js | 130 +++++++++++++++++- website/content/docs/widgets/relation.md | 3 +- 3 files changed, 181 insertions(+), 18 deletions(-) diff --git a/packages/netlify-cms-widget-relation/src/RelationControl.js b/packages/netlify-cms-widget-relation/src/RelationControl.js index 3d7ab4b9..2d1024ff 100644 --- a/packages/netlify-cms-widget-relation/src/RelationControl.js +++ b/packages/netlify-cms-widget-relation/src/RelationControl.js @@ -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) diff --git a/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js b/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js index 5919dc4d..bb6ee06c 100644 --- a/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js +++ b/packages/netlify-cms-widget-relation/src/__tests__/relation.spec.js @@ -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(); + }); + }); + }); }); diff --git a/website/content/docs/widgets/relation.md b/website/content/docs/widgets/relation.md index b1aba5dd..45b2b5f1 100644 --- a/website/content/docs/widgets/relation.md +++ b/website/content/docs/widgets/relation.md @@ -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`