d2-ui
Version:
451 lines (395 loc) • 18.1 kB
JavaScript
import React from 'react';
import PropTypes from 'prop-types';
import { isObject, get } from 'lodash';
import AsyncValidatorRunner from './AsyncValidatorRunner';
import CircularProgres from '../circular-progress/CircularProgress';
const noop = () => {};
class FormBuilder extends React.Component {
constructor(props) {
super(props);
this.state = this.initState(props);
this.asyncValidators = this.createAsyncValidators(props);
this.asyncValidationRunner = props.asyncValidationRunner || new AsyncValidatorRunner();
this.getFieldProp = this.getFieldProp.bind(this);
this.getStateClone = this.getStateClone.bind(this);
}
/**
* Called by React when the component receives new props, but not on the initial render.
*
* State is calculated based on the incoming props, in such a way that existing form fields
* are updated as necessary, but not overridden. See the initState function for details.
*
* @param props
*/
componentWillReceiveProps(props) {
this.asyncValidators = this.createAsyncValidators(props);
const clonedState = this.getStateClone();
props.fields
// Only check fields that are set on the component state
.filter(field => this.state && this.state.fields && this.state.fields[field.name])
// Filter out fields where the values changed
.filter(field => field.value !== this.state.fields[field.name].value)
// Change field value and run validators for the field
.forEach((field) => {
clonedState.fields[field.name].value = field.value;
this.validateField(clonedState, field.name, field.value);
});
this.setState(clonedState);
}
/**
* Custom state deep copy function
*
* @returns {{form: {pristine: (boolean), valid: (boolean), validating: (boolean)}, fields: *}}
*/
getStateClone() {
return {
form: {
pristine: this.state.form.pristine,
valid: this.state.form.valid,
validating: this.state.form.validating,
},
fields: Object.keys(this.state.fields).reduce((p, c) => {
p[c] = {
pristine: this.state.fields[c].pristine,
validating: this.state.fields[c].validating,
valid: this.state.fields[c].valid,
value: this.state.fields[c].value,
error: this.state.fields[c].error,
};
return p;
}, {}),
};
}
/**
* Render the form fields.
*
* @returns {*} An array containing markup for each form field
*/
renderFields() {
const styles = {
field: {
position: 'relative',
},
progress: this.props.validatingProgressStyle,
validatingErrorStyle: {
color: 'orange',
},
};
return this.props.fields.map((field) => {
const { errorTextProp, ...props } = field.props || {};
const fieldState = this.state.fields[field.name] || {};
const changeHandler = this.handleFieldChange.bind(this, field.name);
const onBlurChangeHandler = props.changeEvent === 'onBlur' ?
(e) => {
const stateClone = this.updateFieldState(this.getStateClone(), field.name, { value: e.target.value });
this.validateField(stateClone, field.name, e.target.value);
this.setState(stateClone);
} :
undefined;
const errorText = fieldState && fieldState.validating
? field.validatingLabelText || this.props.validatingLabelText
: errorTextProp;
return (
<div key={field.name} style={Object.assign({}, styles.field, this.props.fieldWrapStyle)}>
{fieldState.validating ? (
<CircularProgres mode="indeterminate" size={0.33} style={styles.progress} />
) : undefined}
<field.component
value={fieldState.value}
onChange={props.changeEvent && props.changeEvent === 'onBlur' ? onBlurChangeHandler : changeHandler}
onBlur={props.changeEvent && props.changeEvent === 'onBlur' ? changeHandler : undefined}
errorStyle={fieldState.validating ? styles.validatingErrorStyle : undefined}
errorText={fieldState.valid ? errorText : fieldState.error}
{...props}
/>
</div>
);
});
}
/**
* Render the component
*
* @returns {XML}
*/
render() {
return (
<div style={this.props.style}>
{this.renderFields()}
</div>
);
}
/**
* Calculates initial state based on the provided props and the existing state, if any.
*
* @param props
* @returns {{form: {pristine: (boolean), valid: (boolean), validating: (boolean)}, fields: *}}
*/
initState(props) {
const state = {
fields: props.fields.reduce((fields, field) => {
const currentFieldState = this.state && this.state.fields && this.state.fields[field.name];
return Object.assign(fields, {
[field.name]: {
value: currentFieldState !== undefined && !currentFieldState.pristine
? currentFieldState.value
: field.value,
pristine: currentFieldState !== undefined
? currentFieldState.value === field.value
: true,
validating: currentFieldState !== undefined ? currentFieldState.validating : false,
valid: currentFieldState !== undefined ? currentFieldState.valid : true,
error: currentFieldState && currentFieldState.error || undefined,
},
});
}, {}),
};
state.form = {
pristine: Object.keys(state.fields).reduce((p, c) => p && state.fields[c].pristine, true),
validating: Object.keys(state.fields).reduce((p, c) => p || state.fields[c].validating, false),
valid: Object.keys(state.fields).reduce((p, c) => p && state.fields[c].valid, true),
};
return state;
}
/**
* Create an object with a property for each field that has async validators, which is later used
* to store Rx.Observable's for any currently running async validators
*
* @param props
* @returns {*}
*/
createAsyncValidators(props) {
return props.fields
.filter(field => Array.isArray(field.asyncValidators) && field.asyncValidators.length)
.reduce((p, currentField) => {
p[currentField.name] = (this.asyncValidators && this.asyncValidators[currentField.name]) || undefined;
return p;
}, {});
}
/**
* Cancel the currently running async validators for the specified field name, if any.
*
* @param fieldName
*/
cancelAsyncValidators(fieldName) {
if (this.asyncValidators[fieldName]) {
this.asyncValidators[fieldName].unsubscribe();
this.asyncValidators[fieldName] = undefined;
}
}
/**
* Utility method to mutate the provided state object in place
*
* @param state A state object
* @param fieldName A valid field name
* @param fieldState Mutations to apply to the specified field name
* @returns {*} A reference to the mutated state object for chaining
*/
updateFieldState(state, fieldName, fieldState) {
const fieldProp = this.getFieldProp(fieldName);
state.fields[fieldName] = {
pristine: fieldState.pristine !== undefined
? !!fieldState.pristine
: state.fields[fieldName].value === fieldProp.value,
validating: fieldState.validating !== undefined ? !!fieldState.validating : state.fields[fieldName].validating,
valid: fieldState.valid !== undefined ? !!fieldState.valid : state.fields[fieldName].valid,
error: fieldState.error,
value: fieldState.value !== undefined ? fieldState.value : state.fields[fieldName].value,
};
// Form state is a composite of field states
const fieldNames = Object.keys(state.fields);
state.form = {
pristine: fieldNames.reduce((p, current) => p && state.fields[current].pristine, true),
validating: fieldNames.reduce((p, current) => p || state.fields[current].validating, false),
valid: fieldNames.reduce((p, current) => p && state.fields[current].valid, true),
};
return state;
}
/**
* Field value change event
*
* This is called whenever the value of the specified field has changed. This will be the onChange event handler, unless
* the changeEvent prop for this field is set to 'onBlur'.
*
* The change event is processed as follows:
*
* - If the value hasn't actually changed, processing stops
* - The field status is set to [not pristine]
* - Any currently running async validators are cancelled
*
* - All synchronous validators are called in the order specified
* - If a validator fails:
* - The field status is set to invalid
* - The field error message is set to the error message for the validator that failed
* - Processing stops
*
* - If all synchronous validators pass:
* - The field status is set to [valid]
* - If there are NO async validators for the field:
* - The onUpdateField callback is called, and processing is finished
*
* - If there ARE async validators for the field:
* - All async validators are started immediately
* - The field status is set to [valid, validating]
* - The validators keep running asynchronously, but the handleFieldChange function terminates
*
* - The async validators keep running in the background until ONE of them fail, or ALL of them succeed:
* - The first async validator to fail causes all processing to stop:
* - The field status is set to [invalid, not validating]
* - The field error message is set to the value that the validator rejected with
* - If all async validators complete successfully:
* - The field status is set to [valid, not validating]
* - The onUpdateField callback is called
*
* @param fieldName The name of the field that changed.
* @param event An event object. Only `event.target.value` is used.
*/
handleFieldChange(fieldName, event) {
const newValue = event.target.value;
const field = this.getFieldProp(fieldName);
// If the field has changeEvent=onBlur the change handler is triggered whenever the field loses focus.
// So if the value didn't actually change, abort the change handler here.
if (field.props && field.props.changeEvent === 'onBlur' && newValue === field.value) {
return;
}
// Using custom clone function to maximize speed, albeit more error prone
const stateClone = this.getStateClone();
// Update value, and set pristine to false
this.setState(this.updateFieldState(stateClone, fieldName, { pristine: false, value: newValue }),
() => {
if (!isObject(newValue) && newValue === (field.value ? field.value : '')) {
this.props.onUpdateField(fieldName, newValue);
return;
}
// Cancel async validators in progress (if any)
if (this.asyncValidators[fieldName]) {
this.cancelAsyncValidators(fieldName);
this.setState(this.updateFieldState(stateClone, fieldName, { validating: false }));
}
// Run synchronous validators
const validatorResult = this.validateField(stateClone, fieldName, newValue);
// Async validators - only run if sync validators pass
if (validatorResult === true) {
this.runAsyncValidators(field, stateClone, fieldName, newValue);
} else {
// Sync validators failed set field status to false
this.setState(this.updateFieldState(stateClone, fieldName, { valid: false, error: validatorResult }), () => {
// Also emit when the validator result is false
this.props.onUpdateFormStatus(this.state.form);
this.props.onUpdateField(fieldName, newValue);
});
}
});
}
runAsyncValidators(field, stateClone, fieldName, newValue) {
if ((field.asyncValidators || []).length > 0) {
// Set field and form state to 'validating'
this.setState(this.updateFieldState(stateClone, fieldName, { validating: true }),
() => {
this.props.onUpdateFormStatus(this.state.form);
this.props.onUpdateField(fieldName, newValue);
// TODO: Subscription to validation results could be done once in `componentDidMount` and be
// disposed in the `componentWillUnmount` method. This way we don't have to create the
// subscription every time the field is changed.
this.asyncValidators[fieldName] = this.asyncValidationRunner
.listenToValidatorsFor(fieldName, stateClone)
.subscribe(
(status) => {
this.setState(
this.updateFieldState(
this.getStateClone(), status.fieldName, {
validating: false,
valid: status.isValid,
error: status.message,
},
), () => {
this.cancelAsyncValidators(status.fieldName);
this.props.onUpdateFormStatus(this.state.form);
},
);
},
);
this.asyncValidationRunner.run(fieldName, field.asyncValidators, newValue);
});
} else {
this.setState(this.updateFieldState(stateClone, fieldName, { valid: true }), () => {
this.props.onUpdateFormStatus(this.state.form);
this.props.onUpdateField(fieldName, newValue);
});
}
}
/**
* Run all synchronous validators (if any) for the field and value, and update the state clone depending on the
* outcome
*
* @param stateClone A clone of the current state
* @param fieldName The name of the field to validate
* @param newValue The value to validate
* @returns {true|String} The error message from the first validator that fails, or true if they all pass
*/
validateField(stateClone, fieldName, newValue) {
const field = this.getFieldProp(fieldName);
let validatorResult = (field.validators || [])
.reduce((pass, currentValidator) => (pass === true
? (currentValidator.validator(newValue, stateClone) === true || currentValidator.message) : pass
), true);
if (get(field, 'fieldOptions.disabled')) {
validatorResult = true;
}
this.updateFieldState(stateClone, fieldName, {
valid: validatorResult === true,
error: validatorResult === true ? undefined : validatorResult,
});
return validatorResult;
}
/**
* Retreive the field that has the specified field name
*
* @param fieldName
* @returns {}
*/
getFieldProp(fieldName) {
return this.props.fields.filter(f => f.name === fieldName)[0];
}
}
/**
* Component prop types
* @type {{fields: (Object|isRequired), validatingLabelText: *, validatingProgressStyle: *, onUpdateField: (Function|isRequired)}}
*/
FormBuilder.propTypes = {
fields: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string.isRequired,
value: PropTypes.any,
component: PropTypes.func.isRequired,
props: PropTypes.shape({
changeEvent: PropTypes.oneOf(['onChange', 'onBlur']),
}),
validators: PropTypes.arrayOf(
PropTypes.shape({
validator: PropTypes.func.isRequired,
message: PropTypes.string.isRequired,
}),
),
asyncValidators: PropTypes.arrayOf(PropTypes.func.isRequired),
validatingLabelText: PropTypes.string,
})).isRequired,
validatingLabelText: PropTypes.string,
validatingProgressStyle: PropTypes.object,
onUpdateField: PropTypes.func.isRequired,
onUpdateFormStatus: PropTypes.func,
style: PropTypes.object,
fieldWrapStyle: PropTypes.object,
};
/**
* Default values for optional props
* @type {{validatingLabelText: string, validatingProgressStyle: {position: string, right: number, top: number}}}
*/
FormBuilder.defaultProps = {
validatingLabelText: 'Validating...',
validatingProgressStyle: {
position: 'absolute',
right: -12,
top: 16,
},
onUpdateFormStatus: noop,
};
export default FormBuilder;