import { Component, ReactNode, ComponentType } from 'react'; import { connect } from 'react-redux'; import debounce from 'lodash/debounce'; import compose from 'recompose/compose'; import { createSelector } from 'reselect'; import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; import difference from 'lodash/difference'; import { WrappedFieldInputProps } from 'redux-form'; import { crudGetMany as crudGetManyAction, crudGetMatching as crudGetMatchingAction, } from '../../actions/dataActions'; import { getPossibleReferences, getPossibleReferenceValues, getReferenceResource, } from '../../reducer'; import { getStatusForArrayInput as getDataStatus } from './referenceDataStatus'; import withTranslate from '../../i18n/translate'; import { Record, Sort, Translate, Pagination, Dispatch } from '../../types'; import { MatchingReferencesError } from './types'; const defaultReferenceSource = (resource: string, source: string) => `${resource}@${source}`; interface ChildrenFuncParams { choices: Record[]; error?: string; isLoading: boolean; onChange: (value: any) => void; setFilter: (filter: any) => void; setPagination: (pagination: Pagination) => void; setSort: (sort: Sort) => void; warning?: string; } interface Props { allowEmpty?: boolean; basePath: string; children: (params: ChildrenFuncParams) => ReactNode; filter?: object; filterToQuery: (filter: {}) => any; input?: WrappedFieldInputProps; meta?: object; perPage?: number; record?: Record; reference: string; referenceSource: typeof defaultReferenceSource; resource: string; sort?: Sort; source: string; } interface EnhancedProps { crudGetMatching: Dispatch; crudGetMany: Dispatch; matchingReferences?: Record[] | MatchingReferencesError; onChange?: () => void; referenceRecords?: Record[]; translate: Translate; } /** * 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 UnconnectedReferenceArrayInputController extends Component< Props & EnhancedProps > { public static defaultProps = { allowEmpty: false, filter: {}, filterToQuery: searchText => ({ q: searchText }), matchingReferences: null, perPage: 25, sort: { field: 'id', order: 'DESC' }, referenceRecords: [], referenceSource: defaultReferenceSource, // used in unit tests }; private params; private debouncedSetFilter; constructor(props: Props & EnhancedProps) { 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.fetchReferencesAndOptions(this.props, {} as Props & EnhancedProps); } componentWillReceiveProps(nextProps: Props & EnhancedProps) { let shouldFetchOptions = false; if ( (this.props.record || { id: undefined }).id !== (nextProps.record || { id: undefined }).id ) { this.fetchReferencesAndOptions(nextProps); } else if (this.props.input.value !== nextProps.input.value) { this.fetchReferences(nextProps); } else { if (!isEqual(nextProps.filter, this.props.filter)) { this.params = { ...this.params, filter: nextProps.filter }; shouldFetchOptions = true; } if (!isEqual(nextProps.sort, this.props.sort)) { this.params = { ...this.params, sort: nextProps.sort }; shouldFetchOptions = true; } if (nextProps.perPage !== this.props.perPage) { this.params = { ...this.params, pagination: { ...this.params.pagination, perPage: nextProps.perPage, }, }; shouldFetchOptions = true; } } if (shouldFetchOptions) { this.fetchOptions(); } } setFilter = (filter: any) => { if (filter !== this.params.filter) { this.params.filter = this.props.filterToQuery(filter); this.fetchOptions(); } }; setPagination = (pagination: Pagination) => { if (pagination !== this.params.pagination) { this.params.pagination = pagination; this.fetchOptions(); } }; setSort = (sort: Sort) => { if (sort !== this.params.sort) { this.params.sort = sort; this.fetchOptions(); } }; fetchReferences = (nextProps, currentProps = this.props) => { const { crudGetMany, input, reference } = nextProps; const ids = input.value; if (ids) { if (!Array.isArray(ids)) { throw Error( 'The value of ReferenceArrayInput should be an array' ); } const idsToFetch = difference( ids, get(currentProps, 'input.value', []) ); if (idsToFetch.length) crudGetMany(reference, idsToFetch); } }; fetchOptions = (props = this.props) => { const { crudGetMatching, reference, source, resource, referenceSource, filter: defaultFilter, } = props; const { pagination, sort, filter } = this.params; crudGetMatching( reference, referenceSource(resource, source), pagination, sort, { ...filter, ...defaultFilter, } ); }; fetchReferencesAndOptions(nextProps, currentProps = this.props) { this.fetchReferences(nextProps, currentProps); this.fetchOptions(nextProps); } render() { const { input, referenceRecords, matchingReferences, onChange, children, translate, } = this.props; const dataStatus = getDataStatus({ input, matchingReferences, referenceRecords, translate, }); return children({ choices: dataStatus.choices, error: dataStatus.error, isLoading: dataStatus.waiting, onChange, setFilter: this.debouncedSetFilter, setPagination: this.setPagination, setSort: this.setSort, warning: dataStatus.warning, }); } } const makeMapStateToProps = () => createSelector( [ getReferenceResource, getPossibleReferenceValues, (_, { resource, input }) => { const { value: referenceIds } = input; if (!referenceIds) { return []; } if (Array.isArray(referenceIds)) { return referenceIds; } throw new Error( ` expects value to be an array, but the value passed as '${resource}.${ input.name }' is type '${typeof referenceIds}': ${referenceIds}` ); }, ], (referenceState, possibleValues, inputIds) => ({ matchingReferences: getPossibleReferences( referenceState, possibleValues, inputIds ), referenceRecords: referenceState && inputIds.reduce((references, referenceId) => { if (referenceState.data[referenceId]) { references.push(referenceState.data[referenceId]); } return references; }, []), }) ); const ReferenceArrayInputController = compose( withTranslate, connect( makeMapStateToProps(), { crudGetMany: crudGetManyAction, crudGetMatching: crudGetMatchingAction, } ) )(UnconnectedReferenceArrayInputController); ReferenceArrayInputController.defaultProps = { referenceSource: defaultReferenceSource, // used in makeMapStateToProps }; export default ReferenceArrayInputController as ComponentType;