ui/Validation.js

import validate from 'validate.js'
import { createEvents } from '@/core/modules/createEvents'
import * as R from 'ramda'
import domify from 'domify'
import mitt from 'mitt'

/**
 * @namespace Validate
 * @class Validate
 * @description Handles form validation, and nothing else
 *
 * @example
 *
 * js:
 * const $form = document.querySelector('[data-form-delivery]')
 * const validator = new Validate($form, {
 * 	rules: {
 * 		email: {
 * 			presence: true,
 * 			email: true
 * 		},
 *
 * 		firstName: {
 * 			presence: true
 * 		},
 *
 * 		surname: {
 * 			presence: true
 * 		}
 * 	},
 * 	ajax: true
 * })
 *
 * validator.mount()
 *
 * validator.on('submit:success', ({ data }) => {
 * 	fetch(data, {
 * 		method: 'POST'
 * 	})
 * 	.then(response => response.json)
 * 	.then(data => {
 * 		// handle the response
 * 	})
 * })
 *
 * html:
 * <form>
 * 	<div class="flex-1" data-field-row="">
 * 		<label for="firstName">First Name*</label>
 * 		<input type="text" name="firstName" id="firstName" required>
 * 	</div>
 * 	<button type="submit">Submit</button>
 * </form>
 *
 * @param {HTMLElement} el - the form to validate
 * @param {Object} options - any form options
 * @property {Boolean} options.ajax - should prevent default be called when submitting the form
 * @property {Boolean} options.rules - form validation rules
 *
 */

export default class Validate {
	defaults = {
		ajax: true
	}

	constructor(el, options) {
		this.$el = el
		this.options = { ...this.defaults, ...options }
		Object.assign(this, mitt())
	}

	events = {
		'change [required]': 'onChange',
		submit: 'onSubmit'
	}

	/** *
	 * @memberof Validate
	 * @method setForm
	 * @desc set the form
	 * @param {HTMLElement} form - the form element to validate
	 *
	 * @return {Validate}
	 */
	setForm = form => {
		this.$el = form

		return this
	}

	/** *
	 * @memberof Validate
	 * @method setOptions
	 * @desc update the options
	 * @param {Object} options - validation rules and options
	 *
	 * @return {Validate}
	 */
	setOptions = options => {
		this.options = { ...this.options, options }

		return this
	}

	/** *
	 * @memberof Validate
	 * @method mount
	 * @desc bootstrap the form, add events
	 *
	 * @return {Validate}
	 */
	mount = () => {
		this.$$events = createEvents.call(this, this.$el, this.events)
		this.$$events.attachAll()

		const { rules } = this.options

		this.$fields = R.reduce((acc, field) => {
			const { name } = field
			const message = domify('<span class="form__error"></span>')
			const parent = field.closest('[data-field-row]')

			parent.appendChild(message)

			acc[name] = {
				field,
				parent,
				message
			}

			return acc
		}, {})([...this.$el.querySelectorAll('[required]')])

		if (rules) {
			this.$el.setAttribute('novalidate', true)
		}

		return this
	}

	/** *
	 * @memberof Validate
	 * @method unmount
	 * @desc remove the events and error nodes
	 *
	 * @return {void}
	 */
	unmount = () => {
		this.$$events.destroy()

		Object.entries(this.$fields).forEach(([, { message, parent }]) => {
			parent.removeChild(message)
		})
	}

	/** *
	 * @memberof Validate
	 * @method onChange
	 * @description Listen to change events on required fields
	 * @param {Object} e - event object
	 * @param {HTMLElement} elm - element that has changed
	 *
	 * @return {void}
	 */
	onChange = (e, elm) => {
		e.preventDefault()
		this.handleErrors(elm)
	}

	/** *
	 * @memberof Validate
	 * @method handleErrors
	 * @description add/remove errors
	 * @param {HTMLElement} elm - element that has changed
	 *
	 * @return {void}
	 */
	handleErrors = elm => {
		const { rules } = this.options
		const { name } = elm
		const errors = validate(this.$el, rules)
		const input = this.$fields[name]

		if (errors && errors[name]) {
			this.showError(input, errors[name])
			this.emit('input:error', {
				input,
				error: errors[name]
			})
		} else {
			this.removeError(input)
			this.emit('input:valid', {
				input
			})
		}
	}

	/** *
	 * @memberof Validate
	 * @method showError
	 * @description update the fields classes and inject the error message
	 * @param {Object} input - the fields object - includes, input, parent and message nodes
	 * @param {Array} error - an array of errors
	 *
	 * @return {void}
	 */
	showError = (input, error) => {
		const { message } = input

		Object.entries(input).forEach(([, node]) => {
			node.classList.remove('is-valid')
			node.classList.add('has-error')
		})

		message.textContent = error.join(' \n')
	}

	/** *
	 * @memberof Validate
	 * @method showError
	 * @description update the fields classes and remove the error message
	 * @param {Object} input - the fields object - includes, input, parent and message nodes
	 *
	 * @return {void}
	 */
	removeError = input => {
		const { message } = input

		Object.entries(input).forEach(([, node]) => {
			node.classList.remove('has-error')
			node.classList.add('is-valid')
		})

		message.textContent = ''
	}

	/** *
	 * @memberof Validate
	 * @method onSubmit
	 * @description handle form submission, check for errors and emit events
	 * @param {Object} e - the events object
	 *
	 * @return {void}
	 */
	onSubmit = e => {
		const { rules, ajax } = this.options

		const errors = validate(this.$el, rules)

		if (errors || ajax) e.preventDefault()

		if (errors) {
			const errorArray = Object.entries(errors).map(([key, value]) => {
				const input = this.$fields[key]
				this.handleErrors(input.field, value)

				return {
					input,
					value
				}
			})

			this.emit('submit:errors', errorArray)
		} else {
			this.emit('submit:success', {
				form: this.$el,
				fields: this.$fields,
				data: new FormData(this.$el)
			})
		}
	}
}