Initial pass at the multistep form block

This commit is contained in:
Dean 2023-12-06 20:39:35 +00:00
parent f0603f7edc
commit 1a03d9d729
13 changed files with 595 additions and 8 deletions

View File

@ -5,6 +5,7 @@
export let name
export let show = false
export let collapsible = true
export let noPadding = false
const dispatch = createEventDispatcher()
const onHeaderClick = () => {
@ -31,6 +32,7 @@
class="property-panel"
class:show={show || !collapsible}
class:no-title={!name}
class:no-padding={noPadding}
>
<slot />
</div>
@ -84,6 +86,10 @@
padding: var(--spacing-xl);
}
.property-panel.no-title.no-padding {
padding: 0px;
}
.show {
display: flex;
flex-direction: column;

View File

@ -34,6 +34,9 @@
$: canAddButtons = max == null || buttonList.length < max
const sanitizeValue = val => {
if (!Array.isArray(val)) {
return null
}
return val?.map(button => {
return button._component ? button : buildPseudoInstance(button)
})

View File

@ -39,7 +39,10 @@
)
}
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: datasource =
componentInstance.dataSource ||
getDatasourceForProvider($currentAsset, componentInstance)
$: resourceId = datasource?.resourceId || datasource?.tableId
$: if (!isEqual(value, cachedValue)) {

View File

@ -0,0 +1,274 @@
<script>
// import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
// import { getComponentForSetting } from "components/design/settings/componentSettings"
import { createEventDispatcher } from "svelte"
import ComponentSettingsSection from "../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import { getDatasourceForProvider } from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
import { Helpers } from "@budibase/bbui"
import FormStepControls from "./FormStepControls.svelte"
import { setContext } from "svelte"
import { writable } from "svelte/store"
export let componentInstance
export let componentBindings
export let value //steps
export let bindings
const dispatch = createEventDispatcher()
let stepState = [...(value || [])]
const stepStore = writable({
stepsCount: stepState?.length || 1,
currentStep: 0,
})
setContext("step-form-block", stepStore)
$: ({ currentStep } = $stepStore)
$: lastIdx = stepState?.length - 1
$: if (stepState.length) {
stepStore.update(state => ({
...state,
stepsCount: stepState.length || 0,
}))
}
// Step Definition Settings
let compSettings = [
{
customType: "formStepControl",
label: "Multi-steps",
key: "steps",
},
{
type: "text",
label: "Title",
key: "title",
nested: true,
},
{
type: "text",
label: "Description",
key: "desc",
nested: true,
},
{
type: "fieldConfiguration",
key: "fields",
nested: true,
},
{
type: "buttonConfiguration",
label: "Buttons",
key: "buttons",
wide: true,
nested: true,
},
]
const emitCurrentStep = step => {
store.actions.preview.sendEvent("builder-meta", {
component: {
_id: componentInstance._id,
step: step,
},
})
}
$: dataSource = getDatasourceForProvider($currentAsset, componentInstance)
$: stepDef = {
component: "@budibase/standard-components/multistepformblock-step",
name: "Formblock step",
settings: compSettings,
}
const addStep = () => {
const newStepIdx = currentStep + 1
stepState = [
...stepState.slice(0, newStepIdx),
{},
...stepState.slice(newStepIdx),
]
stepStore.update(state => ({
...state,
currentStep: newStepIdx,
}))
dispatch("change", stepState)
emitCurrentStep(newStepIdx)
}
const removeStep = () => {
const clone = stepState.map(x => x)
clone.splice(currentStep, 1)
const targetStepIdx = Math.max(currentStep - 1, 0)
stepState = clone.map(x => x)
stepStore.update(state => ({
...state,
currentStep: targetStepIdx,
}))
dispatch("change", stepState)
emitCurrentStep(targetStepIdx)
}
const previousStep = () => {
const prevStepIdx = Math.max(currentStep - 1, 0)
stepStore.update(state => ({
...state,
currentStep: prevStepIdx,
}))
emitCurrentStep(prevStepIdx)
}
const nextStep = () => {
const nextStepIdx = currentStep + 1
stepStore.update(state => ({
...state,
currentStep: Math.min(nextStepIdx, stepState.length - 1),
}))
emitCurrentStep(nextStepIdx)
}
const updateStep = (field, val) => {
stepState[currentStep] ||= {}
stepState[currentStep][field.key] = val
dispatch("change", stepState)
}
const handleStepAction = action => {
switch (action) {
case "addStep":
addStep()
break
case "removeStep":
removeStep()
break
case "nextStep":
nextStep()
break
case "previousStep":
previousStep()
break
default:
console.log("Nothing")
}
}
const processUpdate = (field, val) => {
if (field.key === "steps") {
handleStepAction(val.action)
} else {
updateStep(field, val)
}
}
// Multistep button generation
// This kind of behaviour should be shifted to frontend-core
/*
Also, look at this unicorn
\
\_
/.(((
(,/"(((__,--.
\ ) _( /{
|| " ||
|| ||
'' ''
*/
const generateButtons = () => {
const buttons = []
if (stepState.length > 1) {
if (currentStep > 0) {
buttons.push({
_id: Helpers.uuid(),
_component: "@budibase/standard-components/button",
_instanceName: Helpers.uuid(),
text: "Back",
type: "secondary",
size: "M",
onClick: [
{
parameters: {
type: "prev",
componentId: `${componentInstance._id}-form`,
},
"##eventHandlerType": "Change Form Step",
},
],
})
}
buttons.push({
_id: Helpers.uuid(),
_component: "@budibase/standard-components/button",
_instanceName: Helpers.uuid(),
text: "Next",
type: "cta",
size: "M",
disabled: lastIdx === currentStep,
onClick: [
{
parameters: {
type: "next",
componentId: `${componentInstance._id}-form`,
},
"##eventHandlerType": "Change Form Step",
},
],
})
}
return buttons
}
const buildPseudoInstance = ({ buttons, fields, title, desc }) => {
return {
_id: Helpers.uuid(),
_component: "@budibase/standard-components/multistepformblock-step",
buttons: buttons || generateButtons(),
fields,
title,
desc,
dataSource,
step: currentStep + 1,
_instanceName: `Step ${currentStep + 1}`,
}
}
$: stepConfigInstance = buildPseudoInstance(stepState?.[currentStep] || {})
</script>
<span class="settings-wrap">
<ComponentSettingsSection
includeHidden
componentInstance={stepConfigInstance}
componentDefinition={stepDef}
onUpdateSetting={processUpdate}
getCustomComponent={type => {
const types = { formStepControl: FormStepControls }
return types[type]
}}
getCustomSectionTitle={section => {
console.log(section.name)
if (section.name === "Details" && stepState?.length > 0) {
return `Details (${currentStep}/${stepState?.length})`
}
return section.name
}}
showSectionTitle={false}
showInstanceName={false}
isScreen={false}
noPadding={true}
nested={true}
{bindings}
{componentBindings}
/>
</span>

View File

@ -0,0 +1,81 @@
<script>
import { createEventDispatcher, getContext } from "svelte"
import { ActionButton } from "@budibase/bbui"
const stepState = getContext("step-form-block")
const dispatch = createEventDispatcher()
$: ({ stepsCount, currentStep } = $stepState)
const parseLastIdx = stepsCount => {
return Math.max(stepsCount - 1, 0)
}
$: lastIdx = parseLastIdx(stepsCount)
const stepAction = action => {
dispatch("change", {
action,
})
}
</script>
{#if stepsCount === 1}
<ActionButton
icon="MultipleAdd"
secondary
on:click={() => {
stepAction("addStep")
}}
>
Add Step
</ActionButton>
{:else}
<div class="step-actions">
<ActionButton
size="S"
secondary
icon="ChevronLeft"
disabled={currentStep === 0}
on:click={() => {
stepAction("previousStep")
}}
/>
<ActionButton
size="S"
secondary
disabled={currentStep === lastIdx}
icon="ChevronRight"
on:click={() => {
stepAction("nextStep")
}}
/>
<ActionButton
size="S"
secondary
icon="Close"
disabled={stepsCount === 1}
on:click={() => {
stepAction("removeStep")
}}
/>
<ActionButton
size="S"
secondary
icon="MultipleAdd"
on:click={() => {
stepAction("addStep")
}}
/>
</div>
{/if}
<style>
.step-actions {
display: flex;
gap: var(--spacing-s);
}
.step-actions :global(.spectrum-ActionButton) {
height: 32px;
}
</style>

View File

@ -24,6 +24,7 @@
export let propertyFocus = false
export let info = null
export let disableBindings = false
export let wide
$: nullishValue = value == null || value === ""
$: allBindings = getAllBindings(bindings, componentBindings, nested)
@ -78,7 +79,7 @@
<div
class="property-control"
class:wide={!label || labelHidden}
class:wide={!label || labelHidden || wide === true}
class:highlighted={highlighted && nullishValue}
class:property-focus={propertyFocus}
>
@ -104,6 +105,7 @@
{...props}
on:drawerHide
on:drawerShow
on:meta
/>
</div>
{#if info}
@ -147,7 +149,16 @@
position: relative;
}
.property-control.wide .control {
grid-column: 1 / -1;
flex: 1;
}
.property-control.wide {
grid-template-columns: unset;
display: flex;
flex-direction: column;
width: 100%;
}
.property-control.wide > * {
width: 100%;
}
.text {
font-size: var(--spectrum-global-dimension-font-size-75);

View File

@ -15,9 +15,12 @@
export let componentBindings
export let isScreen = false
export let onUpdateSetting
export let getCustomComponent
export let getCustomSectionTitle
export let showSectionTitle = true
export let includeHidden = false
export let tag
export let noPadding = false
$: sections = getSections(
componentInstance,
@ -129,13 +132,30 @@
})
}
const resolveComponentByType = setting => {
if (setting.type) {
return getComponentForSetting(setting)
} else if (setting.customType && typeof getCustomComponent === "function") {
return getCustomComponent(setting.customType)
}
}
const resolveSectionName = section => {
console.log(resolveSectionName)
if (typeof getCustomSectionTitle === "function") {
return getCustomSectionTitle(section)
} else {
return section.name
}
}
const canRenderControl = (instance, setting, isScreen, includeHidden) => {
// Prevent rendering on click setting for screens
if (setting?.type === "event" && isScreen) {
return false
}
// Check we have a component to render for this setting
const control = getComponentForSetting(setting)
const control = resolveComponentByType(setting)
if (!control) {
return false
}
@ -152,6 +172,7 @@
<DetailSummary
name={showSectionTitle ? section.name : ""}
show={section.collapsed !== true}
{noPadding}
>
{#if section.info}
<div class="section-info">
@ -168,9 +189,10 @@
{#if setting.visible}
<PropertyControl
type={setting.type}
control={getComponentForSetting(setting)}
control={resolveComponentByType(setting)}
label={setting.label}
labelHidden={setting.labelHidden}
wide={setting.wide}
key={setting.key}
value={componentInstance[setting.key]}
defaultValue={setting.defaultValue}

View File

@ -8,6 +8,7 @@
"cardsblock",
"repeaterblock",
"formblock",
"multistepformblock",
"chartblock",
"rowexplorer"
]

View File

@ -6058,10 +6058,14 @@
{
"type": "stepConfiguration",
"key": "steps",
"nested": true
"nested": true,
"labelHidden": true,
"resetOn": ["dataSource", "actionType"],
"defaultValue": [
{ "title": null, "desc": null, "fields": null, "buttons": {} }
]
}
],
"resetOn": ["dataSource", "actionType"]
]
}
],
"actions": [

View File

@ -0,0 +1,166 @@
<script>
import BlockComponent from "components/BlockComponent.svelte"
import Block from "components/Block.svelte"
import { getContext } from "svelte"
import { builderStore } from "stores"
export let componentInstance
export let steps
export let dataSource
export let initialFormStep = 1
const { fetchDatasourceSchema } = getContext("sdk")
const component = getContext("component")
let schema
let formId
const getCurrentStep = () => {
if ($builderStore?.component?._id === $component.id) {
return $builderStore?.component.step
}
return 0
}
$: currentStep = getCurrentStep(
$builderStore?.component?._id,
componentInstance
)
$: fetchSchema(dataSource)
const getPropsForField = field => {
let fieldProps = field._component
? {
...field,
}
: {
field: field.name,
label: field.name,
placeholder: field.name,
_instanceName: field.name,
}
return fieldProps
}
const getDefaultFields = (fields, schema) => {
if (!schema) {
return []
}
let defaultFields = []
if (!fields || fields.length === 0) {
Object.values(schema)
.filter(field => !field.autocolumn)
.forEach(field => {
defaultFields.push({
name: field.name,
active: true,
})
})
}
return [...fields, ...defaultFields].filter(field => field.active)
}
const FieldTypeToComponentMap = {
string: "stringfield",
number: "numberfield",
bigint: "bigintfield",
options: "optionsfield",
array: "multifieldselect",
boolean: "booleanfield",
longform: "longformfield",
datetime: "datetimefield",
attachment: "attachmentfield",
link: "relationshipfield",
json: "jsonfield",
barcodeqr: "codescanner",
bb_reference: "bbreferencefield",
}
const getComponentForField = field => {
const fieldSchemaName = field.field || field.name
if (!fieldSchemaName || !schema?.[fieldSchemaName]) {
return null
}
const type = schema[fieldSchemaName].type
return FieldTypeToComponentMap[type]
}
const fetchSchema = async () => {
schema = (await fetchDatasourceSchema(dataSource)) || {}
}
$: stepProps = steps?.map(step => {
const { title, desc, fields, buttons } = step
return {
fields: getDefaultFields(fields || [], schema),
title,
desc,
buttons,
}
})
</script>
{#key stepProps}
<Block>
<BlockComponent
type="form"
props={{
dataSource,
initialFormStep,
step: $builderStore.inBuilder === true ? currentStep + 1 : null,
}}
context="form"
bind:id={formId}
>
{#each steps || [] as step, idx ("step" + step._id)}
<BlockComponent
type="formstep"
props={{ step: idx + 1, _instanceName: `Step ${idx + 1}` }}
>
<BlockComponent
type="container"
props={{
gap: "M",
direction: "column",
hAlign: "stretch",
vAlign: "top",
size: "shrink",
}}
>
<BlockComponent type="container">
<BlockComponent
type="heading"
props={{ text: stepProps?.[idx]?.title }}
/>
</BlockComponent>
<BlockComponent
type="text"
props={{ text: stepProps?.[idx]?.desc }}
/>
<BlockComponent type="fieldgroup">
{#each stepProps?.[idx]?.fields || [] as field, fieldIdx ("field_" + fieldIdx)}
{#if getComponentForField(field) && field.active}
<BlockComponent
type={getComponentForField(field)}
props={getPropsForField(field)}
order={idx}
interactive
name={field?.field}
/>
{/if}
{/each}
</BlockComponent>
<BlockComponent
type="buttongroup"
props={{ buttons: stepProps?.[idx]?.buttons }}
/>
</BlockComponent>
</BlockComponent>
{/each}
</BlockComponent>
</Block>
{/key}

View File

@ -4,3 +4,4 @@ export { default as repeaterblock } from "./RepeaterBlock.svelte"
export { default as formblock } from "./form/FormBlock.svelte"
export { default as chartblock } from "./ChartBlock.svelte"
export { default as rowexplorer } from "./RowExplorer.svelte"
export { default as multistepformblock } from "./MultiStepFormblock.svelte"

View File

@ -11,6 +11,7 @@
export let readonly = false
export let actionType = "Create"
export let initialFormStep = 1
export let step
// Not exposed as a builder setting. Used internally to disable validation
// for fields rendered in things like search blocks.
@ -36,6 +37,15 @@
let table
let currentStep = writable(getInitialFormStep())
$: if (
currentStep &&
Number.isInteger($currentStep) &&
Number.isInteger(step) &&
step !== currentStep
) {
currentStep.set(step)
}
$: fetchSchema(dataSource)
$: schemaKey = generateSchemaKey(schema)
$: initialValues = getInitialValues(actionType, dataSource, $context)

View File

@ -75,6 +75,11 @@ const loadBudibase = async () => {
} else {
dndStore.actions.reset()
}
} else if ("builder-meta") {
builderStore.update(state => ({
...state,
...data,
}))
}
}