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
|
||||
* 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
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
"icon": "ri-file-edit-line",
|
||||
"children": [
|
||||
"form",
|
||||
"input",
|
||||
"stringfield",
|
||||
"numberfield",
|
||||
"optionsfield",
|
||||
"richtext",
|
||||
"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 =
|
||||
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>
|
||||
|
|
|
@ -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 { 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 => {
|
||||
|
|
|
@ -13,9 +13,11 @@ const createBuilderStore = () => {
|
|||
const store = writable(initialState)
|
||||
const actions = {
|
||||
selectComponent: id => {
|
||||
if (id) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("bb-select-component", { detail: id })
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
return {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 { 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}
|
|
@ -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"
|
||||
|
|
|
@ -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