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 7d25c3da10
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 530 additions and 125 deletions

View File

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

View File

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

View File

@ -169,7 +169,12 @@ export const getComponentBindableProperties = (asset, componentId) => {
/**
* 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) {
return []
}
@ -177,7 +182,9 @@ export const getContextProviderComponents = (asset, componentId, type) => {
// Get the component tree leading up to this component, ignoring the component
// itself
const path = findComponentPath(asset.props, componentId)
path.pop()
if (!options?.includeSelf) {
path.pop()
}
// Filter by only data provider components
return path.filter(component => {
@ -798,6 +805,17 @@ export const buildFormSchema = component => {
if (!component) {
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 fieldSetting = settings.find(
setting => setting.key === "field" && setting.type.startsWith("field/")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,19 +13,29 @@
export let componentBindings
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 generalSettings = settings.filter(setting => !setting.section)
const customSections = settings.filter(setting => setting.section)
return [
let sections = [
{
name: "General",
settings: generalSettings,
},
...(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) => {
@ -36,7 +46,7 @@
}
}
const canRenderControl = (setting, isScreen) => {
const canRenderControl = (instance, setting, isScreen) => {
// Prevent rendering on click setting for screens
if (setting?.type === "event" && isScreen) {
return false
@ -51,6 +61,7 @@
if (setting.dependsOn) {
let dependantSetting = setting.dependsOn
let dependantValue = null
let invert = !!setting.dependsOn.invert
if (typeof setting.dependsOn === "object") {
dependantSetting = setting.dependsOn.setting
dependantValue = setting.dependsOn.value
@ -62,7 +73,7 @@
// If no specific value is depended upon, check if a value exists at all
// for the dependent setting
if (dependantValue == null) {
const currentValue = componentInstance[dependantSetting]
const currentValue = instance[dependantSetting]
if (currentValue === false) {
return false
}
@ -73,7 +84,11 @@
}
// Otherwise check the value matches
return componentInstance[dependantSetting] === dependantValue
if (invert) {
return instance[dependantSetting] !== dependantValue
} else {
return instance[dependantSetting] === dependantValue
}
}
return true
@ -81,52 +96,54 @@
</script>
{#each sections as section, idx (section.name)}
<DetailSummary name={section.name} collapsible={false}>
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen}
<PropertyControl
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)}
{#if section.visible}
<DetailSummary name={section.name} collapsible={false}>
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen}
<PropertyControl
type={setting.type}
control={getComponentForSetting(setting)}
label={setting.label}
key={setting.key}
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}
control={Input}
label="Name"
key="_instanceName"
value={componentInstance._instanceName}
onChange={val => updateSetting("_instanceName", val)}
/>
{/if}
{/each}
{#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")}
<ResetFieldsButton {componentInstance} />
{/if}
{#if idx === 0 && componentDefinition?.block}
<EjectBlockButton />
{/if}
</DetailSummary>
{#each section.settings as setting (setting.key)}
{#if setting.visible}
<PropertyControl
type={setting.type}
control={getComponentForSetting(setting)}
label={setting.label}
key={setting.key}
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}

View File

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

View File

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

View File

@ -3845,7 +3845,7 @@
"label": "Search Columns",
"key": "searchColumns",
"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",
@ -4010,7 +4010,7 @@
"label": "Search Columns",
"key": "searchColumns",
"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",
@ -4395,5 +4395,145 @@
"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 cardsblock } from "./CardsBlock.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
const fetchSchema = async dataSource => {
if (!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 || {}
}
schema = (await fetchDatasourceSchema(dataSource)) || {}
if (!loaded) {
loaded = true
}
@ -95,7 +66,7 @@
$: initialValues = getInitialValues(actionType, dataSource, $context)
$: resetKey = Helpers.hashString(
JSON.stringify(initialValues) + JSON.stringify(schema)
JSON.stringify(initialValues) + JSON.stringify(schema) + disabled
)
</script>

View File

@ -16,7 +16,7 @@ import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js"
*/
export const fetchDatasourceSchema = async (
datasource,
options = { enrichRelationships: false }
options = { enrichRelationships: false, formSchema: false }
) => {
const handler = {
table: TableFetch,
@ -34,7 +34,17 @@ export const fetchDatasourceSchema = async (
// Get the datasource definition and then schema
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) {
return null
}