Merge pull request #14028 from Budibase/feature/automation-row-ux-update

Row Step Automation UX updates and Automation refactoring
This commit is contained in:
deanhannigan 2024-07-03 17:27:50 +01:00 committed by GitHub
commit 6246dcae46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1460 additions and 729 deletions

View File

@ -1,33 +1,25 @@
<script> <script>
import Tooltip from "./Tooltip.svelte"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import AbsTooltip from "./AbsTooltip.svelte"
export let tooltip = "" export let tooltip = ""
export let size = "M" export let size = "M"
export let disabled = true export let disabled = true
let showTooltip = false
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class:container={!!tooltip}> <div class:container={!!tooltip}>
<slot /> <slot />
{#if tooltip} {#if tooltip}
<div class="icon-container"> <div class="icon-container">
<div <AbsTooltip text={tooltip}>
class="icon" <div
class:icon-small={size === "M" || size === "S"} class="icon"
on:mouseover={() => (showTooltip = true)} class:icon-small={size === "M" || size === "S"}
on:mouseleave={() => (showTooltip = false)} on:focus
on:focus >
> <Icon name="InfoOutline" size="S" {disabled} hoverable />
<Icon name="InfoOutline" size="S" {disabled} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
</div> </div>
{/if} </AbsTooltip>
</div> </div>
{/if} {/if}
</div> </div>
@ -44,14 +36,6 @@
margin-left: 5px; margin-left: 5px;
margin-right: 5px; margin-right: 5px;
} }
.tooltip {
position: absolute;
display: flex;
justify-content: center;
top: 15px;
z-index: 200;
width: 160px;
}
.icon { .icon {
transform: scale(0.75); transform: scale(0.75);
} }

View File

@ -112,7 +112,7 @@
This action cannot be undone. This action cannot be undone.
</ConfirmDialog> </ConfirmDialog>
<Modal bind:this={testDataModal} width="30%"> <Modal bind:this={testDataModal} width="30%" zIndex={5}>
<TestDataModal /> <TestDataModal />
</Modal> </Modal>
@ -148,7 +148,6 @@
.header.scrolling { .header.scrolling {
background: var(--background); background: var(--background);
border-bottom: var(--border-light); border-bottom: var(--border-light);
border-left: var(--border-light);
z-index: 1; z-index: 1;
} }

View File

