Merge pull request #2339 from Budibase/custom-form-validation

Custom form validation
This commit is contained in:
Andrew Kingston 2021-08-16 18:29:44 +01:00 committed by GitHub
commit 36a744f577
25 changed files with 810 additions and 140 deletions

View File

@ -48,6 +48,9 @@
padding-top: var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.gap-XXS {
grid-gap: var(--spacing-xs);
}
.gap-XS {
grid-gap: var(--spacing-s);
}

View File

@ -67,6 +67,7 @@
placeholder: setting.placeholder,
}}
{bindings}
{componentDefinition}
/>
{/if}
{/each}

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="attachment" />

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="boolean" />

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="datetime" />

View File

@ -23,6 +23,7 @@
const getOptions = (schema, fieldType) => {
let entries = Object.entries(schema ?? {})
if (fieldType) {
fieldType = fieldType.split("/")[1]
entries = entries.filter(entry => entry[1].type === fieldType)
}
return entries.map(entry => entry[0])

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="longform" />

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="number" />

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="options" />

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="link" />

View File

@ -1,5 +0,0 @@
<script>
import FormFieldSelect from "./FormFieldSelect.svelte"
</script>
<FormFieldSelect {...$$props} on:change type="string" />

View File

@ -0,0 +1,363 @@
<script>
import {
Button,
Icon,
DrawerContent,
Layout,
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 = []
export let type
const Constraints = {
Required: {
label: "Required",
value: "required",
},
MinLength: {
label: "Min length",
value: "minLength",
},
MaxLength: {
label: "Max length",
value: "maxLength",
},
MaxValue: {
label: "Max value",
value: "maxValue",
},
MinValue: {
label: "Min value",
value: "minValue",
},
Equal: {
label: "Must equal",
value: "equal",
},
NotEqual: {
label: "Must not equal",
value: "notEqual",
},
Regex: {
label: "Must match regex",
value: "regex",
},
NotRegex: {
label: "Must not match regex",
value: "notRegex",
},
Contains: {
label: "Must contain row ID",
value: "contains",
},
NotContains: {
label: "Must not contain row ID",
value: "notContains",
},
}
const ConstraintMap = {
["string"]: [
Constraints.Required,
Constraints.MaxLength,
Constraints.Equal,
Constraints.NotEqual,
Constraints.Regex,
Constraints.NotRegex,
],
["number"]: [
Constraints.Required,
Constraints.MaxValue,
Constraints.MinValue,
Constraints.Equal,
Constraints.NotEqual,
],
["boolean"]: [
Constraints.Required,
Constraints.Equal,
Constraints.NotEqual,
],
["datetime"]: [
Constraints.Required,
Constraints.MaxValue,
Constraints.MinValue,
Constraints.Equal,
Constraints.NotEqual,
],
["attachment"]: [Constraints.Required],
["link"]: [
Constraints.Required,
Constraints.Contains,
Constraints.NotContains,
Constraints.MinLength,
Constraints.MaxLength,
],
}
$: dataSourceSchema = getDataSourceSchema($currentAsset, $selectedComponent)
$: field = $selectedComponent?.field
$: schemaRules = parseRulesFromSchema(field, dataSourceSchema || {})
$: fieldType = type?.split("/")[1] || "string"
$: constraintOptions = getConstraintsForType(fieldType)
const getConstraintsForType = type => {
return ConstraintMap[type]
}
const getDataSourceSchema = (asset, component) => {
if (!asset || !component) {
return null
}
const formParent = findClosestMatchingComponent(
asset.props,
component._id,
component => component._component.endsWith("/form")
)
return getSchemaForDatasource(asset, formParent?.dataSource)
}
const parseRulesFromSchema = (field, dataSourceSchema) => {
if (!field || !dataSourceSchema) {
return []
}
const fieldSchema = dataSourceSchema.schema?.[field]
const constraints = fieldSchema?.constraints
if (!constraints) {
return []
}
let rules = []
// Required constraint
if (
field === dataSourceSchema?.table?.primaryDisplay ||
constraints.presence?.allowEmpty === false
) {
rules.push({
constraint: "required",
error: "Required field",
})
}
// String length constraint
if (exists(constraints.length?.maximum)) {
const length = constraints.length.maximum
rules.push({
constraint: "maxLength",
value: length,
error: `Maximum ${length} characters`,
})
}
// Min / max number constraint
if (exists(constraints.numericality?.greaterThanOrEqualTo)) {
const min = constraints.numericality.greaterThanOrEqualTo
rules.push({
constraint: "minValue",
value: min,
error: `Minimum value is ${min}`,
})
}
if (exists(constraints.numericality?.lessThanOrEqualTo)) {
const max = constraints.numericality.lessThanOrEqualTo
rules.push({
constraint: "maxValue",
value: max,
error: `Maximum value is ${max}`,
})
}
return rules
}
const exists = value => {
return value != null && value !== ""
}
const addRule = () => {
rules = [
...(rules || []),
{
valueType: "Binding",
type: fieldType,
id: generate(),
},
]
}
const removeRule = id => {
rules = rules.filter(link => link.id !== id)
}
const duplicateRule = id => {
const existingRule = rules.find(rule => rule.id === id)
const newRule = { ...existingRule, id: generate() }
rules = [...rules, newRule]
}
</script>
<DrawerContent>
<div class="container">
<Layout noPadding gap="M">
<Layout noPadding gap={schemaRules?.length ? "S" : "XS"}>
<Heading size="XS">Schema validation rules</Heading>
{#if schemaRules?.length}
<div class="links">
{#each schemaRules as rule}
<div class="rule schema">
<Select
placeholder="Constraint"
value={rule.constraint}
options={constraintOptions}
disabled
/>
<Select
placeholder={null}
value="Value"
options={["Binding", "Value"]}
disabled
/>
<DrawerBindableInput
placeholder="Constraint value"
value={rule.value}
{bindings}
disabled
/>
<DrawerBindableInput
placeholder="Error message"
value={rule.error}
{bindings}
disabled
/>
<div />
</div>
{/each}
</div>
{:else}
<Body size="S">
There are no built-in validation rules from the schema.
</Body>
{/if}
</Layout>
<Layout noPadding gap="S">
<Heading size="XS">Custom validation rules</Heading>
{#if rules?.length}
<div class="links">
{#each rules as rule (rule.id)}
<div class="rule">
<Select
bind:value={rule.constraint}
options={constraintOptions}
placeholder="Constraint"
/>
<Select
disabled={rule.constraint === "required"}
placeholder={null}
bind:value={rule.valueType}
options={["Binding", "Value"]}
/>
{#if rule.valueType === "Binding"}
<!-- Bindings always get a bindable input -->
<DrawerBindableInput
placeholder="Constraint value"
value={rule.value}
{bindings}
disabled={rule.constraint === "required"}
on:change={e => (rule.value = e.detail)}
/>
{:else if ["maxLength", "minLength", "regex", "notRegex", "contains", "notContains"].includes(rule.constraint)}
<!-- Certain constraints always need string values-->
<Input
bind:value={rule.value}
placeholder="Constraint value"
/>
{:else}
<!-- Otherwise we render a component based on the type -->
{#if ["string", "number", "options", "longform"].includes(rule.type)}
<Input
disabled={rule.constraint === "required"}
bind:value={rule.value}
placeholder="Constraint value"
/>
{:else if rule.type === "boolean"}
<Select
disabled={rule.constraint === "required"}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
bind:value={rule.value}
/>
{:else if rule.type === "datetime"}
<DatePicker
enableTime={false}
disabled={rule.constraint === "required"}
bind:value={rule.value}
/>
{:else}
<DrawerBindableInput disabled />
{/if}
{/if}
<DrawerBindableInput
placeholder="Error message"
value={rule.error}
{bindings}
on:change={e => (rule.error = e.detail)}
/>
<Icon
name="Duplicate"
hoverable
size="S"
on:click={() => duplicateRule(rule.id)}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeRule(rule.id)}
/>
</div>
{/each}
</div>
{/if}
<div class="button">
<Button secondary icon="Add" on:click={addRule}>Add Rule</Button>
</div>
</Layout>
</Layout>
</div>
</DrawerContent>
<style>
.container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
}
.links {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.rule {
gap: var(--spacing-l);
display: grid;
align-items: center;
grid-template-columns: 190px 120px 1fr 1fr auto auto;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
</style>

View File

@ -0,0 +1,33 @@
<script>
import { Button, ActionButton, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import ValidationDrawer from "./ValidationDrawer.svelte"
export let value = []
export let bindings = []
export let componentDefinition
export let type
let drawer
const dispatch = createEventDispatcher()
const save = () => {
dispatch("change", value)
drawer.hide()
}
</script>
<ActionButton on:click={drawer.show}>Configure Validation</ActionButton>
<Drawer bind:this={drawer} title="Validation Rules">
<svelte:fragment slot="description">
Configure validation rules for this field.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<ValidationDrawer
slot="body"
bind:rules={value}
{type}
{bindings}
{componentDefinition}
/>
</Drawer>

View File

@ -12,14 +12,8 @@ import SectionSelect from "./SectionSelect.svelte"
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
import FilterEditor from "./FilterEditor/FilterEditor.svelte"
import URLSelect from "./URLSelect.svelte"
import StringFieldSelect from "./StringFieldSelect.svelte"
import NumberFieldSelect from "./NumberFieldSelect.svelte"
import OptionsFieldSelect from "./OptionsFieldSelect.svelte"
import BooleanFieldSelect from "./BooleanFieldSelect.svelte"
import LongFormFieldSelect from "./LongFormFieldSelect.svelte"
import DateTimeFieldSelect from "./DateTimeFieldSelect.svelte"
import AttachmentFieldSelect from "./AttachmentFieldSelect.svelte"
import RelationshipFieldSelect from "./RelationshipFieldSelect.svelte"
import FormFieldSelect from "./FormFieldSelect.svelte"
import ValidationEditor from "./ValidationEditor/ValidationEditor.svelte"
const componentMap = {
text: Input,
@ -39,14 +33,22 @@ const componentMap = {
navigation: NavigationEditor,
filter: FilterEditor,
url: URLSelect,
"field/string": StringFieldSelect,
"field/number": NumberFieldSelect,
"field/options": OptionsFieldSelect,
"field/boolean": BooleanFieldSelect,
"field/longform": LongFormFieldSelect,
"field/datetime": DateTimeFieldSelect,
"field/attachment": AttachmentFieldSelect,
"field/link": RelationshipFieldSelect,
"field/string": FormFieldSelect,
"field/number": FormFieldSelect,
"field/options": FormFieldSelect,
"field/boolean": FormFieldSelect,
"field/longform": FormFieldSelect,
"field/datetime": FormFieldSelect,
"field/attachment": FormFieldSelect,
"field/link": FormFieldSelect,
// Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation
"validation/string": ValidationEditor,
"validation/number": ValidationEditor,
"validation/boolean": ValidationEditor,
"validation/datetime": ValidationEditor,
"validation/attachment": ValidationEditor,
"validation/link": ValidationEditor,
}
export const getComponentForSettingType = type => {

View File

@ -1796,6 +1796,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/string",
"label": "Validation",
"key": "validation"
}
]
},
@ -1830,6 +1835,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/number",
"label": "Validation",
"key": "validation"
}
]
},
@ -1864,6 +1874,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/string",
"label": "Validation",
"key": "validation"
}
]
},
@ -1915,6 +1930,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/string",
"label": "Validation",
"key": "validation"
}
]
},
@ -1972,6 +1992,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/boolean",
"label": "Validation",
"key": "validation"
}
]
},
@ -2007,6 +2032,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/string",
"label": "Validation",
"key": "validation"
}
]
},
@ -2047,6 +2077,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/datetime",
"label": "Validation",
"key": "validation"
}
]
},
@ -2071,6 +2106,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/attachment",
"label": "Validation",
"key": "validation"
}
]
},
@ -2100,6 +2140,11 @@
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/link",
"label": "Validation",
"key": "validation"
}
]
},

