UNPKG

@ngbrown/react-redux-form

Version:

Create Forms Easily with React and Redux

953 lines (814 loc) 25.2 kB
import React, { Component, createElement, cloneElement } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { compose } from 'redux'; import identity from '../utils/identity'; import shallowEqual from '../utils/shallow-equal'; import _get from '../utils/get'; import merge from '../utils/merge'; import mapValues from '../utils/map-values'; import isPlainObject, { isObjectLike } from '../utils/is-plain-object'; import i from 'icepick'; import omit from '../utils/omit'; import actionTypes from '../action-types'; import debounce from '../utils/debounce'; import _getValue, { getCheckboxValue } from '../utils/get-value'; import getValidity from '../utils/get-validity'; import invertValidity from '../utils/invert-validity'; import getModel from '../utils/get-model'; import persistEventWithCallback from '../utils/persist-event-with-callback'; import actions from '../actions'; import controlPropsMap from '../constants/control-props-map'; import validityKeys from '../constants/validity-keys'; import { dispatchBatchIfNeeded } from '../actions/batch-actions'; import resolveModel from '../utils/resolve-model'; import isNative from '../utils/is-native'; import initialFieldState from '../constants/initial-field-state'; import containsEvent from '../utils/contains-event'; import ComponentWrapper from './control-strip-defaults-component'; const disallowedProps = ['changeAction', 'getFieldFromState', 'store']; function mergeOrSetErrors(model, errors, options) { return actions.setErrors(model, errors, { merge: isObjectLike(errors), ...options, }); } const propTypes = { model: PropTypes.oneOfType([ PropTypes.func, PropTypes.string, ]).isRequired, modelValue: PropTypes.any, viewValue: PropTypes.any, defaultValue: PropTypes.any, control: PropTypes.any, onLoad: PropTypes.func, onSubmit: PropTypes.func, fieldValue: PropTypes.object, mapProps: PropTypes.oneOfType([ PropTypes.func, PropTypes.object, ]), changeAction: PropTypes.func, updateOn: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.string), PropTypes.string, ]), validateOn: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.string), PropTypes.string, ]), validators: PropTypes.oneOfType([ PropTypes.func, PropTypes.object, ]), asyncValidateOn: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.string), PropTypes.string, ]), asyncValidators: PropTypes.oneOfType([ PropTypes.func, PropTypes.object, ]), errors: PropTypes.oneOfType([ PropTypes.func, PropTypes.object, ]), controlProps: PropTypes.object, component: PropTypes.any, dispatch: PropTypes.func, parser: PropTypes.func, formatter: PropTypes.func, ignore: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.string), PropTypes.string, ]), dynamic: PropTypes.bool, store: PropTypes.shape({ subscribe: PropTypes.func, dispatch: PropTypes.func, getState: PropTypes.func, }), getRef: PropTypes.func, withField: PropTypes.bool, debounce: PropTypes.number, persist: PropTypes.bool, getValue: PropTypes.func, isToggle: PropTypes.bool, updateOnEnter: PropTypes.bool, render: PropTypes.func, // HTML5 attributes formNoValidate: PropTypes.bool, }; const htmlAttributes = ['formNoValidate']; const disallowedPropTypeKeys = Object.keys(propTypes) .filter(key => htmlAttributes.indexOf(key) === -1); function createControlClass(s) { const emptyControlProps = {}; const emptyMapProps = {}; class Control extends Component { constructor(props) { super(props); this.getChangeAction = this.getChangeAction.bind(this); this.getValidateAction = this.getValidateAction.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); this.createEventHandler = this.createEventHandler.bind(this); this.handleFocus = this.createEventHandler('focus').bind(this); this.handleBlur = this.createEventHandler('blur').bind(this); this.handleUpdate = this.createEventHandler('change').bind(this); this.forceHandleUpdate = this.createEventHandler('change', true).bind(this); this.handleChange = this.handleChange.bind(this); this.handleLoad = this.handleLoad.bind(this); this.getMappedProps = this.getMappedProps.bind(this); this.getRenderProps = this.getRenderProps.bind(this); this.attachNode = this.attachNode.bind(this); if (props.debounce) { this.handleUpdate = debounce(this.handleUpdate, props.debounce); const oldHandleBlur = this.handleBlur; this.handleBlur = (...args) => { this.handleUpdate.flush(); oldHandleBlur.call(this, ...args); }; } this.willValidate = false; this.state = { viewValue: this.format(props.modelValue), }; } componentDidMount() { this.attachNode(); this.handleLoad(); } componentWillReceiveProps({ modelValue }) { if (modelValue !== this.props.modelValue) { this.setViewValue(modelValue); } } shouldComponentUpdate(nextProps, nextState) { return !shallowEqual(this.props, nextProps, { deepKeys: ['controlProps'], omitKeys: ['mapProps'], }) || !shallowEqual(this.state.viewValue, nextState.viewValue); } componentDidUpdate() { this.handleIntents(); } componentWillUnmount() { const { model, fieldValue, dispatch, validators = {}, errors = {}, persist, } = this.props; if (!persist && fieldValue && !fieldValue.valid) { const keys = Object.keys(validators) .concat(Object.keys(errors), this.willValidate ? validityKeys : []); dispatch(actions.resetValidity(model, keys)); } // flush debounced model changes if (this.handleUpdate.flush) { this.handleUpdate.flush(); } } getRenderProps() { const props = this.props; const { viewValue } = this.state; return { modelValue: props.modelValue, fieldValue: props.fieldValue, onFocus: this.handleFocus, onBlur: this.handleBlur, onChange: this.handleChange, onKeyPress: this.handleKeyPress, viewValue, }; } getMappedProps() { const props = this.props; const { mapProps } = props; const { viewValue } = this.state; const originalProps = { ...props, ...props.controlProps, onFocus: this.handleFocus, onBlur: this.handleBlur, onChange: this.handleChange, onKeyPress: this.handleKeyPress, viewValue, }; if (isPlainObject(mapProps)) { return mapValues(mapProps, (value, key) => { if (typeof value === 'function' && key !== 'component') { return value(originalProps); } return value; }); } else if (typeof mapProps === 'function') { return mapProps(originalProps); } return emptyMapProps; } getChangeAction(event) { return this.props.changeAction( this.props.model, this.getValue(event), { currentValue: this.props.modelValue, external: false, }); } getValidateAction(value, eventName, forceUpdate = false) { const { validators, errors, model, modelValue, updateOn, fieldValue, } = this.props; if (!validators && !errors && !this.willValidate) return false; const nodeErrors = this.getNodeErrors(); // If it is not a change event, use the model value. const valueToValidate = forceUpdate || containsEvent(updateOn, eventName) ? value : modelValue; if (validators || errors) { const fieldValidity = getValidity(validators, valueToValidate); const fieldErrors = merge( getValidity(errors, valueToValidate), nodeErrors); const mergedErrors = validators ? merge(invertValidity(fieldValidity), fieldErrors) : fieldErrors; if (!fieldValue || !shallowEqual(mergedErrors, fieldValue.errors)) { return mergeOrSetErrors(model, mergedErrors); } } else if (nodeErrors && Object.keys(nodeErrors).length) { return mergeOrSetErrors(model, nodeErrors); } return false; } getAsyncValidateAction(value, eventName) { const { asyncValidators, fieldValue, model, modelValue, updateOn, validateOn = updateOn, asyncValidateOn, dispatch, getValue, } = this.props; // If there are no async validators, // do not run async validation if (!asyncValidators) return false; // If it is not a change event, use the model value. const valueToValidate = containsEvent(updateOn, eventName) ? value : modelValue; // If any sync validity is invalid, // do not run async validation // unless sync and async validation occur simultaneously if (validateOn !== asyncValidateOn) { const asyncValidatorKeys = Object.keys(asyncValidators); const syncValid = Object.keys(fieldValue.validity).every((key) => { // If validity is based on async validator, skip if (!!~asyncValidatorKeys.indexOf(key)) return true; return fieldValue.validity[key]; }); if (!syncValid) return false; } dispatch(actions.setValidating(model, true)); mapValues(asyncValidators, (validator, key) => { const outerDone = (valid) => { // get current fieldValue as another validator may have already been processed. const currentFieldValue = this.props.fieldValue; const validity = i.merge(currentFieldValue.validity, { [key]: valid }); dispatch(actions.setValidity(model, validity)); }; validator(getValue(valueToValidate, this.props), outerDone); }); return valueToValidate; } getNodeErrors() { const { node, props: { fieldValue, formNoValidate }, } = this; if (formNoValidate || !node || (node && !node.willValidate)) { this.willValidate = false; return null; } this.willValidate = true; const nodeErrors = {}; validityKeys.forEach((key) => { const errorValidity = node.validity[key]; // If the key is invalid or they key was // previously invalid and is now valid, // set its validity if (errorValidity || (fieldValue && fieldValue.errors[key])) { nodeErrors[key] = errorValidity; } }); return nodeErrors; } setViewValue(viewValue) { if (!this.props.isToggle) { if (this.props.formatter) { const parsedValue = this.parse(viewValue); this.setState({ viewValue: this.format(parsedValue) }); } else { this.setState({ viewValue: this.parse(viewValue) }); } } } getValue(event) { return this.props.getValue(event, this.props); } handleIntents() { const { model, modelValue, fieldValue, fieldValue: { intents }, controlProps, dispatch, updateOn, validateOn = updateOn, } = this.props; if (!Array.isArray(intents) || !intents.length) return; intents.forEach((intent) => { switch (intent.type) { case actionTypes.FOCUS: { if (isNative) return; const focused = fieldValue.focus; if ((focused && this.node.focus) && ( !this.props.isToggle || typeof intent.value === 'undefined' || intent.value === controlProps.value )) { this.node.focus(); dispatch(actions.clearIntents(model, intent)); } return; } case 'validate': if (containsEvent(validateOn, 'change')) { this.validate({ clearIntents: intent }); } return; case 'reset': this.setViewValue(modelValue); if (this.handleUpdate.cancel) { this.handleUpdate.cancel(); } dispatch(actions.clearIntents(model, intent)); if (containsEvent(validateOn, 'change')) { this.validate({}); } return; case 'load': if (!shallowEqual(modelValue, fieldValue.value)) { dispatch(actions.load(model, fieldValue.value, { clearIntents: intent })); } return; default: return; } }); } parse(value) { return this.props.parser ? this.props.parser(value) : value; } format(value) { return this.props.formatter ? this.props.formatter(value) : value; } handleChange(event) { if (event && event.persist) event.persist(); this.setViewValue(this.getValue(event)); this.handleUpdate(event); } handleKeyPress(event) { const { controlProps: { onKeyPress }, updateOnEnter, } = this.props; if (onKeyPress) onKeyPress(event); if (updateOnEnter && event.key === 'Enter') { this.forceHandleUpdate(event); } } handleLoad() { const { model, modelValue, fieldValue, controlProps, onLoad, dispatch, changeAction, parser, } = this.props; let defaultValue = undefined; if (controlProps.hasOwnProperty('defaultValue')) { defaultValue = controlProps.defaultValue; } else if (controlProps.hasOwnProperty('defaultChecked')) { defaultValue = controlProps.defaultChecked; } else if (this.props.hasOwnProperty('defaultValue')) { defaultValue = this.props.defaultValue; } const loadActions = [this.getValidateAction(defaultValue)]; if (typeof defaultValue !== 'undefined') { loadActions.push(changeAction(model, defaultValue)); } else { if (parser) { const parsedValue = parser(modelValue); if (parsedValue !== modelValue) { loadActions.push(changeAction(model, parsedValue)); } } } dispatchBatchIfNeeded(model, loadActions, dispatch); if (onLoad) onLoad(modelValue, fieldValue, this.node); } createEventHandler(eventName, forceUpdate = false) { const eventAction = { focus: actions.silentFocus, blur: actions.blur, }[eventName]; const dispatchBatchActions = (persistedEvent) => { const { dispatch, model, updateOn, validateOn = updateOn, asyncValidateOn, } = this.props; const eventActions = [ eventAction && eventAction(model), (forceUpdate || containsEvent(validateOn, eventName)) && this.getValidateAction(persistedEvent, eventName, forceUpdate), (forceUpdate || containsEvent(asyncValidateOn, eventName)) && this.getAsyncValidateAction(persistedEvent, eventName), (forceUpdate || containsEvent(updateOn, eventName)) && this.getChangeAction(persistedEvent), ]; dispatchBatchIfNeeded(model, eventActions, dispatch); return persistedEvent; }; return (event) => { const { controlProps, parser, ignore, withField, fieldValue, } = this.props; const controlEventHandler = { focus: controlProps.onFocus, blur: controlProps.onBlur, change: controlProps.onChange, }[eventName]; if (containsEvent(ignore, eventName)) { return controlEventHandler ? controlEventHandler(event) : event; } if (this.props.isToggle) { return compose( dispatchBatchActions, persistEventWithCallback(controlEventHandler || identity) )(event); } return compose( dispatchBatchActions, parser, (e) => this.getValue(e), persistEventWithCallback(controlEventHandler || identity) )(event, withField ? fieldValue : undefined); }; } attachNode() { const node = s.findDOMNode && s.findDOMNode(this); if (node) { this.node = node; this.willValidate = node.willValidate; } } validate(options) { const { model, modelValue, fieldValue, validators, errors: errorValidators, dispatch, } = this.props; if ((!validators && !errorValidators && !this.willValidate) || !fieldValue) { return; } const fieldValidity = getValidity(validators, modelValue); const fieldErrors = getValidity(errorValidators, modelValue); const nodeErrors = this.getNodeErrors(); let errors = validators ? merge(invertValidity(fieldValidity), fieldErrors) : fieldErrors; if (this.willValidate) { errors = merge(errors, nodeErrors); } if (!shallowEqual(errors, fieldValue.errors)) { dispatch(mergeOrSetErrors(model, errors, options)); } else if (options.clearIntents) { dispatch(actions.clearIntents(model, options.clearIntents)); } } render() { const { render, controlProps, component, control, getRef, fieldValue, } = this.props; if (render) { return render(this.getRenderProps()); } const mappedProps = omit(this.getMappedProps(), disallowedProps); if (getRef) { mappedProps.getRef = getRef; } if (controlProps.withFieldValue) { mappedProps.fieldValue = fieldValue; } // If there is an existing control, clone it if (control) { return cloneElement( control, { ...mappedProps, defaultValue: undefined, defaultChecked: undefined, }, controlProps.children ); } return createElement( ComponentWrapper, { component, ...controlProps, ...mappedProps, } ); } } Control.displayName = 'Control'; Control.propTypes = propTypes; Control.defaultProps = { changeAction: s.actions.change, updateOn: 'change', asyncValidateOn: 'blur', parser: identity, controlProps: emptyControlProps, ignore: [], dynamic: false, component: 'input', withField: true, persist: false, getValue: _getValue, isToggle: false, updateOnEnter: true, }; function mapStateToProps(state, props) { const { model, controlProps, } = props; const finalControlProps = { ...controlProps, ...omit(props, disallowedPropTypeKeys), }; const modelString = getModel(model, state); const fieldValue = s.getFieldFromState(state, modelString) || initialFieldState; const modelValue = s.get(state, modelString); return { model: modelString, modelValue, fieldValue, controlProps: finalControlProps, }; } const ConnectedControl = resolveModel(connect(mapStateToProps, null, null, { areOwnPropsEqual(nextOwnProps, ownProps) { return shallowEqual(nextOwnProps, ownProps, { omitKeys: ['mapProps'], }); }, areStatePropsEqual(nextStateProps, stateProps) { return shallowEqual(nextStateProps, stateProps, { deepKeys: ['controlProps'], }); }, })(Control)); /* eslint-disable react/prop-types */ /* eslint-disable react/no-multi-comp */ class DefaultConnectedControl extends React.Component { shouldComponentUpdate(nextProps, nextState, nextContext) { return !shallowEqual(this.context, nextContext) || !shallowEqual(this.props, nextProps, { deepKeys: ['controlProps'], omitKeys: ['mapProps'], }); } render() { return ( <ConnectedControl {...this.props} mapProps={{ ...controlPropsMap.default, ...this.props.mapProps, }} /> ); } } // Copy the context types so that we can properly implement shouldComponentUpdate DefaultConnectedControl.contextTypes = ConnectedControl.contextTypes; DefaultConnectedControl.custom = ConnectedControl; class DefaultConnectedControlInput extends DefaultConnectedControl { render() { return ( <ConnectedControl component="input" {...this.props} mapProps={{ ...controlPropsMap.default, ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.input = DefaultConnectedControlInput; class DefaultConnectedControlText extends DefaultConnectedControl { render() { return ( <ConnectedControl component="input" {...this.props} mapProps={{ ...controlPropsMap.text, type: this.props.type || 'text', ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.text = DefaultConnectedControlText; class DefaultConnectedControlPassword extends DefaultConnectedControl { render() { return ( <ConnectedControl component="input" {...this.props} mapProps={{ ...controlPropsMap.text, type: this.props.type || 'password', ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.password = DefaultConnectedControlPassword; class DefaultConnectedControlTextArea extends DefaultConnectedControl { render() { return ( <ConnectedControl component="textarea" updateOnEnter={false} {...this.props} mapProps={{ ...controlPropsMap.textarea, ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.textarea = DefaultConnectedControlTextArea; class DefaultConnectedControlRadio extends DefaultConnectedControl { render() { return ( <ConnectedControl component="input" type="radio" isToggle {...this.props} mapProps={{ ...controlPropsMap.radio, ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.radio = DefaultConnectedControlRadio; class DefaultConnectedControlCheckbox extends DefaultConnectedControl { render() { return ( <ConnectedControl component="input" type="checkbox" isToggle {...this.props} mapProps={{ ...controlPropsMap.checkbox, ...this.props.mapProps, }} getValue={getCheckboxValue} changeAction={this.props.changeAction || s.actions.checkWithValue} /> ); } } DefaultConnectedControl.checkbox = DefaultConnectedControlCheckbox; class DefaultConnectedControlFile extends DefaultConnectedControl { render() { return ( <ConnectedControl component="input" type="file" {...this.props} mapProps={{ ...controlPropsMap.file, ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.file = DefaultConnectedControlFile; class DefaultConnectedControlSelect extends DefaultConnectedControl { render() { return ( <ConnectedControl component="select" {...this.props} mapProps={{ ...controlPropsMap.select, ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.select = DefaultConnectedControlSelect; class DefaultConnectedControlButton extends DefaultConnectedControl { render() { return ( <ConnectedControl component="button" {...this.props} mapProps={{ ...controlPropsMap.button, ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.button = DefaultConnectedControlButton; class DefaultConnectedControlReset extends DefaultConnectedControl { render() { return ( <ConnectedControl component="button" type="reset" {...this.props} mapProps={{ ...controlPropsMap.reset, ...this.props.mapProps, }} /> ); } } DefaultConnectedControl.reset = DefaultConnectedControlReset; return DefaultConnectedControl; } export { createControlClass, }; export default createControlClass;