houdini/app/javascript/legacy/common/ff-form-validation/index.es6

181 lines
6.3 KiB
Text
Raw Normal View History

2019-11-06 20:36:28 +00:00
// License: LGPL-3.0-or-later
const h = require('snabbdom/h')
const R = require('ramda')
const flyd = require('flyd')
const serializeForm = require('form-serialize')
flyd.filter = require('flyd/module/filter')
flyd.mergeAll = require('flyd/module/mergeall')
flyd.keepWhen = require('flyd/module/keepwhen')
flyd.sampleOn = require('flyd/module/sampleon')
const readableProp = require('./lib/readable-prop.es6')
const emailRegex = require('./lib/email-regex.es6')
const currencyRegex = require('./lib/currency-regex.es6')
// constraints: a hash of key/vals where each key is the name of an input
// and each value is an object of validator names and arguments
//
// validators: a hash of validation names mapped to boolean functions
//
// messages: a hash of validator names and field names mapped to error messages
//
// messages match on the most specific thing in the messages hash
// - first checks if there is an exact match on field name
// - then checks for match on validator name
//
// Given a constraint like:
// {name: {required: true}}
//
// All of the following will set an error for above, starting with most specific first:
// {name: {required: 'Please enter a valid name'}
// {name: 'Please enter your name'}
// {required: 'This is required'}
function init(state) {
state = R.merge({
validators: R.merge(defaultValidators, state.validators || {})
, messages: R.merge(defaultMessages, state.messages || {})
, focus$: flyd.stream()
, change$: flyd.stream()
, submit$: flyd.stream()
}, state || {})
const valField = validateField(state)
const valForm = validateForm(state)
const fieldErr$ = flyd.map(valField, state.change$)
const formErr$ = flyd.map(valForm, state.submit$)
const clearErr$ = flyd.map(ev => [ev.target.name, null], state.focus$)
const allErrs$ = flyd.mergeAll([fieldErr$, formErr$, clearErr$])
// Stream of all errors combined into one object
state.errors$ = flyd.scan(R.assoc, {}, allErrs$)
// Stream of field names and new values and whole form data
state.nameVal$ = flyd.map(node => [node.name, node.value], state.change$)
state.data$ = flyd.scan(R.assoc, {}, state.nameVal$)
// Streams of errors and data on form submit
const errorsOnSubmit$ = flyd.sampleOn(state.submit$, state.errors$)
state.validSubmit$ = flyd.filter(R.compose(R.none, R.values), errorsOnSubmit$)
state.validData$ = flyd.keepWhen(state.validSubmit$, state.data$)
return state
}
// Pass in an array of validation functions and the event object
// Will return a pair of [name, errorMsg] (errorMsg will be null if no errors present)
const validateField = R.curry((state, node) => {
const value = node.value
const name = node.name
if(!state.constraints[name]) return [name, null] // no validators for this field present
// Find the first constraint that fails its validator
for(var valName in state.constraints[name]) {
const arg = state.constraints[name][valName]
if(!state.validators[valName]) {
console.warn("Form validation constraint does not exist:", valName)
} else if(!validators[valName](value, arg)) {
const msg = getErr(messages, name, valName, arg)
return [name, String(msg)]
}
}
return [name, null] // no error found
})
// Given the messages object, the validator argument, the field name, and the validator name
// Retrieve and apply the error message function
const getErr = (messages, name, valName, arg) => {
const err = messages[name]
? messages[name][valName] || messages[name]
: messages[valName]
if(typeof err === 'function') return err(arg)
else return err
}
// Retrieve errors for the entire set of form data, used on form submit events,
// using the form data saved into the state
const validateForm = R.curry((state, node) => {
const formData = serializeForm(node, {hash: true})
for(var fieldName in constraints) { // not using higher order .map or reduce so we can break and return early
for(var valName in constraints[fieldName]) {
const arg = constraints[fieldName][valName]
if(!validators[valName]) {
console.warn("Form validation constraint does not exist:", valName)
} else if(!validators[valName](value, arg)) {
const msg = getErr(messages, name, valName, arg)
return [name, String(msg)]
}
}
}
}
// -- Views
const validatedForm = R.curry((state, elm) => {
elm.data = R.merge(elm.data, {
on: {submit: ev => {ev.preventDefault(); state.submit$(ev.currentTarget)}}
})
return elm
})
// A single form field
// Data takes normal snabbdom data for the input/select/textarea (eg props, style, on)
const validatedField = R.curry((state, elm) => {
if(!elm.data.props || !elm.data.props.name) throw new Error(`You need to provide a field name for validation (using the 'props.name' property)`)
var err = state.errors$()[elm.data.props.name]
var invalid = err && err.length
elm.data = R.merge(elm.data, {
on: {
focus: state.focus$
, change: ev => state.change$([ev.currentTarget, state])
}
, class: { invalid }
})
return h('div', {
props: {className: 'ff-field' + (invalid ? ' ff-field--invalid' : ' ff-field--valid')}
}, [
invalid ? h('p.ff-field-errorMessage', err) : ''
, elm
])
})
var defaultMessages = {
email: 'Please enter a valid email address'
, required: 'This field is required'
, currency: 'Please enter valid currency'
, format: "This doesn't look like the right format"
, isNumber: 'This should be a number'
, max: n => `This should be less than ${n}`
, min: n => `This should be at least ${n}`
, equalTo: n => `This should be equal to ${n}`
, maxLength: n => `This should be no longer than ${n}`
, minLength: n => `This should be longer than ${n}`
, lengthEquals: n => `This should have a length of ${n}`
, includedIn: arr => `This should be one of: ${arr.join(', ')}`
}
var defaultValidators = {
email: val => val.match(emailRegex)
, present: val => Boolean(val)
, currency: val => String(val).match(currencyRegex)
, format: (val, arg) => String(val).match(arg)
, isNumber: val => !isNaN(val)
, max: (val, n) => val <= n
, min: (val, n) => val >= n
, equalTo: (val, n) => n === val
, maxLength: (val, n) => val.length <= n
, minLength: (val, n) => val.length >= n
, lengthEquals: (val, n) => val.length === n
, includedIn: (val, arr) => arr.indexOf(val) !== -1
}
module.exports = {init, validatedField, validatedForm}