Merge branch 'master' into BUDI-9104

This commit is contained in:
Mike Sealey 2025-03-07 16:40:39 +00:00 committed by GitHub
commit bdbfeaba5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 905 additions and 352 deletions

View File

@ -1,14 +1,14 @@
<script>
<script lang="ts">
import "@spectrum-css/textfield/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
export let value = ""
export let placeholder = null
export let placeholder: string | undefined = undefined
export let disabled = false
export let readonly = false
export let id = null
export let height = null
export let minHeight = null
export let id: string | undefined = undefined
export let height: string | number | undefined = undefined
export let minHeight: string | number | undefined = undefined
export const getCaretPosition = () => ({
start: textarea.selectionStart,
end: textarea.selectionEnd,
@ -16,18 +16,21 @@
export let align = null
let focus = false
let textarea
let textarea: any
const dispatch = createEventDispatcher()
const onChange = event => {
const onChange = (event: any) => {
dispatch("change", event.target.value)
focus = false
}
const getStyleString = (attribute, value) => {
const getStyleString = (
attribute: string,
value: string | number | undefined
) => {
if (!attribute || value == null) {
return ""
}
if (isNaN(value)) {
if (typeof value === "number" && isNaN(value)) {
return `${attribute}:${value};`
}
return `${attribute}:${value}px;`

View File

@ -1,21 +1,21 @@
<script>
<script lang="ts">
import Field from "./Field.svelte"
import TextArea from "./Core/TextArea.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let label = null
export let labelPosition = "above"
export let placeholder = null
export let value: string | undefined = undefined
export let label: string | undefined = undefined
export let labelPosition: string = "above"
export let placeholder: string | undefined = undefined
export let disabled = false
export let error = null
export let getCaretPosition = null
export let height = null
export let minHeight = null
export let helpText = null
export let error: string | undefined = undefined
export let getCaretPosition: any = undefined
export let height: string | number | undefined = undefined
export let minHeight: string | number | undefined = undefined
export let helpText: string | undefined = undefined
const dispatch = createEventDispatcher()
const onChange = e => {
const onChange = (e: any) => {
value = e.detail
dispatch("change", e.detail)
}
@ -24,7 +24,6 @@
<Field {helpText} {label} {labelPosition} {error}>
<TextArea
bind:getCaretPosition
{error}
{disabled}
{value}
{placeholder}

View File

@ -4,7 +4,7 @@
export let title
export let icon = ""
export let id
export let id = undefined
export let href = "#"
export let link = false

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

@ -32,7 +32,6 @@
})
$: groupedAutomations = groupAutomations(filteredAutomations)
$: showNoResults = searchString && !filteredAutomations.length
const groupAutomations = automations => {
@ -41,7 +40,6 @@
for (let auto of automations) {
let category = null
let dataTrigger = false
// Group by datasource if possible
if (dsTriggers.includes(auto.definition?.trigger?.stepId)) {
if (auto.definition.trigger.inputs?.tableId) {
@ -97,7 +95,10 @@
{triggerGroup?.name}
</div>
{#each triggerGroup.entries as automation}
<AutomationNavItem {automation} icon={triggerGroup.icon} />
<AutomationNavItem
{automation}
icon={automation?.definition?.trigger?.icon}
/>
{/each}
</div>
{/each}

View File

@ -22,7 +22,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 {
@ -70,6 +70,7 @@
} from "@budibase/types"
import PropField from "./PropField.svelte"
import { utils } from "@budibase/shared-core"
import DrawerBindableCodeEditorField from "@/components/common/bindings/DrawerBindableCodeEditorField.svelte"
import { API } from "@/api"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
@ -84,6 +85,7 @@
// Stop unnecessary rendering
const memoBlock = memo(block)
const memoContext = memo({})
const rowTriggers = [
TriggerStepID.ROW_UPDATED,
@ -109,6 +111,7 @@
let selectedRow
$: memoBlock.set(block)
$: memoContext.set($evaluationContext)
$: filters = lookForFilters(schemaProperties)
$: filterCount =
@ -250,7 +253,6 @@
onChange({ ["revision"]: e.detail })
},
updateOnChange: false,
forceModal: true,
},
},
]
@ -278,7 +280,6 @@
onChange({ [rowIdentifier]: e.detail })
},
updateOnChange: false,
forceModal: true,
},
},
]
@ -569,6 +570,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 || {})) {
@ -640,7 +645,7 @@
...newTestData,
body: {
...update,
...automation.testData?.body,
...(automation?.testData?.body || {}),
},
}
}
@ -770,6 +775,7 @@
{...config.props}
{bindings}
on:change={config.props.onChange}
context={$memoContext}
bind:searchTerm={rowSearchTerm}
/>
</PropField>
@ -779,6 +785,7 @@
{...config.props}
{bindings}
on:change={config.props.onChange}
context={$memoContext}
/>
{/if}
{/each}
@ -903,6 +910,7 @@
: "Add signature"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
context={$memoContext}
/>
{:else if isTestModal}
<ModalBindableInput
@ -927,6 +935,7 @@
? queryLimit
: ""}
drawerLeft="260px"
context={$memoContext}
/>
{/if}
</div>
@ -956,6 +965,7 @@
panel={AutomationBindingPanel}
showFilterEmptyDropdown={!rowTriggers.includes(stepId)}
on:change={e => (tempFilters = e.detail)}
evaluationContext={$memoContext}
/>
</DrawerContent>
</Drawer>
@ -998,7 +1008,19 @@
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">
<DrawerBindableCodeEditorField
{bindings}
{schema}
panel={AutomationBindingPanel}
on:change={e => onChange({ [key]: e.detail })}
context={$memoContext}
value={inputData[key]}
/>
</div>
{:else if value.customType === "code" && stepId === ActionStepID.EXECUTE_SCRIPT}
<!-- DEPRECATED -->
<CodeEditorModal
on:hide={() => {
// Push any pending changes when the window closes
@ -1080,6 +1102,7 @@
? queryLimit
: ""}
drawerLeft="260px"
context={$memoContext}
/>
</div>
{/if}

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,132 +0,0 @@
<script>
import { goto } from "@roxi/routify"
import {
keepOpen,
ModalContent,
notifications,
Body,
Layout,
Tabs,
Tab,
Heading,
TextArea,
Dropzone,
} from "@budibase/bbui"
import { datasources, queries } from "@/stores/builder"
import { writable } from "svelte/store"
export let navigateDatasource = false
export let datasourceId
export let createDatasource = false
export let onCancel
const data = writable({
url: "",
raw: "",
file: undefined,
})
let lastTouched = "url"
const getData = async () => {
let dataString
// parse the file into memory and send as string
if (lastTouched === "file") {
dataString = await $data.file.text()
} else if (lastTouched === "url") {
const response = await fetch($data.url)
dataString = await response.text()
} else if (lastTouched === "raw") {
dataString = $data.raw
}
return dataString
}
async function importQueries() {
try {
const dataString = await getData()
if (!datasourceId && !createDatasource) {
throw new Error("No datasource id")
}
const body = {
data: dataString,
datasourceId,
}
const importResult = await queries.import(body)
if (!datasourceId) {
datasourceId = importResult.datasourceId
}
// reload
await datasources.fetch()
await queries.fetch()
if (navigateDatasource) {
$goto(`./datasource/${datasourceId}`)
}
notifications.success(`Imported successfully.`)
} catch (error) {
notifications.error("Error importing queries")
return keepOpen
}
}
</script>
<ModalContent
onConfirm={() => importQueries()}
{onCancel}
confirmText={"Import"}
cancelText="Back"
size="L"
>
<Layout noPadding>
<Heading size="S">Import</Heading>
<Body size="XS"
>Import your rest collection using one of the options below</Body
>
<Tabs selected="File">
<!-- Commenting until nginx csp issue resolved -->
<!-- <Tab title="Link">
<Input
bind:value={$data.url}
on:change={() => (lastTouched = "url")}
label="Enter a URL"
placeholder="e.g. https://petstore.swagger.io/v2/swagger.json"
/>
</Tab> -->
<Tab title="File">
<Dropzone
gallery={false}
value={$data.file ? [$data.file] : []}
on:change={e => {
$data.file = e.detail?.[0]
lastTouched = "file"
}}
fileTags={[
"OpenAPI 3.0",
"OpenAPI 2.0",
"Swagger 2.0",
"cURL",
"YAML",
"JSON",
]}
maximum={1}
/>
</Tab>
<Tab title="Raw Text">
<TextArea
bind:value={$data.raw}
on:change={() => (lastTouched = "raw")}
label={"Paste raw text"}
placeholder={'e.g. curl --location --request GET "https://example.com"'}
/>
</Tab>
</Tabs>
</Layout>
</ModalContent>

View File

@ -43,7 +43,7 @@
const validateDescription = description => {
if (!description?.length) {
return "Please enter a name"
return "Please enter a description"
}
return null
}

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"
@ -47,6 +54,7 @@
import { EditorModes } from "./"
import { themeStore } from "@/stores/portal"
import type { EditorMode } from "@budibase/types"
import { tooltips } from "@codemirror/view"
import type { BindingCompletion, CodeValidator } from "@/types"
import { validateHbsTemplate } from "./validator/hbs"
import { validateJsTemplate } from "./validator/js"
@ -62,11 +70,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
@ -117,7 +127,6 @@
queuedRefresh = true
return
}
if (
editor &&
value &&
@ -271,16 +280,15 @@
EditorView.inputHandler.of((view, from, to, insert) => {
if (jsBindingWrapping && insert === "$") {
let { text } = view.state.doc.lineAt(from)
const left = from ? text.substring(0, from) : ""
const right = to ? text.substring(to) : ""
const wrap = !left.includes('$("') || !right.includes('")')
const wrap =
(!left.includes('$("') || !right.includes('")')) &&
!(left.includes("`") && right.includes("`"))
const anchor = from + (wrap ? 3 : 1)
const tr = view.state.update(
{
changes: [{ from, insert: wrap ? '$("")' : "$" }],
selection: {
anchor: from + (wrap ? 3 : 1),
},
},
{
scrollIntoView: true,
@ -288,6 +296,19 @@
}
)
view.dispatch(tr)
// the selection needs to fired after the dispatch - this seems
// to fix an issue with the cursor not moving when the editor is
// first loaded, the first usage of the editor is not ready
// for the anchor to move as well as perform a change
setTimeout(() => {
view.dispatch(
view.state.update({
selection: {
anchor,
},
})
)
}, 1)
return true
}
return false
@ -369,14 +390,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(() => {
@ -391,7 +423,8 @@
<Label size="S">{label}</Label>
</div>
{/if}
<div class={`code-editor ${mode?.name || ""}`}>
<div class={`code-editor ${mode?.name || ""}`} bind:this={editorEle}>
<div tabindex="-1" bind:this={textarea} />
</div>
@ -400,6 +433,7 @@
.code-editor {
font-size: 12px;
height: 100%;
cursor: text;
}
.code-editor :global(.cm-editor) {
height: 100%;
@ -559,12 +593,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 */
@ -589,6 +622,7 @@
white-space: pre;
text-overflow: ellipsis;
overflow: hidden;
overflow-y: auto;
max-height: 480px;
}
.code-editor :global(.binding__example.helper) {

View File

@ -354,7 +354,7 @@
{#if mode === BindingMode.Text}
{#key completions}
<CodeEditor
value={hbsValue}
value={hbsValue || ""}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
@ -369,7 +369,7 @@
{:else if mode === BindingMode.JavaScript}
{#key completions}
<CodeEditor
value={jsValue ? decodeJSBinding(jsValue) : jsValue}
value={jsValue ? decodeJSBinding(jsValue) : ""}
on:change={onChangeJSValue}
{completions}
{validations}

View File

@ -0,0 +1,173 @@
<script lang="ts">
import { createEventDispatcher } 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,
jsHelperAutocomplete,
} from "../CodeEditor"
import { JsonFormatter } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
import type {
EnrichedBinding,
Snippet,
CaretPositionFn,
InsertAtPositionFn,
JSONValue,
} from "@budibase/types"
import type { BindingCompletion, BindingCompletionOption } from "@/types"
import { snippets } from "@/stores/builder"
const dispatch = createEventDispatcher()
export let bindings: EnrichedBinding[] = []
export let value: string = ""
export let allowHelpers = true
export let allowSnippets = true
export let context = null
export let autofocusEditor = false
export let placeholder = null
export let height = 180
let getCaretPosition: CaretPositionFn | undefined
let insertAtPos: InsertAtPositionFn | undefined
$: readable = runtimeToReadableBinding(bindings, value || "")
$: jsValue = decodeJSBinding(readable)
$: useSnippets = allowSnippets && !$licensing.isFreePlan
$: enrichedBindings = enrichBindings(bindings, context, $snippets)
$: editorMode = EditorModes.JS
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: jsCompletions = getJSCompletions(bindingCompletions, $snippets, {
useHelpers: allowHelpers,
useSnippets,
})
const getJSCompletions = (
bindingCompletions: BindingCompletionOption[],
snippets: Snippet[] | null,
config: {
useHelpers: boolean
useSnippets: boolean
}
) => {
const completions: BindingCompletion[] = []
if (bindingCompletions.length) {
completions.push(jsAutocomplete([...bindingCompletions]))
}
if (config.useHelpers) {
completions.push(
jsHelperAutocomplete([...getHelperCompletions(EditorModes.JS)])
)
}
if (config.useSnippets && snippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
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 (!e.detail?.trim()) {
// Don't bother saving empty values as JS
updateValue(null)
} else {
updateValue(encodeJSBinding(e.detail))
}
}
</script>
<div class="code-panel" style="height:{height}px;">
<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 {
display: flex;
}
/* Editor */
.editor {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -0,0 +1,68 @@
<script>
import { createEventDispatcher } from "svelte"
import {
ClientBindingPanel,
DrawerBindableSlot,
} from "@/components/common/bindings"
import CodeEditorField from "@/components/common/bindings/CodeEditorField.svelte"
export let value = ""
export let panel = ClientBindingPanel
export let schema = null
export let bindings = []
export let context = {}
export let height = 180
const dispatch = createEventDispatcher()
</script>
<div class="wrapper">
<DrawerBindableSlot
{panel}
{schema}
{value}
{bindings}
{context}
title="Edit Code"
type="longform"
allowJS={true}
allowHBS={false}
updateOnChange={false}
on:change={e => {
value = e.detail
dispatch("change", value)
}}
>
<div class="code-editor-wrapper">
<CodeEditorField
{value}
{bindings}
{context}
{height}
allowHBS={false}
allowJS
placeholder={"Add bindings by typing $"}
on:change={e => (value = e.detail)}
on:blur={() => dispatch("change", value)}
/>
</div>
</DrawerBindableSlot>
</div>
<style>
.wrapper :global(.icon.slot-icon) {
top: 1px;
border-radius: 0 4px 0 4px;
border-right: 0;
border-bottom: 1px solid var(--spectrum-alias-border-color);
}
.wrapper :global(.cm-editor),
.wrapper :global(.cm-scroller) {
border-radius: 4px;
}
.code-editor-wrapper {
box-sizing: border-box;
border: 1px solid var(--spectrum-global-color-gray-400);
border-radius: 4px;
}
</style>

View File

@ -22,6 +22,8 @@
export let updateOnChange = true
export let type
export let schema
export let allowHBS = true
export let context = {}
const dispatch = createEventDispatcher()
let bindingDrawer
@ -147,7 +149,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}
@ -171,7 +173,7 @@
{:else}
<slot />
{/if}
{#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)}
{#if !disabled && type !== "formula" && !attachmentTypes.includes(type)}
<div
class={`icon ${getIconClass(value, type)}`}
on:click={() => {
@ -187,7 +189,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 +198,9 @@
on:change={event => (tempValue = event.detail)}
{bindings}
{allowJS}
{allowHBS}
{allowHelpers}
{context}
/>
</Drawer>
@ -208,22 +211,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

@ -0,0 +1,41 @@
<script>
import { Label, Checkbox } from "@budibase/bbui"
import DrawerBindableInput from "@/components/common/bindings/DrawerBindableInput.svelte"
export let parameters
export let bindings = []
</script>
<div class="root">
<Label>Text to copy</Label>
<DrawerBindableInput
title="Text to copy"
{bindings}
value={parameters.textToCopy}
on:change={e => (parameters.textToCopy = e.detail)}
/>
<Label />
<Checkbox text="Show notification" bind:value={parameters.showNotification} />
{#if parameters.showNotification}
<Label>Notification message</Label>
<DrawerBindableInput
title="Notification message"
{bindings}
value={parameters.notificationMessage}
placeholder="Copied to clipboard"
on:change={e => (parameters.notificationMessage = e.detail)}
/>
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 120px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -26,3 +26,4 @@ export { default as CloseModal } from "./CloseModal.svelte"
export { default as ClearRowSelection } from "./ClearRowSelection.svelte"
export { default as DownloadFile } from "./DownloadFile.svelte"
export { default as RowAction } from "./RowAction.svelte"
export { default as CopyToClipboard } from "./CopyToClipboard.svelte"

View File

@ -183,6 +183,17 @@
"name": "Row Action",
"type": "data",
"component": "RowAction"
},
{
"name": "Copy To Clipboard",
"type": "data",
"component": "CopyToClipboard",
"context": [
{
"label": "Copied text",
"value": "copied"
}
]
}
]
}

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,12 @@
let modal
let webhookModal
onMount(() => {
onMount(async () => {
await automationStore.actions.initAppSelf()
// Init the binding evaluation context
automationStore.actions.initContext()
$automationStore.showTestPanel = false
})

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { goto } from "@roxi/routify"
import {
keepOpen,
@ -14,13 +14,14 @@
} from "@budibase/bbui"
import { datasources, queries } from "@/stores/builder"
import { writable } from "svelte/store"
import type { Datasource } from "@budibase/types"
export let navigateDatasource = false
export let datasourceId
export let datasourceId: string | undefined = undefined
export let createDatasource = false
export let onCancel
export let onCancel: (() => void) | undefined = undefined
const data = writable({
const data = writable<{ url: string; raw: string; file?: any }>({
url: "",
raw: "",
file: undefined,
@ -28,12 +29,14 @@
let lastTouched = "url"
const getData = async () => {
$: datasource = $datasources.selected as Datasource
const getData = async (): Promise<string> => {
let dataString
// parse the file into memory and send as string
if (lastTouched === "file") {
dataString = await $data.file.text()
dataString = await $data.file?.text()
} else if (lastTouched === "url") {
const response = await fetch($data.url)
dataString = await response.text()
@ -55,9 +58,9 @@
const body = {
data: dataString,
datasourceId,
datasource,
}
const importResult = await queries.import(body)
const importResult = await queries.importQueries(body)
if (!datasourceId) {
datasourceId = importResult.datasourceId
}
@ -71,8 +74,8 @@
}
notifications.success("Imported successfully")
} catch (error) {
notifications.error("Error importing queries")
} catch (error: any) {
notifications.error(`Error importing queries - ${error.message}`)
return keepOpen
}

View File

@ -1,9 +1,9 @@
import { derived, get } from "svelte/store"
import { derived, get, readable, Readable } from "svelte/store"
import { API } from "@/api"
import { cloneDeep } from "lodash/fp"
import { generate } from "shortid"
import { createHistoryStore, HistoryStore } from "@/stores/builder/history"
import { licensing } from "@/stores/portal"
import { licensing, organisation, environment } from "@/stores/portal"
import { tables, appStore } from "@/stores/builder"
import { notifications } from "@budibase/bbui"
import {
@ -35,9 +35,18 @@ import {
BranchStep,
GetAutomationTriggerDefinitionsResponse,
GetAutomationActionDefinitionsResponse,
AppSelfResponse,
TestAutomationResponse,
isAutomationResults,
RowActionTriggerOutputs,
WebhookTriggerOutputs,
AutomationCustomIOType,
AutomationTriggerResultOutputs,
AutomationTriggerResult,
AutomationStepType,
} from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { FIELDS } from "@/constants/backend"
import { ActionStepID, TriggerStepID } from "@/constants/backend/automations"
import { FIELDS as COLUMNS } from "@/constants/backend"
import { sdk } from "@budibase/shared-core"
import { rowActions } from "./rowActions"
import { getNewStepName } from "@/helpers/automations/nameHelpers"
@ -46,10 +55,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 {
@ -59,7 +69,6 @@ interface DerivedAutomationState extends AutomationState {
const initialAutomationState: AutomationState = {
automations: [],
testResults: null,
showTestPanel: false,
blockDefinitions: {
TRIGGER: {},
@ -91,6 +100,116 @@ const getFinalDefinitions = (
}
const automationActions = (store: AutomationStore) => ({
/**
* Generates a derived store acting as an evaluation context
* for bindings in automations
*
* @returns {Readable<AutomationContext>}
*/
generateContext: (): Readable<AutomationContext> => {
return derived(
[organisation, store.selected, environment, tables],
([$organisation, $selectedAutomation, $env, $tables]) => {
const { platformUrl: url, company, logoUrl: logo } = $organisation
const results: TestAutomationResponse | undefined =
$selectedAutomation?.testResults
const testData: AutomationTriggerResultOutputs | undefined =
$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
let triggerData: AutomationTriggerResultOutputs | undefined
if (results && isAutomationResults(results)) {
const automationTrigger: AutomationTriggerResult | undefined =
results?.trigger
const outputs: AutomationTriggerResultOutputs | undefined =
automationTrigger?.outputs
triggerData = outputs ? outputs : undefined
if (triggerData) {
if (isRowAction && rowActionTable) {
const rowTrigger = triggerData as RowActionTriggerOutputs
// Row action table must always be retrieved as it is never
// returned in the test results
rowTrigger.table = rowActionTable
} else if (isWebhook) {
const webhookTrigger = triggerData as WebhookTriggerOutputs
// Ensure it displays in the event that the configuration have been skipped
webhookTrigger.body = webhookTrigger.body ?? {}
}
}
// Clean up unnecessary data from the context
// Meta contains UI/UX config data. Non-bindable
delete triggerData?.meta
} else {
// Substitute test data in place of the trigger data if the test hasn't been run
triggerData = testData
}
// AppSelf context required to mirror server user context
const userContext = $selectedAutomation.appSelf || {}
// Extract step results from a valid response
const stepResults =
results && isAutomationResults(results) ? results?.steps : []
return {
user: userContext,
// Merge in the trigger data.
...(triggerData ? { trigger: { ...triggerData } } : {}),
// This will initially be empty for each step but will populate
// upon running the test.
steps: stepResults.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 },
}
}
)
},
/**
* Initialise the automation evaluation context
*/
initContext: () => {
store.context = store.actions.generateContext()
},
/**
* 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,
...(appSelfResponse ? { appSelf: appSelfResponse } : {}),
}))
return appSelfResponse
},
/**
* Move a given block from one location on the tree to another.
*
@ -287,9 +406,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,
@ -536,41 +658,72 @@ const automationActions = (store: AutomationStore) => ({
let bindings: any[] = []
const addBinding = (
name: string,
value: any,
schema: any,
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
)
// Skip binding if its invalid
if (!runtimeBinding) {
return
}
const readableBinding = store.actions.determineReadableBinding(
name,
pathBlock
)
const categoryName = store.actions.determineCategoryName(
idx,
isLoopBlock,
bindingName,
loopBlockCount
)
bindings.push(
store.actions.createBindingObject(
name,
value,
icon,
idx,
loopBlockCount,
isLoopBlock,
runtimeBinding,
categoryName,
bindingName
)
const isStep = !isLoopBlock && idx !== 0
const defaultReadable =
bindingName && isStep ? `steps.${bindingName}.${name}` : runtimeBinding
// Check if the schema matches any column types.
const column = Object.values(COLUMNS).find(
col =>
col.type === schema.type &&
("subtype" in col ? col.subtype === schema.subtype : true)
)
// Automation types and column types can collide e.g. "array"
// Exclude where necessary
const ignoreColumnType = schema.customType === AutomationCustomIOType.ROWS
// Shown in the bindable menus
const displayType = ignoreColumnType ? schema.type : column?.name
bindings.push({
readableBinding: readableBinding || defaultReadable,
runtimeBinding,
type: schema.type,
description: schema.description,
icon,
category: categoryName,
display: {
type: displayType,
name,
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
},
})
}
let loopBlockCount = 0
@ -638,8 +791,17 @@ const automationActions = (store: AutomationStore) => ({
console.error("Loop block missing.")
}
}
Object.entries(schema).forEach(([name, value]) => {
addBinding(name, value, icon, blockIdx, isLoopBlock, bindingName)
addBinding(
name,
value,
icon,
blockIdx,
isLoopBlock,
pathBlock,
bindingName
)
})
}
@ -650,23 +812,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 = ""
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
let runtimeName: string
// 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 (
@ -676,29 +875,28 @@ 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.")
return null
return
}
runtimeName = `steps["${stepId}"].${name}`
} else {
const stepId = pathSteps[idx].id
if (!stepId) {
notifications.error("Error generating binding: Step ID not found.")
return null
return
}
runtimeName = `steps.${stepId}.${name}`
}
@ -719,40 +917,6 @@ const automationActions = (store: AutomationStore) => ({
: `Step ${idx - loopBlockCount} outputs`
},
createBindingObject: (
name: string,
value: any,
icon: string,
idx: number,
loopBlockCount: number,
isLoopBlock: boolean,
runtimeBinding: string | null,
categoryName: string,
bindingName?: string
) => {
const field = Object.values(FIELDS).find(
field =>
field.type === value.type &&
("subtype" in field ? field.subtype === value.subtype : true)
)
return {
readableBinding:
bindingName && !isLoopBlock && idx !== 0
? `steps.${bindingName}.${name}`
: runtimeBinding,
runtimeBinding,
type: value.type,
description: value.description,
icon,
category: categoryName,
display: {
type: field?.name || value.type,
name,
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
},
}
},
processBlockInputs: async (
block: AutomationStep,
data: Record<string, any>
@ -804,19 +968,14 @@ 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) {
const message = err.message || err.status || JSON.stringify(err)
throw `Automation test failed - ${message}`
}
if (!result?.trigger && !result?.steps?.length && !result?.message) {
if (result?.err?.code === "usage_limit_exceeded") {
throw "You have exceeded your automation quota"
}
throw "Something went wrong testing your automation"
}
store.update(state => {
state.testResults = result
return state
@ -1404,7 +1563,7 @@ const automationActions = (store: AutomationStore) => ({
}
store.update(state => {
state.selectedAutomationId = id
state.testResults = null
delete state.testResults
state.showTestPanel = false
return state
})
@ -1444,29 +1603,14 @@ const automationActions = (store: AutomationStore) => ({
},
})
class AutomationStore extends BudiStore<AutomationState> {
history: HistoryStore<Automation>
actions: ReturnType<typeof automationActions>
constructor() {
super(initialAutomationState)
this.actions = automationActions(this)
this.history = createHistoryStore({
getDoc: this.actions.getDefinition.bind(this),
selectDoc: this.actions.select.bind(this),
})
// Then wrap save and delete with history
const originalSave = this.actions.save.bind(this.actions)
const originalDelete = this.actions.delete.bind(this.actions)
this.actions.save = this.history.wrapSaveDoc(originalSave)
this.actions.delete = this.history.wrapDeleteDoc(originalDelete)
}
export interface AutomationContext {
user: AppSelfResponse | null
trigger?: AutomationTriggerResultOutputs
steps: Record<string, AutomationStep>
env: Record<string, any>
settings: Record<string, any>
}
export const automationStore = new AutomationStore()
export const automationHistoryStore = automationStore.history
export class SelectedAutomationStore extends DerivedBudiStore<
AutomationState,
DerivedAutomationState
@ -1527,4 +1671,49 @@ export class SelectedAutomationStore extends DerivedBudiStore<
super(initialAutomationState, makeDerivedStore)
}
}
export const selectedAutomation = new SelectedAutomationStore(automationStore)
class AutomationStore extends BudiStore<AutomationState> {
history: HistoryStore<Automation>
actions: ReturnType<typeof automationActions>
selected: SelectedAutomationStore
context: Readable<AutomationContext> | undefined
constructor() {
super(initialAutomationState)
this.actions = automationActions(this)
this.history = createHistoryStore({
getDoc: this.actions.getDefinition.bind(this),
selectDoc: this.actions.select.bind(this),
})
// Then wrap save and delete with history
const originalSave = this.actions.save.bind(this.actions)
const originalDelete = this.actions.delete.bind(this.actions)
this.actions.save = this.history.wrapSaveDoc(originalSave)
this.actions.delete = this.history.wrapDeleteDoc(originalDelete)
this.selected = new SelectedAutomationStore(this)
}
}
export const automationStore = new AutomationStore()
export const automationHistoryStore = automationStore.history
export const selectedAutomation = automationStore.selected
// Define an empty evaluate context at the start
const emptyContext: AutomationContext = {
user: {},
steps: {},
env: {},
settings: {},
}
// Page layout kicks off initialisation, subscription happens within the page
export const evaluationContext: Readable<AutomationContext> = readable(
emptyContext,
set => {
const unsubscribe = automationStore.context?.subscribe(set) ?? (() => {})
return () => unsubscribe()
}
)

View File

@ -11,6 +11,7 @@ import {
automationStore,
selectedAutomation,
automationHistoryStore,
evaluationContext,
} from "./automations"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users"
import { deploymentStore } from "./deployments"
@ -72,6 +73,7 @@ export {
snippets,
rowActions,
appPublished,
evaluationContext,
screenComponentsList,
screenComponentErrors,
screenComponentErrorList,

View File

@ -4,12 +4,12 @@
import { Utils } from "@budibase/frontend-core"
import FormBlockWrapper from "./FormBlockWrapper.svelte"
import { get } from "svelte/store"
import type { TableSchema, UIDatasource } from "@budibase/types"
import type { TableSchema } from "@budibase/types"
type Field = { name: string; active: boolean }
export let actionType: string
export let dataSource: UIDatasource
export let dataSource: { resourceId: string }
export let size: string
export let disabled: boolean
export let fields: (Field | string)[]
@ -30,8 +30,8 @@
// Legacy
export let showDeleteButton: boolean
export let showSaveButton: boolean
export let saveButtonLabel: boolean
export let deleteButtonLabel: boolean
export let saveButtonLabel: string
export let deleteButtonLabel: string
const { fetchDatasourceSchema, generateGoldenSample } = getContext("sdk")
const component = getContext("component")
@ -107,7 +107,7 @@
return [...fields, ...defaultFields].filter(field => field.active)
}
const fetchSchema = async (datasource: UIDatasource) => {
const fetchSchema = async (datasource: { resourceId: string }) => {
schema = (await fetchDatasourceSchema(datasource)) || {}
}
</script>

View File

@ -421,6 +421,28 @@ const showNotificationHandler = action => {
const promptUserHandler = () => {}
const copyToClipboardHandler = async action => {
const { textToCopy, showNotification, notificationMessage } =
action.parameters
if (!textToCopy) {
return
}
try {
await navigator.clipboard.writeText(textToCopy)
if (showNotification) {
const message = notificationMessage || "Copied to clipboard"
notificationStore.actions.success(message, true, 3000)
}
} catch (err) {
console.error("Failed to copy text: ", err)
notificationStore.actions.error("Failed to copy to clipboard")
}
return { copied: textToCopy }
}
const openSidePanelHandler = action => {
const { id } = action.parameters
if (id) {
@ -514,6 +536,7 @@ const handlerMap = {
["Close Modal"]: closeModalHandler,
["Download File"]: downloadFileHandler,
["Row Action"]: rowActionHandler,
["Copy To Clipboard"]: copyToClipboardHandler,
}
const confirmTextMap = {

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

@ -3,7 +3,13 @@ import { sdk as coreSdk } from "@budibase/shared-core"
import { DocumentType } from "../../db/utils"
import { updateTestHistory } from "../../automations/utils"
import { withTestFlag } from "../../utilities/redis"
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
import {
context,
cache,
events,
db as dbCore,
HTTPError,
} from "@budibase/backend-core"
import { automations, features } from "@budibase/pro"
import {
App,
@ -28,6 +34,7 @@ import {
TriggerAutomationResponse,
TestAutomationRequest,
TestAutomationResponse,
Table,
} from "@budibase/types"
import { getActionDefinitions as actionDefs } from "../../automations/actions"
import sdk from "../../sdk"
@ -231,14 +238,22 @@ export async function test(
const { request, appId } = ctx
const { body } = request
let table: Table | undefined
if (coreSdk.automations.isRowAction(automation) && body.row?.tableId) {
table = await sdk.tables.getTable(body.row?.tableId)
if (!table) {
throw new HTTPError("Table not found", 404)
}
}
ctx.body = await withTestFlag(automation._id!, async () => {
const occurredAt = new Date().getTime()
await updateTestHistory(appId, automation, { ...body, occurredAt })
const input = prepareTestInput(body)
const user = sdk.users.getUserContextBindings(ctx.user)
return await triggers.externalTrigger(
automation,
{ ...prepareTestInput(body), appId, user },
{ ...{ ...input, ...(table ? { table } : {}) }, appId, user },
{ getResponses: true }
)
})

View File

@ -11,6 +11,7 @@ import {
import { configs, context, events } from "@budibase/backend-core"
import sdk from "../../../sdk"
import {
AutomationResults,
ConfigType,
FieldType,
FilterCondition,
@ -621,7 +622,7 @@ describe("/automations", () => {
})
)
const res = await config.api.automation.test(automation._id!, {
const response = await config.api.automation.test(automation._id!, {
fields: {},
oldRow: {
City: oldCity,
@ -631,12 +632,14 @@ describe("/automations", () => {
},
})
if (isDidNotTriggerResponse(res)) {
if (isDidNotTriggerResponse(response)) {
throw new Error("Automation did not trigger")
}
const results: AutomationResults = response as AutomationResults
const expectedResult = oldCity === newCity
expect(res.steps[1].outputs.result).toEqual(expectedResult)
expect(results.steps[1].outputs.result).toEqual(expectedResult)
}
)
})
@ -723,7 +726,8 @@ describe("/automations", () => {
if (isDidNotTriggerResponse(res)) {
expect(expectToRun).toEqual(false)
} else {
expect(res.steps[1].outputs.success).toEqual(expectToRun)
const results: AutomationResults = res as AutomationResults
expect(results.steps[1].outputs.success).toEqual(expectToRun)
}
}
)

View File

@ -12,7 +12,7 @@ describe("Webhook trigger test", () => {
async function createWebhookAutomation() {
const { automation } = await createAutomationBuilder(config)
.onWebhook({ fields: { parameter: "string" } })
.onWebhook({ body: { parameter: "string" } })
.createRow({
row: { tableId: table._id!, name: "{{ trigger.parameter }}" },
})

View File

@ -4,6 +4,7 @@ import { TRIGGER_DEFINITIONS } from "../../triggers"
import {
Automation,
AutomationActionStepId,
AutomationResults,
AutomationStep,
AutomationStepInputs,
AutomationTrigger,
@ -213,10 +214,11 @@ class AutomationRunner<TStep extends AutomationTriggerStepId> {
throw new Error(response.message)
}
const results: AutomationResults = response as AutomationResults
// Remove the trigger step from the response.
response.steps.shift()
results.steps.shift()
return response
return results
}
async trigger(

View File

@ -22,6 +22,7 @@ import {
UserBindings,
AutomationResults,
DidNotTriggerResponse,
Table,
} from "@budibase/types"
import { executeInThread } from "../threads/automation"
import { dataFilters, sdk } from "@budibase/shared-core"
@ -154,6 +155,7 @@ interface AutomationTriggerParams {
timeout?: number
appId?: string
user?: UserBindings
table?: Table
}
export async function externalTrigger(

View File

@ -1,3 +1,4 @@
import { AutomationJob } from "../../../sdk/automations"
import {
Automation,
AutomationActionStepId,
@ -78,10 +79,25 @@ export interface TestAutomationRequest {
row?: Row
oldRow?: Row
}
export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse
export function isDidNotTriggerResponse(
response: TestAutomationResponse
): response is DidNotTriggerResponse {
return !!("message" in response && response.message)
}
export function isAutomationResults(
response: TestAutomationResponse
): response is AutomationResults {
return !!(
"steps" in response &&
response.steps &&
"trigger" in response &&
response.trigger
)
}
export type TestAutomationResponse =
| AutomationResults
| DidNotTriggerResponse
| AutomationJob

View File

@ -11,7 +11,7 @@ export interface SaveQueryRequest extends Query {}
export interface SaveQueryResponse extends Query {}
export interface ImportRestQueryRequest {
datasourceId: string
datasourceId?: string
data: string
datasource: Datasource
}

View File

@ -1,3 +1,4 @@
import { Table } from "@budibase/types"
import { SortOrder } from "../../../api"
import {
SearchFilters,
@ -305,6 +306,7 @@ export type RowUpdatedTriggerOutputs = {
row: Row
id: string
revision?: string
oldRow?: Row
}
export type WebhookTriggerInputs = {
@ -312,6 +314,17 @@ export type WebhookTriggerInputs = {
triggerUrl: string
}
export type WebhookTriggerOutputs = {
fields: Record<string, any>
export type WebhookTriggerOutputs = Record<string, any> & {
body: Record<string, any>
}
export type RowActionTriggerInputs = {
tableId: string
}
export type RowActionTriggerOutputs = {
row: Row
id: string
revision?: string
table: Table
}

View File

@ -136,15 +136,7 @@ export interface Automation extends Document {
internal?: boolean
type?: string
disabled?: boolean
testData?: {
row?: Row
meta: {
[key: string]: unknown
}
id: string
revision: string
oldRow?: Row
}
testData?: AutomationTriggerResultOutputs
}
export interface BaseIOStructure {