Merge branch 'master' of github.com:budibase/budibase into budi-8417-check-error-responses-in-middleware-for-environment

This commit is contained in:
Sam Rose 2024-07-04 09:56:37 +01:00
commit ea1b0d4f5c
No known key found for this signature in database
27 changed files with 1461 additions and 730 deletions

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.29.11",
"version": "2.29.12",
"npmClient": "yarn",
"packages": [
"packages/*",

View File

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

View File

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

View File

@ -8,11 +8,63 @@
import { automationStore, selectedAutomation } from "stores/builder"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import { cloneDeep } from "lodash/fp"
import { memo } from "@budibase/frontend-core"
import { AutomationEventType } from "@budibase/types"
let failedParse = null
let trigger = {}
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
trigger = cloneDeep($selectedAutomation.definition.trigger)
@ -20,34 +72,45 @@
// get the outputs so we can define the fields
let schema = Object.entries(trigger.schema?.outputs?.properties || {})
if (trigger?.event === "app:trigger") {
if (trigger?.event === AutomationEventType.APP_TRIGGER) {
schema = [["fields", { customType: "fields" }]]
}
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
$: isError = !trigger.schema.outputs.required.every(
required => testData[required] || required !== "row"
)
$: isError =
!isTriggerValid(trigger) ||
!trigger.schema.outputs.required.every(
required => $memoTestData?.[required] || required !== "row"
)
function parseTestJSON(e) {
let jsonUpdate
try {
const obj = JSON.parse(e.detail)
jsonUpdate = JSON.parse(e.detail)
failedParse = null
automationStore.actions.addTestDataToAutomation(obj)
} catch (e) {
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 () => {
try {
await automationStore.actions.test($selectedAutomation, testData)
await automationStore.actions.test($selectedAutomation, $memoTestData)
$automationStore.showTestPanel = true
} catch (error) {
notifications.error(error)
@ -85,7 +148,7 @@
{#if selectedValues}
<div class="tab-content-padding">
<AutomationBlockSetup
{testData}
testData={$memoTestData}
{schemaProperties}
isTestModal
block={trigger}

View File

@ -1,19 +1,28 @@
<script>
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()
export let value
export let value = {}
export let bindings
export let block
export let isTestModal
let schemaFields
const { STRING, NUMBER, ARRAY } = FieldType
let schemaFields = []
let editableValue
$: editableValue = { ...value }
$: {
let fields = {}
for (const [key, type] of Object.entries(block?.inputs?.fields ?? {})) {
fields = {
...fields,
@ -25,8 +34,8 @@
},
}
if (value[key] === type) {
value[key] = INITIAL_VALUES[type.toUpperCase()]
if (editableValue[key] === type) {
editableValue[key] = INITIAL_VALUES[type.toUpperCase()]
}
}
@ -38,77 +47,58 @@
NUMBER: null,
DATETIME: null,
STRING: "",
OPTIONS: [],
ARRAY: [],
ARRAY: "",
}
const coerce = (value, type) => {
const re = new RegExp(/{{([^{].*?)}}/g)
if (re.test(value)) {
return value
const onChange = (e, field) => {
if (e.detail !== editableValue[field]) {
editableValue[field] = e.detail
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>
{#if schemaFields.length && isTestModal}
<div class="schema-fields">
{#if schemaFields?.length && isTestModal}
<div class="fields">
{#each schemaFields as [field, schema]}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
{bindings}
{value}
{onChange}
/>
<PropField label={field}>
{#if [STRING, NUMBER, ARRAY].includes(schema.type)}
<svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput}
panel={AutomationBindingPanel}
value={editableValue[field]}
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}
</div>
{/if}
<style>
.schema-fields {
display: grid;
grid-gap: var(--spacing-s);
margin-top: var(--spacing-s);
}
.schema-fields :global(label) {
text-transform: capitalize;
.fields {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
}
</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>
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 { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.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()
export let value
export let row
export let meta
export let bindings
export let isTestModal
export let isUpdateRow
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
return clone
const typeToField = Object.values(FIELDS).reduce((acc, field) => {
acc[field.type] = field
return acc
}, {})
const memoStore = memo({
row,
meta,
})
let table
// Row Schema Fields
let schemaFields
let attachmentTypes = [
FieldType.ATTACHMENTS,
@ -30,32 +45,123 @@
FieldType.SIGNATURE_SINGLE,
]
$: {
table = $tables.list.find(table => table._id === value?.tableId)
let customPopover
let popoverAnchor
let editableRow = {}
let editableFields = {}
// Just sorting attachment types to the bottom here for a cleaner UX
schemaFields = Object.entries(table?.schema ?? {}).sort(
([, schemaA], [, schemaB]) =>
(schemaA.type === "attachment") - (schemaB.type === "attachment")
)
// Avoid unnecessary updates
$: memoStore.set({
row,
meta,
})
schemaFields.forEach(([, schema]) => {
if (!schema.autocolumn && !value[schema.name]) {
value[schema.name] = ""
}
})
}
const onChangeTable = e => {
value["tableId"] = e.detail
dispatch("change", value)
}
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
return clone
})
const coerce = (value, type) => {
const re = new RegExp(/{{([^{].*?)}}/g)
if (re.test(value)) {
return value
$: tableId = $memoStore?.row?.tableId
$: initData(tableId, $memoStore?.meta?.fields, $memoStore?.row)
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 (typeof value === "number") {
return value
@ -66,6 +172,9 @@
return value
}
if (type === "array") {
if (!value) {
return []
}
if (Array.isArray(value)) {
return value
}
@ -73,7 +182,9 @@
}
if (type === "link") {
if (Array.isArray(value)) {
if (!value) {
return []
} else if (Array.isArray(value)) {
return value
}
return value.split(",").map(x => x.trim())
@ -86,130 +197,176 @@
return value
}
const onChange = (e, field, type) => {
let newValue = {
...value,
[field]: coerce(e.detail, type),
}
dispatch("change", newValue)
const isFullWidth = type => {
return (
attachmentTypes.includes(type) ||
type === FieldType.JSON ||
type === FieldType.LONGFORM
)
}
const onChangeSetting = (field, key, value) => {
let newField = {}
newField[field] = {
[key]: value,
const onChange = update => {
const customizer = (objValue, srcValue) => {
if (isPlainObject(objValue) && isPlainObject(srcValue)) {
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 = {
...meta?.fields,
...newField,
}
dispatch("change", {
key: "meta",
fields: updatedFields,
})
const result = mergeWith(
{},
{
row: editableRow,
meta: {
fields: editableFields,
},
},
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>
<div class="schema-fields">
<Label>Table</Label>
<div class="field-width">
<Select
on:change={onChangeTable}
value={value.tableId}
options={$tables.list.filter(table => table._id !== TableNames.USERS)}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
</div>
</div>
{#if schemaFields.length}
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn}
<div class:schema-fields={!attachmentTypes.includes(schema.type)}>
<Label>{field}</Label>
<div class:field-width={!attachmentTypes.includes(schema.type)}>
{#if isTestModal}
{#each schemaFields || [] as [field, schema]}
{#if !schema.autocolumn && Object.hasOwn(editableFields, field)}
<PropField label={field} fullWidth={isFullWidth(schema.type)}>
<div class="prop-control-wrap">
{#if isTestModal}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
value={editableRow}
meta={{
fields: editableFields,
}}
{onChange}
/>
{:else}
<DrawerBindableSlot
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
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
{value}
{onChange}
value={editableRow}
meta={{
fields: editableFields,
}}
onChange={change => onChange(change)}
/>
{:else}
<DrawerBindableSlot
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>
</DrawerBindableSlot>
{/if}
</div>
{/if}
{/each}
</PropField>
{/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}
<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>
.field-width {
width: 320px;
.table_field {
display: flex;
padding: var(--spacing-s) var(--spacing-l);
gap: var(--spacing-s);
}
.schema-fields {
display: flex;
justify-content: space-between;
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;
/* Override for general json field override */
.prop-control-wrap :global(.icon.json-slot-icon) {
right: 1px !important;
}
</style>

View File

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

View File

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

View File

@ -23,6 +23,7 @@
export let disableBindings = false
export let forceModal = false
export let context = null
export let autocomplete
const dispatch = createEventDispatcher()
@ -71,6 +72,7 @@
on:blur={onBlur}
{placeholder}
{updateOnChange}
{autocomplete}
/>
{#if !disabled && !disableBindings}
<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,
} from "@budibase/bbui"
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 { environment, licensing, auth } from "stores/portal"
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
let newBlock = {
...block,
@ -184,6 +185,14 @@ const automationActions = store => ({
// Don't save if no changes were made
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
}
await store.actions.save(newAutomation)

View File

@ -100,7 +100,10 @@ export function getError(err: 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(", ")
throw new Error(
`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)) {
if (Array.isArray(attachments)) {
if (!attachments) {
continue
} else if (Array.isArray(attachments)) {
if (attachments.length) {
row[prop] = await Promise.all(
attachments.map(attachment => generateAttachmentRow(attachment))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -209,10 +209,22 @@ describe("rowProcessor - inputProcessing", () => {
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(
bbReferenceProcessor.processInputBBReferences
).not.toHaveBeenCalled()
expect(row).toEqual(newRow)
}
)

View File

@ -255,6 +255,15 @@ export type BucketedContent = AutomationAttachmentContent & {
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 = {
row: Row
oldRow: Row