@ -8,11 +8,63 @@
import { automationStore, selectedAutomation } from "stores/builder" import { automationStore, selectedAutomation } from "stores/builder"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { memo } from "@budibase/frontend-core"
import { AutomationEventType } from "@budibase/types"
let failedParse = null let failedParse = null
let trigger = {} let trigger = {}
let schemaProperties = {} let schemaProperties = {}
const rowTriggers = [
AutomationEventType.ROW_DELETE,
AutomationEventType.ROW_UPDATE,
AutomationEventType.ROW_SAVE,
]
/**
* Parses the automation test data and ensures it is valid
* @param {object} testData contains all config for the test
* @returns {object} valid testData
* @todo Parse *all* data for each trigger type and relay adequate feedback
*/
const parseTestData = testData => {
const autoTrigger = $selectedAutomation?.definition?.trigger
const { tableId } = autoTrigger?.inputs || {}
// Ensure the tableId matches the trigger table for row trigger automations
if (
rowTriggers.includes(autoTrigger?.event) &&
testData?.row?.tableId !== tableId
) {
return {
// Reset Core fields
row: { tableId },
meta: {},
id: "",
revision: "",
}
} else {
// Leave the core data as it is
return testData
}
}
/**
* Before executing a test run, relay if an automation is in a valid state
* @param {object} trigger The automation trigger config
* @returns {boolean} validation status
* @todo Parse *all* trigger types relay adequate feedback
*/
const isTriggerValid = trigger => {
if (rowTriggers.includes(trigger?.event) && !trigger?.inputs?.tableId) {
return false
}
return true
}
const memoTestData = memo(parseTestData($selectedAutomation.testData))
$: memoTestData.set(parseTestData($selectedAutomation.testData))
$: { $: {
// clone the trigger so we're not mutating the reference // clone the trigger so we're not mutating the reference
trigger = cloneDeep($selectedAutomation.definition.trigger) trigger = cloneDeep($selectedAutomation.definition.trigger)
@ -20,34 +72,45 @@
// get the outputs so we can define the fields // get the outputs so we can define the fields
let schema = Object.entries(trigger.schema?.outputs?.properties || {}) let schema = Object.entries(trigger.schema?.outputs?.properties || {})
if (trigger?.event === "app:trigger") { if (trigger?.event === AutomationEventType.APP_TRIGGER) {
schema = [["fields", { customType: "fields" }]] schema = [["fields", { customType: "fields" }]]
} }
schemaProperties = schema schemaProperties = schema
} }
// check to see if there is existing test data in the store
$: testData = $selectedAutomation.testData || {}
// Check the schema to see if required fields have been entered // Check the schema to see if required fields have been entered
$: isError = !trigger.schema.outputs.required.every( $: isError =
required => testData[required] || required !== "row" !isTriggerValid(trigger) ||
) !trigger.schema.outputs.required.every(
required => $memoTestData?.[required] || required !== "row"
)
function parseTestJSON(e) { function parseTestJSON(e) {
let jsonUpdate
try { try {
const obj = JSON.parse(e.detail) jsonUpdate = JSON.parse(e.detail)
failedParse = null failedParse = null
automationStore.actions.addTestDataToAutomation(obj)
} catch (e) { } catch (e) {
failedParse = "Invalid JSON" failedParse = "Invalid JSON"
return false
} }
if (rowTriggers.includes(trigger?.event)) {
const tableId = trigger?.inputs?.tableId
// Reset the tableId as it must match the trigger
if (jsonUpdate?.row?.tableId !== tableId) {
jsonUpdate.row.tableId = tableId
}
}
automationStore.actions.addTestDataToAutomation(jsonUpdate)
} }
const testAutomation = async () => { const testAutomation = async () => {
try { try {
await automationStore.actions.test($selectedAutomation, testData) await automationStore.actions.test($selectedAutomation, $memoTestData)
$automationStore.showTestPanel = true $automationStore.showTestPanel = true
} catch (error) { } catch (error) {
notifications.error(error) notifications.error(error)
@ -85,7 +148,7 @@
{#if selectedValues} {#if selectedValues}
<div class="tab-content-padding"> <div class="tab-content-padding">
<AutomationBlockSetup <AutomationBlockSetup
{testData} testData={$memoTestData}
{schemaProperties} {schemaProperties}
isTestModal isTestModal
block={trigger} block={trigger}

View File

@ -1,19 +1,28 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import RowSelectorTypes from "./RowSelectorTypes.svelte" import PropField from "./PropField.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import { DatePicker, Select } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let value export let value = {}
export let bindings export let bindings
export let block export let block
export let isTestModal export let isTestModal
let schemaFields const { STRING, NUMBER, ARRAY } = FieldType
let schemaFields = []
let editableValue
$: editableValue = { ...value }
$: { $: {
let fields = {} let fields = {}
for (const [key, type] of Object.entries(block?.inputs?.fields ?? {})) { for (const [key, type] of Object.entries(block?.inputs?.fields ?? {})) {
fields = { fields = {
...fields, ...fields,
@ -25,8 +34,8 @@
}, },
} }
if (value[key] === type) { if (editableValue[key] === type) {
value[key] = INITIAL_VALUES[type.toUpperCase()] editableValue[key] = INITIAL_VALUES[type.toUpperCase()]
} }
} }
@ -38,77 +47,58 @@
NUMBER: null, NUMBER: null,
DATETIME: null, DATETIME: null,
STRING: "", STRING: "",
OPTIONS: [], ARRAY: "",
ARRAY: [],
} }
const coerce = (value, type) => { const onChange = (e, field) => {
const re = new RegExp(/{{([^{].*?)}}/g) if (e.detail !== editableValue[field]) {
if (re.test(value)) { editableValue[field] = e.detail
return value dispatch("change", editableValue)
} }
if (type === "boolean") {
if (typeof value === "boolean") {
return value
}
return value === "true"
}
if (type === "number") {
if (typeof value === "number") {
return value
}
return Number(value)
}
if (type === "options") {
return [value]
}
if (type === "array") {
if (Array.isArray(value)) {
return value
}
return value.split(",").map(x => x.trim())
}
if (type === "link") {
if (Array.isArray(value)) {
return value
}
return [value]
}
return value
}
const onChange = (e, field, type) => {
value[field] = coerce(e.detail, type)
dispatch("change", value)
} }
</script> </script>
{#if schemaFields.length && isTestModal} {#if schemaFields?.length && isTestModal}
<div class="schema-fields"> <div class="fields">
{#each schemaFields as [field, schema]} {#each schemaFields as [field, schema]}
<RowSelectorTypes <PropField label={field}>
{isTestModal} {#if [STRING, NUMBER, ARRAY].includes(schema.type)}
{field} <svelte:component
{schema} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
{bindings} panel={AutomationBindingPanel}
{value} value={editableValue[field]}
{onChange} on:change={e => onChange(e, field)}
/> type="string"
{bindings}
allowJS={true}
updateOnChange={false}
title={schema.name}
autocomplete="off"
/>
{:else if schema.type === "boolean"}
<Select
on:change={e => onChange(e, field)}
value={editableValue[field]}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
/>
{:else if schema.type === "datetime"}
<DatePicker
value={editableValue[field]}
on:change={e => onChange(e, field)}
/>
{/if}
</PropField>
{/each} {/each}
</div> </div>
{/if} {/if}
<style> <style>
.schema-fields { .fields {
display: grid; display: flex;
grid-gap: var(--spacing-s); flex-direction: column;
margin-top: var(--spacing-s); gap: var(--spacing-m);
}
.schema-fields :global(label) {
text-transform: capitalize;
} }
</style> </style>

View File

@ -0,0 +1,60 @@
<script>
import { Label } from "@budibase/bbui"
export let label
export let labelTooltip
export let fullWidth = false
export let componentWidth = 320
</script>
<div
class="prop-field"
class:fullWidth
style={`--comp-width: ${componentWidth}px;`}
>
<div class="prop-label" title={label}>
<Label tooltip={labelTooltip}>{label}</Label>
</div>
<div class="prop-control">
<slot />
</div>
</div>
<style>
.prop-field {
display: grid;
grid-template-columns: 1fr var(--comp-width);
}
.prop-field.fullWidth {
grid-template-columns: 1fr;
}
.prop-field.fullWidth .prop-label {
margin-bottom: var(--spacing-s);
}
.prop-label {
display: flex;
align-items: center;
overflow: hidden;
}
.prop-label :global(> div) {
width: 100%;
}
.prop-label :global(> div > label) {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.prop-control {
margin-left: var(--spacing-s);
}
.prop-field.fullWidth .prop-control {
margin-left: 0px;
}
</style>

View File

@ -1,28 +1,43 @@
<script> <script>
import { tables } from "stores/builder" import { tables } from "stores/builder"
import { Select, Checkbox, Label } from "@budibase/bbui" import {
ActionButton,
Popover,
Icon,
TooltipPosition,
TooltipType,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { FieldType } from "@budibase/types" import { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte" import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte" import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import { TableNames } from "constants" import { FIELDS } from "constants/backend"
import { capitalise } from "helpers"
import { memo } from "@budibase/frontend-core"
import PropField from "./PropField.svelte"
import { cloneDeep, isPlainObject, mergeWith } from "lodash"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let value
export let row
export let meta export let meta
export let bindings export let bindings
export let isTestModal export let isTestModal
export let isUpdateRow
$: parsedBindings = bindings.map(binding => { const typeToField = Object.values(FIELDS).reduce((acc, field) => {
let clone = Object.assign({}, binding) acc[field.type] = field
clone.icon = "ShareAndroid" return acc
return clone }, {})
const memoStore = memo({
row,
meta,
}) })
let table let table
// Row Schema Fields
let schemaFields let schemaFields
let attachmentTypes = [ let attachmentTypes = [
FieldType.ATTACHMENTS, FieldType.ATTACHMENTS,
@ -30,32 +45,123 @@
FieldType.SIGNATURE_SINGLE, FieldType.SIGNATURE_SINGLE,
] ]
$: { let customPopover
table = $tables.list.find(table => table._id === value?.tableId) let popoverAnchor
let editableRow = {}
let editableFields = {}
// Just sorting attachment types to the bottom here for a cleaner UX // Avoid unnecessary updates
schemaFields = Object.entries(table?.schema ?? {}).sort( $: memoStore.set({
([, schemaA], [, schemaB]) => row,
(schemaA.type === "attachment") - (schemaB.type === "attachment") meta,
) })
schemaFields.forEach(([, schema]) => { $: parsedBindings = bindings.map(binding => {
if (!schema.autocolumn && !value[schema.name]) { let clone = Object.assign({}, binding)
value[schema.name] = "" clone.icon = "ShareAndroid"
} return clone
}) })
}
const onChangeTable = e => {
value["tableId"] = e.detail
dispatch("change", value)
}
const coerce = (value, type) => { $: tableId = $memoStore?.row?.tableId
const re = new RegExp(/{{([^{].*?)}}/g)
if (re.test(value)) { $: initData(tableId, $memoStore?.meta?.fields, $memoStore?.row)
return value
const initData = (tableId, metaFields, row) => {
if (!tableId) {
return
} }
// Refesh the editable fields
editableFields = cloneDeep(metaFields || {})
// Refresh all the row data
editableRow = cloneDeep(row || {})
table = $tables.list.find(table => table._id === tableId)
if (table) {
editableRow["tableId"] = tableId
schemaFields = Object.entries(table?.schema ?? {})
.filter(entry => {
const [, field] = entry
return field.type !== "formula" && !field.autocolumn
})
.sort(([nameA], [nameB]) => {
return nameA < nameB ? -1 : 1
})
// Parse out any data not in the schema.
for (const column in editableFields) {
if (!Object.hasOwn(table?.schema, column)) {
delete editableFields[column]
}
}
}
// Go through the table schema and build out the editable content
for (const entry of schemaFields) {
const [key, fieldSchema] = entry
const emptyField =
editableRow[key] == null || editableRow[key]?.length === 0
// Put non-empty elements into the update and add their key to the fields list.
if (!emptyField && !Object.hasOwn(editableFields, key)) {
editableFields = {
...editableFields,
[key]: {},
}
}
// Legacy - clearRelationships
// Init the field and add it to the update.
if (emptyField) {
if (editableFields[key]?.clearRelationships === true) {
const emptyField = coerce(
!Object.hasOwn($memoStore?.row, key) ? "" : $memoStore?.row[key],
fieldSchema.type
)
// remove this and place the field in the editable row.
delete editableFields[key]?.clearRelationships
// Default the field
editableRow = {
...editableRow,
[key]: emptyField,
}
} else {
// Purge from the update as its presence is not necessary.
delete editableRow[key]
}
}
}
// Parse all known row schema keys
const schemaKeys = [
"tableId",
...schemaFields.map(entry => {
const [key] = entry
return key
}),
]
// Purge any row keys that are not present in the schema.
for (const rowKey of Object.keys(editableRow)) {
if (!schemaKeys.includes(rowKey)) {
delete editableRow[rowKey]
delete editableFields[rowKey]
}
}
}
// Row coerce
const coerce = (value, type) => {
const re = new RegExp(/{{([^{].*?)}}/g)
if (typeof value === "string" && re.test(value)) {
return value
}
if (type === "number") { if (type === "number") {
if (typeof value === "number") { if (typeof value === "number") {
return value return value
@ -66,6 +172,9 @@
return value return value
} }
if (type === "array") { if (type === "array") {
if (!value) {
return []
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
} }
@ -73,7 +182,9 @@
} }
if (type === "link") { if (type === "link") {
if (Array.isArray(value)) { if (!value) {
return []
} else if (Array.isArray(value)) {
return value return value
} }
return value.split(",").map(x => x.trim()) return value.split(",").map(x => x.trim())
@ -86,130 +197,176 @@
return value return value
} }
const onChange = (e, field, type) => { const isFullWidth = type => {
let newValue = { return (
...value, attachmentTypes.includes(type) ||
[field]: coerce(e.detail, type), type === FieldType.JSON ||
} type === FieldType.LONGFORM
dispatch("change", newValue) )
} }
const onChangeSetting = (field, key, value) => { const onChange = update => {
let newField = {} const customizer = (objValue, srcValue) => {
newField[field] = { if (isPlainObject(objValue) && isPlainObject(srcValue)) {
[key]: value, const result = mergeWith({}, objValue, srcValue, customizer)
let outcome = Object.keys(result).reduce((acc, key) => {
if (result[key] !== null) {
acc[key] = result[key]
}
return acc
}, {})
return outcome
}
return srcValue
} }
let updatedFields = { const result = mergeWith(
...meta?.fields, {},
...newField, {
} row: editableRow,
meta: {
dispatch("change", { fields: editableFields,
key: "meta", },
fields: updatedFields, },
}) update,
customizer
)
dispatch("change", result)
} }
// Ensure any nullish tableId values get set to empty string so
// that the select works
$: if (value?.tableId == null) value = { tableId: "" }
</script> </script>
<div class="schema-fields"> {#each schemaFields || [] as [field, schema]}
<Label>Table</Label> {#if !schema.autocolumn && Object.hasOwn(editableFields, field)}
<div class="field-width"> <PropField label={field} fullWidth={isFullWidth(schema.type)}>
<Select <div class="prop-control-wrap">
on:change={onChangeTable} {#if isTestModal}
value={value.tableId} <RowSelectorTypes
options={$tables.list.filter(table => table._id !== TableNames.USERS)} {isTestModal}
getOptionLabel={table => table.name} {field}
getOptionValue={table => table._id} {schema}
/> bindings={parsedBindings}
</div> value={editableRow}
</div> meta={{
{#if schemaFields.length} fields: editableFields,
{#each schemaFields as [field, schema]} }}
{#if !schema.autocolumn} {onChange}
<div class:schema-fields={!attachmentTypes.includes(schema.type)}> />
<Label>{field}</Label> {:else}
<div class:field-width={!attachmentTypes.includes(schema.type)}> <DrawerBindableSlot
{#if isTestModal} title={$memoStore?.row?.title || field}
panel={AutomationBindingPanel}
type={schema.type}
{schema}
value={editableRow[field]}
on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
>
<RowSelectorTypes <RowSelectorTypes
{isTestModal} {isTestModal}
{field} {field}
{schema} {schema}
bindings={parsedBindings} bindings={parsedBindings}
{value} value={editableRow}
{onChange} meta={{
fields: editableFields,
}}
onChange={change => onChange(change)}
/> />
{:else} </DrawerBindableSlot>
<DrawerBindableSlot {/if}
title={value.title || field}
panel={AutomationBindingPanel}
type={schema.type}
{schema}
value={value[field]}
on:change={e => onChange(e, field)}
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
>
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
{value}
{onChange}
useAttachmentBinding={meta?.fields?.[field]
?.useAttachmentBinding}
{onChangeSetting}
/>
</DrawerBindableSlot>
{/if}
{#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field">
<Checkbox
value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"}
size={"S"}
on:change={e =>
onChangeSetting(field, "clearRelationships", e.detail)}
/>
</div>
{/if}
</div>
</div> </div>
{/if} </PropField>
{/each} {/if}
{/each}
{#if table && schemaFields}
{#key editableFields}
<div
class="add-fields-btn"
class:empty={Object.is(editableFields, {})}
bind:this={popoverAnchor}
>
<ActionButton
icon="Add"
fullWidth
on:click={() => {
customPopover.show()
}}
disabled={!schemaFields}
>Add fields
</ActionButton>
</div>
{/key}
{/if} {/if}
<Popover
align="center"
bind:this={customPopover}
anchor={popoverAnchor}
useAnchorWidth
maxHeight={300}
resizable={false}
offset={10}
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<ul class="spectrum-Menu" role="listbox">
{#each schemaFields || [] as [field, schema]}
{#if !schema.autocolumn}
<li
class="table_field spectrum-Menu-item"
class:is-selected={Object.hasOwn(editableFields, field)}
on:click={() => {
if (Object.hasOwn(editableFields, field)) {
delete editableFields[field]
onChange({
meta: { fields: editableFields },
row: { [field]: null },
})
} else {
editableFields[field] = {}
onChange({ meta: { fields: editableFields } })
}
}}
>
<Icon
name={typeToField?.[schema.type]?.icon}
color={"var(--spectrum-global-color-gray-600)"}
tooltip={capitalise(schema.type)}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Left}
/>
<div class="field_name spectrum-Menu-itemLabel">{field}</div>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/if}
{/each}
</ul>
</Popover>
<style> <style>
.field-width { .table_field {
width: 320px; display: flex;
padding: var(--spacing-s) var(--spacing-l);
gap: var(--spacing-s);
} }
.schema-fields { /* Override for general json field override */
display: flex; .prop-control-wrap :global(.icon.json-slot-icon) {
justify-content: space-between; right: 1px !important;
align-items: center;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
margin-bottom: 10px;
}
.schema-fields :global(label) {
text-transform: capitalize;
}
.checkbox-field {
padding-bottom: var(--spacing-s);
padding-left: 1px;
padding-top: var(--spacing-s);
}
.checkbox-field :global(label) {
text-transform: none;
} }
</style> </style>

View File

@ -11,17 +11,18 @@
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import Editor from "components/integration/QueryEditor.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let onChange export let onChange
export let field export let field
export let schema export let schema
export let value export let value
export let meta
export let bindings export let bindings
export let isTestModal export let isTestModal
export let useAttachmentBinding
export let onChangeSetting $: fieldData = value[field]
$: parsedBindings = bindings.map(binding => { $: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding) let clone = Object.assign({}, binding)
@ -35,14 +36,15 @@
FieldType.SIGNATURE_SINGLE, FieldType.SIGNATURE_SINGLE,
] ]
let previousBindingState = useAttachmentBinding
function schemaHasOptions(schema) { function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length return !!schema.constraints?.inclusion?.length
} }
function handleAttachmentParams(keyValueObj) { function handleAttachmentParams(keyValueObj) {
let params = {} let params = {}
if (!keyValueObj) {
return null
}
if (!Array.isArray(keyValueObj) && keyValueObj) { if (!Array.isArray(keyValueObj) && keyValueObj) {
keyValueObj = [keyValueObj] keyValueObj = [keyValueObj]
@ -50,45 +52,68 @@
if (keyValueObj.length) { if (keyValueObj.length) {
for (let param of keyValueObj) { for (let param of keyValueObj) {
params[param.url] = param.filename params[param.url || ""] = param.filename || ""
} }
} }
return params return params
} }
async function handleToggleChange(toggleField, event) { const handleMediaUpdate = e => {
if (event.detail === true) { const media = e.detail || []
value[toggleField] = [] const isSingle =
} else { schema.type === FieldType.ATTACHMENT_SINGLE ||
value[toggleField] = "" schema.type === FieldType.SIGNATURE_SINGLE
} const parsedMedia = media.map(({ name, value }) => ({
previousBindingState = event.detail url: name,
onChangeSetting(toggleField, "useAttachmentBinding", event.detail) filename: value,
onChange({ detail: value[toggleField] }, toggleField) }))
}
$: if (useAttachmentBinding !== previousBindingState) { if (isSingle) {
if (useAttachmentBinding) { const [singleMedia] = parsedMedia
value[field] = [] // Return only the first entry
} else { return singleMedia
value[field] = "" ? {
url: singleMedia.url,
filename: singleMedia.filename,
}
: null
} }
previousBindingState = useAttachmentBinding
// Return the entire array
return parsedMedia
} }
</script> </script>
{#if schemaHasOptions(schema) && schema.type !== "array"} {#if schemaHasOptions(schema) && schema.type !== "array"}
<Select <Select
on:change={e => onChange(e, field)} on:change={e =>
value={value[field]} onChange({
row: {
[field]: e.detail,
},
})}
value={fieldData}
options={schema.constraints.inclusion} options={schema.constraints.inclusion}
/> />
{:else if schema.type === "datetime"} {:else if schema.type === "datetime"}
<DatePicker value={value[field]} on:change={e => onChange(e, field)} /> <DatePicker
value={fieldData}
on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
/>
{:else if schema.type === "boolean"} {:else if schema.type === "boolean"}
<Select <Select
on:change={e => onChange(e, field)} on:change={e =>
value={value[field]} onChange({
row: {
[field]: e.detail,
},
})}
value={fieldData}
options={[ options={[
{ label: "True", value: "true" }, { label: "True", value: "true" },
{ label: "False", value: "false" }, { label: "False", value: "false" },
@ -96,83 +121,111 @@
/> />
{:else if schemaHasOptions(schema) && schema.type === "array"} {:else if schemaHasOptions(schema) && schema.type === "array"}
<Multiselect <Multiselect
bind:value={value[field]} value={fieldData}
options={schema.constraints.inclusion} options={schema.constraints.inclusion}
on:change={e => onChange(e, field)} on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
/> />
{:else if schema.type === "longform"} {:else if schema.type === "longform"}
<TextArea bind:value={value[field]} on:change={e => onChange(e, field)} /> <TextArea
value={fieldData}
on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
/>
{:else if schema.type === "json"} {:else if schema.type === "json"}
<span> <span>
<Editor <div class="field-wrap json-field">
editorHeight="150" <CodeEditor
mode="json" value={fieldData}
on:change={e => { on:change={e => {
if (e.detail?.value !== value[field]) { onChange({
onChange(e, field, schema.type) row: {
} [field]: e.detail,
}} },
value={value[field]} })
/> }}
/>
</div>
</span> </span>
{:else if schema.type === "link"} {:else if schema.type === "link"}
<LinkedRowSelector <LinkedRowSelector
linkedRows={value[field]} linkedRows={fieldData}
{schema} {schema}
on:change={e => onChange(e, field)} on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
useLabel={false} useLabel={false}
/> />
{:else if schema.type === "bb_reference" || schema.type === "bb_reference_single"} {:else if schema.type === "bb_reference" || schema.type === "bb_reference_single"}
<LinkedRowSelector <LinkedRowSelector
linkedRows={value[field]} linkedRows={fieldData}
{schema} {schema}
linkedTableId={"ta_users"} linkedTableId={"ta_users"}
on:change={e => onChange(e, field)} on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
useLabel={false} useLabel={false}
/> />
{:else if attachmentTypes.includes(schema.type)} {:else if attachmentTypes.includes(schema.type)}
<div class="attachment-field-container"> <div class="attachment-field-container">
<div class="toggle-container"> <div class="toggle-container">
<Toggle <Toggle
value={useAttachmentBinding} value={meta?.fields?.[field]?.useAttachmentBinding}
text={"Use bindings"} text={"Use bindings"}
size={"XS"} size={"XS"}
on:change={e => handleToggleChange(field, e)} on:change={e => {
onChange({
row: {
[field]: null,
},
meta: {
fields: {
[field]: {
useAttachmentBinding: e.detail,
},
},
},
})
}}
/> />
</div> </div>
{#if !useAttachmentBinding}
{#if !meta?.fields?.[field]?.useAttachmentBinding}
<div class="attachment-field-spacing"> <div class="attachment-field-spacing">
<KeyValueBuilder <KeyValueBuilder
on:change={async e => { on:change={e => {
onChange( onChange({
{ row: {
detail: [field]: handleMediaUpdate(e),
schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE
? e.detail.length > 0
? {
url: e.detail[0].name,
filename: e.detail[0].value,
}
: {}
: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
}, },
field })
)
}} }}
object={handleAttachmentParams(value[field])} object={handleAttachmentParams(fieldData)}
allowJS allowJS
{bindings} {bindings}
keyBindings keyBindings
customButtonText={"Add attachment"} customButtonText={schema.type === FieldType.SIGNATURE_SINGLE
? "Add signature"
: "Add attachment"}
keyPlaceholder={"URL"} keyPlaceholder={"URL"}
valuePlaceholder={"Filename"} valuePlaceholder={"Filename"}
actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE || actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE) && schema.type === FieldType.SIGNATURE_SINGLE) &&
Object.keys(value[field]).length >= 1} fieldData}
/> />
</div> </div>
{:else} {:else}
@ -180,8 +233,13 @@
<svelte:component <svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
value={value[field]} value={fieldData}
on:change={e => onChange(e, field)} on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
type="string" type="string"
bindings={parsedBindings} bindings={parsedBindings}
allowJS={true} allowJS={true}
@ -195,20 +253,41 @@
<svelte:component <svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
value={value[field]} value={fieldData}
on:change={e => onChange(e, field)} on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
type="string" type="string"
bindings={parsedBindings} bindings={parsedBindings}
allowJS={true} allowJS={true}
updateOnChange={false} updateOnChange={false}
title={schema.name} title={schema.name}
autocomplete="off"
/> />
{/if} {/if}
<style> <style>
.attachment-field-spacing, .attachment-field-spacing {
.json-input-spacing { border: 1px solid var(--spectrum-global-color-gray-400);
margin-top: var(--spacing-s); border-radius: 4px;
margin-bottom: var(--spacing-l); padding: var(--spacing-s);
}
.field-wrap.json-field {
height: 120px;
}
.field-wrap {
box-sizing: border-box;
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
}
.field-wrap :global(.cm-editor),
.field-wrap :global(.cm-scroller) {
border-radius: 4px;
} }
</style> </style>

View File

@ -8,6 +8,7 @@
export let value export let value
export let isTrigger export let isTrigger
export let disabled = false
$: filteredTables = $tables.list.filter(table => { $: filteredTables = $tables.list.filter(table => {
return !isTrigger || table._id !== TableNames.USERS return !isTrigger || table._id !== TableNames.USERS
@ -25,4 +26,5 @@
options={filteredTables} options={filteredTables}
getOptionLabel={table => table.name} getOptionLabel={table => table.name}
getOptionValue={table => table._id} getOptionValue={table => table._id}
{disabled}
/> />

View File

@ -23,6 +23,7 @@
export let disableBindings = false export let disableBindings = false
export let forceModal = false export let forceModal = false
export let context = null export let context = null
export let autocomplete
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -71,6 +72,7 @@
on:blur={onBlur} on:blur={onBlur}
{placeholder} {placeholder}
{updateOnChange} {updateOnChange}
{autocomplete}
/> />
{#if !disabled && !disableBindings} {#if !disabled && !disableBindings}
<div <div

View File

@ -0,0 +1,12 @@
export { default as BindableCombobox } from "./BindableCombobox.svelte"
export { default as BindingPanel } from "./BindingPanel.svelte"
export { default as BindingSidePanel } from "./BindingSidePanel.svelte"
export { default as DrawerBindableCombobox } from "./DrawerBindableCombobox.svelte"
export { default as ClientBindingPanel } from "./ClientBindingPanel.svelte"
export { default as DrawerBindableInput } from "./DrawerBindableInput.svelte"
export { default as DrawerBindableSlot } from "./DrawerBindableSlot.svelte"
export { default as EvaluationSidePanel } from "./EvaluationSidePanel.svelte"
export { default as ModalBindableInput } from "./ModalBindableInput.svelte"
export { default as ServerBindingPanel } from "./ServerBindingPanel.svelte"
export { default as SnippetDrawer } from "./SnippetDrawer.svelte"
export { default as SnippetSidePanel } from "./SnippetSidePanel.svelte"

View File

@ -11,7 +11,7 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AUTH_TYPE_LABELS, AUTH_TYPES } from "./authTypes" import { AUTH_TYPE_LABELS, AUTH_TYPES } from "./authTypes"
import BindableCombobox from "components/common/bindings/BindableCombobox.svelte" import { BindableCombobox } from "components/common/bindings"
import { getAuthBindings, getEnvironmentBindings } from "dataBinding" import { getAuthBindings, getEnvironmentBindings } from "dataBinding"
import { environment, licensing, auth } from "stores/portal" import { environment, licensing, auth } from "stores/portal"
import CreateEditVariableModal from "components/portal/environment/CreateEditVariableModal.svelte" import CreateEditVariableModal from "components/portal/environment/CreateEditVariableModal.svelte"

View File

@ -157,7 +157,8 @@ const automationActions = store => ({
) )
} }
}, },
updateBlockInputs: async (block, data) => {
processBlockInputs: async (block, data) => {
// Create new modified block // Create new modified block
let newBlock = { let newBlock = {
...block, ...block,
@ -184,6 +185,14 @@ const automationActions = store => ({
// Don't save if no changes were made // Don't save if no changes were made
if (JSON.stringify(newAutomation) === JSON.stringify(automation)) { if (JSON.stringify(newAutomation) === JSON.stringify(automation)) {
return false
}
return newAutomation
},
updateBlockInputs: async (block, data) => {
const newAutomation = await store.actions.processBlockInputs(block, data)
if (newAutomation === false) {
return return
} }
await store.actions.save(newAutomation) await store.actions.save(newAutomation)

View File

@ -100,7 +100,10 @@ export function getError(err: any) {
} }
export function guardAttachment(attachmentObject: any) { export function guardAttachment(attachmentObject: any) {
if (!("url" in attachmentObject) || !("filename" in attachmentObject)) { if (
attachmentObject &&
(!("url" in attachmentObject) || !("filename" in attachmentObject))
) {
const providedKeys = Object.keys(attachmentObject).join(", ") const providedKeys = Object.keys(attachmentObject).join(", ")
throw new Error( throw new Error(
`Attachments must have both "url" and "filename" keys. You have provided: ${providedKeys}` `Attachments must have both "url" and "filename" keys. You have provided: ${providedKeys}`
@ -135,7 +138,9 @@ export async function sendAutomationAttachmentsToStorage(
} }
for (const [prop, attachments] of Object.entries(attachmentRows)) { for (const [prop, attachments] of Object.entries(attachmentRows)) {
if (Array.isArray(attachments)) { if (!attachments) {
continue
} else if (Array.isArray(attachments)) {
if (attachments.length) { if (attachments.length) {
row[prop] = await Promise.all( row[prop] = await Promise.all(
attachments.map(attachment => generateAttachmentRow(attachment)) attachments.map(attachment => generateAttachmentRow(attachment))

View File

@ -82,39 +82,73 @@ export async function run({ inputs, appId, emitter }: AutomationStepInput) {
} }
const tableId = inputs.row.tableId const tableId = inputs.row.tableId
// clear any undefined, null or empty string properties so that they aren't updated // Base update
for (let propKey of Object.keys(inputs.row)) { let rowUpdate: Record<string, any>
const clearRelationships =
inputs.meta?.fields?.[propKey]?.clearRelationships // Legacy
if ( // Find previously set values and add them to the update. Ensure empty relationships
(inputs.row[propKey] == null || inputs.row[propKey]?.length === 0) && // are added to the update if clearRelationships is true
!clearRelationships const legacyUpdated = Object.keys(inputs.row || {}).reduce(
) { (acc: Record<string, any>, key: string) => {
delete inputs.row[propKey] const isEmpty = inputs.row[key] == null || inputs.row[key]?.length === 0
} const fieldConfig = inputs.meta?.fields || {}
if (isEmpty) {
if (
Object.hasOwn(fieldConfig, key) &&
fieldConfig[key].clearRelationships === true
) {
// Explicitly clear the field on update
acc[key] = []
}
} else {
// Keep non-empty values
acc[key] = inputs.row[key]
}
return acc
},
{}
)
// The source of truth for inclusion in the update is: inputs.meta?.fields
const parsedUpdate = Object.keys(inputs.meta?.fields || {}).reduce(
(acc: Record<string, any>, key: string) => {
const fieldConfig = inputs.meta?.fields?.[key] || {}
// Ignore legacy config.
if (Object.hasOwn(fieldConfig, "clearRelationships")) {
return acc
}
acc[key] =
!inputs.row[key] || inputs.row[key]?.length === 0 ? "" : inputs.row[key]
return acc
},
{}
)
rowUpdate = {
tableId,
...parsedUpdate,
...legacyUpdated,
} }
try { try {
if (tableId) { if (tableId) {
inputs.row = await automationUtils.cleanUpRow( rowUpdate = await automationUtils.cleanUpRow(tableId, rowUpdate)
inputs.row.tableId,
inputs.row
)
inputs.row = await automationUtils.sendAutomationAttachmentsToStorage( rowUpdate = await automationUtils.sendAutomationAttachmentsToStorage(
inputs.row.tableId, tableId,
inputs.row rowUpdate
) )
} }
// have to clean up the row, remove the table from it // have to clean up the row, remove the table from it
const ctx: any = buildCtx(appId, emitter, { const ctx: any = buildCtx(appId, emitter, {
body: { body: {
...inputs.row, ...rowUpdate,
_id: inputs.rowId, _id: inputs.rowId,
}, },
params: { params: {
rowId: inputs.rowId, rowId: inputs.rowId,
tableId: tableId, tableId,
}, },
}) })
await rowController.patch(ctx) await rowController.patch(ctx)

View File

@ -4,11 +4,12 @@ import {
AutomationStepType, AutomationStepType,
AutomationTriggerSchema, AutomationTriggerSchema,
AutomationTriggerStepId, AutomationTriggerStepId,
AutomationEventType,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationTriggerSchema = { export const definition: AutomationTriggerSchema = {
name: "App Action", name: "App Action",
event: "app:trigger", event: AutomationEventType.APP_TRIGGER,
icon: "Apps", icon: "Apps",
tagline: "Automation fired from the frontend", tagline: "Automation fired from the frontend",
description: "Trigger an automation from an action inside your app", description: "Trigger an automation from an action inside your app",

View File

@ -4,11 +4,12 @@ import {
AutomationStepType, AutomationStepType,
AutomationTriggerSchema, AutomationTriggerSchema,
AutomationTriggerStepId, AutomationTriggerStepId,
AutomationEventType,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationTriggerSchema = { export const definition: AutomationTriggerSchema = {
name: "Cron Trigger", name: "Cron Trigger",
event: "cron:trigger", event: AutomationEventType.CRON_TRIGGER,
icon: "Clock", icon: "Clock",
tagline: "Cron Trigger (<b>{{inputs.cron}}</b>)", tagline: "Cron Trigger (<b>{{inputs.cron}}</b>)",
description: "Triggers automation on a cron schedule.", description: "Triggers automation on a cron schedule.",

View File

@ -4,11 +4,12 @@ import {
AutomationStepType, AutomationStepType,
AutomationTriggerSchema, AutomationTriggerSchema,
AutomationTriggerStepId, AutomationTriggerStepId,
AutomationEventType,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationTriggerSchema = { export const definition: AutomationTriggerSchema = {
name: "Row Deleted", name: "Row Deleted",
event: "row:delete", event: AutomationEventType.ROW_DELETE,
icon: "TableRowRemoveCenter", icon: "TableRowRemoveCenter",
tagline: "Row is deleted from {{inputs.enriched.table.name}}", tagline: "Row is deleted from {{inputs.enriched.table.name}}",
description: "Fired when a row is deleted from your database", description: "Fired when a row is deleted from your database",

View File

@ -4,11 +4,12 @@ import {
AutomationStepType, AutomationStepType,
AutomationTriggerSchema, AutomationTriggerSchema,
AutomationTriggerStepId, AutomationTriggerStepId,
AutomationEventType,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationTriggerSchema = { export const definition: AutomationTriggerSchema = {
name: "Row Created", name: "Row Created",
event: "row:save", event: AutomationEventType.ROW_SAVE,
icon: "TableRowAddBottom", icon: "TableRowAddBottom",
tagline: "Row is added to {{inputs.enriched.table.name}}", tagline: "Row is added to {{inputs.enriched.table.name}}",
description: "Fired when a row is added to your database", description: "Fired when a row is added to your database",

View File

@ -4,11 +4,12 @@ import {
AutomationStepType, AutomationStepType,
AutomationTriggerSchema, AutomationTriggerSchema,
AutomationTriggerStepId, AutomationTriggerStepId,
AutomationEventType,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationTriggerSchema = { export const definition: AutomationTriggerSchema = {
name: "Row Updated", name: "Row Updated",
event: "row:update", event: AutomationEventType.ROW_UPDATE,
icon: "Refresh", icon: "Refresh",
tagline: "Row is updated in {{inputs.enriched.table.name}}", tagline: "Row is updated in {{inputs.enriched.table.name}}",
description: "Fired when a row is updated in your database", description: "Fired when a row is updated in your database",

View File

@ -4,11 +4,12 @@ import {
AutomationStepType, AutomationStepType,
AutomationTriggerSchema, AutomationTriggerSchema,
AutomationTriggerStepId, AutomationTriggerStepId,
AutomationEventType,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationTriggerSchema = { export const definition: AutomationTriggerSchema = {
name: "Webhook", name: "Webhook",
event: "web:trigger", event: AutomationEventType.WEBHOOK_TRIGGER,
icon: "Send", icon: "Send",
tagline: "Webhook endpoint is hit", tagline: "Webhook endpoint is hit",
description: "Trigger an automation when a HTTP POST webhook is hit", description: "Trigger an automation when a HTTP POST webhook is hit",

View File

@ -13,6 +13,7 @@ import {
Row, Row,
AutomationData, AutomationData,
AutomationJob, AutomationJob,
AutomationEventType,
UpdatedRowEventEmitter, UpdatedRowEventEmitter,
} from "@budibase/types" } from "@budibase/types"
import { executeInThread } from "../threads/automation" import { executeInThread } from "../threads/automation"
@ -71,28 +72,31 @@ async function queueRelevantRowAutomations(
}) })
} }
emitter.on("row:save", async function (event: UpdatedRowEventEmitter) { emitter.on(
AutomationEventType.ROW_SAVE,
async function (event: UpdatedRowEventEmitter) {
/* istanbul ignore next */
if (!event || !event.row || !event.row.tableId) {
return
}
await queueRelevantRowAutomations(event, AutomationEventType.ROW_SAVE)
}
)
emitter.on(AutomationEventType.ROW_UPDATE, async function (event) {
/* istanbul ignore next */ /* istanbul ignore next */
if (!event || !event.row || !event.row.tableId) { if (!event || !event.row || !event.row.tableId) {
return return
} }
await queueRelevantRowAutomations(event, "row:save") await queueRelevantRowAutomations(event, AutomationEventType.ROW_UPDATE)
}) })
emitter.on("row:update", async function (event) { emitter.on(AutomationEventType.ROW_DELETE, async function (event) {
/* istanbul ignore next */ /* istanbul ignore next */
if (!event || !event.row || !event.row.tableId) { if (!event || !event.row || !event.row.tableId) {
return return
} }
await queueRelevantRowAutomations(event, "row:update") await queueRelevantRowAutomations(event, AutomationEventType.ROW_DELETE)
})
emitter.on("row:delete", async function (event) {
/* istanbul ignore next */
if (!event || !event.row || !event.row.tableId) {
return
}
await queueRelevantRowAutomations(event, "row:delete")
}) })
export async function externalTrigger( export async function externalTrigger(
@ -118,7 +122,6 @@ export async function externalTrigger(
} }
params.fields = coercedFields params.fields = coercedFields
} }
const data: AutomationData = { automation, event: params as any } const data: AutomationData = { automation, event: params as any }
if (getResponses) { if (getResponses) {
data.event = { data.event = {

View File

@ -24,6 +24,7 @@ import {
Query, Query,
Webhook, Webhook,
WebhookActionType, WebhookActionType,
AutomationEventType,
} from "@budibase/types" } from "@budibase/types"
import { LoopInput, LoopStepType } from "../../definitions/automations" import { LoopInput, LoopStepType } from "../../definitions/automations"
import { merge } from "lodash" import { merge } from "lodash"
@ -305,7 +306,7 @@ export function loopAutomation(
trigger: { trigger: {
id: "a", id: "a",
type: "TRIGGER", type: "TRIGGER",
event: "row:save", event: AutomationEventType.ROW_SAVE,
stepId: AutomationTriggerStepId.ROW_SAVED, stepId: AutomationTriggerStepId.ROW_SAVED,
inputs: { inputs: {
tableId, tableId,
@ -347,7 +348,7 @@ export function collectAutomation(tableId?: string): Automation {
trigger: { trigger: {
id: "a", id: "a",
type: "TRIGGER", type: "TRIGGER",
event: "row:save", event: AutomationEventType.ROW_SAVE,
stepId: AutomationTriggerStepId.ROW_SAVED, stepId: AutomationTriggerStepId.ROW_SAVED,
inputs: { inputs: {
tableId, tableId,

View File

@ -50,6 +50,13 @@ export const TYPE_TRANSFORM_MAP: any = {
[undefined]: undefined, [undefined]: undefined,
parse: parseArrayString, parse: parseArrayString,
}, },
[FieldType.BB_REFERENCE]: {
//@ts-ignore
[null]: [],
//@ts-ignore
[undefined]: undefined,
parse: parseArrayString,
},
[FieldType.STRING]: { [FieldType.STRING]: {
"": null, "": null,
//@ts-ignore //@ts-ignore
@ -113,6 +120,9 @@ export const TYPE_TRANSFORM_MAP: any = {
[undefined]: undefined, [undefined]: undefined,
parse: parseArrayString, parse: parseArrayString,
}, },
[FieldType.ATTACHMENT_SINGLE]: {
"": null,
},
[FieldType.BOOLEAN]: { [FieldType.BOOLEAN]: {
"": null, "": null,
//@ts-ignore //@ts-ignore

View File

@ -209,10 +209,22 @@ describe("rowProcessor - inputProcessing", () => {
const { row } = await inputProcessing(userId, table, newRow) const { row } = await inputProcessing(userId, table, newRow)
if (userValue === undefined) {
// The 'user' field is omitted
expect(row).toEqual({
name: "Jack",
})
} else {
// The update is processed if null or "". 'user' is changed to an empty array.
expect(row).toEqual({
name: "Jack",
user: [],
})
}
expect( expect(
bbReferenceProcessor.processInputBBReferences bbReferenceProcessor.processInputBBReferences
).not.toHaveBeenCalled() ).not.toHaveBeenCalled()
expect(row).toEqual(newRow)
} }
) )

View File

@ -255,6 +255,15 @@ export type BucketedContent = AutomationAttachmentContent & {
path: string path: string
} }
export enum AutomationEventType {
ROW_SAVE = "row:save",
ROW_UPDATE = "row:update",
ROW_DELETE = "row:delete",
APP_TRIGGER = "app:trigger",
CRON_TRIGGER = "cron:trigger",
WEBHOOK_TRIGGER = "web:trigger",
}
export type UpdatedRowEventEmitter = { export type UpdatedRowEventEmitter = {
row: Row row: Row
oldRow: Row oldRow: Row