bloom-forms
Version:
form functionality used in bloom packages
518 lines (471 loc) • 15.5 kB
JSX
import React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { validatorAggregator as validator } from './validator'
import {
addFormError,
checkCompleted,
clearForm,
createForm,
deleteFormError,
onFocus,
updateDirtyFieldsArr,
updateVisibleFieldsArr,
checkForVisibleFields,
updateForm,
updateProcessingRequest
} from './formActions'
import './styles/form.scss'
// container for wrapping all forms with needed methods
export class Form extends React.Component {
state = {
attemptedSubmit: false,
prepopulated: false,
processingRequest: false
};
static defaultProps = {
testMode: false
};
static propTypes = {
addFormError: PropTypes.func,
clearForm: PropTypes.func,
createForm: PropTypes.func,
deleteFormError: PropTypes.func,
fieldNames: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
name: PropTypes.string.isRequired,
type: PropTypes.string
})
])
).isRequired,
forms: PropTypes.object,
id: PropTypes.string.isRequired,
ignoreFocusOnFirstElement: PropTypes.bool,
prepopulateData: PropTypes.object,
preserveAfterUnmount: PropTypes.bool,
submitForm: PropTypes.func.isRequired,
updateForm: PropTypes.func,
validationHelp: PropTypes.shape({
errorLanguage: PropTypes.object,
dictionary: PropTypes.object
}),
wrapInFormElement: PropTypes.bool
}; // make sure only those that don't come from redux are required for better error logging to end user
static mapDispatchToProps(dispatch, ownProps) {
return {
updateProcessingRequest: (processing, formId) =>
dispatch(updateProcessingRequest(processing, formId)),
addFormError: (formId = ownProps.id, fieldName, errorMessage) =>
dispatch(addFormError(formId, fieldName, errorMessage)),
checkCompleted: (formId = ownProps.id) =>
dispatch(checkCompleted(formId)),
checkForVisibleFields: (formId = ownProps.id) =>
dispatch(checkForVisibleFields(formId)),
clearForm: (formId = ownProps.id) => dispatch(clearForm(formId)),
createForm: (formId = ownProps.id, formObject) =>
dispatch(createForm(formId, formObject)),
deleteFormError: (formId = ownProps.id, fieldName) =>
dispatch(deleteFormError(formId, fieldName)),
onFocus: (formId = ownProps.id, fieldName) =>
dispatch(onFocus(formId, fieldName)),
updateForm: (
e = null,
formId = ownProps.id,
fieldName = null,
optValue = null,
optType = null,
optMultiple = null
) => {
// optType is for manualFieldUpdate and testing //optMultiple is for multi file uploads
fieldName =
fieldName || (e && e.target ? e.target.getAttribute('name') : null)
const type =
optType ||
(
document.getElementById(fieldName) ||
[...document.getElementsByName(fieldName)][0]
).getAttribute('type')
const fields = ownProps.fieldNames.map(field => {
if (typeof field === 'object') {
return field.name
} else return field
})
if (fields.indexOf(fieldName) < 0) {
return console.error(
`Field Name ${fieldName} does not exist on ${ownProps.id}!`
)
}
return dispatch(
updateForm(e, formId, fieldName, optValue, type, optMultiple)
)
},
updateDirtyFieldsArr: (formId = ownProps.id, fieldName) =>
dispatch(updateDirtyFieldsArr(formId, fieldName)),
updateVisibleFieldsArr: (formId = ownProps.id, fieldNames) =>
dispatch(updateVisibleFieldsArr(formId, fieldNames))
}
}
static mapStateToProps(state) {
return {
forms: state.forms
}
}
checkField = async (e, elem = null) => {
// make sure we have all the values we need
const field = elem && elem.getAttribute ? elem : e.target
const fieldName = field.getAttribute('name')
const fieldValue =
(field && field.value && field.value.toString().trim()) || ''
const isRequired =
field.getAttribute('aria-required') || field.getAttribute('required')
try {
// use the validator to find the status of all fields
const fieldStatus = await validator(
{
[fieldName]: {
value: fieldValue,
validateAs: field.getAttribute('data-validate'),
name: fieldName
}
},
this.props.validationHelp
? this.props.validationHelp.errorLanguage
: null,
this.props.validationHelp ? this.props.validationHelp.dictionary : null
)
const allowDeletion = !isRequired || (fieldValue && isRequired)
if (fieldStatus.isValid && allowDeletion) {
if (this.props.deleteFormError) {
// for testing inner component without being connected to redux
this.props.deleteFormError(this.props.id, fieldName)
return Promise.resolve(true)
} else {
return Promise.resolve(true)
}
} else {
if (this.props.addFormError) {
// for testing inner component without being connected to redux
this.props.addFormError(
this.props.id,
fieldName,
fieldStatus.warnings[fieldName]
)
return Promise.resolve(false)
} else {
return Promise.resolve(true)
}
}
} catch (err) {
throw new Error(err)
}
};
processFormDataForSubmit = originalForm => {
const thisForm = { ...originalForm }
for (let field in thisForm) {
if (
thisForm[field].value ||
thisForm[field].value === '' ||
thisForm[field].value === false
) {
if (field.indexOf('confirm') > -1) {
// don't send two of the same field (confirm is for front end)
delete thisForm[field]
} else if (
thisForm[field].value[0] &&
thisForm[field].value[0].type &&
thisForm[field].value[0].name
) {
// contains files
thisForm.files =
thisForm.files &&
thisForm.files.keys() &&
Array.from(thisForm.files.keys()).length
? thisForm.files
: new FormData()
thisForm[field].value.forEach((elem, i) => {
thisForm.files.append(`${field}[${i}]`, elem)
})
delete thisForm[field]
} else if (field != 'isValid') {
thisForm[field] = thisForm[field].value
}
}
}
return thisForm
};
forwardToSubmitForm = async e => {
e.preventDefault()
this.props.updateProcessingRequest(true, this.props.id)
this.setState({
attemptedSubmit: true,
processingRequest: true
})
const thisForm =
this.props.forms && this.props.forms[this.props.id]
? this.processFormDataForSubmit({
...this.props.forms[this.props.id].fields
})
: {}
const unconvertedForm = { ...this.props.forms[this.props.id] }
const files = thisForm.files
if (thisForm.files) {
delete thisForm.files
}
// check each field if it's not a file or 'isValid'
const checkArr = []
for (let field in thisForm) {
if (
(thisForm[field] ||
thisForm[field] === '' ||
thisForm[field] === false) &&
document.getElementById(field)
) {
// validate each field in case onBlur on that field never triggered
checkArr.push(this.checkField(null, document.getElementById(field)))
}
}
return Promise.all(checkArr)
.then(isValidValues => {
if ((isValidValues || []).reduce((a, b) => a && b)) {
const successCallback = () => {
this.props.updateProcessingRequest(false, this.props.id)
this.setState({
processingRequest: false
})
}
const failCallback = () => {
this.props.updateProcessingRequest(false, this.props.id)
this.setState({
processingRequest: false
})
}
if (this.props.testMode) {
return {
thisForm,
files,
successCallback,
failCallback
}
} else {
return this.props.submitForm(
thisForm,
files,
successCallback,
failCallback
)
}
} else {
delete thisForm.isValid
this.props.updateProcessingRequest(false, this.props.id)
this.setState({
processingRequest: false
})
// debugging helper
console.log(
`form id '${this.props.id}' has invalid fields`,
unconvertedForm
)
}
})
.catch(err => {
throw new Error(err)
})
};
focusOnFirst = () => {
const form = document.getElementById(this.props.id)
if (form) {
let firstInput = [...form.querySelectorAll('input, select, textarea')][0]
if (firstInput) {
firstInput.focus()
}
}
};
getVisibleInputs = formId => {
const id = formId || this.props.id || this.props.formId
const el = document.getElementById(id)
if (el) {
const matches = el.querySelectorAll('input, select, textarea')
const fieldNames = []
for (var i = 0; i < matches.length; i++) {
fieldNames.push(matches[i].id)
}
this.props.updateVisibleFieldsArr(id, fieldNames)
}
};
manualFieldUpdate = (
formId = this.props.id,
fieldName,
fieldValue,
type = 'text',
multi = false
) => {
this.props.updateForm(null, formId, fieldName, fieldValue, type, multi)
};
populateFields = (props, responseData, oldFields) => {
let formData = { fields: {} }
// initialize the form with all fields inside
props.fieldNames.forEach(fieldName => {
if (fieldName.type) {
formData.fields[fieldName.name] = {}
switch (fieldName.type) {
case 'checkbox':
formData.fields[fieldName.name].value = false
break
case 'radio':
formData.fields[fieldName.name].value = false
break
default:
formData.fields[fieldName.name] = { value: '' }
}
} else {
formData.fields[fieldName.toString()] = { value: '' }
}
})
if (oldFields) {
formData.fields = { ...formData.fields, ...oldFields }
}
formData.awaitingCheck = []
formData.visibleFields = []
formData.touchedFieds = []
formData.dirtyFields = []
if (responseData) {
for (var key in responseData) {
// explode out any nested fields we might need
if (typeof responseData[key] == 'object') {
for (var field in responseData[key]) {
if (formData.fields[field]) {
// we only want fields that exist in the form to update
formData.fields[field].value = responseData[key][field]
}
}
} else if (formData.fields[key]) {
formData.fields[key].value = responseData[key]
if (!this.state.prepopulated && responseData[key]) {
this.setState({
prepopulated: true
})
}
}
}
}
if (props.createForm) {
props.createForm(props.id, formData)
}
};
componentWillUnmount() {
if (!this.props.preserveAfterUnmount) {
this.props.clearForm()
}
}
componentDidMount() {
if (this.props.prepopulateData) {
this.populateFields(this.props, this.props.prepopulateData)
} else {
this.populateFields(this.props)
}
if (!this.props.ignoreFocusOnFirstElement) {
this.focusOnFirst()
}
this.getVisibleInputs(this.props.id)
if (this.props.submitRoute) {
console.error(
`%c You're using "submitRoute" in form ${
this.props.id
}, which comes from a pre-release version of Bloom Forms. Please use "submitForm".`,
'color: red'
)
}
}
componentWillReceiveProps(newProps) {
if (
newProps.prepopulateData &&
(!this.props.prepopulateData ||
Object.values(this.props.prepopulateData)
.sort()
.toString() !=
Object.values(newProps.prepopulateData)
.sort()
.toString())
) {
this.populateFields(newProps, newProps.prepopulateData)
}
if (
newProps.forms &&
newProps.forms[newProps.id] &&
newProps.forms[newProps.id].checkForVisibleInputs &&
newProps.forms[newProps.id].checkForVisibleFields
) {
this.getVisibleInputs(newProps.id)
}
if (
newProps.forms &&
newProps.forms[newProps.id] &&
newProps.fieldNames.length !=
Object.keys(newProps.forms[newProps.id].fields).length
) {
this.populateFields(newProps, null, newProps.forms[newProps.id].fields)
}
if (
newProps.forms &&
newProps.forms[newProps.id] &&
newProps.forms[newProps.id].awaitingCheck &&
newProps.forms[newProps.id].awaitingCheck.length
) {
newProps.forms[newProps.id].awaitingCheck[0].fieldNames.forEach(name => {
const elem = document.getElementById(name)
if (elem) {
this.checkField(null, elem)
}
})
if (this.props.checkCompleted) {
// for testing inner component without being connected to redux
this.props.checkCompleted(newProps.id)
}
}
}
render() {
let { forms, submitForm, prepopulateData, ...props } = this.props
// make sure this works if the form has one child or many
let children = props.children
? Array.isArray(this.props.children)
? this.props.children
: [this.props.children]
: []
let thisForm = forms && forms[props.id] ? forms[props.id] : null
// clone the children to pass in custom props related to entire form
let formChildren = children.length
? React.Children.map(children, child => {
return React.cloneElement(child, {
addFormError: props.addFormError,
attemptedSubmit: this.state.attemptedSubmit,
checkField: this.checkField,
deleteFormError: props.deleteFormError,
formData: (thisForm && thisForm.fields) || {},
formId: props.id,
isValid: thisForm && thisForm.isValid,
manualFieldUpdate: this.manualFieldUpdate,
prepopulated: this.state.prepopulated,
processingRequest: this.state.processingRequest,
updateForm: props.updateForm,
submitForm: this.forwardToSubmitForm,
updateVisibleFieldsArr: this.updateVisibleFieldsArr,
onFocus: this.onFocus,
updateDirtyFieldsArr: this.updateDirtyFieldsArr,
...props
})
})
: children
if (props.wrapInFormElement) {
return (
<form id={props.id} className={props.className} noValidate>
{formChildren}
</form>
)
} else {
return <React.Fragment>{formChildren}</React.Fragment>
}
}
}
export default connect(Form.mapStateToProps, Form.mapDispatchToProps)(Form)