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

View File

@ -1,54 +1,112 @@
import flatpickr from "flatpickr" 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 // Required constraint
if ( if (
field === table?.primaryDisplay || 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 // String length constraint
if (exists(constraints.length?.maximum)) { if (exists(schemaConstraints.length?.maximum)) {
const length = constraints.length.maximum const length = schemaConstraints.length.maximum
checks.push(lengthConstraint(length)) rules.push({
type: "string",
constraint: "length",
value: length,
error: val => `Maximum length is ${val}`,
})
} }
// Min / max number constraint // Min / max number constraint
if (exists(constraints.numericality?.greaterThanOrEqualTo)) { if (exists(schemaConstraints.numericality?.greaterThanOrEqualTo)) {
const min = constraints.numericality.greaterThanOrEqualTo const min = schemaConstraints.numericality.greaterThanOrEqualTo
checks.push(numericalConstraint(x => x >= min, `Minimum value is ${min}`)) rules.push({
type: "number",
constraint: "minValue",
value: min,
error: val => `Minimum value is ${val}`,
})
} }
if (exists(constraints.numericality?.lessThanOrEqualTo)) { if (exists(schemaConstraints.numericality?.lessThanOrEqualTo)) {
const max = constraints.numericality.lessThanOrEqualTo const max = schemaConstraints.numericality.lessThanOrEqualTo
checks.push(numericalConstraint(x => x <= max, `Maximum value is ${max}`)) rules.push({
type: "number",
constraint: "maxValue",
value: max,
error: val => `Maximum value is ${val}`,
})
} }
// Inclusion constraint // Inclusion constraint
if (exists(constraints.inclusion)) { if (exists(schemaConstraints.inclusion)) {
const options = constraints.inclusion const options = schemaConstraints.inclusion
checks.push(inclusionConstraint(options)) rules.push({
type: "string",
constraint: "inclusion",
value: options,
error: "Invalid value",
})
} }
// Date constraint // Date constraint
if (exists(constraints.datetime?.earliest)) { if (exists(schemaConstraints.datetime?.earliest)) {
const limit = constraints.datetime.earliest const limit = schemaConstraints.datetime.earliest
checks.push(dateConstraint(limit, true)) 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)) { if (exists(schemaConstraints.datetime?.latest)) {
const limit = constraints.datetime.latest const limit = schemaConstraints.datetime.latest
checks.push(dateConstraint(limit, false)) 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 // Evaluate each constraint
return value => { return value => {
for (let check of checks) { for (let rule of rules) {
const error = check(value) const error = evaluateRule(rule, value)
if (error) { if (error) {
return 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
const presenceConstraint = value => { * an error if the validation fails.
let invalid * @param rule the rule object to evaluate
if (Array.isArray(value)) { * @param value the value to validate against
invalid = value.length === 0 * @returns {null|*} an error if validation fails or null if it passes
} else { */
invalid = value == null || value === "" const evaluateRule = (rule, value) => {
} if (!rule) {
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"
}
return null return null
} }
const dateConstraint = (dateString, isEarliest) => { // Determine the correct handler for this rule
const dateLimit = Date.parse(dateString) const handler = handlerMap[rule.constraint]
return value => { if (!handler) {
if (value == null || value === "") { 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 return null
} }
const dateValue = Date.parse(value) return `${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}`
} }
// 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 !== ""
} }