diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationDrawer.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationDrawer.svelte
index b769e1f60e..70f9afe65a 100644
--- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationDrawer.svelte
+++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationDrawer.svelte
@@ -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
/>
+
@@ -225,20 +242,51 @@
Custom validation rules
{#if rules?.length}
- {#each rules as rule}
+ {#each rules as rule (rule.id)}
- (rule.constraintValue = e.detail)}
+ placeholder={null}
+ bind:value={rule.valueType}
+ options={["Binding", "Value"]}
/>
+ {#if rule.valueType === "Binding"}
+ (rule.value = e.detail)}
+ />
+ {:else if ["string", "number", "options", "longform"].includes(rule.type)}
+
+ {:else if fieldType === "boolean"}
+
+ {:else if fieldType === "datetime"}
+
+ {:else}
+
+ {/if}
.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;
}
diff --git a/packages/standard-components/src/forms/validation.js b/packages/standard-components/src/forms/validation.js
index 351a610912..6d67df2d6a 100644
--- a/packages/standard-components/src/forms/validation.js
+++ b/packages/standard-components/src/forms/validation.js
@@ -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 !== ""
}