import { get , has , set } from 'lodash' ; import PropTypes from 'prop-types' ; import React from 'react' ; import FormsyContext from './FormsyContext' ; import { FormsyContextInterface , IModel , InputComponent , IResetModel , IUpdateInputsWithError , IUpdateInputsWithValue , ValidationError , } from './interfaces' ; import { throttle , isObject , isString } from './utils' ; import * as utils from './utils' ; import validationRules from './validationRules' ; import { PassDownProps } from './withFormsy' ; type FormHTMLAttributesCleaned = Omit < React . FormHTMLAttributes < HTMLFormElement > , 'onChange' | 'onSubmit' > ; type OnSubmitCallback = ( model : IModel , resetModel : IResetModel , updateInputsWithError : IUpdateInputsWithError , event : React . SyntheticEvent < React . FormHTMLAttributes < any > > ) => void ; type FormElementType = string | React . ComponentType < { onReset ? : ( e : React . SyntheticEvent ) => void ; onSubmit ? : ( e : React . SyntheticEvent ) => void ; disabled ? : boolean ; children ? : React . ReactChildren ; } > ; export interface FormsyProps extends FormHTMLAttributesCleaned { disabled : boolean ; mapping : null | ( ( model : IModel ) => IModel ) ; onChange : ( model : IModel , isChanged : boolean ) => void ; onInvalid : ( ) => void ; onReset ? : ( ) => void ; onSubmit ? : OnSubmitCallback ; onValidSubmit ? : OnSubmitCallback ; onInvalidSubmit : OnSubmitCallback ; onValid : ( ) => void ; preventDefaultSubmit ? : boolean ; preventExternalInvalidation ? : boolean ; validationErrors ? : null | object ; formElement ? : FormElementType ; } export interface FormsyState { canChange : boolean ; contextValue : FormsyContextInterface ; formSubmitted ? : boolean ; isPristine ? : boolean ; isSubmitting : boolean ; isValid : boolean ; } const ONE_RENDER_FRAME = 66 ; export class Formsy extends React . Component < FormsyProps , FormsyState > { public inputs : InstanceType < any & PassDownProps < any > > [ ] ; public emptyArray : any [ ] ; public prevInputNames : any [ ] | null = null ; public static displayName = 'Formsy' ; public static propTypes = { disabled : PropTypes . bool , mapping : PropTypes . func , formElement : PropTypes . oneOfType ( [ PropTypes . string , PropTypes . object , PropTypes . func ] ) , onChange : PropTypes . func , onInvalid : PropTypes . func , onInvalidSubmit : PropTypes . func , onReset : PropTypes . func , onSubmit : PropTypes . func , onValid : PropTypes . func , onValidSubmit : PropTypes . func , preventDefaultSubmit : PropTypes . bool , preventExternalInvalidation : PropTypes . bool , validationErrors : PropTypes . object } ; public static defaultProps : Partial < FormsyProps > = { disabled : false , mapping : null , onChange : utils . noop , onInvalid : utils . noop , onInvalidSubmit : utils . noop , onReset : utils . noop , onSubmit : utils . noop , onValid : utils . noop , onValidSubmit : utils . noop , preventDefaultSubmit : true , preventExternalInvalidation : false , validationErrors : { } , formElement : 'form' } ; private readonly throttledValidateForm : ( ) => void ; public constructor ( props ) { super ( props ) ; this . state = { canChange : false , isSubmitting : false , isValid : true , contextValue : { attachToForm : this . attachToForm , detachFromForm : this . detachFromForm , isFormDisabled : props . disabled , isValidValue : this . isValidValue , validate : this . validate , runValidation : this . runValidation } } ; this . inputs = [ ] ; this . emptyArray = [ ] ; this . throttledValidateForm = throttle ( this . validateForm , ONE_RENDER_FRAME ) ; } public componentDidMount = ( ) => { this . prevInputNames = this . inputs . map ( ( component ) => component . props . name ) ; this . validateForm ( ) ; } ; public componentDidUpdate = ( prevProps ) => { const { validationErrors , disabled } = this . props ; if ( validationErrors && isObject ( validationErrors ) && Object . keys ( validationErrors ) . length > 0 ) { this . setInputValidationErrors ( validationErrors ) ; } const newInputNames = this . inputs . map ( ( component ) => component . props . name ) ; if ( this . prevInputNames && ! utils . isSame ( this . prevInputNames , newInputNames ) ) { this . prevInputNames = newInputNames ; this . validateForm ( ) ; } if ( disabled !== prevProps . disabled ) { this . setState ( ( state ) => ( { ... state , contextValue : { ... state . contextValue , isFormDisabled : disabled } } ) ) ; } } ; public getCurrentValues = ( ) => this . inputs . reduce ( ( valueAccumulator , component ) => { const { props : { name } , state : { value } , } = component ; valueAccumulator [ name ] = utils . protectAgainstParamReassignment ( value ) ; return valueAccumulator ; } , { } ) ; public getModel = ( ) => { const currentValues = this . getCurrentValues ( ) ; return this . mapModel ( currentValues ) ; } ; public getPristineValues = ( ) => this . inputs . reduce ( ( valueAccumulator , component ) => { const { props : { name , value } , } = component ; valueAccumulator [ name ] = utils . protectAgainstParamReassignment ( value ) ; return valueAccumulator ; } , { } ) ; public setFormPristine = ( isPristine ) => { this . setState ( { formSubmitted : ! isPristine } ) ; this . inputs . forEach ( ( component ) => { component . setState ( { formSubmitted : ! isPristine , isPristine } ) ; } ) ; } ; public setInputValidationErrors = ( errors ) => { const { preventExternalInvalidation } = this . props ; const { isValid } = this . state ; this . inputs . forEach ( ( component ) => { const { name } = component . props ; component . setState ( { isValid : ! ( name in errors ) , validationError : isString ( errors [ name ] ) ? [ errors [ name ] ] : errors [ name ] } ) ; } ) ; if ( ! preventExternalInvalidation && isValid ) { this . setFormValidState ( false ) ; } } ; public setFormValidState = ( allIsValid ) => { const { onValid , onInvalid } = this . props ; this . setState ( { isValid : allIsValid } ) ; if ( allIsValid ) { onValid ( ) ; } else { onInvalid ( ) ; } } ; public isValidValue = ( component , value ) => this . runValidation ( component , value ) . isValid ; public isFormDisabled = ( ) => this . props . disabled ; public mapModel = ( model ) : IModel => { const { mapping } = this . props ; if ( mapping ) { return mapping ( model ) ; } const returnModel = { } ; Object . keys ( model ) . forEach ( ( key ) => { set ( returnModel , key , model [ key ] ) ; } ) ; return returnModel ; } ; public reset = ( model ? ) => { this . setFormPristine ( true ) ; this . resetModel ( model ) ; } ; private resetInternal = ( event ) => { const { onReset } = this . props ; event . preventDefault ( ) ; this . reset ( ) ; if ( onReset ) { onReset ( ) ; } } ; private resetModel : IResetModel = ( data ) => { this . inputs . forEach ( ( component ) => { const { name } = component . props ; if ( data && has ( data , name ) ) { component . setValue ( get ( data , name ) ) ; } else { component . resetValue ( ) ; } } ) ; this . validateForm ( ) ; } ; public runValidation = < V > ( component , value = component . state . value ) : { isRequired : boolean ; isValid : boolean ; validationError : ValidationError [ ] ; } => { const { validationErrors } = this . props ; const { validationError , validationErrors : componentValidationErrors , name } = component . props ; const currentValues = this . getCurrentValues ( ) ; const validationResults = utils . runRules ( value , currentValues , component . validations , validationRules ) ; const requiredResults = utils . runRules ( value , currentValues , component . requiredValidations , validationRules ) ; const isRequired = Object . keys ( component . requiredValidations ) . length ? ! ! requiredResults . success . length : false ; const isValid = ! validationResults . failed . length && ! ( validationErrors && validationErrors [ component . props . name ] ) ; return { isRequired , isValid : isRequired ? false : isValid , validationError : ( ( ) => { if ( isValid && ! isRequired ) { return this . emptyArray ; } if ( validationResults . errors . length ) { return validationResults . errors ; } if ( validationErrors && validationErrors [ name ] ) { return isString ( validationErrors [ name ] ) ? [ validationErrors [ name ] ] : validationErrors [ name ] ; } if ( isRequired ) { const error = componentValidationErrors [ requiredResults . success [ 0 ] ] || validationError ; return error ? [ error ] : null ; } if ( validationResults . failed . length ) { return validationResults . failed . map ( ( failed ) => ( componentValidationErrors [ failed ] ? componentValidationErrors [ failed ] : validationError ) ) . filter ( ( x , pos , arr ) => arr . indexOf ( x ) === pos ) ; } return undefined ; } ) ( ) } ; } ; public attachToForm = ( component ) => { if ( this . inputs . indexOf ( component ) === - 1 ) { this . inputs . push ( component ) ; } const { onChange } = this . props ; const { canChange } = this . state ; if ( canChange ) { onChange ( this . getModel ( ) , this . isChanged ( ) ) ; } this . throttledValidateForm ( ) ; } ; public detachFromForm = < V > ( component ) => { const componentPos = this . inputs . indexOf ( component ) ; if ( componentPos !== - 1 ) { this . inputs = this . inputs . slice ( 0 , componentPos ) . concat ( this . inputs . slice ( componentPos + 1 ) ) ; } this . validateForm ( ) ; } ; public isChanged = ( ) => ! utils . isSame ( this . getPristineValues ( ) , this . getCurrentValues ( ) ) ; public submit = ( event ? ) => { const { onSubmit , onValidSubmit , onInvalidSubmit , preventDefaultSubmit } = this . props ; const { isValid } = this . state ; if ( preventDefaultSubmit && event && event . preventDefault ) { event . preventDefault ( ) ; } this . setFormPristine ( false ) ; const model = this . getModel ( ) ; onSubmit ( model , this . resetModel , this . updateInputsWithError , event ) ; if ( isValid ) { onValidSubmit ( model , this . resetModel , this . updateInputsWithError , event ) ; } else { onInvalidSubmit ( model , this . resetModel , this . updateInputsWithError , event ) ; } } ; public updateInputsWithError : IUpdateInputsWithError = ( errors , invalidate ) => { const { preventExternalInvalidation } = this . props ; const { isValid } = this . state ; Object . entries ( errors ) . forEach ( ( [ name , error ] ) => { const component = this . inputs . find ( ( input ) => input . props . name === name ) ; if ( ! component ) { throw new Error ( `You are trying to update an input that does not exist. Verify errors object with input names. ${ JSON . stringify ( errors ) }` ) ; } component . setState ( { isValid : preventExternalInvalidation , validationError : utils . isString ( error ) ? [ error ] : error } ) ; } ) ; if ( invalidate && isValid ) { this . setFormValidState ( false ) ; } } ; public updateInputsWithValue : IUpdateInputsWithValue < any > = ( data , validate ) => { this . inputs . forEach ( ( component ) => { const { name } = component . props ; if ( data && has ( data , name ) ) { component . setValue ( get ( data , name ) , validate ) ; } } ) ; } ; public validate = < V > ( component ) => { const { onChange } = this . props ; const { canChange } = this . state ; if ( canChange ) { onChange ( this . getModel ( ) , this . isChanged ( ) ) ; } const validationState = this . runValidation < V > ( component ) ; component . setState ( validationState , this . validateForm ) ; } ; public validateForm = ( ) => { const onValidationComplete = ( ) => { const allIsValid = this . inputs . every ( ( component ) => component . state . isValid ) ; this . setFormValidState ( allIsValid ) ; this . setState ( { canChange : true } ) ; } ; this . inputs . forEach ( ( component , index ) => { const validationState = this . runValidation ( component ) ; const isFinalInput = index === this . inputs . length - 1 ; const callback = isFinalInput ? onValidationComplete : null ; component . setState ( validationState , callback ) ; } ) ; if ( ! this . inputs . length ) { this . setState ( { canChange : true } , onValidationComplete ) ; } } ; public render ( ) { const { children , mapping , onChange , onInvalid , onInvalidSubmit , onReset , onSubmit , onValid , onValidSubmit , preventDefaultSubmit , preventExternalInvalidation , validationErrors , disabled , formElement , ... nonFormsyProps } = this . props ; const { contextValue } = this . state ; return React . createElement ( FormsyContext . Provider , { value : contextValue } , React . createElement ( formElement , { onReset : this . resetInternal , onSubmit : this . submit , ... nonFormsyProps , disabled } , children ) ) ; } }