// 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}