181 lines
6.3 KiB
Text
181 lines
6.3 KiB
Text
|
// 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}
|
||
|
|