View File

@ -6,6 +6,7 @@
export let field
export let label
export let disabled = false
export let validation
let fieldState
let fieldApi
@ -35,6 +36,7 @@
{label}
{field}
{disabled}
{validation}
type="attachment"
bind:fieldState
bind:fieldApi
@ -44,6 +46,7 @@
<CoreDropzone
value={$fieldState.value}
disabled={$fieldState.disabled}
error={$fieldState.error}
on:change={e => {
fieldApi.setValue(e.detail)
}}

View File

@ -7,6 +7,7 @@
export let text
export let disabled = false
export let size
export let validation
export let defaultValue
let fieldState
@ -30,6 +31,7 @@
{label}
{field}
{disabled}
{validation}
defaultValue={isTruthy(defaultValue)}
type="boolean"
bind:fieldState

View File

@ -7,6 +7,7 @@
export let placeholder
export let disabled = false
export let enableTime = false
export let validation
export let defaultValue
let fieldState
@ -42,6 +43,7 @@
{label}
{field}
{disabled}
{validation}
defaultValue={parseDate(defaultValue)}
type="datetime"
bind:fieldState

View File

@ -11,6 +11,7 @@
export let defaultValue
export let type
export let disabled = false
export let validation
// Get contexts
const formContext = getContext("form")
@ -21,13 +22,21 @@
// Register field with form
const formApi = formContext?.formApi
const labelPosition = fieldGroupContext?.labelPosition || "above"
const formField = formApi?.registerField(field, defaultValue, disabled)
const formField = formApi?.registerField(
field,
defaultValue,
disabled,
validation
)
// Expose field properties to parent component
fieldState = formField?.fieldState
fieldApi = formField?.fieldApi
fieldSchema = formField?.fieldSchema
// Keep validation rules up to date
$: fieldApi?.updateValidation(validation)
// Extract label position from field group context
$: labelPositionClass =
labelPosition === "above" ? "" : `spectrum-FieldLabel--${labelPosition}`

