import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { intlShape } from 'meteor/vulcan:i18n'; import classNames from 'classnames'; import { Components } from 'meteor/vulcan:core'; import { registerComponent } from 'meteor/vulcan:core'; import debounce from 'lodash.debounce'; import get from 'lodash/get'; import merge from 'lodash/merge'; import { isEmptyValue } from '../modules/utils.js'; class FormComponent extends PureComponent { constructor(props) { super(props); const value = this.getValue(props); if (this.showCharsRemaining(props)) { const characterCount = value ? value.length : 0; this.state = { charsRemaining: props.max - characterCount, }; } } handleChange = (name, value) => { // if value is an empty string, delete the field if (value === '') { value = null; } // if this is a number field, convert value before sending it up to Form if (this.getType() === 'number') { value = Number(value); } this.props.updateCurrentValues({ [this.props.path]: value }); // for text fields, update character count on change if (this.showCharsRemaining()) { this.updateCharacterCount(value); } }; /* Note: not currently used because when function is debounced some changes might not register if the user submits form too soon */ handleChangeDebounced = debounce(this.handleChange, 500, { leading: true }); updateCharacterCount = value => { const characterCount = value ? value.length : 0; this.setState({ charsRemaining: this.props.max - characterCount, }); }; /* Get value from Form state through document and currentValues props */ getValue = props => { let value; const p = props || this.props; const { document, currentValues, defaultValue, path, datatype } = p; const documentValue = get(document, path); const currentValue = currentValues[path]; const isDeleted = p.deletedValues.includes(path); if (isDeleted) { value = ''; } else { if (datatype[0].type === Array) { // for object and arrays, use lodash's merge // if field type is array, use [] as merge seed to force result to be an array as well value = merge([], documentValue, currentValue); } else if (datatype[0].type === Object) { value = merge({}, documentValue, currentValue); } else { // note: value has to default to '' to make component controlled value = currentValue || documentValue || ''; } // replace empty value, which has not been prefilled, by the default value from the schema if (isEmptyValue(value)) { if (defaultValue) value = defaultValue; } } return value; }; /* Whether to keep track of and show remaining chars */ showCharsRemaining = props => { const p = props || this.props; return p.max && ['url', 'email', 'textarea', 'text'].includes(this.getType(p)); }; /* Get errors from Form state through context */ getErrors = () => { const fieldErrors = this.props.errors.filter(error => error.path === this.props.path); return fieldErrors; }; /* Get form input type, either based on input props, or by guessing based on form field type */ getType = props => { const p = props || this.props; const fieldType = p.datatype && p.datatype[0].type; const autoType = fieldType === Number ? 'number' : fieldType === Boolean ? 'checkbox' : fieldType === Date ? 'date' : 'text'; return p.input || autoType; }; renderComponent() { const { input, beforeComponent, afterComponent, options, name, label, formType, throwError, updateCurrentValues, currentValues, addToDeletedValues, deletedValues, clearFieldErrors, } = this.props; const value = this.getValue(); // these properties are whitelisted so that they can be safely passed to the actual form input // and avoid https://facebook.github.io/react/warnings/unknown-prop.html warnings const inputProperties = { name, options, label, onChange: this.handleChange, value, ...this.props.inputProperties, }; // note: we also pass value on props directly const properties = { ...this.props, value, errors: this.getErrors(), // only get errors for the current field throwError, inputProperties, currentValues, updateCurrentValues, deletedValues, addToDeletedValues, clearFieldErrors, }; // if input is a React component, use it if (typeof input === 'function') { const InputComponent = input; return ; } else { // else pick a predefined component switch (this.getType()) { case 'nested': return ; case 'number': return ; case 'url': return ; case 'email': return ; case 'textarea': return ; case 'checkbox': // formsy-react-components expects a boolean value for checkbox // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/checkbox.js#L20 properties.inputProperties.value = !!properties.inputProperties.value; return ; case 'checkboxgroup': // formsy-react-components expects an array value // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/checkbox-group.js#L42 if (!Array.isArray(properties.inputProperties.value)) { properties.inputProperties.value = [properties.inputProperties.value]; } // in case of checkbox groups, check "checked" option to populate value if this is a "new document" form const checkedValues = _.where(properties.options, { checked: true }).map(option => option.value); if (checkedValues.length && !properties.inputProperties.value && formType === 'new') { properties.inputProperties.value = checkedValues; } return ; case 'radiogroup': // TODO: remove this? // formsy-react-compnents RadioGroup expects an onChange callback // https://github.com/twisty/formsy-react-components/blob/v0.11.1/src/radio-group.js#L33 // properties.onChange = (name, value) => { // this.context.updateCurrentValues({ [name]: value }); // }; return ; case 'select': const noneOption = { label: this.context.intl.formatMessage({ id: 'forms.select_option' }), value: '', disabled: true, }; properties.inputProperties.options = [noneOption, ...properties.inputProperties.options]; return ; case 'selectmultiple': properties.inputProperties.multiple = true; return ; case 'datetime': return ; case 'date': return ; case 'time': return ; case 'text': return ; default: const CustomComponent = Components[input]; return CustomComponent ? ( ) : ( ); } } } showClear = () => { return ['datetime', 'time', 'select', 'radiogroup'].includes(this.props.input); }; clearField = e => { e.preventDefault(); this.props.updateCurrentValues({ [this.props.path]: null }); }; renderClear() { return ( ); } render() { const { beforeComponent, afterComponent, name, input } = this.props; const hasErrors = this.getErrors() && this.getErrors().length; const inputName = typeof input === 'function' ? input.name : input; const inputClass = classNames('form-input', `input-${name}`, `form-component-${inputName || 'default'}`, { 'input-error': hasErrors, }); return (
{beforeComponent ? beforeComponent : null} {this.renderComponent()} {hasErrors ? : null} {this.showClear() ? this.renderClear() : null} {this.showCharsRemaining() && (
{this.state.charsRemaining}
)} {afterComponent ? afterComponent : null}
); } } FormComponent.propTypes = { document: PropTypes.object, name: PropTypes.string, label: PropTypes.string, value: PropTypes.any, placeholder: PropTypes.string, prefilledValue: PropTypes.any, options: PropTypes.any, input: PropTypes.any, datatype: PropTypes.any, path: PropTypes.string, disabled: PropTypes.bool, nestedSchema: PropTypes.object, currentValues: PropTypes.object, deletedValues: PropTypes.array, updateCurrentValues: PropTypes.func, errors: PropTypes.array, addToDeletedValues: PropTypes.func, }; FormComponent.contextTypes = { intl: intlShape, getDocument: PropTypes.func, }; registerComponent('FormComponent', FormComponent);