Merge pull request #2339 from Budibase/custom-form-validation
Custom form validation
This commit is contained in:
commit
36a744f577
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -67,6 +67,7 @@
|
|||
placeholder: setting.placeholder,
|
||||
}}
|
||||
{bindings}
|
||||
{componentDefinition}
|
||||
/>
|
||||
{/if}
|
||||
{/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) => {
|
||||
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])
|
||||
|
|
|
@ -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 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 => {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}`
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 === "") {
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
// Parse as number
|
||||
if (type === "number") {
|
||||
if (isNaN(value)) {
|
||||
return "Must be a number"
|
||||
return null
|
||||
}
|
||||
const number = parseFloat(value)
|
||||
if (!constraint(number)) {
|
||||
return error
|
||||
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
|
||||
}
|
||||
|
||||
const inclusionConstraint =
|
||||
(options = []) =>
|
||||
value => {
|
||||
if (value == null || value === "") {
|
||||
return null
|
||||
}
|
||||
if (!options.includes(value)) {
|
||||
return "Invalid value"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const dateConstraint = (dateString, isEarliest) => {
|
||||
const dateLimit = Date.parse(dateString)
|
||||
return value => {
|
||||
if (value == null || value === "") {
|
||||
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}`
|
||||
}
|
||||
// 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