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
* type.
* Recurses through the component tree and finds all components which match
* a certain selector
*/
export const findAllMatchingComponents = (rootComponent, selector) => {
if (!rootComponent || !selector) {
@ -81,6 +81,26 @@ export const findAllMatchingComponents = (rootComponent, selector) => {
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
* components until a match is found

View File

@ -8,7 +8,9 @@
"icon": "ri-file-edit-line",
"children": [
"form",
"input",
"stringfield",
"numberfield",
"optionsfield",
"richtext",
"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 =
selectedOption && selectedOption.label ? selectedOption.label : value || ""
selectedOption && selectedOption.label
? selectedOption.label
: value || "Choose option"
</script>
<div
@ -129,11 +131,16 @@
on:keydown={handleEscape}
class="bb-select-menu">
<ul>
<li
on:click|self={() => handleClick(null)}
class:selected={value == null || value === ''}>
Choose option
</li>
{#if isOptionsObject}
{#each options as { value: v, label }}
<li
{...handleStyleBind(v)}
on:click|self={handleClick(v)}
on:click|self={() => handleClick(v)}
class:selected={value === v}>
{label}
</li>
@ -142,7 +149,7 @@
{#each options as v}
<li
{...handleStyleBind(v)}
on:click|self={handleClick(v)}
on:click|self={() => handleClick(v)}
class:selected={value === v}>
{v}
</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 { IconSelect } from "./PropertyControls/IconSelect"
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 componentInstance = {}
@ -58,6 +61,9 @@
icon: IconSelect,
field: TableViewFieldSelect,
multifield: MultiTableViewFieldSelect,
"field/string": StringFieldSelect,
"field/number": NumberFieldSelect,
"field/options": OptionsFieldSelect,
}
const getControl = type => {

View File

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

View File

@ -9,7 +9,7 @@ const selectedComponentColor = "#4285f4"
*/
const buildStyleString = (styleObject, customStyles) => {
let str = ""
Object.entries(styleObject).forEach(([style, value]) => {
Object.entries(styleObject || {}).forEach(([style, value]) => {
if (style && value != null) {
str += `${style}: ${value}; `
}
@ -60,14 +60,14 @@ export const styleable = (node, styles = {}) => {
}
// Creates event listeners and applies initial styles
const setupStyles = newStyles => {
const setupStyles = (newStyles = {}) => {
const componentId = newStyles.id
const selectable = newStyles.allowSelection
const customStyles = newStyles.custom
const normalStyles = newStyles.normal
const selectable = !!newStyles.allowSelection
const customStyles = newStyles.custom || ""
const normalStyles = newStyles.normal || {}
const hoverStyles = {
...normalStyles,
...newStyles.hover,
...(newStyles.hover || {}),
}
// 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
// builder preview
selectComponent = event => {
builderStore.actions.selectComponent(newStyles.id)
builderStore.actions.selectComponent(componentId)
return blockEvent(event)
}

View File

@ -1121,7 +1121,7 @@
}
]
},
"input": {
"stringfield": {
"name": "Text Field",
"description": "A textfield component that allows the user to input text.",
"icon": "ri-edit-box-line",
@ -1129,7 +1129,7 @@
"bindable": true,
"settings": [
{
"type": "text",
"type": "field/string",
"label": "Field",
"key": "field"
},
@ -1142,11 +1142,54 @@
"type": "text",
"label": "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",
"label": "Required",
"key": "required"
"type": "text",
"label": "Label",
"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>
import { setContext, getContext, onMount } from "svelte"
import { writable, get } from "svelte/store"
import { createValidatorFromConstraints } from "./validation"
export let datasource
export let theme
@ -10,7 +11,8 @@
const component = getContext("component")
let loaded = false
let schema = {}
let schema
let table
let fieldMap = {}
// Keep form state up to date with form fields
@ -21,13 +23,18 @@
// Form API contains functions to control the form
const formApi = {
registerField: (field, validate) => {
registerField: field => {
if (!field) {
return
}
if (fieldMap[field] != null) {
return fieldMap[field]
}
// Create validation function based on field schema
const constraints = schema?.[field]?.constraints
const validate = createValidatorFromConstraints(constraints, field, table)
fieldMap[field] = {
fieldState: makeFieldState(field),
fieldApi: makeFieldApi(field, validate),
@ -85,15 +92,16 @@
const fetchSchema = async () => {
if (!datasource?.tableId) {
schema = {}
table = null
} else {
const table = await API.fetchTableDefinition(datasource?.tableId)
table = await API.fetchTableDefinition(datasource?.tableId)
if (table) {
if (datasource.type === "query") {
schema = table.parameters
console.log("No implementation for queries yet")
schema = {}
} else {
schema = table.schema || {}
}
console.log(table)
}
}
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 { Label } from "@budibase/bbui"
import { getContext } from "svelte"
import Placeholder from "./Placeholder.svelte"
export let field
export let label
export let placeholder
export let validate = value => (value ? null : "Required")
export let type = "text"
const { styleable } = getContext("sdk")
const component = getContext("component")
@ -23,9 +24,9 @@
</script>
{#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}
<div>Form components need to be wrapped in a Form.</div>
<Placeholder>Form components need to be wrapped in a Form</Placeholder>
{:else}
<div class="container" use:styleable={$component.styles}>
{#if label}
@ -44,7 +45,7 @@
value={$fieldState.value || ''}
placeholder={placeholder || ''}
on:blur={onBlur}
type="text"
{type}
class="spectrum-Textfield-input" />
</div>
{#if $fieldState.error}

View File

@ -1,2 +1,4 @@
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
}