Rewrite client form library to derive state where possible and handle steps

This commit is contained in:
Andrew Kingston 2021-08-19 12:53:31 +01:00
parent 3fa5b3b571
commit 700e36854c
14 changed files with 250 additions and 218 deletions

View File

@ -1749,8 +1749,12 @@
"key": "valid" "key": "valid"
}, },
{ {
"label": "Step", "label": "Current Step",
"key": "step" "key": "currentStep"
},
{
"label": "Current Step Valid",
"key": "currentStepValid"
} }
] ]
}, },
@ -1776,16 +1780,7 @@
"defaultValue": 1, "defaultValue": 1,
"min": 1 "min": 1
} }
], ]
"context": {
"type": "static",
"values": [
{
"label": "Valid",
"key": "valid"
}
]
}
}, },
"fieldgroup": { "fieldgroup": {
"name": "Field Group", "name": "Field Group",

View File

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

View File

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

View File

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

View File

@ -15,7 +15,8 @@
// Get contexts // Get contexts
const formContext = getContext("form") const formContext = getContext("form")
const fieldGroupContext = getContext("fieldGroup") const formStepContext = getContext("form-step")
const fieldGroupContext = getContext("field-group")
const { styleable } = getContext("sdk") const { styleable } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
@ -26,16 +27,20 @@
field, field,
defaultValue, defaultValue,
disabled, disabled,
validation validation,
formStepContext || 1
) )
// Expose field properties to parent component // Expose field properties to parent component
fieldState = formField?.fieldState $: fieldState = $formField?.fieldState
fieldApi = formField?.fieldApi $: fieldApi = $formField?.fieldApi
fieldSchema = formField?.fieldSchema $: fieldSchema = $formField?.fieldSchema
// Keep validation rules up to date // Keep validation rules up to date
$: fieldApi?.updateValidation(validation) $: updateValidation(validation)
const updateValidation = validation => {
fieldApi?.updateValidation(validation)
}
// Extract label position from field group context // Extract label position from field group context
$: labelPositionClass = $: labelPositionClass =
@ -46,7 +51,7 @@
<div class="spectrum-Form-item" use:styleable={$component.styles}> <div class="spectrum-Form-item" use:styleable={$component.styles}>
<label <label
class:hidden={!label} class:hidden={!label}
for={$fieldState?.fieldId} for={fieldState?.fieldId}
class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelPositionClass}`} class={`spectrum-FieldLabel spectrum-FieldLabel--sizeM spectrum-Form-itemLabel ${labelPositionClass}`}
> >
{label || ""} {label || ""}
@ -64,8 +69,8 @@
/> />
{:else} {:else}
<slot /> <slot />
{#if $fieldState.error} {#if fieldState.error}
<div class="error">{$fieldState.error}</div> <div class="error">{fieldState.error}</div>
{/if} {/if}
{/if} {/if}
</div> </div>

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<script> <script>
import { getContext } from "svelte" import { getContext, onMount } from "svelte"
import InnerForm from "./InnerForm.svelte" import InnerForm from "./InnerForm.svelte"
export let dataSource export let dataSource
@ -9,6 +9,11 @@
export let actionType = "Create" export let actionType = "Create"
const context = getContext("context") 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 // Returns the closes data context which isn't a built in context
const getInitialValues = (type, dataSource, context) => { const getInitialValues = (type, dataSource, context) => {
@ -32,19 +37,48 @@
return closestContext 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) $: initialValues = getInitialValues(actionType, dataSource, $context)
$: resetKey = JSON.stringify(initialValues) $: resetKey = JSON.stringify(initialValues)
// Load the form schema on mount
onMount(fetchSchema)
</script> </script>
{#key resetKey} {#if loaded}
<InnerForm {#key resetKey}
{dataSource} <InnerForm
{theme} {dataSource}
{size} {theme}
{disabled} {size}
{actionType} {disabled}
{initialValues} {actionType}
> {schema}
<slot /> {table}
</InnerForm> {initialValues}
{/key} >
<slot />
</InnerForm>
{/key}
{/if}

View File

@ -1,5 +1,5 @@
<script> <script>
import { getContext } from "svelte" import { getContext, setContext } from "svelte"
import Placeholder from "../Placeholder.svelte" import Placeholder from "../Placeholder.svelte"
export let step export let step
@ -8,18 +8,17 @@
const component = getContext("component") const component = getContext("component")
const formContext = getContext("form") const formContext = getContext("form")
// Set form step context so fields know what step they are within
setContext("form-step", step || 1)
$: formState = formContext?.formState $: formState = formContext?.formState
$: currentStep = $formState?.currentStep
</script> </script>
{#if !formContext} {#if !formContext}
<Placeholder text="Form steps need to be wrapped in a form" /> <Placeholder text="Form steps need to be wrapped in a form" />
{:else if step === $formState.step} {:else if step === currentStep}
<div use:styleable={$component.styles}> <div use:styleable={$component.styles}>
<div>
Step {step} is visible!
</div>
<slot /> <slot />
</div> </div>
{:else}
<div>hiding step {step}!</div>
{/if} {/if}

View File

@ -1,41 +1,84 @@
<script> <script>
import { setContext, getContext, onMount } from "svelte" import { setContext, getContext } from "svelte"
import { writable, get } from "svelte/store" import { derived, get, writable } from "svelte/store"
import { createValidatorFromConstraints } from "./validation" import { createValidatorFromConstraints } from "./validation"
import { generateID } from "../helpers" import { generateID } from "../helpers"
export let dataSource export let dataSource
export let disabled = false export let disabled = false
export let initialValues export let initialValues
export let schema
export let table
const component = getContext("component") const component = getContext("component")
const { styleable, API, Provider, ActionTypes } = getContext("sdk") const { styleable, Provider, ActionTypes } = getContext("sdk")
let loaded = false let fields = []
let schema const currentStep = writable(1)
let table
let fieldMap = {}
// Form state contains observable data about the form
const formState = writable({ const formState = writable({
values: initialValues, values: {},
errors: {}, errors: {},
valid: true, valid: true,
step: 1, currentStep: 1,
}) })
// Form API contains functions to control the form // 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 which fields belong in which steps
$: 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)
}
const formApi = { const formApi = {
registerField: ( registerField: (
field, field,
defaultValue = null, defaultValue = null,
fieldDisabled = false, fieldDisabled = false,
validationRules validationRules,
step = 1
) => { ) => {
if (!field) { if (!field) {
return return
} }
// Skip if we've already registered this field
const existingField = getField(field)
if (existingField) {
return existingField
}
// Auto columns are always disabled // Auto columns are always disabled
const isAutoColumn = !!schema?.[field]?.autocolumn const isAutoColumn = !!schema?.[field]?.autocolumn
@ -48,99 +91,79 @@
table table
) )
// Construct field object // Construct field info
fieldMap[field] = { const fieldInfo = writable({
fieldState: makeFieldState( name: field,
field, step: step || 1,
validator, fieldState: {
fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue,
error: null,
disabled: disabled || fieldDisabled || isAutoColumn,
defaultValue, defaultValue,
disabled || fieldDisabled || isAutoColumn validator,
), },
fieldApi: makeFieldApi(field, defaultValue), fieldApi: makeFieldApi(field, defaultValue),
fieldSchema: schema?.[field] ?? {}, fieldSchema: schema?.[field] ?? {},
})
// Add this field
fields = [...fields, fieldInfo]
return fieldInfo
},
validate: (onlyCurrentStep = false) => {
// Validate only the current step if required
if (onlyCurrentStep) {
const stepFields = fields.filter(f => get(f).step === get(currentStep))
for (let field of stepFields) {
if (!get(field).fieldApi.validate()) {
return false
}
}
return true
} }
// Set initial value // Otherwise validate all fields
const initialValue = get(fieldMap[field].fieldState).value for (let field of fields) {
formState.update(state => ({ if (!get(field).fieldApi.validate()) {
...state, return false
values: { }
...state.values, }
[field]: initialValue, return true
},
}))
return fieldMap[field]
},
validate: () => {
const fields = Object.keys(fieldMap)
fields.forEach(field => {
const { fieldApi } = fieldMap[field]
fieldApi.validate()
})
return get(formState).valid
}, },
clear: () => { clear: () => {
const fields = Object.keys(fieldMap) // Clear the form by clearing each individual field
fields.forEach(field => { fields.forEach(field => {
const { fieldApi } = fieldMap[field] get(field).fieldApi.clearValue()
fieldApi.clearValue()
}) })
}, },
nextStep: () => { nextStep: () => {
formState.update(state => ({ currentStep.update(step => step + 1)
...state,
step: state.step + 1,
}))
}, },
prevStep: () => { prevStep: () => {
formState.update(state => ({ currentStep.update(step => Math.max(1, step - 1))
...state,
step: Math.max(1, state.step - 1),
}))
}, },
} }
// 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 },
{ type: ActionTypes.NextFormStep, callback: formApi.nextStep },
{ type: ActionTypes.PrevFormStep, callback: formApi.prevStep },
]
// Creates an API for a specific field // Creates an API for a specific field
const makeFieldApi = field => { const makeFieldApi = field => {
// Sets the value for a certain field and invokes validation // Sets the value for a certain field and invokes validation
const setValue = (value, skipCheck = false) => { const setValue = (value, skipCheck = false) => {
const { fieldState } = fieldMap[field] const fieldInfo = getField(field)
const { validator } = get(fieldState) const { fieldState } = get(fieldInfo)
const { validator } = fieldState
// Skip if the value is the same // Skip if the value is the same
if (!skipCheck && get(fieldState).value === value) { if (!skipCheck && fieldState.value === value) {
return return
} }
// Update field state // Update field state
const error = validator ? validator(value) : null const error = validator ? validator(value) : null
fieldState.update(state => { fieldInfo.update(state => {
state.value = value state.fieldState.value = value
state.error = error state.fieldState.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
return state return state
}) })
@ -149,30 +172,23 @@
// Clears the value of a certain field back to the initial value // Clears the value of a certain field back to the initial value
const clearValue = () => { const clearValue = () => {
const { fieldState } = fieldMap[field] const fieldInfo = getField(field)
const { defaultValue } = get(fieldState) const { fieldState } = get(fieldInfo)
const newValue = initialValues[field] ?? defaultValue const newValue = initialValues[field] ?? fieldState.defaultValue
// Update field state // Update field state
fieldState.update(state => { fieldInfo.update(state => {
state.value = newValue state.fieldState.value = newValue
state.error = null state.fieldState.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
return state return state
}) })
} }
// Updates the validator rules for a certain field // Updates the validator rules for a certain field
const updateValidation = validationRules => { const updateValidation = validationRules => {
const { fieldState } = fieldMap[field] const fieldInfo = getField(field)
const { value, error } = get(fieldState) const { fieldState } = get(fieldInfo)
const { value, error } = fieldState
// Create new validator // Create new validator
const schemaConstraints = schema?.[field]?.constraints const schemaConstraints = schema?.[field]?.constraints
@ -184,8 +200,8 @@
) )
// Update validator // Update validator
fieldState.update(state => { fieldInfo.update(state => {
state.validator = validator state.fieldState.validator = validator
return state return state
}) })
@ -201,63 +217,46 @@
clearValue, clearValue,
updateValidation, updateValidation,
validate: () => { validate: () => {
const { fieldState } = fieldMap[field] // Validate the field by force setting the same value again
setValue(get(fieldState).value, true) const { fieldState } = get(getField(field))
return setValue(fieldState.value, true)
}, },
} }
} }
// Creates observable state data about a specific field // Provide form state and api for full control by children
const makeFieldState = (field, validator, defaultValue, fieldDisabled) => { setContext("form", {
return writable({ formState,
field, formApi,
fieldId: `id-${generateID()}`,
value: initialValues[field] ?? defaultValue,
error: null,
disabled: fieldDisabled,
defaultValue,
validator,
})
}
// Fetches the form schema from this form's dataSource, if one exists // Data source is needed by attachment fields to be able to upload files
const fetchSchema = async () => { // to the correct table ID
if (!dataSource?.tableId) { dataSource,
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
}
// Load the form schema on mount // Provide form step context so that forms without any step components
onMount(fetchSchema) // 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.NextFormStep, callback: formApi.nextStep },
{ type: ActionTypes.PrevFormStep, callback: formApi.prevStep },
]
</script> </script>
<Provider <Provider
{actions} {actions}
data={{ data={{
...$formState.values, ...$values,
tableId: dataSource?.tableId, valid,
valid: $formState.valid, currentStep: $currentStep,
step: $formState.step, currentStepValid: $currentStepValid,
}} }}
> >
<div use:styleable={$component.styles}> <div use:styleable={$component.styles}>
{#if loaded} <slot />
<slot />
{/if}
</div> </div>
</Provider> </Provider>

View File

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

View File

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

View File

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

View File

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