Add automatic schema validation to forms and add builder settings for specific field types

This commit is contained in:
Andrew Kingston 2021-01-26 14:40:44 +00:00
parent efbb599f02
commit 365c503224
18 changed files with 271 additions and 31 deletions

View File

@ -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

View File

@ -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"
] ]

View File

@ -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} />

View File

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

View File

@ -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>

View File

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

View File

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

View File

@ -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 => {

View File

@ -13,9 +13,11 @@ const createBuilderStore = () => {
const store = writable(initialState) const store = writable(initialState)
const actions = { const actions = {
selectComponent: id => { selectComponent: id => {
if (id) {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("bb-select-component", { detail: id }) new CustomEvent("bb-select-component", { detail: id })
) )
}
}, },
} }
return { return {

View File

@ -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)
} }

View File

@ -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"
} }
] ]
} }

View File

@ -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

View File

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

View File

@ -0,0 +1 @@
Select

View File

@ -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}

View File

@ -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}

View File

@ -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"

View File

@ -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
}