Merge pull request #7456 from Budibase/form-block

Form block
This commit is contained in:
Andrew Kingston 2022-10-07 13:14:13 +01:00 committed by GitHub
commit 354817dbf3
17 changed files with 530 additions and 125 deletions

View File

@ -1,5 +1,5 @@
import filterTests from "../support/filterTests" import filterTests from "../support/filterTests"
const interact = require('../support/interact') const interact = require("../support/interact")
filterTests(["smoke", "all"], () => { filterTests(["smoke", "all"], () => {
context("Query Level Transformers", () => { context("Query Level Transformers", () => {

View File

@ -185,43 +185,42 @@ export const makeComponentUnique = component => {
// Replace component ID // Replace component ID
const oldId = component._id const oldId = component._id
const newId = Helpers.uuid() const newId = Helpers.uuid()
component._id = newId let definition = JSON.stringify(component)
if (component._children?.length) { // Replace all instances of this ID in HBS bindings
let children = JSON.stringify(component._children) definition = definition.replace(new RegExp(oldId, "g"), newId)
// Replace all instances of this ID in child HBS bindings // Replace all instances of this ID in JS bindings
children = children.replace(new RegExp(oldId, "g"), newId) const bindings = findHBSBlocks(definition)
bindings.forEach(binding => {
// JSON.stringify will have escaped double quotes, so we need
// to account for that
let sanitizedBinding = binding.replace(/\\"/g, '"')
// Replace all instances of this ID in child JS bindings // Check if this is a valid JS binding
const bindings = findHBSBlocks(children) let js = decodeJSBinding(sanitizedBinding)
bindings.forEach(binding => { if (js != null) {
// JSON.stringify will have escaped double quotes, so we need // Replace ID inside JS binding
// to account for that js = js.replace(new RegExp(oldId, "g"), newId)
let sanitizedBinding = binding.replace(/\\"/g, '"')
// Check if this is a valid JS binding // Create new valid JS binding
let js = decodeJSBinding(sanitizedBinding) let newBinding = encodeJSBinding(js)
if (js != null) {
// Replace ID inside JS binding
js = js.replace(new RegExp(oldId, "g"), newId)
// Create new valid JS binding // Replace escaped double quotes
let newBinding = encodeJSBinding(js) newBinding = newBinding.replace(/"/g, '\\"')
// Replace escaped double quotes // Insert new JS back into binding.
newBinding = newBinding.replace(/"/g, '\\"') // A single string replace here is better than a regex as
// the binding contains special characters, and we only need
// to replace a single instance.
definition = definition.replace(binding, newBinding)
}
})
// Insert new JS back into binding. // Recurse on all children
// A single string replace here is better than a regex as component = JSON.parse(definition)
// the binding contains special characters, and we only need return {
// to replace a single instance. ...component,
children = children.replace(binding, newBinding) _children: component._children?.map(makeComponentUnique),
}
})
// Recurse on all children
component._children = JSON.parse(children)
component._children.forEach(makeComponentUnique)
} }
} }

View File

@ -169,7 +169,12 @@ export const getComponentBindableProperties = (asset, componentId) => {
/** /**
* Gets all data provider components above a component. * Gets all data provider components above a component.
*/ */
export const getContextProviderComponents = (asset, componentId, type) => { export const getContextProviderComponents = (
asset,
componentId,
type,
options = { includeSelf: false }
) => {
if (!asset || !componentId) { if (!asset || !componentId) {
return [] return []
} }
@ -177,7 +182,9 @@ export const getContextProviderComponents = (asset, componentId, type) => {
// Get the component tree leading up to this component, ignoring the component // Get the component tree leading up to this component, ignoring the component
// itself // itself
const path = findComponentPath(asset.props, componentId) const path = findComponentPath(asset.props, componentId)
path.pop() if (!options?.includeSelf) {
path.pop()
}
// Filter by only data provider components // Filter by only data provider components
return path.filter(component => { return path.filter(component => {
@ -798,6 +805,17 @@ export const buildFormSchema = component => {
if (!component) { if (!component) {
return schema return schema
} }
// If this is a form block, simply use the fields setting
if (component._component.endsWith("formblock")) {
let schema = {}
component.fields?.forEach(field => {
schema[field] = { type: "string" }
})
return schema
}
// Otherwise find all field component children
const settings = getComponentSettings(component._component) const settings = getComponentSettings(component._component)
const fieldSetting = settings.find( const fieldSetting = settings.find(
setting => setting.key === "field" && setting.type.startsWith("field/") setting => setting.key === "field" && setting.type.startsWith("field/")

View File

@ -621,7 +621,7 @@ export const getFrontendStore = () => {
// Make new component unique if copying // Make new component unique if copying
if (!cut) { if (!cut) {
makeComponentUnique(componentToPaste) componentToPaste = makeComponentUnique(componentToPaste)
} }
newComponentId = componentToPaste._id newComponentId = componentToPaste._id
@ -931,7 +931,7 @@ export const getFrontendStore = () => {
} }
// Replace block with ejected definition // Replace block with ejected definition
makeComponentUnique(ejectedDefinition) ejectedDefinition = makeComponentUnique(ejectedDefinition)
const index = parent._children.findIndex(x => x._id === componentId) const index = parent._children.findIndex(x => x._id === componentId)
parent._children[index] = ejectedDefinition parent._children[index] = ejectedDefinition
nextSelectedComponentId = ejectedDefinition._id nextSelectedComponentId = ejectedDefinition._id

View File

@ -21,6 +21,7 @@
export let key export let key
export let actions export let actions
export let bindings = [] export let bindings = []
export let nested
$: showAvailableActions = !actions?.length $: showAvailableActions = !actions?.length
@ -187,6 +188,7 @@
this={selectedActionComponent} this={selectedActionComponent}
parameters={selectedAction.parameters} parameters={selectedAction.parameters}
bindings={allBindings} bindings={allBindings}
{nested}
/> />
</div> </div>
{/key} {/key}

View File

@ -12,6 +12,7 @@
export let value = [] export let value = []
export let name export let name
export let bindings export let bindings
export let nested
let drawer let drawer
let tmpValue let tmpValue
@ -90,6 +91,7 @@
eventType={name} eventType={name}
{bindings} {bindings}
{key} {key}
{nested}
/> />
</Drawer> </Drawer>

View File

@ -10,11 +10,13 @@
export let parameters export let parameters
export let bindings = [] export let bindings = []
export let nested
$: formComponents = getContextProviderComponents( $: formComponents = getContextProviderComponents(
$currentAsset, $currentAsset,
$store.selectedComponentId, $store.selectedComponentId,
"form" "form",
{ includeSelf: nested }
) )
$: schemaComponents = getContextProviderComponents( $: schemaComponents = getContextProviderComponents(
$currentAsset, $currentAsset,

View File

@ -95,6 +95,7 @@
bindings={allBindings} bindings={allBindings}
name={key} name={key}
text={label} text={label}
{nested}
{key} {key}
{type} {type}
{...props} {...props}

View File

@ -4,6 +4,7 @@
export let value export let value
export let bindings export let bindings
export let placeholder
$: urlOptions = $store.screens $: urlOptions = $store.screens
.map(screen => screen.routing?.route) .map(screen => screen.routing?.route)
@ -13,6 +14,7 @@
<DrawerBindableCombobox <DrawerBindableCombobox
{value} {value}
{bindings} {bindings}
{placeholder}
on:change on:change
options={urlOptions} options={urlOptions}
appendBindingsAsOptions={false} appendBindingsAsOptions={false}

View File

@ -13,19 +13,29 @@
export let componentBindings export let componentBindings
export let isScreen = false export let isScreen = false
$: sections = getSections(componentDefinition) $: sections = getSections(componentInstance, componentDefinition, isScreen)
const getSections = definition => { const getSections = (instance, definition, isScreen) => {
const settings = definition?.settings ?? [] const settings = definition?.settings ?? []
const generalSettings = settings.filter(setting => !setting.section) const generalSettings = settings.filter(setting => !setting.section)
const customSections = settings.filter(setting => setting.section) const customSections = settings.filter(setting => setting.section)
return [ let sections = [
{ {
name: "General", name: "General",
settings: generalSettings, settings: generalSettings,
}, },
...(customSections || []), ...(customSections || []),
] ]
// Filter out settings which shouldn't be rendered
sections.forEach(section => {
section.settings.forEach(setting => {
setting.visible = canRenderControl(instance, setting, isScreen)
})
section.visible = section.settings.some(setting => setting.visible)
})
return sections
} }
const updateSetting = async (key, value) => { const updateSetting = async (key, value) => {
@ -36,7 +46,7 @@
} }
} }
const canRenderControl = (setting, isScreen) => { const canRenderControl = (instance, setting, isScreen) => {
// Prevent rendering on click setting for screens // Prevent rendering on click setting for screens
if (setting?.type === "event" && isScreen) { if (setting?.type === "event" && isScreen) {
return false return false
@ -51,6 +61,7 @@
if (setting.dependsOn) { if (setting.dependsOn) {
let dependantSetting = setting.dependsOn let dependantSetting = setting.dependsOn
let dependantValue = null let dependantValue = null
let invert = !!setting.dependsOn.invert
if (typeof setting.dependsOn === "object") { if (typeof setting.dependsOn === "object") {
dependantSetting = setting.dependsOn.setting dependantSetting = setting.dependsOn.setting
dependantValue = setting.dependsOn.value dependantValue = setting.dependsOn.value
@ -62,7 +73,7 @@
// If no specific value is depended upon, check if a value exists at all // If no specific value is depended upon, check if a value exists at all
// for the dependent setting // for the dependent setting
if (dependantValue == null) { if (dependantValue == null) {
const currentValue = componentInstance[dependantSetting] const currentValue = instance[dependantSetting]
if (currentValue === false) { if (currentValue === false) {
return false return false
} }
@ -73,7 +84,11 @@
} }
// Otherwise check the value matches // Otherwise check the value matches
return componentInstance[dependantSetting] === dependantValue if (invert) {
return instance[dependantSetting] !== dependantValue
} else {
return instance[dependantSetting] === dependantValue
}
} }
return true return true
@ -81,52 +96,54 @@
</script> </script>
{#each sections as section, idx (section.name)} {#each sections as section, idx (section.name)}
<DetailSummary name={section.name} collapsible={false}> {#if section.visible}
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen} <DetailSummary name={section.name} collapsible={false}>
<PropertyControl {#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen}
control={Input}
label="Name"
key="_instanceName"
value={componentInstance._instanceName}
onChange={val => updateSetting("_instanceName", val)}
/>
{/if}
{#each section.settings as setting (setting.key)}
{#if canRenderControl(setting, isScreen)}
<PropertyControl <PropertyControl
type={setting.type} control={Input}
control={getComponentForSetting(setting)} label="Name"
label={setting.label} key="_instanceName"
key={setting.key} value={componentInstance._instanceName}
value={componentInstance[setting.key]} onChange={val => updateSetting("_instanceName", val)}
defaultValue={setting.defaultValue}
nested={setting.nested}
onChange={val => updateSetting(setting.key, val)}
highlighted={$store.highlightedSettingKey === setting.key}
info={setting.info}
props={{
// Generic settings
placeholder: setting.placeholder || null,
// Select settings
options: setting.options || [],
// Number fields
min: setting.min || null,
max: setting.max || null,
}}
{bindings}
{componentBindings}
{componentInstance}
{componentDefinition}
/> />
{/if} {/if}
{/each} {#each section.settings as setting (setting.key)}
{#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")} {#if setting.visible}
<ResetFieldsButton {componentInstance} /> <PropertyControl
{/if} type={setting.type}
{#if idx === 0 && componentDefinition?.block} control={getComponentForSetting(setting)}
<EjectBlockButton /> label={setting.label}
{/if} key={setting.key}
</DetailSummary> value={componentInstance[setting.key]}
defaultValue={setting.defaultValue}
nested={setting.nested}
onChange={val => updateSetting(setting.key, val)}
highlighted={$store.highlightedSettingKey === setting.key}
info={setting.info}
props={{
// Generic settings
placeholder: setting.placeholder || null,
// Select settings
options: setting.options || [],
// Number fields
min: setting.min || null,
max: setting.max || null,
}}
{bindings}
{componentBindings}
{componentInstance}
{componentDefinition}
/>
{/if}
{/each}
{#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")}
<ResetFieldsButton {componentInstance} />
{/if}
{#if idx === 0 && componentDefinition?.block}
<EjectBlockButton />
{/if}
</DetailSummary>
{/if}
{/each} {/each}

View File

@ -5,7 +5,8 @@
"children": [ "children": [
"tableblock", "tableblock",
"cardsblock", "cardsblock",
"repeaterblock" "repeaterblock",
"formblock"
] ]
}, },
{ {

View File

@ -38,7 +38,7 @@
let duplicateScreen = Helpers.cloneDeep(screen) let duplicateScreen = Helpers.cloneDeep(screen)
delete duplicateScreen._id delete duplicateScreen._id
delete duplicateScreen._rev delete duplicateScreen._rev
makeComponentUnique(duplicateScreen.props) duplicateScreen.props = makeComponentUnique(duplicateScreen.props)
// Attach the new name and URL // Attach the new name and URL
duplicateScreen.routing.route = sanitizeUrl(screenUrl) duplicateScreen.routing.route = sanitizeUrl(screenUrl)

View File

@ -3845,7 +3845,7 @@
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns", "placeholder": "Choose search columns",
"info": "Only the first 3 search columns will be used" "info": "Only the first 5 search columns will be used"
}, },
{ {
"type": "filter", "type": "filter",
@ -4010,7 +4010,7 @@
"label": "Search Columns", "label": "Search Columns",
"key": "searchColumns", "key": "searchColumns",
"placeholder": "Choose search columns", "placeholder": "Choose search columns",
"info": "Only the first 3 search columns will be used" "info": "Only the first 5 search columns will be used"
}, },
{ {
"type": "filter", "type": "filter",
@ -4395,5 +4395,145 @@
"required": true "required": true
} }
] ]
},
"formblock": {
"name": "Form Block",
"icon": "Form",
"styles": ["size"],
"block": true,
"info": "Form blocks are only compatible with internal or SQL tables",
"settings": [
{
"type": "select",
"label": "Type",
"key": "actionType",
"options": ["Create", "Update", "View"],
"defaultValue": "Create"
},
{
"type": "table",
"label": "Table",
"key": "dataSource"
},
{
"type": "text",
"label": "Row ID",
"key": "rowId",
"nested": true,
"dependsOn": {
"setting": "actionType",
"value": "Create",
"invert": true
}
},
{
"type": "text",
"label": "Title",
"key": "title",
"nested": true
},
{
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
},
{
"section": true,
"name": "Fields",
"settings": [
{
"type": "multifield",
"label": "Fields",
"key": "fields"
},
{
"type": "select",
"label": "Field labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
}
]
},
{
"section": true,
"name": "Buttons",
"settings": [
{
"type": "boolean",
"label": "Show save button",
"key": "showSaveButton",
"defaultValue": true,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
},
{
"type": "boolean",
"label": "Show delete button",
"key": "showDeleteButton",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "Update"
}
},
{
"type": "url",
"label": "Navigate after button press",
"key": "actionUrl",
"placeholder": "Choose a screen",
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
}
]
}
],
"context": [
{
"type": "form",
"suffix": "form"
},
{
"type": "schema",
"suffix": "repeater"
}
]
} }
} }

View File

@ -0,0 +1,239 @@
<script>
import { getContext } from "svelte"
import BlockComponent from "../../BlockComponent.svelte"
import Block from "../../Block.svelte"
import Placeholder from "../Placeholder.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
export let actionType
export let dataSource
export let size
export let disabled
export let fields
export let labelPosition
export let title
export let showSaveButton
export let showDeleteButton
export let rowId
export let actionUrl
const { fetchDatasourceSchema, builderStore } = getContext("sdk")
const FieldTypeToComponentMap = {
string: "stringfield",
number: "numberfield",
options: "optionsfield",
array: "multifieldselect",
boolean: "booleanfield",
longform: "longformfield",
datetime: "datetimefield",
attachment: "attachmentfield",
link: "relationshipfield",
json: "jsonfield",
}
let schema
let formId
let providerId
let repeaterId
$: fetchSchema(dataSource)
$: onSave = [
{
"##eventHandlerType": "Save Row",
parameters: {
providerId: formId,
tableId: dataSource?.tableId,
},
},
{
"##eventHandlerType": "Close Screen Modal",
},
{
"##eventHandlerType": "Navigate To",
parameters: {
url: actionUrl,
},
},
]
$: onDelete = [
{
"##eventHandlerType": "Delete Row",
parameters: {
confirm: true,
tableId: dataSource?.tableId,
rowId: `{{ ${safe(repeaterId)}.${safe("_id")} }}`,
revId: `{{ ${safe(repeaterId)}.${safe("_rev")} }}`,
},
},
{
"##eventHandlerType": "Close Screen Modal",
},
{
"##eventHandlerType": "Navigate To",
parameters: {
url: actionUrl,
},
},
]
$: filter = [
{
field: "_id",
operator: "equal",
type: "string",
value: rowId,
valueType: "Binding",
},
]
// If we're using an "update" form, use the real data provider. If we're
// using a create form, we just want a fake array so that our repeater
// will actually render the form, but data doesn't matter.
$: dataProvider =
actionType !== "Create"
? `{{ literal ${safe(providerId)} }}`
: { rows: [{}] }
$: renderDeleteButton = showDeleteButton && actionType === "Update"
$: renderSaveButton = showSaveButton && actionType !== "View"
$: renderButtons = renderDeleteButton || renderSaveButton
$: renderHeader = renderButtons || title
const fetchSchema = async () => {
schema = (await fetchDatasourceSchema(dataSource)) || {}
}
const getComponentForField = field => {
if (!field || !schema?.[field]) {
return null
}
const type = schema[field].type
return FieldTypeToComponentMap[type]
}
</script>
<Block>
{#if fields?.length}
<BlockComponent
type="dataprovider"
context="provider"
bind:id={providerId}
props={{
dataSource,
filter,
limit: rowId ? 1 : $builderStore.inBuilder ? 1 : 0,
paginate: false,
}}
>
<BlockComponent
type="repeater"
context="repeater"
bind:id={repeaterId}
props={{
dataProvider,
noRowsMessage: "We couldn't find a row to display",
}}
>
<BlockComponent
type="form"
props={{
actionType: actionType === "Create" ? "Create" : "Update",
dataSource,
size,
disabled: disabled || actionType === "View",
}}
context="form"
bind:id={formId}
>
<BlockComponent
type="container"
props={{
direction: "column",
hAlign: "stretch",
vAlign: "top",
gap: "M",
}}
>
{#if renderHeader}
<BlockComponent
type="container"
props={{
direction: "row",
hAlign: "stretch",
vAlign: "center",
gap: "M",
wrap: true,
}}
order={0}
>
<BlockComponent
type="heading"
props={{ text: title || "" }}
order={0}
/>
{#if renderButtons}
<BlockComponent
type="container"
props={{
direction: "row",
hAlign: "stretch",
vAlign: "center",
gap: "M",
wrap: true,
}}
order={1}
>
{#if renderDeleteButton}
<BlockComponent
type="button"
props={{
text: "Delete",
onClick: onDelete,
quiet: true,
type: "secondary",
}}
order={0}
/>
{/if}
{#if renderSaveButton}
<BlockComponent
type="button"
props={{
text: "Save",
onClick: onSave,
type: "cta",
}}
order={1}
/>
{/if}
</BlockComponent>
{/if}
</BlockComponent>
{/if}
<BlockComponent
type="fieldgroup"
props={{ labelPosition }}
order={1}
>
{#each fields as field, idx}
{#if getComponentForField(field)}
<BlockComponent
type={getComponentForField(field)}
props={{
field,
label: field,
placeholder: field,
disabled,
}}
order={idx}
/>
{/if}
{/each}
</BlockComponent>
</BlockComponent>
</BlockComponent>
</BlockComponent>
</BlockComponent>
{:else}
<Placeholder
text="Choose your table and add some fields to your form to get started"
/>
{/if}
</Block>

View File

@ -1,3 +1,4 @@
export { default as tableblock } from "./TableBlock.svelte" export { default as tableblock } from "./TableBlock.svelte"
export { default as cardsblock } from "./CardsBlock.svelte" export { default as cardsblock } from "./CardsBlock.svelte"
export { default as repeaterblock } from "./RepeaterBlock.svelte" export { default as repeaterblock } from "./RepeaterBlock.svelte"
export { default as formblock } from "./FormBlock.svelte"

View File

@ -48,36 +48,7 @@
// Fetches the form schema from this form's dataSource // Fetches the form schema from this form's dataSource
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (!dataSource) { schema = (await fetchDatasourceSchema(dataSource)) || {}
schema = {}
}
// If the datasource is a query, then we instead use a schema of the query
// parameters rather than the output schema
else if (
dataSource.type === "query" &&
dataSource._id &&
actionType === "Create"
) {
try {
const query = await API.fetchQueryDefinition(dataSource._id)
let paramSchema = {}
const params = query.parameters || []
params.forEach(param => {
paramSchema[param.name] = { ...param, type: "string" }
})
schema = paramSchema
} catch (error) {
schema = {}
}
}
// For all other cases, just grab the normal schema
else {
const dataSourceSchema = await fetchDatasourceSchema(dataSource)
schema = dataSourceSchema || {}
}
if (!loaded) { if (!loaded) {
loaded = true loaded = true
} }
@ -95,7 +66,7 @@
$: initialValues = getInitialValues(actionType, dataSource, $context) $: initialValues = getInitialValues(actionType, dataSource, $context)
$: resetKey = Helpers.hashString( $: resetKey = Helpers.hashString(
JSON.stringify(initialValues) + JSON.stringify(schema) JSON.stringify(initialValues) + JSON.stringify(schema) + disabled
) )
</script> </script>

View File

@ -16,7 +16,7 @@ import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js"
*/ */
export const fetchDatasourceSchema = async ( export const fetchDatasourceSchema = async (
datasource, datasource,
options = { enrichRelationships: false } options = { enrichRelationships: false, formSchema: false }
) => { ) => {
const handler = { const handler = {
table: TableFetch, table: TableFetch,
@ -34,7 +34,17 @@ export const fetchDatasourceSchema = async (
// Get the datasource definition and then schema // Get the datasource definition and then schema
const definition = await instance.getDefinition(datasource) const definition = await instance.getDefinition(datasource)
let schema = instance.getSchema(datasource, definition)
// Get the normal schema as long as we aren't wanting a form schema
let schema
if (datasource?.type !== "query" || !options?.formSchema) {
schema = instance.getSchema(datasource, definition)
} else if (definition.parameters?.length) {
schema = {}
definition.parameters.forEach(param => {
schema[param.name] = { ...param, type: "string" }
})
}
if (!schema) { if (!schema) {
return null return null
} }