Merge pull request #2441 from Budibase/multistep-forms

Multi-step forms
This commit is contained in:
Andrew Kingston 2021-08-25 12:40:44 +01:00 committed by GitHub
commit 5ed4ace66b
54 changed files with 914 additions and 293 deletions

View File

@ -65,6 +65,7 @@
"@spectrum-css/search": "^3.0.2",
"@spectrum-css/sidenav": "^3.0.2",
"@spectrum-css/statuslight": "^3.0.2",
"@spectrum-css/stepper": "^3.0.3",
"@spectrum-css/switch": "^1.0.2",
"@spectrum-css/table": "^3.0.1",
"@spectrum-css/tabs": "^3.0.1",

View File

@ -12,6 +12,7 @@
export let getOptionValue = option => option
export let readonly = false
export let autocomplete = false
export let sort = false
const dispatch = createEventDispatcher()
$: selectedLookupMap = getSelectedLookupMap(value)
@ -83,4 +84,5 @@
{getOptionLabel}
{getOptionValue}
onSelectOption={toggleOption}
{sort}
/>

View File

@ -25,11 +25,12 @@
export let quiet = false
export let autoWidth = false
export let autocomplete = false
export let sort = false
const dispatch = createEventDispatcher()
let searchTerm = null
$: sortedOptions = getSortedOptions(options, getOptionLabel)
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
$: filteredOptions = getFilteredOptions(
sortedOptions,
searchTerm,
@ -45,10 +46,13 @@
open = true
}
const getSortedOptions = (options, getLabel) => {
const getSortedOptions = (options, getLabel, sort) => {
if (!options?.length || !Array.isArray(options)) {
return []
}
if (!sort) {
return options
}
return options.sort((a, b) => {
const labelA = getLabel(a)
const labelB = getLabel(b)

View File

@ -15,6 +15,7 @@
export let quiet = false
export let autoWidth = false
export let autocomplete = false
export let sort = false
const dispatch = createEventDispatcher()
let open = false
@ -72,6 +73,7 @@
{getOptionIcon}
{fieldIcon}
{autocomplete}
{sort}
isPlaceholder={value == null || value === ""}
placeholderOption={placeholder}
isOptionSelected={option => option === value}

View File

@ -0,0 +1,172 @@
<script>
import "@spectrum-css/textfield/dist/index-vars.css"
import "@spectrum-css/actionbutton/dist/index-vars.css"
import "@spectrum-css/stepper/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
export let value = null
export let placeholder = null
export let disabled = false
export let error = null
export let id = null
export let readonly = false
export let updateOnChange = true
export let quiet = false
export let min
export let max
export let step
const dispatch = createEventDispatcher()
let focus = false
// We need to keep the field value bound to a different variable in order
// to properly handle erroneous values. If we don't do this then it is
// possible for the field to show stale text which does not represent the
// real value. The reactive statement is to ensure that external changes to
// the value prop are reflected.
let fieldValue = value
$: fieldValue = value
// Ensure step is always a numeric value defaulting to 1
$: step = step == null || isNaN(step) ? 1 : step
const updateValue = value => {
if (readonly) {
return
}
const float = parseFloat(value)
value = isNaN(float) ? null : float
if (value != null) {
if (min != null && value < min) {
value = min
} else if (max != null && value > max) {
value = max
}
}
dispatch("change", value)
fieldValue = value
}
const onFocus = () => {
if (readonly) {
return
}
focus = true
}
const onBlur = event => {
if (readonly) {
return
}
focus = false
updateValue(event.target.value)
}
const onInput = event => {
if (readonly || !updateOnChange) {
return
}
updateValue(event.target.value)
}
const updateValueOnEnter = event => {
if (readonly) {
return
}
if (event.key === "Enter") {
updateValue(event.target.value)
}
}
const stepUp = () => {
if (value == null || isNaN(value)) {
updateValue(step)
} else {
updateValue(value + step)
}
}
const stepDown = () => {
if (value == null || isNaN(value)) {
updateValue(step)
} else {
updateValue(value - step)
}
}
</script>
<div
class="spectrum-Stepper"
class:spectrum-Stepper--quiet={quiet}
class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={focus}
>
{#if error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<div class="spectrum-Textfield spectrum-Stepper-textfield">
<input
{disabled}
{readonly}
{id}
bind:value={fieldValue}
placeholder={placeholder || ""}
type="number"
class="spectrum-Textfield-input spectrum-Stepper-input"
on:click
on:blur
on:focus
on:input
on:keyup
on:blur={onBlur}
on:focus={onFocus}
on:input={onInput}
on:keyup={updateValueOnEnter}
/>
</div>
<span class="spectrum-Stepper-buttons">
<button
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-Stepper-stepUp"
tabindex="-1"
on:click={stepUp}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronUp75"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron75" />
</svg>
</button>
<button
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-Stepper-stepDown"
tabindex="-1"
on:click={stepDown}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown75"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron75" />
</svg>
</button>
</span>
</div>
<style>
.spectrum-Stepper {
width: 100%;
}
.spectrum-Stepper::before {
display: none;
}
</style>

View File

@ -9,3 +9,4 @@ export { default as CoreSwitch } from "./Switch.svelte"
export { default as CoreSearch } from "./Search.svelte"
export { default as CoreDatePicker } from "./DatePicker.svelte"
export { default as CoreDropzone } from "./Dropzone.svelte"
export { default as CoreStepper } from "./Stepper.svelte"

View File

@ -13,6 +13,7 @@
export let options = []
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let sort = false
const dispatch = createEventDispatcher()
const onChange = e => {
@ -29,6 +30,7 @@
{value}
{options}
{placeholder}
{sort}
{getOptionLabel}
{getOptionValue}
on:change={onChange}

View File

@ -16,6 +16,7 @@
export let getOptionIcon = option => option?.icon
export let quiet = false
export let autoWidth = false
export let sort = false
const dispatch = createEventDispatcher()
const onChange = e => {
@ -41,6 +42,7 @@
{options}
{placeholder}
{autoWidth}
{sort}
{getOptionLabel}
{getOptionValue}
{getOptionIcon}

View File

@ -0,0 +1,45 @@
<script>
import Field from "./Field.svelte"
import Stepper from "./Core/Stepper.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let label = null
export let labelPosition = "above"
export let placeholder = null
export let disabled = false
export let readonly = false
export let error = null
export let updateOnChange = true
export let quiet = false
export let min = null
export let max = null
export let step = 1
const dispatch = createEventDispatcher()
const onChange = e => {
value = e.detail
dispatch("change", e.detail)
}
</script>
<Field {label} {labelPosition} {error}>
<Stepper
{updateOnChange}
{error}
{disabled}
{readonly}
{value}
{placeholder}
{quiet}
{min}
{max}
{step}
on:change={onChange}
on:click
on:input
on:blur
on:focus
on:keyup
/>
</Field>

View File

@ -19,7 +19,7 @@
<li
data-cy={dataCy}
on:click|preventDefault={onClick}
on:click|preventDefault={disabled ? null : onClick}
class="spectrum-Menu-item"
class:is-disabled={disabled}
role="menuitem"

View File

@ -5,6 +5,7 @@ import "@spectrum-css/icon/dist/index-vars.css"
// Components
export { default as Input } from "./Form/Input.svelte"
export { default as Stepper } from "./Form/Stepper.svelte"
export { default as TextArea } from "./Form/TextArea.svelte"
export { default as Select } from "./Form/Select.svelte"
export { default as Combobox } from "./Form/Combobox.svelte"

View File

@ -206,6 +206,11 @@
resolved "https://registry.yarnpkg.com/@spectrum-css/statuslight/-/statuslight-3.0.2.tgz#dc54b6cd113413dcdb909c486b5d7bae60db65c5"
integrity sha512-xodB8g8vGJH20XmUj9ZsPlM1jHrGeRbvmVXkz0q7YvQrYAhim8pP3W+XKKZAletPFAuu8cmUOc6SWn6i4X4z6w==
"@spectrum-css/stepper@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@spectrum-css/stepper/-/stepper-3.0.3.tgz#ae89846886431e3edeee060207b8f81540f73a34"
integrity sha512-prAD61ImlOTs9b6PfB3cB08x4lAfxtvnW+RZiTYky0E8GgZdrc/MfCkL5/oqQaIQUtyQv/3Lb7ELAf/0K8QTXw==
"@spectrum-css/switch@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@spectrum-css/switch/-/switch-1.0.2.tgz#f0b4c69271964573e02b08e90998096e49e1de44"

View File

@ -120,71 +120,79 @@ const getContextBindings = (asset, componentId) => {
// Create bindings for each data provider
dataProviders.forEach(component => {
const def = store.actions.components.getDefinition(component._component)
const contextDefinition = def.context
let schema
let readablePrefix
const contexts = Array.isArray(def.context) ? def.context : [def.context]
if (contextDefinition.type === "form") {
// Forms do not need table schemas
// Their schemas are built from their component field names
schema = buildFormSchema(component)
readablePrefix = "Fields"
} else if (contextDefinition.type === "static") {
// Static contexts are fully defined by the components
schema = {}
const values = contextDefinition.values || []
values.forEach(value => {
schema[value.key] = { name: value.label, type: "string" }
})
} else if (contextDefinition.type === "schema") {
// Schema contexts are generated dynamically depending on their data
const datasource = getDatasourceForProvider(asset, component)
if (!datasource) {
// Create bindings for each context block provided by this data provider
contexts.forEach(context => {
if (!context?.type) {
return
}
const info = getSchemaForDatasource(asset, datasource)
schema = info.schema
readablePrefix = info.table?.name
}
if (!schema) {
return
}
const keys = Object.keys(schema).sort()
let schema
let readablePrefix
// Create bindable properties for each schema field
const safeComponentId = makePropSafe(component._id)
keys.forEach(key => {
const fieldSchema = schema[key]
// Make safe runtime binding and replace certain bindings with a
// new property to help display components
let runtimeBoundKey = key
if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first`
if (context.type === "form") {
// Forms do not need table schemas
// Their schemas are built from their component field names
schema = buildFormSchema(component)
readablePrefix = "Fields"
} else if (context.type === "static") {
// Static contexts are fully defined by the components
schema = {}
const values = context.values || []
values.forEach(value => {
schema[value.key] = { name: value.label, type: "string" }
})
} else if (context.type === "schema") {
// Schema contexts are generated dynamically depending on their data
const datasource = getDatasourceForProvider(asset, component)
if (!datasource) {
return
}
const info = getSchemaForDatasource(asset, datasource)
schema = info.schema
readablePrefix = info.table?.name
}
const runtimeBinding = `${safeComponentId}.${makePropSafe(
runtimeBoundKey
)}`
// Optionally use a prefix with readable bindings
let readableBinding = component._instanceName
if (readablePrefix) {
readableBinding += `.${readablePrefix}`
if (!schema) {
return
}
readableBinding += `.${fieldSchema.name || key}`
// Create the binding object
bindings.push({
type: "context",
runtimeBinding,
readableBinding,
// Field schema and provider are required to construct relationship
// datasource options, based on bindable properties
fieldSchema,
providerId: component._id,
const keys = Object.keys(schema).sort()
// Create bindable properties for each schema field
const safeComponentId = makePropSafe(component._id)
keys.forEach(key => {
const fieldSchema = schema[key]
// Make safe runtime binding and replace certain bindings with a
// new property to help display components
let runtimeBoundKey = key
if (fieldSchema.type === "link") {
runtimeBoundKey = `${key}_text`
} else if (fieldSchema.type === "attachment") {
runtimeBoundKey = `${key}_first`
}
const runtimeBinding = `${safeComponentId}.${makePropSafe(
runtimeBoundKey
)}`
// Optionally use a prefix with readable bindings
let readableBinding = component._instanceName
if (readablePrefix) {
readableBinding += `.${readablePrefix}`
}
readableBinding += `.${fieldSchema.name || key}`
// Create the binding object
bindings.push({
type: "context",
runtimeBinding,
readableBinding,
// Field schema and provider are required to construct relationship
// datasource options, based on bindable properties
fieldSchema,
providerId: component._id,
})
})
})
})

View File

@ -20,7 +20,12 @@ import { fetchComponentLibDefinitions } from "../loadComponentLibraries"
import api from "../api"
import { FrontendTypes } from "constants"
import analytics from "analytics"
import { findComponentType, findComponentParent } from "../storeUtils"
import {
findComponentType,
findComponentParent,
findClosestMatchingComponent,
findAllMatchingComponents,
} from "../storeUtils"
import { uuid } from "../uuid"
import { removeBindings } from "../dataBinding"
@ -334,6 +339,18 @@ export const getFrontendStore = () => {
if (definition.hasChildren) {
extras._children = []
}
if (componentName.endsWith("/formstep")) {
const parentForm = findClosestMatchingComponent(
get(currentAsset).props,
get(selectedComponent)._id,
component => component._component.endsWith("/form")
)
const formSteps = findAllMatchingComponents(parentForm, component =>
component._component.endsWith("/formstep")
)
extras.step = formSteps.length + 1
extras._instanceName = `Step ${formSteps.length + 1}`
}
return {
_id: uuid(),

View File

@ -86,7 +86,7 @@ const createScreen = table => {
valueType: "Binding",
},
],
limit: table.type === "external" ? undefined : 1,
limit: 1,
paginate: false,
})
@ -94,6 +94,7 @@ const createScreen = table => {
.instanceName("Repeater")
.customProps({
dataProvider: `{{ literal ${makePropSafe(provider._json._id)} }}`,
noRowsMessage: "We couldn't find a row to display",
})
const form = makeMainForm()

View File

@ -19,6 +19,7 @@
data-cy="{meta.name}-select"
bind:value
options={meta.constraints.inclusion}
sort
/>
{:else if type === "datetime"}
<DatePicker {label} bind:value />

View File

@ -153,6 +153,7 @@
label="Display Column"
bind:value={primaryDisplay}
options={fields}
sort
/>
</div>
{/if}

View File

@ -47,6 +47,7 @@
getOptionValue={row => row._id}
on:change={e => (linkedIds = e.detail ? [e.detail] : [])}
{label}
sort
/>
{:else}
<Multiselect
@ -55,5 +56,6 @@
options={rows}
getOptionLabel={getPrettyName}
getOptionValue={row => row._id}
sort
/>
{/if}

View File

@ -46,7 +46,7 @@
<ActionMenu disabled={!item.isCategory}>
<ActionButton
icon={item.icon}
disabled={isChildAllowed(item, $selectedComponent)}
disabled={!item.isCategory && isChildAllowed(item, $selectedComponent)}
quiet
size="S"
slot="control"
@ -66,6 +66,7 @@
dataCy={`component-${item.name}`}
icon={item.icon}
on:click={() => onItemChosen(item)}
disabled={isChildAllowed(item, $selectedComponent)}
>
{item.name}
</MenuItem>

View File

@ -10,6 +10,7 @@
"icon": "Form",
"children": [
"form",
"formstep",
"fieldgroup",
"stringfield",
"numberfield",

View File

@ -85,6 +85,8 @@
props={{
options: setting.options || [],
placeholder: setting.placeholder || null,
min: setting.min || null,
max: setting.max || null,
}}
{bindings}
{componentDefinition}

View File

@ -33,7 +33,7 @@
$: selectedActionComponent =
selectedAction &&
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_KEY]).component
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_KEY])?.component
// Select the first action if we delete an action
$: {
@ -116,7 +116,7 @@
</ActionMenu>
</Layout>
<Layout noPadding>
{#if selectedAction}
{#if selectedActionComponent}
<div class="selected-action-container">
<svelte:component
this={selectedActionComponent}

View File

@ -0,0 +1,68 @@
<script>
import { Select, Label, Stepper } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding"
import { onMount } from "svelte"
export let parameters
$: actionProviders = getActionProviderComponents(
$currentAsset,
$store.selectedComponentId,
"ChangeFormStep"
)
const typeOptions = [
{
label: "Next step",
value: "next",
},
{
label: "Previous step",
value: "prev",
},
{
label: "First step",
value: "first",
},
{
label: "Specific step",
value: "specific",
},
]
onMount(() => {
if (!parameters.type) {
parameters.type = "next"
}
})
</script>
<div class="root">
<Label small>Form</Label>
<Select
placeholder={null}
bind:value={parameters.componentId}
options={actionProviders}
getOptionLabel={x => x._instanceName}
getOptionValue={x => x._id}
/>
<Label small>Step</Label>
<Select bind:value={parameters.type} options={typeOptions} />
{#if parameters.type === "specific"}
<Label small>Number</Label>
<Stepper bind:value={parameters.number} />
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -29,7 +29,7 @@
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 800px;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -11,7 +11,6 @@
<style>
.root {
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@ -8,7 +8,6 @@
<style>
.root {
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@ -25,7 +25,7 @@
align-items: center;
gap: var(--spacing-m);
grid-template-columns: auto 1fr;
max-width: 800px;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { Select, Label, Checkbox } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding"
@ -20,6 +20,11 @@
getOptionLabel={x => x._instanceName}
getOptionValue={x => x._id}
/>
<div />
<Checkbox
text="Validate only current step"
bind:value={parameters.onlyCurrentStep}
/>
</div>
<style>
@ -29,7 +34,7 @@
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 800px;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -7,6 +7,7 @@ import ValidateForm from "./ValidateForm.svelte"
import LogOut from "./LogOut.svelte"
import ClearForm from "./ClearForm.svelte"
import CloseScreenModal from "./CloseScreenModal.svelte"
import ChangeFormStep from "./ChangeFormStep.svelte"
// Defines which actions are available to configure in the front end.
// Unfortunately the "name" property is used as the identifier so please don't
@ -52,4 +53,8 @@ export default [
name: "Close Screen Modal",
component: CloseScreenModal,
},
{
name: "Change Form Step",
component: ChangeFormStep,
},
]

View File

@ -17,7 +17,7 @@
}
</script>
<ActionButton on:click={drawer.show}>Configure Validation</ActionButton>
<ActionButton on:click={drawer.show}>Configure validation</ActionButton>
<Drawer bind:this={drawer} title="Validation Rules">
<svelte:fragment slot="description">
Configure validation rules for this field.

View File

@ -1,4 +1,4 @@
import { Checkbox, Input, Select } from "@budibase/bbui"
import { Checkbox, Input, Select, Stepper } from "@budibase/bbui"
import DataSourceSelect from "./DataSourceSelect.svelte"
import DataProviderSelect from "./DataProviderSelect.svelte"
import EventsEditor from "./EventsEditor"
@ -22,7 +22,7 @@ const componentMap = {
dataSource: DataSourceSelect,
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Input,
number: Stepper,
event: EventsEditor,
table: TableSelect,
color: ColorPicker,

View File

@ -63,6 +63,7 @@
$: selected =
$builderStore.inBuilder &&
$builderStore.selectedComponentId === instance._id
$: inSelectedPath = $builderStore.selectedComponentPath?.includes(id)
$: interactive = $builderStore.previewType === "layout" || insideScreenslot
$: evaluateConditions(enrichedSettings?._conditions)
$: componentSettings = { ...enrichedSettings, ...conditionalSettings }
@ -74,7 +75,6 @@
styles: { ...instance._styles, id, empty, interactive },
empty,
selected,
props: componentSettings,
name,
})
@ -175,13 +175,12 @@
</script>
{#key propsHash}
{#if constructor && componentSettings && visible}
{#if constructor && componentSettings && (visible || inSelectedPath)}
<div
class={`component ${id}`}
data-type={interactive ? "component" : ""}
data-id={id}
data-name={name}
class:hidden={!visible}
>
<svelte:component this={constructor} {...componentSettings}>
{#if children.length}

View File

@ -61,7 +61,7 @@
// Sanity limit of 100 active indicators
const children = Array.from(parents)
.map(parent => parent?.childNodes?.[0])
.filter(child => child != null)
.filter(node => node?.nodeType === 1)
.slice(0, 100)
// If there aren't any nodes then reset

View File

@ -7,6 +7,7 @@ export const ActionTypes = {
RefreshDatasource: "RefreshDatasource",
SetDataProviderQuery: "SetDataProviderQuery",
ClearForm: "ClearForm",
ChangeFormStep: "ChangeFormStep",
}
export const ApiVersion = "1"

View File

@ -1,5 +1,6 @@
import { writable, derived } from "svelte/store"
import Manifest from "@budibase/standard-components/manifest.json"
import { findComponentById, findComponentPathById } from "../utils/components"
const dispatchEvent = (type, data = {}) => {
window.dispatchEvent(
@ -9,25 +10,6 @@ const dispatchEvent = (type, data = {}) => {
)
}
const findComponentById = (component, componentId) => {
if (!component || !componentId) {
return null
}
if (component._id === componentId) {
return component
}
if (!component._children?.length) {
return null
}
for (let child of component._children) {
const result = findComponentById(child, componentId)
if (result) {
return result
}
}
return null
}
const createBuilderStore = () => {
const initialState = {
inBuilder: false,
@ -37,9 +19,15 @@ const createBuilderStore = () => {
selectedComponentId: null,
previewId: null,
previewType: null,
selectedPath: [],
}
const writableStore = writable(initialState)
const derivedStore = derived(writableStore, $state => {
// Avoid any of this logic if we aren't in the builder preview
if (!$state.inBuilder) {
return $state
}
// Derive the selected component instance and definition
const { layout, screen, previewType, selectedComponentId } = $state
const asset = previewType === "layout" ? layout : screen
@ -47,10 +35,15 @@ const createBuilderStore = () => {
const prefix = "@budibase/standard-components/"
const type = component?._component?.replace(prefix, "")
const definition = type ? Manifest[type] : null
// Derive the selected component path
const path = findComponentPathById(asset.props, selectedComponentId) || []
return {
...$state,
selectedComponent: component,
selectedComponentDefinition: definition,
selectedComponentPath: path?.map(component => component._id),
}
})
@ -67,6 +60,14 @@ const createBuilderStore = () => {
notifyLoaded: () => {
dispatchEvent("preview-loaded")
},
setSelectedPath: path => {
console.log("set to ")
console.log(path)
writableStore.update(state => {
state.selectedPath = path
return state
})
},
}
return {
...writableStore,

View File

@ -1,7 +1,12 @@
import { derived } from "svelte/store"
import { derived, get } from "svelte/store"
import { routeStore } from "./routes"
import { builderStore } from "./builder"
import { appStore } from "./app"
import {
findComponentPathById,
findChildrenByType,
findComponentById,
} from "../utils/components"
const createScreenStore = () => {
const store = derived(
@ -36,8 +41,39 @@ const createScreenStore = () => {
}
)
// Utils to parse component definitions
const actions = {
findComponentById: componentId => {
const { activeScreen, activeLayout } = get(store)
let result = findComponentById(activeScreen?.props, componentId)
if (result) {
return result
}
return findComponentById(activeLayout?.props)
},
findComponentPathById: componentId => {
const { activeScreen, activeLayout } = get(store)
let result = findComponentPathById(activeScreen?.props, componentId)
if (result) {
return result
}
return findComponentPathById(activeLayout?.props)
},
findChildrenByType: (componentId, type) => {
const component = actions.findComponentById(componentId)
if (!component || !component._children) {
return null
}
let children = []
findChildrenByType(component, type, children)
console.log(children)
return children
},
}
return {
subscribe: store.subscribe,
actions,
}
}

View File

@ -66,10 +66,15 @@ const queryExecutionHandler = async action => {
})
}
const executeActionHandler = async (context, componentId, actionType) => {
const executeActionHandler = async (
context,
componentId,
actionType,
params
) => {
const fn = context[`${componentId}_${actionType}`]
if (fn) {
return await fn()
return await fn(params)
}
}
@ -77,7 +82,8 @@ const validateFormHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ValidateForm
ActionTypes.ValidateForm,
action.parameters.onlyCurrentStep
)
}
@ -101,6 +107,15 @@ const clearFormHandler = async (action, context) => {
)
}
const changeFormStepHandler = async (action, context) => {
return await executeActionHandler(
context,
action.parameters.componentId,
ActionTypes.ChangeFormStep,
action.parameters
)
}
const closeScreenModalHandler = () => {
// Emit this as a window event, so parent screens which are iframing us in
// can close the modal
@ -118,6 +133,7 @@ const handlerMap = {
["Log Out"]: logoutHandler,
["Clear Form"]: clearFormHandler,
["Close Screen Modal"]: closeScreenModalHandler,
["Change Form Step"]: changeFormStepHandler,
}
const confirmTextMap = {

View File

@ -51,6 +51,12 @@ export const enrichProps = (props, context) => {
condition.settingValue,
totalContext
)
// If there is an onclick function in here then it won't be serialised
// properly, and therefore will not be updated properly.
// The solution to this is add a rand which will ensure diffs happen
// every time.
condition.rand = Math.random()
}
})
}

View File

@ -0,0 +1,62 @@
/**
* Finds a component instance by ID
*/
export const findComponentById = (component, componentId) => {
if (!component || !componentId) {
return null
}
if (component._id === componentId) {
return component
}
if (!component._children?.length) {
return null
}
for (let child of component._children) {
const result = findComponentById(child, componentId)
if (result) {
return result
}
}
return null
}
/**
* Finds the component path to a component
*/
export const findComponentPathById = (component, componentId, path = []) => {
if (!component || !componentId) {
return null
}
path = [...path, component]
if (component._id === componentId) {
return path
}
if (!component._children?.length) {
return null
}
for (let child of component._children) {
const result = findComponentPathById(child, componentId, path)
if (result) {
return result
}
}
return null
}
/**
* Finds all children instances of a certain component type of a given component
*/
export const findChildrenByType = (component, type, children = []) => {
if (!component) {
return
}
if (component._component.endsWith(`/${type}`)) {
children.push(component)
}
if (!component._children?.length) {
return
}
component._children.forEach(child => {
findChildrenByType(child, type, children)
})
}

View File

@ -1702,10 +1702,11 @@
"name": "Form",
"icon": "Form",
"hasChildren": true,
"illegalChildren": ["section"],
"illegalChildren": ["section", "form"],
"actions": [
"ValidateForm",
"ClearForm"
"ClearForm",
"ChangeFormStep"
],
"styles": ["size"],
"settings": [
@ -1732,9 +1733,44 @@
"defaultValue": false
}
],
"context": {
"type": "form"
}
"context": [
{
"type": "static",
"values": [
{
"label": "Valid",
"key": "__valid"
},
{
"label": "Current Step",
"key": "__currentStep"
},
{
"label": "Current Step Valid",
"key": "__currentStepValid"
}
]
},
{
"type": "form"
}
]
},
"formstep": {
"name": "Form Step",
"icon": "AssetsAdded",
"hasChildren": true,
"illegalChildren": ["section", "form", "form step"],
"styles": ["size"],
"settings": [
{
"type": "number",
"label": "Step",
"key": "step",
"defaultValue": 1,
"min": 1
}
]
},
"fieldgroup": {
"name": "Field Group",

View File

@ -42,11 +42,11 @@
bind:fieldApi
defaultValue={[]}
>
{#if $fieldState}
{#if fieldState}
<CoreDropzone
value={$fieldState.value}
disabled={$fieldState.disabled}
error={$fieldState.error}
value={fieldState.value}
disabled={fieldState.disabled}
error={fieldState.error}
on:change={e => {
fieldApi.setValue(e.detail)
}}

View File

@ -39,10 +39,10 @@
>
{#if fieldState}
<CoreCheckbox
value={$fieldState.value}
disabled={$fieldState.disabled}
error={$fieldState.error}
id={$fieldState.fieldId}
value={fieldState.value}
disabled={fieldState.disabled}
error={fieldState.error}
id={fieldState.fieldId}
{size}
on:change={e => fieldApi.setValue(e.detail)}
{text}

View File

@ -51,11 +51,11 @@
>
{#if fieldState}
<CoreDatePicker
value={$fieldState.value}
value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)}
disabled={$fieldState.disabled}
error={$fieldState.error}
id={$fieldState.fieldId}
disabled={fieldState.disabled}
error={fieldState.error}
id={fieldState.fieldId}
{enableTime}
{placeholder}
/>

View File

@ -1,7 +1,7 @@
<script>
import Placeholder from "../Placeholder.svelte"
import FieldGroupFallback from "./FieldGroupFallback.svelte"
import { getContext } from "svelte"
import { getContext, onDestroy } from "svelte"
export let label
export let field
@ -15,7 +15,8 @@
// Get contexts
const formContext = getContext("form")
const fieldGroupContext = getContext("fieldGroup")
const formStepContext = getContext("form-step")
const fieldGroupContext = getContext("field-group")
const { styleable } = getContext("sdk")
const component = getContext("component")
@ -26,16 +27,23 @@
field,
defaultValue,
disabled,
validation
validation,
formStepContext || 1
)
// Expose field properties to parent component
fieldState = formField?.fieldState
fieldApi = formField?.fieldApi
fieldSchema = formField?.fieldSchema
// Update form properties in parent component on every store change
const unsubscribe = formField?.subscribe(value => {
fieldState = value?.fieldState
fieldApi = value?.fieldApi
fieldSchema = value?.fieldSchema
})
onDestroy(() => unsubscribe && unsubscribe())
// Keep validation rules up to date
$: fieldApi?.updateValidation(validation)
$: updateValidation(validation)
const updateValidation = validation => {
fieldApi?.updateValidation(validation)
}
// Extract label position from field group context
$: labelPositionClass =
@ -46,7 +54,7 @@
<div class="spectrum-Form-item" use:styleable={$component.styles}>
<label
class:hidden={!label}
for={$fieldState?.fieldId}
for={fieldState?.fieldId}
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelPositionClass}`}
>
{label || ""}
@ -64,8 +72,8 @@
/>
{:else}
<slot />
{#if $fieldState.error}
<div class="error">{$fieldState.error}</div>
{#if fieldState.error}
<div class="error">{fieldState.error}</div>
{/if}
{/if}
</div>

View File

@ -5,7 +5,7 @@
const { styleable } = getContext("sdk")
const component = getContext("component")
setContext("fieldGroup", { labelPosition })
setContext("field-group", { labelPosition })
</script>
<div class="wrapper" use:styleable={$component.styles}>

View File

@ -1,7 +1,7 @@
<script>
import { getContext } from "svelte"
const fieldGroupContext = getContext("fieldGroup")
const fieldGroupContext = getContext("field-group")
</script>
{#if fieldGroupContext}

View File

@ -1,5 +1,5 @@
<script>
import { getContext } from "svelte"
import { getContext, onMount } from "svelte"
import InnerForm from "./InnerForm.svelte"
export let dataSource
@ -9,6 +9,11 @@
export let actionType = "Create"
const context = getContext("context")
const { API } = getContext("sdk")
let loaded = false
let schema
let table
// Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, context) => {
@ -32,19 +37,48 @@
return closestContext
}
// Fetches the form schema from this form's dataSource, if one exists
const fetchSchema = async () => {
if (!dataSource?.tableId) {
schema = {}
table = null
} else {
table = await API.fetchTableDefinition(dataSource?.tableId)
if (table) {
if (dataSource?.type === "query") {
schema = {}
const params = table.parameters || []
params.forEach(param => {
schema[param.name] = { ...param, type: "string" }
})
} else {
schema = table.schema || {}
}
}
}
loaded = true
}
$: initialValues = getInitialValues(actionType, dataSource, $context)
$: resetKey = JSON.stringify(initialValues)
// Load the form schema on mount
onMount(fetchSchema)
</script>
{#key resetKey}
<InnerForm
{dataSource}
{theme}
{size}
{disabled}
{actionType}
{initialValues}
>
<slot />
</InnerForm>
{/key}
{#if loaded}
{#key resetKey}
<InnerForm
{dataSource}
{theme}
{size}
{disabled}
{actionType}
{schema}
{table}
{initialValues}
>
<slot />
</InnerForm>
{/key}
{/if}

View File

@ -0,0 +1,35 @@
<script>
import { getContext, setContext } from "svelte"
import Placeholder from "../Placeholder.svelte"
export let step = 1
const { styleable, builderStore } = getContext("sdk")
const component = getContext("component")
const formContext = getContext("form")
// Set form step context so fields know what step they are within
setContext("form-step", step || 1)
$: formState = formContext?.formState
$: currentStep = $formState?.currentStep
// If in the builder preview, show this step if a child is selected
$: {
if (
formContext &&
$builderStore.inBuilder &&
$builderStore.selectedComponentPath?.includes($component.id)
) {
formContext.formApi.setStep(step)
}
}
</script>
{#if !formContext}
<Placeholder text="Form steps need to be wrapped in a form" />
{:else if step === currentStep}
<div use:styleable={$component.styles}>
<slot />
</div>
{/if}

View File

@ -1,36 +1,89 @@
<script>
import { setContext, getContext, onMount } from "svelte"
import { writable, get } from "svelte/store"
import { setContext, getContext } from "svelte"
import { derived, get, writable } from "svelte/store"
import { createValidatorFromConstraints } from "./validation"
import { generateID } from "../helpers"
export let dataSource
export let disabled = false
export let initialValues
export let schema
export let table
const component = getContext("component")
const { styleable, API, Provider, ActionTypes } = getContext("sdk")
const { styleable, Provider, ActionTypes } = getContext("sdk")
let loaded = false
let schema
let table
let fieldMap = {}
let fields = []
const currentStep = writable(1)
const formState = writable({
values: {},
errors: {},
valid: true,
currentStep: 1,
})
// Form state contains observable data about the form
const formState = writable({ values: initialValues, errors: {}, valid: true })
// Reactive derived stores to derive form state from field array
$: values = deriveFieldProperty(fields, f => f.fieldState.value)
$: errors = deriveFieldProperty(fields, f => f.fieldState.error)
$: valid = !Object.values($errors).some(error => error != null)
// Derive whether the current form step is valid
$: currentStepValid = derived(
[currentStep, ...fields],
([currentStepValue, ...fieldsValue]) => {
return !fieldsValue
.filter(f => f.step === currentStepValue)
.some(f => f.fieldState.error != null)
}
)
// Update form state store from derived stores
$: {
formState.set({
values: $values,
errors: $errors,
valid,
currentStep: $currentStep,
})
}
// Generates a derived store from an array of fields, comprised of a map of
// extracted values from the field array
const deriveFieldProperty = (fieldStores, getProp) => {
return derived(fieldStores, fieldValues => {
const reducer = (map, field) => ({ ...map, [field.name]: getProp(field) })
return fieldValues.reduce(reducer, {})
})
}
// Searches the field array for a certain field
const getField = name => {
return fields.find(field => get(field).name === name)
}
// Form API contains functions to control the form
const formApi = {
registerField: (
field,
defaultValue = null,
fieldDisabled = false,
validationRules
validationRules,
step = 1
) => {
if (!field) {
return
}
// If we've already registered this field then wipe any errors and
// return the existing field
const existingField = getField(field)
if (existingField) {
existingField.update(state => {
state.fieldState.error = null
return state
})
return existingField
}
// Auto columns are always disabled
const isAutoColumn = !!schema?.[field]?.autocolumn
@ -43,85 +96,86 @@
table
)
// Construct field object
fieldMap[field] = {
fieldState: makeFieldState(
field,
validator,
// Construct field info
const fieldInfo = writable({
name: field,
step: step || 1,
fieldState: {
fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue,
error: null,
disabled: disabled || fieldDisabled || isAutoColumn,
defaultValue,
disabled || fieldDisabled || isAutoColumn
),
validator,
},
fieldApi: makeFieldApi(field, defaultValue),
fieldSchema: schema?.[field] ?? {},
})
// Add this field
fields = [...fields, fieldInfo]
return fieldInfo
},
validate: (onlyCurrentStep = false) => {
let valid = true
let validationFields = fields
// Reduce fields to only the current step if required
if (onlyCurrentStep) {
validationFields = fields.filter(f => get(f).step === get(currentStep))
}
// Set initial value
const initialValue = get(fieldMap[field].fieldState).value
formState.update(state => ({
...state,
values: {
...state.values,
[field]: initialValue,
},
}))
return fieldMap[field]
},
validate: () => {
const fields = Object.keys(fieldMap)
fields.forEach(field => {
const { fieldApi } = fieldMap[field]
fieldApi.validate()
// Validate fields and check if any are invalid
validationFields.forEach(field => {
if (!get(field).fieldApi.validate()) {
valid = false
}
})
return get(formState).valid
return valid
},
clear: () => {
const fields = Object.keys(fieldMap)
// Clear the form by clearing each individual field
fields.forEach(field => {
const { fieldApi } = fieldMap[field]
fieldApi.clearValue()
get(field).fieldApi.clearValue()
})
},
changeStep: ({ type, number }) => {
if (type === "next") {
currentStep.update(step => step + 1)
} else if (type === "prev") {
currentStep.update(step => Math.max(1, step - 1))
} else if (type === "first") {
currentStep.set(1)
} else if (type === "specific" && number && !isNaN(number)) {
currentStep.set(number)
}
},
setStep: step => {
if (step) {
currentStep.set(step)
}
},
}
// Provide both form API and state to children
setContext("form", { formApi, formState, dataSource })
// Action context to pass to children
const actions = [
{ type: ActionTypes.ValidateForm, callback: formApi.validate },
{ type: ActionTypes.ClearForm, callback: formApi.clear },
]
// Creates an API for a specific field
const makeFieldApi = field => {
// Sets the value for a certain field and invokes validation
const setValue = (value, skipCheck = false) => {
const { fieldState } = fieldMap[field]
const { validator } = get(fieldState)
const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo)
const { validator } = fieldState
// Skip if the value is the same
if (!skipCheck && get(fieldState).value === value) {
if (!skipCheck && fieldState.value === value) {
return
}
// Update field state
const error = validator ? validator(value) : null
fieldState.update(state => {
state.value = value
state.error = error
return state
})
// Update form state
formState.update(state => {
state.values = { ...state.values, [field]: value }
if (error) {
state.errors = { ...state.errors, [field]: error }
} else {
delete state.errors[field]
}
state.valid = Object.keys(state.errors).length === 0
fieldInfo.update(state => {
state.fieldState.value = value
state.fieldState.error = error
return state
})
@ -130,30 +184,23 @@
// Clears the value of a certain field back to the initial value
const clearValue = () => {
const { fieldState } = fieldMap[field]
const { defaultValue } = get(fieldState)
const newValue = initialValues[field] ?? defaultValue
const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo)
const newValue = initialValues[field] ?? fieldState.defaultValue
// Update field state
fieldState.update(state => {
state.value = newValue
state.error = null
return state
})
// Update form state
formState.update(state => {
state.values = { ...state.values, [field]: newValue }
delete state.errors[field]
state.valid = Object.keys(state.errors).length === 0
fieldInfo.update(state => {
state.fieldState.value = newValue
state.fieldState.error = null
return state
})
}
// Updates the validator rules for a certain field
const updateValidation = validationRules => {
const { fieldState } = fieldMap[field]
const { value, error } = get(fieldState)
const fieldInfo = getField(field)
const { fieldState } = get(fieldInfo)
const { value, error } = fieldState
// Create new validator
const schemaConstraints = schema?.[field]?.constraints
@ -165,8 +212,8 @@
)
// Update validator
fieldState.update(state => {
state.validator = validator
fieldInfo.update(state => {
state.fieldState.validator = validator
return state
})
@ -182,58 +229,48 @@
clearValue,
updateValidation,
validate: () => {
const { fieldState } = fieldMap[field]
setValue(get(fieldState).value, true)
// Validate the field by force setting the same value again
const { fieldState } = get(getField(field))
return setValue(fieldState.value, true)
},
}
}
// Creates observable state data about a specific field
const makeFieldState = (field, validator, defaultValue, fieldDisabled) => {
return writable({
field,
fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue,
error: null,
disabled: fieldDisabled,
defaultValue,
validator,
})
}
// Provide form state and api for full control by children
setContext("form", {
formState,
formApi,
// Fetches the form schema from this form's dataSource, if one exists
const fetchSchema = async () => {
if (!dataSource?.tableId) {
schema = {}
table = null
} else {
table = await API.fetchTableDefinition(dataSource?.tableId)
if (table) {
if (dataSource?.type === "query") {
schema = {}
const params = table.parameters || []
params.forEach(param => {
schema[param.name] = { ...param, type: "string" }
})
} else {
schema = table.schema || {}
}
}
}
loaded = true
}
// Data source is needed by attachment fields to be able to upload files
// to the correct table ID
dataSource,
})
// Load the form schema on mount
onMount(fetchSchema)
// Provide form step context so that forms without any step components
// register their fields to step 1
setContext("form-step", 1)
// Action context to pass to children
const actions = [
{ type: ActionTypes.ValidateForm, callback: formApi.validate },
{ type: ActionTypes.ClearForm, callback: formApi.clear },
{ type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
]
// Create data context to provide
$: dataContext = {
...initialValues,
...$values,
// These static values are prefixed to avoid clashes with actual columns
__valid: valid,
__currentStep: $currentStep,
__currentStepValid: $currentStepValid,
}
</script>
<Provider
{actions}
data={{ ...$formState.values, tableId: dataSource?.tableId }}
>
<Provider {actions} data={dataContext}>
<div use:styleable={$component.styles}>
{#if loaded}
<slot />
{/if}
<slot />
</div>
</Provider>

View File

@ -25,11 +25,11 @@
>
{#if fieldState}
<CoreTextArea
value={$fieldState.value}
value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)}
disabled={$fieldState.disabled}
error={$fieldState.error}
id={$fieldState.fieldId}
disabled={fieldState.disabled}
error={fieldState.error}
id={fieldState.fieldId}
{placeholder}
/>
{/if}

View File

@ -77,23 +77,24 @@
{#if fieldState}
{#if !optionsType || optionsType === "select"}
<CoreSelect
value={$fieldState.value}
id={$fieldState.fieldId}
disabled={$fieldState.disabled}
error={$fieldState.error}
value={fieldState.value}
id={fieldState.fieldId}
disabled={fieldState.disabled}
error={fieldState.error}
{options}
{placeholder}
on:change={e => fieldApi.setValue(e.detail)}
getOptionLabel={flatOptions ? x => x : x => x.label}
getOptionValue={flatOptions ? x => x : x => x.value}
{autocomplete}
sort={true}
/>
{:else if optionsType === "radio"}
<CoreRadioGroup
value={$fieldState.value}
id={$fieldState.fieldId}
disabled={$fieldState.disabled}
error={$fieldState.error}
value={fieldState.value}
id={fieldState.fieldId}
disabled={fieldState.disabled}
error={fieldState.error}
{options}
on:change={e => fieldApi.setValue(e.detail)}
getOptionLabel={flatOptions ? x => x : x => x.label}

View File

@ -23,8 +23,8 @@
$: linkedTableId = fieldSchema?.tableId
$: fetchRows(linkedTableId)
$: fetchTable(linkedTableId)
$: singleValue = flatten($fieldState?.value)?.[0]
$: multiValue = flatten($fieldState?.value) ?? []
$: singleValue = flatten(fieldState?.value)?.[0]
$: multiValue = flatten(fieldState?.value) ?? []
$: component = multiselect ? CoreMultiselect : CoreSelect
const fetchTable = async id => {
@ -81,12 +81,13 @@
{autocomplete}
value={multiselect ? multiValue : singleValue}
on:change={multiselect ? multiHandler : singleHandler}
id={$fieldState.fieldId}
disabled={$fieldState.disabled}
error={$fieldState.error}
id={fieldState.fieldId}
disabled={fieldState.disabled}
error={fieldState.error}
getOptionLabel={getDisplayName}
getOptionValue={option => option._id}
{placeholder}
sort={true}
/>
{/if}
</Field>

View File

@ -27,11 +27,11 @@
{#if fieldState}
<CoreTextField
updateOnChange={false}
value={$fieldState.value}
value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)}
disabled={$fieldState.disabled}
error={$fieldState.error}
id={$fieldState.fieldId}
disabled={fieldState.disabled}
error={fieldState.error}
id={fieldState.fieldId}
{placeholder}
{type}
/>

View File

@ -9,3 +9,4 @@ export { default as datetimefield } from "./DateTimeField.svelte"
export { default as attachmentfield } from "./AttachmentField.svelte"
export { default as relationshipfield } from "./RelationshipField.svelte"
export { default as passwordfield } from "./PasswordField.svelte"
export { default as formstep } from "./FormStep.svelte"