UNPKG

react-dynamic-forms

Version:

Dynamic forms library for React

504 lines (462 loc) 18.9 kB
/** * Copyright (c) 2015 - present, The Regents of the University of California, * through Lawrence Berkeley National Laboratory (subject to receipt * of any required approvals from the U.S. Dept. of Energy). * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ import React from "react"; import _ from "underscore"; import deepCopy from "deepcopy"; import Flexbox from "flexbox-react"; import PropTypes from "prop-types"; import Field from "./Field"; import Schema from "./Schema"; import { FormEditStates, FormGroupLayout } from "../js/constants"; // Pass in the <Schema> element and will return all the <Fields> under it. function getFieldsFromSchema(schema) { if (!React.isValidElement(schema)) { return {}; } let fields = {}; if (schema.type === Schema) { React.Children.forEach(schema.props.children, child => { if (child.type === Field) { fields[child.props.name] = deepCopy(child.props); } }); } return fields; } function getRulesFromSchema(schema) { if (!React.isValidElement(schema)) { return {}; } let rules = {}; if (schema.type === Schema) { React.Children.forEach(schema.props.children, child => { if (child.type === Field) { const required = child.props.required || false; const validation = deepCopy(child.props.validation); // the conform is a function that can not be copied properly if (child.props.validation && "conform" in child.props.validation) { validation["conform"] = child.props.validation["conform"]; } rules[child.props.name] = { required, validation }; } }); } return rules; } export default class Form extends React.Component { constructor(props) { super(props); this.state = { missingCounts: {}, errorCounts: {}, selection: null }; } /** * Collect together props for the given fieldName which can * be applied to any of the formGroup wrapped form widgets. These * props contain info extracted from our schema and current * values, namely from: * - formFields * - formRules * - formValues * * In addition, the props contain callbacks for: * - value changed * - missing count changed * - error counts changed * - edit selection */ getFieldProps({ formFields, formRules, formHiddenList }, fieldName) { let props = {}; props.labelWidth = this.props.labelWidth || 300; if (_.has(formFields, fieldName)) { props.key = fieldName; props.name = fieldName; props.label = formFields[fieldName].label; props.placeholder = formFields[fieldName].placeholder; props.help = formFields[fieldName].help; props.hidden = false; props.disabled = false; props.edit = false; props.showRequired = true; if (this.props.edit === FormEditStates.SELECTED) { props.edit = this.state.selection === fieldName; props.showRequired = props.edit; props.allowEdit = true; } else if (this.props.edit === FormEditStates.ALWAYS) { props.edit = true; } else if (this.props.edit === FormEditStates.NEVER) { props.showRequired = false; } if (this.props.edit === FormEditStates.TABLE) { props.layout = FormGroupLayout.INLINE; } else { props.layout = this.props.groupLayout; } if (formFields[fieldName].disabled) { props.disabled = true; } if (_.contains(formHiddenList, fieldName)) { props.disabled = true; props.hidden = true; } } else { throw new Error(`Attr '${fieldName}' is not a part of the form schema`); } // If the field is required and validation rules if (_.has(formRules, fieldName)) { props.required = formRules[fieldName].required; props.validation = formRules[fieldName].validation; } // Field value if (this.props.value.has(fieldName)) { props.value = this.props.value.get(fieldName); } // Callbacks props.onSelectItem = (fieldName) => this.handleSelectItem(fieldName); props.onErrorCountChange = (fieldName, count) => this.handleErrorCountChange(fieldName, count); props.onMissingCountChange = (fieldName, count) => this.handleMissingCountChange(fieldName, count); props.onChange = (fieldName, d) => this.handleChange(fieldName, d); props.onBlur = (fieldName) => this.handleBlur(fieldName); return props; } /** * Queue state pushes pending value of state to our parent's callback. The important * thing here is that the action is deferred, meaning it will be called only * after the callstack is unwound. The deferred action also blocks other deferred * actions until it is run. * * When the deferred action takes place, the following happens: * * 1 A user action occurs * 2 queueChange is called one or many times * 3 stack unwinds... * -- * 4 A state structure is constructed out of the pending structures * 5 setState is actually called, which will cause React to re-render * 5a rendering may mount new form elements, which may themselves * result in calls to queueChange() (for example: mounted components * will report their missing/error states via supplied callbacks) * 5b those changes will also be added to the pending structures, but will * not be flushed until the outer queueChange deferred action is complete * 6 callbacks registered with us are called with updated values, missing counts * and error counts * 7 stack unwinds... * -- * 8 stack unwinds again and the deferred action will be called again if another was created * as a side effect of step (5) above */ queueChange() { if (!this._deferSet) { _.defer(() => { this._deferSet = false; // Write in missingCounts and errorCounts into our state let state = {}; if (this._pendingMissing) { state.missingCounts = this._pendingMissing; } if (this._pendingErrors) { state.errorCounts = this._pendingErrors; } this.setState(state); let missingCount = 0; let errorCount = 0; const schema = this.props.schema; const formFields = getFieldsFromSchema(schema); const ignoreList = this.getHiddenFields(formFields); // Missing count callback if (this._pendingMissing) { const missingFields = []; _.each(this._pendingMissing, (c, fieldName) => { if (!_.contains(ignoreList, fieldName)) { missingCount += c; missingFields.push(fieldName); } }); if (this.props.onMissingCountChange) { this.props.onMissingCountChange( this.props.name, missingCount, missingFields ); } this._pendingMissing = null; } // Error callback if (this._pendingErrors) { const errorFields = []; _.each(this._pendingErrors, (c, fieldName) => { if (!_.contains(ignoreList, fieldName)) { missingCount += c; errorFields.push(fieldName); } errorCount += c; }); if (this.props.onErrorCountChange) { this.props.onErrorCountChange(this.props.name, errorCount, errorFields); } } // On change callback if (this._pendingValues) { if (this.props.onChange) { this.props.onChange(this.props.name, this._pendingValues); } this._pendingValues = null; } }); this._deferSet = true; } } /** * If the form has a submit input and that fires then this will catch that * and pass it up to the forms onSubmit callback. */ handleSubmit(e) { e.preventDefault(); } /** * This is the handler for changes to the error state of this form's fields. * * If a field is complex, such as another form or a list view, then errorCount * will be the telly all the errors within that form or list. If it is a simple * field control, such as a textedit then the errorCount will be either 0 or 1. * * The mapping of field names (passed in as the fieldName) and the count is updated * in _pendingErrors until built up state is flushed to the related callback. */ handleErrorCountChange(fieldName, errorCount) { this._pendingErrors = this._pendingErrors || deepCopy(this.state.errorCounts) || {}; this._pendingErrors[fieldName] = errorCount; this.queueChange(); } /** * This is the handler for changes to the missing state of this form controls. * * If a field is complex, such as another form or a list view, then missingCount * will be the telly all the missing values (for required fields) within that * form or list. If it is a simple control such as a textedit then the * missingCount will be either 0 or 1. * * The mapping of field names (passed in as the fieldName) and the missing count is * updated in _pendingMissing until built up state is flushed to the related callback. */ handleMissingCountChange(fieldName, missingCount) { this._pendingMissing = this._pendingMissing || deepCopy(this.state.missingCounts) || {}; this._pendingMissing[fieldName] = missingCount; this.queueChange(); } /** * This is the main handler for value change notifications from * this form's controls. * * As part of this handler we call this.props.onPendingChange() * if it is supplied. This hook enables either the value to be modified * before it is included in the updated state. * * Changes to the formValues are queued in _pendingValues * until built up change is flushed to the onChange callback. */ handleChange(fieldName, newValue) { // Hook to allow the component to alter the value before it is set. // However, you should be careful with side effects to state here. let v = newValue; if (this.props.onPendingChange) { v = this.props.onPendingChange(fieldName, newValue) || v; } // If we don't have pending values then we build initialize them // out of the current values, then build on top of that with any // change notifications we get. We deliver those batched together // in queueChange after we've accumulated missing and error counts. this._pendingValues = this._pendingValues || this.props.value; this._pendingValues = this._pendingValues.set(fieldName, v); this.queueChange(); } handleBlur(fieldName) { if (this.state.selection) { this.setState({ selection: null }); } } /** * Handle the selection change. This is when you have an inline form * and the user clicks on the pencil icon to activate editing of * that item. That item is the selection. Only one item can be selected * at once. If the same item is selected again it is deselected. */ handleSelectItem(fieldName) { if (this.state.selection !== fieldName) { this.setState({ selection: fieldName }); } else { this.setState({ selection: null }); } } /** * @private * * Returns the current list of hidden form fields using the `visible` prop * That prop is either a tag or list of tags. Those are compared to tags * for each field within the schema to determine a visibility set of fields. * This is called every render. */ getHiddenFields(formFields) { let result = []; if (this.props.visible) { _.each(formFields, (field, fieldName) => { let makeHidden; const tags = field.tags || []; if (_.isArray(this.props.visible)) { makeHidden = !(_.intersection(tags, this.props.visible).length > 0 || _.contains(tags, "all")); } else { makeHidden = !(_.contains(tags, this.props.visible) || _.contains(tags, "all")); } if (makeHidden) { result.push(fieldName); } }); } return result; } /** * @private * * Traverses all the children and builds the set of props for each element. * This is what takes the prop `field="field_id"`, looks up "field_id" on the schema * then applies all the needed props from the schema, along with callbacks to * track state. */ renderChildren(formState, childList) { const childCount = React.Children.count(childList); let children = []; React.Children.forEach(childList, (child, i) => { if (child) { let newChild; const key = child.key || `key-${i}`; let props = { key }; if (typeof child.props.children !== "string") { if (_.has(child.props, "field")) { const fieldName = child.props.field; props = { ...props, ...this.getFieldProps(formState, fieldName) }; } if (React.Children.count(child.props.children) > 0) { props = { ...props, children: this.renderChildren(formState, child.props.children) }; } } newChild = React.cloneElement(child, props); if (childCount > 1) { children.push(newChild); } else { children = newChild; } } else { children = null; } }); return children; } /** * Restrict how often we render the form. It's likely that the container * for the form is keeping track of other state such as missing counts, so * here we make sure something we care about actually changed before doing * the whole form render. */ shouldComponentUpdate(nextProps, nextState) { const update = nextProps.value !== this.props.value || nextProps.edit !== this.props.edit || nextProps.schema !== this.props.schema || nextProps.visibility !== this.props.visibility || nextState.selection !== this.state.selection; return update; } /** * Render the form and all its children. */ render() { const inner = this.props.inner; const schema = this.props.schema; const formFields = getFieldsFromSchema(schema); const formRules = getRulesFromSchema(schema); const formHiddenList = this.getHiddenFields(formFields); const formState = { formFields, formRules, formHiddenList }; /* <form class="form-inline"> <div class="form-group"> <label class="sr-only" for="exampleInputEmail3">Email address</label> <input type="email" class="form-control" id="exampleInputEmail3" placeholder="Email"> </div> <div class="form-group"> <label class="sr-only" for="exampleInputPassword3">Password</label> <input type="password" class="form-control" id="exampleInputPassword3" placeholder="Password"> </div> <div class="checkbox"> <label> <input type="checkbox"> Remember me </label> </div> <button type="submit" class="btn btn-default">Sign in</button> </form> */ let formClass = this.props.formClassName; if (this.props.inline) { formClass += "form-inline"; } if (this.props.edit === FormEditStates.TABLE) { return ( <Flexbox flexDirection="row" className={this.props.formClassName} key={this.props.formKey} > {this.renderChildren(formState, this.props.children)} </Flexbox> ); } else { if (inner) { return ( <form className={formClass} style={this.props.formStyle} key={this.props.formKey} onSubmit={e => { this.handleSubmit(e); }} noValidate > {this.renderChildren(formState, this.props.children)} </form> ); } else { return ( <div className={this.props.formClassName} style={this.props.formStyle} key={this.props.formKey} > {this.renderChildren(formState, this.props.children)} </div> ); } } } } Form.propTypes = { value: PropTypes.object }; Form.defaultProps = { formStyle: {}, formClass: "form-horizontal", formKey: "form", groupLayout: FormGroupLayout.ROW };