|
|
|
@ -1,54 +1,112 @@ |
|
|
|
import flatpickr from "flatpickr" |
|
|
|
|
|
|
|
export const createValidatorFromConstraints = (constraints, field, table) => { |
|
|
|
let checks = [] |
|
|
|
/** |
|
|
|
* Creates a validation function from a combination of schema-level constraints |
|
|
|
* and custom validation rules |
|
|
|
* @param schemaConstraints any schema level constraints from the table |
|
|
|
* @param customRules any custom validation rules |
|
|
|
* @param field the field name we are evaluating |
|
|
|
* @param table the definition of the table we are evaluating |
|
|
|
* @returns {function} a validator function which accepts test values |
|
|
|
*/ |
|
|
|
export const createValidatorFromConstraints = ( |
|
|
|
schemaConstraints, |
|
|
|
customRules, |
|
|
|
field, |
|
|
|
table |
|
|
|
) => { |
|
|
|
let rules = [] |
|
|
|
|
|
|
|
if (constraints) { |
|
|
|
// Convert schema constraints into validation rules
|
|
|
|
if (schemaConstraints) { |
|
|
|
// Required constraint
|
|
|
|
if ( |
|
|
|
field === table?.primaryDisplay || |
|
|
|
constraints.presence?.allowEmpty === false |
|
|
|
schemaConstraints.presence?.allowEmpty === false |
|
|
|
) { |
|
|
|
checks.push(presenceConstraint) |
|
|
|
rules.push({ |
|
|
|
type: "string", |
|
|
|
constraint: "required", |
|
|
|
error: "Required", |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
// String length constraint
|
|
|
|
if (exists(constraints.length?.maximum)) { |
|
|
|
const length = constraints.length.maximum |
|
|
|
checks.push(lengthConstraint(length)) |
|
|
|
if (exists(schemaConstraints.length?.maximum)) { |
|
|
|
const length = schemaConstraints.length.maximum |
|
|
|
rules.push({ |
|
|
|
type: "string", |
|
|
|
constraint: "length", |
|
|
|
value: length, |
|
|
|
error: val => `Maximum length is ${val}`, |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
// Min / max number constraint
|
|
|
|
if (exists(constraints.numericality?.greaterThanOrEqualTo)) { |
|
|
|
const min = constraints.numericality.greaterThanOrEqualTo |
|
|
|
checks.push(numericalConstraint(x => x >= min, `Minimum value is ${min}`)) |
|
|
|
if (exists(schemaConstraints.numericality?.greaterThanOrEqualTo)) { |
|
|
|
const min = schemaConstraints.numericality.greaterThanOrEqualTo |
|
|
|
rules.push({ |
|
|
|
type: "number", |
|
|
|
constraint: "minValue", |
|
|
|
value: min, |
|
|
|
error: val => `Minimum value is ${val}`, |
|
|
|
}) |
|
|
|
} |
|
|
|
if (exists(constraints.numericality?.lessThanOrEqualTo)) { |
|
|
|
const max = constraints.numericality.lessThanOrEqualTo |
|
|
|
checks.push(numericalConstraint(x => x <= max, `Maximum value is ${max}`)) |
|
|
|
if (exists(schemaConstraints.numericality?.lessThanOrEqualTo)) { |
|
|
|
const max = schemaConstraints.numericality.lessThanOrEqualTo |
|
|
|
rules.push({ |
|
|
|
type: "number", |
|
|
|
constraint: "maxValue", |
|
|
|
value: max, |
|
|
|
error: val => `Maximum value is ${val}`, |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
// Inclusion constraint
|
|
|
|
if (exists(constraints.inclusion)) { |
|
|
|
const options = constraints.inclusion |
|
|
|
checks.push(inclusionConstraint(options)) |
|
|
|
if (exists(schemaConstraints.inclusion)) { |
|
|
|
const options = schemaConstraints.inclusion |
|
|
|
rules.push({ |
|
|
|
type: "string", |
|
|
|
constraint: "inclusion", |
|
|
|
value: options, |
|
|
|
error: "Invalid value", |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
// Date constraint
|
|
|
|
if (exists(constraints.datetime?.earliest)) { |
|
|
|
const limit = constraints.datetime.earliest |
|
|
|
checks.push(dateConstraint(limit, true)) |
|
|
|
if (exists(schemaConstraints.datetime?.earliest)) { |
|
|
|
const limit = schemaConstraints.datetime.earliest |
|
|
|
rules.push({ |
|
|
|
type: "datetime", |
|
|
|
constraint: "minValue", |
|
|
|
value: limit, |
|
|
|
error: val => { |
|
|
|
const date = flatpickr.formatDate(new Date(val), "F j Y, H:i") |
|
|
|
return `Earliest date is ${date}` |
|
|
|
}, |
|
|
|
}) |
|
|
|
} |
|
|
|
if (exists(constraints.datetime?.latest)) { |
|
|
|
const limit = constraints.datetime.latest |
|
|
|
checks.push(dateConstraint(limit, false)) |
|
|
|
if (exists(schemaConstraints.datetime?.latest)) { |
|
|
|
const limit = schemaConstraints.datetime.latest |
|
|
|
rules.push({ |
|
|
|
type: "datetime", |
|
|
|
constraint: "maxValue", |
|
|
|
value: limit, |
|
|
|
error: val => { |
|
|
|
const date = flatpickr.formatDate(new Date(val), "F j Y, H:i") |
|
|
|
return `Latest date is ${date}` |
|
|
|
}, |
|
|
|
}) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Add custom validation rules
|
|
|
|
rules = rules.concat(customRules || []) |
|
|
|
|
|
|
|
// Evaluate each constraint
|
|
|
|
return value => { |
|
|
|
for (let check of checks) { |
|
|
|
const error = check(value) |
|
|
|
for (let rule of rules) { |
|
|
|
const error = evaluateRule(rule, value) |
|
|
|
if (error) { |
|
|
|
return error |
|
|
|
} |
|
|
|
@ -57,61 +115,185 @@ export const createValidatorFromConstraints = (constraints, field, table) => { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const exists = value => value != null && value !== "" |
|
|
|
/** |
|
|
|
* Evaluates a validation rule against a value and optionally returns |
|
|
|
* an error if the validation fails. |
|
|
|
* @param rule the rule object to evaluate |
|
|
|
* @param value the value to validate against |
|
|
|
* @returns {null|*} an error if validation fails or null if it passes |
|
|
|
*/ |
|
|
|
const evaluateRule = (rule, value) => { |
|
|
|
if (!rule) { |
|
|
|
return null |
|
|
|
} |
|
|
|
|
|
|
|
const presenceConstraint = value => { |
|
|
|
let invalid |
|
|
|
if (Array.isArray(value)) { |
|
|
|
invalid = value.length === 0 |
|
|
|
} else { |
|
|
|
invalid = value == null || value === "" |
|
|
|
// Determine the correct handler for this rule
|
|
|
|
const handler = handlerMap[rule.constraint] |
|
|
|
if (!handler) { |
|
|
|
return null |
|
|
|
} |
|
|
|
|
|
|
|
// Coerce values into correct types
|
|
|
|
const parsedValue = parseType(value, rule.type) |
|
|
|
const parsedRuleValue = parseType(rule.value, rule.type) |
|
|
|
|
|
|
|
// Evaluate the rule
|
|
|
|
const pass = handler(parsedValue, parsedRuleValue) |
|
|
|
if (pass) { |
|
|
|
return null |
|
|
|
} |
|
|
|
return invalid ? "Required field" : null |
|
|
|
} |
|
|
|
|
|
|
|
const lengthConstraint = maxLength => value => { |
|
|
|
if (value && value.length > maxLength) { |
|
|
|
return `Maximum ${maxLength} characters` |
|
|
|
// Return an error if the validation failed
|
|
|
|
let error = rule.error |
|
|
|
if (typeof error === "function") { |
|
|
|
error = rule.error(parsedRuleValue) |
|
|
|
} |
|
|
|
return null |
|
|
|
return error || "Error" |
|
|
|
} |
|
|
|
|
|
|
|
const numericalConstraint = (constraint, error) => value => { |
|
|
|
if (value == null || value === "") { |
|
|
|
/** |
|
|
|
* Parses a value to the specified type so that values are always compared |
|
|
|
* in the same format. |
|
|
|
* @param value the value to parse |
|
|
|
* @param type the type to parse |
|
|
|
* @returns {boolean|string|*|number|null} the parsed value, or null if invalid |
|
|
|
*/ |
|
|
|
const parseType = (value, type) => { |
|
|
|
// Treat nulls or empty strings as null
|
|
|
|
if (!exists(value) || !type) { |
|
|
|
return null |
|
|
|
} |
|
|
|
if (isNaN(value)) { |
|
|
|
return "Must be a number" |
|
|
|
} |
|
|
|
const number = parseFloat(value) |
|
|
|
if (!constraint(number)) { |
|
|
|
return error |
|
|
|
|
|
|
|
// Parse as string
|
|
|
|
if (type === "string") { |
|
|
|
if (typeof value === "string" || Array.isArray(value)) { |
|
|
|
return value |
|
|
|
} |
|
|
|
if (value.length === 0) { |
|
|
|
return null |
|
|
|
} |
|
|
|
return `${value}` |
|
|
|
} |
|
|
|
return null |
|
|
|
} |
|
|
|
|
|
|
|
const inclusionConstraint = |
|
|
|
(options = []) => |
|
|
|
value => { |
|
|
|
if (value == null || value === "") { |
|
|
|
// Parse as number
|
|
|
|
if (type === "number") { |
|
|
|
if (isNaN(value)) { |
|
|
|
return null |
|
|
|
} |
|
|
|
if (!options.includes(value)) { |
|
|
|
return "Invalid value" |
|
|
|
return parseFloat(value) |
|
|
|
} |
|
|
|
|
|
|
|
// Parse as date
|
|
|
|
if (type === "datetime") { |
|
|
|
if (value instanceof Date) { |
|
|
|
return value.getTime() |
|
|
|
} |
|
|
|
return null |
|
|
|
const time = isNaN(value) ? Date.parse(value) : new Date(value).getTime() |
|
|
|
return isNaN(time) ? null : time |
|
|
|
} |
|
|
|
|
|
|
|
const dateConstraint = (dateString, isEarliest) => { |
|
|
|
const dateLimit = Date.parse(dateString) |
|
|
|
return value => { |
|
|
|
if (value == null || value === "") { |
|
|
|
// Parse as boolean
|
|
|
|
if (type === "boolean") { |
|
|
|
if (typeof value === "string") { |
|
|
|
return value.toLowerCase() === "true" |
|
|
|
} |
|
|
|
return value === true |
|
|
|
} |
|
|
|
|
|
|
|
// Parse attachments, treating no elements as null
|
|
|
|
if (type === "attachment") { |
|
|
|
if (!Array.isArray(value) || !value.length) { |
|
|
|
return null |
|
|
|
} |
|
|
|
return value |
|
|
|
} |
|
|
|
|
|
|
|
// Parse links, treating no elements as null
|
|
|
|
if (type === "link") { |
|
|
|
if (!Array.isArray(value) || !value.length) { |
|
|
|
return null |
|
|
|
} |
|
|
|
const dateValue = Date.parse(value) |
|
|
|
const valid = isEarliest ? dateValue >= dateLimit : dateValue <= dateLimit |
|
|
|
const adjective = isEarliest ? "Earliest" : "Latest" |
|
|
|
const limitString = flatpickr.formatDate(new Date(dateLimit), "F j Y, H:i") |
|
|
|
return valid ? null : `${adjective} is ${limitString}` |
|
|
|
return value |
|
|
|
} |
|
|
|
|
|
|
|
// If some unknown type, treat as null to avoid breaking validators
|
|
|
|
return null |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates a required constraint
|
|
|
|
const requiredHandler = value => { |
|
|
|
return value != null |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates a max length constraint
|
|
|
|
const maxLengthHandler = (value, maxLength) => { |
|
|
|
if (value == null) { |
|
|
|
return true |
|
|
|
} |
|
|
|
return value.length <= maxLength |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates a min value constraint
|
|
|
|
const minValueHandler = (value, minValue) => { |
|
|
|
if (value == null) { |
|
|
|
return true |
|
|
|
} |
|
|
|
return value >= minValue |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates a max value constraint
|
|
|
|
const maxValueHandler = (value, maxValue) => { |
|
|
|
if (value == null) { |
|
|
|
return true |
|
|
|
} |
|
|
|
return value <= maxValue |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates an inclusion constraint
|
|
|
|
const inclusionHandler = (value, options) => { |
|
|
|
return options.includes(value) |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates an equal constraint
|
|
|
|
const equalHandler = (value, referenceValue) => { |
|
|
|
return value === referenceValue |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates a not equal constraint
|
|
|
|
const notEqualHandler = (value, referenceValue) => { |
|
|
|
return !equalHandler(value, referenceValue) |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates a regex constraint
|
|
|
|
const regexHandler = (value, regex) => { |
|
|
|
return new RegExp(regex).test(value) |
|
|
|
} |
|
|
|
|
|
|
|
// Evaluates a not regex constraint
|
|
|
|
const notRegexHandler = (value, regex) => { |
|
|
|
return !regexHandler(value, regex) |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Map of constraint types to handlers. |
|
|
|
*/ |
|
|
|
const handlerMap = { |
|
|
|
required: requiredHandler, |
|
|
|
maxLength: maxLengthHandler, |
|
|
|
minValue: minValueHandler, |
|
|
|
maxValue: maxValueHandler, |
|
|
|
inclusion: inclusionHandler, |
|
|
|
equal: equalHandler, |
|
|
|
notEqual: notEqualHandler, |
|
|
|
regex: regexHandler, |
|
|
|
notRegex: notRegexHandler, |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Helper to check for null, undefined or empty string values |
|
|
|
* @param value the value to test |
|
|
|
* @returns {boolean} whether the value exists or not |
|
|
|
*/ |
|
|
|
const exists = value => { |
|
|
|
return value != null && value !== "" |
|
|
|
} |
|
|
|
|