Merge pull request #2339 from Budibase/custom-form-validation
Custom form validation
This commit is contained in:
commit
e6399cad33
|
@ -48,6 +48,9 @@
|
||||||
padding-top: var(--spacing-l);
|
padding-top: var(--spacing-l);
|
||||||
padding-bottom: var(--spacing-l);
|
padding-bottom: var(--spacing-l);
|
||||||
}
|
}
|
||||||
|
.gap-XXS {
|
||||||
|
grid-gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
.gap-XS {
|
.gap-XS {
|
||||||
grid-gap: var(--spacing-s);
|
grid-gap: var(--spacing-s);
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,6 +67,7 @@
|
||||||
placeholder: setting.placeholder,
|
placeholder: setting.placeholder,
|
||||||
}}
|
}}
|
||||||
{bindings}
|
{bindings}
|
||||||
|
{componentDefinition}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="attachment" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="boolean" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="datetime" />
|
|
|
@ -23,6 +23,7 @@
|
||||||
const getOptions = (schema, fieldType) => {
|
const getOptions = (schema, fieldType) => {
|
||||||
let entries = Object.entries(schema ?? {})
|
let entries = Object.entries(schema ?? {})
|
||||||
if (fieldType) {
|
if (fieldType) {
|
||||||
|
fieldType = fieldType.split("/")[1]
|
||||||
entries = entries.filter(entry => entry[1].type === fieldType)
|
entries = entries.filter(entry => entry[1].type === fieldType)
|
||||||
}
|
}
|
||||||
return entries.map(entry => entry[0])
|
return entries.map(entry => entry[0])
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="longform" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="number" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="options" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="link" />
|
|
|
@ -1,5 +0,0 @@
|
||||||
<script>
|
|
||||||
import FormFieldSelect from "./FormFieldSelect.svelte"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<FormFieldSelect {...$$props} on:change type="string" />
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -12,14 +12,8 @@ import SectionSelect from "./SectionSelect.svelte"
|
||||||
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
|
import NavigationEditor from "./NavigationEditor/NavigationEditor.svelte"
|
||||||
import FilterEditor from "./FilterEditor/FilterEditor.svelte"
|
import FilterEditor from "./FilterEditor/FilterEditor.svelte"
|
||||||
import URLSelect from "./URLSelect.svelte"
|
import URLSelect from "./URLSelect.svelte"
|
||||||
import StringFieldSelect from "./StringFieldSelect.svelte"
|
import FormFieldSelect from "./FormFieldSelect.svelte"
|
||||||
import NumberFieldSelect from "./NumberFieldSelect.svelte"
|
import ValidationEditor from "./ValidationEditor/ValidationEditor.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"
|
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
text: Input,
|
text: Input,
|
||||||
|
@ -39,14 +33,22 @@ const componentMap = {
|
||||||
navigation: NavigationEditor,
|
navigation: NavigationEditor,
|
||||||
filter: FilterEditor,
|
filter: FilterEditor,
|
||||||
url: URLSelect,
|
url: URLSelect,
|
||||||
"field/string": StringFieldSelect,
|
"field/string": FormFieldSelect,
|
||||||
"field/number": NumberFieldSelect,
|
"field/number": FormFieldSelect,
|
||||||
"field/options": OptionsFieldSelect,
|
"field/options": FormFieldSelect,
|
||||||
"field/boolean": BooleanFieldSelect,
|
"field/boolean": FormFieldSelect,
|
||||||
"field/longform": LongFormFieldSelect,
|
"field/longform": FormFieldSelect,
|
||||||
"field/datetime": DateTimeFieldSelect,
|
"field/datetime": FormFieldSelect,
|
||||||
"field/attachment": AttachmentFieldSelect,
|
"field/attachment": FormFieldSelect,
|
||||||
"field/link": RelationshipFieldSelect,
|
"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 => {
|
export const getComponentForSettingType = type => {
|
||||||
|
|
|
@ -1796,6 +1796,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/string",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1830,6 +1835,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/number",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1864,6 +1874,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/string",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1915,6 +1930,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/string",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -1972,6 +1992,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/boolean",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -2007,6 +2032,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/string",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -2047,6 +2077,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/datetime",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -2071,6 +2106,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/attachment",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -2100,6 +2140,11 @@
|
||||||
"label": "Disabled",
|
"label": "Disabled",
|
||||||
"key": "disabled",
|
"key": "disabled",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "validation/link",
|
||||||
|
"label": "Validation",
|
||||||
|
"key": "validation"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
export let field
|
export let field
|
||||||
export let label
|
export let label
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
|
export let validation
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
let fieldApi
|
let fieldApi
|
||||||
|
@ -35,6 +36,7 @@
|
||||||
{label}
|
{label}
|
||||||
{field}
|
{field}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{validation}
|
||||||
type="attachment"
|
type="attachment"
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
bind:fieldApi
|
bind:fieldApi
|
||||||
|
@ -44,6 +46,7 @@
|
||||||
<CoreDropzone
|
<CoreDropzone
|
||||||
value={$fieldState.value}
|
value={$fieldState.value}
|
||||||
disabled={$fieldState.disabled}
|
disabled={$fieldState.disabled}
|
||||||
|
error={$fieldState.error}
|
||||||
on:change={e => {
|
on:change={e => {
|
||||||
fieldApi.setValue(e.detail)
|
fieldApi.setValue(e.detail)
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
export let text
|
export let text
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let size
|
export let size
|
||||||
|
export let validation
|
||||||
export let defaultValue
|
export let defaultValue
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
{label}
|
{label}
|
||||||
{field}
|
{field}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{validation}
|
||||||
defaultValue={isTruthy(defaultValue)}
|
defaultValue={isTruthy(defaultValue)}
|
||||||
type="boolean"
|
type="boolean"
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
export let placeholder
|
export let placeholder
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let enableTime = false
|
export let enableTime = false
|
||||||
|
export let validation
|
||||||
export let defaultValue
|
export let defaultValue
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
|
@ -42,6 +43,7 @@
|
||||||
{label}
|
{label}
|
||||||
{field}
|
{field}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{validation}
|
||||||
defaultValue={parseDate(defaultValue)}
|
defaultValue={parseDate(defaultValue)}
|
||||||
type="datetime"
|
type="datetime"
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
export let defaultValue
|
export let defaultValue
|
||||||
export let type
|
export let type
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
|
export let validation
|
||||||
|
|
||||||
// Get contexts
|
// Get contexts
|
||||||
const formContext = getContext("form")
|
const formContext = getContext("form")
|
||||||
|
@ -21,13 +22,21 @@
|
||||||
// Register field with form
|
// Register field with form
|
||||||
const formApi = formContext?.formApi
|
const formApi = formContext?.formApi
|
||||||
const labelPosition = fieldGroupContext?.labelPosition || "above"
|
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
|
// Expose field properties to parent component
|
||||||
fieldState = formField?.fieldState
|
fieldState = formField?.fieldState
|
||||||
fieldApi = formField?.fieldApi
|
fieldApi = formField?.fieldApi
|
||||||
fieldSchema = formField?.fieldSchema
|
fieldSchema = formField?.fieldSchema
|
||||||
|
|
||||||
|
// Keep validation rules up to date
|
||||||
|
$: fieldApi?.updateValidation(validation)
|
||||||
|
|
||||||
// Extract label position from field group context
|
// Extract label position from field group context
|
||||||
$: labelPositionClass =
|
$: labelPositionClass =
|
||||||
labelPosition === "above" ? "" : `spectrum-FieldLabel--${labelPosition}`
|
labelPosition === "above" ? "" : `spectrum-FieldLabel--${labelPosition}`
|
||||||
|
|
|
@ -21,7 +21,12 @@
|
||||||
|
|
||||||
// Form API contains functions to control the form
|
// Form API contains functions to control the form
|
||||||
const formApi = {
|
const formApi = {
|
||||||
registerField: (field, defaultValue = null, fieldDisabled = false) => {
|
registerField: (
|
||||||
|
field,
|
||||||
|
defaultValue = null,
|
||||||
|
fieldDisabled = false,
|
||||||
|
validationRules
|
||||||
|
) => {
|
||||||
if (!field) {
|
if (!field) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -30,17 +35,23 @@
|
||||||
const isAutoColumn = !!schema?.[field]?.autocolumn
|
const isAutoColumn = !!schema?.[field]?.autocolumn
|
||||||
|
|
||||||
// Create validation function based on field schema
|
// Create validation function based on field schema
|
||||||
const constraints = schema?.[field]?.constraints
|
const schemaConstraints = schema?.[field]?.constraints
|
||||||
const validate = createValidatorFromConstraints(constraints, field, table)
|
const validator = createValidatorFromConstraints(
|
||||||
|
schemaConstraints,
|
||||||
|
validationRules,
|
||||||
|
field,
|
||||||
|
table
|
||||||
|
)
|
||||||
|
|
||||||
// Construct field object
|
// Construct field object
|
||||||
fieldMap[field] = {
|
fieldMap[field] = {
|
||||||
fieldState: makeFieldState(
|
fieldState: makeFieldState(
|
||||||
field,
|
field,
|
||||||
|
validator,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
disabled || fieldDisabled || isAutoColumn
|
disabled || fieldDisabled || isAutoColumn
|
||||||
),
|
),
|
||||||
fieldApi: makeFieldApi(field, defaultValue, validate),
|
fieldApi: makeFieldApi(field, defaultValue),
|
||||||
fieldSchema: schema?.[field] ?? {},
|
fieldSchema: schema?.[field] ?? {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,9 +94,11 @@
|
||||||
]
|
]
|
||||||
|
|
||||||
// Creates an API for a specific field
|
// 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 setValue = (value, skipCheck = false) => {
|
||||||
const { fieldState } = fieldMap[field]
|
const { fieldState } = fieldMap[field]
|
||||||
|
const { validator } = get(fieldState)
|
||||||
|
|
||||||
// Skip if the value is the same
|
// Skip if the value is the same
|
||||||
if (!skipCheck && get(fieldState).value === value) {
|
if (!skipCheck && get(fieldState).value === value) {
|
||||||
|
@ -93,7 +106,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update field state
|
// Update field state
|
||||||
const error = validate ? validate(value) : null
|
const error = validator ? validator(value) : null
|
||||||
fieldState.update(state => {
|
fieldState.update(state => {
|
||||||
state.value = value
|
state.value = value
|
||||||
state.error = error
|
state.error = error
|
||||||
|
@ -115,15 +128,20 @@
|
||||||
return !error
|
return !error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clears the value of a certain field back to the initial value
|
||||||
const clearValue = () => {
|
const clearValue = () => {
|
||||||
const { fieldState } = fieldMap[field]
|
const { fieldState } = fieldMap[field]
|
||||||
|
const { defaultValue } = get(fieldState)
|
||||||
const newValue = initialValues[field] ?? defaultValue
|
const newValue = initialValues[field] ?? defaultValue
|
||||||
|
|
||||||
|
// Update field state
|
||||||
fieldState.update(state => {
|
fieldState.update(state => {
|
||||||
state.value = newValue
|
state.value = newValue
|
||||||
state.error = null
|
state.error = null
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Update form state
|
||||||
formState.update(state => {
|
formState.update(state => {
|
||||||
state.values = { ...state.values, [field]: newValue }
|
state.values = { ...state.values, [field]: newValue }
|
||||||
delete state.errors[field]
|
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 {
|
return {
|
||||||
setValue,
|
setValue,
|
||||||
clearValue,
|
clearValue,
|
||||||
|
updateValidation,
|
||||||
validate: () => {
|
validate: () => {
|
||||||
const { fieldState } = fieldMap[field]
|
const { fieldState } = fieldMap[field]
|
||||||
setValue(get(fieldState).value, true)
|
setValue(get(fieldState).value, true)
|
||||||
|
@ -143,13 +189,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates observable state data about a specific field
|
// Creates observable state data about a specific field
|
||||||
const makeFieldState = (field, defaultValue, fieldDisabled) => {
|
const makeFieldState = (field, validator, defaultValue, fieldDisabled) => {
|
||||||
return writable({
|
return writable({
|
||||||
field,
|
field,
|
||||||
fieldId: `id-${generateID()}`,
|
fieldId: `id-${generateID()}`,
|
||||||
value: initialValues[field] ?? defaultValue,
|
value: initialValues[field] ?? defaultValue,
|
||||||
error: null,
|
error: null,
|
||||||
disabled: fieldDisabled,
|
disabled: fieldDisabled,
|
||||||
|
defaultValue,
|
||||||
|
validator,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
export let label
|
export let label
|
||||||
export let placeholder
|
export let placeholder
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
|
export let validation
|
||||||
export let defaultValue = ""
|
export let defaultValue = ""
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
|
@ -16,6 +17,7 @@
|
||||||
{label}
|
{label}
|
||||||
{field}
|
{field}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{validation}
|
||||||
{defaultValue}
|
{defaultValue}
|
||||||
type="longform"
|
type="longform"
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
export let placeholder
|
export let placeholder
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
export let optionsType = "select"
|
export let optionsType = "select"
|
||||||
|
export let validation
|
||||||
export let defaultValue
|
export let defaultValue
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
|
@ -18,6 +19,7 @@
|
||||||
{field}
|
{field}
|
||||||
{label}
|
{label}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{validation}
|
||||||
{defaultValue}
|
{defaultValue}
|
||||||
type="options"
|
type="options"
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
export let label
|
export let label
|
||||||
export let placeholder
|
export let placeholder
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
|
export let validation
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
let fieldApi
|
let fieldApi
|
||||||
|
@ -64,6 +65,7 @@
|
||||||
{label}
|
{label}
|
||||||
{field}
|
{field}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{validation}
|
||||||
type="link"
|
type="link"
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
bind:fieldApi
|
bind:fieldApi
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
export let placeholder
|
export let placeholder
|
||||||
export let type = "text"
|
export let type = "text"
|
||||||
export let disabled = false
|
export let disabled = false
|
||||||
|
export let validation
|
||||||
export let defaultValue = ""
|
export let defaultValue = ""
|
||||||
|
|
||||||
let fieldState
|
let fieldState
|
||||||
|
@ -17,6 +18,7 @@
|
||||||
{label}
|
{label}
|
||||||
{field}
|
{field}
|
||||||
{disabled}
|
{disabled}
|
||||||
|
{validation}
|
||||||
{defaultValue}
|
{defaultValue}
|
||||||
type={type === "number" ? "number" : "string"}
|
type={type === "number" ? "number" : "string"}
|
||||||
bind:fieldState
|
bind:fieldState
|
||||||
|
|
|
@ -1,54 +1,108 @@
|
||||||
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: `Maximum length is ${length}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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: `Minimum value is ${min}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
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: `Maximum value is ${max}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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))
|
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)) {
|
if (exists(schemaConstraints.datetime?.latest)) {
|
||||||
const limit = constraints.datetime.latest
|
const limit = schemaConstraints.datetime.latest
|
||||||
checks.push(dateConstraint(limit, false))
|
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
|
// 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 +111,197 @@ 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" : 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 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
|
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 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 !== ""
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue