UNPKG

@nubz/gds-validation

Version:

Form validation with GDS error templates, works with Govuk Prototype Kit

356 lines (328 loc) 16.5 kB
require('@js-joda/timezone') const Locale = require('@js-joda/locale_en').Locale const LocalDate = require('@js-joda/core').LocalDate const DateTimeFormatter = require('@js-joda/core').DateTimeFormatter const govDateFormat = DateTimeFormatter.ofPattern('d MMMM uuuu').withLocale(Locale.ENGLISH) const isoDateRegex = /^\d{4}-([0][1-9]|1[0-2])-([0-2][1-9]|[1-3]0|3[01])$/ const wholeDateErrors = ['date', 'required', 'beforeDate', 'afterDate', 'beforeFixedDate', 'afterFixedDate', 'beforeToday'] const dayErrors = ['dayRequired', 'dayAndYearRequired', 'dayAndMonthRequired'] const monthErrors = ['monthRequired', 'monthAndYearRequired', 'dayAndMonthRequired'] const yearErrors = ['yearRequired', 'monthAndYearRequired', 'dayAndYearRequired'] const addCommas = val => val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') const stripCommas = val => val.toString().trim().replace(/,/g, '') const zeroPad = val => !isNaN(+val) ? (val.toString().length === 1 ? '0' + val : val) : val const dateErrorLink = errorKey => { if (wholeDateErrors.includes(errorKey) || dayErrors.includes(errorKey)) { return 'day' } else if (monthErrors.includes(errorKey)) { return 'month' } else if (yearErrors.includes(errorKey)) { return 'year' } else throw `errorKey ${errorKey} not supported by dateErrorLink` } const getDateInputsInError = (fieldKey, field, value) => { const errorKey = getDateErrorKey(value) const inputs = [] if (wholeDateErrors.includes(errorKey) || dayErrors.includes(errorKey)) { inputs.push('day') } if (wholeDateErrors.includes(errorKey) || monthErrors.includes(errorKey)) { inputs.push('month') } if (wholeDateErrors.includes(errorKey) || yearErrors.includes(errorKey)) { inputs.push('year') } return inputs } const makeYearString = (data, key) => data.hasOwnProperty(`${key}-year`) && data[`${key}-year`].length > 0 ? data[`${key}-year`] : null const makeMonthString = (data, key) => data.hasOwnProperty(`${key}-month`) && data[`${key}-month`].length > 0 ? zeroPad(data[`${key}-month`]) : null const makeDayString = (data, key) => data.hasOwnProperty(`${key}-day`) && data[`${key}-day`].length > 0 ? zeroPad(data[`${key}-day`]) : null const makeDateString = (data, key) => { if (data.hasOwnProperty(key) && isoDateRegex.test(data[key])) { return data[key] } const year = makeYearString(data, key) const month = makeMonthString(data, key) const day = makeDayString(data, key) const hasMissingDateInputs = [day, month, year].some(input => !input) return hasMissingDateInputs ? [day, month, year] : `${year}-${month}-${day}` } const currencyDisplay = val => { if (!val) return '' const withoutCommas = stripCommas(val) const asFloat = parseFloat(withoutCommas) return ${addCommas(asFloat % 1 !== 0 ? Math.abs(asFloat).toFixed(2) : Math.abs(parseInt(withoutCommas)))}` } const isAfter = (value, min) => LocalDate.parse(value).isAfter(LocalDate.parse(min)) const isBefore = (value, max) => LocalDate.parse(value).isBefore(LocalDate.parse(max)) const minMaxTemplates = { number: { betweenMinAndMax: 'betweenMinAndMaxNumbers', min: 'numberMin', max: 'numberMax' }, currency: { betweenMinAndMax: 'betweenCurrencyMinAndMax', min: 'currencyMin', max: 'currencyMax' }, date: { betweenMinAndMax: 'betweenMinAndMaxDates', min: 'afterFixedDate', max: 'beforeFixedDate' } } const inputType = field => field.inputType || 'characters' const capitalise = word => word.charAt(0).toUpperCase() + word.slice(1) const slugify = str => str.toLowerCase().replace(/\W+/g, '-').replace(/-$/, '') const errorTemplates = { required: field => `Enter ${field.name}`, betweenMinAndMaxLength: field => `${capitalise(field.name)} must be between ${field.minLength} and ${field.maxLength} ${inputType(field)}`, betweenMinAndMaxNumbers: field => `${capitalise(field.name)} must be between ${field.evalMinValue} and ${field.evalMaxValue}`, betweenCurrencyMinAndMax: field => `${capitalise(field.name)} must be between ${currencyDisplay(field.evalMinValue)}${field.minDescription ? `, ${field.minDescription},` : ``} and ${currencyDisplay(field.evalMaxValue)}${field.maxDescription ? `, ${field.maxDescription}` : ``}`, betweenMinAndMaxDates: field => `${capitalise(field.name)} must be between ${LocalDate.parse(field.evalMinValue).format(govDateFormat)}${field.minDescription ? `, ${field.minDescription},` : ``} and ${LocalDate.parse(field.evalMaxValue).format(govDateFormat)}${field.maxDescription ? `, ${field.maxDescription}` : ``}`, tooShort: field => `${capitalise(field.name)} must must be ${field.minLength} ${inputType(field)} or more`, tooLong: field => `${capitalise(field.name)} must be ${field.maxLength} ${inputType(field)} or fewer`, exactLength: field => `${capitalise(field.name)} must be ${field.exactLength} ${inputType(field)}`, number: field => `${capitalise(field.name)} must be a number`, numberMin: field => `${capitalise(field.name)} must be ${field.evalMinValue} or more${field.minDescription ? `, ${field.minDescription}` : ``}`, numberMax: field => `${capitalise(field.name)} must be ${field.evalMaxValue} or less${field.maxDescription ? `, ${field.maxDescription}` : ``}`, currency: field => `${capitalise(field.name)} must be an amount of money`, currencyMin: field => `${capitalise(field.name)} must be ${currencyDisplay(field.evalMinValue)} or more${field.minDescription ? `, ${field.minDescription}` : ``}`, currencyMax: field => `${capitalise(field.name)} must be ${currencyDisplay(field.evalMaxValue)} or less${field.maxDescription ? `, ${field.maxDescription}` : ``}`, pattern: field => `${field.patternText || field.name + ` is not valid`}`, enum: field => `Select ${field.name}`, missingFile: field => `Upload ${field.name}`, date: field => `${capitalise(field.name)} must be a real date`, dayRequired: field => `${capitalise(field.name)} must include a day`, monthRequired: field => `${capitalise(field.name)} must include a month`, yearRequired: field => `${capitalise(field.name)} must include a year`, dayAndYearRequired: field => `${capitalise(field.name)} must include a day and a year`, dayAndMonthRequired: field => `${capitalise(field.name)} must include a day and a month`, monthAndYearRequired: field => `${capitalise(field.name)} must include a month and a year`, beforeToday: field => `${capitalise(field.name)} must be in the past`, afterToday: field => `${capitalise(field.name)} must be in the future`, afterFixedDate: field => `${capitalise(field.name)} must be after ${LocalDate.parse(field.evalMinValue).format(govDateFormat)}${field.minDescription ? `, ${field.minDescription}` : ``}`, beforeFixedDate: field => `${capitalise(field.name)} must be before ${LocalDate.parse(field.evalMaxValue).format(govDateFormat)}${field.maxDescription ? `, ${field.maxDescription}` : ``}`, noMatch: field => `${capitalise(field.name)} does not match ${field.noMatchText || `our records`}` } const getDateErrorKey = value => { if (!value[0] && !value[1] && !value[2]) { return 'required' } else if (value[0] && !value[1] && !value[2]) { return 'monthAndYearRequired' } else if (!value[0] && value[1] && !value[2]) { return 'dayAndYearRequired' } else if (!value[0] && !value[1] && value[2]) { return 'dayAndMonthRequired' } else if (value[0] && value[1] && !value[2]) { return 'yearRequired' } else if (value[0] && !value[1] && value[2]) { return 'monthRequired' } else if (!value[0] && value[1] && value[2]) { return 'dayRequired' } else { return 'date' } } const evaluatedValues = (data, field) => { if (field.hasOwnProperty('min') && minMaxTemplates.hasOwnProperty(field.type)) { switch (typeof field.min) { case 'number': field.evalMinValue = field.min break case 'string': if (field.type === 'date') { const isDateString = isoDateRegex.test(field.min) if (isDateString) { field.evalMinValue = field.min } else if (!Array.isArray(makeDateString(data, field.min))) { field.evalMinValue = makeDateString(data, field.min) } } else { field.evalMinValue =parseFloat(data[field.min]) } break case 'function': field.evalMinValue = field.min(data) break } } if (field.hasOwnProperty('max') && minMaxTemplates.hasOwnProperty(field.type)) { switch (typeof field.max) { case 'number': field.evalMaxValue = field.max break case 'string': if (field.type === 'date') { const isDateString = isoDateRegex.test(field.max) if (isDateString) { field.evalMaxValue = field.max } else if (!Array.isArray(makeDateString(data, field.max))) { field.evalMaxValue = makeDateString(data, field.max) } } else { field.evalMaxValue =parseFloat(data[field.max]) } break case 'function': field.evalMaxValue = field.max(data) break } } } const isValidField = (payLoad, field, fieldKey) => { if (typeof field.includeIf === 'function' && !field.includeIf(payLoad)) { return true } evaluatedValues(payLoad, field) if (payLoad.hasOwnProperty(fieldKey) && typeof field.transform === 'function') { payLoad[fieldKey] = field.transform(payLoad) } if (payLoad.hasOwnProperty(fieldKey) && field.type === 'currency') { payLoad[fieldKey] = stripCommas(payLoad[fieldKey].toString().replace(/£/, '')) } if (field.type === 'date' && !isoDateRegex.test(payLoad[fieldKey])) { payLoad[fieldKey] = makeDateString(payLoad, fieldKey) } if (payLoad.hasOwnProperty(fieldKey)) { return !validationError(field, payLoad[fieldKey], fieldKey) } return false } const buildHref = (fieldKey, field, value) => { if (field.type === 'enum' && field.validValues.length > 0) { return fieldKey + '-' + slugify(field.validValues[0]) } else if (field.type === 'date') { return fieldKey + '-' + dateErrorLink(getDateErrorKey(value)) } return fieldKey } const isValidDate = date => { try { return LocalDate.parse(date) } catch (e) { return false } } const errorMessage = (errorKey, field) => field.hasOwnProperty('errors') && field.errors.hasOwnProperty(errorKey) ? (typeof field.errors[errorKey] === 'function' ? field.errors[errorKey](field) : field.errors[errorKey]) : errorTemplates[errorKey](field) const validationError = (field, value, fieldKey) => { let errorText switch (field.type) { case 'date': if (Array.isArray(value)) { errorText = errorMessage(getDateErrorKey(value), field) } else if (!isValidDate(value)) { errorText = errorMessage('date', field) } break case 'optionalString': break case 'nonEmptyString': default: if (!value || !value.length) { errorText = errorMessage('required', field) } break case 'enum': if (!value || !field.validValues.includes(value)) { errorText = errorMessage('enum', field) } break case 'array': if ((field.minLength && (!value || value.length < field.minLength)) || (field.validValues && Array.isArray(value) && !value.every(v => field.validValues.includes(v)))) { errorText = errorMessage('enum', field) } break case 'number': if (!value.length) { errorText = errorMessage('required', field) } else if (isNaN(+value)) { errorText = errorMessage('number', field) } break case 'currency': if (value.length === 0 || typeof value === 'undefined') { errorText = errorMessage('required', field) } else if (!/^[0-9,]+(\.[0-9]{1,2})?$/.test(value)) { errorText = errorMessage('currency', field) } break case 'file': if (!value || !value.length) { errorText = errorMessage('missingFile', field) } break } // check generic field rules if (!errorText && !(field.type === 'optionalString' && value.length === 0)) { if (field.hasOwnProperty('exactLength') && value.toString().replace(/ /g, '').length !== field.exactLength) { errorText = errorMessage('exactLength', field) } else if (field.hasOwnProperty('minLength') && field.hasOwnProperty('maxLength') && (value.length < field.minLength || value.length > field.maxLength)) { errorText = errorMessage('betweenMinAndMaxLength', field) } else if (field.hasOwnProperty('maxLength') && value.length > field.maxLength) { errorText = errorMessage('tooLong', field) } else if (field.hasOwnProperty('minLength') && value.length < field.minLength) { errorText = errorMessage('tooShort', field) } else if (field.hasOwnProperty('evalMinValue') && field.hasOwnProperty('evalMaxValue')) { if (field.type === 'date' && (!isAfter(value, field.evalMinValue) || !isBefore(value, field.evalMaxValue))) { errorText = errorMessage(minMaxTemplates[field.type].betweenMinAndMax, field) } else if (parseFloat(value) < field.evalMinValue || parseFloat(value) > field.evalMaxValue) { errorText = errorMessage(minMaxTemplates[field.type].betweenMinAndMax, field) } } else if (field.hasOwnProperty('evalMinValue') && ((field.type !== 'date' && parseFloat(value) < field.evalMinValue) || (field.type === 'date' && !isAfter(value, field.evalMinValue)))) { errorText = errorMessage(minMaxTemplates[field.type].min, field) } else if (field.hasOwnProperty('evalMaxValue') && ((field.type !== 'date' && parseFloat(value) > field.evalMaxValue) || (field.type === 'date' && !isBefore(value, field.evalMaxValue)))) { errorText = errorMessage(minMaxTemplates[field.type].max, field) } else if (field.hasOwnProperty('regex') && !field.regex.test(value)) { errorText = errorMessage('pattern', field) } else if (field.hasOwnProperty('beforeToday') && !LocalDate.parse(value).isBefore(LocalDate.now())) { errorText = errorMessage('beforeToday', field) } else if (field.hasOwnProperty('afterToday') && !LocalDate.parse(value).isAfter(LocalDate.now())) { errorText = errorMessage('afterToday', field) } else if (field.hasOwnProperty('matches') && !field.matches.includes(value)) { errorText = errorMessage('noMatch', field) } else if (field.hasOwnProperty('matchingExclusions') && field.matchingExclusions.includes(value)) { errorText = errorMessage('noMatch', field) } } return errorText ? { id: fieldKey, href: '#' + buildHref(fieldKey, field, value), text: errorText, inputs: field.type === 'date' ? getDateInputsInError(fieldKey, field, value) : [ fieldKey ] } : null } const isValidFieldWrapper = (payLoad, page) => fieldKey => isValidField(payLoad, page.fields[fieldKey], fieldKey) const isValidPage = (payLoad, page) => Object.keys(page.fields).every(isValidFieldWrapper(payLoad, page)) const isValidPageWrapper = data => page => Object.keys(page.fields).every(isValidFieldWrapper(data, page)) const getPageErrors = (data, page) => Object.keys(page.fields) .reduce((list, next) => { const error = !isValidField(data, page.fields[next], next) && validationError(page.fields[next], data[next], next) if (error) { list.summary = [...list.summary, error] list.inline[next] = error list.text[next] = error.text list.hasErrors = true } return list }, {summary: [], inline: {}, text: {}, hasErrors: false}) module.exports = { addCommas, stripCommas, currencyDisplay, capitalise, slugify, zeroPad, dateErrorLink, errorTemplates, getPageErrors, isValidPageWrapper, isValidPage, isValidField, errorMessage }