Add automatic schema validation to forms and add builder settings for specific field types
This commit is contained in:
parent
efbb599f02
commit
365c503224
|
@ -59,8 +59,8 @@ export const findComponentPath = (rootComponent, id, path = []) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recurses through the component tree and finds all components of a certain
|
* Recurses through the component tree and finds all components which match
|
||||||
* type.
|
* a certain selector
|
||||||
*/
|
*/
|
||||||
export const findAllMatchingComponents = (rootComponent, selector) => {
|
export const findAllMatchingComponents = (rootComponent, selector) => {
|
||||||
if (!rootComponent || !selector) {
|
if (!rootComponent || !selector) {
|
||||||
|
@ -81,6 +81,26 @@ export const findAllMatchingComponents = (rootComponent, selector) => {
|
||||||
return components.reverse()
|
return components.reverse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the closes parent component which matches certain criteria
|
||||||
|
*/
|
||||||
|
export const findClosestMatchingComponent = (
|
||||||
|
rootComponent,
|
||||||
|
componentId,
|
||||||
|
selector
|
||||||
|
) => {
|
||||||
|
if (!selector) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const componentPath = findComponentPath(rootComponent, componentId).reverse()
|
||||||
|
for (let component of componentPath) {
|
||||||
|
if (selector(component)) {
|
||||||
|
return component
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recurses through a component tree evaluating a matching function against
|
* Recurses through a component tree evaluating a matching function against
|
||||||
* components until a match is found
|
* components until a match is found
|
||||||
|
|
|
@ -8,7 +8,9 @@
|
||||||
"icon": "ri-file-edit-line",
|
"icon": "ri-file-edit-line",
|
||||||
"children": [
|
"children": [
|
||||||
"form",
|
"form",
|
||||||
"input",
|
"stringfield",
|
||||||
|
"numberfield",
|
||||||
|
"optionsfield",
|
||||||
"richtext",
|
"richtext",
|
||||||
"datepicker"
|
"datepicker"
|
||||||
]
|
]
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script>
|
||||||
|
import OptionSelect from "./OptionSelect.svelte"
|
||||||
|
import {
|
||||||
|
getDatasourceForProvider,
|
||||||
|
getSchemaForDatasource,
|
||||||
|
} from "builderStore/dataBinding"
|
||||||
|
import { currentAsset } from "builderStore"
|
||||||
|
import { findClosestMatchingComponent } from "builderStore/storeUtils"
|
||||||
|
|
||||||
|
export let componentInstance
|
||||||
|
export let value
|
||||||
|
export let onChange
|
||||||
|
export let type
|
||||||
|
|
||||||
|
$: form = findClosestMatchingComponent(
|
||||||
|
$currentAsset.props,
|
||||||
|
componentInstance._id,
|
||||||
|
component => component._component === "@budibase/standard-components/form"
|
||||||
|
)
|
||||||
|
$: datasource = getDatasourceForProvider(form)
|
||||||
|
$: schema = getSchemaForDatasource(datasource).schema
|
||||||
|
$: options = getOptions(schema, type)
|
||||||
|
|
||||||
|
const getOptions = (schema, fieldType) => {
|
||||||
|
let entries = Object.entries(schema)
|
||||||
|
if (fieldType) {
|
||||||
|
entries = entries.filter(entry => entry[1].type === fieldType)
|
||||||
|
}
|
||||||
|
return entries.map(entry => entry[0])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<OptionSelect {value} {onChange} {options} />
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script>
|
||||||
|
import FormFieldSelect from "./FormFieldSelect.svelte"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormFieldSelect {...$$props} type="number" />
|
|
@ -106,7 +106,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$: displayLabel =
|
$: displayLabel =
|
||||||
selectedOption && selectedOption.label ? selectedOption.label : value || ""
|
selectedOption && selectedOption.label
|
||||||
|
? selectedOption.label
|
||||||
|
: value || "Choose option"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -129,11 +131,16 @@
|
||||||
on:keydown={handleEscape}
|
on:keydown={handleEscape}
|
||||||
class="bb-select-menu">
|
class="bb-select-menu">
|
||||||
<ul>
|
<ul>
|
||||||
|
<li
|
||||||
|
on:click|self={() => handleClick(null)}
|
||||||
|
class:selected={value == null || value === ''}>
|
||||||
|
Choose option
|
||||||
|
</li>
|
||||||
{#if isOptionsObject}
|
{#if isOptionsObject}
|
||||||
{#each options as { value: v, label }}
|
{#each options as { value: v, label }}
|
||||||
<li
|
<li
|
||||||
{...handleStyleBind(v)}
|
{...handleStyleBind(v)}
|
||||||
on:click|self={handleClick(v)}
|
on:click|self={() => handleClick(v)}
|
||||||
class:selected={value === v}>
|
class:selected={value === v}>
|
||||||
{label}
|
{label}
|
||||||
</li>
|
</li>
|
||||||
|
@ -142,7 +149,7 @@
|
||||||
{#each options as v}
|
{#each options as v}
|
||||||
<li
|
<li
|
||||||
{...handleStyleBind(v)}
|
{...handleStyleBind(v)}
|
||||||
on:click|self={handleClick(v)}
|
on:click|self={() => handleClick(v)}
|
||||||
class:selected={value === v}>
|
class:selected={value === v}>
|
||||||
{v}
|
{v}
|
||||||
</li>
|
</li>
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script>
|
||||||
|
import FormFieldSelect from "./FormFieldSelect.svelte"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormFieldSelect {...$$props} type="options" />
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script>
|
||||||
|
import FormFieldSelect from "./FormFieldSelect.svelte"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormFieldSelect {...$$props} type="string" />
|
|
@ -17,6 +17,9 @@
|
||||||
import DetailScreenSelect from "./PropertyControls/DetailScreenSelect.svelte"
|
import DetailScreenSelect from "./PropertyControls/DetailScreenSelect.svelte"
|
||||||
import { IconSelect } from "./PropertyControls/IconSelect"
|
import { IconSelect } from "./PropertyControls/IconSelect"
|
||||||
import ColorPicker from "./PropertyControls/ColorPicker.svelte"
|
import ColorPicker from "./PropertyControls/ColorPicker.svelte"
|
||||||
|
import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte"
|
||||||
|
import NumberFieldSelect from "./PropertyControls/NumberFieldSelect.svelte"
|
||||||
|
import OptionsFieldSelect from "./PropertyControls/OptionsFieldSelect.svelte"
|
||||||
|
|
||||||
export let componentDefinition = {}
|
export let componentDefinition = {}
|
||||||
export let componentInstance = {}
|
export let componentInstance = {}
|
||||||
|
@ -58,6 +61,9 @@
|
||||||
icon: IconSelect,
|
icon: IconSelect,
|
||||||
field: TableViewFieldSelect,
|
field: TableViewFieldSelect,
|
||||||
multifield: MultiTableViewFieldSelect,
|
multifield: MultiTableViewFieldSelect,
|
||||||
|
"field/string": StringFieldSelect,
|
||||||
|
"field/number": NumberFieldSelect,
|
||||||
|
"field/options": OptionsFieldSelect,
|
||||||
}
|
}
|
||||||
|
|
||||||
const getControl = type => {
|
const getControl = type => {
|
||||||
|
|
|
@ -13,9 +13,11 @@ const createBuilderStore = () => {
|
||||||
const store = writable(initialState)
|
const store = writable(initialState)
|
||||||
const actions = {
|
const actions = {
|
||||||
selectComponent: id => {
|
selectComponent: id => {
|
||||||
window.dispatchEvent(
|
if (id) {
|
||||||
new CustomEvent("bb-select-component", { detail: id })
|
window.dispatchEvent(
|
||||||
)
|
new CustomEvent("bb-select-component", { detail: id })
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -9,7 +9,7 @@ const selectedComponentColor = "#4285f4"
|
||||||
*/
|
*/
|
||||||
const buildStyleString = (styleObject, customStyles) => {
|
const buildStyleString = (styleObject, customStyles) => {
|
||||||
let str = ""
|
let str = ""
|
||||||
Object.entries(styleObject).forEach(([style, value]) => {
|
Object.entries(styleObject || {}).forEach(([style, value]) => {
|
||||||
if (style && value != null) {
|
if (style && value != null) {
|
||||||
str += `${style}: ${value}; `
|
str += `${style}: ${value}; `
|
||||||
}
|
}
|
||||||
|
@ -60,14 +60,14 @@ export const styleable = (node, styles = {}) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates event listeners and applies initial styles
|
// Creates event listeners and applies initial styles
|
||||||
const setupStyles = newStyles => {
|
const setupStyles = (newStyles = {}) => {
|
||||||
const componentId = newStyles.id
|
const componentId = newStyles.id
|
||||||
const selectable = newStyles.allowSelection
|
const selectable = !!newStyles.allowSelection
|
||||||
const customStyles = newStyles.custom
|
const customStyles = newStyles.custom || ""
|
||||||
const normalStyles = newStyles.normal
|
const normalStyles = newStyles.normal || {}
|
||||||
const hoverStyles = {
|
const hoverStyles = {
|
||||||
...normalStyles,
|
...normalStyles,
|
||||||
...newStyles.hover,
|
...(newStyles.hover || {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies a style string to a DOM node, enriching it for the builder
|
// Applies a style string to a DOM node, enriching it for the builder
|
||||||
|
@ -89,7 +89,7 @@ export const styleable = (node, styles = {}) => {
|
||||||
// Handler to select a component in the builder when clicking it in the
|
// Handler to select a component in the builder when clicking it in the
|
||||||
// builder preview
|
// builder preview
|
||||||
selectComponent = event => {
|
selectComponent = event => {
|
||||||
builderStore.actions.selectComponent(newStyles.id)
|
builderStore.actions.selectComponent(componentId)
|
||||||
return blockEvent(event)
|
return blockEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1121,7 +1121,7 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"input": {
|
"stringfield": {
|
||||||
"name": "Text Field",
|
"name": "Text Field",
|
||||||
"description": "A textfield component that allows the user to input text.",
|
"description": "A textfield component that allows the user to input text.",
|
||||||
"icon": "ri-edit-box-line",
|
"icon": "ri-edit-box-line",
|
||||||
|
@ -1129,7 +1129,7 @@
|
||||||
"bindable": true,
|
"bindable": true,
|
||||||
"settings": [
|
"settings": [
|
||||||
{
|
{
|
||||||
"type": "text",
|
"type": "field/string",
|
||||||
"label": "Field",
|
"label": "Field",
|
||||||
"key": "field"
|
"key": "field"
|
||||||
},
|
},
|
||||||
|
@ -1142,11 +1142,54 @@
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"label": "Placeholder",
|
"label": "Placeholder",
|
||||||
"key": "placeholder"
|
"key": "placeholder"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"numberfield": {
|
||||||
|
"name": "Number Field",
|
||||||
|
"description": "A textfield component that allows the user to input numbers.",
|
||||||
|
"icon": "ri-edit-box-line",
|
||||||
|
"styleable": true,
|
||||||
|
"bindable": true,
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "field/number",
|
||||||
|
"label": "Field",
|
||||||
|
"key": "field"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "boolean",
|
"type": "text",
|
||||||
"label": "Required",
|
"label": "Label",
|
||||||
"key": "required"
|
"key": "label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Placeholder",
|
||||||
|
"key": "placeholder"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"optionsfield": {
|
||||||
|
"name": "Options Picker",
|
||||||
|
"description": "A textfield component that allows the user to input text.",
|
||||||
|
"icon": "ri-edit-box-line",
|
||||||
|
"styleable": true,
|
||||||
|
"bindable": true,
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"type": "field/options",
|
||||||
|
"label": "Field",
|
||||||
|
"key": "field"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Label",
|
||||||
|
"key": "label"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"label": "Placeholder",
|
||||||
|
"key": "placeholder"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { setContext, getContext, onMount } from "svelte"
|
import { setContext, getContext, onMount } from "svelte"
|
||||||
import { writable, get } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
|
import { createValidatorFromConstraints } from "./validation"
|
||||||
|
|
||||||
export let datasource
|
export let datasource
|
||||||
export let theme
|
export let theme
|
||||||
|
@ -10,7 +11,8 @@
|
||||||
const component = getContext("component")
|
const component = getContext("component")
|
||||||
|
|
||||||
let loaded = false
|
let loaded = false
|
||||||
let schema = {}
|
let schema
|
||||||
|
let table
|
||||||
let fieldMap = {}
|
let fieldMap = {}
|
||||||
|
|
||||||
// Keep form state up to date with form fields
|
// Keep form state up to date with form fields
|
||||||
|
@ -21,13 +23,18 @@
|
||||||
|
|
||||||
// Form API contains functions to control the form
|
// Form API contains functions to control the form
|
||||||
const formApi = {
|
const formApi = {
|
||||||
registerField: (field, validate) => {
|
registerField: field => {
|
||||||
if (!field) {
|
if (!field) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (fieldMap[field] != null) {
|
if (fieldMap[field] != null) {
|
||||||
return fieldMap[field]
|
return fieldMap[field]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create validation function based on field schema
|
||||||
|
const constraints = schema?.[field]?.constraints
|
||||||
|
const validate = createValidatorFromConstraints(constraints, field, table)
|
||||||
|
|
||||||
fieldMap[field] = {
|
fieldMap[field] = {
|
||||||
fieldState: makeFieldState(field),
|
fieldState: makeFieldState(field),
|
||||||
fieldApi: makeFieldApi(field, validate),
|
fieldApi: makeFieldApi(field, validate),
|
||||||
|
@ -85,15 +92,16 @@
|
||||||
const fetchSchema = async () => {
|
const fetchSchema = async () => {
|
||||||
if (!datasource?.tableId) {
|
if (!datasource?.tableId) {
|
||||||
schema = {}
|
schema = {}
|
||||||
|
table = null
|
||||||
} else {
|
} else {
|
||||||
const table = await API.fetchTableDefinition(datasource?.tableId)
|
table = await API.fetchTableDefinition(datasource?.tableId)
|
||||||
if (table) {
|
if (table) {
|
||||||
if (datasource.type === "query") {
|
if (datasource.type === "query") {
|
||||||
schema = table.parameters
|
console.log("No implementation for queries yet")
|
||||||
|
schema = {}
|
||||||
} else {
|
} else {
|
||||||
schema = table.schema || {}
|
schema = table.schema || {}
|
||||||
}
|
}
|
||||||
console.log(table)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loaded = true
|
loaded = true
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script>
|
||||||
|
import StringField from "./StringField.svelte"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<StringField {...$$props} type="number" />
|
|
@ -0,0 +1 @@
|
||||||
|
Select
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script>
|
||||||
|
import { getContext } from "svelte"
|
||||||
|
|
||||||
|
const { styleable, builderStore } = getContext("sdk")
|
||||||
|
const component = getContext("component")
|
||||||
|
|
||||||
|
$: styles = {
|
||||||
|
id: $component.styles.id,
|
||||||
|
allowSelection: $component.styles.allowSelection,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $builderStore.inBuilder}
|
||||||
|
<div use:styleable={styles}>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
{/if}
|
|
@ -2,11 +2,12 @@
|
||||||
import "@spectrum-css/textfield/dist/index-vars.css"
|
import "@spectrum-css/textfield/dist/index-vars.css"
|
||||||
import { Label } from "@budibase/bbui"
|
import { Label } from "@budibase/bbui"
|
||||||
import { getContext } from "svelte"
|
import { getContext } from "svelte"
|
||||||
|
import Placeholder from "./Placeholder.svelte"
|
||||||
|
|
||||||
export let field
|
export let field
|
||||||
export let label
|
export let label
|
||||||
export let placeholder
|
export let placeholder
|
||||||
export let validate = value => (value ? null : "Required")
|
export let type = "text"
|
||||||
|
|
||||||
const { styleable } = getContext("sdk")
|
const { styleable } = getContext("sdk")
|
||||||
const component = getContext("component")
|
const component = getContext("component")
|
||||||
|
@ -23,9 +24,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !field}
|
{#if !field}
|
||||||
<div>Add the Field setting to start using your component!</div>
|
<Placeholder>Add the Field setting to start using your component</Placeholder>
|
||||||
{:else if !fieldState}
|
{:else if !fieldState}
|
||||||
<div>Form components need to be wrapped in a Form.</div>
|
<Placeholder>Form components need to be wrapped in a Form</Placeholder>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="container" use:styleable={$component.styles}>
|
<div class="container" use:styleable={$component.styles}>
|
||||||
{#if label}
|
{#if label}
|
||||||
|
@ -44,7 +45,7 @@
|
||||||
value={$fieldState.value || ''}
|
value={$fieldState.value || ''}
|
||||||
placeholder={placeholder || ''}
|
placeholder={placeholder || ''}
|
||||||
on:blur={onBlur}
|
on:blur={onBlur}
|
||||||
type="text"
|
{type}
|
||||||
class="spectrum-Textfield-input" />
|
class="spectrum-Textfield-input" />
|
||||||
</div>
|
</div>
|
||||||
{#if $fieldState.error}
|
{#if $fieldState.error}
|
|
@ -1,2 +1,4 @@
|
||||||
export { default as form } from "./Form.svelte"
|
export { default as form } from "./Form.svelte"
|
||||||
export { default as input } from "./Input.svelte"
|
export { default as stringfield } from "./StringField.svelte"
|
||||||
|
export { default as numberfield } from "./NumberField.svelte"
|
||||||
|
export { default as optionsfield } from "./OptionsField.svelte"
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
export const createValidatorFromConstraints = (constraints, field, table) => {
|
||||||
|
let checks = []
|
||||||
|
|
||||||
|
if (constraints) {
|
||||||
|
// Required constraint
|
||||||
|
if (
|
||||||
|
field === table?.primaryDisplay ||
|
||||||
|
constraints.presence?.allowEmpty === false
|
||||||
|
) {
|
||||||
|
checks.push(presenceConstraint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String length constraint
|
||||||
|
if (constraints.length?.maximum) {
|
||||||
|
const length = constraints.length?.maximum
|
||||||
|
checks.push(lengthConstraint(length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Min / max number constraint
|
||||||
|
if (constraints.numericality?.greaterThanOrEqualTo != null) {
|
||||||
|
const min = constraints.numericality.greaterThanOrEqualTo
|
||||||
|
checks.push(numericalConstraint(x => x >= min, `Minimum value is ${min}`))
|
||||||
|
}
|
||||||
|
if (constraints.numericality?.lessThanOrEqualTo != null) {
|
||||||
|
const max = constraints.numericality.lessThanOrEqualTo
|
||||||
|
checks.push(numericalConstraint(x => x <= max, `Maximum value is ${max}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inclusion constraint
|
||||||
|
if (constraints.inclusion !== undefined) {
|
||||||
|
const options = constraints.inclusion
|
||||||
|
checks.push(inclusionConstraint(options))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate each constraint
|
||||||
|
return value => {
|
||||||
|
for (let check of checks) {
|
||||||
|
const error = check(value)
|
||||||
|
if (error) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const presenceConstraint = value => {
|
||||||
|
return value == null || value === "" ? "Required" : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const lengthConstraint = maxLength => value => {
|
||||||
|
if (value && value.length > maxLength) {
|
||||||
|
;`Maximum ${maxLength} characters`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericalConstraint = (constraint, error) => value => {
|
||||||
|
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
|
||||||
|
}
|
Loading…
Reference in New Issue