diff --git a/packages/bbui/src/Layout/Layout.svelte b/packages/bbui/src/Layout/Layout.svelte index 86fd844ca1..af60675582 100644 --- a/packages/bbui/src/Layout/Layout.svelte +++ b/packages/bbui/src/Layout/Layout.svelte @@ -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); } diff --git a/packages/builder/src/components/design/PropertiesPanel/ComponentSettingsSection.svelte b/packages/builder/src/components/design/PropertiesPanel/ComponentSettingsSection.svelte index 972a2bb7fe..35a4a9db19 100644 --- a/packages/builder/src/components/design/PropertiesPanel/ComponentSettingsSection.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/ComponentSettingsSection.svelte @@ -67,6 +67,7 @@ placeholder: setting.placeholder, }} {bindings} + {componentDefinition} /> {/if} {/each} diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/AttachmentFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/AttachmentFieldSelect.svelte deleted file mode 100644 index 44557157ba..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/AttachmentFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/BooleanFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/BooleanFieldSelect.svelte deleted file mode 100644 index 131a375b22..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/BooleanFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/DateTimeFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/DateTimeFieldSelect.svelte deleted file mode 100644 index c3b9b052c4..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/DateTimeFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FormFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FormFieldSelect.svelte index 0926561640..f8e4b18fe4 100644 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FormFieldSelect.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/FormFieldSelect.svelte @@ -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]) diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/LongFormFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/LongFormFieldSelect.svelte deleted file mode 100644 index 65dd54d53a..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/LongFormFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/NumberFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/NumberFieldSelect.svelte deleted file mode 100644 index 2006aca0bb..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/NumberFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/OptionsFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/OptionsFieldSelect.svelte deleted file mode 100644 index a01b837797..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/OptionsFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/RelationshipFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/RelationshipFieldSelect.svelte deleted file mode 100644 index 5cdb25a112..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/RelationshipFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/StringFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/StringFieldSelect.svelte deleted file mode 100644 index 62765676e3..0000000000 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/StringFieldSelect.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationDrawer.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationDrawer.svelte new file mode 100644 index 0000000000..b4da2e8e6e --- /dev/null +++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationDrawer.svelte @@ -0,0 +1,363 @@ + + + + + + + Schema validation rules + {#if schemaRules?.length} + + {#each schemaRules as rule} + + + + + + + + {/each} + + {:else} + + There are no built-in validation rules from the schema. + + {/if} + + + Custom validation rules + {#if rules?.length} + + {#each rules as rule (rule.id)} + + + + + {#if rule.valueType === "Binding"} + + (rule.value = e.detail)} + /> + {:else if ["maxLength", "minLength", "regex", "notRegex", "contains", "notContains"].includes(rule.constraint)} + + + {:else} + + {#if ["string", "number", "options", "longform"].includes(rule.type)} + + {:else if rule.type === "boolean"} + + {:else if rule.type === "datetime"} + + {:else} + + {/if} + {/if} + (rule.error = e.detail)} + /> + duplicateRule(rule.id)} + /> + removeRule(rule.id)} + /> + + {/each} + + {/if} + + Add Rule + + + + + + + diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationEditor.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationEditor.svelte new file mode 100644 index 0000000000..0b7fd12de2 --- /dev/null +++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/ValidationEditor/ValidationEditor.svelte @@ -0,0 +1,33 @@ + + +Configure Validation + + + Configure validation rules for this field. + + Save + + diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/componentSettings.js b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/componentSettings.js index 8435971714..6cf2d7e056 100644 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/componentSettings.js +++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/componentSettings.js @@ -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 => { diff --git a/packages/standard-components/manifest.json b/packages/standard-components/manifest.json index a45283d63a..1b7a09cdef 100644 --- a/packages/standard-components/manifest.json +++ b/packages/standard-components/manifest.json @@ -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" } ] }, diff --git a/packages/standard-components/src/forms/AttachmentField.svelte b/packages/standard-components/src/forms/AttachmentField.svelte index 0b454f9800..f2c4a35030 100644 --- a/packages/standard-components/src/forms/AttachmentField.svelte +++ b/packages/standard-components/src/forms/AttachmentField.svelte @@ -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 @@ { fieldApi.setValue(e.detail) }} diff --git a/packages/standard-components/src/forms/BooleanField.svelte b/packages/standard-components/src/forms/BooleanField.svelte index e9eb57d305..d8507b8a03 100644 --- a/packages/standard-components/src/forms/BooleanField.svelte +++ b/packages/standard-components/src/forms/BooleanField.svelte @@ -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 diff --git a/packages/standard-components/src/forms/DateTimeField.svelte b/packages/standard-components/src/forms/DateTimeField.svelte index 87089114fc..bd26e3995a 100644 --- a/packages/standard-components/src/forms/DateTimeField.svelte +++ b/packages/standard-components/src/forms/DateTimeField.svelte @@ -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 diff --git a/packages/standard-components/src/forms/Field.svelte b/packages/standard-components/src/forms/Field.svelte index fd632c32d7..5de84f6777 100644 --- a/packages/standard-components/src/forms/Field.svelte +++ b/packages/standard-components/src/forms/Field.svelte @@ -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}` diff --git a/packages/standard-components/src/forms/InnerForm.svelte b/packages/standard-components/src/forms/InnerForm.svelte index 64901d1919..eea2e6ebf0 100644 --- a/packages/standard-components/src/forms/InnerForm.svelte +++ b/packages/standard-components/src/forms/InnerForm.svelte @@ -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, }) } diff --git a/packages/standard-components/src/forms/LongFormField.svelte b/packages/standard-components/src/forms/LongFormField.svelte index 231c247197..f710904254 100644 --- a/packages/standard-components/src/forms/LongFormField.svelte +++ b/packages/standard-components/src/forms/LongFormField.svelte @@ -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 diff --git a/packages/standard-components/src/forms/OptionsField.svelte b/packages/standard-components/src/forms/OptionsField.svelte index 18debfb23d..04bb582190 100644 --- a/packages/standard-components/src/forms/OptionsField.svelte +++ b/packages/standard-components/src/forms/OptionsField.svelte @@ -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 diff --git a/packages/standard-components/src/forms/RelationshipField.svelte b/packages/standard-components/src/forms/RelationshipField.svelte index b695994e75..f7d6581c7a 100644 --- a/packages/standard-components/src/forms/RelationshipField.svelte +++ b/packages/standard-components/src/forms/RelationshipField.svelte @@ -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 diff --git a/packages/standard-components/src/forms/StringField.svelte b/packages/standard-components/src/forms/StringField.svelte index be58d458e1..f04177ec05 100644 --- a/packages/standard-components/src/forms/StringField.svelte +++ b/packages/standard-components/src/forms/StringField.svelte @@ -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 diff --git a/packages/standard-components/src/forms/validation.js b/packages/standard-components/src/forms/validation.js index b1df80509f..30b6fd7ca7 100644 --- a/packages/standard-components/src/forms/validation.js +++ b/packages/standard-components/src/forms/validation.js @@ -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 !== "" }