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