Merge branch 'master' into fix/couchdb-integration

This commit is contained in:
Michael Drury 2024-07-03 17:36:16 +01:00 committed by GitHub
commit 4933658b98
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 1815 additions and 868 deletions

View File

@ -214,6 +214,7 @@ jobs:
echo "pro_commit=$pro_commit" echo "pro_commit=$pro_commit"
echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT" echo "pro_commit=$pro_commit" >> "$GITHUB_OUTPUT"
echo "base_commit=$base_commit" echo "base_commit=$base_commit"
echo "base_commit=$base_commit" >> "$GITHUB_OUTPUT"
base_commit_excluding_merges=$(git log --no-merges -n 1 --format=format:%H $base_commit) base_commit_excluding_merges=$(git log --no-merges -n 1 --format=format:%H $base_commit)
echo "base_commit_excluding_merges=$base_commit_excluding_merges" echo "base_commit_excluding_merges=$base_commit_excluding_merges"
@ -230,7 +231,7 @@ jobs:
base_commit_excluding_merges='${{ steps.get_pro_commits.outputs.base_commit_excluding_merges }}' base_commit_excluding_merges='${{ steps.get_pro_commits.outputs.base_commit_excluding_merges }}'
pro_commit='${{ steps.get_pro_commits.outputs.pro_commit }}' pro_commit='${{ steps.get_pro_commits.outputs.pro_commit }}'
any_commit=$(git log --no-merges $base_commit...$pro_commit) any_commit=$(git log --no-merges $base_commit_excluding_merges...$pro_commit)
if [ -n "$any_commit" ]; then if [ -n "$any_commit" ]; then
echo $any_commit echo $any_commit

View File

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

View File

@ -200,6 +200,9 @@ const environment = {
}, },
ROLLING_LOG_MAX_SIZE: process.env.ROLLING_LOG_MAX_SIZE || "10M", ROLLING_LOG_MAX_SIZE: process.env.ROLLING_LOG_MAX_SIZE || "10M",
DISABLE_SCIM_CALLS: process.env.DISABLE_SCIM_CALLS, DISABLE_SCIM_CALLS: process.env.DISABLE_SCIM_CALLS,
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
} }
// clean up any environment variable edge cases // clean up any environment variable edge cases

View File