View File

@ -21,7 +21,12 @@
// Form API contains functions to control the form
const formApi = {
registerField: (field, defaultValue = null, fieldDisabled = false) => {
registerField: (
field,
defaultValue = null,
fieldDisabled = false,
validationRules
) => {
if (!field) {
return
}
@ -30,17 +35,23 @@
const isAutoColumn = !!schema?.[field]?.autocolumn
// Create validation function based on field schema
const constraints = schema?.[field]?.constraints
const validate = createValidatorFromConstraints(constraints, field, table)
const schemaConstraints = schema?.[field]?.constraints
const validator = createValidatorFromConstraints(
schemaConstraints,
validationRules,
field,
table
)
// Construct field object
fieldMap[field] = {
fieldState: makeFieldState(
field,
validator,
defaultValue,
disabled || fieldDisabled || isAutoColumn
),
fieldApi: makeFieldApi(field, defaultValue, validate),
fieldApi: makeFieldApi(field, defaultValue),
fieldSchema: schema?.[field] ?? {},
}
@ -83,9 +94,11 @@
]
// Creates an API for a specific field
const makeFieldApi = (field, defaultValue, validate) => {
const makeFieldApi = field => {
// Sets the value for a certain field and invokes validation
const setValue = (value, skipCheck = false) => {
const { fieldState } = fieldMap[field]
const { validator } = get(fieldState)
// Skip if the value is the same
if (!skipCheck && get(fieldState).value === value) {
@ -93,7 +106,7 @@
}
// Update field state
const error = validate ? validate(value) : null
const error = validator ? validator(value) : null
fieldState.update(state => {
state.value = value
state.error = error
@ -115,15 +128,20 @@
return !error
}
// Clears the value of a certain field back to the initial value
const clearValue = () => {
const { fieldState } = fieldMap[field]
const { defaultValue } = get(fieldState)
const newValue = initialValues[field] ?? defaultValue
// Update field state
fieldState.update(state => {
state.value = newValue
state.error = null
return state
})
// Update form state
formState.update(state => {
state.values = { ...state.values, [field]: newValue }
delete state.errors[field]
@ -132,9 +150,37 @@
})
}
// Updates the validator rules for a certain field
const updateValidation = validationRules => {
const { fieldState } = fieldMap[field]
const { value, error } = get(fieldState)
// Create new validator
const schemaConstraints = schema?.[field]?.constraints
const validator = createValidatorFromConstraints(
schemaConstraints,
validationRules,
field,
table
)
// Update validator
fieldState.update(state => {
state.validator = validator
return state
})
// If there is currently an error, run the validator again in case
// the error should be cleared by the new validation rules
if (error) {
setValue(value, true)
}
}
return {
setValue,
clearValue,
updateValidation,
validate: () => {
const { fieldState } = fieldMap[field]
setValue(get(fieldState).value, true)
@ -143,13 +189,15 @@
}
// Creates observable state data about a specific field
const makeFieldState = (field, defaultValue, fieldDisabled) => {
const makeFieldState = (field, validator, defaultValue, fieldDisabled) => {
return writable({
field,
fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue,
error: null,
disabled: fieldDisabled,
defaultValue,
validator,
})
}

View File

@ -6,6 +6,7 @@
export let label
export let placeholder
export let disabled = false
export let validation
export let defaultValue = ""
let fieldState
@ -16,6 +17,7 @@
{label}
{field}
{disabled}
{validation}
{defaultValue}
type="longform"
bind:fieldState

View File

@ -7,6 +7,7 @@
export let placeholder
export let disabled = false
export let optionsType = "select"
export let validation
export let defaultValue
let fieldState
@ -18,6 +19,7 @@
{field}
{label}
{disabled}
{validation}
{defaultValue}
type="options"
bind:fieldState

View File

@ -9,6 +9,7 @@
export let label
export let placeholder
export let disabled = false
export let validation
let fieldState
let fieldApi
@ -64,6 +65,7 @@
{label}
{field}
{disabled}
{validation}
type="link"
bind:fieldState
bind:fieldApi

View File

@ -7,6 +7,7 @@
export let placeholder
export let type = "text"
export let disabled = false
export let validation
export let defaultValue = ""
let fieldState
@ -17,6 +18,7 @@
{label}
{field}
{disabled}
{validation}
{defaultValue}
type={type === "number" ? "number" : "string"}
bind:fieldState

View File

@ -1,54 +1,108 @@
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: `Maximum length is ${length}`,
})
}
// 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: `Minimum value is ${min}`,
})
}
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: `Maximum value is ${max}`,
})
}
// 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
const limitString = flatpickr.formatDate(new Date(limit), "F j Y, H:i")
rules.push({
type: "datetime",
constraint: "minValue",
value: limit,
error: `Earliest date is ${limitString}`,
})
}
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
const limitString = flatpickr.formatDate(new Date(limit), "F j Y, H:i")
rules.push({
type: "datetime",
constraint: "maxValue",
value: limit,
error: `Latest date is ${limitString}`,
})
}
}
// 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 +111,197 @@ 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" : 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 input value into correct type
value = parseType(value, rule.type)
// Evaluate the rule
const pass = handler(value, rule)
return pass ? null : rule.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 min length constraint
const minLengthHandler = (value, rule) => {
const limit = parseType(rule.value, "number")
return value && value.length >= limit
}
// Evaluates a max length constraint
const maxLengthHandler = (value, rule) => {
const limit = parseType(rule.value, "number")
return value == null || value.length <= limit
}
// Evaluates a min value constraint
const minValueHandler = (value, rule) => {
// Use same type as the value so that things can be compared
const limit = parseType(rule.value, rule.type)
return value && value >= limit
}
// Evaluates a max value constraint
const maxValueHandler = (value, rule) => {
// Use same type as the value so that things can be compared
const limit = parseType(rule.value, rule.type)
return value == null || value <= limit
}
// Evaluates an inclusion constraint
const inclusionHandler = (value, rule) => {
return value == null || rule.value.includes(value)
}
// Evaluates an equal constraint
const equalHandler = (value, rule) => {
const ruleValue = parseType(rule.value, rule.type)
return value === ruleValue
}
// Evaluates a not equal constraint
const notEqualHandler = (value, rule) => {
const ruleValue = parseType(rule.value, rule.type)
if (value == null && ruleValue == null) {
return true
}
return value !== ruleValue
}
// Evaluates a regex constraint
const regexHandler = (value, rule) => {
const regex = parseType(rule.value, "string")
return new RegExp(regex).test(value)
}
// Evaluates a not regex constraint
const notRegexHandler = (value, rule) => {
return !regexHandler(value, rule)
}
// Evaluates a contains constraint
const containsHandler = (value, rule) => {
const expectedValue = parseType(rule.value, "string")
return value && value.includes(expectedValue)
}
// Evaluates a not contains constraint
const notContainsHandler = (value, rule) => {
return !containsHandler(value, rule)
}
/**
* Map of constraint types to handlers.
*/
const handlerMap = {
required: requiredHandler,
minLength: minLengthHandler,
maxLength: maxLengthHandler,
minValue: minValueHandler,
maxValue: maxValueHandler,
inclusion: inclusionHandler,
equal: equalHandler,
notEqual: notEqualHandler,
regex: regexHandler,
notRegex: notRegexHandler,
contains: containsHandler,
notContains: notContainsHandler,
}
/**
* 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 !== ""
}