diff --git a/docs/Inputs.md b/docs/Inputs.md index 1978e5cef50..cbc0fbaef0d 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -555,6 +555,111 @@ The enclosed component may further filter results (that's the case, for instance ``` +## `` + +Use `` to edit an array of reference values, i.e. to let users choose a list of values (usually foreign keys) from another REST endpoint. + +`` fetches the related resources (using the `CRUD_GET_MANY` REST method) as well as possible resources (using the +`CRUD_GET_MATCHING` REST method) in the reference endpoint. + +For instance, if the post object has many tags, a post resource may look like: + +```js +{ + id: 1234, + tag_ids: [1, 23, 4] +} +``` + +Then `` would fetch a list of tag resources from these two calls: + +``` +http://myapi.com/tags?id=[1,23,4] +http://myapi.com/tags?page=1&perPage=25 +``` + +Once it receives the deduplicated reference resources, this component delegates rendering to a subcomponent, to which it passes the possible choices as the `choices` attribute. + +This means you can use `` with [``](#selectarrayinput), or with the component of your choice, provided it supports the `choices` attribute. + +The component expects a `source` and a `reference` attributes. For instance, to make the `tag_ids` for a `post` editable: + +```js +import { ReferenceArrayInput, SelectArrayInput } from 'admin-on-rest' + + + + +``` + +![SelectArrayInput](./img/select-array-input.gif) + +**Note**: You **must** add a `` for the reference resource - admin-on-rest needs it to fetch the reference data. You can omit the list prop in this reference if you want to hide it in the sidebar menu. + +```js + + + + +``` + +Set the `allowEmpty` prop when the empty value is allowed. + +```js +import { ReferenceArrayInput, SelectArrayInput } from 'admin-on-rest' + + + + +``` + +**Tip**: `allowEmpty` is set by default for all Input components children of the `` component + +You can tweak how this component fetches the possible values using the `perPage`, `sort`, and `filter` props. + +{% raw %} +```js +// by default, fetches only the first 25 values. You can extend this limit +// by setting the `perPage` prop. + + + + +// by default, orders the possible values by id desc. You can change this order +// by setting the `sort` prop (an object with `field` and `order` properties). + + + + +// you can filter the query used to populate the possible values. Use the +// `filter` prop for that. + + + +``` +{% endraw %} + +The enclosed component may further filter results (that's the case, for instance, for ``). `ReferenceArrayInput` passes a `setFilter` function as prop to its child component. It uses the value to create a filter for the query - by default `{ q: [searchText] }`. You can customize the mapping +`searchText => searchQuery` by setting a custom `filterToQuery` function prop: + +```js + ({ name: searchText })}> + + +``` + ## `` `` is the ideal component if you want to allow your users to edit some HTML contents. It @@ -649,12 +754,14 @@ const choices = [ ]; ``` -However, in some cases (e.g. inside a ``), you may not want the choice to be translated. In that case, set the `translateChoice` prop to false. +However, in some cases, you may not want the choice to be translated. In that case, set the `translateChoice` prop to false. ```jsx ``` +Note that `translateChoice` is set to false when `` is a child of ``. + Lastly, use the `options` attribute if you want to override any of Material UI's `` attributes: {% raw %} @@ -679,6 +786,85 @@ import { SelectInput, ReferenceInput } from 'admin-on-rest' If, instead of showing choices as a dropdown list, you prefer to display them as a list of radio buttons, try the [``](#radiobuttongroupinput). And if the list is too big, prefer the [``](#autocompleteinput). +## `` + +To let users choose several values in a list using a dropdown, use ``. It renders using [material-ui-chip-input](https://github.com/TeamWertarbyte/material-ui-chip-input). Set the `choices` attribute to determine the options (with `id`, `name` tuples): + +```js +import { SelectArrayInput } from 'admin-on-rest'; + + +``` + +![SelectArrayInput](./img/select-array-input.gif) + +You can also customize the properties to use for the option name and value, +thanks to the `optionText` and `optionValue` attributes. + +```js +const choices = [ + { _id: '1', name: 'Book', plural_name: 'Books' }, + { _id: '2', name: 'Video', plural_name: 'Videos' }, + { _id: '3', name: 'Audio', plural_name: 'Audios' }, +]; + +``` + +`optionText` also accepts a function, so you can shape the option text at will: + +```js +const choices = [ + { id: '1', name: 'Book', quantity: 23 }, + { id: '2', name: 'Video', quantity: 56 }, + { id: '3', name: 'Audio', quantity: 12 }, +]; +const optionRenderer = choice => `${choice.name} (${choice.quantity})`; + +``` + +The choices are translated by default, so you can use translation identifiers as choices: + +```js +const choices = [ + { id: 'books', name: 'myroot.category.books' }, + { id: 'sport', name: 'myroot.category.sport' }, +]; +``` + +However, in some cases, you may not want the choice to be translated. In that case, set the `translateChoice` prop to false. + +```js + +``` + +Note that `translateChoice` is set to false when `` is a child of ``. + +Lastly, use the `options` attribute if you want to override any of the `` attributes: + +{% raw %} +```js + +``` +{% endraw %} + +Refer to [the ChipInput documentation](https://github.com/TeamWertarbyte/material-ui-chip-input) for more details. + +**Tip**: If you want to populate the `choices` attribute with a list of related records, you should decorate `` with [``](#referencearrayinput), and leave the `choices` empty: + +```js +import { SelectArrayInput, ReferenceArrayInput } from 'admin-on-rest' + + + + +``` + ## `` `` is the most common input. It is used for texts, emails, URL or passwords. In translates to an HTML `` tag. diff --git a/docs/Reference.md b/docs/Reference.md index 09cabe9485f..3d64942d0b6 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -46,6 +46,7 @@ title: "Reference" * [``](./Inputs.html#numberinput) * [``](./List.html#pagination) * [``](./Inputs.html#radiobuttongroupinput) +* [``](./Inputs.html#referencearrayinput) * [``](./Fields.html#referencefield) * [``](./Inputs.html#referenceinput) * [``](./Fields.html#referencemanyfield) @@ -55,6 +56,7 @@ title: "Reference" * [``](./Fields.html#richtextfield) * [``](./Inputs.html#richtextinput) * `` +* [``](./Inputs.html#selectarrayinput) * [``](./Inputs.html#selectinput) * `` * `` diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 83ce43f9420..4526fb48fc8 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -254,12 +254,18 @@
  • <ReferenceInput>
  • +
  • + <ReferenceArrayInput> +
  • <RichTextInput>
  • <SelectInput>
  • +
  • + <SelectArrayInput> +
  • <TextInput>
  • diff --git a/docs/img/select-array-input.gif b/docs/img/select-array-input.gif new file mode 100644 index 00000000000..c1f22d63f5b Binary files /dev/null and b/docs/img/select-array-input.gif differ diff --git a/example/i18n/en.js b/example/i18n/en.js index d46dad6199a..81f0695e220 100644 --- a/example/i18n/en.js +++ b/example/i18n/en.js @@ -16,6 +16,7 @@ export const messages = { pictures: 'Related Pictures', published_at: 'Published at', teaser: 'Teaser', + tags: 'Tags', title: 'Title', views: 'Views', }, diff --git a/example/i18n/fr.js b/example/i18n/fr.js index 28bd4fe4190..c36d7538050 100644 --- a/example/i18n/fr.js +++ b/example/i18n/fr.js @@ -15,6 +15,7 @@ export const messages = { pictures: 'Photos associées', published_at: 'Publié le', teaser: 'Description', + tags: 'Catégories', title: 'Titre', views: 'Vues', }, diff --git a/example/posts.js b/example/posts.js index a24966c1f7a..5c450a7594d 100644 --- a/example/posts.js +++ b/example/posts.js @@ -21,8 +21,10 @@ import { NumberInput, ReferenceArrayField, ReferenceManyField, + ReferenceArrayInput, Responsive, RichTextField, + SelectArrayInput, SelectField, SelectInput, Show, @@ -139,7 +141,9 @@ export const PostEdit = ({ ...props }) => ( - + + + `${resource}@${source}`; + +/** + * An Input component for fields containing a list of references to another resource. + * Useful for 'hasMany' relationship. + * + * @example + * The post object has many tags, so the post resource looks like: + * { + * id: 1234, + * tag_ids: [ "1", "23", "4" ] + * } + * + * ReferenceArrayInput component fetches the current resources (using the + * `CRUD_GET_MANY` REST method) as well as possible resources (using the + * `CRUD_GET_MATCHING` REST method) in the reference endpoint. It then + * delegates rendering to a subcomponent, to which it passes the possible + * choices as the `choices` attribute. + * + * Use it with a selector component as child, like `` + * or . + * + * @example + * export const PostEdit = (props) => ( + * + * + * + * + * + * + * + * ); + * + * By default, restricts the possible values to 25. You can extend this limit + * by setting the `perPage` prop. + * + * @example + * + * + * + * + * By default, orders the possible values by id desc. You can change this order + * by setting the `sort` prop (an object with `field` and `order` properties). + * + * @example + * + * + * + * + * Also, you can filter the query used to populate the possible values. Use the + * `filter` prop for that. + * + * @example + * + * + * + * + * The enclosed component may filter results. ReferenceArrayInput passes a + * `setFilter` function as prop to its child component. It uses the value to + * create a filter for the query - by default { q: [searchText] }. You can + * customize the mapping searchText => searchQuery by setting a custom + * `filterToQuery` function prop: + * + * @example + * ({ name: searchText })}> + * + * + */ +export class ReferenceArrayInput extends Component { + constructor(props) { + super(props); + const { perPage, sort, filter } = props; + // stored as a property rather than state because we don't want redraw of async updates + this.params = { pagination: { page: 1, perPage }, sort, filter }; + this.debouncedSetFilter = debounce(this.setFilter.bind(this), 500); + } + + componentDidMount() { + this.fetchReferenceAndOptions(); + } + + componentWillReceiveProps(nextProps) { + if (this.props.record.id !== nextProps.record.id) { + this.fetchReferenceAndOptions(nextProps); + } + } + + setFilter = (filter) => { + if (filter !== this.params.filter) { + this.params.filter = this.props.filterToQuery(filter); + this.fetchReferenceAndOptions(); + } + } + + setPagination = (pagination) => { + if (pagination !== this.param.pagination) { + this.param.pagination = pagination; + this.fetchReferenceAndOptions(); + } + } + + setSort = (sort) => { + if (sort !== this.params.sort) { + this.params.sort = sort; + this.fetchReferenceAndOptions(); + } + } + + fetchReferenceAndOptions({ input, reference, source, resource } = this.props) { + const { pagination, sort, filter } = this.params; + const ids = input.value; + if (ids) { + if (!Array.isArray(ids)) { + throw Error('The value of ReferenceArrayInput should be an array'); + } + this.props.crudGetMany(reference, ids); + } + this.props.crudGetMatching(reference, referenceSource(resource, source), pagination, sort, filter); + } + + render() { + const { input, resource, label, source, referenceRecords, allowEmpty, matchingReferences, basePath, onChange, children, meta } = this.props; + + if (React.Children.count(children) !== 1) { + throw new Error(' only accepts a single child (like )'); + } + + if (!(referenceRecords && referenceRecords.length > 0) && !allowEmpty) { + return ; + } + + return React.cloneElement(children, { + allowEmpty, + input, + label: typeof label === 'undefined' ? `resources.${resource}.fields.${source}` : label, + resource, + meta, + source, + choices: matchingReferences, + basePath, + onChange, + setFilter: this.debouncedSetFilter, + setPagination: this.setPagination, + setSort: this.setSort, + translateChoice: false, + }); + } +} + +ReferenceArrayInput.propTypes = { + addField: PropTypes.bool.isRequired, + allowEmpty: PropTypes.bool.isRequired, + basePath: PropTypes.string, + children: PropTypes.element.isRequired, + crudGetMatching: PropTypes.func.isRequired, + crudGetMany: PropTypes.func.isRequired, + filter: PropTypes.object, + filterToQuery: PropTypes.func.isRequired, + input: PropTypes.object.isRequired, + label: PropTypes.string, + matchingReferences: PropTypes.array, + meta: PropTypes.object, + onChange: PropTypes.func, + perPage: PropTypes.number, + reference: PropTypes.string.isRequired, + referenceRecords: PropTypes.array, + resource: PropTypes.string.isRequired, + sort: PropTypes.shape({ + field: PropTypes.string, + order: PropTypes.oneOf(['ASC', 'DESC']), + }), + source: PropTypes.string, +}; + +ReferenceArrayInput.defaultProps = { + allowEmpty: false, + filter: {}, + filterToQuery: searchText => ({ q: searchText }), + matchingReferences: [], + perPage: 25, + sort: { field: 'id', order: 'DESC' }, + referenceRecords: [], +}; + +function mapStateToProps(state, props) { + const referenceIds = props.input.value || []; + const data = state.admin[props.reference].data; + return { + referenceRecords: referenceIds.reduce((references, referenceId) => { + if (data[referenceId]) { + references.push(data[referenceId]); + } + return references; + }, []), + matchingReferences: getPossibleReferences( + state, + referenceSource(props.resource, props.source), + props.reference, + referenceIds, + ), + }; +} + +const ConnectedReferenceInput = connect(mapStateToProps, { + crudGetMany: crudGetManyAction, + crudGetMatching: crudGetMatchingAction, +})(ReferenceArrayInput); + +ConnectedReferenceInput.defaultProps = { + addField: true, +}; + +export default ConnectedReferenceInput; diff --git a/src/mui/input/ReferenceArrayInput.spec.js b/src/mui/input/ReferenceArrayInput.spec.js new file mode 100644 index 00000000000..3c86086bfcf --- /dev/null +++ b/src/mui/input/ReferenceArrayInput.spec.js @@ -0,0 +1,199 @@ +import React from 'react'; +import assert from 'assert'; +import { shallow } from 'enzyme'; +import sinon from 'sinon'; +import { ReferenceArrayInput } from './ReferenceArrayInput'; + +describe('', () => { + const defaultProps = { + crudGetMatching: () => true, + crudGetMany: () => true, + input: {}, + reference: 'tags', + resource: 'posts', + source: 'tag_ids', + }; + const MyComponent = () => ; + + it('should not render anything if there is no referenceRecord and allowEmpty is false', () => { + const wrapper = shallow(( + + + + )); + const MyComponentElement = wrapper.find('MyComponent'); + assert.equal(MyComponentElement.length, 0); + }); + + it('should not render enclosed component if allowEmpty is true', () => { + const wrapper = shallow(( + + + + )); + const MyComponentElement = wrapper.find('MyComponent'); + assert.equal(MyComponentElement.length, 1); + }); + + it('should call crudGetMatching on mount with default fetch values', () => { + const crudGetMatching = sinon.spy(); + shallow(( + + + + ), { lifecycleExperimental: true }); + assert.deepEqual(crudGetMatching.args[0], [ + 'tags', + 'posts@tag_ids', + { + page: 1, + perPage: 25, + }, + { + field: 'id', + order: 'DESC', + }, + {}, + ]); + }); + + it('should allow to customize crudGetMatching arguments with perPage, sort, and filter props', () => { + const crudGetMatching = sinon.spy(); + shallow(( + + + + ), { lifecycleExperimental: true }); + assert.deepEqual(crudGetMatching.args[0], [ + 'tags', + 'posts@tag_ids', + { + page: 1, + perPage: 5, + }, + { + field: 'foo', + order: 'ASC', + }, + { + q: 'foo', + }, + ]); + }); + + it('should call crudGetMatching when setFilter is called', () => { + const crudGetMatching = sinon.spy(); + const wrapper = shallow(( + + + + ), { lifecycleExperimental: true }); + wrapper.instance().setFilter('bar'); + assert.deepEqual(crudGetMatching.args[1], [ + 'tags', + 'posts@tag_ids', + { + page: 1, + perPage: 25, + }, + { + field: 'id', + order: 'DESC', + }, + { + q: 'bar', + }, + ]); + }); + + it('should use custom filterToQuery function prop', () => { + const crudGetMatching = sinon.spy(); + const wrapper = shallow(( + ({ foo: searchText })} + > + + + ), { lifecycleExperimental: true }); + wrapper.instance().setFilter('bar'); + assert.deepEqual(crudGetMatching.args[1], [ + 'tags', + 'posts@tag_ids', + { + page: 1, + perPage: 25, + }, + { + field: 'id', + order: 'DESC', + }, + { + foo: 'bar', + }, + ]); + }); + + it('should call crudGetMany on mount if value is set', () => { + const crudGetMany = sinon.spy(); + shallow(( + + + + ), { lifecycleExperimental: true }); + assert.deepEqual(crudGetMany.args[0], [ + 'tags', + [5, 6], + ]); + }); + + it('should pass onChange down to child component', () => { + const onChange = sinon.spy(); + const wrapper = shallow(( + + + + )); + wrapper.find('MyComponent').simulate('change', 'foo'); + assert.deepEqual(onChange.args[0], [ + 'foo', + ]); + }); + + it('should pass meta down to child component', () => { + const wrapper = shallow( + + + , + ); + + const myComponent = wrapper.find('MyComponent'); + assert.notEqual(myComponent.prop('meta', undefined)); + }); +}); diff --git a/src/mui/input/ReferenceInput.js b/src/mui/input/ReferenceInput.js index 166499709b7..c0d5031efdd 100644 --- a/src/mui/input/ReferenceInput.js +++ b/src/mui/input/ReferenceInput.js @@ -206,7 +206,7 @@ function mapStateToProps(state, props) { const referenceId = props.input.value; return { referenceRecord: state.admin[props.reference].data[referenceId], - matchingReferences: getPossibleReferences(state, referenceSource(props.resource, props.source), props.reference, referenceId), + matchingReferences: getPossibleReferences(state, referenceSource(props.resource, props.source), props.reference, [referenceId]), }; } diff --git a/src/mui/input/ReferenceInput.spec.js b/src/mui/input/ReferenceInput.spec.js index f53db79bf73..23606d33837 100644 --- a/src/mui/input/ReferenceInput.spec.js +++ b/src/mui/input/ReferenceInput.spec.js @@ -3,7 +3,6 @@ import assert from 'assert'; import { shallow } from 'enzyme'; import sinon from 'sinon'; import { ReferenceInput } from './ReferenceInput'; -import { SelectInput } from './SelectInput'; describe('', () => { const defaultProps = { diff --git a/src/mui/input/SelectArrayInput.js b/src/mui/input/SelectArrayInput.js new file mode 100644 index 00000000000..0bbcddf5176 --- /dev/null +++ b/src/mui/input/SelectArrayInput.js @@ -0,0 +1,205 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import ChipInput from 'material-ui-chip-input'; + +import translate from '../../i18n/translate'; +import FieldTitle from '../../util/FieldTitle'; + +const dataSourceConfig = { text: 'text', value: 'value' }; + +/** + * An Input component for an array + * + * @example + * + * + * Pass possible options as an array of objects in the 'choices' attribute. + * + * By default, the options are built from: + * - the 'id' property as the option value, + * - the 'name' property an the option text + * @example + * const choices = [ + * { id: '1', name: 'Book' }, + * { id: '2', name: 'Video' }, + * { id: '3', name: 'Audio' }, + * ]; + * + * + * You can also customize the properties to use for the option name and value, + * thanks to the 'optionText' and 'optionValue' attributes. + * @example + * const choices = [ + * { _id: '1', name: 'Book', plural_name: 'Books' }, + * { _id: '2', name: 'Video', plural_name: 'Videos' }, + * { _id: '3', name: 'Audio', plural_name: 'Audios' }, + * ]; + * + * + * `optionText` also accepts a function, so you can shape the option text at will: + * @example + * const choices = [ + * { id: '1', name: 'Book', quantity: 23 }, + * { id: '2', name: 'Video', quantity: 56 }, + * { id: '3', name: 'Audio', quantity: 12 }, + * ]; + * const optionRenderer = choice => `${choice.name} (${choice.quantity})`; + * + * + * The object passed as `options` props is passed to the material-ui-chip-input component + * @see https://github.com/TeamWertarbyte/material-ui-chip-input + */ +export class SelectArrayInput extends Component { + state = { + values: [], + }; + + componentWillMount = () => { + this.setState({ + values: this.getChoicesForValues(this.props.input.value || [], this.props.choices), + }); + } + + componentWillReceiveProps = (nextProps) => { + if ( + this.props.choices !== nextProps.choices || + this.props.input.value !== nextProps.input.value + ) { + this.setState({ + values: this.getChoicesForValues(nextProps.input.value || [], nextProps.choices), + }); + } + }; + + handleBlur = () => { + const extracted = this.extractIds(this.state.values); + this.props.onBlur(extracted); + this.props.input.onBlur(extracted); + }; + + handleFocus = () => { + const extracted = this.extractIds(this.state.values); + this.props.onFocus(extracted); + this.props.input.onFocus(extracted); + }; + + handleAdd = (newValue) => { + const values = [...this.state.values, newValue]; + this.setState({ values }); + this.handleChange(values); + }; + + handleDelete = (newValue) => { + const values = this.state.values.filter(v => (v.value !== newValue)); + this.setState({ values }); + this.handleChange(values); + }; + + handleChange = (eventOrValue) => { + const extracted = this.extractIds(eventOrValue); + this.props.onChange(extracted); + this.props.input.onChange(extracted); + }; + + extractIds = (eventOrValue) => { + const value = (eventOrValue.target && eventOrValue.target.value) ? eventOrValue.target.value : eventOrValue; + if (Array.isArray(value)) { + return value.map(o => o.value); + } + return [value]; + }; + + getChoicesForValues = (values, choices = []) => { + const { optionValue, optionText } = this.props; + if (!values || !Array.isArray(values)) { + throw Error('Value of SelectArrayInput should be an array'); + } + return values + .map(value => choices.find(c => c[optionValue] === value) || { [optionValue]: value, [optionText]: value }) + .map(this.formatChoice); + }; + + formatChoices = choices => choices.map(this.formatChoice); + + formatChoice = (choice) => { + const { optionText, optionValue, translateChoice, translate } = this.props; + const choiceText = typeof optionText === 'function' ? optionText(choice) : choice[optionText]; + return { + value: choice[optionValue], + text: translateChoice ? translate(choiceText, { _: choiceText }) : choiceText, + }; + } + + render() { + const { + elStyle, + input, + choices, + label, + meta: { touched, error }, + options, + optionText, + optionValue, + resource, + source, + setFilter, + translate, + translateChoice, + } = this.props; + + return ( + } + errorText={touched && error} + style={elStyle} + dataSource={this.formatChoices(choices)} + dataSourceConfig={dataSourceConfig} + openOnFocus + {...options} + /> + ); + } +} + +SelectArrayInput.propTypes = { + addField: PropTypes.bool.isRequired, + elStyle: PropTypes.object, + choices: PropTypes.arrayOf(PropTypes.object), + input: PropTypes.object, + label: PropTypes.string, + meta: PropTypes.object, + name: PropTypes.string, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + setFilter: PropTypes.func, + options: PropTypes.object, + optionText: PropTypes.string.isRequired, + optionValue: PropTypes.string.isRequired, + resource: PropTypes.string, + source: PropTypes.string, + translate: PropTypes.func.isRequired, + translateChoice: PropTypes.bool.isRequired, +}; + +SelectArrayInput.defaultProps = { + addField: true, + choices: [], + onBlur: () => true, + onChange: () => true, + onFocus: () => true, + options: {}, + optionText: 'name', + optionValue: 'id', + translateChoice: true, +}; + +export default translate(SelectArrayInput); diff --git a/src/mui/input/SelectArrayInput.spec.js b/src/mui/input/SelectArrayInput.spec.js new file mode 100644 index 00000000000..3b445e0459e --- /dev/null +++ b/src/mui/input/SelectArrayInput.spec.js @@ -0,0 +1,180 @@ +import React from 'react'; +import assert from 'assert'; +import { shallow } from 'enzyme'; +import { SelectArrayInput } from './SelectArrayInput'; + +describe('', () => { + const defaultProps = { + source: 'foo', + meta: {}, + input: {}, + translate: x => x, + }; + + it('should use a ChipInput', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput'); + assert.equal(ChipInputElement.length, 1); + }); + + it('should use the input parameter value as the initial input value', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('value'), [{ value: 1, text: 1 }, { value: 2, text: 2 }]); + }); + + it('should pass choices to the ChipInput as dataSource', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('dataSource'), [ + { value: 1, text: 'Book' }, + { value: 2, text: 'Video' }, + { value: 3, text: 'Audio' }, + ]); + }); + + it('should use the dataSource to set the initial input value', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('value'), [ + { value: 1, text: 'Book' }, + { value: 2, text: 'Video' }, + ]); + }); + + it('should update the value when the dataSource updates', () => { + const input = { value: [1, 2] }; + const wrapper = shallow(); + wrapper.setProps({ + ...defaultProps, + choices: [ + { id: 1, name: 'Book' }, + { id: 2, name: 'Video' }, + { id: 3, name: 'Audio' }, + ], + input, + }); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('value'), [ + { value: 1, text: 'Book' }, + { value: 2, text: 'Video' }, + ]); + }); + + it('should use optionValue as value identifier', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('dataSource'), [ + { value: 'B', text: 'Book' }, + ]); + }); + + it('should use optionText with a string value as text identifier', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('dataSource'), [ + { value: 'B', text: 'Book' }, + ]); + }); + + it('should use optionText with a function value as text identifier', () => { + const wrapper = shallow( choice.foobar} + choices={[ + { id: 'B', foobar: 'Book' }, + ]} + />); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('dataSource'), [ + { value: 'B', text: 'Book' }, + ]); + }); + + it('should translate the choices by default', () => { + const wrapper = shallow( `**${x}**`} + />); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('dataSource'), [ + { value: 1, text: '**Book**' }, + { value: 2, text: '**Video**' }, + { value: 3, text: '**Audio**' }, + ]); + }); + + it('should not translate the choices if translateChoice is false', () => { + const wrapper = shallow( `**${x}**`} + translateChoice={false} + />); + const ChipInputElement = wrapper.find('ChipInput').first(); + assert.deepEqual(ChipInputElement.prop('dataSource'), [ + { value: 1, text: 'Book' }, + { value: 2, text: 'Video' }, + { value: 3, text: 'Audio' }, + ]); + }); + + describe('error message', () => { + it('should not be displayed if field is pristine', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput'); + assert.equal(ChipInputElement.prop('errorText'), false); + }); + + it('should not be displayed if field has been touched but is valid', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput'); + assert.equal(ChipInputElement.prop('errorText'), false); + }); + + it('should be displayed if field has been touched and is invalid', () => { + const wrapper = shallow(); + const ChipInputElement = wrapper.find('ChipInput'); + assert.equal(ChipInputElement.prop('errorText'), 'Required field.'); + }); + }); +}); diff --git a/src/mui/input/index.js b/src/mui/input/index.js index 692bfdb3b41..c1cb8ca707d 100644 --- a/src/mui/input/index.js +++ b/src/mui/input/index.js @@ -9,6 +9,8 @@ export LongTextInput from './LongTextInput'; export NullableBooleanInput from './NullableBooleanInput'; export NumberInput from './NumberInput'; export RadioButtonGroupInput from './RadioButtonGroupInput'; +export ReferenceArrayInput from './ReferenceArrayInput'; export ReferenceInput from './ReferenceInput'; +export SelectArrayInput from './SelectArrayInput'; export SelectInput from './SelectInput'; export TextInput from './TextInput'; diff --git a/src/reducer/references/possibleValues.js b/src/reducer/references/possibleValues.js index 941b65e99a9..ca7b792b91b 100644 --- a/src/reducer/references/possibleValues.js +++ b/src/reducer/references/possibleValues.js @@ -14,14 +14,11 @@ export default (previousState = initialState, { type, payload, meta }) => { } }; -export const getPossibleReferences = (state, referenceSource, reference, selectedId) => { - if (!state.admin.references.possibleValues[referenceSource]) { - return typeof selectedId === 'undefined' || !state.admin[reference].data[selectedId] ? [] : [state.admin[reference].data[selectedId]]; - } - const possibleValues = state.admin.references.possibleValues[referenceSource]; - if (typeof selectedId !== 'undefined' && !possibleValues.includes(selectedId)) { - possibleValues.unshift(selectedId); - } +export const getPossibleReferences = (state, referenceSource, reference, selectedIds = []) => { + const possibleValues = state.admin.references.possibleValues[referenceSource] + ? Array.from(state.admin.references.possibleValues[referenceSource]) + : []; + selectedIds.forEach(id => possibleValues.includes(id) || possibleValues.unshift(id)); return possibleValues .map(id => state.admin[reference].data[id]) .filter(r => typeof r !== 'undefined');