Allow custom validation rules to use bindings or raw values

This commit is contained in:
Andrew Kingston 2021-08-10 14:36:00 +01:00
parent 67be24b524
commit a041d8dcc7
2 changed files with 321 additions and 91 deletions

View File

@ -7,11 +7,14 @@
Select,
Heading,
Body,
Input,
DatePicker,
} from "@budibase/bbui"
import { currentAsset, selectedComponent } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { getSchemaForDatasource } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid"
export let rules = []
export let bindings = []
@ -100,10 +103,11 @@
$: dataSourceSchema = getDataSourceSchema($currentAsset, $selectedComponent)
$: field = $selectedComponent?.field
$: schemaRules = parseRulesFromSchema(field, dataSourceSchema || {})
$: constraintOptions = getConstraintsForType(type)
$: fieldType = type?.split("/")[1] || "string"
$: constraintOptions = getConstraintsForType(fieldType)
const getConstraintsForType = type => {
return ConstraintMap[type?.split("/")[1] || "string"]
return ConstraintMap[type]
}
const getDataSourceSchema = (asset, component) => {
@ -145,7 +149,7 @@
const length = constraints.length.maximum
rules.push({
constraint: "maxLength",
constraintValue: length,
value: length,
error: `Maximum ${length} characters`,
})
}
@ -155,7 +159,7 @@
const min = constraints.numericality.greaterThanOrEqualTo
rules.push({
constraint: "minValue",
constraintValue: min,
value: min,
error: `Minimum value is ${min}`,
})
}
@ -163,7 +167,7 @@
const max = constraints.numericality.lessThanOrEqualTo
rules.push({
constraint: "maxValue",
constraintValue: max,
value: max,
error: `Maximum value is ${max}`,
})
}
@ -176,7 +180,14 @@
}
const addRule = () => {
rules = [...(rules || []), {}]
rules = [
...(rules || []),
{
valueType: "Binding",
type: fieldType,
id: generate(),
},
]
}
const removeRule = id => {
@ -199,9 +210,15 @@
options={constraintOptions}
disabled
/>
<Select
placeholder={null}
value="Value"
options={["Binding", "Value"]}
disabled
/>
<DrawerBindableInput
placeholder="Constraint value"
value={rule.constraintValue}
value={rule.value}
{bindings}
disabled
/>
@ -225,20 +242,51 @@
<Heading size="XS">Custom validation rules</Heading>
{#if rules?.length}
<div class="links">
{#each rules as rule}
{#each rules as rule (rule.id)}
<div class="rule">
<Select
bind:value={rule.constraint}
options={constraintOptions}
placeholder="Constraint"
/>
<DrawerBindableInput
placeholder="Constraint value"
value={rule.constraintValue}
{bindings}
<Select
disabled={rule.constraint === "required"}
on:change={e => (rule.constraintValue = e.detail)}
placeholder={null}
bind:value={rule.valueType}
options={["Binding", "Value"]}
/>
{#if rule.valueType === "Binding"}
<DrawerBindableInput
placeholder="Constraint value"
value={rule.value}
{bindings}
disabled={rule.constraint === "required"}
on:change={e => (rule.value = e.detail)}
/>
{:else if ["string", "number", "options", "longform"].includes(rule.type)}
<Input
disabled={rule.constraint === "required"}
bind:value={rule.value}
placeholder="Constraint value"
/>
{:else if fieldType === "boolean"}
<Select
disabled={rule.constraint === "required"}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
bind:value={rule.value}
/>
{:else if fieldType === "datetime"}
<DatePicker
enableTime={false}
disabled={rule.constraint === "required"}
bind:value={rule.value}
/>
{:else}
<DrawerBindableInput disabled />
{/if}
<DrawerBindableInput
placeholder="Error message"
value={rule.error}
@ -266,7 +314,7 @@
<style>
.container {
width: 100%;
max-width: 800px;
max-width: 1000px;
margin: 0 auto;
}
.links {
@ -281,7 +329,7 @@
gap: var(--spacing-l);
display: grid;
align-items: center;
grid-template-columns: 1fr 1fr 1fr 20px;
grid-template-columns: 1fr 1fr 1fr 1fr 20px;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}

View File

@ -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 !== ""
const presenceConstraint = value => {
let invalid
if (Array.isArray(value)) {
invalid = value.length === 0
} else {
invalid = value == null || value === ""
}
return invalid ? "Required field" : null
}
const lengthConstraint = maxLength => value => {
if (value && value.length > maxLength) {
return `Maximum ${maxLength} characters`
}
return null
}
const numericalConstraint = (constraint, error) => value => {
if (value == null || value === "") {
return null
}
if (isNaN(value)) {
return "Must be a number"
}
const number = parseFloat(value)
if (!constraint(number)) {
return error
}
return null
}
const inclusionConstraint =
(options = []) =>
value => {
if (value == null || value === "") {
return null
}
if (!options.includes(value)) {
return "Invalid 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 dateConstraint = (dateString, isEarliest) => {
const dateLimit = Date.parse(dateString)
return value => {
if (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 an error if the validation failed
let error = rule.error
if (typeof error === "function") {
error = rule.error(parsedRuleValue)
}
return error || "Error"
}
/**
* 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
}
// Parse as string
if (type === "string") {
if (typeof value === "string" || Array.isArray(value)) {
return value
}
if (value.length === 0) {
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}`
}
// Parse as number
if (type === "number") {
if (isNaN(value)) {
return null
}
return parseFloat(value)
}
// Parse as date
if (type === "datetime") {
if (value instanceof Date) {
return value.getTime()
}
const time = isNaN(value) ? Date.parse(value) : new Date(value).getTime()
return isNaN(time) ? null : time
}
// 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
}
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 !== ""
}