Add custom component actions. Simplify client context. Add form validation action

This commit is contained in:
Andrew Kingston 2021-02-01 18:51:22 +00:00
parent dbe7e8d4b7
commit cf43cf765c
28 changed files with 264 additions and 195 deletions

View File

@ -1,7 +1,7 @@
import { cloneDeep } from "lodash/fp"
import { get } from "svelte/store"
import { backendUiStore, store } from "builderStore"
import { findAllMatchingComponents, findComponentPath } from "./storeUtils"
import { findComponentPath } from "./storeUtils"
import { TableNames } from "../constants"
// Regex to match all instances of template strings
@ -11,9 +11,7 @@ const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
* Gets all bindable data context fields and instance fields.
*/
export const getBindableProperties = (rootComponent, componentId) => {
const contextBindings = getContextBindings(rootComponent, componentId)
const componentBindings = getComponentBindings(rootComponent)
return [...contextBindings, ...componentBindings]
return getContextBindings(rootComponent, componentId)
}
/**
@ -36,6 +34,30 @@ export const getDataProviderComponents = (rootComponent, componentId) => {
})
}
/**
* Gets all data provider components above a component.
*/
export const getActionProviderComponents = (
rootComponent,
componentId,
actionType
) => {
if (!rootComponent || !componentId) {
return []
}
// Get the component tree leading up to this component, ignoring the component
// itself
const path = findComponentPath(rootComponent, componentId)
path.pop()
// Filter by only data provider components
return path.filter(component => {
const def = store.actions.components.getDefinition(component._component)
return def?.actions?.includes(actionType)
})
}
/**
* Gets a datasource object for a certain data provider component
*/
@ -149,30 +171,6 @@ export const getContextBindings = (rootComponent, componentId) => {
return contextBindings
}
/**
* Gets all bindable components. These are form components which allow their
* values to be bound to.
*/
export const getComponentBindings = rootComponent => {
if (!rootComponent) {
return []
}
const componentSelector = component => {
const type = component._component
const definition = store.actions.components.getDefinition(type)
return definition?.bindable
}
const components = findAllMatchingComponents(rootComponent, componentSelector)
return components.map(component => {
return {
type: "instance",
providerId: component._id,
runtimeBinding: `${component._id}`,
readableBinding: `${component._instanceName}`,
}
})
}
/**
* Gets a schema for a datasource object.
*/

View File

@ -1,15 +1,6 @@
<script>
import {
Button,
Body,
DropdownMenu,
ModalContent,
Spacer,
} from "@budibase/bbui"
import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
import { Button, DropdownMenu, Spacer } from "@budibase/bbui"
import actionTypes from "./actions"
import { createEventDispatcher } from "svelte"
import { automationStore } from "builderStore"
const EVENT_TYPE_KEY = "##eventHandlerType"

View File

