Merge branch 'master' of https://github.com/budibase/budibase into BUDI-9104
This commit is contained in:
commit
ec382c313c
|
@ -165,6 +165,7 @@ jobs:
|
|||
oracle,
|
||||
sqs,
|
||||
elasticsearch,
|
||||
dynamodb,
|
||||
none,
|
||||
]
|
||||
steps:
|
||||
|
@ -205,6 +206,8 @@ jobs:
|
|||
docker pull postgres:9.5.25
|
||||
elif [ "${{ matrix.datasource }}" == "elasticsearch" ]; then
|
||||
docker pull elasticsearch@${{ steps.dotenv.outputs.ELASTICSEARCH_SHA }}
|
||||
elif [ "${{ matrix.datasource }}" == "dynamodb" ]; then
|
||||
docker pull amazon/dynamodb-local@${{ steps.dotenv.outputs.DYNAMODB_SHA }}
|
||||
fi
|
||||
docker pull minio/minio &
|
||||
docker pull redis &
|
||||
|
|
|
@ -88,6 +88,16 @@ export default async function setup() {
|
|||
content: `
|
||||
[log]
|
||||
level = warn
|
||||
|
||||
[httpd]
|
||||
socket_options = [{nodelay, true}]
|
||||
|
||||
[couchdb]
|
||||
single_node = true
|
||||
|
||||
[cluster]
|
||||
n = 1
|
||||
q = 1
|
||||
`,
|
||||
target: "/opt/couchdb/etc/local.d/test-couchdb.ini",
|
||||
},
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "3.4.22",
|
||||
"version": "3.4.24",
|
||||
"npmClient": "yarn",
|
||||
"concurrency": 20,
|
||||
"command": {
|
||||
|
|
|
@ -3,7 +3,6 @@ import { newid } from "../utils"
|
|||
import { Queue, QueueOptions, JobOptions } from "./queue"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
import { Job, JobId, JobInformation } from "bull"
|
||||
import { cloneDeep } from "lodash"
|
||||
|
||||
function jobToJobInformation(job: Job): JobInformation {
|
||||
let cron = ""
|
||||
|
@ -88,9 +87,7 @@ export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
|
|||
*/
|
||||
async process(concurrencyOrFunc: number | any, func?: any) {
|
||||
func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc
|
||||
this._emitter.on("message", async msg => {
|
||||
const message = cloneDeep(msg)
|
||||
|
||||
this._emitter.on("message", async message => {
|
||||
// For the purpose of testing, don't trigger cron jobs immediately.
|
||||
// Require the test to trigger them manually with timestamps.
|
||||
if (!message.manualTrigger && message.opts?.repeat != null) {
|
||||
|
@ -165,6 +162,9 @@ export class InMemoryQueue<T = any> implements Partial<Queue<T>> {
|
|||
opts,
|
||||
}
|
||||
this._messages.push(message)
|
||||
if (this._messages.length > 1000) {
|
||||
this._messages.shift()
|
||||
}
|
||||
this._addCount++
|
||||
this._emitter.emit("message", message)
|
||||
}
|
||||
|
|
|
@ -1,22 +1,26 @@
|
|||
<script>
|
||||
<script lang="ts" context="module">
|
||||
type Option = any
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Picker from "./Picker.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let value = []
|
||||
export let id = null
|
||||
export let placeholder = null
|
||||
export let disabled = false
|
||||
export let options = []
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let readonly = false
|
||||
export let autocomplete = false
|
||||
export let sort = false
|
||||
export let autoWidth = false
|
||||
export let searchTerm = null
|
||||
export let customPopoverHeight = undefined
|
||||
export let open = false
|
||||
export let loading
|
||||
export let value: string[] = []
|
||||
export let id: string | undefined = undefined
|
||||
export let placeholder: string | null = null
|
||||
export let disabled: boolean = false
|
||||
export let options: Option[] = []
|
||||
export let getOptionLabel = (option: Option, _index?: number) => option
|
||||
export let getOptionValue = (option: Option, _index?: number) => option
|
||||
export let readonly: boolean = false
|
||||
export let autocomplete: boolean = false
|
||||
export let sort: boolean = false
|
||||
export let autoWidth: boolean = false
|
||||
export let searchTerm: string | null = null
|
||||
export let customPopoverHeight: string | undefined = undefined
|
||||
export let open: boolean = false
|
||||
export let loading: boolean
|
||||
export let onOptionMouseenter = () => {}
|
||||
export let onOptionMouseleave = () => {}
|
||||
|
||||
|
@ -27,10 +31,15 @@
|
|||
$: optionLookupMap = getOptionLookupMap(options)
|
||||
|
||||
$: fieldText = getFieldText(arrayValue, optionLookupMap, placeholder)
|
||||
$: isOptionSelected = optionValue => selectedLookupMap[optionValue] === true
|
||||
$: isOptionSelected = (optionValue: string) =>
|
||||
selectedLookupMap[optionValue] === true
|
||||
$: toggleOption = makeToggleOption(selectedLookupMap, arrayValue)
|
||||
|
||||
const getFieldText = (value, map, placeholder) => {
|
||||
const getFieldText = (
|
||||
value: string[],
|
||||
map: Record<string, any> | null,
|
||||
placeholder: string | null
|
||||
) => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
if (!map) {
|
||||
return ""
|
||||
|
@ -42,8 +51,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
const getSelectedLookupMap = value => {
|
||||
let map = {}
|
||||
const getSelectedLookupMap = (value: string[]) => {
|
||||
const map: Record<string, boolean> = {}
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
value.forEach(option => {
|
||||
if (option) {
|
||||
|
@ -54,22 +63,23 @@
|
|||
return map
|
||||
}
|
||||
|
||||
const getOptionLookupMap = options => {
|
||||
let map = null
|
||||
if (options?.length) {
|
||||
map = {}
|
||||
options.forEach((option, idx) => {
|
||||
const optionValue = getOptionValue(option, idx)
|
||||
if (optionValue != null) {
|
||||
map[optionValue] = getOptionLabel(option, idx) || ""
|
||||
}
|
||||
})
|
||||
const getOptionLookupMap = (options: Option[]) => {
|
||||
if (!options?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const map: Record<string, any> = {}
|
||||
options.forEach((option, idx) => {
|
||||
const optionValue = getOptionValue(option, idx)
|
||||
if (optionValue != null) {
|
||||
map[optionValue] = getOptionLabel(option, idx) || ""
|
||||
}
|
||||
})
|
||||
return map
|
||||
}
|
||||
|
||||
const makeToggleOption = (map, value) => {
|
||||
return optionValue => {
|
||||
const makeToggleOption = (map: Record<string, boolean>, value: string[]) => {
|
||||
return (optionValue: string) => {
|
||||
if (map[optionValue]) {
|
||||
const filtered = value.filter(option => option !== optionValue)
|
||||
dispatch("change", filtered)
|
||||
|
|
|
@ -8,11 +8,13 @@
|
|||
export let invalid: boolean = false
|
||||
export let disabled: boolean = false
|
||||
export let closable: boolean = false
|
||||
export let emphasized: boolean = false
|
||||
</script>
|
||||
|
||||
<div
|
||||
class:is-invalid={invalid}
|
||||
class:is-disabled={disabled}
|
||||
class:is-emphasized={emphasized}
|
||||
class="spectrum-Tags-item"
|
||||
role="listitem"
|
||||
>
|
||||
|
@ -40,4 +42,9 @@
|
|||
margin-bottom: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.is-emphasized {
|
||||
border-color: var(--spectrum-global-color-blue-700);
|
||||
color: var(--spectrum-global-color-blue-700);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import posthog from "posthog-js"
|
||||
import { Events } from "./constants"
|
||||
|
||||
export default class PosthogClient {
|
||||
constructor(token) {
|
||||
token: string
|
||||
initialised: boolean
|
||||
|
||||
constructor(token: string) {
|
||||
this.token = token
|
||||
this.initialised = false
|
||||
}
|
||||
|
||||
init() {
|
||||
|
@ -12,6 +15,8 @@ export default class PosthogClient {
|
|||
posthog.init(this.token, {
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
// disable by default
|
||||
disable_session_recording: true,
|
||||
})
|
||||
posthog.set_config({ persistence: "cookie" })
|
||||
|
||||
|
@ -22,7 +27,7 @@ export default class PosthogClient {
|
|||
* Set the posthog context to the current user
|
||||
* @param {String} id - unique user id
|
||||
*/
|
||||
identify(id) {
|
||||
identify(id: string) {
|
||||
if (!this.initialised) return
|
||||
|
||||
posthog.identify(id)
|
||||
|
@ -32,7 +37,7 @@ export default class PosthogClient {
|
|||
* Update user metadata associated with current user in posthog
|
||||
* @param {Object} meta - user fields
|
||||
*/
|
||||
updateUser(meta) {
|
||||
updateUser(meta: Record<string, any>) {
|
||||
if (!this.initialised) return
|
||||
|
||||
posthog.people.set(meta)
|
||||
|
@ -43,28 +48,22 @@ export default class PosthogClient {
|
|||
* @param {String} event - event identifier
|
||||
* @param {Object} props - properties for the event
|
||||
*/
|
||||
captureEvent(eventName, props) {
|
||||
if (!this.initialised) return
|
||||
|
||||
props.sourceApp = "builder"
|
||||
posthog.capture(eventName, props)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit NPS feedback to posthog.
|
||||
* @param {Object} values - NPS Values
|
||||
*/
|
||||
npsFeedback(values) {
|
||||
if (!this.initialised) return
|
||||
|
||||
localStorage.setItem(Events.NPS.SUBMITTED, Date.now())
|
||||
|
||||
const prefixedFeedback = {}
|
||||
for (let key in values) {
|
||||
prefixedFeedback[`feedback_${key}`] = values[key]
|
||||
captureEvent(event: string, props: Record<string, any>) {
|
||||
if (!this.initialised) {
|
||||
return
|
||||
}
|
||||
|
||||
posthog.capture(Events.NPS.SUBMITTED, prefixedFeedback)
|
||||
props.sourceApp = "builder"
|
||||
posthog.capture(event, props)
|
||||
}
|
||||
|
||||
enableSessionRecording() {
|
||||
if (!this.initialised) {
|
||||
return
|
||||
}
|
||||
posthog.set_config({
|
||||
disable_session_recording: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
|
@ -31,6 +31,10 @@ class AnalyticsHub {
|
|||
posthog.captureEvent(eventName, props)
|
||||
}
|
||||
|
||||
enableSessionRecording() {
|
||||
posthog.enableSessionRecording()
|
||||
}
|
||||
|
||||
async logout() {
|
||||
posthog.logout()
|
||||
}
|
||||
|
|
|
@ -23,9 +23,8 @@
|
|||
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
|
||||
let selectedAction
|
||||
let actions = Object.entries($automationStore.blockDefinitions.ACTION).filter(
|
||||
entry => {
|
||||
const [key] = entry
|
||||
return key !== AutomationActionStepId.BRANCH
|
||||
([key, action]) => {
|
||||
return key !== AutomationActionStepId.BRANCH && action.deprecated !== true
|
||||
}
|
||||
)
|
||||
let lockedFeatures = [
|
||||
|
@ -186,6 +185,10 @@
|
|||
</div>
|
||||
{:else if isDisabled}
|
||||
<Icon name="Help" tooltip={disabled()[idx].message} />
|
||||
{:else if action.new}
|
||||
<Tags>
|
||||
<Tag emphasized>New</Tag>
|
||||
</Tags>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -227,6 +230,10 @@
|
|||
grid-gap: var(--spectrum-alias-grid-baseline);
|
||||
}
|
||||
|
||||
.item :global(.spectrum-Tags-itemLabel) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item {
|
||||
cursor: pointer;
|
||||
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
|
||||
|
@ -237,6 +244,8 @@
|
|||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
border-width: 2px;
|
||||
min-height: 3.5rem;
|
||||
display: flex;
|
||||
}
|
||||
.item:not(.disabled):hover,
|
||||
.selected {
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
<script>
|
||||
import { automationStore, selectedAutomation } from "@/stores/builder"
|
||||
import { Icon, Body, AbsTooltip, StatusLight } from "@budibase/bbui"
|
||||
import {
|
||||
Icon,
|
||||
Body,
|
||||
AbsTooltip,
|
||||
StatusLight,
|
||||
Tags,
|
||||
Tag,
|
||||
} from "@budibase/bbui"
|
||||
import { externalActions } from "./ExternalActions"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { Features } from "@/constants/backend/automations"
|
||||
|
@ -24,6 +31,7 @@
|
|||
$: blockRefs = $selectedAutomation?.blockRefs || {}
|
||||
$: stepNames = automation?.definition.stepNames || {}
|
||||
$: allSteps = automation?.definition.steps || []
|
||||
$: blockDefinition = $automationStore.blockDefinitions.ACTION[block.stepId]
|
||||
$: automationName = itemName || stepNames?.[block.id] || block?.name || ""
|
||||
$: automationNameError = getAutomationNameError(automationName)
|
||||
$: status = updateStatus(testResult)
|
||||
|
@ -135,7 +143,16 @@
|
|||
{#if isHeaderTrigger}
|
||||
<Body size="XS"><b>Trigger</b></Body>
|
||||
{:else}
|
||||
<Body size="XS"><b>{isBranch ? "Branch" : "Step"}</b></Body>
|
||||
<Body size="XS">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<b>{isBranch ? "Branch" : "Step"}</b>
|
||||
{#if blockDefinition.deprecated}
|
||||
<Tags>
|
||||
<Tag invalid>Deprecated</Tag>
|
||||
</Tags>
|
||||
{/if}
|
||||
</div>
|
||||
</Body>
|
||||
{/if}
|
||||
|
||||
{#if enableNaming}
|
||||
|
|
|
@ -145,9 +145,11 @@
|
|||
return
|
||||
}
|
||||
popoverAnchor = target
|
||||
|
||||
const doc = new DOMParser().parseFromString(helper.description, "text/html")
|
||||
hoverTarget = {
|
||||
type: "helper",
|
||||
description: helper.description,
|
||||
description: doc.body.textContent || "",
|
||||
code: getHelperExample(helper, mode === BindingMode.JavaScript),
|
||||
}
|
||||
popover.show()
|
||||
|
@ -241,20 +243,19 @@
|
|||
>
|
||||
{#if hoverTarget.description}
|
||||
<div>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
{@html hoverTarget.description}
|
||||
{hoverTarget.description}
|
||||
</div>
|
||||
{/if}
|
||||
{#if hoverTarget.code}
|
||||
{#if mode === BindingMode.JavaScript}
|
||||
{#if mode === BindingMode.Text || (mode === BindingMode.JavaScript && hoverTarget.type === "binding")}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
<pre>{@html hoverTarget.code}</pre>
|
||||
{:else}
|
||||
<CodeEditor
|
||||
value={hoverTarget.code?.trim()}
|
||||
mode={EditorModes.JS}
|
||||
readonly
|
||||
/>
|
||||
{:else if mode === BindingMode.Text}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
|
||||
<pre>{@html hoverTarget.code}</pre>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -15,6 +15,7 @@ export const ActionStepID = {
|
|||
DELETE_ROW: "DELETE_ROW",
|
||||
OUTGOING_WEBHOOK: "OUTGOING_WEBHOOK",
|
||||
EXECUTE_SCRIPT: "EXECUTE_SCRIPT",
|
||||
EXECUTE_SCRIPT_V2: "EXECUTE_SCRIPT_V2",
|
||||
EXECUTE_QUERY: "EXECUTE_QUERY",
|
||||
SERVER_LOG: "SERVER_LOG",
|
||||
DELAY: "DELAY",
|
||||
|
|
|
@ -33,6 +33,8 @@ import {
|
|||
isRowSaveTrigger,
|
||||
isAppTrigger,
|
||||
BranchStep,
|
||||
GetAutomationTriggerDefinitionsResponse,
|
||||
GetAutomationActionDefinitionsResponse,
|
||||
} from "@budibase/types"
|
||||
import { ActionStepID } from "@/constants/backend/automations"
|
||||
import { FIELDS } from "@/constants/backend"
|
||||
|
@ -68,16 +70,19 @@ const initialAutomationState: AutomationState = {
|
|||
}
|
||||
|
||||
const getFinalDefinitions = (
|
||||
triggers: Record<string, any>,
|
||||
actions: Record<string, any>
|
||||
triggers: GetAutomationTriggerDefinitionsResponse,
|
||||
actions: GetAutomationActionDefinitionsResponse
|
||||
): BlockDefinitions => {
|
||||
const creatable: Record<string, any> = {}
|
||||
Object.entries(triggers).forEach(entry => {
|
||||
if (entry[0] === AutomationTriggerStepId.ROW_ACTION) {
|
||||
return
|
||||
const creatable: Partial<GetAutomationTriggerDefinitionsResponse> = {}
|
||||
for (const [key, trigger] of Object.entries(triggers)) {
|
||||
if (key === AutomationTriggerStepId.ROW_ACTION) {
|
||||
continue
|
||||
}
|
||||
creatable[entry[0]] = entry[1]
|
||||
})
|
||||
if (trigger.deprecated === true) {
|
||||
continue
|
||||
}
|
||||
creatable[key as keyof GetAutomationTriggerDefinitionsResponse] = trigger
|
||||
}
|
||||
return {
|
||||
TRIGGER: triggers,
|
||||
CREATABLE_TRIGGER: creatable,
|
||||
|
@ -679,7 +684,10 @@ const automationActions = (store: AutomationStore) => ({
|
|||
runtimeName = `loop.${name}`
|
||||
} else if (idx === 0) {
|
||||
runtimeName = `trigger.${name}`
|
||||
} else if (currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT) {
|
||||
} else if (
|
||||
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT ||
|
||||
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT_V2
|
||||
) {
|
||||
const stepId = pathSteps[idx].id
|
||||
if (!stepId) {
|
||||
notifications.error("Error generating binding: Step ID not found.")
|
||||
|
|
|
@ -8,6 +8,7 @@ import {
|
|||
SystemStatusResponse,
|
||||
} from "@budibase/types"
|
||||
import { BudiStore } from "../BudiStore"
|
||||
import Analytics from "../../analytics"
|
||||
|
||||
interface AdminState extends GetEnvironmentResponse {
|
||||
loaded: boolean
|
||||
|
@ -33,6 +34,8 @@ export class AdminStore extends BudiStore<AdminState> {
|
|||
await this.getEnvironment()
|
||||
// enable system status checks in the cloud
|
||||
if (get(this.store).cloud) {
|
||||
// in cloud allow this
|
||||
Analytics.enableSessionRecording()
|
||||
await this.getSystemStatus()
|
||||
this.checkStatus()
|
||||
}
|
||||
|
|
|
@ -5,11 +5,12 @@
|
|||
import { memo } from "@budibase/frontend-core"
|
||||
import Placeholder from "../Placeholder.svelte"
|
||||
import InnerForm from "./InnerForm.svelte"
|
||||
import type { FieldApi } from "."
|
||||
|
||||
export let label: string | undefined = undefined
|
||||
export let field: string | undefined = undefined
|
||||
export let fieldState: any
|
||||
export let fieldApi: any
|
||||
export let fieldApi: FieldApi
|
||||
export let fieldSchema: any
|
||||
export let defaultValue: string | undefined = undefined
|
||||
export let type: any
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||
import { FieldType, InternalTable } from "@budibase/types"
|
||||
import { BasicOperator, FieldType, InternalTable } from "@budibase/types"
|
||||
import { fetchData, Utils } from "@budibase/frontend-core"
|
||||
import { getContext } from "svelte"
|
||||
import Field from "./Field.svelte"
|
||||
|
@ -9,10 +9,11 @@
|
|||
RelationshipFieldMetadata,
|
||||
Row,
|
||||
} from "@budibase/types"
|
||||
import type { FieldApi, FieldState } from "."
|
||||
|
||||
export let field: string | undefined = undefined
|
||||
export let label: string | undefined = undefined
|
||||
export let placeholder: any = undefined
|
||||
export let placeholder: string | undefined = undefined
|
||||
export let disabled: boolean = false
|
||||
export let readonly: boolean = false
|
||||
export let validation: any
|
||||
|
@ -35,12 +36,13 @@
|
|||
const { API } = getContext("sdk")
|
||||
|
||||
// Field state
|
||||
let fieldState: any
|
||||
let fieldApi: any
|
||||
let fieldState: FieldState<string | string[]> | undefined
|
||||
|
||||
let fieldApi: FieldApi
|
||||
let fieldSchema: RelationshipFieldMetadata | undefined
|
||||
|
||||
// Local UI state
|
||||
let searchTerm: any
|
||||
let searchTerm: string
|
||||
let open: boolean = false
|
||||
|
||||
// Options state
|
||||
|
@ -106,17 +108,14 @@
|
|||
filter: SearchFilter[],
|
||||
linkedTableId?: string
|
||||
) => {
|
||||
if (!linkedTableId) {
|
||||
return undefined
|
||||
}
|
||||
const datasource =
|
||||
datasourceType === "table"
|
||||
dsType === "table"
|
||||
? {
|
||||
type: datasourceType,
|
||||
tableId: fieldSchema?.tableId!,
|
||||
type: dsType,
|
||||
tableId: linkedTableId!,
|
||||
}
|
||||
: {
|
||||
type: datasourceType,
|
||||
type: dsType,
|
||||
tableId: InternalTable.USER_METADATA,
|
||||
}
|
||||
return fetchData({
|
||||
|
@ -306,14 +305,14 @@
|
|||
}
|
||||
|
||||
// Ensure we match all filters, rather than any
|
||||
let newFilter: any = filter
|
||||
let newFilter = filter
|
||||
if (searchTerm) {
|
||||
// @ts-expect-error this doesn't fit types, but don't want to change it yet
|
||||
newFilter = (newFilter || []).filter(x => x.operator !== "allOr")
|
||||
newFilter.push({
|
||||
// Use a big numeric prefix to avoid clashing with an existing filter
|
||||
field: `999:${primaryDisplay}`,
|
||||
operator: "string",
|
||||
operator: BasicOperator.STRING,
|
||||
value: searchTerm,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -19,3 +19,15 @@ export { default as codescanner } from "./CodeScannerField.svelte"
|
|||
export { default as signaturesinglefield } from "./SignatureField.svelte"
|
||||
export { default as bbreferencefield } from "./BBReferenceField.svelte"
|
||||
export { default as bbreferencesinglefield } from "./BBReferenceSingleField.svelte"
|
||||
|
||||
export interface FieldApi {
|
||||
setValue(value: any): boolean
|
||||
deregister(): void
|
||||
}
|
||||
|
||||
export interface FieldState<T> {
|
||||
value: T
|
||||
fieldId: string
|
||||
disabled: boolean
|
||||
readonly: boolean
|
||||
}
|
|
@ -3,4 +3,5 @@ MYSQL_SHA=sha256:9de9d54fecee6253130e65154b930978b1fcc336bcc86dfd06e89b72a2588eb
|
|||
POSTGRES_SHA=sha256:bd0d8e485d1aca439d39e5ea99b931160bd28d862e74c786f7508e9d0053090e
|
||||
MONGODB_SHA=sha256:afa36bca12295b5f9dae68a493c706113922bdab520e901bd5d6c9d7247a1d8d
|
||||
MARIADB_SHA=sha256:e59ba8783bf7bc02a4779f103bb0d8751ac0e10f9471089709608377eded7aa8
|
||||
ELASTICSEARCH_SHA=sha256:9a6443f55243f6acbfeb4a112d15eb3b9aac74bf25e0e39fa19b3ddd3a6879d0
|
||||
ELASTICSEARCH_SHA=sha256:9a6443f55243f6acbfeb4a112d15eb3b9aac74bf25e0e39fa19b3ddd3a6879d0
|
||||
DYNAMODB_SHA=sha256:cf8cebd061f988628c02daff10fdb950a54478feff9c52f6ddf84710fe3c3906
|
|
@ -1,7 +1,7 @@
|
|||
import * as triggers from "../../automations/triggers"
|
||||
import { sdk as coreSdk } from "@budibase/shared-core"
|
||||
import { DocumentType } from "../../db/utils"
|
||||
import { updateTestHistory, removeDeprecated } from "../../automations/utils"
|
||||
import { updateTestHistory } from "../../automations/utils"
|
||||
import { withTestFlag } from "../../utilities/redis"
|
||||
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
|
||||
import { automations, features } from "@budibase/pro"
|
||||
|
@ -34,14 +34,6 @@ import sdk from "../../sdk"
|
|||
import { builderSocket } from "../../websockets"
|
||||
import env from "../../environment"
|
||||
|
||||
async function getActionDefinitions() {
|
||||
return removeDeprecated(await actionDefs())
|
||||
}
|
||||
|
||||
function getTriggerDefinitions() {
|
||||
return removeDeprecated(triggers.TRIGGER_DEFINITIONS)
|
||||
}
|
||||
|
||||
/*************************
|
||||
* *
|
||||
* BUILDER FUNCTIONS *
|
||||
|
@ -141,21 +133,21 @@ export async function clearLogError(
|
|||
export async function getActionList(
|
||||
ctx: UserCtx<void, GetAutomationActionDefinitionsResponse>
|
||||
) {
|
||||
ctx.body = await getActionDefinitions()
|
||||
ctx.body = await actionDefs()
|
||||
}
|
||||
|
||||
export async function getTriggerList(
|
||||
ctx: UserCtx<void, GetAutomationTriggerDefinitionsResponse>
|
||||
) {
|
||||
ctx.body = getTriggerDefinitions()
|
||||
ctx.body = triggers.TRIGGER_DEFINITIONS
|
||||
}
|
||||
|
||||
export async function getDefinitionList(
|
||||
ctx: UserCtx<void, GetAutomationStepDefinitionsResponse>
|
||||
) {
|
||||
ctx.body = {
|
||||
trigger: getTriggerDefinitions(),
|
||||
action: await getActionDefinitions(),
|
||||
trigger: triggers.TRIGGER_DEFINITIONS,
|
||||
action: await actionDefs(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@ import {
|
|||
Table,
|
||||
} from "@budibase/types"
|
||||
import { mocks } from "@budibase/backend-core/tests"
|
||||
import { removeDeprecated } from "../../../automations/utils"
|
||||
import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
|
||||
import { basicTable } from "../../../tests/utilities/structures"
|
||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
|
@ -64,15 +63,11 @@ describe("/automations", () => {
|
|||
it("returns all of the definitions in one", async () => {
|
||||
const { action, trigger } = await config.api.automation.getDefinitions()
|
||||
|
||||
let definitionsLength = Object.keys(
|
||||
removeDeprecated(BUILTIN_ACTION_DEFINITIONS)
|
||||
).length
|
||||
|
||||
expect(Object.keys(action).length).toBeGreaterThanOrEqual(
|
||||
definitionsLength
|
||||
Object.keys(BUILTIN_ACTION_DEFINITIONS).length
|
||||
)
|
||||
expect(Object.keys(trigger).length).toEqual(
|
||||
Object.keys(removeDeprecated(TRIGGER_DEFINITIONS)).length
|
||||
Object.keys(TRIGGER_DEFINITIONS).length
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@ -290,8 +285,7 @@ describe("/automations", () => {
|
|||
await setup.delay(500)
|
||||
let elements = await getAllTableRows(config)
|
||||
// don't test it unless there are values to test
|
||||
if (elements.length > 1) {
|
||||
expect(elements.length).toBeGreaterThanOrEqual(MAX_RETRIES)
|
||||
if (elements.length >= 1) {
|
||||
expect(elements[0].name).toEqual("Test")
|
||||
expect(elements[0].description).toEqual("TEST")
|
||||
return
|
||||
|
|
|
@ -166,18 +166,6 @@ if (descriptions.length) {
|
|||
)
|
||||
}
|
||||
|
||||
const resetRowUsage = async () => {
|
||||
await config.doInContext(
|
||||
undefined,
|
||||
async () =>
|
||||
await quotas.setUsage(
|
||||
0,
|
||||
StaticQuotaName.ROWS,
|
||||
QuotaUsageType.STATIC
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const getRowUsage = async () => {
|
||||
const { total } = await config.doInContext(undefined, () =>
|
||||
quotas.getCurrentUsageValues(
|
||||
|
@ -188,19 +176,27 @@ if (descriptions.length) {
|
|||
return total
|
||||
}
|
||||
|
||||
const assertRowUsage = async (expected: number) => {
|
||||
const usage = await getRowUsage()
|
||||
async function expectRowUsage(expected: number, f: () => Promise<void>) {
|
||||
const before = await getRowUsage()
|
||||
await f()
|
||||
const after = await getRowUsage()
|
||||
const usage = after - before
|
||||
|
||||
// Because our quota tracking is not perfect, we allow a 10% margin of
|
||||
// error. This is to account for the fact that parallel writes can result
|
||||
// in some quota updates getting lost. We don't have any need to solve this
|
||||
// right now, so we just allow for some error.
|
||||
// error. This is to account for the fact that parallel writes can
|
||||
// result in some quota updates getting lost. We don't have any need
|
||||
// to solve this right now, so we just allow for some error.
|
||||
if (expected === 0) {
|
||||
expect(usage).toEqual(0)
|
||||
return
|
||||
}
|
||||
expect(usage).toBeGreaterThan(expected * 0.9)
|
||||
expect(usage).toBeLessThan(expected * 1.1)
|
||||
if (usage < 0) {
|
||||
expect(usage).toBeGreaterThan(expected * 1.1)
|
||||
expect(usage).toBeLessThan(expected * 0.9)
|
||||
} else {
|
||||
expect(usage).toBeGreaterThan(expected * 0.9)
|
||||
expect(usage).toBeLessThan(expected * 1.1)
|
||||
}
|
||||
}
|
||||
|
||||
const defaultRowFields = isInternal
|
||||
|
@ -215,91 +211,86 @@ if (descriptions.length) {
|
|||
table = await config.api.table.save(defaultTable())
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetRowUsage()
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("creates a new row successfully", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
const row = await config.api.row.save(table._id!, {
|
||||
name: "Test Contact",
|
||||
await expectRowUsage(isInternal ? 1 : 0, async () => {
|
||||
const row = await config.api.row.save(table._id!, {
|
||||
name: "Test Contact",
|
||||
})
|
||||
expect(row.name).toEqual("Test Contact")
|
||||
expect(row._rev).toBeDefined()
|
||||
})
|
||||
expect(row.name).toEqual("Test Contact")
|
||||
expect(row._rev).toBeDefined()
|
||||
await assertRowUsage(isInternal ? rowUsage + 1 : rowUsage)
|
||||
})
|
||||
|
||||
it("fails to create a row for a table that does not exist", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
await config.api.row.save("1234567", {}, { status: 404 })
|
||||
await assertRowUsage(rowUsage)
|
||||
await expectRowUsage(0, async () => {
|
||||
await config.api.row.save("1234567", {}, { status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
it("fails to create a row if required fields are missing", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
const table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
required: {
|
||||
type: FieldType.STRING,
|
||||
name: "required",
|
||||
constraints: {
|
||||
type: "string",
|
||||
presence: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
await config.api.row.save(
|
||||
table._id!,
|
||||
{},
|
||||
{
|
||||
status: 500,
|
||||
body: {
|
||||
validationErrors: {
|
||||
required: ["can't be blank"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
it("increment row autoId per create row request", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const newTable = await config.api.table.save(
|
||||
await expectRowUsage(0, async () => {
|
||||
const table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
"Row ID": {
|
||||
name: "Row ID",
|
||||
type: FieldType.NUMBER,
|
||||
subtype: AutoFieldSubType.AUTO_ID,
|
||||
icon: "ri-magic-line",
|
||||
autocolumn: true,
|
||||
required: {
|
||||
type: FieldType.STRING,
|
||||
name: "required",
|
||||
constraints: {
|
||||
type: "number",
|
||||
type: "string",
|
||||
presence: true,
|
||||
numericality: {
|
||||
greaterThanOrEqualTo: "",
|
||||
lessThanOrEqualTo: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
await config.api.row.save(
|
||||
table._id!,
|
||||
{},
|
||||
{
|
||||
status: 500,
|
||||
body: {
|
||||
validationErrors: {
|
||||
required: ["can't be blank"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
let previousId = 0
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const row = await config.api.row.save(newTable._id!, {})
|
||||
expect(row["Row ID"]).toBeGreaterThan(previousId)
|
||||
previousId = row["Row ID"]
|
||||
}
|
||||
await assertRowUsage(isInternal ? rowUsage + 10 : rowUsage)
|
||||
isInternal &&
|
||||
it("increment row autoId per create row request", async () => {
|
||||
await expectRowUsage(isInternal ? 10 : 0, async () => {
|
||||
const newTable = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
"Row ID": {
|
||||
name: "Row ID",
|
||||
type: FieldType.NUMBER,
|
||||
subtype: AutoFieldSubType.AUTO_ID,
|
||||
icon: "ri-magic-line",
|
||||
autocolumn: true,
|
||||
constraints: {
|
||||
type: "number",
|
||||
presence: true,
|
||||
numericality: {
|
||||
greaterThanOrEqualTo: "",
|
||||
lessThanOrEqualTo: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
let previousId = 0
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const row = await config.api.row.save(newTable._id!, {})
|
||||
expect(row["Row ID"]).toBeGreaterThan(previousId)
|
||||
previousId = row["Row ID"]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
|
@ -985,16 +976,16 @@ if (descriptions.length) {
|
|||
describe("update", () => {
|
||||
it("updates an existing row successfully", async () => {
|
||||
const existing = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.save(table._id!, {
|
||||
_id: existing._id,
|
||||
_rev: existing._rev,
|
||||
name: "Updated Name",
|
||||
await expectRowUsage(0, async () => {
|
||||
const res = await config.api.row.save(table._id!, {
|
||||
_id: existing._id,
|
||||
_rev: existing._rev,
|
||||
name: "Updated Name",
|
||||
})
|
||||
|
||||
expect(res.name).toEqual("Updated Name")
|
||||
})
|
||||
|
||||
expect(res.name).toEqual("Updated Name")
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
!isInternal &&
|
||||
|
@ -1177,23 +1168,22 @@ if (descriptions.length) {
|
|||
it("should update only the fields that are supplied", async () => {
|
||||
const existing = await config.api.row.save(table._id!, {})
|
||||
|
||||
const rowUsage = await getRowUsage()
|
||||
await expectRowUsage(0, async () => {
|
||||
const row = await config.api.row.patch(table._id!, {
|
||||
_id: existing._id!,
|
||||
_rev: existing._rev!,
|
||||
tableId: table._id!,
|
||||
name: "Updated Name",
|
||||
})
|
||||
|
||||
const row = await config.api.row.patch(table._id!, {
|
||||
_id: existing._id!,
|
||||
_rev: existing._rev!,
|
||||
tableId: table._id!,
|
||||
name: "Updated Name",
|
||||
expect(row.name).toEqual("Updated Name")
|
||||
expect(row.description).toEqual(existing.description)
|
||||
|
||||
const savedRow = await config.api.row.get(table._id!, row._id!)
|
||||
|
||||
expect(savedRow.description).toEqual(existing.description)
|
||||
expect(savedRow.name).toEqual("Updated Name")
|
||||
})
|
||||
|
||||
expect(row.name).toEqual("Updated Name")
|
||||
expect(row.description).toEqual(existing.description)
|
||||
|
||||
const savedRow = await config.api.row.get(table._id!, row._id!)
|
||||
|
||||
expect(savedRow.description).toEqual(existing.description)
|
||||
expect(savedRow.name).toEqual("Updated Name")
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("should update only the fields that are supplied and emit the correct oldRow", async () => {
|
||||
|
@ -1224,20 +1214,19 @@ if (descriptions.length) {
|
|||
|
||||
it("should throw an error when given improper types", async () => {
|
||||
const existing = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
await config.api.row.patch(
|
||||
table._id!,
|
||||
{
|
||||
_id: existing._id!,
|
||||
_rev: existing._rev!,
|
||||
tableId: table._id!,
|
||||
name: 1,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
|
||||
await assertRowUsage(rowUsage)
|
||||
await expectRowUsage(0, async () => {
|
||||
await config.api.row.patch(
|
||||
table._id!,
|
||||
{
|
||||
_id: existing._id!,
|
||||
_rev: existing._rev!,
|
||||
tableId: table._id!,
|
||||
name: 1,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("should not overwrite links if those links are not set", async () => {
|
||||
|
@ -1452,25 +1441,25 @@ if (descriptions.length) {
|
|||
|
||||
it("should be able to delete a row", async () => {
|
||||
const createdRow = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [createdRow],
|
||||
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [createdRow],
|
||||
})
|
||||
expect(res[0]._id).toEqual(createdRow._id)
|
||||
})
|
||||
expect(res[0]._id).toEqual(createdRow._id)
|
||||
await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage)
|
||||
})
|
||||
|
||||
it("should be able to delete a row with ID only", async () => {
|
||||
const createdRow = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [createdRow._id!],
|
||||
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [createdRow._id!],
|
||||
})
|
||||
expect(res[0]._id).toEqual(createdRow._id)
|
||||
expect(res[0].tableId).toEqual(table._id!)
|
||||
})
|
||||
expect(res[0]._id).toEqual(createdRow._id)
|
||||
expect(res[0].tableId).toEqual(table._id!)
|
||||
await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage)
|
||||
})
|
||||
|
||||
it("should be able to bulk delete rows, including a row that doesn't exist", async () => {
|
||||
|
@ -1560,31 +1549,29 @@ if (descriptions.length) {
|
|||
})
|
||||
|
||||
it("should return no errors on valid row", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
await expectRowUsage(0, async () => {
|
||||
const res = await config.api.row.validate(table._id!, {
|
||||
name: "ivan",
|
||||
})
|
||||
|
||||
const res = await config.api.row.validate(table._id!, {
|
||||
name: "ivan",
|
||||
expect(res.valid).toBe(true)
|
||||
expect(Object.keys(res.errors)).toEqual([])
|
||||
})
|
||||
|
||||
expect(res.valid).toBe(true)
|
||||
expect(Object.keys(res.errors)).toEqual([])
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("should errors on invalid row", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
await expectRowUsage(0, async () => {
|
||||
const res = await config.api.row.validate(table._id!, { name: 1 })
|
||||
|
||||
const res = await config.api.row.validate(table._id!, { name: 1 })
|
||||
|
||||
if (isInternal) {
|
||||
expect(res.valid).toBe(false)
|
||||
expect(Object.keys(res.errors)).toEqual(["name"])
|
||||
} else {
|
||||
// Validation for external is not implemented, so it will always return valid
|
||||
expect(res.valid).toBe(true)
|
||||
expect(Object.keys(res.errors)).toEqual([])
|
||||
}
|
||||
await assertRowUsage(rowUsage)
|
||||
if (isInternal) {
|
||||
expect(res.valid).toBe(false)
|
||||
expect(Object.keys(res.errors)).toEqual(["name"])
|
||||
} else {
|
||||
// Validation for external is not implemented, so it will always return valid
|
||||
expect(res.valid).toBe(true)
|
||||
expect(Object.keys(res.errors)).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -1596,15 +1583,15 @@ if (descriptions.length) {
|
|||
it("should be able to delete a bulk set of rows", async () => {
|
||||
const row1 = await config.api.row.save(table._id!, {})
|
||||
const row2 = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [row1, row2],
|
||||
await expectRowUsage(isInternal ? -2 : 0, async () => {
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [row1, row2],
|
||||
})
|
||||
|
||||
expect(res.length).toEqual(2)
|
||||
await config.api.row.get(table._id!, row1._id!, { status: 404 })
|
||||
})
|
||||
|
||||
expect(res.length).toEqual(2)
|
||||
await config.api.row.get(table._id!, row1._id!, { status: 404 })
|
||||
await assertRowUsage(isInternal ? rowUsage - 2 : rowUsage)
|
||||
})
|
||||
|
||||
it("should be able to delete a variety of row set types", async () => {
|
||||
|
@ -1613,41 +1600,42 @@ if (descriptions.length) {
|
|||
config.api.row.save(table._id!, {}),
|
||||
config.api.row.save(table._id!, {}),
|
||||
])
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [row1, row2._id!, { _id: row3._id }],
|
||||
await expectRowUsage(isInternal ? -3 : 0, async () => {
|
||||
const res = await config.api.row.bulkDelete(table._id!, {
|
||||
rows: [row1, row2._id!, { _id: row3._id }],
|
||||
})
|
||||
|
||||
expect(res.length).toEqual(3)
|
||||
await config.api.row.get(table._id!, row1._id!, { status: 404 })
|
||||
})
|
||||
|
||||
expect(res.length).toEqual(3)
|
||||
await config.api.row.get(table._id!, row1._id!, { status: 404 })
|
||||
await assertRowUsage(isInternal ? rowUsage - 3 : rowUsage)
|
||||
})
|
||||
|
||||
it("should accept a valid row object and delete the row", async () => {
|
||||
const row1 = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.delete(table._id!, row1 as DeleteRow)
|
||||
await expectRowUsage(isInternal ? -1 : 0, async () => {
|
||||
const res = await config.api.row.delete(
|
||||
table._id!,
|
||||
row1 as DeleteRow
|
||||
)
|
||||
|
||||
expect(res.id).toEqual(row1._id)
|
||||
await config.api.row.get(table._id!, row1._id!, { status: 404 })
|
||||
await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage)
|
||||
expect(res.id).toEqual(row1._id)
|
||||
await config.api.row.get(table._id!, row1._id!, { status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
it.each([{ not: "valid" }, { rows: 123 }, "invalid"])(
|
||||
"should ignore malformed/invalid delete request: %s",
|
||||
async (request: any) => {
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
await config.api.row.delete(table._id!, request, {
|
||||
status: 400,
|
||||
body: {
|
||||
message: "Invalid delete rows request",
|
||||
},
|
||||
await expectRowUsage(0, async () => {
|
||||
await config.api.row.delete(table._id!, request, {
|
||||
status: 400,
|
||||
body: {
|
||||
message: "Invalid delete rows request",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
await assertRowUsage(rowUsage)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
@ -1733,31 +1721,29 @@ if (descriptions.length) {
|
|||
})
|
||||
)
|
||||
|
||||
const rowUsage = await getRowUsage()
|
||||
await expectRowUsage(isInternal ? 2 : 0, async () => {
|
||||
await config.api.row.bulkImport(table._id!, {
|
||||
rows: [
|
||||
{
|
||||
name: "Row 1",
|
||||
description: "Row 1 description",
|
||||
},
|
||||
{
|
||||
name: "Row 2",
|
||||
description: "Row 2 description",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await config.api.row.bulkImport(table._id!, {
|
||||
rows: [
|
||||
{
|
||||
name: "Row 1",
|
||||
description: "Row 1 description",
|
||||
},
|
||||
{
|
||||
name: "Row 2",
|
||||
description: "Row 2 description",
|
||||
},
|
||||
],
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows.length).toEqual(2)
|
||||
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
expect(rows[0].name).toEqual("Row 1")
|
||||
expect(rows[0].description).toEqual("Row 1 description")
|
||||
expect(rows[1].name).toEqual("Row 2")
|
||||
expect(rows[1].description).toEqual("Row 2 description")
|
||||
})
|
||||
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows.length).toEqual(2)
|
||||
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
expect(rows[0].name).toEqual("Row 1")
|
||||
expect(rows[0].description).toEqual("Row 1 description")
|
||||
expect(rows[1].name).toEqual("Row 2")
|
||||
expect(rows[1].description).toEqual("Row 2 description")
|
||||
|
||||
await assertRowUsage(isInternal ? rowUsage + 2 : rowUsage)
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
|
@ -1782,35 +1768,33 @@ if (descriptions.length) {
|
|||
description: "Existing description",
|
||||
})
|
||||
|
||||
const rowUsage = await getRowUsage()
|
||||
await expectRowUsage(2, async () => {
|
||||
await config.api.row.bulkImport(table._id!, {
|
||||
rows: [
|
||||
{
|
||||
name: "Row 1",
|
||||
description: "Row 1 description",
|
||||
},
|
||||
{ ...existingRow, name: "Updated existing row" },
|
||||
{
|
||||
name: "Row 2",
|
||||
description: "Row 2 description",
|
||||
},
|
||||
],
|
||||
identifierFields: ["_id"],
|
||||
})
|
||||
|
||||
await config.api.row.bulkImport(table._id!, {
|
||||
rows: [
|
||||
{
|
||||
name: "Row 1",
|
||||
description: "Row 1 description",
|
||||
},
|
||||
{ ...existingRow, name: "Updated existing row" },
|
||||
{
|
||||
name: "Row 2",
|
||||
description: "Row 2 description",
|
||||
},
|
||||
],
|
||||
identifierFields: ["_id"],
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows.length).toEqual(3)
|
||||
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
expect(rows[0].name).toEqual("Row 1")
|
||||
expect(rows[0].description).toEqual("Row 1 description")
|
||||
expect(rows[1].name).toEqual("Row 2")
|
||||
expect(rows[1].description).toEqual("Row 2 description")
|
||||
expect(rows[2].name).toEqual("Updated existing row")
|
||||
expect(rows[2].description).toEqual("Existing description")
|
||||
})
|
||||
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows.length).toEqual(3)
|
||||
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
expect(rows[0].name).toEqual("Row 1")
|
||||
expect(rows[0].description).toEqual("Row 1 description")
|
||||
expect(rows[1].name).toEqual("Row 2")
|
||||
expect(rows[1].description).toEqual("Row 2 description")
|
||||
expect(rows[2].name).toEqual("Updated existing row")
|
||||
expect(rows[2].description).toEqual("Existing description")
|
||||
|
||||
await assertRowUsage(rowUsage + 2)
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
|
@ -1835,36 +1819,34 @@ if (descriptions.length) {
|
|||
description: "Existing description",
|
||||
})
|
||||
|
||||
const rowUsage = await getRowUsage()
|
||||
await expectRowUsage(3, async () => {
|
||||
await config.api.row.bulkImport(table._id!, {
|
||||
rows: [
|
||||
{
|
||||
name: "Row 1",
|
||||
description: "Row 1 description",
|
||||
},
|
||||
{ ...existingRow, name: "Updated existing row" },
|
||||
{
|
||||
name: "Row 2",
|
||||
description: "Row 2 description",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await config.api.row.bulkImport(table._id!, {
|
||||
rows: [
|
||||
{
|
||||
name: "Row 1",
|
||||
description: "Row 1 description",
|
||||
},
|
||||
{ ...existingRow, name: "Updated existing row" },
|
||||
{
|
||||
name: "Row 2",
|
||||
description: "Row 2 description",
|
||||
},
|
||||
],
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows.length).toEqual(4)
|
||||
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
expect(rows[0].name).toEqual("Existing row")
|
||||
expect(rows[0].description).toEqual("Existing description")
|
||||
expect(rows[1].name).toEqual("Row 1")
|
||||
expect(rows[1].description).toEqual("Row 1 description")
|
||||
expect(rows[2].name).toEqual("Row 2")
|
||||
expect(rows[2].description).toEqual("Row 2 description")
|
||||
expect(rows[3].name).toEqual("Updated existing row")
|
||||
expect(rows[3].description).toEqual("Existing description")
|
||||
})
|
||||
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows.length).toEqual(4)
|
||||
|
||||
rows.sort((a, b) => a.name.localeCompare(b.name))
|
||||
expect(rows[0].name).toEqual("Existing row")
|
||||
expect(rows[0].description).toEqual("Existing description")
|
||||
expect(rows[1].name).toEqual("Row 1")
|
||||
expect(rows[1].description).toEqual("Row 1 description")
|
||||
expect(rows[2].name).toEqual("Row 2")
|
||||
expect(rows[2].description).toEqual("Row 2 description")
|
||||
expect(rows[3].name).toEqual("Updated existing row")
|
||||
expect(rows[3].description).toEqual("Existing description")
|
||||
|
||||
await assertRowUsage(rowUsage + 3)
|
||||
})
|
||||
|
||||
// Upserting isn't yet supported in MSSQL / Oracle, see:
|
||||
|
@ -2187,29 +2169,29 @@ if (descriptions.length) {
|
|||
return { linkedTable, firstRow, secondRow }
|
||||
}
|
||||
)
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
// test basic enrichment
|
||||
const resBasic = await config.api.row.get(
|
||||
linkedTable._id!,
|
||||
secondRow._id!
|
||||
)
|
||||
expect(resBasic.link.length).toBe(1)
|
||||
expect(resBasic.link[0]).toEqual({
|
||||
_id: firstRow._id,
|
||||
primaryDisplay: firstRow.name,
|
||||
await expectRowUsage(0, async () => {
|
||||
// test basic enrichment
|
||||
const resBasic = await config.api.row.get(
|
||||
linkedTable._id!,
|
||||
secondRow._id!
|
||||
)
|
||||
expect(resBasic.link.length).toBe(1)
|
||||
expect(resBasic.link[0]).toEqual({
|
||||
_id: firstRow._id,
|
||||
primaryDisplay: firstRow.name,
|
||||
})
|
||||
|
||||
// test full enrichment
|
||||
const resEnriched = await config.api.row.getEnriched(
|
||||
linkedTable._id!,
|
||||
secondRow._id!
|
||||
)
|
||||
expect(resEnriched.link.length).toBe(1)
|
||||
expect(resEnriched.link[0]._id).toBe(firstRow._id)
|
||||
expect(resEnriched.link[0].name).toBe("Test Contact")
|
||||
expect(resEnriched.link[0].description).toBe("original description")
|
||||
})
|
||||
|
||||
// test full enrichment
|
||||
const resEnriched = await config.api.row.getEnriched(
|
||||
linkedTable._id!,
|
||||
secondRow._id!
|
||||
)
|
||||
expect(resEnriched.link.length).toBe(1)
|
||||
expect(resEnriched.link[0]._id).toBe(firstRow._id)
|
||||
expect(resEnriched.link[0].name).toBe("Test Contact")
|
||||
expect(resEnriched.link[0].description).toBe("original description")
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -2826,34 +2826,44 @@ if (descriptions.length) {
|
|||
return total
|
||||
}
|
||||
|
||||
const assertRowUsage = async (expected: number) => {
|
||||
const usage = await getRowUsage()
|
||||
async function expectRowUsage<T>(
|
||||
expected: number,
|
||||
f: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const before = await getRowUsage()
|
||||
const result = await f()
|
||||
const after = await getRowUsage()
|
||||
const usage = after - before
|
||||
expect(usage).toBe(expected)
|
||||
return result
|
||||
}
|
||||
|
||||
it("should be able to delete a row", async () => {
|
||||
const createdRow = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
await config.api.row.bulkDelete(view.id, { rows: [createdRow] })
|
||||
await assertRowUsage(isInternal ? rowUsage - 1 : rowUsage)
|
||||
const createdRow = await expectRowUsage(isInternal ? 1 : 0, () =>
|
||||
config.api.row.save(table._id!, {})
|
||||
)
|
||||
await expectRowUsage(isInternal ? -1 : 0, () =>
|
||||
config.api.row.bulkDelete(view.id, { rows: [createdRow] })
|
||||
)
|
||||
await config.api.row.get(table._id!, createdRow._id!, {
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
it("should be able to delete multiple rows", async () => {
|
||||
const rows = await Promise.all([
|
||||
config.api.row.save(table._id!, {}),
|
||||
config.api.row.save(table._id!, {}),
|
||||
config.api.row.save(table._id!, {}),
|
||||
])
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
await config.api.row.bulkDelete(view.id, {
|
||||
rows: [rows[0], rows[2]],
|
||||
const rows = await expectRowUsage(isInternal ? 3 : 0, async () => {
|
||||
return [
|
||||
await config.api.row.save(table._id!, {}),
|
||||
await config.api.row.save(table._id!, {}),
|
||||
await config.api.row.save(table._id!, {}),
|
||||
]
|
||||
})
|
||||
|
||||
await assertRowUsage(isInternal ? rowUsage - 2 : rowUsage)
|
||||
await expectRowUsage(isInternal ? -2 : 0, async () => {
|
||||
await config.api.row.bulkDelete(view.id, {
|
||||
rows: [rows[0], rows[2]],
|
||||
})
|
||||
})
|
||||
|
||||
await config.api.row.get(table._id!, rows[0]._id!, {
|
||||
status: 404,
|
||||
|
|
|
@ -4,6 +4,7 @@ import * as createRow from "./steps/createRow"
|
|||
import * as updateRow from "./steps/updateRow"
|
||||
import * as deleteRow from "./steps/deleteRow"
|
||||
import * as executeScript from "./steps/executeScript"
|
||||
import * as executeScriptV2 from "./steps/executeScriptV2"
|
||||
import * as executeQuery from "./steps/executeQuery"
|
||||
import * as outgoingWebhook from "./steps/outgoingWebhook"
|
||||
import * as serverLog from "./steps/serverLog"
|
||||
|
@ -44,6 +45,7 @@ const ACTION_IMPLS: ActionImplType = {
|
|||
DELETE_ROW: deleteRow.run,
|
||||
OUTGOING_WEBHOOK: outgoingWebhook.run,
|
||||
EXECUTE_SCRIPT: executeScript.run,
|
||||
EXECUTE_SCRIPT_V2: executeScriptV2.run,
|
||||
EXECUTE_QUERY: executeQuery.run,
|
||||
SERVER_LOG: serverLog.run,
|
||||
DELAY: delay.run,
|
||||
|
@ -70,6 +72,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<
|
|||
DELETE_ROW: automations.steps.deleteRow.definition,
|
||||
OUTGOING_WEBHOOK: automations.steps.outgoingWebhook.definition,
|
||||
EXECUTE_SCRIPT: automations.steps.executeScript.definition,
|
||||
EXECUTE_SCRIPT_V2: automations.steps.executeScriptV2.definition,
|
||||
EXECUTE_QUERY: automations.steps.executeQuery.definition,
|
||||
SERVER_LOG: automations.steps.serverLog.definition,
|
||||
DELAY: automations.steps.delay.definition,
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import * as automationUtils from "../automationUtils"
|
||||
import {
|
||||
ExecuteScriptStepInputs,
|
||||
ExecuteScriptStepOutputs,
|
||||
} from "@budibase/types"
|
||||
import { processStringSync } from "@budibase/string-templates"
|
||||
|
||||
export async function run({
|
||||
inputs,
|
||||
context,
|
||||
}: {
|
||||
inputs: ExecuteScriptStepInputs
|
||||
context: Record<string, any>
|
||||
}): Promise<ExecuteScriptStepOutputs> {
|
||||
let { code } = inputs
|
||||
|
||||
if (code == null) {
|
||||
return {
|
||||
success: false,
|
||||
response: {
|
||||
message: "Invalid inputs",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
code = code.trim()
|
||||
|
||||
if (!code.startsWith("{{ js ")) {
|
||||
return {
|
||||
success: false,
|
||||
response: {
|
||||
message: "Expected code to be a {{ js }} template block",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
success: true,
|
||||
value: processStringSync(inputs.code, context, { noThrow: false }),
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
response: automationUtils.getError(err),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,158 @@
|
|||
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
|
||||
import * as automation from "../index"
|
||||
import * as setup from "./utilities"
|
||||
import { Table } from "@budibase/types"
|
||||
|
||||
function encodeJS(js: string): string {
|
||||
return `{{ js "${Buffer.from(js, "utf-8").toString("base64")}" }}`
|
||||
}
|
||||
|
||||
describe("Execute Script Automations", () => {
|
||||
let config = setup.getConfig(),
|
||||
table: Table
|
||||
|
||||
beforeEach(async () => {
|
||||
await automation.init()
|
||||
await config.init()
|
||||
table = await config.createTable()
|
||||
await config.createRow()
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
it("should execute a basic script and return the result", async () => {
|
||||
config.name = "Basic Script Execution"
|
||||
const builder = createAutomationBuilder(config)
|
||||
|
||||
const results = await builder
|
||||
.onAppAction()
|
||||
.executeScriptV2({ code: encodeJS("return 2 + 2") })
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.value).toEqual(4)
|
||||
})
|
||||
|
||||
it("should access bindings from previous steps", async () => {
|
||||
config.name = "Access Bindings"
|
||||
const builder = createAutomationBuilder(config)
|
||||
|
||||
const results = await builder
|
||||
.onAppAction()
|
||||
.executeScriptV2(
|
||||
{
|
||||
code: encodeJS(`return $("trigger.fields.data").map(x => x * 2)`),
|
||||
},
|
||||
{ stepId: "binding-script-step" }
|
||||
)
|
||||
.test({ fields: { data: [1, 2, 3] } })
|
||||
|
||||
expect(results.steps[0].outputs.value).toEqual([2, 4, 6])
|
||||
})
|
||||
|
||||
it("should handle script execution errors gracefully", async () => {
|
||||
config.name = "Handle Script Errors"
|
||||
const builder = createAutomationBuilder(config)
|
||||
|
||||
const results = await builder
|
||||
.onAppAction()
|
||||
.executeScriptV2({
|
||||
code: encodeJS("return nonexistentVariable.map(x => x)"),
|
||||
})
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.response).toContain(
|
||||
"ReferenceError: nonexistentVariable is not defined"
|
||||
)
|
||||
expect(results.steps[0].outputs.success).toEqual(false)
|
||||
})
|
||||
|
||||
it("should handle conditional logic in scripts", async () => {
|
||||
config.name = "Conditional Script Logic"
|
||||
const builder = createAutomationBuilder(config)
|
||||
|
||||
const results = await builder
|
||||
.onAppAction()
|
||||
.executeScriptV2({
|
||||
code: encodeJS(`
|
||||
if ($("trigger.fields.value") > 5) {
|
||||
return "Value is greater than 5";
|
||||
} else {
|
||||
return "Value is 5 or less";
|
||||
}
|
||||
`),
|
||||
})
|
||||
.test({ fields: { value: 10 } })
|
||||
|
||||
expect(results.steps[0].outputs.value).toEqual("Value is greater than 5")
|
||||
})
|
||||
|
||||
it("should use multiple steps and validate script execution", async () => {
|
||||
config.name = "Multi-Step Script Execution"
|
||||
const builder = createAutomationBuilder(config)
|
||||
|
||||
const results = await builder
|
||||
.onAppAction()
|
||||
.serverLog(
|
||||
{ text: "Starting multi-step automation" },
|
||||
{ stepId: "start-log-step" }
|
||||
)
|
||||
.createRow(
|
||||
{ row: { name: "Test Row", value: 42, tableId: table._id } },
|
||||
{ stepId: "abc123" }
|
||||
)
|
||||
.executeScriptV2(
|
||||
{
|
||||
code: encodeJS(`
|
||||
const createdRow = $("steps")['abc123'];
|
||||
return createdRow.row.value * 2;
|
||||
`),
|
||||
},
|
||||
{ stepId: "ScriptingStep1" }
|
||||
)
|
||||
.serverLog({
|
||||
text: `Final result is {{ steps.ScriptingStep1.value }}`,
|
||||
})
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.message).toContain(
|
||||
"Starting multi-step automation"
|
||||
)
|
||||
expect(results.steps[1].outputs.row.value).toEqual(42)
|
||||
expect(results.steps[2].outputs.value).toEqual(84)
|
||||
expect(results.steps[3].outputs.message).toContain("Final result is 84")
|
||||
})
|
||||
|
||||
it("should fail if the code has not been encoded as a handlebars template", async () => {
|
||||
config.name = "Invalid Code Encoding"
|
||||
const builder = createAutomationBuilder(config)
|
||||
|
||||
const results = await builder
|
||||
.onAppAction()
|
||||
.executeScriptV2({
|
||||
code: "return 2 + 2",
|
||||
})
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.response.message).toEqual(
|
||||
"Expected code to be a {{ js }} template block"
|
||||
)
|
||||
expect(results.steps[0].outputs.success).toEqual(false)
|
||||
})
|
||||
|
||||
it("does not process embedded handlebars templates", async () => {
|
||||
config.name = "Embedded Handlebars"
|
||||
const builder = createAutomationBuilder(config)
|
||||
|
||||
const results = await builder
|
||||
.onAppAction()
|
||||
.executeScriptV2({
|
||||
code: encodeJS(`return "{{ triggers.row.whatever }}"`),
|
||||
})
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.value).toEqual(
|
||||
"{{ triggers.row.whatever }}"
|
||||
)
|
||||
expect(results.steps[0].outputs.success).toEqual(true)
|
||||
})
|
||||
})
|
|
@ -195,7 +195,34 @@ describe("Attempt to run a basic loop automation", () => {
|
|||
.serverLog({ text: "{{steps.1.iterations}}" })
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.status).toBe(
|
||||
AutomationStepStatus.MAX_ITERATIONS
|
||||
)
|
||||
expect(results.steps[0].outputs.iterations).toBe(2)
|
||||
expect(results.steps[0].outputs.items).toHaveLength(2)
|
||||
expect(results.steps[0].outputs.items[0].message).toEndWith("test")
|
||||
expect(results.steps[0].outputs.items[1].message).toEndWith("test2")
|
||||
})
|
||||
|
||||
it("should stop when a failure condition is hit", async () => {
|
||||
const results = await createAutomationBuilder(config)
|
||||
.onAppAction()
|
||||
.loop({
|
||||
option: LoopStepType.ARRAY,
|
||||
binding: ["test", "test2", "test3"],
|
||||
failure: "test3",
|
||||
})
|
||||
.serverLog({ text: "{{loop.currentItem}}" })
|
||||
.serverLog({ text: "{{steps.1.iterations}}" })
|
||||
.test({ fields: {} })
|
||||
|
||||
expect(results.steps[0].outputs.status).toBe(
|
||||
AutomationStepStatus.FAILURE_CONDITION
|
||||
)
|
||||
expect(results.steps[0].outputs.iterations).toBe(2)
|
||||
expect(results.steps[0].outputs.items).toHaveLength(2)
|
||||
expect(results.steps[0].outputs.items[0].message).toEndWith("test")
|
||||
expect(results.steps[0].outputs.items[1].message).toEndWith("test2")
|
||||
})
|
||||
|
||||
it("should run an automation with loop and max iterations to ensure context correctness further down the tree", async () => {
|
||||
|
|
|
@ -100,6 +100,7 @@ class BranchStepBuilder<TStep extends AutomationTriggerStepId> {
|
|||
loop = this.step(AutomationActionStepId.LOOP)
|
||||
serverLog = this.step(AutomationActionStepId.SERVER_LOG)
|
||||
executeScript = this.step(AutomationActionStepId.EXECUTE_SCRIPT)
|
||||
executeScriptV2 = this.step(AutomationActionStepId.EXECUTE_SCRIPT_V2)
|
||||
filter = this.step(AutomationActionStepId.FILTER)
|
||||
bash = this.step(AutomationActionStepId.EXECUTE_BASH)
|
||||
openai = this.step(AutomationActionStepId.OPENAI)
|
||||
|
|
|
@ -4,17 +4,8 @@ import { automationQueue } from "./bullboard"
|
|||
import { updateEntityMetadata } from "../utilities"
|
||||
import { context, db as dbCore, utils } from "@budibase/backend-core"
|
||||
import { getAutomationMetadataParams } from "../db/utils"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { quotas } from "@budibase/pro"
|
||||
import {
|
||||
Automation,
|
||||
AutomationActionStepId,
|
||||
AutomationJob,
|
||||
AutomationStepDefinition,
|
||||
AutomationTriggerDefinition,
|
||||
AutomationTriggerStepId,
|
||||
MetadataType,
|
||||
} from "@budibase/types"
|
||||
import { Automation, AutomationJob, MetadataType } from "@budibase/types"
|
||||
import { automationsEnabled } from "../features"
|
||||
import { helpers, REBOOT_CRON } from "@budibase/shared-core"
|
||||
import tracer from "dd-trace"
|
||||
|
@ -113,23 +104,6 @@ export async function updateTestHistory(
|
|||
)
|
||||
}
|
||||
|
||||
export function removeDeprecated<
|
||||
T extends
|
||||
| Record<keyof typeof AutomationTriggerStepId, AutomationTriggerDefinition>
|
||||
| Record<keyof typeof AutomationActionStepId, AutomationStepDefinition>
|
||||
>(definitions: T): T {
|
||||
const base: Record<
|
||||
string,
|
||||
AutomationTriggerDefinition | AutomationStepDefinition
|
||||
> = cloneDeep(definitions)
|
||||
for (let key of Object.keys(base)) {
|
||||
if (base[key].deprecated) {
|
||||
delete base[key]
|
||||
}
|
||||
}
|
||||
return base as T
|
||||
}
|
||||
|
||||
// end the repetition and the job itself
|
||||
export async function disableAllCrons(appId: any) {
|
||||
const promises = []
|
||||
|
|
|
@ -20,9 +20,12 @@ export interface TriggerOutput {
|
|||
|
||||
export interface AutomationContext {
|
||||
trigger: AutomationTriggerResultOutputs
|
||||
steps: [AutomationTriggerResultOutputs, ...AutomationStepResultOutputs[]]
|
||||
stepsById: Record<string, AutomationStepResultOutputs>
|
||||
steps: Record<
|
||||
string,
|
||||
AutomationStepResultOutputs | AutomationTriggerResultOutputs
|
||||
>
|
||||
stepsByName: Record<string, AutomationStepResultOutputs>
|
||||
stepsById: Record<string, AutomationStepResultOutputs>
|
||||
env?: Record<string, string>
|
||||
user?: UserBindings
|
||||
settings?: {
|
||||
|
@ -31,4 +34,6 @@ export interface AutomationContext {
|
|||
company?: string
|
||||
}
|
||||
loop?: { currentItem: any }
|
||||
_stepIndex: number
|
||||
_error: boolean
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ class AutomationEmitter implements ContextEmitter {
|
|||
|
||||
if (chainAutomations === true) {
|
||||
return MAX_AUTOMATIONS_ALLOWED
|
||||
} else if (env.isTest()) {
|
||||
return 0
|
||||
} else if (chainAutomations === undefined && env.SELF_HOSTED) {
|
||||
return MAX_AUTOMATIONS_ALLOWED
|
||||
} else {
|
||||
|
|
|
@ -14,15 +14,14 @@ import {
|
|||
UpdateCommandInput,
|
||||
DeleteCommandInput,
|
||||
} from "@aws-sdk/lib-dynamodb"
|
||||
import { DynamoDB } from "@aws-sdk/client-dynamodb"
|
||||
import { DynamoDB, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"
|
||||
import { AWS_REGION } from "../constants"
|
||||
|
||||
interface DynamoDBConfig {
|
||||
export interface DynamoDBConfig {
|
||||
region: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
endpoint?: string
|
||||
currentClockSkew?: boolean
|
||||
}
|
||||
|
||||
const SCHEMA: Integration = {
|
||||
|
@ -138,22 +137,16 @@ const SCHEMA: Integration = {
|
|||
},
|
||||
}
|
||||
|
||||
class DynamoDBIntegration implements IntegrationBase {
|
||||
private config: DynamoDBConfig
|
||||
private client
|
||||
export class DynamoDBIntegration implements IntegrationBase {
|
||||
private config: DynamoDBClientConfig
|
||||
private client: DynamoDBDocument
|
||||
|
||||
constructor(config: DynamoDBConfig) {
|
||||
this.config = config
|
||||
|
||||
// User is using a local dynamoDB endpoint, don't auth with remote
|
||||
if (this.config?.endpoint?.includes("localhost")) {
|
||||
// @ts-ignore
|
||||
this.config = {}
|
||||
}
|
||||
|
||||
this.config = {
|
||||
...this.config,
|
||||
currentClockSkew: true,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
region: config.region || AWS_REGION,
|
||||
endpoint: config.endpoint || undefined,
|
||||
}
|
||||
|
|
|
@ -1,167 +1,108 @@
|
|||
jest.mock("@aws-sdk/lib-dynamodb", () => ({
|
||||
DynamoDBDocument: {
|
||||
from: jest.fn(() => ({
|
||||
update: jest.fn(),
|
||||
put: jest.fn(),
|
||||
query: jest.fn(() => ({
|
||||
Items: [],
|
||||
})),
|
||||
scan: jest.fn(() => ({
|
||||
Items: [],
|
||||
})),
|
||||
delete: jest.fn(),
|
||||
get: jest.fn(),
|
||||
})),
|
||||
},
|
||||
}))
|
||||
jest.mock("@aws-sdk/client-dynamodb")
|
||||
import { default as DynamoDBIntegration } from "../dynamodb"
|
||||
import { Datasource } from "@budibase/types"
|
||||
import { DynamoDBConfig, DynamoDBIntegration } from "../dynamodb"
|
||||
import { DatabaseName, datasourceDescribe } from "./utils"
|
||||
import {
|
||||
CreateTableCommandInput,
|
||||
DynamoDB,
|
||||
DynamoDBClientConfig,
|
||||
} from "@aws-sdk/client-dynamodb"
|
||||
|
||||
class TestConfiguration {
|
||||
integration: any
|
||||
const describes = datasourceDescribe({ only: [DatabaseName.DYNAMODB] })
|
||||
|
||||
constructor(config: any = {}) {
|
||||
this.integration = new DynamoDBIntegration.integration(config)
|
||||
async function createTable(client: DynamoDB, req: CreateTableCommandInput) {
|
||||
try {
|
||||
await client.deleteTable({ TableName: req.TableName })
|
||||
} catch (e: any) {
|
||||
if (e.name !== "ResourceNotFoundException") {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return await client.createTable(req)
|
||||
}
|
||||
|
||||
describe("DynamoDB Integration", () => {
|
||||
let config: any
|
||||
let tableName = "Users"
|
||||
if (describes.length > 0) {
|
||||
describe.each(describes)("DynamoDB Integration", ({ dsProvider }) => {
|
||||
let table = "Users"
|
||||
let rawDatasource: Datasource
|
||||
let dynamodb: DynamoDBIntegration
|
||||
|
||||
beforeEach(() => {
|
||||
config = new TestConfiguration()
|
||||
})
|
||||
|
||||
it("calls the create method with the correct params", async () => {
|
||||
await config.integration.create({
|
||||
table: tableName,
|
||||
json: {
|
||||
Name: "John",
|
||||
},
|
||||
})
|
||||
expect(config.integration.client.put).toHaveBeenCalledWith({
|
||||
TableName: tableName,
|
||||
Name: "John",
|
||||
})
|
||||
})
|
||||
|
||||
it("calls the read method with the correct params", async () => {
|
||||
const indexName = "Test"
|
||||
|
||||
const response = await config.integration.read({
|
||||
table: tableName,
|
||||
index: indexName,
|
||||
json: {},
|
||||
})
|
||||
expect(config.integration.client.query).toHaveBeenCalledWith({
|
||||
TableName: tableName,
|
||||
IndexName: indexName,
|
||||
})
|
||||
expect(response).toEqual([])
|
||||
})
|
||||
|
||||
it("calls the scan method with the correct params", async () => {
|
||||
const indexName = "Test"
|
||||
|
||||
const response = await config.integration.scan({
|
||||
table: tableName,
|
||||
index: indexName,
|
||||
json: {},
|
||||
})
|
||||
expect(config.integration.client.scan).toHaveBeenCalledWith({
|
||||
TableName: tableName,
|
||||
IndexName: indexName,
|
||||
})
|
||||
expect(response).toEqual([])
|
||||
})
|
||||
|
||||
it("calls the get method with the correct params", async () => {
|
||||
await config.integration.get({
|
||||
table: tableName,
|
||||
json: {
|
||||
Id: 123,
|
||||
},
|
||||
})
|
||||
|
||||
expect(config.integration.client.get).toHaveBeenCalledWith({
|
||||
TableName: tableName,
|
||||
Id: 123,
|
||||
})
|
||||
})
|
||||
|
||||
it("calls the update method with the correct params", async () => {
|
||||
await config.integration.update({
|
||||
table: tableName,
|
||||
json: {
|
||||
Name: "John",
|
||||
},
|
||||
})
|
||||
expect(config.integration.client.update).toHaveBeenCalledWith({
|
||||
TableName: tableName,
|
||||
Name: "John",
|
||||
})
|
||||
})
|
||||
|
||||
it("calls the delete method with the correct params", async () => {
|
||||
await config.integration.delete({
|
||||
table: tableName,
|
||||
json: {
|
||||
Name: "John",
|
||||
},
|
||||
})
|
||||
expect(config.integration.client.delete).toHaveBeenCalledWith({
|
||||
TableName: tableName,
|
||||
Name: "John",
|
||||
})
|
||||
})
|
||||
|
||||
it("configures the dynamoDB constructor based on an empty endpoint parameter", async () => {
|
||||
const config = {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "test",
|
||||
secretAccessKey: "test",
|
||||
function item(json: Record<string, any>) {
|
||||
return { table, json: { Item: json } }
|
||||
}
|
||||
|
||||
const integration: any = new DynamoDBIntegration.integration(config)
|
||||
|
||||
expect(integration.config).toEqual({
|
||||
currentClockSkew: true,
|
||||
...config,
|
||||
})
|
||||
})
|
||||
|
||||
it("configures the dynamoDB constructor based on a localhost endpoint parameter", async () => {
|
||||
const config = {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "test",
|
||||
secretAccessKey: "test",
|
||||
endpoint: "localhost:8080",
|
||||
function key(json: Record<string, any>) {
|
||||
return { table, json: { Key: json } }
|
||||
}
|
||||
|
||||
const integration: any = new DynamoDBIntegration.integration(config)
|
||||
beforeEach(async () => {
|
||||
const ds = await dsProvider()
|
||||
rawDatasource = ds.rawDatasource!
|
||||
dynamodb = new DynamoDBIntegration(
|
||||
rawDatasource.config! as DynamoDBConfig
|
||||
)
|
||||
|
||||
expect(integration.config).toEqual({
|
||||
region: "us-east-1",
|
||||
currentClockSkew: true,
|
||||
endpoint: "localhost:8080",
|
||||
const config: DynamoDBClientConfig = {
|
||||
credentials: {
|
||||
accessKeyId: "test",
|
||||
secretAccessKey: "test",
|
||||
},
|
||||
region: "us-east-1",
|
||||
endpoint: rawDatasource.config!.endpoint,
|
||||
}
|
||||
|
||||
const client = new DynamoDB(config)
|
||||
await createTable(client, {
|
||||
TableName: table,
|
||||
KeySchema: [{ AttributeName: "Id", KeyType: "HASH" }],
|
||||
AttributeDefinitions: [{ AttributeName: "Id", AttributeType: "N" }],
|
||||
ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
it("can create and read a record", async () => {
|
||||
await dynamodb.create(item({ Id: 1, Name: "John" }))
|
||||
|
||||
const resp = await dynamodb.get(key({ Id: 1 }))
|
||||
expect(resp.Item).toEqual({ Id: 1, Name: "John" })
|
||||
})
|
||||
|
||||
it("can scan", async () => {
|
||||
await dynamodb.create(item({ Id: 1, Name: "John" }))
|
||||
await dynamodb.create(item({ Id: 2, Name: "Jane" }))
|
||||
await dynamodb.create(item({ Id: 3, Name: "Jack" }))
|
||||
|
||||
const resp = await dynamodb.scan({ table, json: {}, index: null })
|
||||
expect(resp).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ Id: 1, Name: "John" },
|
||||
{ Id: 2, Name: "Jane" },
|
||||
{ Id: 3, Name: "Jack" },
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("can update", async () => {
|
||||
await dynamodb.create(item({ Id: 1, Foo: "John" }))
|
||||
await dynamodb.update({
|
||||
table,
|
||||
json: {
|
||||
Key: { Id: 1 },
|
||||
UpdateExpression: "SET Foo = :foo",
|
||||
ExpressionAttributeValues: { ":foo": "Jane" },
|
||||
},
|
||||
})
|
||||
|
||||
const updatedRecord = await dynamodb.get(key({ Id: 1 }))
|
||||
expect(updatedRecord.Item).toEqual({ Id: 1, Foo: "Jane" })
|
||||
})
|
||||
|
||||
it("can delete", async () => {
|
||||
await dynamodb.create(item({ Id: 1, Name: "John" }))
|
||||
await dynamodb.delete(key({ Id: 1 }))
|
||||
|
||||
const deletedRecord = await dynamodb.get(key({ Id: 1 }))
|
||||
expect(deletedRecord.Item).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it("configures the dynamoDB constructor based on a remote endpoint parameter", async () => {
|
||||
const config = {
|
||||
region: "us-east-1",
|
||||
accessKeyId: "test",
|
||||
secretAccessKey: "test",
|
||||
endpoint: "dynamodb.aws.foo.net",
|
||||
}
|
||||
|
||||
const integration = new DynamoDBIntegration.integration(config)
|
||||
|
||||
// @ts-ignore
|
||||
expect(integration.config).toEqual({
|
||||
currentClockSkew: true,
|
||||
...config,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import { Datasource, SourceName } from "@budibase/types"
|
||||
import { GenericContainer, Wait } from "testcontainers"
|
||||
import { testContainerUtils } from "@budibase/backend-core/tests"
|
||||
import { startContainer } from "."
|
||||
import { DYNAMODB_IMAGE } from "./images"
|
||||
import { DynamoDBConfig } from "../../dynamodb"
|
||||
|
||||
let ports: Promise<testContainerUtils.Port[]>
|
||||
|
||||
export async function getDatasource(): Promise<Datasource> {
|
||||
if (!ports) {
|
||||
ports = startContainer(
|
||||
new GenericContainer(DYNAMODB_IMAGE)
|
||||
.withExposedPorts(8000)
|
||||
.withWaitStrategy(
|
||||
Wait.forSuccessfulCommand(
|
||||
// https://stackoverflow.com/a/77373799
|
||||
`if [ "$(curl -s -o /dev/null -I -w ''%{http_code}'' http://localhost:8000)" == "400" ]; then exit 0; else exit 1; fi`
|
||||
).withStartupTimeout(60000)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const port = (await ports).find(x => x.container === 8000)?.host
|
||||
if (!port) {
|
||||
throw new Error("DynamoDB port not found")
|
||||
}
|
||||
|
||||
const config: DynamoDBConfig = {
|
||||
accessKeyId: "test",
|
||||
secretAccessKey: "test",
|
||||
region: "us-east-1",
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
}
|
||||
|
||||
return {
|
||||
type: "datasource",
|
||||
source: SourceName.DYNAMODB,
|
||||
config,
|
||||
}
|
||||
}
|
|
@ -420,15 +420,16 @@ export class GoogleSheetsMock {
|
|||
}
|
||||
|
||||
const newRows = body.values.map(v => this.valuesToRowData(v))
|
||||
const toDelete =
|
||||
params.insertDataOption === "INSERT_ROWS" ? newRows.length : 0
|
||||
sheet.data[0].rowData.splice(endRowIndex + 1, toDelete, ...newRows)
|
||||
sheet.data[0].rowMetadata.splice(endRowIndex + 1, toDelete, {
|
||||
const newMetadata = newRows.map(() => ({
|
||||
hiddenByUser: false,
|
||||
hiddenByFilter: false,
|
||||
pixelSize: 100,
|
||||
developerMetadata: [],
|
||||
})
|
||||
}))
|
||||
const toDelete =
|
||||
params.insertDataOption === "INSERT_ROWS" ? newRows.length : 0
|
||||
sheet.data[0].rowData.splice(endRowIndex + 1, toDelete, ...newRows)
|
||||
sheet.data[0].rowMetadata.splice(endRowIndex + 1, toDelete, ...newMetadata)
|
||||
|
||||
// It's important to give back a correct updated range because the API
|
||||
// library we use makes use of it to assign the correct row IDs to rows.
|
||||
|
|
|
@ -13,3 +13,4 @@ export const POSTGRES_LEGACY_IMAGE = `postgres:9.5.25`
|
|||
export const MONGODB_IMAGE = `mongo@${process.env.MONGODB_SHA}`
|
||||
export const MARIADB_IMAGE = `mariadb@${process.env.MARIADB_SHA}`
|
||||
export const ELASTICSEARCH_IMAGE = `elasticsearch@${process.env.ELASTICSEARCH_SHA}`
|
||||
export const DYNAMODB_IMAGE = `amazon/dynamodb-local@${process.env.DYNAMODB_SHA}`
|
||||
|
|
|
@ -7,6 +7,7 @@ import * as mssql from "./mssql"
|
|||
import * as mariadb from "./mariadb"
|
||||
import * as oracle from "./oracle"
|
||||
import * as elasticsearch from "./elasticsearch"
|
||||
import * as dynamodb from "./dynamodb"
|
||||
import { testContainerUtils } from "@budibase/backend-core/tests"
|
||||
import { Knex } from "knex"
|
||||
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
|
||||
|
@ -25,6 +26,7 @@ export enum DatabaseName {
|
|||
ORACLE = "oracle",
|
||||
SQS = "sqs",
|
||||
ELASTICSEARCH = "elasticsearch",
|
||||
DYNAMODB = "dynamodb",
|
||||
}
|
||||
|
||||
const DATASOURCE_PLUS = [
|
||||
|
@ -50,6 +52,7 @@ const providers: Record<DatabaseName, DatasourceProvider> = {
|
|||
// rest
|
||||
[DatabaseName.ELASTICSEARCH]: elasticsearch.getDatasource,
|
||||
[DatabaseName.MONGODB]: mongodb.getDatasource,
|
||||
[DatabaseName.DYNAMODB]: dynamodb.getDatasource,
|
||||
}
|
||||
|
||||
export interface DatasourceDescribeReturnPromise {
|
||||
|
|
|
@ -23,6 +23,6 @@ nock.enableNetConnect(host => {
|
|||
|
||||
testContainerUtils.setupEnv(env, coreEnv)
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
timers.cleanup()
|
||||
})
|
||||
|
|
|
@ -99,6 +99,7 @@ export default class TestConfiguration {
|
|||
request?: supertest.SuperTest<supertest.Test>
|
||||
started: boolean
|
||||
appId?: string
|
||||
name?: string
|
||||
allApps: App[]
|
||||
app?: App
|
||||
prodApp?: App
|
||||
|
|
|
@ -146,8 +146,9 @@ export abstract class TestAPI {
|
|||
}
|
||||
}
|
||||
|
||||
let resp: Response | undefined = undefined
|
||||
try {
|
||||
return await req
|
||||
resp = await req
|
||||
} catch (e: any) {
|
||||
// We've found that occasionally the connection between supertest and the
|
||||
// server supertest starts gets reset. Not sure why, but retrying it
|
||||
|
@ -161,6 +162,7 @@ export abstract class TestAPI {
|
|||
}
|
||||
throw e
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
protected async getHeaders(
|
||||
|
|
|
@ -143,7 +143,6 @@ async function branchMatches(
|
|||
branch: Readonly<Branch>
|
||||
): Promise<boolean> {
|
||||
const toFilter: Record<string, any> = {}
|
||||
const preparedCtx = prepareContext(ctx)
|
||||
|
||||
// Because we allow bindings on both the left and right of each condition in
|
||||
// automation branches, we can't pass the BranchSearchFilters directly to
|
||||
|
@ -160,9 +159,9 @@ async function branchMatches(
|
|||
filter.conditions = filter.conditions.map(evaluateBindings)
|
||||
} else {
|
||||
for (const [field, value] of Object.entries(filter)) {
|
||||
toFilter[field] = processStringSync(field, preparedCtx)
|
||||
toFilter[field] = processStringSync(field, ctx)
|
||||
if (typeof value === "string" && findHBSBlocks(value).length > 0) {
|
||||
filter[field] = processStringSync(value, preparedCtx)
|
||||
filter[field] = processStringSync(value, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -178,17 +177,6 @@ async function branchMatches(
|
|||
return result.length > 0
|
||||
}
|
||||
|
||||
function prepareContext(context: AutomationContext) {
|
||||
return {
|
||||
...context,
|
||||
steps: {
|
||||
...context.steps,
|
||||
...context.stepsById,
|
||||
...context.stepsByName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichBaseContext(context: AutomationContext) {
|
||||
context.env = await sdkUtils.getEnvironmentVariables()
|
||||
|
||||
|
@ -304,41 +292,37 @@ class Orchestrator {
|
|||
}
|
||||
|
||||
hasErrored(context: AutomationContext): boolean {
|
||||
const [_trigger, ...steps] = context.steps
|
||||
for (const step of steps) {
|
||||
if (step.success === false) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return context._error === true
|
||||
}
|
||||
|
||||
async execute(): Promise<AutomationResults> {
|
||||
return await tracer.trace("execute", async span => {
|
||||
span.addTags({ appId: this.appId, automationId: this.automation._id })
|
||||
|
||||
const job = cloneDeep(this.job)
|
||||
delete job.data.event.appId
|
||||
delete job.data.event.metadata
|
||||
const data = cloneDeep(this.job.data)
|
||||
delete data.event.appId
|
||||
delete data.event.metadata
|
||||
|
||||
if (this.isCron() && !job.data.event.timestamp) {
|
||||
job.data.event.timestamp = Date.now()
|
||||
if (this.isCron() && !data.event.timestamp) {
|
||||
data.event.timestamp = Date.now()
|
||||
}
|
||||
|
||||
const trigger: AutomationTriggerResult = {
|
||||
id: job.data.automation.definition.trigger.id,
|
||||
stepId: job.data.automation.definition.trigger.stepId,
|
||||
id: data.automation.definition.trigger.id,
|
||||
stepId: data.automation.definition.trigger.stepId,
|
||||
inputs: null,
|
||||
outputs: job.data.event,
|
||||
outputs: data.event,
|
||||
}
|
||||
const result: AutomationResults = { trigger, steps: [trigger] }
|
||||
|
||||
const ctx: AutomationContext = {
|
||||
trigger: trigger.outputs,
|
||||
steps: [trigger.outputs],
|
||||
stepsById: {},
|
||||
steps: { "0": trigger.outputs },
|
||||
stepsByName: {},
|
||||
stepsById: {},
|
||||
user: trigger.outputs.user,
|
||||
_error: false,
|
||||
_stepIndex: 1,
|
||||
}
|
||||
await enrichBaseContext(ctx)
|
||||
|
||||
|
@ -348,7 +332,7 @@ class Orchestrator {
|
|||
try {
|
||||
await helpers.withTimeout(timeout, async () => {
|
||||
const [stepOutputs, executionTime] = await utils.time(() =>
|
||||
this.executeSteps(ctx, job.data.automation.definition.steps)
|
||||
this.executeSteps(ctx, data.automation.definition.steps)
|
||||
)
|
||||
|
||||
result.steps.push(...stepOutputs)
|
||||
|
@ -400,9 +384,20 @@ class Orchestrator {
|
|||
step: AutomationStep,
|
||||
result: AutomationStepResult
|
||||
) {
|
||||
ctx.steps.push(result.outputs)
|
||||
ctx.steps[step.id] = result.outputs
|
||||
ctx.steps[step.name || step.id] = result.outputs
|
||||
|
||||
ctx.stepsById[step.id] = result.outputs
|
||||
ctx.stepsByName[step.name || step.id] = result.outputs
|
||||
|
||||
ctx._stepIndex ||= 0
|
||||
ctx.steps[ctx._stepIndex] = result.outputs
|
||||
ctx._stepIndex++
|
||||
|
||||
if (result.outputs.success === false) {
|
||||
ctx._error = true
|
||||
}
|
||||
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
|
@ -449,7 +444,7 @@ class Orchestrator {
|
|||
stepToLoop: AutomationStep
|
||||
): Promise<AutomationStepResult> {
|
||||
return await tracer.trace("executeLoopStep", async span => {
|
||||
await processObject(step.inputs, prepareContext(ctx))
|
||||
await processObject(step.inputs, ctx)
|
||||
|
||||
const maxIterations = getLoopMaxIterations(step)
|
||||
const items: Record<string, any>[] = []
|
||||
|
@ -478,6 +473,7 @@ class Orchestrator {
|
|||
return stepFailure(stepToLoop, {
|
||||
status: AutomationStepStatus.MAX_ITERATIONS,
|
||||
iterations,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -488,6 +484,8 @@ class Orchestrator {
|
|||
})
|
||||
return stepFailure(stepToLoop, {
|
||||
status: AutomationStepStatus.FAILURE_CONDITION,
|
||||
iterations,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -557,8 +555,16 @@ class Orchestrator {
|
|||
throw new Error(`Cannot find automation step by name ${step.stepId}`)
|
||||
}
|
||||
|
||||
const inputs = automationUtils.cleanInputValues(
|
||||
await processObject(cloneDeep(step.inputs), prepareContext(ctx)),
|
||||
let inputs = cloneDeep(step.inputs)
|
||||
if (step.stepId !== AutomationActionStepId.EXECUTE_SCRIPT_V2) {
|
||||
// The EXECUTE_SCRIPT_V2 step saves its input.code value as a `{{ js
|
||||
// "..." }}` template, and expects to receive it that way in the
|
||||
// function that runs it. So we skip this next bit for that step.
|
||||
inputs = await processObject(inputs, ctx)
|
||||
}
|
||||
|
||||
inputs = automationUtils.cleanInputValues(
|
||||
inputs,
|
||||
step.schema.inputs.properties
|
||||
)
|
||||
|
||||
|
@ -566,7 +572,7 @@ class Orchestrator {
|
|||
inputs,
|
||||
appId: this.appId,
|
||||
emitter: this.emitter,
|
||||
context: prepareContext(ctx),
|
||||
context: ctx,
|
||||
})
|
||||
|
||||
if (
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
export const definition: AutomationStepDefinition = {
|
||||
name: "JS Scripting",
|
||||
tagline: "Execute JavaScript Code",
|
||||
deprecated: true,
|
||||
icon: "Code",
|
||||
description: "Run a piece of JavaScript code in your automation",
|
||||
type: AutomationStepType.ACTION,
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
AutomationActionStepId,
|
||||
AutomationCustomIOType,
|
||||
AutomationFeature,
|
||||
AutomationIOType,
|
||||
AutomationStepDefinition,
|
||||
AutomationStepType,
|
||||
} from "@budibase/types"
|
||||
|
||||
export const definition: AutomationStepDefinition = {
|
||||
name: "JavaScript",
|
||||
tagline: "Execute JavaScript Code",
|
||||
icon: "Brackets",
|
||||
description: "Run a piece of JavaScript code in your automation",
|
||||
type: AutomationStepType.ACTION,
|
||||
internal: true,
|
||||
new: true,
|
||||
stepId: AutomationActionStepId.EXECUTE_SCRIPT_V2,
|
||||
inputs: {},
|
||||
features: {
|
||||
[AutomationFeature.LOOPING]: true,
|
||||
},
|
||||
schema: {
|
||||
inputs: {
|
||||
properties: {
|
||||
code: {
|
||||
type: AutomationIOType.STRING,
|
||||
customType: AutomationCustomIOType.CODE,
|
||||
title: "Code",
|
||||
},
|
||||
},
|
||||
required: ["code"],
|
||||
},
|
||||
outputs: {
|
||||
properties: {
|
||||
value: {
|
||||
type: AutomationIOType.STRING,
|
||||
description: "The result of the return statement",
|
||||
},
|
||||
success: {
|
||||
type: AutomationIOType.BOOLEAN,
|
||||
description: "Whether the action was successful",
|
||||
},
|
||||
},
|
||||
required: ["success"],
|
||||
},
|
||||
},
|
||||
}
|
|
@ -7,6 +7,7 @@ export * as deleteRow from "./deleteRow"
|
|||
export * as discord from "./discord"
|
||||
export * as executeQuery from "./executeQuery"
|
||||
export * as executeScript from "./executeScript"
|
||||
export * as executeScriptV2 from "./executeScriptV2"
|
||||
export * as filter from "./filter"
|
||||
export * as loop from "./loop"
|
||||
export * as make from "./make"
|
||||
|
|
|
@ -117,7 +117,8 @@ export function isSupportedUserSearch(
|
|||
{ op: BasicOperator.EQUAL, key: "_id" },
|
||||
{ op: ArrayOperator.ONE_OF, key: "_id" },
|
||||
]
|
||||
for (const [key, operation] of Object.entries(query)) {
|
||||
const { allOr, onEmptyFilter, ...filters } = query
|
||||
for (const [key, operation] of Object.entries(filters)) {
|
||||
if (typeof operation !== "object") {
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -44,6 +44,19 @@ const ADDED_HELPERS = {
|
|||
description:
|
||||
"Produce a humanized duration left/until given an amount of time and the type of time measurement.",
|
||||
},
|
||||
difference: {
|
||||
args: ["from", "to", "[unitType=ms]"],
|
||||
example:
|
||||
'{{ difference "2025-09-30" "2025-06-17" "seconds" }} -> 9072000',
|
||||
description:
|
||||
"Gets the difference between two dates, in milliseconds. Pass a third parameter to adjust the unit measurement.",
|
||||
},
|
||||
durationFromNow: {
|
||||
args: ["time"],
|
||||
example: '{{durationFromNow "2021-09-30"}} -> 8 months',
|
||||
description:
|
||||
"Produce a humanized duration left/until given an amount of time and the type of time measurement.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,7 @@ export const HelperFunctionNames = {
|
|||
ALL: "all",
|
||||
LITERAL: "literal",
|
||||
JS: "js",
|
||||
DECODE_ID: "decodeId",
|
||||
}
|
||||
|
||||
export const LITERAL_MARKER = "%LITERAL%"
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import dayjs from "dayjs"
|
||||
import dayjs, { UnitType } from "dayjs"
|
||||
|
||||
import dayjsDurationPlugin from "dayjs/plugin/duration"
|
||||
import dayjsAdvancedFormatPlugin from "dayjs/plugin/advancedFormat"
|
||||
|
@ -121,7 +121,7 @@ export const date = (str: any, pattern: any, options: any) => {
|
|||
return date.format(config.pattern)
|
||||
}
|
||||
|
||||
export const duration = (str: any, pattern: any, format: any) => {
|
||||
export const duration = (str: any, pattern: any, format?: any) => {
|
||||
const config = initialConfig(str, pattern)
|
||||
|
||||
setLocale(config.str, config.pattern)
|
||||
|
@ -133,3 +133,13 @@ export const duration = (str: any, pattern: any, format: any) => {
|
|||
return duration.humanize()
|
||||
}
|
||||
}
|
||||
|
||||
export const difference = (from: string, to: string, units?: UnitType) => {
|
||||
const result = dayjs(new Date(from)).diff(dayjs(new Date(to)), units)
|
||||
return result
|
||||
}
|
||||
|
||||
export const durationFromNow = (from: string) => {
|
||||
const diff = difference(from, new Date().toISOString(), "ms")
|
||||
return duration(diff, "ms")
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @ts-ignore we don't have types for it
|
||||
import helpers from "@budibase/handlebars-helpers"
|
||||
|
||||
import { date, duration } from "./date"
|
||||
import { date, difference, duration, durationFromNow } from "./date"
|
||||
import {
|
||||
HelperFunctionBuiltin,
|
||||
EXTERNAL_FUNCTION_COLLECTIONS,
|
||||
|
@ -9,8 +9,10 @@ import {
|
|||
import Handlebars from "handlebars"
|
||||
|
||||
const ADDED_HELPERS = {
|
||||
date: date,
|
||||
duration: duration,
|
||||
date,
|
||||
duration,
|
||||
difference,
|
||||
durationFromNow,
|
||||
}
|
||||
|
||||
export const externalCollections = EXTERNAL_FUNCTION_COLLECTIONS
|
||||
|
|
|
@ -25,13 +25,29 @@ function isObject(value: string | any[]) {
|
|||
)
|
||||
}
|
||||
|
||||
const HELPERS = [
|
||||
export const HELPERS = [
|
||||
// external helpers
|
||||
new Helper(HelperFunctionNames.OBJECT, (value: any) => {
|
||||
return new Handlebars.SafeString(JSON.stringify(value))
|
||||
}),
|
||||
// javascript helper
|
||||
new Helper(HelperFunctionNames.JS, processJS, false),
|
||||
new Helper(HelperFunctionNames.DECODE_ID, (_id: string | { _id: string }) => {
|
||||
if (!_id) {
|
||||
return []
|
||||
}
|
||||
// have to replace on the way back as we swapped out the double quotes
|
||||
// when encoding, but JSON can't handle the single quotes
|
||||
const id = typeof _id === "string" ? _id : _id._id
|
||||
const decoded: string = decodeURIComponent(id).replace(/'/g, '"')
|
||||
try {
|
||||
const parsed = JSON.parse(decoded)
|
||||
return Array.isArray(parsed) ? parsed : [parsed]
|
||||
} catch (err) {
|
||||
// wasn't json - likely was handlebars for a many to many
|
||||
return [_id]
|
||||
}
|
||||
}),
|
||||
// this help is applied to all statements
|
||||
new Helper(
|
||||
HelperFunctionNames.ALL,
|
||||
|
|
|
@ -1222,6 +1222,22 @@
|
|||
],
|
||||
"example": "{{duration 8 \"seconds\"}} -> a few seconds",
|
||||
"description": "<p>Produce a humanized duration left/until given an amount of time and the type of time measurement.</p>\n"
|
||||
},
|
||||
"difference": {
|
||||
"args": [
|
||||
"from",
|
||||
"to",
|
||||
"[unitType=ms]"
|
||||
],
|
||||
"example": "{{ difference \"2025-09-30\" \"2025-06-17\" \"seconds\" }} -> 9072000",
|
||||
"description": "<p>Gets the difference between two dates, in milliseconds. Pass a third parameter to adjust the unit measurement.</p>\n"
|
||||
},
|
||||
"durationFromNow": {
|
||||
"args": [
|
||||
"time"
|
||||
],
|
||||
"example": "{{durationFromNow \"2021-09-30\"}} -> 8 months",
|
||||
"description": "<p>Produce a humanized duration left/until given an amount of time and the type of time measurement.</p>\n"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -517,3 +517,44 @@ describe("helper overlap", () => {
|
|||
expect(output).toEqual("a")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Test the decodeId helper", () => {
|
||||
it("should decode a valid encoded ID", async () => {
|
||||
const encodedId = encodeURIComponent("[42]") // "%5B42%5D"
|
||||
const output = await processString("{{ decodeId id }}", { id: encodedId })
|
||||
expect(output).toBe("42")
|
||||
})
|
||||
|
||||
it("Should return an unchanged string if the string isn't encoded", async () => {
|
||||
const unencodedId = "forty-two"
|
||||
const output = await processString("{{ decodeId id }}", { id: unencodedId })
|
||||
expect(output).toBe("forty-two")
|
||||
})
|
||||
|
||||
it("Should return a string of comma-separated IDs when passed multiple IDs in a URI encoded array", async () => {
|
||||
const encodedIds = encodeURIComponent("[1,2,3]") // "%5B1%2C2%2C3%5D"
|
||||
const output = await processString("{{ decodeId id }}", { id: encodedIds })
|
||||
expect(output).toBe("1,2,3")
|
||||
})
|
||||
|
||||
it("Handles empty array gracefully", async () => {
|
||||
const output = await processString("{{ decodeId value }}", {
|
||||
value: [],
|
||||
})
|
||||
expect(output).toBe("[[]]")
|
||||
})
|
||||
|
||||
it("Handles undefined gracefully", async () => {
|
||||
const output = await processString("{{ decodeId value }}", {
|
||||
value: undefined,
|
||||
})
|
||||
expect(output).toBe("")
|
||||
})
|
||||
|
||||
it("Handles null gracefully", async () => {
|
||||
const output = await processString("{{ decodeId value }}", {
|
||||
value: undefined,
|
||||
})
|
||||
expect(output).toBe("")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import tk from "timekeeper"
|
||||
import * as date from "../../src/helpers/date"
|
||||
|
||||
const frozenDate = new Date("2025-03-06T11:38:41.000Z")
|
||||
tk.freeze(frozenDate)
|
||||
|
||||
describe("date helper", () => {
|
||||
describe("difference", () => {
|
||||
it("should return the difference between two dates", () => {
|
||||
const result = date.difference(
|
||||
"2021-01-02T12:34:56.789Z",
|
||||
"2021-01-01T01:00:00.000Z"
|
||||
)
|
||||
const expected =
|
||||
1 * 24 * 60 * 60 * 1000 + // 1 day
|
||||
11 * 60 * 60 * 1000 + // 11 hours
|
||||
34 * 60 * 1000 + // 34 minutes
|
||||
56 * 1000 + // seconds
|
||||
789 // milliseconds
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
|
||||
it("should be able to set the time unit", () => {
|
||||
const result = date.difference(
|
||||
"2021-01-02T12:34:56",
|
||||
"2021-01-01T01:00:00",
|
||||
"days"
|
||||
)
|
||||
expect(result).toEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("durationFromNow", () => {
|
||||
it("should return the difference between two close dates", () => {
|
||||
const result = date.durationFromNow("2025-03-06T11:38:43.000Z")
|
||||
expect(result).toEqual("a few seconds")
|
||||
})
|
||||
|
||||
it("should return the difference between two days hours apart", () => {
|
||||
const result = date.durationFromNow("2025-03-06T01:00:00.000Z")
|
||||
expect(result).toEqual("11 hours")
|
||||
})
|
||||
|
||||
it("accepts days in the past", () => {
|
||||
const result = date.durationFromNow("2025-03-01")
|
||||
expect(result).toEqual("5 days")
|
||||
})
|
||||
|
||||
it("accepts days in the future", () => {
|
||||
const result = date.durationFromNow("2025-03-08")
|
||||
expect(result).toEqual("2 days")
|
||||
})
|
||||
})
|
||||
})
|
|
@ -63,6 +63,7 @@ export enum AutomationActionStepId {
|
|||
EXECUTE_BASH = "EXECUTE_BASH",
|
||||
OUTGOING_WEBHOOK = "OUTGOING_WEBHOOK",
|
||||
EXECUTE_SCRIPT = "EXECUTE_SCRIPT",
|
||||
EXECUTE_SCRIPT_V2 = "EXECUTE_SCRIPT_V2",
|
||||
EXECUTE_QUERY = "EXECUTE_QUERY",
|
||||
SERVER_LOG = "SERVER_LOG",
|
||||
DELAY = "DELAY",
|
||||
|
|
|
@ -84,6 +84,10 @@ export type ActionImplementations<T extends Hosting> = {
|
|||
ExecuteScriptStepInputs,
|
||||
ExecuteScriptStepOutputs
|
||||
>
|
||||
[AutomationActionStepId.EXECUTE_SCRIPT_V2]: ActionImplementation<
|
||||
ExecuteScriptStepInputs,
|
||||
ExecuteScriptStepOutputs
|
||||
>
|
||||
[AutomationActionStepId.FILTER]: ActionImplementation<
|
||||
FilterStepInputs,
|
||||
FilterStepOutputs
|
||||
|
@ -155,6 +159,7 @@ export interface AutomationStepSchemaBase {
|
|||
type: AutomationStepType
|
||||
internal?: boolean
|
||||
deprecated?: boolean
|
||||
new?: boolean
|
||||
blockToLoop?: string
|
||||
schema: {
|
||||
inputs: InputOutputBlock
|
||||
|
@ -177,6 +182,8 @@ export type AutomationStepInputs<T extends AutomationActionStepId> =
|
|||
? ExecuteQueryStepInputs
|
||||
: T extends AutomationActionStepId.EXECUTE_SCRIPT
|
||||
? ExecuteScriptStepInputs
|
||||
: T extends AutomationActionStepId.EXECUTE_SCRIPT_V2
|
||||
? ExecuteScriptStepInputs
|
||||
: T extends AutomationActionStepId.FILTER
|
||||
? FilterStepInputs
|
||||
: T extends AutomationActionStepId.QUERY_ROWS
|
||||
|
@ -279,6 +286,9 @@ export type ExecuteQueryStep =
|
|||
export type ExecuteScriptStep =
|
||||
AutomationStepSchema<AutomationActionStepId.EXECUTE_SCRIPT>
|
||||
|
||||
export type ExecuteScriptV2Step =
|
||||
AutomationStepSchema<AutomationActionStepId.EXECUTE_SCRIPT_V2>
|
||||
|
||||
export type FilterStep = AutomationStepSchema<AutomationActionStepId.FILTER>
|
||||
|
||||
export type QueryRowsStep =
|
||||
|
@ -325,6 +335,7 @@ export type AutomationStep =
|
|||
| DeleteRowStep
|
||||
| ExecuteQueryStep
|
||||
| ExecuteScriptStep
|
||||
| ExecuteScriptV2Step
|
||||
| FilterStep
|
||||
| QueryRowsStep
|
||||
| SendEmailSmtpStep
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
GetAutomationActionDefinitionsResponse,
|
||||
GetAutomationTriggerDefinitionsResponse,
|
||||
} from "../../api"
|
||||
|
||||
export interface BranchPath {
|
||||
stepIdx: number
|
||||
branchIdx: number
|
||||
|
@ -6,7 +11,7 @@ export interface BranchPath {
|
|||
}
|
||||
|
||||
export interface BlockDefinitions {
|
||||
TRIGGER: Record<string, any>
|
||||
CREATABLE_TRIGGER: Record<string, any>
|
||||
ACTION: Record<string, any>
|
||||
TRIGGER: Partial<GetAutomationTriggerDefinitionsResponse>
|
||||
CREATABLE_TRIGGER: Partial<GetAutomationTriggerDefinitionsResponse>
|
||||
ACTION: Partial<GetAutomationActionDefinitionsResponse>
|
||||
}
|
||||
|
|
|
@ -12,8 +12,7 @@ nock.enableNetConnect(host => {
|
|||
return (
|
||||
host.includes("localhost") ||
|
||||
host.includes("127.0.0.1") ||
|
||||
host.includes("::1") ||
|
||||
host.includes("ethereal.email") // used in realEmail.spec.ts
|
||||
host.includes("::1")
|
||||
)
|
||||
})
|
||||
|
||||
|
|
Loading…
Reference in New Issue