@ -221,7 +221,7 @@ export class UserDB {
const tenantId = getTenantId() const tenantId = getTenantId()
const db = getGlobalDB() const db = getGlobalDB()
let { email, _id, userGroups = [], roles } = user const { email, _id, userGroups = [], roles } = user
if (!email && !_id) { if (!email && !_id) {
throw new Error("_id or email is required") throw new Error("_id or email is required")
@ -231,11 +231,10 @@ export class UserDB {
if (_id) { if (_id) {
// try to get existing user from db // try to get existing user from db
try { try {
dbUser = (await db.get(_id)) as User dbUser = await usersCore.getById(_id)
if (email && dbUser.email !== email) { if (email && dbUser.email !== email && !opts.allowChangingEmail) {
throw "Email address cannot be changed" throw new Error("Email address cannot be changed")
} }
email = dbUser.email
} catch (e: any) { } catch (e: any) {
if (e.status === 404) { if (e.status === 404) {
// do nothing, save this new user with the id specified - required for SSO auth // do nothing, save this new user with the id specified - required for SSO auth
@ -271,13 +270,13 @@ export class UserDB {
// make sure we set the _id field for a new user // make sure we set the _id field for a new user
// Also if this is a new user, associate groups with them // Also if this is a new user, associate groups with them
let groupPromises = [] const groupPromises = []
if (!_id) { if (!_id) {
_id = builtUser._id!
if (userGroups.length > 0) { if (userGroups.length > 0) {
for (let groupId of userGroups) { for (let groupId of userGroups) {
groupPromises.push(UserDB.groups.addUsers(groupId, [_id!])) groupPromises.push(
UserDB.groups.addUsers(groupId, [builtUser._id!])
)
} }
} }
} }
@ -288,6 +287,11 @@ export class UserDB {
builtUser._rev = response.rev builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser) await eventHelpers.handleSaveEvents(builtUser, dbUser)
if (dbUser && builtUser.email !== dbUser.email) {
// Remove the plaform email reference if the email changed
await platform.users.removeUser({ email: dbUser.email } as User)
}
await platform.users.addUser( await platform.users.addUser(
tenantId, tenantId,
builtUser._id!, builtUser._id!,

View File

@ -0,0 +1,188 @@
import { User, UserStatus } from "@budibase/types"
import { DBTestConfiguration, generator, structures } from "../../../tests"
import { UserDB } from "../db"
import { searchExistingEmails } from "../lookup"
const db = UserDB
const config = new DBTestConfiguration()
const quotas = {
addUsers: jest
.fn()
.mockImplementation(
(_change: number, _creatorsChange: number, cb?: () => Promise<any>) =>
cb && cb()
),
removeUsers: jest
.fn()
.mockImplementation(
(_change: number, _creatorsChange: number, cb?: () => Promise<any>) =>
cb && cb()
),
}
const groups = {
addUsers: jest.fn(),
getBulk: jest.fn(),
getGroupBuilderAppIds: jest.fn(),
}
const features = { isSSOEnforced: jest.fn(), isAppBuildersEnabled: jest.fn() }
describe("UserDB", () => {
beforeAll(() => {
db.init(quotas, groups, features)
})
describe("save", () => {
describe("create", () => {
it("creating a new user will persist it", async () => {
const email = generator.email({})
const user: User = structures.users.user({
email,
tenantId: config.getTenantId(),
})
await config.doInTenant(async () => {
const saveUserResponse = await db.save(user)
const persistedUser = await db.getUserByEmail(email)
expect(persistedUser).toEqual({
...user,
_id: saveUserResponse._id,
_rev: expect.stringMatching(/^1-\w+/),
password: expect.not.stringMatching(user.password!),
status: UserStatus.ACTIVE,
createdAt: Date.now(),
updatedAt: new Date().toISOString(),
})
})
})
it("the same email cannot be used twice in the same tenant", async () => {
const email = generator.email({})
const user: User = structures.users.user({
email,
tenantId: config.getTenantId(),
})
await config.doInTenant(() => db.save(user))
await config.doInTenant(() =>
expect(db.save(user)).rejects.toThrow(
`Email already in use: '${email}'`
)
)
})
it("the same email cannot be used twice in different tenants", async () => {
const email = generator.email({})
const user: User = structures.users.user({
email,
tenantId: config.getTenantId(),
})
await config.doInTenant(() => db.save(user))
config.newTenant()
await config.doInTenant(() =>
expect(db.save(user)).rejects.toThrow(
`Email already in use: '${email}'`
)
)
})
})
describe("update", () => {
let user: User
beforeEach(async () => {
user = await config.doInTenant(() =>
db.save(
structures.users.user({
email: generator.email({}),
tenantId: config.getTenantId(),
})
)
)
})
it("can update user properties", async () => {
await config.doInTenant(async () => {
const updatedName = generator.first()
user.firstName = updatedName
await db.save(user)
const persistedUser = await db.getUserByEmail(user.email)
expect(persistedUser).toEqual(
expect.objectContaining({
_id: user._id,
email: user.email,
firstName: updatedName,
lastName: user.lastName,
})
)
})
})
it("email cannot be updated by default", async () => {
await config.doInTenant(async () => {
await expect(
db.save({ ...user, email: generator.email({}) })
).rejects.toThrow("Email address cannot be changed")
})
})
it("email can be updated if specified", async () => {
await config.doInTenant(async () => {
const newEmail = generator.email({})
await db.save(
{ ...user, email: newEmail },
{ allowChangingEmail: true }
)
const persistedUser = await db.getUserByEmail(newEmail)
expect(persistedUser).toEqual(
expect.objectContaining({
_id: user._id,
email: newEmail,
lastName: user.lastName,
_rev: expect.stringMatching(/^2-\w+/),
})
)
})
})
it("updating emails frees previous emails", async () => {
await config.doInTenant(async () => {
const previousEmail = user.email
const newEmail = generator.email({})
expect(await searchExistingEmails([previousEmail, newEmail])).toEqual(
[previousEmail]
)
await db.save(
{ ...user, email: newEmail },
{ allowChangingEmail: true }
)
expect(await searchExistingEmails([previousEmail, newEmail])).toEqual(
[newEmail]
)
await db.save(
structures.users.user({
email: previousEmail,
tenantId: config.getTenantId(),
})
)
expect(await searchExistingEmails([previousEmail, newEmail])).toEqual(
[previousEmail, newEmail]
)
})
})
})
})
})

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">
<AbsTooltip text={tooltip}>
<div <div
class="icon" class="icon"
class:icon-small={size === "M" || size === "S"} class:icon-small={size === "M" || size === "S"}
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:focus on:focus
> >
<Icon name="InfoOutline" size="S" {disabled} /> <Icon name="InfoOutline" size="S" {disabled} hoverable />
</div> </div>
{#if showTooltip} </AbsTooltip>
<div class="tooltip">
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
</div>
{/if}
</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,11 +1,10 @@
<script> <script>
import TableSelector from "./TableSelector.svelte" import TableSelector from "./TableSelector.svelte"
import RowSelector from "./RowSelector.svelte"
import FieldSelector from "./FieldSelector.svelte" import FieldSelector from "./FieldSelector.svelte"
import SchemaSetup from "./SchemaSetup.svelte" import SchemaSetup from "./SchemaSetup.svelte"
import RowSelector from "./RowSelector.svelte"
import { import {
Button, Button,
Input,
Select, Select,
Label, Label,
ActionButton, ActionButton,
@ -15,26 +14,27 @@
Checkbox, Checkbox,
DatePicker, DatePicker,
DrawerContent, DrawerContent,
Helpers,
Toggle, Toggle,
Icon,
Divider, Divider,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation, tables } from "stores/builder" import { automationStore, selectedAutomation, tables } from "stores/builder"
import { environment, licensing } from "stores/portal" import { environment, licensing } from "stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte" import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import {
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte" BindingSidePanel,
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" DrawerBindableSlot,
DrawerBindableInput,
ServerBindingPanel as AutomationBindingPanel,
ModalBindableInput,
} from "components/common/bindings"
import CodeEditorModal from "./CodeEditorModal.svelte" import CodeEditorModal from "./CodeEditorModal.svelte"
import QuerySelector from "./QuerySelector.svelte"
import QueryParamSelector from "./QueryParamSelector.svelte" import QueryParamSelector from "./QueryParamSelector.svelte"
import AutomationSelector from "./AutomationSelector.svelte" import AutomationSelector from "./AutomationSelector.svelte"
import CronBuilder from "./CronBuilder.svelte" import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { BindingHelpers, BindingType } from "components/common/bindings/utils" import { BindingHelpers, BindingType } from "components/common/bindings/utils"
import { import {
@ -43,31 +43,57 @@
EditorModes, EditorModes,
} from "components/common/CodeEditor" } from "components/common/CodeEditor"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { QueryUtils, Utils, search } from "@budibase/frontend-core" import { QueryUtils, Utils, search, memo } from "@budibase/frontend-core"
import { import {
getSchemaForDatasourcePlus, getSchemaForDatasourcePlus,
getEnvironmentBindings, getEnvironmentBindings,
} from "dataBinding" } from "dataBinding"
import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte" import { onMount } from "svelte"
import { writable } from "svelte/store"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import {
AutomationEventType,
AutomationStepType,
AutomationActionStepId,
} from "@budibase/types"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import PropField from "./PropField.svelte"
export let block export let block
export let testData export let testData
export let schemaProperties export let schemaProperties
export let isTestModal = false export let isTestModal = false
// Stop unnecessary rendering
const memoBlock = memo(block)
const rowTriggers = [
TriggerStepID.ROW_UPDATED,
TriggerStepID.ROW_SAVED,
TriggerStepID.ROW_DELETED,
]
const rowEvents = [
AutomationEventType.ROW_DELETE,
AutomationEventType.ROW_SAVE,
AutomationEventType.ROW_UPDATE,
]
const rowSteps = [ActionStepID.UPDATE_ROW, ActionStepID.CREATE_ROW]
let webhookModal let webhookModal
let drawer let drawer
let inputData let inputData
let insertAtPos, getCaretPosition let insertAtPos, getCaretPosition
let stepLayouts = {}
$: memoBlock.set(block)
$: filters = lookForFilters(schemaProperties) || [] $: filters = lookForFilters(schemaProperties) || []
$: tempFilters = filters $: tempFilters = filters
$: stepId = block.stepId $: stepId = block.stepId
$: bindings = getAvailableBindings(block, $selectedAutomation?.definition) $: bindings = getAvailableBindings(block, $selectedAutomation?.definition)
$: getInputData(testData, block.inputs) $: getInputData(testData, $memoBlock.inputs)
$: tableId = inputData ? inputData.tableId : null $: tableId = inputData ? inputData.tableId : null
$: table = tableId $: table = tableId
? $tables.list.find(table => table._id === inputData.tableId) ? $tables.list.find(table => table._id === inputData.tableId)
@ -81,31 +107,33 @@
{ allowLinks: true } { allowLinks: true }
) )
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000" $: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
$: isTrigger = block?.type === "TRIGGER" $: isTrigger = block?.type === AutomationStepType.TRIGGER
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
$: codeMode = $: codeMode =
stepId === "EXECUTE_BASH" ? EditorModes.Handlebars : EditorModes.JS stepId === AutomationActionStepId.EXECUTE_BASH
? EditorModes.Handlebars
: EditorModes.JS
$: bindingsHelpers = new BindingHelpers(getCaretPosition, insertAtPos, { $: bindingsHelpers = new BindingHelpers(getCaretPosition, insertAtPos, {
disableWrapping: true, disableWrapping: true,
}) })
$: editingJs = codeMode === EditorModes.JS $: editingJs = codeMode === EditorModes.JS
$: requiredProperties = block.schema.inputs.required || [] $: requiredProperties = isTestModal ? [] : block.schema["inputs"].required
$: stepCompletions = $: stepCompletions =
codeMode === EditorModes.Handlebars codeMode === EditorModes.Handlebars
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: [] : []
let testDataRowVisibility = {}
const getInputData = (testData, blockInputs) => { const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity // Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs) let newInputData = testData || cloneDeep(blockInputs)
// Ensures the app action fields are populated // Ensures the app action fields are populated
if (block.event === "app:trigger" && !newInputData?.fields) { if (
block.event === AutomationEventType.APP_TRIGGER &&
!newInputData?.fields
) {
newInputData = cloneDeep(blockInputs) newInputData = cloneDeep(blockInputs)
} }
inputData = newInputData inputData = newInputData
setDefaultEnumValues() setDefaultEnumValues()
} }
@ -117,15 +145,338 @@
} }
} }
} }
const onChange = Utils.sequential(async (e, key) => {
// Store for any UX related data
const stepStore = writable({})
$: currentStep = $stepStore?.[block.id]
$: customStepLayouts($memoBlock, schemaProperties, currentStep)
const customStepLayouts = block => {
if (
rowSteps.includes(block.stepId) ||
(rowTriggers.includes(block.stepId) && isTestModal)
) {
const schema = schemaProperties.reduce((acc, entry) => {
const [key, val] = entry
acc[key] = val
return acc
}, {})
// Optionally build the rev field config when its needed.
const getRevConfig = () => {
const rowRevEntry = schema["revision"]
if (!rowRevEntry) {
return []
}
const rowRevlabel = getFieldLabel("revision", rowRevEntry)
return isTestModal
? [
{
type: DrawerBindableInput,
title: rowRevlabel,
props: {
panel: AutomationBindingPanel,
value: inputData["revision"],
onChange: e => {
onChange({ ["revision"]: e.detail })
},
bindings,
updateOnChange: false,
forceModal: true,
},
},
]
: []
}
const getIdConfig = () => {
const rowIdentifier = isTestModal ? "id" : "rowId"
const rowIdEntry = schema[rowIdentifier]
if (!rowIdEntry) {
return []
}
const rowIdlabel = getFieldLabel(rowIdentifier, rowIdEntry)
return [
{
type: DrawerBindableInput,
title: rowIdlabel,
props: {
panel: AutomationBindingPanel,
value: inputData[rowIdentifier],
onChange: e => {
onChange({ [rowIdentifier]: e.detail })
},
bindings,
updateOnChange: false,
forceModal: true,
},
},
]
}
// A select to switch from `row` to `oldRow`
const getRowTypeConfig = () => {
if (!isTestModal || block.event !== AutomationEventType.ROW_UPDATE) {
return []
}
if (!$stepStore?.[block.id]) {
stepStore.update(state => ({
...state,
[block.id]: {
rowType: "row",
},
}))
}
return [
{
type: Select,
tooltip: `You can configure test data for both the updated row and
the old row, if you need it. Just select the one you wish to alter`,
title: "Row data",
props: {
value: $stepStore?.[block.id].rowType,
onChange: e => {
stepStore.update(state => ({
...state,
[block.id]: {
rowType: e.detail,
},
}))
},
getOptionLabel: type => type.name,
getOptionValue: type => type.id,
options: [
{
id: "row",
name: "Updated row",
},
{ id: "oldRow", name: "Old row" },
],
},
},
]
}
const getRowSelector = () => {
const baseProps = {
bindings,
isTestModal,
isUpdateRow: block.stepId === ActionStepID.UPDATE_ROW,
}
if (isTestModal && currentStep?.rowType === "oldRow") {
return [
{
type: RowSelector,
props: {
row: inputData["oldRow"] || {
tableId: inputData["row"].tableId,
},
meta: {
fields: inputData["meta"].oldFields || {},
},
onChange: e => {
onChange({
oldRow: e.detail.row,
meta: {
fields: inputData["meta"].fields,
oldFields: e.detail.meta.fields,
},
})
},
...baseProps,
},
},
]
}
return [
{
type: RowSelector,
props: {
row: inputData["row"],
meta: inputData["meta"] || {},
onChange: e => {
onChange(e.detail)
},
...baseProps,
},
},
]
}
stepLayouts[block.stepId] = {
row: {
schema: schema["row"],
//?layout: RowLayoutStepComponent.
content: [
{
type: TableSelector,
title: "Table",
props: {
isTrigger,
value: inputData["row"]?.tableId ?? "",
onChange: e => {
const rowKey = $stepStore?.[block.id]?.rowType || "row"
onChange({
_tableId: e.detail,
meta: {},
[rowKey]: e.detail
? {
tableId: e.detail,
}
: {},
})
},
disabled: isTestModal,
},
},
...getIdConfig(),
...getRevConfig(),
...getRowTypeConfig(),
{
type: Divider,
props: {
noMargin: true,
},
},
...getRowSelector(),
],
},
}
}
}
/**
* Handler for row trigger automation updates.
@param {object} update - An automation block.inputs update object
@example
onRowTriggerUpdate({
"tableId" : "ta_bb_employee"
})
*/
const onRowTriggerUpdate = async update => {
if (
Object.hasOwn(update, "tableId") &&
$selectedAutomation.testData?.row?.tableId !== update.tableId
) {
try {
const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
searchableSchema: true,
}).schema
// Parse the block inputs as usual
const updatedAutomation =
await automationStore.actions.processBlockInputs(block, {
schema: reqSchema,
...update,
})
// Save the entire automation and reset the testData
await automationStore.actions.save({
...updatedAutomation,
testData: {
// Reset Core fields
row: { tableId: update.tableId },
oldRow: { tableId: update.tableId },
meta: {},
id: "",
revision: "",
},
})
return
} catch (e) {
console.error("Error saving automation", e)
notifications.error("Error saving automation")
}
}
}
/**
* Handler for App trigger automation updates.
* Ensure updates to the field list are reflected in testData
@param {object} update - An app trigger update object
@example
onAppTriggerUpdate({
"fields" : {"myField": "123", "myArray": "cat,dog,badger"}
})
*/
const onAppTriggerUpdate = async update => {
try {
// Parse the block inputs as usual
const updatedAutomation =
await automationStore.actions.processBlockInputs(block, {
schema: {},
...update,
})
// Exclude default or invalid data from the test data
let updatedFields = {}
for (const key of Object.keys(block?.inputs?.fields || {})) {
if (Object.hasOwn(update.fields, key)) {
if (key !== "") {
updatedFields[key] = updatedAutomation.testData?.fields?.[key]
}
}
}
// Save the entire automation and reset the testData
await automationStore.actions.save({
...updatedAutomation,
testData: {
fields: updatedFields,
},
})
} catch (e) {
console.error("Error saving automation", e)
notifications.error("Error saving automation")
}
}
/**
* Handler for automation block input updates.
@param {object} update - An automation inputs update object
@example
onChange({
meta: { fields : { "Photo": { useAttachmentBinding: false }} }
row: { "Active": true, "Order Id" : 14, ... }
})
*/
const onChange = Utils.sequential(async update => {
const request = cloneDeep(update)
// Process app trigger updates
if (isTrigger && !isTestModal) {
// Row trigger
if (rowEvents.includes(block.event)) {
await onRowTriggerUpdate(request)
return
}
// App trigger
if (block.event === AutomationEventType.APP_TRIGGER) {
await onAppTriggerUpdate(request)
return
}
}
// We need to cache the schema as part of the definition because it is // We need to cache the schema as part of the definition because it is
// used in the server to detect relationships. It would be far better to // used in the server to detect relationships. It would be far better to
// instead fetch the schema in the backend at runtime. // instead fetch the schema in the backend at runtime.
// If _tableId is explicitly included in the update request, the schema will be requested
let schema let schema
if (e.detail?.tableId) { if (request?._tableId) {
schema = getSchemaForDatasourcePlus(e.detail.tableId, { schema = getSchemaForDatasourcePlus(request._tableId, {
searchableSchema: true, searchableSchema: true,
}).schema }).schema
delete request._tableId
} }
try { try {
if (isTestModal) { if (isTestModal) {
@ -136,21 +487,22 @@
newTestData = { newTestData = {
...newTestData, ...newTestData,
body: { body: {
[key]: e.detail, ...update,
...$selectedAutomation.testData?.body, ...$selectedAutomation.testData?.body,
}, },
} }
} }
newTestData = { newTestData = {
...newTestData, ...newTestData,
[key]: e.detail, ...request,
} }
await automationStore.actions.addTestDataToAutomation(newTestData) await automationStore.actions.addTestDataToAutomation(newTestData)
} else { } else {
const data = { schema, [key]: e.detail } const data = { schema, ...request }
await automationStore.actions.updateBlockInputs(block, data) await automationStore.actions.updateBlockInputs(block, data)
} }
} catch (error) { } catch (error) {
console.error("Error saving automation", error)
notifications.error("Error saving automation") notifications.error("Error saving automation")
} }
}) })
@ -195,14 +547,17 @@
let runtimeName let runtimeName
/* Begin special cases for generating custom schemas based on triggers */ /* Begin special cases for generating custom schemas based on triggers */
if (idx === 0 && automation.trigger?.event === "app:trigger") { if (
idx === 0 &&
automation.trigger?.event === AutomationEventType.APP_TRIGGER
) {
return `trigger.fields.${name}` return `trigger.fields.${name}`
} }
if ( if (
idx === 0 && idx === 0 &&
(automation.trigger?.event === "row:update" || (automation.trigger?.event === AutomationEventType.ROW_UPDATE ||
automation.trigger?.event === "row:save") automation.trigger?.event === AutomationEventType.ROW_SAVE)
) { ) {
let noRowKeywordBindings = ["id", "revision", "oldRow"] let noRowKeywordBindings = ["id", "revision", "oldRow"]
if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}` if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}`
@ -277,7 +632,10 @@
} }
} }
if (idx === 0 && automation.trigger?.event === "app:trigger") { if (
idx === 0 &&
automation.trigger?.event === AutomationEventType.APP_TRIGGER
) {
schema = Object.fromEntries( schema = Object.fromEntries(
Object.keys(automation.trigger.inputs.fields || []).map(key => [ Object.keys(automation.trigger.inputs.fields || []).map(key => [
key, key,
@ -286,8 +644,9 @@
) )
} }
if ( if (
(idx === 0 && automation.trigger.event === "row:update") || (idx === 0 &&
(idx === 0 && automation.trigger.event === "row:save") automation.trigger.event === AutomationEventType.ROW_UPDATE) ||
(idx === 0 && automation.trigger.event === AutomationEventType.ROW_SAVE)
) { ) {
let table = $tables.list.find( let table = $tables.list.find(
table => table._id === automation.trigger.inputs.tableId table => table._id === automation.trigger.inputs.tableId
@ -353,10 +712,12 @@
function saveFilters(key) { function saveFilters(key) {
const filters = QueryUtils.buildQuery(tempFilters) const filters = QueryUtils.buildQuery(tempFilters)
const defKey = `${key}-def`
onChange({ detail: filters }, key) onChange({
// need to store the builder definition in the automation [key]: filters,
onChange({ detail: tempFilters }, defKey) [`${key}-def`]: tempFilters, // need to store the builder definition in the automation
})
drawer.hide() drawer.hide()
} }
@ -373,6 +734,7 @@
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" && value.customType !== "triggerSchema" &&
value.customType !== "automationFields" && value.customType !== "automationFields" &&
value.customType !== "fields" &&
value.type !== "signature_single" && value.type !== "signature_single" &&
value.type !== "attachment" && value.type !== "attachment" &&
value.type !== "attachment_single" value.type !== "attachment_single"
@ -381,11 +743,10 @@
function getFieldLabel(key, value) { function getFieldLabel(key, value) {
const requiredSuffix = requiredProperties.includes(key) ? "*" : "" const requiredSuffix = requiredProperties.includes(key) ? "*" : ""
return `${value.title || (key === "row" ? "Row" : key)} ${requiredSuffix}` const label = `${
} value.title || (key === "row" ? "Row" : key)
} ${requiredSuffix}`
function toggleTestDataRowVisibility(key) { return Helpers.capitalise(label)
testDataRowVisibility[key] = !testDataRowVisibility[key]
} }
function handleAttachmentParams(keyValueObj) { function handleAttachmentParams(keyValueObj) {
@ -398,16 +759,6 @@
return params return params
} }
function toggleAttachmentBinding(e, key) {
onChange(
{
detail: "",
},
key
)
onChange({ detail: { useAttachmentBinding: e.detail } }, "meta")
}
onMount(async () => { onMount(async () => {
try { try {
await environment.loadVariables() await environment.loadVariables()
@ -417,7 +768,32 @@
}) })
</script> </script>
<div class="fields"> <div class="step-fields">
<!-- Custom Layouts -->
{#if stepLayouts[block.stepId]}
{#each Object.keys(stepLayouts[block.stepId] || {}) as key}
{#if canShowField(key, stepLayouts[block.stepId].schema)}
{#each stepLayouts[block.stepId][key].content as config}
{#if config.title}
<PropField label={config.title} labelTooltip={config.tooltip}>
<svelte:component
this={config.type}
{...config.props}
on:change={config.props.onChange}
/>
</PropField>
{:else}
<svelte:component
this={config.type}
{...config.props}
on:change={config.props.onChange}
/>
{/if}
{/each}
{/if}
{/each}
{:else}
<!-- Default Schema Property Layout -->
{#each schemaProperties as [key, value]} {#each schemaProperties as [key, value]}
{#if canShowField(key, value)} {#if canShowField(key, value)}
{@const label = getFieldLabel(key, value)} {@const label = getFieldLabel(key, value)}
@ -426,13 +802,15 @@
<Label <Label
tooltip={value.title === "Binding / Value" tooltip={value.title === "Binding / Value"
? "If using the String input type, please use a comma or newline separated string" ? "If using the String input type, please use a comma or newline separated string"
: null}>{label}</Label : null}
> >
{label}
</Label>
{/if} {/if}
<div class:field-width={shouldRenderField(value)}> <div class:field-width={shouldRenderField(value)}>
{#if value.type === "string" && value.enum && canShowField(key, value)} {#if value.type === "string" && value.enum && canShowField(key, value)}
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}
placeholder={false} placeholder={false}
options={value.enum} options={value.enum}
@ -445,16 +823,14 @@
editorWidth="448" editorWidth="448"
mode="json" mode="json"
value={inputData[key]?.value} value={inputData[key]?.value}
on:change={e => { on:change={e => onChange({ [key]: e.detail })}
onChange(e, key)
}}
/> />
{:else if value.type === "boolean"} {:else if value.type === "boolean"}
<div style="margin-top: 10px"> <div style="margin-top: 10px">
<Checkbox <Checkbox
text={value.title} text={value.title}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
/> />
</div> </div>
{:else if value.type === "date"} {:else if value.type === "date"}
@ -463,7 +839,7 @@
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={"date"} type={"date"}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
{bindings} {bindings}
allowJS={true} allowJS={true}
updateOnChange={false} updateOnChange={false}
@ -471,12 +847,12 @@
> >
<DatePicker <DatePicker
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
/> />
</DrawerBindableSlot> </DrawerBindableSlot>
{:else if value.customType === "column"} {:else if value.customType === "column"}
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}
options={Object.keys(table?.schema || {})} options={Object.keys(table?.schema || {})}
/> />
@ -490,7 +866,14 @@
value={inputData?.meta?.useAttachmentBinding} value={inputData?.meta?.useAttachmentBinding}
text={"Use bindings"} text={"Use bindings"}
size={"XS"} size={"XS"}
on:change={e => toggleAttachmentBinding(e, key)} on:change={e => {
onChange({
[key]: null,
meta: {
useAttachmentBinding: e.detail,
},
})
}}
/> />
</div> </div>
@ -498,20 +881,19 @@
{#if !inputData?.meta?.useAttachmentBinding} {#if !inputData?.meta?.useAttachmentBinding}
<KeyValueBuilder <KeyValueBuilder
on:change={e => on:change={e =>
onChange( onChange({
{ [key]: e.detail.map(({ name, value }) => ({
detail: e.detail.map(({ name, value }) => ({
url: name, url: name,
filename: value, filename: value,
})), })),
}, })}
key
)}
object={handleAttachmentParams(inputData[key])} object={handleAttachmentParams(inputData[key])}
allowJS allowJS
{bindings} {bindings}
keyBindings keyBindings
customButtonText={"Add attachment"} customButtonText={value.type === "attachment"
? "Add attachment"
: "Add signature"}
keyPlaceholder={"URL"} keyPlaceholder={"URL"}
valuePlaceholder={"Filename"} valuePlaceholder={"Filename"}
/> />
@ -521,18 +903,17 @@
value={inputData[key]} value={inputData[key]}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type={value.customType}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
{bindings} {bindings}
updateOnChange={false} updateOnChange={false}
/> />
{:else} {:else}
<div class="test">
<DrawerBindableInput <DrawerBindableInput
title={value.title ?? label} title={value.title ?? label}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type={value.customType}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
{bindings} {bindings}
updateOnChange={false} updateOnChange={false}
placeholder={value.customType === "queryLimit" placeholder={value.customType === "queryLimit"
@ -540,7 +921,6 @@
: ""} : ""}
drawerLeft="260px" drawerLeft="260px"
/> />
</div>
{/if} {/if}
</div> </div>
</div> </div>
@ -561,55 +941,20 @@
/> />
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
{:else if value.customType === "password"}
<Input
type="password"
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "email"}
{#if isTestModal}
<ModalBindableInput
title={value.title ?? label}
value={inputData[key]}
panel={AutomationBindingPanel}
type="email"
on:change={e => onChange(e, key)}
{bindings}
updateOnChange={false}
/>
{:else}
<DrawerBindableInput
title={value.title ?? label}
panel={AutomationBindingPanel}
type="email"
value={inputData[key]}
on:change={e => onChange(e, key)}
{bindings}
allowJS={false}
updateOnChange={false}
drawerLeft="260px"
/>
{/if}
{:else if value.customType === "query"}
<QuerySelector
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "cron"} {:else if value.customType === "cron"}
<CronBuilder <CronBuilder
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}
/> />
{:else if value.customType === "automationFields"} {:else if value.customType === "automationFields"}
<AutomationSelector <AutomationSelector
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}
{bindings} {bindings}
/> />
{:else if value.customType === "queryParams"} {:else if value.customType === "queryParams"}
<QueryParamSelector <QueryParamSelector
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}
{bindings} {bindings}
/> />
@ -617,78 +962,35 @@
<TableSelector <TableSelector
{isTrigger} {isTrigger}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
/> />
{:else if value.customType === "row"}
{#if isTestModal}
<div class="align-horizontally">
<Icon
name={testDataRowVisibility[key] ? "Remove" : "Add"}
hoverable
on:click={() => toggleTestDataRowVisibility(key)}
/>
<Label size="XL">{label}</Label>
</div>
{#if testDataRowVisibility[key]}
<RowSelector
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{/if}
<Divider />
{:else}
<RowSelector
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {
if (e.detail?.key) {
onChange(e, e.detail.key)
} else {
onChange(e, key)
}
}}
{bindings}
{isTestModal}
{isUpdateRow}
/>
{/if}
{:else if value.customType === "webhookUrl"} {:else if value.customType === "webhookUrl"}
<WebhookDisplay <WebhookDisplay value={inputData[key]} />
on:change={e => onChange(e, key)}
value={inputData[key]}
/>
{:else if value.customType === "fields"} {:else if value.customType === "fields"}
<FieldSelector <FieldSelector
{block} {block}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
{bindings} {bindings}
{isTestModal} {isTestModal}
/> />
{:else if value.customType === "triggerSchema"} {:else if value.customType === "triggerSchema"}
<SchemaSetup <SchemaSetup
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]} value={inputData[key]}
/> />
{:else if value.customType === "code"} {:else if value.customType === "code"}
<CodeEditorModal> <CodeEditorModal>
<div class:js-editor={editingJs}> <div class:js-editor={editingJs}>
<div class:js-code={editingJs} style="width:100%;height:500px;"> <div
class:js-code={editingJs}
style="width:100%;height:500px;"
>
<CodeEditor <CodeEditor
value={inputData[key]} value={inputData[key]}
on:change={e => { on:change={e => {
// need to pass without the value inside // need to pass without the value inside
onChange({ detail: e.detail }, key) onChange({ [key]: e.detail })
inputData[key] = e.detail inputData[key] = e.detail
}} }}
completions={stepCompletions} completions={stepCompletions}
@ -724,7 +1026,7 @@
</CodeEditorModal> </CodeEditorModal>
{:else if value.customType === "loopOption"} {:else if value.customType === "loopOption"}
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
autoWidth autoWidth
value={inputData[key]} value={inputData[key]}
options={["Array", "String"]} options={["Array", "String"]}
@ -737,18 +1039,18 @@
value={inputData[key]} value={inputData[key]}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type={value.customType}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
{bindings} {bindings}
updateOnChange={false} updateOnChange={false}
/> />
{:else} {:else}
<div class="test"> <div>
<DrawerBindableInput <DrawerBindableInput
title={value.title ?? label} title={value.title ?? label}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={value.customType} type={value.customType}
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange({ [key]: e.detail })}
{bindings} {bindings}
updateOnChange={false} updateOnChange={false}
placeholder={value.customType === "queryLimit" placeholder={value.customType === "queryLimit"
@ -763,12 +1065,14 @@
</div> </div>
{/if} {/if}
{/each} {/each}
{/if}
</div> </div>
<Modal bind:this={webhookModal} width="30%"> <Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal /> <CreateWebhookModal />
</Modal> </Modal>
{#if stepId === TriggerStepID.WEBHOOK} {#if stepId === TriggerStepID.WEBHOOK && !isTestModal}
<Button secondary on:click={() => webhookModal.show()}>Set Up Webhook</Button> <Button secondary on:click={() => webhookModal.show()}>Set Up Webhook</Button>
{/if} {/if}
@ -777,18 +1081,12 @@
width: 320px; width: 320px;
} }
.align-horizontally { .step-fields {
display: flex;
gap: var(--spacing-s);
align-items: center;
}
.fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
gap: var(--spacing-s); gap: var(--spacing-l);
} }
.block-field { .block-field {
@ -808,10 +1106,6 @@
margin-top: var(--spacing-s); margin-top: var(--spacing-s);
} }
.test :global(.drawer) {
width: 10000px !important;
}
.js-editor { .js-editor {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

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}
panel={AutomationBindingPanel}
value={editableValue[field]}
on:change={e => onChange(e, field)}
type="string"
{bindings} {bindings}
{value} allowJS={true}
{onChange} 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,
})
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
return clone
})
$: 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
) )
schemaFields.forEach(([, schema]) => { // remove this and place the field in the editable row.
if (!schema.autocolumn && !value[schema.name]) { delete editableFields[key]?.clearRelationships
value[schema.name] = ""
// Default the field
editableRow = {
...editableRow,
[key]: emptyField,
}
} else {
// Purge from the update as its presence is not necessary.
delete editableRow[key]
} }
})
} }
const onChangeTable = e => {
value["tableId"] = e.detail
dispatch("change", value)
} }
// 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 coerce = (value, type) => {
const re = new RegExp(/{{([^{].*?)}}/g) const re = new RegExp(/{{([^{].*?)}}/g)
if (re.test(value)) { if (typeof value === "string" && re.test(value)) {
return 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,70 +197,73 @@
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: {
fields: editableFields,
},
},
update,
customizer
)
dispatch("change", result)
} }
dispatch("change", {
key: "meta",
fields: updatedFields,
})
}
// 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}
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} {#if isTestModal}
<RowSelectorTypes <RowSelectorTypes
{isTestModal} {isTestModal}
{field} {field}
{schema} {schema}
bindings={parsedBindings} bindings={parsedBindings}
{value} value={editableRow}
meta={{
fields: editableFields,
}}
{onChange} {onChange}
/> />
{:else} {:else}
<DrawerBindableSlot <DrawerBindableSlot
title={value.title || field} title={$memoStore?.row?.title || field}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}
type={schema.type} type={schema.type}
{schema} {schema}
value={value[field]} value={editableRow[field]}
on:change={e => onChange(e, field)} on:change={e =>
onChange({
row: {
[field]: e.detail,
},
})}
{bindings} {bindings}
allowJS={true} allowJS={true}
updateOnChange={false} updateOnChange={false}
@ -160,56 +274,99 @@
{field} {field}
{schema} {schema}
bindings={parsedBindings} bindings={parsedBindings}
{value} value={editableRow}
{onChange} meta={{
useAttachmentBinding={meta?.fields?.[field] fields: editableFields,
?.useAttachmentBinding} }}
{onChangeSetting} onChange={change => onChange(change)}
/> />
</DrawerBindableSlot> </DrawerBindableSlot>
{/if} {/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>
</PropField>
{/if} {/if}
{/each} {/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 }) => ({
url: name,
filename: value,
}))
if (isSingle) {
const [singleMedia] = parsedMedia
// Return only the first entry
return singleMedia
? {
url: singleMedia.url,
filename: singleMedia.filename,
} }
previousBindingState = event.detail : null
onChangeSetting(toggleField, "useAttachmentBinding", event.detail)
onChange({ detail: value[toggleField] }, toggleField)
} }
$: if (useAttachmentBinding !== previousBindingState) { // Return the entire array
if (useAttachmentBinding) { return parsedMedia
value[field] = []
} else {
value[field] = ""
}
previousBindingState = useAttachmentBinding
} }
</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)

@ -1 +1 @@
Subproject commit e8f2c5a14780e1f61ec3896821ba5f93d486eb72 Subproject commit dbb78c8737c291871500bc655e30f331f6ffccbf

View File

@ -1,23 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
module SendgridMock {
class Email {
constructor() {
// @ts-ignore
this.apiKey = null
}
setApiKey(apiKey: any) {
// @ts-ignore
this.apiKey = apiKey
}
async send(msg: any) {
if (msg.to === "invalid@example.com") {
throw "Invalid"
}
return msg
}
}
module.exports = new Email()
}

View File

@ -94,7 +94,7 @@
"koa2-ratelimit": "1.1.1", "koa2-ratelimit": "1.1.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"memorystream": "0.3.1", "memorystream": "0.3.1",
"mongodb": "^6.8.0", "mongodb": "6.7.0",
"mssql": "10.0.1", "mssql": "10.0.1",
"mysql2": "3.9.8", "mysql2": "3.9.8",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",

View File

@ -10,6 +10,6 @@ export const MIGRATIONS: AppMigration[] = [
{ {
id: "20240604153647_initial_sqs", id: "20240604153647_initial_sqs",
func: m20240604153647_initial_sqs, func: m20240604153647_initial_sqs,
disabled: !env.SQS_SEARCH_ENABLE, disabled: !(env.SQS_MIGRATION_ENABLE || env.SQS_SEARCH_ENABLE),
}, },
] ]

View File

@ -40,7 +40,7 @@ const migration = async () => {
// only do initial search if environment is using SQS already // only do initial search if environment is using SQS already
// initial search makes sure that all the indexes have been created // initial search makes sure that all the indexes have been created
// and are ready to use, avoiding any initial waits for large tables // and are ready to use, avoiding any initial waits for large tables
if (env.SQS_SEARCH_ENABLE) { if (env.SQS_MIGRATION_ENABLE || env.SQS_SEARCH_ENABLE) {
const tables = await sdk.tables.getAllInternalTables() const tables = await sdk.tables.getAllInternalTables()
// do these one by one - running in parallel could cause problems // do these one by one - running in parallel could cause problems
for (let table of tables) { for (let table of tables) {

View File

@ -66,16 +66,21 @@ function oldLinkDocument(): Omit<LinkDocument, "tableId"> {
} }
} }
async function sqsDisabled(cb: () => Promise<void>) { type SQSEnvVar = "SQS_MIGRATION_ENABLE" | "SQS_SEARCH_ENABLE"
await config.withEnv({ SQS_SEARCH_ENABLE: "" }, cb)
async function sqsDisabled(envVar: SQSEnvVar, cb: () => Promise<void>) {
await config.withEnv({ [envVar]: "" }, cb)
} }
async function sqsEnabled(cb: () => Promise<void>) { async function sqsEnabled(envVar: SQSEnvVar, cb: () => Promise<void>) {
await config.withEnv({ SQS_SEARCH_ENABLE: "1" }, cb) await config.withEnv({ [envVar]: "1" }, cb)
} }
describe.each(["SQS_MIGRATION_ENABLE", "SQS_SEARCH_ENABLE"] as SQSEnvVar[])(
"SQS migration with (%s)",
envVar => {
beforeAll(async () => { beforeAll(async () => {
await sqsDisabled(async () => { await sqsDisabled(envVar, async () => {
await config.init() await config.init()
const table = await config.api.table.save(basicTable()) const table = await config.api.table.save(basicTable())
tableId = table._id! tableId = table._id!
@ -85,11 +90,10 @@ beforeAll(async () => {
}) })
}) })
describe("SQS migration", () => {
it("test migration runs as expected against an older DB", async () => { it("test migration runs as expected against an older DB", async () => {
const db = dbCore.getDB(config.appId!) const db = dbCore.getDB(config.appId!)
// confirm nothing exists initially // confirm nothing exists initially
await sqsDisabled(async () => { await sqsDisabled(envVar, async () => {
let error: any | undefined let error: any | undefined
try { try {
await db.get(SQLITE_DESIGN_DOC_ID) await db.get(SQLITE_DESIGN_DOC_ID)
@ -99,7 +103,7 @@ describe("SQS migration", () => {
expect(error).toBeDefined() expect(error).toBeDefined()
expect(error.status).toBe(404) expect(error.status).toBe(404)
}) })
await sqsEnabled(async () => { await sqsEnabled(envVar, async () => {
await processMigrations(config.appId!, MIGRATIONS) await processMigrations(config.appId!, MIGRATIONS)
const designDoc = await db.get<SQLiteDefinition>(SQLITE_DESIGN_DOC_ID) const designDoc = await db.get<SQLiteDefinition>(SQLITE_DESIGN_DOC_ID)
expect(designDoc.sql.tables).toBeDefined() expect(designDoc.sql.tables).toBeDefined()
@ -126,4 +130,5 @@ describe("SQS migration", () => {
expect(linkDoc.doc2.rowId).toEqual(rowId1) expect(linkDoc.doc2.rowId).toEqual(rowId1)
}) })
}) })
}) }
)

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

@ -7,8 +7,8 @@ import {
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
} from "@budibase/types" } from "@budibase/types"
import { env } from "@budibase/backend-core"
import * as automationUtils from "../automationUtils" import * as automationUtils from "../automationUtils"
import environment from "../../environment"
enum Model { enum Model {
GPT_35_TURBO = "gpt-3.5-turbo", GPT_35_TURBO = "gpt-3.5-turbo",
@ -60,7 +60,7 @@ export const definition: AutomationStepSchema = {
} }
export async function run({ inputs }: AutomationStepInput) { export async function run({ inputs }: AutomationStepInput) {
if (!environment.OPENAI_API_KEY) { if (!env.OPENAI_API_KEY) {
return { return {
success: false, success: false,
response: response:
@ -77,7 +77,7 @@ export async function run({ inputs }: AutomationStepInput) {
try { try {
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: environment.OPENAI_API_KEY, apiKey: env.OPENAI_API_KEY,
}) })
const completion = await openai.chat.completions.create({ const completion = await openai.chat.completions.create({

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
// 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 ( if (
(inputs.row[propKey] == null || inputs.row[propKey]?.length === 0) && Object.hasOwn(fieldConfig, key) &&
!clearRelationships fieldConfig[key].clearRelationships === true
) { ) {
delete inputs.row[propKey] // 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

@ -1,6 +1,4 @@
const setup = require("./utilities") import { getConfig, runStep, afterAll as _afterAll } from "./utilities"
import environment from "../../environment"
import { OpenAI } from "openai" import { OpenAI } from "openai"
jest.mock("openai", () => ({ jest.mock("openai", () => ({
@ -26,42 +24,41 @@ const mockedOpenAI = OpenAI as jest.MockedClass<typeof OpenAI>
const OPENAI_PROMPT = "What is the meaning of life?" const OPENAI_PROMPT = "What is the meaning of life?"
describe("test the openai action", () => { describe("test the openai action", () => {
let config = setup.getConfig() let config = getConfig()
let resetEnv: () => void | undefined
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })
beforeEach(() => { beforeEach(() => {
environment.OPENAI_API_KEY = "abc123" resetEnv = config.setCoreEnv({ OPENAI_API_KEY: "abc123" })
}) })
afterAll(setup.afterAll) afterEach(() => {
resetEnv()
})
afterAll(_afterAll)
it("should present the correct error message when the OPENAI_API_KEY variable isn't set", async () => { it("should present the correct error message when the OPENAI_API_KEY variable isn't set", async () => {
delete environment.OPENAI_API_KEY await config.withCoreEnv({ OPENAI_API_KEY: "" }, async () => {
let res = await runStep("OPENAI", { prompt: OPENAI_PROMPT })
let res = await setup.runStep("OPENAI", {
prompt: OPENAI_PROMPT,
})
expect(res.response).toEqual( expect(res.response).toEqual(
"OpenAI API Key not configured - please add the OPENAI_API_KEY environment variable." "OpenAI API Key not configured - please add the OPENAI_API_KEY environment variable."
) )
expect(res.success).toBeFalsy() expect(res.success).toBeFalsy()
}) })
})
it("should be able to receive a response from ChatGPT given a prompt", async () => { it("should be able to receive a response from ChatGPT given a prompt", async () => {
const res = await setup.runStep("OPENAI", { const res = await runStep("OPENAI", { prompt: OPENAI_PROMPT })
prompt: OPENAI_PROMPT,
})
expect(res.response).toEqual("This is a test") expect(res.response).toEqual("This is a test")
expect(res.success).toBeTruthy() expect(res.success).toBeTruthy()
}) })
it("should present the correct error message when a prompt is not provided", async () => { it("should present the correct error message when a prompt is not provided", async () => {
const res = await setup.runStep("OPENAI", { const res = await runStep("OPENAI", { prompt: null })
prompt: null,
})
expect(res.response).toEqual( expect(res.response).toEqual(
"Budibase OpenAI Automation Failed: No prompt supplied" "Budibase OpenAI Automation Failed: No prompt supplied"
) )
@ -84,7 +81,7 @@ describe("test the openai action", () => {
} as any) } as any)
) )
const res = await setup.runStep("OPENAI", { const res = await runStep("OPENAI", {
prompt: OPENAI_PROMPT, prompt: OPENAI_PROMPT,
}) })

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 */ /* 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_SAVE)
}
)
emitter.on(AutomationEventType.ROW_UPDATE, async function (event) {
/* istanbul ignore next */
if (!event || !event.row || !event.row.tableId) {
return
}
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

@ -75,16 +75,12 @@ const environment = {
AUTOMATION_MAX_ITERATIONS: AUTOMATION_MAX_ITERATIONS:
parseIntSafe(process.env.AUTOMATION_MAX_ITERATIONS) || parseIntSafe(process.env.AUTOMATION_MAX_ITERATIONS) ||
DEFAULTS.AUTOMATION_MAX_ITERATIONS, DEFAULTS.AUTOMATION_MAX_ITERATIONS,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
DYNAMO_ENDPOINT: process.env.DYNAMO_ENDPOINT, DYNAMO_ENDPOINT: process.env.DYNAMO_ENDPOINT,
QUERY_THREAD_TIMEOUT: QUERY_THREAD_TIMEOUT, QUERY_THREAD_TIMEOUT: QUERY_THREAD_TIMEOUT,
AUTOMATION_THREAD_TIMEOUT: AUTOMATION_THREAD_TIMEOUT:
parseIntSafe(process.env.AUTOMATION_THREAD_TIMEOUT) || parseIntSafe(process.env.AUTOMATION_THREAD_TIMEOUT) ||
DEFAULT_AUTOMATION_TIMEOUT, DEFAULT_AUTOMATION_TIMEOUT,
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
PLUGINS_DIR: process.env.PLUGINS_DIR || DEFAULTS.PLUGINS_DIR, PLUGINS_DIR: process.env.PLUGINS_DIR || DEFAULTS.PLUGINS_DIR,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB, MAX_IMPORT_SIZE_MB: process.env.MAX_IMPORT_SIZE_MB,
SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS, SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
// SQL // SQL
@ -92,6 +88,7 @@ const environment = {
SQL_LOGGING_ENABLE: process.env.SQL_LOGGING_ENABLE, SQL_LOGGING_ENABLE: process.env.SQL_LOGGING_ENABLE,
SQL_ALIASING_DISABLE: process.env.SQL_ALIASING_DISABLE, SQL_ALIASING_DISABLE: process.env.SQL_ALIASING_DISABLE,
SQS_SEARCH_ENABLE: process.env.SQS_SEARCH_ENABLE, SQS_SEARCH_ENABLE: process.env.SQS_SEARCH_ENABLE,
SQS_MIGRATION_ENABLE: process.env.SQS_MIGRATION_ENABLE,
// flags // flags
ALLOW_DEV_AUTOMATIONS: process.env.ALLOW_DEV_AUTOMATIONS, ALLOW_DEV_AUTOMATIONS: process.env.ALLOW_DEV_AUTOMATIONS,
DISABLE_THREADING: process.env.DISABLE_THREADING, DISABLE_THREADING: process.env.DISABLE_THREADING,

View File

@ -8,6 +8,7 @@ import {
tenancy, tenancy,
users, users,
cache, cache,
env as coreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { watch } from "../watch" import { watch } from "../watch"
import * as automations from "../automations" import * as automations from "../automations"
@ -132,8 +133,8 @@ export async function startup(
// check and create admin user if required // check and create admin user if required
// this must be run after the api has been initialised due to // this must be run after the api has been initialised due to
// the app user sync // the app user sync
const bbAdminEmail = env.BB_ADMIN_USER_EMAIL, const bbAdminEmail = coreEnv.BB_ADMIN_USER_EMAIL,
bbAdminPassword = env.BB_ADMIN_USER_PASSWORD bbAdminPassword = coreEnv.BB_ADMIN_USER_PASSWORD
if ( if (
env.SELF_HOSTED && env.SELF_HOSTED &&
!env.MULTI_TENANCY && !env.MULTI_TENANCY &&

View File

@ -14,11 +14,15 @@ describe("check BB_ADMIN environment variables", () => {
await tenancy.doInTenant(tenancy.DEFAULT_TENANT_ID, async () => { await tenancy.doInTenant(tenancy.DEFAULT_TENANT_ID, async () => {
await config.withEnv( await config.withEnv(
{ {
BB_ADMIN_USER_EMAIL: EMAIL,
BB_ADMIN_USER_PASSWORD: PASSWORD,
MULTI_TENANCY: "0", MULTI_TENANCY: "0",
SELF_HOSTED: "1", SELF_HOSTED: "1",
}, },
() =>
config.withCoreEnv(
{
BB_ADMIN_USER_EMAIL: EMAIL,
BB_ADMIN_USER_PASSWORD: PASSWORD,
},
async () => { async () => {
await startup({ rerun: true }) await startup({ rerun: true })
const user = await users.getGlobalUserByEmail(EMAIL, { const user = await users.getGlobalUserByEmail(EMAIL, {
@ -26,9 +30,12 @@ describe("check BB_ADMIN environment variables", () => {
}) })
expect(user).toBeDefined() expect(user).toBeDefined()
expect(user?.password).toBeDefined() expect(user?.password).toBeDefined()
expect(await utils.compare(PASSWORD, user?.password!)).toEqual(true) expect(await utils.compare(PASSWORD, user?.password!)).toEqual(
true
)
} }
) )
)
}) })
}) })
}) })

View File

@ -290,7 +290,7 @@ export default class TestConfiguration {
* that can be called to reset the environment variables to their original values. * that can be called to reset the environment variables to their original values.
*/ */
setCoreEnv(newEnvVars: Partial<typeof coreEnv>): () => void { setCoreEnv(newEnvVars: Partial<typeof coreEnv>): () => void {
const oldEnv = cloneDeep(env) const oldEnv = cloneDeep(coreEnv)
let key: keyof typeof newEnvVars let key: keyof typeof newEnvVars
for (key in newEnvVars) { for (key in newEnvVars) {

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

View File

@ -3,4 +3,5 @@ export interface SaveUserOpts {
requirePassword?: boolean requirePassword?: boolean
currentUserId?: string currentUserId?: string
skipPasswordValidation?: boolean skipPasswordValidation?: boolean
allowChangingEmail?: boolean
} }

View File

@ -574,6 +574,41 @@ describe("scim", () => {
expect(events.user.updated).toHaveBeenCalledTimes(1) expect(events.user.updated).toHaveBeenCalledTimes(1)
}) })
it("an existing user's email can be updated", async () => {
const newEmail = structures.generator.email()
const body: ScimUpdateRequest = {
schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
Operations: [
{
op: "Replace",
path: 'emails[type eq "work"].value',
value: newEmail,
},
],
}
const response = await patchScimUser({ id: user.id, body })
const expectedScimUser: ScimUserResponse = {
...user,
emails: [
{
value: newEmail,
type: "work",
primary: true,
},
],
}
expect(response).toEqual(expectedScimUser)
const persistedUser = await config.api.scimUsersAPI.find(user.id)
expect(persistedUser).toEqual(expectedScimUser)
expect((await config.api.users.getUser(user.id)).body).toEqual(
expect.objectContaining({ _id: user.id, email: newEmail })
)
})
}) })
describe("DELETE /api/global/scim/v2/users/:id", () => { describe("DELETE /api/global/scim/v2/users/:id", () => {

View File

@ -16112,10 +16112,10 @@ mongodb-connection-string-url@^3.0.0:
"@types/whatwg-url" "^11.0.2" "@types/whatwg-url" "^11.0.2"
whatwg-url "^13.0.0" whatwg-url "^13.0.0"
mongodb@^6.8.0: mongodb@6.7.0:
version "6.8.0" version "6.7.0"
resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.8.0.tgz#680450f113cdea6d2d9f7121fe57cd29111fd2ce" resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-6.7.0.tgz#f86e51e6530e6a2ca4a99d7cfdf6f409223ac199"
integrity sha512-HGQ9NWDle5WvwMnrvUxsFYPd3JEbqD3RgABHBQRuoCEND0qzhsd0iH5ypHsf1eJ+sXmvmyKpP+FLOKY8Il7jMw== integrity sha512-TMKyHdtMcO0fYBNORiYdmM25ijsHs+Njs963r4Tro4OQZzqYigAzYQouwWRg4OIaiLRUEGUh/1UAcH5lxdSLIA==
dependencies: dependencies:
"@mongodb-js/saslprep" "^1.1.5" "@mongodb-js/saslprep" "^1.1.5"
bson "^6.7.0" bson "^6.7.0"