v2 JavaScript UX, automation bug fixes and general refactoring

This commit is contained in:
Dean 2025-01-29 11:51:12 +00:00
parent 8693cdc67f
commit 0463462b77
22 changed files with 614 additions and 87 deletions

View File

@ -18,8 +18,12 @@
import AutomationBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import FlowItemActions from "./FlowItemActions.svelte"
import { automationStore, selectedAutomation } from "@/stores/builder"
import { QueryUtils, Utils } from "@budibase/frontend-core"
import {
automationStore,
selectedAutomation,
evaluationContext,
} from "@/stores/builder"
import { QueryUtils, Utils, memo } from "@budibase/frontend-core"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
import DragZone from "./DragZone.svelte"
@ -34,11 +38,14 @@
export let automation
const view = getContext("draggableView")
const memoContext = memo({})
let drawer
let open = true
let confirmDeleteModal
$: memoContext.set($evaluationContext)
$: branch = step.inputs?.branches?.[branchIdx]
$: editableConditionUI = branch.conditionUI || {}
@ -100,6 +107,7 @@
allowOnEmpty={false}
builderType={"condition"}
docsURL={null}
evaluationContext={$memoContext}
/>
</DrawerContent>
</Drawer>

View File

@ -21,7 +21,7 @@
} from "@budibase/bbui"
import CreateWebhookModal from "@/components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, tables } from "@/stores/builder"
import { automationStore, tables, evaluationContext } from "@/stores/builder"
import { environment } from "@/stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import {
@ -62,6 +62,8 @@
} from "@budibase/types"
import PropField from "./PropField.svelte"
import { utils } from "@budibase/shared-core"
import { encodeJSBinding } from "@budibase/string-templates"
import CodeEditorField from "@/components/common/bindings/CodeEditorField.svelte"
export let automation
export let block
@ -74,6 +76,7 @@
// Stop unnecessary rendering
const memoBlock = memo(block)
const memoContext = memo({})
const rowTriggers = [
TriggerStepID.ROW_UPDATED,
@ -97,6 +100,7 @@
let stepLayouts = {}
$: memoBlock.set(block)
$: memoContext.set($evaluationContext)
$: filters = lookForFilters(schemaProperties)
$: filterCount =
@ -140,6 +144,7 @@
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: []
// TODO: check if it inputData != newInputData (memo)
const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs)
@ -156,6 +161,7 @@
}
const setDefaultEnumValues = () => {
// TODO: Update this for memoisation
for (const [key, value] of schemaProperties) {
if (value.type === "string" && value.enum && inputData[key] == null) {
inputData[key] = value.enum[0]
@ -200,7 +206,6 @@
onChange({ ["revision"]: e.detail })
},
updateOnChange: false,
forceModal: true,
},
},
]
@ -228,7 +233,6 @@
onChange({ [rowIdentifier]: e.detail })
},
updateOnChange: false,
forceModal: true,
},
},
]
@ -476,6 +480,10 @@
...update,
})
if (!updatedAutomation) {
return
}
// Exclude default or invalid data from the test data
let updatedFields = {}
for (const key of Object.keys(block?.inputs?.fields || {})) {
@ -547,7 +555,7 @@
...newTestData,
body: {
...update,
...automation.testData?.body,
...(automation?.testData?.body || {}),
},
}
}
@ -668,6 +676,7 @@
{...config.props}
{bindings}
on:change={config.props.onChange}
context={$memoContext}
/>
</PropField>
{:else}
@ -676,6 +685,7 @@
{...config.props}
{bindings}
on:change={config.props.onChange}
context={$memoContext}
/>
{/if}
{/each}
@ -800,6 +810,7 @@
: "Add signature"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
context={$memoContext}
/>
{:else if isTestModal}
<ModalBindableInput
@ -824,6 +835,7 @@
? queryLimit
: ""}
drawerLeft="260px"
context={$memoContext}
/>
{/if}
</div>
@ -853,6 +865,7 @@
panel={AutomationBindingPanel}
showFilterEmptyDropdown={!rowTriggers.includes(stepId)}
on:change={e => (tempFilters = e.detail)}
evaluationContext={$memoContext}
/>
</DrawerContent>
</Drawer>
@ -895,7 +908,39 @@
on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]}
/>
{:else if value.customType === "code"}
{:else if value.customType === "code" && stepId === ActionStepID.EXECUTE_SCRIPT_V2}
<div class="scriptv2-wrapper">
<DrawerBindableSlot
title={"Edit Code"}
panel={AutomationBindingPanel}
type={"longform"}
{schema}
on:change={e => onChange({ [key]: e.detail })}
value={inputData[key]}
{bindings}
allowJS={true}
allowHBS={false}
updateOnChange={false}
context={$memoContext}
>
<div class="field-wrap code-editor">
<CodeEditorField
value={inputData[key]}
{bindings}
context={$memoContext}
allowHBS={false}
allowJS
placeholder={codeMode === EditorModes.Handlebars
? "Add bindings by typing {{"
: null}
on:blur={e =>
onChange({ [key]: encodeJSBinding(e.detail) })}
/>
</div>
</DrawerBindableSlot>
</div>
{:else if value.customType === "code" && stepId === ActionStepID.EXECUTE_SCRIPT}
<!-- DEPRECATED -->
<CodeEditorModal
on:hide={() => {
// Push any pending changes when the window closes
@ -977,6 +1022,7 @@
? queryLimit
: ""}
drawerLeft="260px"
context={$memoContext}
/>
</div>
{/if}
@ -1044,4 +1090,23 @@
flex: 3;
margin-top: calc((var(--spacing-xl) * -1) + 1px);
}
.field-wrap :global(.cm-editor),
.field-wrap :global(.cm-scroller) {
border-radius: 4px;
}
.field-wrap {
box-sizing: border-box;
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
}
.field-wrap.code-editor {
height: 180px;
}
.scriptv2-wrapper :global(.icon.slot-icon) {
top: 1px;
border-bottom-left-radius: var(--spectrum-alias-border-radius-regular);
border-right: 0px;
border-bottom: 1px solid var(--spectrum-alias-border-color);
}
</style>

