Allow custom validation rules to use bindings or raw values
This commit is contained in:
parent
67be24b524
commit
a041d8dcc7
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 !== ""
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue