1
0
Fork 0
mirror of synced 2024-09-08 13:41:09 +12:00

Merge pull request #10568 from Budibase/feature/sync-automations

Synchronous automation for App actions and webhook
This commit is contained in:
Peter Clement 2023-05-26 17:27:21 +01:00 committed by GitHub
commit 70dcbbd292
47 changed files with 643 additions and 105 deletions

View file

@ -90,6 +90,10 @@ export const useScimIntegration = () => {
return useFeature(Feature.SCIM) return useFeature(Feature.SCIM)
} }
export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS)
}
// QUOTAS // QUOTAS
export const setAutomationLogsQuota = (value: number) => { export const setAutomationLogsQuota = (value: number) => {

View file

@ -1,3 +1,4 @@
import { ActionStepID } from "constants/backend/automations"
import { TableNames } from "../constants" import { TableNames } from "../constants"
import { import {
AUTO_COLUMN_DISPLAY_NAMES, AUTO_COLUMN_DISPLAY_NAMES,
@ -53,3 +54,9 @@ export function buildAutoColumn(tableName, name, subtype) {
} }
return base return base
} }
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View file

@ -6,24 +6,48 @@
Body, Body,
Icon, Icon,
notifications, notifications,
Tags,
Tag,
} from "@budibase/bbui" } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import { admin } from "stores/portal" import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions" import { externalActions } from "./ExternalActions"
import { TriggerStepID } from "constants/backend/automations"
import { checkForCollectStep } from "builderStore/utils"
export let blockIdx export let blockIdx
export let lastStep
const disabled = { let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
SEND_EMAIL_SMTP: { let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
}
let selectedAction let selectedAction
let actionVal let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION) let actions = Object.entries($automationStore.blockDefinitions.ACTION)
$: collectBlockExists = checkForCollectStep($selectedAutomation)
const disabled = () => {
return {
SEND_EMAIL_SMTP: {
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
COLLECT: {
disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists,
message: collectDisabledMessage(),
},
}
}
const collectDisabledMessage = () => {
if (collectBlockExists) {
return "Only one Collect step allowed"
}
if (!lastStep) {
return "Only available as the last step"
}
}
const external = actions.reduce((acc, elm) => { const external = actions.reduce((acc, elm) => {
const [k, v] = elm const [k, v] = elm
if (!v.internal && !v.custom) { if (!v.internal && !v.custom) {
@ -38,6 +62,15 @@
acc[k] = v acc[k] = v
} }
delete acc.LOOP delete acc.LOOP
// Filter out Collect block if not App Action or Webhook
if (
!collectBlockAllowedSteps.includes(
$selectedAutomation.definition.trigger.stepId
)
) {
delete acc.COLLECT
}
return acc return acc
}, {}) }, {})
@ -48,7 +81,6 @@
} }
return acc return acc
}, {}) }, {})
console.log(plugins)
const selectAction = action => { const selectAction = action => {
actionVal = action actionVal = action
@ -72,7 +104,7 @@
<ModalContent <ModalContent
title="Add automation step" title="Add automation step"
confirmText="Save" confirmText="Save"
size="M" size="L"
disabled={!selectedAction} disabled={!selectedAction}
onConfirm={addBlockToAutomation} onConfirm={addBlockToAutomation}
> >
@ -107,7 +139,7 @@
<Detail size="S">Actions</Detail> <Detail size="S">Actions</Detail>
<div class="item-list"> <div class="item-list">
{#each Object.entries(internal) as [idx, action]} {#each Object.entries(internal) as [idx, action]}
{@const isDisabled = disabled[idx] && disabled[idx].disabled} {@const isDisabled = disabled()[idx] && disabled()[idx].disabled}
<div <div
class="item" class="item"
class:disabled={isDisabled} class:disabled={isDisabled}
@ -117,8 +149,14 @@
<div class="item-body"> <div class="item-body">
<Icon name={action.icon} /> <Icon name={action.icon} />
<Body size="XS">{action.name}</Body> <Body size="XS">{action.name}</Body>
{#if isDisabled} {#if isDisabled && !syncAutomationsEnabled}
<Icon name="Help" tooltip={disabled[idx].message} /> <div class="tag-color">
<Tags>
<Tag icon="LockClosed">Business</Tag>
</Tags>
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{/if} {/if}
</div> </div>
</div> </div>
@ -152,6 +190,7 @@
display: flex; display: flex;
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
gap: var(--spacing-m); gap: var(--spacing-m);
align-items: center;
} }
.item-list { .item-list {
display: grid; display: grid;
@ -181,4 +220,8 @@
.disabled :global(.spectrum-Body) { .disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
} }
.tag-color :global(.spectrum-Tags-item) {
background: var(--spectrum-global-color-gray-200);
}
</style> </style>

View file

@ -17,7 +17,11 @@
import ActionModal from "./ActionModal.svelte" import ActionModal from "./ActionModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte" import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte" import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
import { ActionStepID, TriggerStepID } from "constants/backend/automations" import {
ActionStepID,
TriggerStepID,
Features,
} from "constants/backend/automations"
import { permissions } from "stores/backend" import { permissions } from "stores/backend"
export let block export let block
@ -31,6 +35,9 @@
let showLooping = false let showLooping = false
let role let role
$: collectBlockExists = $selectedAutomation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
$: automationId = $selectedAutomation?._id $: automationId = $selectedAutomation?._id
$: showBindingPicker = $: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW || block.stepId === ActionStepID.CREATE_ROW ||
@ -184,7 +191,7 @@
{#if !isTrigger} {#if !isTrigger}
<div> <div>
<div class="block-options"> <div class="block-options">
{#if !loopBlock} {#if block?.features?.[Features.LOOPING] || !block.features}
<ActionButton on:click={() => addLooping()} icon="Reuse"> <ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping Add Looping
</ActionButton> </ActionButton>
@ -224,21 +231,28 @@
</Layout> </Layout>
</div> </div>
{/if} {/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
</div> </div>
<div class="separator" /> {#if !collectBlockExists || !lastStep}
<Icon on:click={() => actionModal.show()} hoverable name="AddCircle" size="S" />
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" /> <div class="separator" />
<Icon
on:click={() => actionModal.show()}
hoverable
name="AddCircle"
size="S"
/>
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" />
{/if}
{/if} {/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
<style> <style>
.delete-padding { .delete-padding {
padding-left: 30px; padding-left: 30px;

View file

@ -126,8 +126,7 @@
} }
const getAllBindings = (bindings, eventContextBindings, actions) => { const getAllBindings = (bindings, eventContextBindings, actions) => {
let allBindings = eventContextBindings.concat(bindings) let allBindings = []
if (!actions) { if (!actions) {
return [] return []
} }
@ -145,14 +144,35 @@
.forEach(action => { .forEach(action => {
// Check we have a binding for this action, and generate one if not // Check we have a binding for this action, and generate one if not
const stateBinding = makeStateBinding(action.parameters.key) const stateBinding = makeStateBinding(action.parameters.key)
const hasKey = allBindings.some(binding => { const hasKey = bindings.some(binding => {
return binding.runtimeBinding === stateBinding.runtimeBinding return binding.runtimeBinding === stateBinding.runtimeBinding
}) })
if (!hasKey) { if (!hasKey) {
allBindings.push(stateBinding) bindings.push(stateBinding)
} }
}) })
// Get which indexes are asynchronous automations as we want to filter them out from the bindings
const asynchronousAutomationIndexes = actions
.map((action, index) => {
if (
action[EVENT_TYPE_KEY] === "Trigger Automation" &&
!action.parameters?.synchronous
) {
return index
}
})
.filter(index => index !== undefined)
// Based on the above, filter out the asynchronous automations from the bindings
if (asynchronousAutomationIndexes) {
allBindings = eventContextBindings
.filter((binding, index) => {
return !asynchronousAutomationIndexes.includes(index)
})
.concat(bindings)
} else {
allBindings = eventContextBindings.concat(bindings)
}
return allBindings return allBindings
} }
</script> </script>

View file

@ -1,8 +1,8 @@
<script> <script>
import { Select, Label, Input, Checkbox } from "@budibase/bbui" import { Select, Label, Input, Checkbox, Icon } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
import { TriggerStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
export let parameters = {} export let parameters = {}
export let bindings = [] export let bindings = []
@ -16,6 +16,14 @@
? AUTOMATION_STATUS.EXISTING ? AUTOMATION_STATUS.EXISTING
: AUTOMATION_STATUS.NEW : AUTOMATION_STATUS.NEW
$: {
if (automationStatus === AUTOMATION_STATUS.NEW) {
parameters.synchronous = false
}
parameters.synchronous = automations.find(
automation => automation._id === parameters.automationId
)?.synchronous
}
$: automations = $automationStore.automations $: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP) .filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
.map(automation => { .map(automation => {
@ -23,10 +31,15 @@
automation.definition.trigger.inputs.fields || {} automation.definition.trigger.inputs.fields || {}
).map(([name, type]) => ({ name, type })) ).map(([name, type]) => ({ name, type }))
let hasCollectBlock = automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
return { return {
name: automation.name, name: automation.name,
_id: automation._id, _id: automation._id,
schema, schema,
synchronous: hasCollectBlock,
} }
}) })
$: hasAutomations = automations && automations.length > 0 $: hasAutomations = automations && automations.length > 0
@ -35,6 +48,8 @@
) )
$: selectedSchema = selectedAutomation?.schema $: selectedSchema = selectedAutomation?.schema
$: error = parameters.timeout > 120 ? "Timeout must be less than 120s" : null
const onFieldsChanged = e => { const onFieldsChanged = e => {
parameters.fields = Object.entries(e.detail || {}).reduce( parameters.fields = Object.entries(e.detail || {}).reduce(
(acc, [key, value]) => { (acc, [key, value]) => {
@ -57,6 +72,14 @@
parameters.fields = {} parameters.fields = {}
parameters.automationId = automations[0]?._id parameters.automationId = automations[0]?._id
} }
const onChange = value => {
let automationId = value.detail
parameters.synchronous = automations.find(
automation => automation._id === automationId
)?.synchronous
parameters.automationId = automationId
}
</script> </script>
<div class="root"> <div class="root">
@ -85,6 +108,7 @@
{#if automationStatus === AUTOMATION_STATUS.EXISTING} {#if automationStatus === AUTOMATION_STATUS.EXISTING}
<Select <Select
on:change={onChange}
bind:value={parameters.automationId} bind:value={parameters.automationId}
placeholder="Choose automation" placeholder="Choose automation"
options={automations} options={automations}
@ -98,6 +122,29 @@
/> />
{/if} {/if}
{#if parameters.synchronous}
<Label small />
<div class="synchronous-info">
<Icon name="Info" />
<div>
<i
>This automation will run synchronously as it contains a Collect
step</i
>
</div>
</div>
<Label small />
<div class="timeout-width">
<Input
label="Timeout in seconds (120 max)"
type="number"
{error}
bind:value={parameters.timeout}
/>
</div>
{/if}
<Label small /> <Label small />
<Checkbox <Checkbox
text="Do not display default notification" text="Do not display default notification"
@ -133,6 +180,9 @@
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
} }
.timeout-width {
width: 30%;
}
.params { .params {
display: grid; display: grid;
@ -142,6 +192,11 @@
align-items: center; align-items: center;
} }
.synchronous-info {
display: flex;
gap: var(--spacing-s);
}
.fields { .fields {
margin-top: var(--spacing-l); margin-top: var(--spacing-l);
display: grid; display: grid;

View file

@ -57,7 +57,13 @@
{ {
"name": "Trigger Automation", "name": "Trigger Automation",
"type": "application", "type": "application",
"component": "TriggerAutomation" "component": "TriggerAutomation",
"context": [
{
"label": "Automation Result",
"value": "result"
}
]
}, },
{ {
"name": "Update Field Value", "name": "Update Field Value",

View file

@ -20,9 +20,14 @@ export const ActionStepID = {
FILTER: "FILTER", FILTER: "FILTER",
QUERY_ROWS: "QUERY_ROWS", QUERY_ROWS: "QUERY_ROWS",
LOOP: "LOOP", LOOP: "LOOP",
COLLECT: "COLLECT",
// these used to be lowercase step IDs, maintain for backwards compat // these used to be lowercase step IDs, maintain for backwards compat
discord: "discord", discord: "discord",
slack: "slack", slack: "slack",
zapier: "zapier", zapier: "zapier",
integromat: "integromat", integromat: "integromat",
} }
export const Features = {
LOOPING: "LOOPING",
}

View file

@ -116,6 +116,9 @@ export const createLicensingStore = () => {
const auditLogsEnabled = license.features.includes( const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS Constants.Features.AUDIT_LOGS
) )
const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
store.update(state => { store.update(state => {
return { return {
...state, ...state,
@ -130,6 +133,7 @@ export const createLicensingStore = () => {
environmentVariablesEnabled, environmentVariablesEnabled,
auditLogsEnabled, auditLogsEnabled,
enforceableSSO, enforceableSSO,
syncAutomationsEnabled,
} }
}) })
}, },

View file

@ -122,13 +122,23 @@ const deleteRowHandler = async action => {
} }
const triggerAutomationHandler = async action => { const triggerAutomationHandler = async action => {
const { fields, notificationOverride } = action.parameters const { fields, notificationOverride, timeout } = action.parameters
if (fields) { if (fields) {
try { try {
await API.triggerAutomation({ const result = await API.triggerAutomation({
automationId: action.parameters.automationId, automationId: action.parameters.automationId,
fields, fields,
timeout,
}) })
// Value will exist if automation is synchronous, so return it.
if (result.value) {
if (!notificationOverride) {
notificationStore.actions.success("Automation ran successfully")
}
return { result }
}
if (!notificationOverride) { if (!notificationOverride) {
notificationStore.actions.success("Automation triggered") notificationStore.actions.success("Automation triggered")
} }
@ -138,7 +148,6 @@ const triggerAutomationHandler = async action => {
} }
} }
} }
const navigationHandler = action => { const navigationHandler = action => {
const { url, peek, externalNewTab } = action.parameters const { url, peek, externalNewTab } = action.parameters
routeStore.actions.navigate(url, peek, externalNewTab) routeStore.actions.navigate(url, peek, externalNewTab)

View file

@ -4,10 +4,10 @@ export const buildAutomationEndpoints = API => ({
* @param automationId the ID of the automation to trigger * @param automationId the ID of the automation to trigger
* @param fields the fields to trigger the automation with * @param fields the fields to trigger the automation with
*/ */
triggerAutomation: async ({ automationId, fields }) => { triggerAutomation: async ({ automationId, fields, timeout }) => {
return await API.post({ return await API.post({
url: `/api/automations/${automationId}/trigger`, url: `/api/automations/${automationId}/trigger`,
body: { fields }, body: { fields, timeout },
}) })
}, },

View file

@ -70,6 +70,7 @@ export const Features = {
ENFORCEABLE_SSO: "enforceableSSO", ENFORCEABLE_SSO: "enforceableSSO",
BRANDING: "branding", BRANDING: "branding",
SCIM: "scim", SCIM: "scim",
SYNC_AUTOMATIONS: "syncAutomations",
} }
// Role IDs // Role IDs

@ -1 +1 @@
Subproject commit 2d6f999586fcb62bc98b26416ee406f6328e6615 Subproject commit 3d307df17a53ba25bbf5d9ddc94b1706c813eb6f

View file

@ -14,9 +14,16 @@ import { deleteEntityMetadata } from "../../utilities"
import { MetadataTypes } from "../../constants" import { MetadataTypes } from "../../constants"
import { setTestFlag, clearTestFlag } from "../../utilities/redis" import { setTestFlag, clearTestFlag } from "../../utilities/redis"
import { context, cache, events } from "@budibase/backend-core" import { context, cache, events } from "@budibase/backend-core"
import { automations } from "@budibase/pro" import { automations, features } from "@budibase/pro"
import { Automation, BBContext } from "@budibase/types" import {
Automation,
AutomationActionStepId,
AutomationResults,
BBContext,
} from "@budibase/types"
import { getActionDefinitions as actionDefs } from "../../automations/actions" import { getActionDefinitions as actionDefs } from "../../automations/actions"
import sdk from "../../sdk"
import { db as dbCore } from "@budibase/backend-core"
async function getActionDefinitions() { async function getActionDefinitions() {
return removeDeprecated(await actionDefs()) return removeDeprecated(await actionDefs())
@ -257,13 +264,34 @@ export async function getDefinitionList(ctx: BBContext) {
export async function trigger(ctx: BBContext) { export async function trigger(ctx: BBContext) {
const db = context.getAppDB() const db = context.getAppDB()
let automation = await db.get(ctx.params.id) let automation = await db.get(ctx.params.id)
await triggers.externalTrigger(automation, {
...ctx.request.body, let hasCollectStep = sdk.automations.utils.checkForCollectStep(automation)
appId: ctx.appId, if (hasCollectStep && (await features.isSyncAutomationsEnabled())) {
}) const response: AutomationResults = await triggers.externalTrigger(
ctx.body = { automation,
message: `Automation ${automation._id} has been triggered.`, {
automation, fields: ctx.request.body.fields,
timeout: ctx.request.body.timeout * 1000 || 120000,
},
{ getResponses: true }
)
let collectedValue = response.steps.find(
step => step.stepId === AutomationActionStepId.COLLECT
)
ctx.body = collectedValue?.outputs
} else {
if (ctx.appId && !dbCore.isProdAppID(ctx.appId)) {
ctx.throw(400, "Only apps in production support this endpoint")
}
await triggers.externalTrigger(automation, {
...ctx.request.body,
appId: ctx.appId,
})
ctx.body = {
message: `Automation ${automation._id} has been triggered.`,
automation,
}
} }
} }

View file

@ -6,8 +6,11 @@ import {
WebhookActionType, WebhookActionType,
BBContext, BBContext,
Automation, Automation,
AutomationActionStepId,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
import * as pro from "@budibase/pro"
const toJsonSchema = require("to-json-schema") const toJsonSchema = require("to-json-schema")
const validate = require("jsonschema").validate const validate = require("jsonschema").validate
@ -78,15 +81,36 @@ export async function trigger(ctx: BBContext) {
if (webhook.action.type === WebhookActionType.AUTOMATION) { if (webhook.action.type === WebhookActionType.AUTOMATION) {
// trigger with both the pure request and then expand it // trigger with both the pure request and then expand it
// incase the user has produced a schema to bind to // incase the user has produced a schema to bind to
await triggers.externalTrigger(target, { let hasCollectStep = sdk.automations.utils.checkForCollectStep(target)
body: ctx.request.body,
...ctx.request.body, if (hasCollectStep && (await pro.features.isSyncAutomationsEnabled())) {
appId: prodAppId, const response = await triggers.externalTrigger(
}) target,
} {
ctx.status = 200 body: ctx.request.body,
ctx.body = { ...ctx.request.body,
message: "Webhook trigger fired successfully", appId: prodAppId,
},
{ getResponses: true }
)
let collectedValue = response.steps.find(
(step: any) => step.stepId === AutomationActionStepId.COLLECT
)
ctx.status = 200
ctx.body = collectedValue.outputs
} else {
await triggers.externalTrigger(target, {
body: ctx.request.body,
...ctx.request.body,
appId: prodAppId,
})
ctx.status = 200
ctx.body = {
message: "Webhook trigger fired successfully",
}
}
} }
} catch (err: any) { } catch (err: any) {
if (err.status === 404) { if (err.status === 404) {

View file

@ -65,7 +65,6 @@ router
) )
.post( .post(
"/api/automations/:id/trigger", "/api/automations/:id/trigger",
appInfoMiddleware({ appType: AppType.PROD }),
paramResource("id"), paramResource("id"),
authorized( authorized(
permissions.PermissionType.AUTOMATION, permissions.PermissionType.AUTOMATION,

View file

@ -1,15 +1,27 @@
const { import {
checkBuilderEndpoint, checkBuilderEndpoint,
getAllTableRows, getAllTableRows,
clearAllAutomations, clearAllAutomations,
testAutomation, testAutomation,
} = require("./utilities/TestFunctions") } from "./utilities/TestFunctions"
const setup = require("./utilities") import * as setup from "./utilities"
const { basicAutomation, newAutomation, automationTrigger, automationStep } = setup.structures import {
const MAX_RETRIES = 4 TRIGGER_DEFINITIONS,
const { TRIGGER_DEFINITIONS, BUILTIN_ACTION_DEFINITIONS } = require("../../../automations") BUILTIN_ACTION_DEFINITIONS,
const { events } = require("@budibase/backend-core") } from "../../../automations"
import { events } from "@budibase/backend-core"
import sdk from "../../../sdk"
import { Automation } from "@budibase/types"
import { mocks } from "@budibase/backend-core/tests"
const MAX_RETRIES = 4
let {
basicAutomation,
newAutomation,
automationTrigger,
automationStep,
collectAutomation,
} = setup.structures
jest.setTimeout(30000) jest.setTimeout(30000)
@ -24,6 +36,7 @@ describe("/automations", () => {
}) })
beforeEach(() => { beforeEach(() => {
// @ts-ignore
events.automation.deleted.mockClear() events.automation.deleted.mockClear()
}) })
@ -32,7 +45,7 @@ describe("/automations", () => {
const res = await request const res = await request
.get(`/api/automations/action/list`) .get(`/api/automations/action/list`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(Object.keys(res.body).length).not.toEqual(0) expect(Object.keys(res.body).length).not.toEqual(0)
@ -42,7 +55,7 @@ describe("/automations", () => {
const res = await request const res = await request
.get(`/api/automations/trigger/list`) .get(`/api/automations/trigger/list`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(Object.keys(res.body).length).not.toEqual(0) expect(Object.keys(res.body).length).not.toEqual(0)
@ -52,14 +65,18 @@ describe("/automations", () => {
const res = await request const res = await request
.get(`/api/automations/definitions/list`) .get(`/api/automations/definitions/list`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
let definitionsLength = Object.keys(BUILTIN_ACTION_DEFINITIONS).length let definitionsLength = Object.keys(BUILTIN_ACTION_DEFINITIONS).length
definitionsLength-- // OUTGOING_WEBHOOK is deprecated definitionsLength-- // OUTGOING_WEBHOOK is deprecated
expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual(definitionsLength) expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual(
expect(Object.keys(res.body.trigger).length).toEqual(Object.keys(TRIGGER_DEFINITIONS).length) definitionsLength
)
expect(Object.keys(res.body.trigger).length).toEqual(
Object.keys(TRIGGER_DEFINITIONS).length
)
}) })
}) })
@ -72,7 +89,7 @@ describe("/automations", () => {
.post(`/api/automations`) .post(`/api/automations`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.send(automation) .send(automation)
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.message).toEqual("Automation created successfully") expect(res.body.message).toEqual("Automation created successfully")
@ -91,7 +108,7 @@ describe("/automations", () => {
.post(`/api/automations`) .post(`/api/automations`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.send(automation) .send(automation)
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.message).toEqual("Automation created successfully") expect(res.body.message).toEqual("Automation created successfully")
@ -107,7 +124,7 @@ describe("/automations", () => {
config, config,
method: "POST", method: "POST",
url: `/api/automations`, url: `/api/automations`,
body: automation body: automation,
}) })
}) })
}) })
@ -118,7 +135,7 @@ describe("/automations", () => {
const res = await request const res = await request
.get(`/api/automations/${automation._id}`) .get(`/api/automations/${automation._id}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body._id).toEqual(automation._id) expect(res.body._id).toEqual(automation._id)
expect(res.body._rev).toEqual(automation._rev) expect(res.body._rev).toEqual(automation._rev)
@ -134,8 +151,8 @@ describe("/automations", () => {
row: { row: {
name: "{{trigger.row.name}}", name: "{{trigger.row.name}}",
description: "{{trigger.row.description}}", description: "{{trigger.row.description}}",
tableId: table._id tableId: table._id,
} },
} }
automation.appId = config.appId automation.appId = config.appId
automation = await config.createAutomation(automation) automation = await config.createAutomation(automation)
@ -162,23 +179,68 @@ describe("/automations", () => {
}) })
}) })
describe("update", () => { describe("trigger", () => {
it("does not trigger an automation when not synchronous and in dev", async () => {
let automation = newAutomation()
automation = await config.createAutomation(automation)
const res = await request
.post(`/api/automations/${automation._id}/trigger`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(400)
const update = async (automation) => { expect(res.body.message).toEqual(
"Only apps in production support this endpoint"
)
})
it("triggers a synchronous automation", async () => {
mocks.licenses.useSyncAutomations()
let automation = collectAutomation()
automation = await config.createAutomation(automation)
const res = await request
.post(`/api/automations/${automation._id}/trigger`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.success).toEqual(true)
expect(res.body.value).toEqual([1, 2, 3])
})
it("triggers an asynchronous automation", async () => {
let automation = newAutomation()
automation = await config.createAutomation(automation)
await config.publish()
const res = await request
.post(`/api/automations/${automation._id}/trigger`)
.set(config.defaultHeaders({}, true))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual(
`Automation ${automation._id} has been triggered.`
)
})
})
describe("update", () => {
const update = async (automation: Automation) => {
return request return request
.put(`/api/automations`) .put(`/api/automations`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.send(automation) .send(automation)
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
} }
const updateWithPost = async (automation) => { const updateWithPost = async (automation: Automation) => {
return request return request
.post(`/api/automations`) .post(`/api/automations`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.send(automation) .send(automation)
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
} }
@ -199,7 +261,9 @@ describe("/automations", () => {
expect(automationRes._rev).not.toEqual(automation._rev) expect(automationRes._rev).not.toEqual(automation._rev)
// content updates // content updates
expect(automationRes.name).toEqual("Updated Name") expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual(`Automation ${automation._id} updated successfully.`) expect(message).toEqual(
`Automation ${automation._id} updated successfully.`
)
// events // events
expect(events.automation.created).not.toBeCalled() expect(events.automation.created).not.toBeCalled()
expect(events.automation.stepCreated).not.toBeCalled() expect(events.automation.stepCreated).not.toBeCalled()
@ -207,7 +271,6 @@ describe("/automations", () => {
expect(events.automation.triggerUpdated).not.toBeCalled() expect(events.automation.triggerUpdated).not.toBeCalled()
}) })
it("updates a automations name using POST request", async () => { it("updates a automations name using POST request", async () => {
let automation = newAutomation() let automation = newAutomation()
await config.createAutomation(automation) await config.createAutomation(automation)
@ -226,7 +289,9 @@ describe("/automations", () => {
expect(automationRes._rev).not.toEqual(automation._rev) expect(automationRes._rev).not.toEqual(automation._rev)
// content updates // content updates
expect(automationRes.name).toEqual("Updated Name") expect(automationRes.name).toEqual("Updated Name")
expect(message).toEqual(`Automation ${automation._id} updated successfully.`) expect(message).toEqual(
`Automation ${automation._id} updated successfully.`
)
// events // events
expect(events.automation.created).not.toBeCalled() expect(events.automation.created).not.toBeCalled()
expect(events.automation.stepCreated).not.toBeCalled() expect(events.automation.stepCreated).not.toBeCalled()
@ -237,7 +302,9 @@ describe("/automations", () => {
it("updates an automation trigger", async () => { it("updates an automation trigger", async () => {
let automation = newAutomation() let automation = newAutomation()
automation = await config.createAutomation(automation) automation = await config.createAutomation(automation)
automation.definition.trigger = automationTrigger(TRIGGER_DEFINITIONS.WEBHOOK) automation.definition.trigger = automationTrigger(
TRIGGER_DEFINITIONS.WEBHOOK
)
jest.clearAllMocks() jest.clearAllMocks()
await update(automation) await update(automation)
@ -266,7 +333,6 @@ describe("/automations", () => {
expect(events.automation.triggerUpdated).not.toBeCalled() expect(events.automation.triggerUpdated).not.toBeCalled()
}) })
it("removes automation steps", async () => { it("removes automation steps", async () => {
let automation = newAutomation() let automation = newAutomation()
automation.definition.steps.push(automationStep()) automation.definition.steps.push(automationStep())
@ -305,11 +371,11 @@ describe("/automations", () => {
it("return all the automations for an instance", async () => { it("return all the automations for an instance", async () => {
await clearAllAutomations(config) await clearAllAutomations(config)
const autoConfig = basicAutomation() const autoConfig = basicAutomation()
automation = await config.createAutomation(autoConfig) await config.createAutomation(autoConfig)
const res = await request const res = await request
.get(`/api/automations`) .get(`/api/automations`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body[0]).toEqual(expect.objectContaining(autoConfig)) expect(res.body[0]).toEqual(expect.objectContaining(autoConfig))
@ -330,7 +396,7 @@ describe("/automations", () => {
const res = await request const res = await request
.delete(`/api/automations/${automation.id}/${automation.rev}`) .delete(`/api/automations/${automation.id}/${automation.rev}`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect('Content-Type', /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.id).toEqual(automation._id) expect(res.body.id).toEqual(automation._id)
@ -346,4 +412,13 @@ describe("/automations", () => {
}) })
}) })
}) })
describe("checkForCollectStep", () => {
it("should return true if a collect step exists in an automation", async () => {
let automation = collectAutomation()
await config.createAutomation(automation)
let res = await sdk.automations.utils.checkForCollectStep(automation)
expect(res).toEqual(true)
})
})
}) })

View file

@ -1,11 +1,13 @@
const setup = require("./utilities") import { Webhook } from "@budibase/types"
const { checkBuilderEndpoint } = require("./utilities/TestFunctions") import * as setup from "./utilities"
const { basicWebhook, basicAutomation } = setup.structures import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import { mocks } from "@budibase/backend-core/tests"
const { basicWebhook, basicAutomation, collectAutomation } = setup.structures
describe("/webhooks", () => { describe("/webhooks", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let webhook let webhook: Webhook
afterAll(setup.afterAll) afterAll(setup.afterAll)
@ -13,10 +15,11 @@ describe("/webhooks", () => {
config.modeSelf() config.modeSelf()
await config.init() await config.init()
const autoConfig = basicAutomation() const autoConfig = basicAutomation()
autoConfig.definition.trigger = { autoConfig.definition.trigger.schema = {
schema: { outputs: { properties: {} } }, outputs: { properties: {} },
inputs: {}, inputs: { properties: {} },
} }
autoConfig.definition.trigger.inputs = {}
await config.createAutomation(autoConfig) await config.createAutomation(autoConfig)
webhook = await config.createWebhook() webhook = await config.createWebhook()
} }
@ -70,7 +73,7 @@ describe("/webhooks", () => {
describe("delete", () => { describe("delete", () => {
beforeAll(setupTest) beforeAll(setupTest)
it("should successfully delete", async () => { it("should successfully delete", async () => {
const res = await request const res = await request
.delete(`/api/webhooks/${webhook._id}/${webhook._rev}`) .delete(`/api/webhooks/${webhook._id}/${webhook._rev}`)
@ -97,7 +100,7 @@ describe("/webhooks", () => {
const res = await request const res = await request
.post(`/api/webhooks/schema/${config.getAppId()}/${webhook._id}`) .post(`/api/webhooks/schema/${config.getAppId()}/${webhook._id}`)
.send({ .send({
a: 1 a: 1,
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
@ -112,7 +115,7 @@ describe("/webhooks", () => {
expect(fetch.body[0]).toBeDefined() expect(fetch.body[0]).toBeDefined()
expect(fetch.body[0].bodySchema).toEqual({ expect(fetch.body[0].bodySchema).toEqual({
properties: { properties: {
a: { type: "integer" } a: { type: "integer" },
}, },
type: "object", type: "object",
}) })
@ -131,4 +134,23 @@ describe("/webhooks", () => {
expect(res.body.message).toBeDefined() expect(res.body.message).toBeDefined()
}) })
}) })
})
it("should trigger a synchronous webhook call ", async () => {
mocks.licenses.useSyncAutomations()
let automation = collectAutomation()
let newAutomation = await config.createAutomation(automation)
let syncWebhook = await config.createWebhook(
basicWebhook(newAutomation._id)
)
// replicate changes before checking webhook
await config.publish()
const res = await request
.post(`/api/webhooks/trigger/${config.prodAppId}/${syncWebhook._id}`)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.success).toEqual(true)
expect(res.body.value).toEqual([1, 2, 3])
})
})

View file

@ -14,6 +14,7 @@ import * as filter from "./steps/filter"
import * as delay from "./steps/delay" import * as delay from "./steps/delay"
import * as queryRow from "./steps/queryRows" import * as queryRow from "./steps/queryRows"
import * as loop from "./steps/loop" import * as loop from "./steps/loop"
import * as collect from "./steps/collect"
import env from "../environment" import env from "../environment"
import { import {
AutomationStepSchema, AutomationStepSchema,
@ -39,6 +40,7 @@ const ACTION_IMPLS: Record<
DELAY: delay.run, DELAY: delay.run,
FILTER: filter.run, FILTER: filter.run,
QUERY_ROWS: queryRow.run, QUERY_ROWS: queryRow.run,
COLLECT: collect.run,
// these used to be lowercase step IDs, maintain for backwards compat // these used to be lowercase step IDs, maintain for backwards compat
discord: discord.run, discord: discord.run,
slack: slack.run, slack: slack.run,
@ -59,6 +61,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
FILTER: filter.definition, FILTER: filter.definition,
QUERY_ROWS: queryRow.definition, QUERY_ROWS: queryRow.definition,
LOOP: loop.definition, LOOP: loop.definition,
COLLECT: collect.definition,
// these used to be lowercase step IDs, maintain for backwards compat // these used to be lowercase step IDs, maintain for backwards compat
discord: discord.definition, discord: discord.definition,
slack: slack.definition, slack: slack.definition,

View file

@ -5,6 +5,7 @@ import environment from "../../environment"
import { import {
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
AutomationIOType, AutomationIOType,
AutomationStepInput, AutomationStepInput,
AutomationStepSchema, AutomationStepSchema,
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
description: "Run a bash script", description: "Run a bash script",
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.EXECUTE_BASH, stepId: AutomationActionStepId.EXECUTE_BASH,
inputs: {}, inputs: {},
schema: { schema: {

View file

@ -0,0 +1,58 @@
import {
AutomationActionStepId,
AutomationStepSchema,
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationFeature,
} from "@budibase/types"
export const definition: AutomationStepSchema = {
name: "Collect Data",
tagline: "Collect data to be sent to design",
icon: "Collection",
description:
"Collects specified data so it can be provided to the design section",
type: AutomationStepType.ACTION,
internal: true,
features: {},
stepId: AutomationActionStepId.COLLECT,
inputs: {},
schema: {
inputs: {
properties: {
collection: {
type: AutomationIOType.STRING,
title: "What to Collect",
},
},
required: ["collection"],
},
outputs: {
properties: {
success: {
type: AutomationIOType.BOOLEAN,
description: "Whether the action was successful",
},
value: {
type: AutomationIOType.STRING,
description: "Collected data",
},
},
required: ["success", "value"],
},
},
}
export async function run({ inputs }: AutomationStepInput) {
if (!inputs.collection) {
return {
success: false,
}
} else {
return {
success: true,
value: inputs.collection,
}
}
}

View file

@ -4,6 +4,7 @@ import { buildCtx } from "./utils"
import { import {
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
AutomationIOType, AutomationIOType,
AutomationStepInput, AutomationStepInput,
AutomationStepSchema, AutomationStepSchema,
@ -17,6 +18,9 @@ export const definition: AutomationStepSchema = {
description: "Add a row to your database", description: "Add a row to your database",
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.CREATE_ROW, stepId: AutomationActionStepId.CREATE_ROW,
inputs: {}, inputs: {},
schema: { schema: {

View file

@ -14,6 +14,7 @@ export const definition: AutomationStepSchema = {
description: "Delay the automation until an amount of time has passed", description: "Delay the automation until an amount of time has passed",
stepId: AutomationActionStepId.DELAY, stepId: AutomationActionStepId.DELAY,
internal: true, internal: true,
features: {},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -8,6 +8,7 @@ import {
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationStepSchema = { export const definition: AutomationStepSchema = {
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
stepId: AutomationActionStepId.DELETE_ROW, stepId: AutomationActionStepId.DELETE_ROW,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -6,6 +6,7 @@ import {
AutomationStepInput, AutomationStepInput,
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
AutomationFeature,
} from "@budibase/types" } from "@budibase/types"
const DEFAULT_USERNAME = "Budibase Automate" const DEFAULT_USERNAME = "Budibase Automate"
@ -19,6 +20,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.discord, stepId: AutomationActionStepId.discord,
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: false, internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -4,6 +4,7 @@ import * as automationUtils from "../automationUtils"
import { import {
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
AutomationIOType, AutomationIOType,
AutomationStepInput, AutomationStepInput,
AutomationStepSchema, AutomationStepSchema,
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
stepId: AutomationActionStepId.EXECUTE_QUERY, stepId: AutomationActionStepId.EXECUTE_QUERY,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -4,6 +4,7 @@ import * as automationUtils from "../automationUtils"
import { import {
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
AutomationIOType, AutomationIOType,
AutomationStepInput, AutomationStepInput,
AutomationStepSchema, AutomationStepSchema,
@ -19,6 +20,9 @@ export const definition: AutomationStepSchema = {
internal: true, internal: true,
stepId: AutomationActionStepId.EXECUTE_SCRIPT, stepId: AutomationActionStepId.EXECUTE_SCRIPT,
inputs: {}, inputs: {},
features: {
[AutomationFeature.LOOPING]: true,
},
schema: { schema: {
inputs: { inputs: {
properties: { properties: {

View file

@ -28,6 +28,7 @@ export const definition: AutomationStepSchema = {
"Conditionally halt automations which do not meet certain conditions", "Conditionally halt automations which do not meet certain conditions",
type: AutomationStepType.LOGIC, type: AutomationStepType.LOGIC,
internal: true, internal: true,
features: {},
stepId: AutomationActionStepId.FILTER, stepId: AutomationActionStepId.FILTER,
inputs: { inputs: {
condition: FilterConditions.EQUAL, condition: FilterConditions.EQUAL,

View file

@ -13,6 +13,7 @@ export const definition: AutomationStepSchema = {
description: "Loop", description: "Loop",
stepId: AutomationActionStepId.LOOP, stepId: AutomationActionStepId.LOOP,
internal: true, internal: true,
features: {},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -6,6 +6,7 @@ import {
AutomationStepInput, AutomationStepInput,
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
AutomationFeature,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationStepSchema = { export const definition: AutomationStepSchema = {
@ -18,6 +19,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.integromat, stepId: AutomationActionStepId.integromat,
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: false, internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -22,6 +22,7 @@ export const definition: AutomationStepSchema = {
description: "Interact with the OpenAI ChatGPT API.", description: "Interact with the OpenAI ChatGPT API.",
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: true, internal: true,
features: {},
stepId: AutomationActionStepId.OPENAI, stepId: AutomationActionStepId.OPENAI,
inputs: { inputs: {
prompt: "", prompt: "",

View file

@ -4,6 +4,7 @@ import * as automationUtils from "../automationUtils"
import { import {
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
AutomationIOType, AutomationIOType,
AutomationStepInput, AutomationStepInput,
AutomationStepSchema, AutomationStepSchema,
@ -32,6 +33,9 @@ export const definition: AutomationStepSchema = {
description: "Send a request of specified method to a URL", description: "Send a request of specified method to a URL",
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.OUTGOING_WEBHOOK, stepId: AutomationActionStepId.OUTGOING_WEBHOOK,
inputs: { inputs: {
requestMethod: "POST", requestMethod: "POST",

View file

@ -6,6 +6,7 @@ import * as automationUtils from "../automationUtils"
import { import {
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
AutomationIOType, AutomationIOType,
AutomationStepInput, AutomationStepInput,
AutomationStepSchema, AutomationStepSchema,
@ -42,6 +43,9 @@ export const definition: AutomationStepSchema = {
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
stepId: AutomationActionStepId.QUERY_ROWS, stepId: AutomationActionStepId.QUERY_ROWS,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -6,6 +6,7 @@ import {
AutomationStepInput, AutomationStepInput,
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
AutomationFeature,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationStepSchema = { export const definition: AutomationStepSchema = {
@ -15,6 +16,9 @@ export const definition: AutomationStepSchema = {
name: "Send Email (SMTP)", name: "Send Email (SMTP)",
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.SEND_EMAIL_SMTP, stepId: AutomationActionStepId.SEND_EMAIL_SMTP,
inputs: {}, inputs: {},
schema: { schema: {

View file

@ -4,6 +4,7 @@ import {
AutomationStepInput, AutomationStepInput,
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
AutomationFeature,
} from "@budibase/types" } from "@budibase/types"
/** /**
@ -19,6 +20,9 @@ export const definition: AutomationStepSchema = {
description: "Logs the given text to the server (using console.log)", description: "Logs the given text to the server (using console.log)",
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.SERVER_LOG, stepId: AutomationActionStepId.SERVER_LOG,
inputs: { inputs: {
text: "", text: "",

View file

@ -6,6 +6,7 @@ import {
AutomationStepInput, AutomationStepInput,
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
AutomationFeature,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationStepSchema = { export const definition: AutomationStepSchema = {
@ -16,6 +17,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.slack, stepId: AutomationActionStepId.slack,
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: false, internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
inputs: {}, inputs: {},
schema: { schema: {
inputs: { inputs: {

View file

@ -4,6 +4,7 @@ import { buildCtx } from "./utils"
import { import {
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
AutomationFeature,
AutomationIOType, AutomationIOType,
AutomationStepInput, AutomationStepInput,
AutomationStepSchema, AutomationStepSchema,
@ -17,6 +18,9 @@ export const definition: AutomationStepSchema = {
description: "Update a row in your database", description: "Update a row in your database",
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: true, internal: true,
features: {
[AutomationFeature.LOOPING]: true,
},
stepId: AutomationActionStepId.UPDATE_ROW, stepId: AutomationActionStepId.UPDATE_ROW,
inputs: {}, inputs: {},
schema: { schema: {

View file

@ -6,6 +6,7 @@ import {
AutomationStepInput, AutomationStepInput,
AutomationStepType, AutomationStepType,
AutomationIOType, AutomationIOType,
AutomationFeature,
} from "@budibase/types" } from "@budibase/types"
export const definition: AutomationStepSchema = { export const definition: AutomationStepSchema = {
@ -13,6 +14,9 @@ export const definition: AutomationStepSchema = {
stepId: AutomationActionStepId.zapier, stepId: AutomationActionStepId.zapier,
type: AutomationStepType.ACTION, type: AutomationStepType.ACTION,
internal: false, internal: false,
features: {
[AutomationFeature.LOOPING]: true,
},
description: "Trigger a Zapier Zap via webhooks", description: "Trigger a Zapier Zap via webhooks",
tagline: "Trigger a Zapier Zap", tagline: "Trigger a Zapier Zap",
icon: "ri-flashlight-line", icon: "ri-flashlight-line",

View file

@ -10,6 +10,7 @@ import * as utils from "./utils"
import env from "../environment" import env from "../environment"
import { context, db as dbCore } from "@budibase/backend-core" import { context, db as dbCore } from "@budibase/backend-core"
import { Automation, Row, AutomationData, AutomationJob } from "@budibase/types" import { Automation, Row, AutomationData, AutomationJob } from "@budibase/types"
import { executeSynchronously } from "../threads/automation"
export const TRIGGER_DEFINITIONS = definitions export const TRIGGER_DEFINITIONS = definitions
const JOB_OPTS = { const JOB_OPTS = {
@ -91,7 +92,7 @@ emitter.on("row:delete", async function (event) {
export async function externalTrigger( export async function externalTrigger(
automation: Automation, automation: Automation,
params: { fields: Record<string, any> }, params: { fields: Record<string, any>; timeout?: number },
{ getResponses }: { getResponses?: boolean } = {} { getResponses }: { getResponses?: boolean } = {}
) { ) {
if ( if (
@ -118,7 +119,7 @@ export async function externalTrigger(
automation, automation,
} }
const job = { data } as AutomationJob const job = { data } as AutomationJob
return utils.processEvent(job) return executeSynchronously(job)
} else { } else {
return automationQueue.add(data, JOB_OPTS) return automationQueue.add(data, JOB_OPTS)
} }

View file

@ -1,5 +1,7 @@
import * as webhook from "./webhook" import * as webhook from "./webhook"
import * as utils from "./utils"
export default { export default {
webhook, webhook,
utils,
} }

View file

@ -0,0 +1,7 @@
import { Automation, AutomationActionStepId } from "@budibase/types"
export function checkForCollectStep(automation: Automation) {
return automation.definition.steps.some(
(step: any) => step.stepId === AutomationActionStepId.COLLECT
)
}

View file

@ -373,7 +373,7 @@ class TestConfiguration {
// HEADERS // HEADERS
defaultHeaders(extras = {}) { defaultHeaders(extras = {}, prodApp = false) {
const tenantId = this.getTenantId() const tenantId = this.getTenantId()
const authObj: AuthToken = { const authObj: AuthToken = {
userId: this.defaultUserValues.globalUserId, userId: this.defaultUserValues.globalUserId,
@ -390,7 +390,9 @@ class TestConfiguration {
...extras, ...extras,
} }
if (this.appId) { if (prodApp) {
headers[constants.Header.APP_ID] = this.prodAppId
} else if (this.appId) {
headers[constants.Header.APP_ID] = this.appId headers[constants.Header.APP_ID] = this.appId
} }
return headers return headers

View file

@ -199,6 +199,48 @@ export function loopAutomation(tableId: string, loopOpts?: any): Automation {
return automation as Automation return automation as Automation
} }
export function collectAutomation(tableId?: string): Automation {
const automation: any = {
name: "looping",
type: "automation",
definition: {
steps: [
{
id: "b",
type: "ACTION",
internal: true,
stepId: AutomationActionStepId.EXECUTE_SCRIPT,
inputs: {
code: "return [1,2,3]",
},
schema: BUILTIN_ACTION_DEFINITIONS.EXECUTE_SCRIPT.schema,
},
{
id: "c",
type: "ACTION",
internal: true,
stepId: AutomationActionStepId.COLLECT,
inputs: {
collection: "{{ literal steps.1.value }}",
},
schema: BUILTIN_ACTION_DEFINITIONS.SERVER_LOG.schema,
},
],
trigger: {
id: "a",
type: "TRIGGER",
event: "row:save",
stepId: AutomationTriggerStepId.ROW_SAVED,
inputs: {
tableId,
},
schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema,
},
},
}
return automation as Automation
}
export function basicRow(tableId: string) { export function basicRow(tableId: string) {
return { return {
name: "Test Contact", name: "Test Contact",

View file

@ -68,6 +68,7 @@ class Orchestrator {
constructor(job: AutomationJob) { constructor(job: AutomationJob) {
let automation = job.data.automation let automation = job.data.automation
let triggerOutput = job.data.event let triggerOutput = job.data.event
let timeout = job.data.event.timeout
const metadata = triggerOutput.metadata const metadata = triggerOutput.metadata
this._chainCount = metadata ? metadata.automationChainCount! : 0 this._chainCount = metadata ? metadata.automationChainCount! : 0
this._appId = triggerOutput.appId as string this._appId = triggerOutput.appId as string
@ -240,7 +241,9 @@ class Orchestrator {
let loopStepNumber: any = undefined let loopStepNumber: any = undefined
let loopSteps: LoopStep[] | undefined = [] let loopSteps: LoopStep[] | undefined = []
let metadata let metadata
let timeoutFlag = false
let wasLoopStep = false let wasLoopStep = false
let timeout = this._job.data.event.timeout
// check if this is a recurring automation, // check if this is a recurring automation,
if (isProdAppID(this._appId) && isRecurring(automation)) { if (isProdAppID(this._appId) && isRecurring(automation)) {
metadata = await this.getMetadata() metadata = await this.getMetadata()
@ -251,6 +254,16 @@ class Orchestrator {
} }
for (let step of automation.definition.steps) { for (let step of automation.definition.steps) {
if (timeoutFlag) {
break
}
if (timeout) {
setTimeout(() => {
timeoutFlag = true
}, timeout || 12000)
}
stepCount++ stepCount++
let input: any, let input: any,
iterations = 1, iterations = 1,
@ -495,6 +508,32 @@ export function execute(job: Job, callback: WorkerCallback) {
}) })
} }
export function executeSynchronously(job: Job) {
const appId = job.data.event.appId
if (!appId) {
throw new Error("Unable to execute, event doesn't contain app ID.")
}
const timeoutPromise = new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error("Timeout exceeded"))
}, job.data.event.timeout || 12000)
})
return context.doInAppContext(appId, async () => {
const envVars = await sdkUtils.getEnvironmentVariables()
// put into automation thread for whole context
return context.doInEnvironmentContext(envVars, async () => {
const automationOrchestrator = new Orchestrator(job)
const response = await Promise.race([
automationOrchestrator.execute(),
timeoutPromise,
])
return response
})
})
}
export const removeStalled = async (job: Job) => { export const removeStalled = async (job: Job) => {
const appId = job.data.event.appId const appId = job.data.event.appId
if (!appId) { if (!appId) {

View file

@ -57,6 +57,7 @@ export enum AutomationActionStepId {
FILTER = "FILTER", FILTER = "FILTER",
QUERY_ROWS = "QUERY_ROWS", QUERY_ROWS = "QUERY_ROWS",
LOOP = "LOOP", LOOP = "LOOP",
COLLECT = "COLLECT",
OPENAI = "OPENAI", OPENAI = "OPENAI",
// these used to be lowercase step IDs, maintain for backwards compat // these used to be lowercase step IDs, maintain for backwards compat
discord = "discord", discord = "discord",
@ -123,6 +124,11 @@ export interface AutomationStepSchema {
outputs: InputOutputBlock outputs: InputOutputBlock
} }
custom?: boolean custom?: boolean
features?: Partial<Record<AutomationFeature, boolean>>
}
export enum AutomationFeature {
LOOPING = "LOOPING",
} }
export interface AutomationStep extends AutomationStepSchema { export interface AutomationStep extends AutomationStepSchema {

View file

@ -5,6 +5,7 @@ export interface AutomationDataEvent {
appId?: string appId?: string
metadata?: AutomationMetadata metadata?: AutomationMetadata
automation?: Automation automation?: Automation
timeout?: number
} }
export interface AutomationData { export interface AutomationData {

View file

@ -8,6 +8,7 @@ export enum Feature {
ENFORCEABLE_SSO = "enforceableSSO", ENFORCEABLE_SSO = "enforceableSSO",
BRANDING = "branding", BRANDING = "branding",
SCIM = "scim", SCIM = "scim",
SYNC_AUTOMATIONS = "syncAutomations",
} }
export type PlanFeatures = { [key in PlanType]: Feature[] | undefined } export type PlanFeatures = { [key in PlanType]: Feature[] | undefined }