@ -0,0 +1,39 @@
<script>
import { DataList, Label } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding"
export let parameters
$: actionProviders = getActionProviderComponents(
$currentAsset.props,
$store.selectedComponentId,
"ValidateForm"
)
$: console.log(actionProviders)
</script>
<div class="root">
<Label size="m" color="dark">Form</Label>
<DataList secondary bind:value={parameters.componentId} label="asd">
<option value="" />
{#if actionProviders}
{#each actionProviders as component}
<option value={component._id}>{component._instanceName}</option>
{/each}
{/if}
</DataList>
</div>
<style>
.root {
display: flex;
flex-direction: row;
align-items: baseline;
}
.root :global(> div) {
flex: 1;
margin-left: var(--spacing-l);
}
</style>

View File

@ -3,6 +3,7 @@ import SaveRow from "./SaveRow.svelte"
import DeleteRow from "./DeleteRow.svelte"
import ExecuteQuery from "./ExecuteQuery.svelte"
import TriggerAutomation from "./TriggerAutomation.svelte"
import ValidateForm from "./ValidateForm.svelte"
// defines what actions are available, when adding a new one
// the component is the setup panel for the action
@ -30,4 +31,8 @@ export default [
name: "Trigger Automation",
component: TriggerAutomation,
},
{
name: "Validate Form",
component: ValidateForm,
},
]

View File

@ -4,12 +4,17 @@
import Component from "./Component.svelte"
import NotificationDisplay from "./NotificationDisplay.svelte"
import SDK from "../sdk"
import { createDataStore, initialise, screenStore, authStore } from "../store"
import {
createContextStore,
initialise,
screenStore,
authStore,
} from "../store"
// Provide contexts
setContext("sdk", SDK)
setContext("component", writable({}))
setContext("data", createDataStore())
setContext("context", createContextStore())
let loaded = false

View File

@ -4,7 +4,7 @@
import * as ComponentLibrary from "@budibase/standard-components"
import Router from "./Router.svelte"
import { enrichProps, propsAreSame } from "../utils/componentProps"
import { authStore, bindingStore, builderStore } from "../store"
import { authStore, builderStore } from "../store"
import { hashString } from "../utils/hash"
export let definition = {}
@ -22,7 +22,7 @@
let latestUpdateTime
// Get contexts
const dataContext = getContext("data")
const context = getContext("context")
// Create component context
const componentStore = writable({})
@ -32,7 +32,7 @@
$: constructor = getComponentConstructor(definition._component)
$: children = definition._children || []
$: id = definition._id
$: updateComponentProps(definition, $dataContext, $bindingStore, $authStore)
$: updateComponentProps(definition, $context, $authStore)
$: styles = definition._styles
// Update component context
@ -53,13 +53,13 @@
}
// Enriches any string component props using handlebars
const updateComponentProps = async (definition, context, bindings, user) => {
const updateComponentProps = async (definition, context, user) => {
// Record the timestamp so we can reference it after enrichment
latestUpdateTime = Date.now()
const enrichmentTime = latestUpdateTime
// Enrich props with context
const enrichedProps = await enrichProps(definition, context, bindings, user)
const enrichedProps = await enrichProps(definition, context, user)
// Abandon this update if a newer update has started
if (enrichmentTime !== latestUpdateTime) {

View File

@ -1,15 +0,0 @@
<script>
import { getContext, setContext } from "svelte"
import { createDataStore } from "../store"
export let row
// Clone and create new data context for this component tree
const dataContext = getContext("data")
const component = getContext("component")
const newData = createDataStore($dataContext)
setContext("data", newData)
$: newData.actions.addContext(row, $component.id)
</script>
<slot />

View File

@ -0,0 +1,29 @@
<script>
import { getContext, setContext } from "svelte"
import { createContextStore } from "../store"
export let data
export let actions
// Clone and create new data context for this component tree
const context = getContext("context")
const component = getContext("component")
const newContext = createContextStore($context)
setContext("context", newContext)
// Add data context
$: {
if (data !== undefined) {
newContext.actions.provideData($component.id, data)
}
}
// Add actions context
$: {
actions?.forEach(({ type, callback }) => {
newContext.actions.provideAction($component.id, type, callback)
})
}
</script>
<slot />

View File

@ -1,3 +1,7 @@
export const TableNames = {
USERS: "ta_users",
}
export const ActionTypes = {
ValidateForm: "ValidateForm",
}

View File

@ -4,12 +4,12 @@ import {
notificationStore,
routeStore,
screenStore,
bindingStore,
builderStore,
} from "./store"
import { styleable } from "./utils/styleable"
import { linkable } from "./utils/linkable"
import DataProvider from "./components/DataProvider.svelte"
import Provider from "./components/Provider.svelte"
import { ActionTypes } from "./constants"
export default {
API,
@ -20,6 +20,6 @@ export default {
builderStore,
styleable,
linkable,
DataProvider,
setBindableValue: bindingStore.actions.setBindableValue,
Provider,
ActionTypes,
}

View File

@ -1,21 +0,0 @@
import { writable } from "svelte/store"
const createBindingStore = () => {
const store = writable({})
const setBindableValue = (componentId, value) => {
store.update(state => {
if (componentId) {
state[componentId] = value
}
return state
})
}
return {
subscribe: store.subscribe,
actions: { setBindableValue },
}
}
export const bindingStore = createBindingStore()

View File

@ -0,0 +1,34 @@
import { writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
export const createContextStore = existingContext => {
const store = writable({ ...existingContext })
// Adds a data context layer to the tree
const provideData = (componentId, data) => {
store.update(state => {
if (componentId) {
state[componentId] = data
state[`${componentId}_draft`] = cloneDeep(data)
state.closestComponentId = componentId
}
return state
})
}
// Adds an action context layer to the tree
const provideAction = (componentId, actionType, callback) => {
store.update(state => {
if (actionType && componentId) {
state[`${componentId}_${actionType}`] = callback
}
return state
})
}
return {
subscribe: store.subscribe,
update: store.update,
actions: { provideData, provideAction },
}
}

View File

@ -1,26 +0,0 @@
import { writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
export const createDataStore = existingContext => {
const store = writable({ ...existingContext })
// Adds a context layer to the data context tree
const addContext = (row, componentId) => {
store.update(state => {
if (componentId) {
state[componentId] = row
state[`${componentId}_draft`] = cloneDeep(row)
state.closestComponentId = componentId
}
return state
})
}
return {
subscribe: store.subscribe,
update: store.update,
actions: { addContext },
}
}
export const dataStore = createDataStore()

View File

@ -3,10 +3,9 @@ export { notificationStore } from "./notification"
export { routeStore } from "./routes"
export { screenStore } from "./screens"
export { builderStore } from "./builder"
export { bindingStore } from "./binding"
// Data stores are layered and duplicated, so it is not a singleton
export { createDataStore, dataStore } from "./data"
// Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context"
// Initialises an app by loading screens and routes
export { initialise } from "./initialise"

View File

@ -2,6 +2,7 @@ import { get } from "svelte/store"
import { enrichDataBinding, enrichDataBindings } from "./enrichDataBinding"
import { routeStore, builderStore } from "../store"
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "../api"
import { ActionTypes } from "../constants"
const saveRowHandler = async (action, context) => {
const { fields, providerId } = action.parameters
@ -59,12 +60,21 @@ const queryExecutionHandler = async (action, context) => {
})
}
const validateFormHandler = async (action, context) => {
const { componentId } = action.parameters
const fn = context[`${componentId}_${ActionTypes.ValidateForm}`]
if (fn) {
return await fn()
}
}
const handlerMap = {
["Save Row"]: saveRowHandler,
["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler,
["Execute Query"]: queryExecutionHandler,
["Trigger Automation"]: triggerAutomationHandler,
["Validate Form"]: validateFormHandler,
}
/**
@ -79,7 +89,18 @@ export const enrichButtonActions = (actions, context) => {
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => {
for (let i = 0; i < handlers.length; i++) {
await handlers[i](actions[i], context)
try {
const result = await handlers[i](actions[i], context)
// A handler returning `false` is a flag to stop execution of handlers
if (result === false) {
return
}
} catch (error) {
console.error("Error while executing button handler")
console.error(error)
// Stop executing on an error
return
}
}
}
}

View File

@ -21,7 +21,7 @@ export const propsAreSame = (a, b) => {
* Enriches component props.
* Data bindings are enriched, and button actions are enriched.
*/
export const enrichProps = async (props, dataContexts, dataBindings, user) => {
export const enrichProps = async (props, context, user) => {
// Exclude all private props that start with an underscore
let validProps = {}
Object.entries(props)
@ -32,20 +32,22 @@ export const enrichProps = async (props, dataContexts, dataBindings, user) => {
// Create context of all bindings and data contexts
// Duplicate the closest context as "data" which the builder requires
const context = {
...dataContexts,
...dataBindings,
const totalContext = {
...context,
user,
data: dataContexts[dataContexts.closestComponentId],
data_draft: dataContexts[`${dataContexts.closestComponentId}_draft`],
data: context[context.closestComponentId],
data_draft: context[`${context.closestComponentId}_draft`],
}
// Enrich all data bindings in top level props
let enrichedProps = await enrichDataBindings(validProps, context)
let enrichedProps = await enrichDataBindings(validProps, totalContext)
// Enrich button actions if they exist
if (props._component.endsWith("/button") && enrichedProps.onClick) {
enrichedProps.onClick = enrichButtonActions(enrichedProps.onClick, context)
enrichedProps.onClick = enrichButtonActions(
enrichedProps.onClick,
totalContext
)
}
return enrichedProps

View File

@ -1051,11 +1051,12 @@
},
"form": {
"name": "Form",
"icon": "ri-file-edit-line",
"icon": "ri-file-text-line",
"styleable": true,
"hasChildren": true,
"dataProvider": true,
"datasourceSetting": "datasource",
"actions": ["ValidateForm"],
"settings": [
{
"type": "datasource",
@ -1102,7 +1103,7 @@
},
"fieldgroup": {
"name": "Field Group",
"icon": "ri-edit-box-line",
"icon": "ri-layout-row-line",
"styleable": true,
"hasChildren": true,
"settings": [
@ -1130,7 +1131,7 @@
},
"stringfield": {
"name": "Text Field",
"icon": "ri-edit-box-line",
"icon": "ri-t-box-line",
"styleable": true,
"settings": [
{
@ -1174,7 +1175,7 @@
},
"optionsfield": {
"name": "Options Picker",
"icon": "ri-edit-box-line",
"icon": "ri-file-list-line",
"styleable": true,
"settings": [
{
@ -1197,7 +1198,7 @@
},
"booleanfield": {
"name": "Checkbox",
"icon": "ri-edit-box-line",
"icon": "ri-checkbox-line",
"styleable": true,
"settings": [
{
@ -1219,7 +1220,7 @@
},
"longformfield": {
"name": "Rich Text",
"icon": "ri-edit-box-line",
"icon": "ri-file-edit-line",
"styleable": true,
"settings": [
{
@ -1270,7 +1271,7 @@
},
"attachmentfield": {
"name": "Attachment",
"icon": "ri-calendar-line",
"icon": "ri-image-edit-line",
"styleable": true,
"settings": [
{
@ -1287,7 +1288,7 @@
},
"relationshipfield": {
"name": "Relationship Picker",
"icon": "ri-edit-box-line",
"icon": "ri-links-line",
"styleable": true,
"settings": [
{

View File

@ -14,7 +14,7 @@
const { styleable, API } = getContext("sdk")
const component = getContext("component")
const dataContext = getContext("data")
const context = getContext("context")
export let wide = false
@ -23,7 +23,7 @@
let fields = []
// Fetch info about the closest data context
$: getFormData($dataContext[$dataContext.closestComponentId])
$: getFormData($context[$context.closestComponentId])
const getFormData = async context => {
if (context) {
@ -32,7 +32,7 @@
fields = Object.keys(schema ?? {})
// Use the draft version for editing
row = $dataContext[`${$dataContext.closestComponentId}_draft`]
row = $context[`${$context.closestComponentId}_draft`]
}
}
</script>

View File

@ -1,14 +0,0 @@
<script>
import { getContext } from "svelte"
const { styleable, setBindableValue } = getContext("sdk")
const component = getContext("component")
let value
function onBlur() {
setBindableValue($component.id, value)
}
</script>
<input bind:value on:blur={onBlur} use:styleable={$component.styles} />

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte"
import { isEmpty } from "lodash/fp"
const { API, styleable, DataProvider, builderStore } = getContext("sdk")
const { API, styleable, Provider, builderStore } = getContext("sdk")
const component = getContext("component")
export let datasource = []
@ -26,9 +26,9 @@
<p>Add some components too</p>
{:else}
{#each rows as row}
<DataProvider {row}>
<Provider data={row}>
<slot />
</DataProvider>
</Provider>
{/each}
{/if}
{:else if loaded && $builderStore.inBuilder}

View File

@ -1,14 +1,14 @@
<script>
import { getContext } from "svelte"
const { DataProvider, styleable } = getContext("sdk")
const { Provider, styleable } = getContext("sdk")
const component = getContext("component")
export let table
</script>
<div use:styleable={$component.styles}>
<DataProvider row={{ tableId: table }}>
<Provider data={{ tableId: table }}>
<slot />
</DataProvider>
</Provider>
</div>

View File

@ -1,7 +1,7 @@
<script>
import { onMount, getContext } from "svelte"
const { API, screenStore, routeStore, DataProvider, styleable } = getContext(
const { API, screenStore, routeStore, Provider, styleable } = getContext(
"sdk"
)
const component = getContext("component")
@ -39,8 +39,8 @@
{#if row}
<div use:styleable={$component.styles}>
<DataProvider {row}>
<Provider data={row}>
<slot />
</DataProvider>
</Provider>
</div>
{/if}

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
export let title
export let datasource

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
export let title
export let datasource

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
// Common props
export let title

View File

@ -5,7 +5,6 @@
import { isEmpty } from "lodash/fp"
const { API } = getContext("sdk")
const dataContext = getContext("data")
export let title
export let datasource

View File

@ -9,9 +9,9 @@
export let theme
export let size
const { styleable, API, setBindableValue, DataProvider } = getContext("sdk")
const component = getContext("component")
const dataContext = getContext("data")
const context = getContext("context")
const { styleable, API, Provider, ActionTypes } = getContext("sdk")
let loaded = false
let schema
@ -26,12 +26,11 @@
// Use the closest data context as the initial form values if it matches
const initialValues = getInitialValues(
$dataContext[$dataContext.closestComponentId]
$context[`${$context.closestComponentId}`]
)
// Form state contains observable data about the form
const formState = writable({ values: initialValues, errors: {}, valid: true })
$: updateFormState(fieldMap)
// Form API contains functions to control the form
const formApi = {
@ -52,29 +51,65 @@
fieldApi: makeFieldApi(field, defaultValue, validate),
fieldSchema: schema?.[field] ?? {},
}
fieldMap = fieldMap
return fieldMap[field]
},
validate: () => {
const fields = Object.keys(fieldMap)
fields.forEach(field => {
const { fieldApi } = fieldMap[field]
fieldApi.validate()
})
return get(formState).valid
},
}
// Provide both form API and state to children
setContext("form", { formApi, formState })
// Action context to pass to children
$: actions = [{ type: ActionTypes.ValidateForm, callback: formApi.validate }]
// Creates an API for a specific field
const makeFieldApi = (field, defaultValue, validate) => {
return {
setValue: value => {
const setValue = (value, skipCheck = false) => {
const { fieldState } = fieldMap[field]
fieldState.update(state => {
if (state.value === value) {
return state
// Skip if the value is the same
if (!skipCheck && get(fieldState).value === value) {
return
}
state.value = value == null ? defaultValue : value
state.error = validate ? validate(state.value) : null
state.valid = !state.error
const newValue = value == null ? defaultValue : value
const newError = validate ? validate(newValue) : null
const newValid = !newError
// Update field state
fieldState.update(state => {
state.value = newValue
state.error = newError
state.valid = newValid
return state
})
fieldMap = fieldMap
// Update form state
formState.update(state => {
state.values = { ...state.values, [field]: newValue }
if (newError) {
state.errors = { ...state.errors, [field]: newError }
} else {
delete state.errors[field]
}
state.valid = Object.keys(state.errors).length === 0
return state
})
return newValid
}
return {
setValue,
validate: () => {
const { fieldState } = fieldMap[field]
setValue(get(fieldState).value, true)
},
}
}
@ -90,21 +125,6 @@
})
}
// Updates the form states from the field data
const updateFormState = fieldMap => {
let values = { ...initialValues }
let errors = {}
Object.entries(fieldMap).forEach(([field, formField]) => {
const fieldState = get(formField.fieldState)
values[field] = fieldState.value
if (fieldState.error) {
errors[field] = fieldState.error
}
})
const valid = Object.keys(errors).length === 0
formState.set({ values, errors, valid })
}
// Fetches the form schema from this form's datasource, if one exists
const fetchSchema = async () => {
if (!datasource?.tableId) {
@ -128,7 +148,9 @@
onMount(fetchSchema)
</script>
<DataProvider row={{ ...$formState.values, tableId: datasource?.tableId }}>
<Provider
{actions}
data={{ ...$formState.values, tableId: datasource?.tableId }}>
<div
lang="en"
dir="ltr"
@ -138,7 +160,7 @@
<slot />
{/if}
</div>
</DataProvider>
</Provider>
<style>
div {

View File

@ -16,7 +16,6 @@
const setters = new Map([["number", number]])
const SDK = getContext("sdk")
const component = getContext("component")
const dataContext = getContext("data")
const { API, styleable } = SDK
export let datasource = {}