Merge pull request #2441 from Budibase/multistep-forms
Multi-step forms
This commit is contained in:
commit
5ed4ace66b
|
@ -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",
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -120,23 +120,30 @@ 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
|
||||
const contexts = Array.isArray(def.context) ? def.context : [def.context]
|
||||
|
||||
// Create bindings for each context block provided by this data provider
|
||||
contexts.forEach(context => {
|
||||
if (!context?.type) {
|
||||
return
|
||||
}
|
||||
|
||||
let schema
|
||||
let readablePrefix
|
||||
|
||||
if (contextDefinition.type === "form") {
|
||||
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 (contextDefinition.type === "static") {
|
||||
} else if (context.type === "static") {
|
||||
// Static contexts are fully defined by the components
|
||||
schema = {}
|
||||
const values = contextDefinition.values || []
|
||||
const values = context.values || []
|
||||
values.forEach(value => {
|
||||
schema[value.key] = { name: value.label, type: "string" }
|
||||
})
|
||||
} else if (contextDefinition.type === "schema") {
|
||||
} else if (context.type === "schema") {
|
||||
// Schema contexts are generated dynamically depending on their data
|
||||
const datasource = getDatasourceForProvider(asset, component)
|
||||
if (!datasource) {
|
||||
|
@ -188,6 +195,7 @@ const getContextBindings = (asset, componentId) => {
|
|||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
data-cy="{meta.name}-select"
|
||||
bind:value
|
||||
options={meta.constraints.inclusion}
|
||||
sort
|
||||
/>
|
||||
{:else if type === "datetime"}
|
||||
<DatePicker {label} bind:value />
|
||||
|
|
|
@ -153,6 +153,7 @@
|
|||
label="Display Column"
|
||||
bind:value={primaryDisplay}
|
||||
options={fields}
|
||||
sort
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"icon": "Form",
|
||||
"children": [
|
||||
"form",
|
||||
"formstep",
|
||||
"fieldgroup",
|
||||
"stringfield",
|
||||
"numberfield",
|
||||
|
|
|
@ -85,6 +85,8 @@
|
|||
props={{
|
||||
options: setting.options || [],
|
||||
placeholder: setting.placeholder || null,
|
||||
min: setting.min || null,
|
||||
max: setting.max || null,
|
||||
}}
|
||||
{bindings}
|
||||
{componentDefinition}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
|
||||
<style>
|
||||
.root {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
|
||||
<style>
|
||||
.root {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -7,6 +7,7 @@ export const ActionTypes = {
|
|||
RefreshDatasource: "RefreshDatasource",
|
||||
SetDataProviderQuery: "SetDataProviderQuery",
|
||||
ClearForm: "ClearForm",
|
||||
ChangeFormStep: "ChangeFormStep",
|
||||
}
|
||||
|
||||
export const ApiVersion = "1"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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": {
|
||||
"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",
|
||||
|
|
|
@ -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)
|
||||
}}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
|
||||
const fieldGroupContext = getContext("fieldGroup")
|
||||
const fieldGroupContext = getContext("field-group")
|
||||
</script>
|
||||
|
||||
{#if fieldGroupContext}
|
||||
|
|
|
@ -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}
|
||||
{#if loaded}
|
||||
{#key resetKey}
|
||||
<InnerForm
|
||||
{dataSource}
|
||||
{theme}
|
||||
{size}
|
||||
{disabled}
|
||||
{actionType}
|
||||
{schema}
|
||||
{table}
|
||||
{initialValues}
|
||||
>
|
||||
<slot />
|
||||
</InnerForm>
|
||||
{/key}
|
||||
{/key}
|
||||
{/if}
|
||||
|
|
|
@ -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}
|
|
@ -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" }
|
||||
// Data source is needed by attachment fields to be able to upload files
|
||||
// to the correct table ID
|
||||
dataSource,
|
||||
})
|
||||
} else {
|
||||
schema = table.schema || {}
|
||||
}
|
||||
}
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
// 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}
|
||||
</div>
|
||||
</Provider>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue