Merge remote-tracking branch 'origin/global-bindings' into fixes/global-bindings

This commit is contained in:
Dean 2024-01-17 11:08:38 +00:00
commit 53a2329811
20 changed files with 638 additions and 159 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.14.7", "version": "2.14.8",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -66,6 +66,7 @@
"@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",
"@zerodevx/svelte-json-view": "^1.0.7",
"codemirror": "^5.59.0", "codemirror": "^5.59.0",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",

View File

@ -19,10 +19,15 @@
export let lastStep export let lastStep
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK] let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction let selectedAction
let actionVal let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION) let actions = Object.entries($automationStore.blockDefinitions.ACTION)
let lockedFeatures = [
ActionStepID.COLLECT,
ActionStepID.TRIGGER_AUTOMATION_RUN,
]
$: collectBlockExists = checkForCollectStep($selectedAutomation) $: collectBlockExists = checkForCollectStep($selectedAutomation)
@ -36,6 +41,10 @@
disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists, disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists,
message: collectDisabledMessage(), message: collectDisabledMessage(),
}, },
TRIGGER_AUTOMATION_RUN: {
disabled: !triggerAutomationRunEnabled,
message: collectDisabledMessage(),
},
} }
} }
@ -149,7 +158,7 @@
<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 && !syncAutomationsEnabled && action.stepId === ActionStepID.COLLECT} {#if isDisabled && !syncAutomationsEnabled && !triggerAutomationRunEnabled && lockedFeatures.includes(action.stepId)}
<div class="tag-color"> <div class="tag-color">
<Tags> <Tags>
<Tag icon="LockClosed">Premium</Tag> <Tag icon="LockClosed">Premium</Tag>

View File

@ -1,7 +1,8 @@
<script> <script>
import { Icon, Divider, Tabs, Tab, TextArea, Label } from "@budibase/bbui" import { Icon, Divider, Tabs, Tab, Label } from "@budibase/bbui"
import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte" import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte"
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
import { JsonView } from "@zerodevx/svelte-json-view"
export let automation export let automation
export let testResults export let testResults
@ -14,13 +15,6 @@
return results?.steps.filter(x => x.stepId !== ActionStepID.LOOP || []) return results?.steps.filter(x => x.stepId !== ActionStepID.LOOP || [])
} }
function textArea(results, message) {
if (!results) {
return message
}
return JSON.stringify(results, null, 2)
}
$: filteredResults = prepTestResults(testResults) $: filteredResults = prepTestResults(testResults)
$: { $: {
@ -71,26 +65,34 @@
{/if} {/if}
<div class="tabs"> <div class="tabs">
<Tabs noHorizPadding selected="Input"> <Tabs quiet noHorizPadding selected="Input">
<Tab title="Input"> <Tab title="Input">
<TextArea <div class="wrap">
minHeight="160px" {#if filteredResults?.[idx]?.inputs}
disabled <JsonView depth={2} json={filteredResults?.[idx]?.inputs} />
value={textArea(filteredResults?.[idx]?.inputs, "No input")} {:else}
/> No input
{/if}
</div>
</Tab> </Tab>
<Tab title="Output"> <Tab title="Output">
<TextArea <div class="wrap">
minHeight="160px" {#if filteredResults?.[idx]?.inputs}
disabled <JsonView
value={textArea(filteredResults?.[idx]?.outputs, "No output")} depth={2}
json={filteredResults?.[idx]?.outputs}
/> />
{:else}
No input
{/if}
</div>
</Tab> </Tab>
</Tabs> </Tabs>
</div> </div>
{/if} {/if}
{/if} {/if}
</div> </div>
{#if blocks.length - 1 !== idx} {#if blocks.length - 1 !== idx}
<div class="separator" /> <div class="separator" />
{/if} {/if}
@ -104,6 +106,33 @@
overflow: auto; overflow: auto;
} }
.wrap {
font-family: monospace;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border: 1px solid var(--spectrum-global-color-gray-300);
font-size: 12px;
max-height: 160px; /* Adjusted max-height */
height: 160px;
--jsonPaddingLeft: 20px;
--jsonborderleft: 20px;
overflow: auto;
overflow: overlay;
overflow-x: hidden;
padding-left: var(--spacing-s);
padding-top: var(--spacing-xl);
border-radius: 4px;
}
.wrap::-webkit-scrollbar {
width: 7px; /* width of the scrollbar */
}
.wrap::-webkit-scrollbar-track {
background: transparent; /* transparent track */
}
.tabs { .tabs {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,44 +1,117 @@
<script> <script>
import AutomationList from "./AutomationList.svelte"
import CreateAutomationModal from "./CreateAutomationModal.svelte" import CreateAutomationModal from "./CreateAutomationModal.svelte"
import { Modal, Icon } from "@budibase/bbui" import { Modal, notifications, Layout } from "@budibase/bbui"
import Panel from "components/design/Panel.svelte" import NavHeader from "components/common/NavHeader.svelte"
import { onMount } from "svelte"
import {
automationStore,
selectedAutomation,
userSelectedResourceMap,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import EditAutomationPopover from "./EditAutomationPopover.svelte"
export let modal export let modal
export let webhookModal export let webhookModal
let searchString
$: selectedAutomationId = $selectedAutomation?._id
$: filteredAutomations = $automationStore.automations
.filter(automation => {
return (
!searchString ||
automation.name.toLowerCase().includes(searchString.toLowerCase())
)
})
.sort((a, b) => {
const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase()
return lowerA > lowerB ? 1 : -1
})
$: showNoResults = searchString && !filteredAutomations.length
onMount(async () => {
try {
await automationStore.actions.fetch()
} catch (error) {
notifications.error("Error getting automations list")
}
})
function selectAutomation(id) {
automationStore.actions.select(id)
}
</script> </script>
<Panel title="Automations" borderRight noHeaderBorder titleCSS={false}> <div class="side-bar">
<span class="panel-title-content" slot="panel-title-content"> <div class="side-bar-controls">
<div class="header"> <NavHeader
<div>Automations</div> title="Automations"
<div on:click={modal.show} class="add-automation-button"> placeholder="Search for automation"
<Icon name="Add" /> bind:value={searchString}
onAdd={() => modal.show()}
/>
</div> </div>
<div class="side-bar-nav">
{#each filteredAutomations as automation}
<NavItem
text={automation.name}
selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
>
<EditAutomationPopover {automation} />
</NavItem>
{/each}
{#if showNoResults}
<Layout paddingY="none" paddingX="L">
<div class="no-results">
There aren't any automations matching that name
</div> </div>
</span> </Layout>
<AutomationList /> {/if}
</Panel> </div>
</div>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<CreateAutomationModal {webhookModal} /> <CreateAutomationModal {webhookModal} />
</Modal> </Modal>
<style> <style>
.header { .side-bar {
flex: 0 0 260px;
display: flex; display: flex;
flex-direction: column;
align-items: stretch;
border-right: var(--border-light);
background: var(--spectrum-global-color-gray-100);
overflow: hidden;
transition: margin-left 300ms ease-out;
}
@media (max-width: 640px) {
.side-bar {
margin-left: -262px;
}
}
.side-bar-controls {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center; align-items: center;
justify-content: space-between; gap: var(--spacing-l);
gap: var(--spacing-m); padding: 0 var(--spacing-l);
}
.side-bar-nav {
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
} }
.add-automation-button { .no-results {
margin-left: 130px; color: var(--spectrum-global-color-gray-600);
color: var(--grey-7);
cursor: pointer;
}
.add-automation-button:hover {
color: var(--ink);
} }
</style> </style>

View File

@ -28,6 +28,7 @@
import CodeEditorModal from "./CodeEditorModal.svelte" import CodeEditorModal from "./CodeEditorModal.svelte"
import QuerySelector from "./QuerySelector.svelte" import QuerySelector from "./QuerySelector.svelte"
import QueryParamSelector from "./QueryParamSelector.svelte" import QueryParamSelector from "./QueryParamSelector.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 ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
@ -51,7 +52,6 @@
export let testData export let testData
export let schemaProperties export let schemaProperties
export let isTestModal = false export let isTestModal = false
let webhookModal let webhookModal
let drawer let drawer
let fillWidth = true let fillWidth = true
@ -101,7 +101,6 @@
} }
} }
} }
const onChange = Utils.sequential(async (e, key) => { const onChange = Utils.sequential(async (e, key) => {
// 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
@ -145,6 +144,7 @@
if (!block || !automation) { if (!block || !automation) {
return [] return []
} }
// Find previous steps to the selected one // Find previous steps to the selected one
let allSteps = [...automation.steps] let allSteps = [...automation.steps]
@ -156,22 +156,96 @@
// Extract all outputs from all previous steps as available bindingsx§x // Extract all outputs from all previous steps as available bindingsx§x
let bindings = [] let bindings = []
let loopBlockCount = 0 let loopBlockCount = 0
const addBinding = (name, value, icon, idx, isLoopBlock, bindingName) => {
const runtimeBinding = determineRuntimeBinding(name, idx, isLoopBlock)
const categoryName = determineCategoryName(idx, isLoopBlock, bindingName)
bindings.push(
createBindingObject(
name,
value,
icon,
idx,
loopBlockCount,
isLoopBlock,
runtimeBinding,
categoryName,
bindingName
)
)
}
const determineRuntimeBinding = (name, idx, isLoopBlock) => {
let runtimeName
/* Begin special cases for generating custom schemas based on triggers */
if (idx === 0 && automation.trigger?.event === "app:trigger") {
return `trigger.fields.${name}`
}
if (
(idx === 0 && automation.trigger?.event === "row:update") ||
automation.trigger?.event === "row:save"
) {
if (name !== "id" && name !== "revision") return `trigger.row.${name}`
}
/* End special cases for generating custom schemas based on triggers */
if (isLoopBlock) {
runtimeName = `loop.${name}`
} else if (block.name.startsWith("JS")) {
runtimeName = `steps[${idx - loopBlockCount}].${name}`
} else {
runtimeName = `steps.${idx - loopBlockCount}.${name}`
}
return idx === 0 ? `trigger.${name}` : runtimeName
}
const determineCategoryName = (idx, isLoopBlock, bindingName) => {
if (idx === 0) return "Trigger outputs"
if (isLoopBlock) return "Loop Outputs"
return bindingName
? `${bindingName} outputs`
: `Step ${idx - loopBlockCount} outputs`
}
const createBindingObject = (
name,
value,
icon,
idx,
loopBlockCount,
isLoopBlock,
runtimeBinding,
categoryName,
bindingName
) => {
return {
readableBinding: bindingName
? `${bindingName}.${name}`
: runtimeBinding,
runtimeBinding,
type: value.type,
description: value.description,
icon,
category: categoryName,
display: {
type: value.type,
name,
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
},
}
}
for (let idx = 0; idx < blockIdx; idx++) { for (let idx = 0; idx < blockIdx; idx++) {
let wasLoopBlock = allSteps[idx - 1]?.stepId === ActionStepID.LOOP let wasLoopBlock = allSteps[idx - 1]?.stepId === ActionStepID.LOOP
let isLoopBlock = let isLoopBlock =
allSteps[idx]?.stepId === ActionStepID.LOOP && allSteps[idx]?.stepId === ActionStepID.LOOP &&
allSteps.find(x => x.blockToLoop === block.id) allSteps.some(x => x.blockToLoop === block.id)
let schema = cloneDeep(allSteps[idx]?.schema?.outputs?.properties) ?? {}
let bindingName =
automation.stepNames?.[allSteps[idx - loopBlockCount].id]
// If the previous block was a loop block, decrement the index so the following
// steps are in the correct order
if (wasLoopBlock) {
loopBlockCount++
continue
}
let schema = allSteps[idx]?.schema?.outputs?.properties ?? {}
// If its a Loop Block, we need to add this custom schema
if (isLoopBlock) { if (isLoopBlock) {
schema = { schema = {
currentItem: { currentItem: {
@ -180,54 +254,45 @@
}, },
} }
} }
const outputs = Object.entries(schema)
let bindingIcon = ""
let bindingRank = 0
if (idx === 0) {
bindingIcon = automation.trigger.icon
} else if (isLoopBlock) {
bindingIcon = "Reuse"
bindingRank = idx + 1
} else {
bindingIcon = allSteps[idx].icon
bindingRank = idx - loopBlockCount
}
let bindingName =
automation.stepNames?.[allSteps[idx - loopBlockCount].id]
bindings = bindings.concat(
outputs.map(([name, value]) => {
let runtimeName = isLoopBlock
? `loop.${name}`
: block.name.startsWith("JS")
? `steps[${idx - loopBlockCount}].${name}`
: `steps.${idx - loopBlockCount}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : runtimeName
let categoryName if (idx === 0 && automation.trigger?.event === "app:trigger") {
if (idx === 0) { schema = Object.fromEntries(
categoryName = "Trigger outputs" Object.keys(automation.trigger.inputs.fields || []).map(key => [
} else if (isLoopBlock) { key,
categoryName = "Loop Outputs" { type: automation.trigger.inputs.fields[key] },
} else if (bindingName) { ])
categoryName = `${bindingName} outputs` )
} else { }
categoryName = `Step ${idx - loopBlockCount} outputs` if (
(idx === 0 && automation.trigger.event === "row:update") ||
(idx === 0 && automation.trigger.event === "row:save")
) {
let table = $tables.list.find(
table => table._id === automation.trigger.inputs.tableId
)
// We want to generate our own schema for the bindings from the table schema itself
for (const key in table?.schema) {
schema[key] = {
type: table.schema[key].type,
}
}
// remove the original binding
delete schema.row
}
let icon =
idx === 0
? automation.trigger.icon
: isLoopBlock
? "Reuse"
: allSteps[idx].icon
if (wasLoopBlock) {
loopBlockCount++
continue
} }
return { Object.entries(schema).forEach(([name, value]) =>
readableBinding: bindingName ? `${bindingName}.${name}` : runtime, addBinding(name, value, icon, idx, isLoopBlock, bindingName)
runtimeBinding: runtime,
type: value.type,
description: value.description,
icon: bindingIcon,
category: categoryName,
display: {
type: value.type,
name: name,
rank: bindingRank,
},
}
})
) )
} }
@ -245,10 +310,8 @@
}) })
) )
} }
return bindings return bindings
} }
function lookForFilters(properties) { function lookForFilters(properties) {
if (!properties) { if (!properties) {
return [] return []
@ -286,7 +349,8 @@
value.customType !== "code" && value.customType !== "code" &&
value.customType !== "queryParams" && value.customType !== "queryParams" &&
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" value.customType !== "triggerSchema" &&
value.customType !== "automationFields"
) )
} }
@ -421,6 +485,12 @@
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
value={inputData[key]} value={inputData[key]}
/> />
{:else if value.customType === "automationFields"}
<AutomationSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "queryParams"} {:else if value.customType === "queryParams"}
<QueryParamSelector <QueryParamSelector
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}

View File

@ -0,0 +1,87 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { automationStore, selectedAutomation } from "builderStore"
import { TriggerStepID } from "constants/backend/automations"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
const dispatch = createEventDispatcher()
export let value
export let bindings = []
const onChangeAutomation = e => {
value.automationId = e.detail
dispatch("change", value)
}
const onChange = (e, field) => {
value[field] = e.detail
dispatch("change", value)
}
$: if (value?.automationId == null) value = { automationId: "" }
$: automationFields =
$automationStore.automations.find(
automation => automation._id === value?.automationId
)?.definition?.trigger?.inputs?.fields || []
$: filteredAutomations = $automationStore.automations.filter(
automation =>
automation.definition.trigger.stepId === TriggerStepID.APP &&
automation._id !== $selectedAutomation._id
)
</script>
<div class="schema-field">
<Label>Automation</Label>
<div class="field-width">
<Select
on:change={onChangeAutomation}
value={value.automationId}
options={filteredAutomations}
getOptionValue={automation => automation._id}
getOptionLabel={automation => automation.name}
/>
</div>
</div>
{#if Object.keys(automationFields)}
{#each Object.keys(automationFields) as field}
<div class="schema-field">
<Label>{field}</Label>
<div class="field-width">
<DrawerBindableInput
panel={AutomationBindingPanel}
extraThin
value={value[field]}
on:change={e => onChange(e, field)}
type="string"
{bindings}
fillWidth={true}
updateOnChange={false}
/>
</div>
</div>
{/each}
{/if}
<style>
.field-width {
width: 320px;
}
.schema-field {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
margin-bottom: 10px;
}
.schema-field :global(label) {
text-transform: capitalize;
}
</style>

View File

@ -21,6 +21,7 @@ export const ActionStepID = {
QUERY_ROWS: "QUERY_ROWS", QUERY_ROWS: "QUERY_ROWS",
LOOP: "LOOP", LOOP: "LOOP",
COLLECT: "COLLECT", COLLECT: "COLLECT",
TRIGGER_AUTOMATION_RUN: "TRIGGER_AUTOMATION_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", discord: "discord",
slack: "slack", slack: "slack",

View File

@ -125,6 +125,10 @@ export const createLicensingStore = () => {
const syncAutomationsEnabled = license.features.includes( const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS Constants.Features.SYNC_AUTOMATIONS
) )
const triggerAutomationRunEnabled = license.features.includes(
Constants.Features.TRIGGER_AUTOMATION_RUN
)
const perAppBuildersEnabled = license.features.includes( const perAppBuildersEnabled = license.features.includes(
Constants.Features.APP_BUILDERS Constants.Features.APP_BUILDERS
) )
@ -147,6 +151,7 @@ export const createLicensingStore = () => {
auditLogsEnabled, auditLogsEnabled,
enforceableSSO, enforceableSSO,
syncAutomationsEnabled, syncAutomationsEnabled,
triggerAutomationRunEnabled,
isViewPermissionsEnabled, isViewPermissionsEnabled,
perAppBuildersEnabled, perAppBuildersEnabled,
} }

View File

@ -0,0 +1,12 @@
const actual = jest.requireActual("@budibase/pro")
const pro = {
...actual,
features: {
...actual.features,
isTriggerAutomationRunEnabled: () => {
return true
},
},
}
export = pro

View File

@ -15,6 +15,7 @@ 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 * as collect from "./steps/collect"
import * as triggerAutomationRun from "./steps/triggerAutomationRun"
import env from "../environment" import env from "../environment"
import { import {
AutomationStepSchema, AutomationStepSchema,
@ -41,6 +42,7 @@ const ACTION_IMPLS: Record<
FILTER: filter.run, FILTER: filter.run,
QUERY_ROWS: queryRow.run, QUERY_ROWS: queryRow.run,
COLLECT: collect.run, COLLECT: collect.run,
TRIGGER_AUTOMATION_RUN: triggerAutomationRun.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,
@ -62,6 +64,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<string, AutomationStepSchema> =
QUERY_ROWS: queryRow.definition, QUERY_ROWS: queryRow.definition,
LOOP: loop.definition, LOOP: loop.definition,
COLLECT: collect.definition, COLLECT: collect.definition,
TRIGGER_AUTOMATION_RUN: triggerAutomationRun.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

@ -99,7 +99,7 @@ export async function run({ inputs }: AutomationStepInput) {
} else { } else {
result = false result = false
} }
return { success: true, result } return { success: true, result, refValue: field, comparisonValue: value }
} catch (err) { } catch (err) {
return { success: false, result: false } return { success: false, result: false }
} }

View File

@ -0,0 +1,90 @@
import {
AutomationActionStepId,
AutomationStepSchema,
AutomationStepInput,
AutomationStepType,
AutomationIOType,
AutomationResults,
Automation,
AutomationCustomIOType,
} from "@budibase/types"
import * as triggers from "../triggers"
import { db as dbCore, context } from "@budibase/backend-core"
import { features } from "@budibase/pro"
export const definition: AutomationStepSchema = {
name: "Trigger an automation",
tagline: "Triggers an automation synchronously",
icon: "Sync",
description: "Triggers an automation synchronously",
type: AutomationStepType.ACTION,
internal: true,
features: {},
stepId: AutomationActionStepId.TRIGGER_AUTOMATION_RUN,
inputs: {},
schema: {
inputs: {
properties: {
automation: {
type: AutomationIOType.OBJECT,
properties: {
automationId: {
type: AutomationIOType.STRING,
customType: AutomationCustomIOType.AUTOMATION,
},
},
customType: AutomationCustomIOType.AUTOMATION_FIELDS,
title: "automatioFields",
required: ["automationId"],
},
timeout: {
type: AutomationIOType.NUMBER,
title: "Timeout (ms)",
},
},
required: ["automationId"],
},
outputs: {
properties: {
success: {
type: AutomationIOType.BOOLEAN,
description: "Whether the automation was successful",
},
value: {
type: AutomationIOType.OBJECT,
description: "Automation Result",
},
},
required: ["success", "value"],
},
},
}
export async function run({ inputs }: AutomationStepInput) {
const { automationId, ...fieldParams } = inputs.automation
if (await features.isTriggerAutomationRunEnabled()) {
if (!inputs.automation.automationId) {
return {
success: false,
}
} else {
const db = context.getAppDB()
let automation = await db.get<Automation>(inputs.automation.automationId)
const response: AutomationResults = await triggers.externalTrigger(
automation,
{
fields: { ...fieldParams },
timeout: inputs.timeout * 1000 || 120000,
},
{ getResponses: true }
)
return {
success: true,
value: response.steps,
}
}
}
}

View File

@ -0,0 +1,43 @@
jest.spyOn(global.console, "error")
import * as setup from "./utilities"
import * as automation from "../index"
import { serverLogAutomation } from "../../tests/utilities/structures"
describe("Test triggering an automation from another automation", () => {
let config = setup.getConfig()
beforeAll(async () => {
await automation.init()
await config.init()
})
afterAll(async () => {
await automation.shutdown()
setup.afterAll()
})
it("should trigger an other server log automation", async () => {
let automation = serverLogAutomation()
let newAutomation = await config.createAutomation(automation)
const inputs: any = {
automation: { automationId: newAutomation._id, timeout: 12000 },
}
const res = await setup.runStep(
setup.actions.TRIGGER_AUTOMATION_RUN.stepId,
inputs
)
// Check if the SERVER_LOG step was successful
expect(res.value[1].outputs.success).toBe(true)
})
it("should fail gracefully if the automation id is incorrect", async () => {
const inputs: any = { automation: { automationId: null, timeout: 12000 } }
const res = await setup.runStep(
setup.actions.TRIGGER_AUTOMATION_RUN.stepId,
inputs
)
expect(res.success).toBe(false)
})
})

View File

@ -21,6 +21,7 @@ import {
Table, Table,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
TableSourceType, TableSourceType,
AutomationIOType,
} from "@budibase/types" } from "@budibase/types"
const { BUILTIN_ROLE_IDS } = roles const { BUILTIN_ROLE_IDS } = roles
@ -153,6 +154,56 @@ export function basicAutomation(appId?: string): Automation {
} }
} }
export function serverLogAutomation(appId?: string): Automation {
return {
name: "My Automation",
screenId: "kasdkfldsafkl",
live: true,
uiTree: {},
definition: {
trigger: {
stepId: AutomationTriggerStepId.APP,
name: "test",
tagline: "test",
icon: "test",
description: "test",
type: AutomationStepType.TRIGGER,
id: "test",
inputs: {},
schema: {
inputs: {
properties: {},
},
outputs: {
properties: {},
},
},
},
steps: [
{
stepId: AutomationActionStepId.SERVER_LOG,
name: "Backend log",
tagline: "Console log a value in the backend",
icon: "Monitoring",
description: "Logs the given text to the server (using console.log)",
internal: true,
features: {
LOOPING: true,
},
inputs: {
text: "log statement",
},
schema: BUILTIN_ACTION_DEFINITIONS.SERVER_LOG.schema,
id: "y8lkZbeSe",
type: AutomationStepType.ACTION,
},
],
},
type: "automation",
appId: appId!,
}
}
export function loopAutomation(tableId: string, loopOpts?: any): Automation { export function loopAutomation(tableId: string, loopOpts?: any): Automation {
if (!loopOpts) { if (!loopOpts) {
loopOpts = { loopOpts = {

View File

@ -28,6 +28,8 @@ export enum AutomationCustomIOType {
TRIGGER_SCHEMA = "triggerSchema", TRIGGER_SCHEMA = "triggerSchema",
CRON = "cron", CRON = "cron",
WEBHOOK_URL = "webhookUrl", WEBHOOK_URL = "webhookUrl",
AUTOMATION = "automation",
AUTOMATION_FIELDS = "automationFields",
} }
export enum AutomationTriggerStepId { export enum AutomationTriggerStepId {
@ -61,6 +63,7 @@ export enum AutomationActionStepId {
LOOP = "LOOP", LOOP = "LOOP",
COLLECT = "COLLECT", COLLECT = "COLLECT",
OPENAI = "OPENAI", OPENAI = "OPENAI",
TRIGGER_AUTOMATION_RUN = "TRIGGER_AUTOMATION_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", discord = "discord",
slack = "slack", slack = "slack",

View File

@ -9,6 +9,7 @@ export enum Feature {
BRANDING = "branding", BRANDING = "branding",
SCIM = "scim", SCIM = "scim",
SYNC_AUTOMATIONS = "syncAutomations", SYNC_AUTOMATIONS = "syncAutomations",
TRIGGER_AUTOMATION_RUN = "triggerAutomationRun",
APP_BUILDERS = "appBuilders", APP_BUILDERS = "appBuilders",
OFFLINE = "offline", OFFLINE = "offline",
EXPANDED_PUBLIC_API = "expandedPublicApi", EXPANDED_PUBLIC_API = "expandedPublicApi",

View File

@ -22,6 +22,7 @@ export enum LockName {
QUOTA_USAGE_EVENT = "quota_usage_event", QUOTA_USAGE_EVENT = "quota_usage_event",
APP_MIGRATION = "app_migrations", APP_MIGRATION = "app_migrations",
PROCESS_AUTO_COLUMNS = "process_auto_columns", PROCESS_AUTO_COLUMNS = "process_auto_columns",
PROCESS_USER_INVITE = "process_user_invite",
} }
export type LockOptions = { export type LockOptions = {

View File

@ -12,6 +12,8 @@ import {
InviteUserRequest, InviteUserRequest,
InviteUsersRequest, InviteUsersRequest,
InviteUsersResponse, InviteUsersResponse,
LockName,
LockType,
MigrationType, MigrationType,
SaveUserResponse, SaveUserResponse,
SearchUsersRequest, SearchUsersRequest,
@ -27,6 +29,7 @@ import {
platform, platform,
tenancy, tenancy,
db, db,
locks,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { checkAnyUserExists } from "../../../utilities/users" import { checkAnyUserExists } from "../../../utilities/users"
import { isEmailConfigured } from "../../../utilities/email" import { isEmailConfigured } from "../../../utilities/email"
@ -380,9 +383,16 @@ export const inviteAccept = async (
) => { ) => {
const { inviteCode, password, firstName, lastName } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {
await locks.doWithLock(
{
type: LockType.AUTO_EXTEND,
name: LockName.PROCESS_USER_INVITE,
resource: inviteCode,
systemLock: true,
},
async () => {
// info is an extension of the user object that was stored by global // info is an extension of the user object that was stored by global
const { email, info }: any = await cache.invite.getCode(inviteCode) const { email, info } = await cache.invite.getCode(inviteCode)
await cache.invite.deleteCode(inviteCode)
const user = await tenancy.doInTenant(info.tenantId, async () => { const user = await tenancy.doInTenant(info.tenantId, async () => {
let request: any = { let request: any = {
firstName, firstName,
@ -393,7 +403,7 @@ export const inviteAccept = async (
roles: info.apps, roles: info.apps,
tenantId: info.tenantId, tenantId: info.tenantId,
} }
let builder: { global: boolean; apps?: string[] } = { const builder: { global: boolean; apps?: string[] } = {
global: info?.builder?.global || false, global: info?.builder?.global || false,
} }
@ -408,23 +418,25 @@ export const inviteAccept = async (
} }
const saved = await userSdk.db.save(request) const saved = await userSdk.db.save(request)
const db = tenancy.getGlobalDB() await events.user.inviteAccepted(saved)
const user = await db.get<User>(saved._id)
await events.user.inviteAccepted(user)
return saved return saved
}) })
await cache.invite.deleteCode(inviteCode)
ctx.body = { ctx.body = {
_id: user._id!, _id: user._id!,
_rev: user._rev!, _rev: user._rev!,
email: user.email, email: user.email,
} }
}
)
} catch (err: any) { } catch (err: any) {
if (err.code === ErrorCode.USAGE_LIMIT_EXCEEDED) { if (err.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
// explicitly re-throw limit exceeded errors // explicitly re-throw limit exceeded errors
ctx.throw(400, err) ctx.throw(400, err)
} }
console.warn("Error inviting user", err) console.warn("Error inviting user", err)
ctx.throw(400, "Unable to create new user, invitation invalid.") ctx.throw(400, err || "Unable to create new user, invitation invalid.")
} }
} }

View File

@ -3,26 +3,14 @@
packages_to_remove="@budibase/backend-core @budibase/bbui @budibase/builder @budibase/cli @budibase/client @budibase/frontend-core @budibase/pro @budibase/sdk @budibase/server @budibase/shared-core @budibase/string-templates @budibase/types @budibase/worker" packages_to_remove="@budibase/backend-core @budibase/bbui @budibase/builder @budibase/cli @budibase/client @budibase/frontend-core @budibase/pro @budibase/sdk @budibase/server @budibase/shared-core @budibase/string-templates @budibase/types @budibase/worker"
package_json_path="$1" package_json_path="$1"
package_json=$(cat "$package_json_path")
process_package() { process_package() {
pkg_path="$1" pkg_path="$1"
package_json=$(cat "$pkg_path")
has_changes=false
for package_name in $packages_to_remove; do for package_name in $packages_to_remove; do
if echo "$package_json" | jq -e --arg package_name "$package_name" '.dependencies | has($package_name)' > /dev/null; then jq "del(.dependencies[\"$package_name\"])" $pkg_path > tmp_file.json && mv tmp_file.json $pkg_path
package_json=$(echo "$package_json" | jq "del(.dependencies[\"$package_name\"])") jq "del(.resolutions[\"$package_name\"])" $pkg_path > tmp_file.json && mv tmp_file.json $pkg_path
has_changes=true
fi
done done
if [ "$has_changes" = true ]; then
echo "$package_json" > "$pkg_path"
fi
} }
process_package "$package_json_path" process_package "$package_json_path"
package_json=$(cat "$package_json_path")
echo "$package_json" | jq "del(.resolutions)" > "$1"