View File

@ -25,6 +25,7 @@
export let meta
export let bindings
export let isTestModal
export let context = {}
const typeToField = Object.values(FIELDS).reduce((acc, field) => {
acc[field.type] = field
@ -58,7 +59,7 @@
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
clone.icon = clone.icon ?? "ShareAndroid"
return clone
})
@ -258,6 +259,7 @@
fields: editableFields,
}}
{onChange}
{context}
/>
{:else}
<DrawerBindableSlot
@ -276,6 +278,7 @@
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
{context}
>
<RowSelectorTypes
{isTestModal}
@ -286,6 +289,7 @@
meta={{
fields: editableFields,
}}
{context}
onChange={change => onChange(change)}
/>
</DrawerBindableSlot>

View File

@ -25,12 +25,13 @@
export let meta
export let bindings
export let isTestModal
export let context
$: fieldData = value[field]
$: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid"
clone.icon = clone.icon ?? "ShareAndroid"
return clone
})
@ -232,6 +233,7 @@
actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE ||
schema.type === FieldType.SIGNATURE_SINGLE) &&
fieldData}
{context}
/>
</div>
{:else}

View File

@ -1,18 +1,11 @@
<script>
import { Input, Select, Button } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
import { memo } from "@budibase/frontend-core"
import { generate } from "shortid"
export let value = {}
$: fieldsArray = value
? Object.entries(value).map(([name, type]) => ({
name,
type,
}))
: []
const typeOptions = [
{
label: "Text",
@ -36,16 +29,42 @@
},
]
const dispatch = createEventDispatcher()
const memoValue = memo({ data: {} })
$: memoValue.set({ data: value })
$: fieldsArray = $memoValue.data
? Object.entries($memoValue.data).map(([name, type]) => ({
name,
type,
id: generate(),
}))
: []
function addField() {
const newValue = { ...value }
const newValue = { ...$memoValue.data }
newValue[""] = "string"
dispatch("change", newValue)
fieldsArray = [...fieldsArray, { name: "", type: "string", id: generate() }]
}
function removeField(name) {
const newValues = { ...value }
delete newValues[name]
dispatch("change", newValues)
function removeField(idx) {
const entries = [...fieldsArray]
// Remove empty field
if (!entries[idx]?.name) {
fieldsArray.splice(idx, 1)
fieldsArray = [...fieldsArray]
return
}
entries.splice(idx, 1)
const update = entries.reduce((newVals, current) => {
newVals[current.name.trim()] = current.type
return newVals
}, {})
dispatch("change", update)
}
const fieldNameChanged = originalName => e => {
@ -57,11 +76,16 @@
} else {
entries = entries.filter(f => f.name !== originalName)
}
value = entries.reduce((newVals, current) => {
newVals[current.name.trim()] = current.type
return newVals
}, {})
dispatch("change", value)
const update = entries
.filter(entry => entry.name)
.reduce((newVals, current) => {
newVals[current.name.trim()] = current.type
return newVals
}, {})
if (Object.keys(update).length) {
dispatch("change", update)
}
}
</script>
@ -69,7 +93,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="root">
<div class="spacer" />
{#each fieldsArray as field}
{#each fieldsArray as field, idx (field.id)}
<div class="field">
<Input
value={field.name}
@ -88,7 +112,9 @@
/>
<i
class="remove-field ri-delete-bin-line"
on:click={() => removeField(field.name)}
on:click={() => {
removeField(idx)
}}
/>
</div>
{/each}
@ -115,4 +141,12 @@
align-items: center;
gap: var(--spacing-m);
}
.remove-field {
cursor: pointer;
}
.remove-field:hover {
color: var(--spectrum-global-color-gray-900);
}
</style>

View File

@ -1,3 +1,10 @@
<script context="module" lang="ts">
export const DropdownPosition = {
Relative: "top",
Absolute: "right",
}
</script>
<script lang="ts">
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte"
@ -45,6 +52,7 @@
import { EditorModes } from "./"
import { themeStore } from "@/stores/portal"
import type { EditorMode } from "@budibase/types"
import { tooltips } from "@codemirror/view"
export let label: string | undefined = undefined
// TODO: work out what best type fits this
@ -57,11 +65,13 @@
export let jsBindingWrapping = true
export let readonly = false
export let readonlyLineNumbers = false
export let dropdown = DropdownPosition.Relative
const dispatch = createEventDispatcher()
let textarea: HTMLDivElement
let editor: EditorView
let editorEle: HTMLDivElement
let mounted = false
let isEditorInitialised = false
let queuedRefresh = false
@ -112,7 +122,6 @@
queuedRefresh = true
return
}
if (
editor &&
value &&
@ -343,14 +352,25 @@
const baseExtensions = buildBaseExtensions()
editor = new EditorView({
doc: value?.toString(),
extensions: buildExtensions(baseExtensions),
doc: String(value),
extensions: buildExtensions([
...baseExtensions,
dropdown == DropdownPosition.Absolute
? tooltips({
position: "absolute",
})
: [],
]),
parent: textarea,
})
}
onMount(async () => {
mounted = true
// Capture scrolling
editorEle.addEventListener("wheel", e => {
e.stopPropagation()
})
})
onDestroy(() => {
@ -366,7 +386,7 @@
</div>
{/if}
<div class={`code-editor ${mode?.name || ""}`}>
<div class={`code-editor ${mode?.name || ""}`} bind:this={editorEle}>
<div tabindex="-1" bind:this={textarea} />
</div>
@ -534,12 +554,11 @@
/* Live binding value / helper container */
.code-editor :global(.cm-completionInfo) {
margin-left: var(--spacing-s);
margin: 0px var(--spacing-s);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: var(--border-radius-s);
background-color: var(--spectrum-global-color-gray-50);
padding: var(--spacing-m);
margin-top: -2px;
}
/* Wrapper around helpers */
@ -564,6 +583,7 @@
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
overflow-y: auto;
max-height: 480px;
}
.code-editor :global(.binding__example.helper) {

View File

@ -61,8 +61,6 @@
let mode: BindingMode | null
let sidePanel: SidePanel | null
let initialValueJS = value?.startsWith?.("{{ js ")
let jsValue: string | null = initialValueJS ? value : null
let hbsValue: string | null = initialValueJS ? null : value
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
let targetMode: BindingMode | null = null
@ -71,6 +69,10 @@
let expressionError: string | undefined
let evaluating = false
// Ensure these values are not stale
$: jsValue = initialValueJS ? value : null
$: hbsValue = initialValueJS ? null : value
$: useSnippets = allowSnippets && !$licensing.isFreePlan
$: editorModeOptions = getModeOptions(allowHBS, allowJS)
$: sidePanelOptions = getSidePanelOptions(

View File

@ -0,0 +1,203 @@
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte"
import {
decodeJSBinding,
encodeJSBinding,
processObjectSync,
} from "@budibase/string-templates"
import { runtimeToReadableBinding } from "@/dataBinding"
import CodeEditor, { DropdownPosition } from "../CodeEditor/CodeEditor.svelte"
import {
getHelperCompletions,
jsAutocomplete,
snippetAutoComplete,
EditorModes,
bindingsToCompletions,
} from "../CodeEditor"
import { JsonFormatter } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
import { BindingMode } from "@budibase/types"
import type {
EnrichedBinding,
BindingCompletion,
Snippet,
CaretPositionFn,
InsertAtPositionFn,
JSONValue,
} from "@budibase/types"
import type { CompletionContext } from "@codemirror/autocomplete"
import { snippets } from "@/stores/builder"
const dispatch = createEventDispatcher()
export let bindings: EnrichedBinding[] = []
export let value: string = ""
export let allowHBS = true
export let allowJS = false
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let autofocusEditor = false
export let placeholder = null
let mode: BindingMode | null
let initialValueJS = value?.startsWith?.("{{ js ")
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
// TO Switch the runtime
$: readable = runtimeToReadableBinding(bindings, value || "")
$: jsValue = decodeJSBinding(readable)
$: useSnippets = allowSnippets && !$licensing.isFreePlan
$: editorModeOptions = getModeOptions(allowHBS, allowJS)
$: enrichedBindings = enrichBindings(bindings, context, $snippets)
$: editorMode = EditorModes.JS
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: jsCompletions = getJSCompletions(
bindingCompletions,
$snippets,
useSnippets
)
const getJSCompletions = (
bindingCompletions: BindingCompletion[],
snippets: Snippet[] | null,
useSnippets?: boolean
) => {
const completions: ((_: CompletionContext) => any)[] = [
jsAutocomplete([
...bindingCompletions,
...(allowHelpers ? getHelperCompletions(EditorModes.JS) : []),
]),
]
if (useSnippets && snippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const getModeOptions = (allowHBS: boolean, allowJS: boolean) => {
let options = []
if (allowHBS) {
options.push(BindingMode.Text)
}
if (allowJS) {
options.push(BindingMode.JavaScript)
}
return options
}
const highlightJSON = (json: JSONValue) => {
return JsonFormatter.format(json, {
keyColor: "#e06c75",
numberColor: "#e5c07b",
stringColor: "#98c379",
trueColor: "#d19a66",
falseColor: "#d19a66",
nullColor: "#c678dd",
})
}
const enrichBindings = (
bindings: EnrichedBinding[],
context: any,
snippets: Snippet[] | null
) => {
// Create a single big array to enrich in one go
const bindingStrings = bindings.map(binding => {
if (binding.runtimeBinding.startsWith('trim "')) {
// Account for nasty hardcoded HBS bindings for roles, for legacy
// compatibility
return `{{ ${binding.runtimeBinding} }}`
} else {
return `{{ literal ${binding.runtimeBinding} }}`
}
})
const bindingEvaluations = processObjectSync(bindingStrings, {
...context,
snippets,
})
// Enrich bindings with evaluations and highlighted HTML
return bindings.map((binding, idx) => {
if (!context || typeof bindingEvaluations !== "object") {
return binding
}
const evalObj: Record<any, any> = bindingEvaluations
const value = JSON.stringify(evalObj[idx], null, 2)
return {
...binding,
value,
valueHTML: highlightJSON(value),
}
})
}
const updateValue = (val: any) => {
dispatch("change", val)
}
const onChangeJSValue = (e: { detail: string }) => {
// if(typeof onChange === "function"){
// }
if (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue(null)
} else {
updateValue(encodeJSBinding(e.detail))
}
}
onMount(() => {
// Set the initial mode appropriately
const initialValueMode = initialValueJS
? BindingMode.JavaScript
: BindingMode.Text
if (editorModeOptions.includes(initialValueMode)) {
mode = initialValueMode
} else {
mode = editorModeOptions[0]
}
})
</script>
<div class="code-panel">
<div class="editor">
{#key jsCompletions}
<CodeEditor
value={jsValue}
on:change={onChangeJSValue}
on:blur
completions={jsCompletions}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
autofocus={autofocusEditor}
placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping
dropdown={DropdownPosition.Absolute}
/>
{/key}
</div>
</div>
<style>
.code-panel {
height: 100%;
display: flex;
}
/* Editor */
.editor {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -23,6 +23,9 @@
export let type
export let schema
export let allowHBS = true
export let context = {}
const dispatch = createEventDispatcher()
let bindingDrawer
let currentVal = value
@ -147,7 +150,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="control" class:disabled>
{#if !isValid(value)}
{#if !isValid(value) && !$$slots.default}
<Input
{label}
{disabled}
@ -187,7 +190,6 @@
on:drawerShow
bind:this={bindingDrawer}
title={title ?? placeholder ?? "Bindings"}
forceModal={true}
>
<Button cta slot="buttons" on:click={saveBinding}>Save</Button>
<svelte:component
@ -197,7 +199,9 @@
on:change={event => (tempValue = event.detail)}
{bindings}
{allowJS}
{allowHBS}
{allowHelpers}
{context}
/>
</Drawer>
@ -208,22 +212,22 @@
}
.slot-icon {
right: 31px !important;
right: 31px;
border-right: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: 0px !important;
border-bottom-right-radius: 0px !important;
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.text-area-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 1px !important;
border-bottom-right-radius: 0px;
top: 1px;
}
.json-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 1px !important;
right: 0px !important;
border-bottom-right-radius: 0px;
top: 1px;
right: 0px;
}
.icon {

View File

@ -5,6 +5,7 @@
export let bindings = []
export let value = ""
export let allowJS = false
export let allowHBS = true
export let context = null
$: enrichedBindings = enrichBindings(bindings)
@ -22,8 +23,10 @@
<BindingPanel
bindings={enrichedBindings}
snippets={$snippets}
allowHelpers
{value}
{allowJS}
{allowHBS}
{context}
on:change
/>

View File

@ -16,6 +16,7 @@
export let datasource
export let builderType
export let docsURL
export let evaluationContext = {}
</script>
<CoreFilterBuilder
@ -32,5 +33,6 @@
{allowOnEmpty}
{builderType}
{docsURL}
{evaluationContext}
on:change
/>

View File

@ -39,6 +39,7 @@
export let allowJS = false
export let actionButtonDisabled = false
export let compare = (option, value) => option === value
export let context = null
let fields = Object.entries(object || {}).map(([name, value]) => ({
name,
@ -132,6 +133,7 @@
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
{context}
/>
{:else}
<Input readonly={readOnly} bind:value={field.name} on:blur={changed} />
@ -158,6 +160,7 @@
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
{context}
/>
{:else}
<Input

View File

@ -29,7 +29,9 @@
let modal
let webhookModal
onMount(() => {
onMount(async () => {
await automationStore.actions.initAppSelf()
$automationStore.showTestPanel = false
})

View File

@ -1,9 +1,9 @@
import { derived, get } from "svelte/store"
import { derived, get, Readable, Writable } from "svelte/store"
import { API } from "@/api"
import { cloneDeep } from "lodash/fp"
import { generate } from "shortid"
import { createHistoryStore } from "@/stores/builder/history"
import { licensing } from "@/stores/portal"
import { licensing, organisation, environment, auth } from "@/stores/portal"
import { tables, appStore } from "@/stores/builder"
import { notifications } from "@budibase/bbui"
import {
@ -32,8 +32,10 @@ import {
BlockDefinitions,
GetAutomationTriggerDefinitionsResponse,
GetAutomationActionDefinitionsResponse,
AppSelfResponse,
TestAutomationResponse,
} from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { ActionStepID, TriggerStepID } from "@/constants/backend/automations"
import { FIELDS } from "@/constants/backend"
import { sdk } from "@budibase/shared-core"
import { rowActions } from "./rowActions"
@ -43,10 +45,11 @@ import { BudiStore, DerivedBudiStore } from "@/stores/BudiStore"
interface AutomationState {
automations: Automation[]
testResults: any | null
testResults?: TestAutomationResponse
showTestPanel: boolean
blockDefinitions: BlockDefinitions
selectedAutomationId: string | null
appSelf?: AppSelfResponse
}
interface DerivedAutomationState extends AutomationState {
@ -56,7 +59,6 @@ interface DerivedAutomationState extends AutomationState {
const initialAutomationState: AutomationState = {
automations: [],
testResults: null,
showTestPanel: false,
blockDefinitions: {
TRIGGER: {},
@ -88,6 +90,20 @@ const getFinalDefinitions = (
}
const automationActions = (store: AutomationStore) => ({
/**
* Fetches the app user context used for live evaluation
* This matches the context used on the server
* @returns {AppSelfResponse | null}
*/
initAppSelf: async (): Promise<AppSelfResponse | null> => {
// Fetch and update the app self if it hasn't been set
const appSelfResponse = await API.fetchSelf()
store.update(state => ({
...state,
appSelf: appSelfResponse,
}))
return appSelfResponse
},
/**
* Move a given block from one location on the tree to another.
*
@ -284,9 +300,12 @@ const automationActions = (store: AutomationStore) => ({
* Build a sequential list of all steps on the step path provided
*
* @param {Array<Object>} pathWay e.g. [{stepIdx:2},{branchIdx:0, stepIdx:2},...]
* @returns {Array<Object>} all steps encountered on the provided path
* @returns {Array<AutomationStep | AutomationTrigger>} all steps encountered on the provided path
*/
getPathSteps: (pathWay: Array<BranchPath>, automation: Automation) => {
getPathSteps: (
pathWay: Array<BranchPath>,
automation: Automation
): Array<AutomationStep | AutomationTrigger> => {
// Base Steps, including trigger
const steps = [
automation.definition.trigger,
@ -533,18 +552,24 @@ const automationActions = (store: AutomationStore) => ({
icon: string,
idx: number,
isLoopBlock: boolean,
bindingName?: string
pathBlock: AutomationStep | AutomationTrigger,
bindingName: string
) => {
if (!name) return
const runtimeBinding = store.actions.determineRuntimeBinding(
name,
idx,
isLoopBlock,
bindingName,
automation,
currentBlock,
pathSteps
)
const readableBinding = store.actions.determineReadableBinding(
name,
pathBlock
)
const categoryName = store.actions.determineCategoryName(
idx,
isLoopBlock,
@ -561,7 +586,8 @@ const automationActions = (store: AutomationStore) => ({
isLoopBlock,
runtimeBinding,
categoryName,
bindingName
bindingName,
readableBinding
)
)
}
@ -636,7 +662,15 @@ const automationActions = (store: AutomationStore) => ({
}
}
Object.entries(schema).forEach(([name, value]) => {
addBinding(name, value, icon, blockIdx, isLoopBlock, bindingName)
addBinding(
name,
value,
icon,
blockIdx,
isLoopBlock,
pathBlock,
bindingName
)
})
}
@ -647,23 +681,60 @@ const automationActions = (store: AutomationStore) => ({
return bindings
},
determineReadableBinding: (
name: string,
block: AutomationStep | AutomationTrigger
) => {
const rowTriggers = [
TriggerStepID.ROW_UPDATED,
TriggerStepID.ROW_SAVED,
TriggerStepID.ROW_DELETED,
TriggerStepID.ROW_ACTION,
]
const isTrigger = block.type === AutomationStepType.TRIGGER
const isAppTrigger = block.stepId === AutomationTriggerStepId.APP
const isRowTrigger = rowTriggers.includes(block.stepId)
let readableBinding: string = ""
if (isTrigger) {
if (isAppTrigger) {
readableBinding = `trigger.fields.${name}`
} else if (isRowTrigger) {
let noRowKeywordBindings = ["id", "revision", "oldRow"]
readableBinding = noRowKeywordBindings.includes(name)
? `trigger.${name}`
: `trigger.row.${name}`
} else {
readableBinding = `trigger.${name}`
}
}
return readableBinding
},
determineRuntimeBinding: (
name: string,
idx: number,
isLoopBlock: boolean,
bindingName: string | undefined,
automation: Automation,
currentBlock: AutomationStep | AutomationTrigger | undefined,
pathSteps: (AutomationStep | AutomationTrigger)[]
) => {
let runtimeName: string | null
// Legacy support for EXECUTE_SCRIPT steps
const isJSScript =
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT
/* Begin special cases for generating custom schemas based on triggers */
if (
idx === 0 &&
automation.definition.trigger?.event === AutomationEventType.APP_TRIGGER
) {
return `trigger.fields.${name}`
return isJSScript
? `trigger.fields["${name}"]`
: `trigger.fields.[${name}]`
}
if (
@ -673,18 +744,17 @@ const automationActions = (store: AutomationStore) => ({
automation.definition.trigger?.event === AutomationEventType.ROW_SAVE)
) {
let noRowKeywordBindings = ["id", "revision", "oldRow"]
if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}`
if (!noRowKeywordBindings.includes(name)) {
return isJSScript ? `trigger.row["${name}"]` : `trigger.row.[${name}]`
}
}
/* End special cases for generating custom schemas based on triggers */
if (isLoopBlock) {
runtimeName = `loop.${name}`
} else if (idx === 0) {
runtimeName = `trigger.${name}`
} else if (
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT ||
currentBlock?.stepId === AutomationActionStepId.EXECUTE_SCRIPT_V2
) {
runtimeName = `trigger.[${name}]`
} else if (isJSScript) {
const stepId = pathSteps[idx].id
if (!stepId) {
notifications.error("Error generating binding: Step ID not found.")
@ -725,18 +795,22 @@ const automationActions = (store: AutomationStore) => ({
isLoopBlock: boolean,
runtimeBinding: string | null,
categoryName: string,
bindingName?: string
bindingName?: string,
readableBinding?: string
) => {
const field = Object.values(FIELDS).find(
field =>
field.type === value.type &&
("subtype" in field ? field.subtype === value.subtype : true)
)
const readableBindingDefault =
bindingName && !isLoopBlock && idx !== 0
? `steps.${bindingName}.${name}`
: runtimeBinding
return {
readableBinding:
bindingName && !isLoopBlock && idx !== 0
? `steps.${bindingName}.${name}`
: runtimeBinding,
readableBinding: readableBinding || readableBindingDefault,
runtimeBinding,
type: value.type,
description: value.description,
@ -801,7 +875,7 @@ const automationActions = (store: AutomationStore) => ({
},
test: async (automation: Automation, testData: any) => {
let result: any
let result: TestAutomationResponse
try {
result = await API.testAutomation(automation._id!, testData)
} catch (err: any) {
@ -1395,7 +1469,7 @@ const automationActions = (store: AutomationStore) => ({
}
store.update(state => {
state.selectedAutomationId = id
state.testResults = null
delete state.testResults
state.showTestPanel = false
return state
})
@ -1521,3 +1595,86 @@ export class SelectedAutomationStore extends DerivedBudiStore<
}
}
export const selectedAutomation = new SelectedAutomationStore(automationStore)
type TriggerContext = AutomationTrigger & {
meta?: Record<any, any>
table?: Table
body?: Record<any, any>
row?: Record<any, any>
oldRow?: Record<any, any>
outputs?: Record<any, any>
}
export interface AutomationContext {
user: AppSelfResponse
trigger: TriggerContext
steps: Record<string, AutomationStep>
env: Record<string, any>
settings: Record<string, any>
}
export const evaluationContext: Readable<AutomationContext> = derived(
[organisation, selectedAutomation, environment, tables],
([$organisation, $selectedAutomation, $env, $tables]) => {
const { platformUrl: url, company, logoUrl: logo } = $organisation
const results: TestAutomationResponse | undefined =
$selectedAutomation?.testResults
const testData = $selectedAutomation.data?.testData || {}
const triggerDef = $selectedAutomation.data?.definition?.trigger
const isWebhook = triggerDef?.stepId! === TriggerStepID.WEBHOOK
const isRowAction = triggerDef?.stepId! === TriggerStepID.ROW_ACTION
const rowActionTableId = triggerDef?.inputs?.tableId
const rowActionTable = rowActionTableId
? $tables.list.find(table => table._id === rowActionTableId)
: undefined
// Needs a clone to avoid state mutation.
const triggerData: TriggerContext = cloneDeep(
results?.trigger?.outputs || testData
)
if (isRowAction && rowActionTable) {
// Row action table must always be retrieved as it is never
// returned in the test results
triggerData.table = rowActionTable
} else if (isWebhook) {
// Ensure it displays in the event that the configuration have been skipped
triggerData.body = triggerData.body ?? {}
}
// Clean up unnecessary data from the context
// Meta contains UI/UX config data. Non-bindable
delete triggerData?.meta
// AppSelf context required to mirror server user context
const userContext = $selectedAutomation.appSelf || {}
return {
user: userContext,
trigger: {
...triggerData,
},
// This will initially be empty for each step but will populate
// upon running the test.
steps: (results?.steps || []).reduce(
(acc: Record<string, any>, res: Record<string, any>) => {
acc[res.id] = res.outputs
return acc
},
{}
),
env: ($env?.variables || []).reduce(
(acc: Record<string, any>, variable: Record<string, any>) => {
acc[variable.name] = ""
return acc
},
{}
),
settings: { url, company, logo },
}
}
)

View File

@ -11,6 +11,7 @@ import {
automationStore,
selectedAutomation,
automationHistoryStore,
evaluationContext,
} from "./automations.js"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js"
@ -67,6 +68,7 @@ export {
snippets,
rowActions,
appPublished,
evaluationContext,
}
export const reset = () => {

View File

@ -10,6 +10,7 @@
export let drawerTitle
export let toReadable
export let toRuntime
export let evaluationContext = {}
const dispatch = createEventDispatcher()
@ -66,7 +67,6 @@
>
Confirm
</Button>
<svelte:component
this={panel}
slot="body"
@ -76,6 +76,7 @@
allowHBS
on:change={drawerOnChange}
{bindings}
context={evaluationContext}
/>
</Drawer>

View File

@ -42,6 +42,7 @@
export let panel
export let toReadable
export let toRuntime
export let evaluationContext = {}
$: editableFilters = migrateFilters(filters)
$: {
@ -385,6 +386,7 @@
{panel}
{toReadable}
{toRuntime}
{evaluationContext}
on:change={e => {
const updated = {
...filter,
@ -423,6 +425,7 @@
{panel}
{toReadable}
{toRuntime}
{evaluationContext}
on:change={e => {
onFilterFieldUpdate(
{ ...filter, ...e.detail },

View File

@ -24,6 +24,7 @@
export let drawerTitle
export let toReadable
export let toRuntime
export let evaluationContext = {}
const dispatch = createEventDispatcher()
const { OperatorOptions, FilterValueType } = Constants
@ -156,6 +157,7 @@
allowHBS
on:change={drawerOnChange}
{bindings}
context={evaluationContext}
/>
</Drawer>

View File

@ -21,6 +21,7 @@ import {
AutomationRowEvent,
UserBindings,
AutomationResults,
DidNotTriggerResponse,
} from "@budibase/types"
import { executeInThread } from "../threads/automation"
import { dataFilters, sdk } from "@budibase/shared-core"
@ -33,14 +34,6 @@ const JOB_OPTS = {
import * as automationUtils from "../automations/automationUtils"
import { doesTableExist } from "../sdk/app/tables/getters"
type DidNotTriggerResponse = {
outputs: {
success: false
status: AutomationStatus.STOPPED
}
message: AutomationStoppedReason.TRIGGER_FILTER_NOT_MET
}
async function getAllAutomations() {
const db = context.getAppDB()
let automations = await db.allDocs<Automation>(

View File

@ -1,7 +1,9 @@
import { AutomationJob, DidNotTriggerResponse } from "../../../sdk/automations"
import {
Automation,
AutomationActionStepId,
AutomationLogPage,
AutomationResults,
AutomationStatus,
AutomationStepDefinition,
AutomationTriggerDefinition,
@ -74,4 +76,8 @@ export interface TestAutomationRequest {
fields: Record<string, any>
row?: Row
}
export interface TestAutomationResponse {}
export type TestAutomationResponse =
| AutomationResults
| DidNotTriggerResponse
| AutomationJob

View File

@ -5,6 +5,7 @@ import { ReadStream } from "fs"
import { Row } from "../row"
import { Table } from "../table"
import { AutomationStep, AutomationTrigger } from "./schema"
import { StepOutputs, TriggerOutputs } from "./StepInputsOutputs"
export enum AutomationIOType {
OBJECT = "object",
@ -194,7 +195,7 @@ export enum AutomationStoppedReason {
export interface AutomationResults {
automationId?: string
status?: AutomationStatus
trigger?: AutomationTrigger
trigger?: AutomationTrigger & { outputs: TriggerOutputs }
steps: {
stepId: AutomationTriggerStepId | AutomationActionStepId
inputs: {

View File

@ -1,6 +1,8 @@
import {
Automation,
AutomationMetadata,
AutomationStatus,
AutomationStoppedReason,
Row,
UserBindings,
} from "../../documents"
@ -29,3 +31,11 @@ export interface AutomationRowEvent {
}
export type AutomationJob = Job<AutomationData>
export type DidNotTriggerResponse = {
outputs: {
success: false
status: AutomationStatus.STOPPED
}
message: AutomationStoppedReason.TRIGGER_FILTER_NOT_MET
}