Merge branch 'master' into sqs-test-fixes

This commit is contained in:
Sam Rose 2024-11-06 10:16:16 +00:00 committed by GitHub
commit 9caf001746
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 3308 additions and 687 deletions

View File

@ -3,7 +3,7 @@ name: Deploy QA
on: on:
push: push:
branches: branches:
- feature/automation-branching-ux - master
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.0.4", "version": "3.1.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -3,8 +3,8 @@
import Flowchart from "./FlowChart/FlowChart.svelte" import Flowchart from "./FlowChart/FlowChart.svelte"
</script> </script>
{#if $selectedAutomation} {#if $selectedAutomation?.data}
{#key $selectedAutomation._id} {#key $selectedAutomation.data._id}
<Flowchart automation={$selectedAutomation} /> <Flowchart automation={$selectedAutomation.data} />
{/key} {/key}
{/if} {/if}

View File

@ -0,0 +1,497 @@
<script>
import { writable } from "svelte/store"
import {
setContext,
onMount,
createEventDispatcher,
onDestroy,
tick,
} from "svelte"
import { Utils } from "@budibase/frontend-core"
import { selectedAutomation, automationStore } from "stores/builder"
export function zoomIn() {
const scale = Number(Math.min($view.scale + 0.1, 1.5).toFixed(2))
view.update(state => ({
...state,
scale,
}))
}
export function zoomOut() {
const scale = Number(Math.max($view.scale - 0.1, 0).toFixed(2))
view.update(state => ({
...state,
scale,
}))
}
export async function reset() {
contentDims = {
...contentDims,
w: contentDims.original.w,
h: contentDims.original.h,
}
dragOffset = []
contentPos.update(state => ({
...state,
x: 0,
y: 0,
}))
view.update(state => ({
...state,
scale: 1,
}))
}
export async function zoomToFit() {
const { width: wViewPort, height: hViewPort } =
viewPort.getBoundingClientRect()
const scaleTarget = Math.min(
wViewPort / contentDims.original.w,
hViewPort / contentDims.original.h
)
// Smallest ratio determines which dimension needs squeezed
view.update(state => ({
...state,
scale: scaleTarget,
}))
await tick()
const adjustedY = (hViewPort - contentDims.original.h) / 2
contentPos.update(state => ({
...state,
x: 0,
y: parseInt(0 + adjustedY),
}))
}
const dispatch = createEventDispatcher()
// View State
const view = writable({
dragging: false,
moveStep: null,
dragSpot: null,
scale: 1,
dropzones: {},
//focus - node to center on?
})
setContext("draggableView", view)
// View internal pos tracking
const internalPos = writable({ x: 0, y: 0 })
setContext("viewPos", internalPos)
// Content pos tracking
const contentPos = writable({ x: 0, y: 0, scrollX: 0, scrollY: 0 })
setContext("contentPos", contentPos)
// Elements
let mainContent
let viewPort
let contentWrap
// Mouse down
let down = false
// Monitor the size of the viewPort
let observer
// Size of the core display content
let contentDims = {}
// Size of the view port
let viewDims = {}
// When dragging the content, maintain the drag start offset
let dragOffset
// Used when focusing the UI on trigger
let loaded = false
// Edge around the draggable content
let contentDragPadding = 200
const onScale = async () => {
dispatch("zoom", $view.scale)
await getDims()
}
const getDims = async () => {
if (!mainContent) return
if (!contentDims.original) {
contentDims.original = {
w: parseInt(mainContent.getBoundingClientRect().width),
h: parseInt(mainContent.getBoundingClientRect().height),
}
}
viewDims = viewPort.getBoundingClientRect()
contentDims = {
...contentDims,
w: contentDims.original.w * $view.scale,
h: contentDims.original.h * $view.scale,
}
}
const eleXY = (coords, ele) => {
const { clientX, clientY } = coords
const rect = ele.getBoundingClientRect()
const x = Math.round(clientX - rect.left)
const y = Math.round(clientY - rect.top)
return { x: Math.max(x, 0), y: Math.max(y, 0) }
}
const buildWrapStyles = (pos, scale, dims) => {
const { x, y } = pos
const { w, h } = dims
return `--posX: ${x}px; --posY: ${y}px; --scale: ${scale}; --wrapH: ${h}px; --wrapW: ${w}px`
}
const onViewScroll = e => {
e.preventDefault()
let currentScale = $view.scale
let scrollIncrement = 35
let xBump = 0
let yBump = 0
if (e.shiftKey) {
// Scroll horizontal - Needs Limits
xBump = scrollIncrement * (e.deltaX < 0 ? -1 : 1)
contentPos.update(state => ({
...state,
x: state.x - xBump,
y: state.y,
// If scrolling *and* dragging, maintain a record of the scroll offset
...($view.dragging
? {
scrollX: state.scrollX - xBump,
}
: {}),
}))
} else if (e.ctrlKey || e.metaKey) {
// Scale the content on scrolling
let updatedScale
if (e.deltaY < 0) {
updatedScale = Math.min(1, currentScale + 0.05)
} else if (e.deltaY > 0) {
updatedScale = Math.max(0, currentScale - 0.05)
}
view.update(state => ({
...state,
scale: Number(updatedScale.toFixed(2)),
}))
} else {
yBump = scrollIncrement * (e.deltaY < 0 ? -1 : 1)
contentPos.update(state => ({
...state,
x: state.x,
y: state.y - yBump,
// If scrolling *and* dragging, maintain a record of the scroll offset
...($view.dragging
? {
scrollY: state.scrollY - yBump,
}
: {}),
}))
}
}
// Optimization options
const onViewMouseMove = async e => {
if (!viewPort) {
return
}
const { x, y } = eleXY(e, viewPort)
internalPos.update(() => ({
x,
y,
}))
if (down && !$view.dragging && dragOffset) {
contentPos.update(state => ({
...state,
x: x - dragOffset[0],
y: y - dragOffset[1],
}))
}
}
const onViewDragEnd = () => {
down = false
dragOffset = [0, 0]
}
const handleDragDrop = () => {
const sourceBlock = $selectedAutomation.blockRefs[$view.moveStep.id]
const sourcePath = sourceBlock.pathTo
const dropZone = $view.dropzones[$view.droptarget]
const destPath = dropZone?.path
automationStore.actions.moveBlock(
sourcePath,
destPath,
$selectedAutomation.data
)
}
const onMouseUp = () => {
if ($view.droptarget) {
handleDragDrop()
}
view.update(state => ({
...state,
dragging: false,
moveStep: null,
dragSpot: null,
dropzones: {},
droptarget: null,
}))
// Clear the scroll offset for dragging
contentPos.update(state => ({
...state,
scrollY: 0,
scrollX: 0,
}))
}
const onMouseMove = async e => {
if (!viewPort) {
return
}
// Update viewDims to get the latest viewport dimensions
viewDims = viewPort.getBoundingClientRect()
if ($view.moveStep && $view.dragging === false) {
view.update(state => ({
...state,
dragging: true,
}))
}
const checkIntersection = (pos, dzRect) => {
return (
pos.x < dzRect.right &&
pos.x > dzRect.left &&
pos.y < dzRect.bottom &&
pos.y > dzRect.top
)
}
if ($view.dragging) {
const adjustedX =
(e.clientX - viewDims.left - $view.moveStep.offsetX) / $view.scale
const adjustedY =
(e.clientY - viewDims.top - $view.moveStep.offsetY) / $view.scale
view.update(state => ({
...state,
dragSpot: {
x: adjustedX,
y: adjustedY,
},
}))
}
if ($view.moveStep && $view.dragging) {
let hovering = false
Object.entries($view.dropzones).forEach(entry => {
const [dzKey, dz] = entry
if (checkIntersection({ x: e.clientX, y: e.clientY }, dz.dims)) {
hovering = true
view.update(state => ({
...state,
droptarget: dzKey,
}))
}
})
// Ensure that when it stops hovering, it clears the drop target
if (!hovering) {
view.update(state => ({
...state,
droptarget: null,
}))
}
}
}
const onMoveContent = e => {
if (down || !viewPort) {
return
}
const { x, y } = eleXY(e, viewPort)
dragOffset = [Math.abs(x - $contentPos.x), Math.abs(y - $contentPos.y)]
}
const focusOnLoad = () => {
if ($view.focusEle && !loaded) {
const focusEleDims = $view.focusEle
const viewWidth = viewDims.width
// The amount to shift the content in order to center the trigger on load.
// The content is also padded with `contentDragPadding`
// The sidebar offset factors into the left positioning of the content here.
const targetX =
contentWrap.getBoundingClientRect().x -
focusEleDims.x +
(viewWidth / 2 - focusEleDims.width / 2)
// Update the content position state
// Shift the content up slightly to accommodate the padding
contentPos.update(state => ({
...state,
x: targetX,
y: -(contentDragPadding / 2),
}))
loaded = true
}
}
// Update dims after scaling
$: {
$view.scale
onScale()
}
// Focus on a registered element
$: {
$view.focusEle
focusOnLoad()
}
// Content mouse pos and scale to css variables.
// The wrap is set to match the content size
$: wrapStyles = buildWrapStyles($contentPos, $view.scale, contentDims)
onMount(() => {
observer = new ResizeObserver(getDims)
observer.observe(viewPort)
})
onDestroy(() => {
observer.disconnect()
})
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
class="draggable-canvas"
role="region"
aria-label="Viewport for building automations"
on:mouseup={onMouseUp}
on:mousemove={Utils.domDebounce(onMouseMove)}
style={`--dragPadding: ${contentDragPadding}px;`}
>
<div
class="draggable-view"
bind:this={viewPort}
on:wheel={Utils.domDebounce(onViewScroll)}
on:mousemove={Utils.domDebounce(onViewMouseMove)}
on:mouseup={onViewDragEnd}
on:mouseleave={onViewDragEnd}
>
<!-- <div class="debug">
<span>
View Pos [{$internalPos.x}, {$internalPos.y}]
</span>
<span>View Dims [{viewDims.width}, {viewDims.height}]</span>
<span>Mouse Down [{down}]</span>
<span>Drag [{$view.dragging}]</span>
<span>Dragging [{$view?.moveStep?.id || "no"}]</span>
<span>Scale [{$view.scale}]</span>
<span>Content [{JSON.stringify($contentPos)}]</span>
</div> -->
<div
class="content-wrap"
style={wrapStyles}
bind:this={contentWrap}
class:dragging={down}
>
<div
class="content"
bind:this={mainContent}
on:mousemove={Utils.domDebounce(onMoveContent)}
on:mousedown={e => {
if (e.which === 1 || e.button === 0) {
down = true
}
}}
on:mouseup={e => {
if (e.which === 1 || e.button === 0) {
down = false
}
}}
on:mouseleave={() => {
down = false
view.update(state => ({
...state,
dragging: false,
moveStep: null,
dragSpot: null,
dropzones: {},
}))
}}
>
<slot name="content" />
</div>
</div>
</div>
</div>
<style>
.draggable-canvas {
width: 100%;
height: 100%;
}
.draggable-view {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.content-wrap {
min-width: 100%;
position: absolute;
top: 0;
left: 0;
width: var(--wrapW);
height: var(--wrapH);
cursor: grab;
transform: translate(var(--posX), var(--posY));
}
.content {
transform: scale(var(--scale));
user-select: none;
padding: var(--dragPadding);
}
.content-wrap.dragging {
cursor: grabbing;
}
/* .debug {
display: flex;
align-items: center;
gap: 8px;
position: fixed;
padding: 8px;
z-index: 2;
} */
</style>

View File

@ -9,27 +9,42 @@
Tags, Tags,
Tag, Tag,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AutomationActionStepId } from "@budibase/types"
import { automationStore, selectedAutomation } from "stores/builder" import { automationStore, selectedAutomation } from "stores/builder"
import { admin, licensing } from "stores/portal" import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions" import { externalActions } from "./ExternalActions"
import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { checkForCollectStep } from "helpers/utils"
export let blockIdx export let block
export let lastStep
export let modal export let modal
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK] let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction let selectedAction
let actions = Object.entries($automationStore.blockDefinitions.ACTION) let actions = Object.entries($automationStore.blockDefinitions.ACTION).filter(
entry => {
const [key] = entry
return key !== AutomationActionStepId.BRANCH
}
)
let lockedFeatures = [ let lockedFeatures = [
ActionStepID.COLLECT, ActionStepID.COLLECT,
ActionStepID.TRIGGER_AUTOMATION_RUN, ActionStepID.TRIGGER_AUTOMATION_RUN,
] ]
$: collectBlockExists = checkForCollectStep($selectedAutomation) $: blockRef = $selectedAutomation.blockRefs?.[block.id]
$: lastStep = blockRef?.terminating
$: pathSteps = block.id
? automationStore.actions.getPathSteps(
blockRef.pathTo,
$selectedAutomation?.data
)
: []
$: collectBlockExists = pathSteps?.some(
step => step.stepId === ActionStepID.COLLECT
)
const disabled = () => { const disabled = () => {
return { return {
@ -75,7 +90,7 @@
// Filter out Collect block if not App Action or Webhook // Filter out Collect block if not App Action or Webhook
if ( if (
!collectBlockAllowedSteps.includes( !collectBlockAllowedSteps.includes(
$selectedAutomation.definition.trigger.stepId $selectedAutomation.data.definition.trigger.stepId
) )
) { ) {
delete acc.COLLECT delete acc.COLLECT
@ -100,9 +115,14 @@
action.stepId, action.stepId,
action action
) )
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
await automationStore.actions.addBlockToAutomation(
newBlock,
blockRef ? blockRef.pathTo : block.pathTo
)
modal.hide() modal.hide()
} catch (error) { } catch (error) {
console.error(error)
notifications.error("Error saving automation") notifications.error("Error saving automation")
} }
} }

View File

@ -0,0 +1,283 @@
<script>
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import {
Drawer,
DrawerContent,
ActionButton,
Icon,
Layout,
Body,
Divider,
TooltipPosition,
TooltipType,
Button,
Modal,
ModalContent,
} from "@budibase/bbui"
import PropField from "components/automation/SetupPanel/PropField.svelte"
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 } from "@budibase/frontend-core"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte"
import DragZone from "./DragZone.svelte"
const dispatch = createEventDispatcher()
export let pathTo
export let branchIdx
export let step
export let isLast
export let bindings
export let automation
const view = getContext("draggableView")
let drawer
let condition
let open = true
let confirmDeleteModal
$: branch = step.inputs?.branches?.[branchIdx]
$: editableConditionUI = cloneDeep(branch.conditionUI || {})
$: condition = QueryUtils.buildQuery(editableConditionUI)
// Parse all the bindings into fields for the condition builder
$: schemaFields = bindings.map(binding => {
return {
name: `{{${binding.runtimeBinding}}}`,
displayName: `${binding.category} - ${binding.display.name}`,
type: "string",
}
})
$: branchBlockRef = {
branchNode: true,
pathTo: (pathTo || []).concat({ branchIdx, branchStepId: step.id }),
}
</script>
<Modal bind:this={confirmDeleteModal}>
<ModalContent
size="M"
title={"Are you sure you want to delete?"}
confirmText="Delete"
onConfirm={async () => {
await automationStore.actions.deleteBranch(
branchBlockRef.pathTo,
$selectedAutomation.data
)
}}
>
<Body>By deleting this branch, you will delete all of its contents.</Body>
</ModalContent>
</Modal>
<Drawer bind:this={drawer} title="Branch condition" forceModal>
<Button
cta
slot="buttons"
on:click={() => {
drawer.hide()
dispatch("change", {
conditionUI: editableConditionUI,
condition,
})
}}
>
Save
</Button>
<DrawerContent slot="body">
<FilterBuilder
filters={editableConditionUI}
{bindings}
{schemaFields}
datasource={{ type: "custom" }}
panel={AutomationBindingPanel}
on:change={e => {
editableConditionUI = e.detail
}}
allowOnEmpty={false}
builderType={"condition"}
docsURL={null}
/>
</DrawerContent>
</Drawer>
<div class="flow-item">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class={`block branch-node hoverable`}
class:selected={false}
on:mousedown={e => {
e.stopPropagation()
}}
>
<FlowItemHeader
{automation}
{open}
itemName={branch.name}
block={step}
deleteStep={async () => {
const branchSteps = step.inputs?.children[branch.id]
if (branchSteps.length) {
confirmDeleteModal.show()
} else {
await automationStore.actions.deleteBranch(
branchBlockRef.pathTo,
$selectedAutomation.data
)
}
}}
on:update={async e => {
let stepUpdate = cloneDeep(step)
let branchUpdate = stepUpdate.inputs?.branches.find(
stepBranch => stepBranch.id == branch.id
)
branchUpdate.name = e.detail
const updatedAuto = automationStore.actions.updateStep(
pathTo,
$selectedAutomation.data,
stepUpdate
)
await automationStore.actions.save(updatedAuto)
}}
on:toggle={() => (open = !open)}
>
<div slot="custom-actions" class="branch-actions">
<Icon
on:click={() => {
automationStore.actions.branchLeft(
branchBlockRef.pathTo,
$selectedAutomation.data,
step
)
}}
tooltip={"Move left"}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Top}
hoverable
disabled={branchIdx == 0}
name="ArrowLeft"
/>
<Icon
on:click={() => {
automationStore.actions.branchRight(
branchBlockRef.pathTo,
$selectedAutomation.data,
step
)
}}
tooltip={"Move right"}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Top}
hoverable
disabled={isLast}
name="ArrowRight"
/>
</div>
</FlowItemHeader>
{#if open}
<Divider noMargin />
<div class="blockSection">
<!-- Content body for possible slot -->
<Layout noPadding>
<PropField label="Only run when">
<ActionButton fullWidth on:click={drawer.show}>
{editableConditionUI?.groups?.length
? "Update condition"
: "Add condition"}
</ActionButton>
</PropField>
<div class="footer">
<Icon
name="InfoOutline"
size="S"
color="var(--spectrum-global-color-gray-700)"
/>
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
Only the first branch which matches its condition will run
</Body>
</div>
</Layout>
</div>
{/if}
</div>
<div class="separator" />
{#if $view.dragging}
<DragZone path={branchBlockRef.pathTo} />
{:else}
<FlowItemActions block={branchBlockRef} />
{/if}
{#if step.inputs.children[branch.id]?.length}
<div class="separator" />
{/if}
</div>
<style>
.branch-actions {
display: flex;
gap: var(--spacing-l);
}
.footer {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.flow-item {
display: flex;
flex-direction: column;
align-items: center;
}
.block-options {
justify-content: flex-end;
align-items: center;
display: flex;
gap: var(--spacing-m);
}
.center-items {
display: flex;
align-items: center;
}
.splitHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.iconAlign {
padding: 0 0 0 var(--spacing-m);
display: inline-block;
}
.block {
width: 480px;
font-size: 16px;
background-color: var(--background);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px 4px 4px 4px;
}
.blockSection {
padding: var(--spacing-xl);
}
.separator {
width: 1px;
height: 25px;
border-left: 1px dashed var(--grey-4);
color: var(--grey-4);
align-self: center;
}
.blockTitle {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,66 @@
<script>
import { getContext, onMount } from "svelte"
import { generate } from "shortid"
export let path
let dropEle
let dzid = generate()
const view = getContext("draggableView")
onMount(() => {
// Always return up-to-date values
view.update(state => {
return {
...state,
dropzones: {
...(state.dropzones || {}),
[dzid]: {
get dims() {
return dropEle.getBoundingClientRect()
},
path,
},
},
}
})
})
</script>
<div
id={`dz-${dzid}`}
bind:this={dropEle}
class="drag-zone"
class:drag-over={$view?.droptarget === dzid}
>
<span class="move-to">Move to</span>
</div>
<style>
.drag-zone.drag-over {
background-color: #1ca872b8;
}
.drag-zone {
min-height: calc(var(--spectrum-global-dimension-size-225) + 12px);
min-width: 100%;
background-color: rgba(28, 168, 114, 0.2);
border-radius: 4px;
border: 1px dashed #1ca872;
position: relative;
text-align: center;
}
.move-to {
position: absolute;
left: 0;
right: 0;
top: -50%;
margin: auto;
width: fit-content;
font-weight: 600;
border-radius: 8px;
padding: 4px 8px;
background-color: rgb(28, 168, 114);
color: var(--spectrum-global-color-gray-50);
}
</style>

View File

@ -1,27 +1,63 @@
<script> <script>
import { import {
automationStore, automationStore,
selectedAutomation,
automationHistoryStore, automationHistoryStore,
selectedAutomation,
} from "stores/builder" } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import FlowItem from "./FlowItem.svelte"
import TestDataModal from "./TestDataModal.svelte" import TestDataModal from "./TestDataModal.svelte"
import { flip } from "svelte/animate" import {
import { fly } from "svelte/transition" Icon,
import { Icon, notifications, Modal, Toggle } from "@budibase/bbui" notifications,
Modal,
Toggle,
Button,
ActionButton,
} from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte" import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import StepNode from "./StepNode.svelte"
import { memo } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import DraggableCanvas from "../DraggableCanvas.svelte"
export let automation export let automation
const memoAutomation = memo(automation)
let testDataModal let testDataModal
let confirmDeleteDialog let confirmDeleteDialog
let scrolling = false let scrolling = false
let blockRefs = {}
let treeEle
let draggable
$: blocks = getBlocks(automation).filter(x => x.stepId !== ActionStepID.LOOP) // Memo auto - selectedAutomation
$: isRowAction = sdk.automations.isRowAction(automation) $: memoAutomation.set(automation)
// Parse the automation tree state
$: refresh($memoAutomation)
$: blocks = getBlocks($memoAutomation).filter(
x => x.stepId !== ActionStepID.LOOP
)
$: isRowAction = sdk.automations.isRowAction($memoAutomation)
const refresh = () => {
// Build global automation bindings.
const environmentBindings =
automationStore.actions.buildEnvironmentBindings()
// Get all processed block references
blockRefs = $selectedAutomation.blockRefs
automationStore.update(state => {
return {
...state,
bindings: [...environmentBindings],
}
})
}
const getBlocks = automation => { const getBlocks = automation => {
let blocks = [] let blocks = []
@ -34,39 +70,46 @@
const deleteAutomation = async () => { const deleteAutomation = async () => {
try { try {
await automationStore.actions.delete($selectedAutomation) await automationStore.actions.delete(automation)
} catch (error) { } catch (error) {
notifications.error("Error deleting automation") notifications.error("Error deleting automation")
} }
} }
const handleScroll = e => {
if (e.target.scrollTop >= 30) {
scrolling = true
} else if (e.target.scrollTop) {
// Set scrolling back to false if scrolled back to less than 100px
scrolling = false
}
}
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="header" class:scrolling> <div class="header" class:scrolling>
<div class="header-left"> <div class="header-left">
<UndoRedoControl store={automationHistoryStore} /> <UndoRedoControl store={automationHistoryStore} showButtonGroup />
<div class="zoom">
<div class="group">
<ActionButton icon="Add" quiet on:click={draggable.zoomIn} />
<ActionButton icon="Remove" quiet on:click={draggable.zoomOut} />
</div>
</div>
<Button
secondary
on:click={() => {
draggable.zoomToFit()
}}
>
Zoom to fit
</Button>
</div> </div>
<div class="controls"> <div class="controls">
<div <Button
class:disabled={!$selectedAutomation?.definition?.trigger} icon={"Play"}
cta
disabled={!automation?.definition?.trigger}
on:click={() => { on:click={() => {
testDataModal.show() testDataModal.show()
}} }}
class="buttons"
> >
<Icon size="M" name="Play" /> Run test
<div>Run test</div> </Button>
</div>
<div class="buttons"> <div class="buttons">
<Icon disabled={!$automationStore.testResults} size="M" name="Multiple" /> <Icon disabled={!$automationStore.testResults} size="M" name="Multiple" />
<div <div
@ -79,36 +122,39 @@
</div> </div>
</div> </div>
{#if !isRowAction} {#if !isRowAction}
<div class="setting-spacing"> <div class="toggle-active setting-spacing">
<Toggle <Toggle
text={automation.disabled ? "Paused" : "Activated"} text={automation.disabled ? "Paused" : "Activated"}
on:change={automationStore.actions.toggleDisabled( on:change={automationStore.actions.toggleDisabled(
automation._id, automation._id,
automation.disabled automation.disabled
)} )}
disabled={!$selectedAutomation?.definition?.trigger} disabled={!automation?.definition?.trigger}
value={!automation.disabled} value={!automation.disabled}
/> />
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<div class="canvas" on:scroll={handleScroll}>
<div class="content"> <div class="root" bind:this={treeEle}>
{#each blocks as block, idx (block.id)} <DraggableCanvas bind:this={draggable}>
<div <span class="main-content" slot="content">
class="block" {#if Object.keys(blockRefs).length}
animate:flip={{ duration: 500 }} {#each blocks as block, idx (block.id)}
in:fly={{ x: 500, duration: 500 }} <StepNode
out:fly|local={{ x: 500, duration: 500 }} step={blocks[idx]}
> stepIdx={idx}
{#if block.stepId !== ActionStepID.LOOP} isLast={blocks?.length - 1 === idx}
<FlowItem {testDataModal} {block} {idx} /> automation={$memoAutomation}
{/if} blocks={blockRefs}
</div> />
{/each} {/each}
</div> {/if}
</span>
</DraggableCanvas>
</div> </div>
<ConfirmDialog <ConfirmDialog
bind:this={confirmDeleteDialog} bind:this={confirmDeleteDialog}
okText="Delete Automation" okText="Delete Automation"
@ -125,32 +171,43 @@
</Modal> </Modal>
<style> <style>
.canvas { .toggle-active :global(.spectrum-Switch) {
padding: var(--spacing-l) var(--spacing-xl); margin: 0px;
overflow-y: auto; }
.root :global(.main-content) {
display: flex;
flex-direction: column;
align-items: center;
max-height: 100%; max-height: 100%;
height: 100%;
width: 100%;
}
.header-left {
display: flex;
gap: var(--spacing-l);
} }
.header-left :global(div) { .header-left :global(div) {
border-right: none; border-right: none;
} }
/* Fix for firefox not respecting bottom padding in scrolling containers */
.canvas > *:last-child { .root {
padding-bottom: 40px; height: 100%;
width: 100%;
} }
.block { .root :global(.block) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
} }
.content { .root :global(.blockSection) {
flex-grow: 1; width: 100%;
padding: 23px 23px 80px;
box-sizing: border-box; box-sizing: border-box;
overflow-x: hidden;
} }
.header.scrolling { .header.scrolling {
@ -166,13 +223,15 @@
align-items: center; align-items: center;
padding-left: var(--spacing-l); padding-left: var(--spacing-l);
transition: background 130ms ease-out; transition: background 130ms ease-out;
flex: 0 0 48px; flex: 0 0 60px;
padding-right: var(--spacing-xl); padding-right: var(--spacing-xl);
} }
.controls { .controls {
display: flex; display: flex;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.buttons { .buttons {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@ -188,4 +247,33 @@
pointer-events: none; pointer-events: none;
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
} }
.group {
border-radius: 4px;
display: flex;
flex-direction: row;
}
.group :global(> *:not(:first-child)) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 2px solid var(--spectrum-global-color-gray-300);
}
.group :global(> *:not(:last-child)) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.header-left .group :global(.spectrum-Button),
.header-left .group :global(.spectrum-ActionButton),
.header-left .group :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-900) !important;
}
.header-left .group :global(.spectrum-Button),
.header-left .group :global(.spectrum-ActionButton) {
background: var(--spectrum-global-color-gray-200) !important;
}
.header-left .group :global(.spectrum-Button:hover),
.header-left .group :global(.spectrum-ActionButton:hover) {
background: var(--spectrum-global-color-gray-300) !important;
}
</style> </style>

View File

@ -1,8 +1,8 @@
<script> <script>
import { import {
automationStore, automationStore,
selectedAutomation,
permissions, permissions,
selectedAutomation,
tables, tables,
} from "stores/builder" } from "stores/builder"
import { import {
@ -11,54 +11,115 @@
Layout, Layout,
Detail, Detail,
Modal, Modal,
Button,
notifications,
Label, Label,
AbsTooltip, AbsTooltip,
InlineAlert,
} from "@budibase/bbui" } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import ActionModal from "./ActionModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte" import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte" import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
import { ActionStepID, TriggerStepID } from "constants/backend/automations" import { ActionStepID, TriggerStepID } from "constants/backend/automations"
import { AutomationStepType } from "@budibase/types"
import FlowItemActions from "./FlowItemActions.svelte"
import DragHandle from "components/design/settings/controls/DraggableList/drag-handle.svelte"
import { getContext } from "svelte"
import DragZone from "./DragZone.svelte"
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
export let block export let block
export let blockRef
export let testDataModal export let testDataModal
export let idx export let idx
export let automation
export let bindings
export let draggable = true
const view = getContext("draggableView")
const pos = getContext("viewPos")
const contentPos = getContext("contentPos")
let selected
let webhookModal let webhookModal
let actionModal
let open = true let open = true
let showLooping = false let showLooping = false
let role let role
let blockEle
let positionStyles
let blockDims
$: collectBlockExists = $selectedAutomation.definition.steps.some( const updateBlockDims = () => {
if (!blockEle) {
return
}
const { width, height } = blockEle.getBoundingClientRect()
blockDims = { width: width / $view.scale, height: height / $view.scale }
}
const loadSteps = blockRef => {
return blockRef
? automationStore.actions.getPathSteps(blockRef.pathTo, automation)
: []
}
$: pathSteps = loadSteps(blockRef)
$: collectBlockExists = pathSteps.some(
step => step.stepId === ActionStepID.COLLECT step => step.stepId === ActionStepID.COLLECT
) )
$: automationId = $selectedAutomation?._id $: automationId = automation?._id
$: isTrigger = block.type === "TRIGGER" $: isTrigger = block.type === AutomationStepType.TRIGGER
$: steps = $selectedAutomation?.definition?.steps ?? [] $: lastStep = blockRef?.terminating
$: blockIdx = steps.findIndex(step => step.id === block.id)
$: lastStep = !isTrigger && blockIdx + 1 === steps.length $: loopBlock = pathSteps.find(x => x.blockToLoop === block.id)
$: totalBlocks = $selectedAutomation?.definition?.steps.length + 1
$: loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block.id
)
$: isAppAction = block?.stepId === TriggerStepID.APP $: isAppAction = block?.stepId === TriggerStepID.APP
$: isAppAction && setPermissions(role) $: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId) $: isAppAction && getPermissions(automationId)
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation) && { $: triggerInfo = sdk.automations.isRowAction($selectedAutomation?.data) && {
title: "Automation trigger", title: "Automation trigger",
tableName: $tables.list.find( tableName: $tables.list.find(
x => x._id === $selectedAutomation.definition.trigger.inputs?.tableId x =>
x._id === $selectedAutomation.data?.definition?.trigger?.inputs?.tableId
)?.name, )?.name,
} }
$: selected = $view?.moveStep && $view?.moveStep?.id === block.id
$: if (selected && blockEle) {
updateBlockDims()
}
$: placeholderDims = buildPlaceholderStyles(blockDims)
// Move the selected item
// Listen for scrolling in the content. As its scrolled this will be updated
$: move(
blockEle,
$view?.dragSpot,
selected,
$contentPos?.scrollX,
$contentPos?.scrollY
)
const move = (block, dragPos, selected, scrollX, scrollY) => {
if ((!block && !selected) || !dragPos) {
return
}
positionStyles = `
--blockPosX: ${Math.round(dragPos.x - scrollX / $view.scale)}px;
--blockPosY: ${Math.round(dragPos.y - scrollY / $view.scale)}px;
`
}
const buildPlaceholderStyles = dims => {
if (!dims) {
return ""
}
const { width, height } = dims
return `--pswidth: ${Math.round(width)}px;
--psheight: ${Math.round(height)}px;`
}
async function setPermissions(role) { async function setPermissions(role) {
if (!role || !automationId) { if (!role || !automationId) {
return return
@ -82,23 +143,13 @@
} }
} }
async function removeLooping() { async function deleteStep() {
try { await automationStore.actions.deleteAutomationBlock(blockRef.pathTo)
await automationStore.actions.deleteAutomationBlock(loopBlock)
} catch (error) {
notifications.error("Error saving automation")
}
} }
async function deleteStep() { async function removeLooping() {
try { let loopBlockRef = $selectedAutomation.blockRefs[blockRef.looped]
if (loopBlock) { await automationStore.actions.deleteAutomationBlock(loopBlockRef.pathTo)
await automationStore.actions.deleteAutomationBlock(loopBlock)
}
await automationStore.actions.deleteAutomationBlock(block, blockIdx)
} catch (error) {
notifications.error("Error saving automation")
}
} }
async function addLooping() { async function addLooping() {
@ -109,122 +160,201 @@
loopDefinition loopDefinition
) )
loopBlock.blockToLoop = block.id loopBlock.blockToLoop = block.id
await automationStore.actions.addBlockToAutomation(loopBlock, blockIdx) await automationStore.actions.addBlockToAutomation(
loopBlock,
blockRef.pathTo
)
}
const onHandleMouseDown = e => {
if (isTrigger) {
e.preventDefault()
return
}
e.stopPropagation()
view.update(state => ({
...state,
moveStep: {
id: block.id,
offsetX: $pos.x,
offsetY: $pos.y,
},
}))
} }
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> {#if block.stepId !== "LOOP"}
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}> <!-- svelte-ignore a11y-no-static-element-interactions -->
{#if loopBlock} <div
<div class="blockSection"> id={`block-${block.id}`}
class={`block ${block.type} hoverable`}
class:selected
class:draggable
>
<div class="wrap">
{#if $view.dragging && selected}
<div class="drag-placeholder" style={placeholderDims} />
{/if}
<div <div
on:click={() => { bind:this={blockEle}
showLooping = !showLooping class="block-content"
class:dragging={$view.dragging && selected}
style={positionStyles}
on:mousedown={e => {
e.stopPropagation()
}} }}
class="splitHeader"
> >
<div class="center-items"> {#if draggable}
<svg <div
width="28px" class="handle"
height="28px" class:grabbing={selected}
class="spectrum-Icon" on:mousedown={onHandleMouseDown}
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
> >
<use xlink:href="#spectrum-icon-18-Reuse" /> <DragHandle />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
</div> </div>
</div> {/if}
<div class="block-core">
{#if loopBlock}
<div class="blockSection">
<div
on:click={() => {
showLooping = !showLooping
}}
class="splitHeader"
>
<div class="center-items">
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Reuse" />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
</div>
</div>
<div class="blockTitle"> <div class="blockTitle">
<AbsTooltip type="negative" text="Remove looping"> <AbsTooltip type="negative" text="Remove looping">
<Icon on:click={removeLooping} hoverable name="DeleteOutline" /> <Icon
</AbsTooltip> on:click={removeLooping}
hoverable
name="DeleteOutline"
/>
</AbsTooltip>
<div style="margin-left: 10px;" on:click={() => {}}> <div style="margin-left: 10px;" on:click={() => {}}>
<Icon hoverable name={showLooping ? "ChevronDown" : "ChevronUp"} /> <Icon
</div> hoverable
name={showLooping ? "ChevronDown" : "ChevronUp"}
/>
</div>
</div>
</div>
</div>
<Divider noMargin />
{#if !showLooping}
<div class="blockSection">
<Layout noPadding gap="S">
<AutomationBlockSetup
schemaProperties={Object.entries(
$automationStore.blockDefinitions.ACTION.LOOP.schema
.inputs.properties
)}
{webhookModal}
block={loopBlock}
{automation}
{bindings}
/>
</Layout>
</div>
<Divider noMargin />
{/if}
{/if}
<FlowItemHeader
{automation}
{open}
{block}
{testDataModal}
{idx}
{addLooping}
{deleteStep}
on:toggle={() => (open = !open)}
on:update={async e => {
const newName = e.detail
if (newName.length === 0) {
await automationStore.actions.deleteAutomationName(block.id)
} else {
await automationStore.actions.saveAutomationName(
block.id,
newName
)
}
}}
/>
{#if open}
<Divider noMargin />
<div class="blockSection">
<Layout noPadding gap="S">
{#if isAppAction}
<div>
<Label>Role</Label>
<RoleSelect bind:value={role} />
</div>
{/if}
<AutomationBlockSetup
schemaProperties={Object.entries(
block?.schema?.inputs?.properties || {}
)}
{block}
{webhookModal}
{automation}
{bindings}
/>
{#if isTrigger && triggerInfo}
<InfoDisplay
title={triggerInfo.title}
body="This trigger is tied to your '{triggerInfo.tableName}' table"
icon="InfoOutline"
/>
{/if}
</Layout>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
</div>
<Divider noMargin /> {#if !collectBlockExists || !lastStep}
{#if !showLooping} <div class="separator" />
<div class="blockSection"> {#if $view.dragging}
<Layout noPadding gap="S"> <DragZone path={blockRef?.pathTo} />
<AutomationBlockSetup {:else}
schemaProperties={Object.entries( <FlowItemActions
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs {block}
.properties on:branch={() => {
)} automationStore.actions.branchAutomation(
{webhookModal} $selectedAutomation.blockRefs[block.id].pathTo,
block={loopBlock} automation
/> )
</Layout> }}
</div> />
<Divider noMargin /> {/if}
{#if !lastStep}
<div class="separator" />
{/if} {/if}
{/if} {/if}
<FlowItemHeader
{open}
{block}
{testDataModal}
{idx}
{addLooping}
{deleteStep}
on:toggle={() => (open = !open)}
/>
{#if open}
<Divider noMargin />
<div class="blockSection">
<Layout noPadding gap="S">
{#if isAppAction}
<div>
<Label>Role</Label>
<RoleSelect bind:value={role} />
</div>
{/if}
<AutomationBlockSetup
schemaProperties={Object.entries(block.schema.inputs.properties)}
{block}
{webhookModal}
/>
{#if triggerInfo}
<InlineAlert
header={triggerInfo.title}
message={`This trigger is tied to your "${triggerInfo.tableName}" table`}
/>
{/if}
{#if lastStep}
<Button on:click={() => testDataModal.show()} cta>
Finish and test automation
</Button>
{/if}
</Layout>
</div>
{/if}
</div>
{#if !collectBlockExists || !lastStep}
<div class="separator" />
<Icon
on:click={() => actionModal.show()}
hoverable
name="AddCircle"
size="S"
/>
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" />
{/if}
{/if} {/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal modal={actionModal} {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%"> <Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal /> <CreateWebhookModal />
</Modal> </Modal>
@ -255,27 +385,73 @@
.block { .block {
width: 480px; width: 480px;
font-size: 16px; font-size: 16px;
background-color: var(--background); border-radius: 4px;
border: 1px solid var(--spectrum-global-color-gray-300); }
border-radius: 4px 4px 4px 4px; .block .wrap {
width: 100%;
position: relative;
}
.block.draggable .wrap {
display: flex;
flex-direction: row;
}
.block.draggable .wrap .handle {
height: auto;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--grey-3);
padding: 6px;
color: var(--grey-6);
cursor: grab;
}
.block.draggable .wrap .handle.grabbing {
cursor: grabbing;
}
.block.draggable .wrap .handle :global(.drag-handle) {
width: 6px;
}
.block .wrap .block-content {
width: 100%;
display: flex;
flex-direction: row;
background-color: var(--background);
border: 1px solid var(--grey-3);
border-radius: 4px;
} }
.blockSection { .blockSection {
padding: var(--spacing-xl); padding: var(--spacing-xl);
} }
.separator { .separator {
width: 1px; width: 1px;
height: 25px; height: 25px;
border-left: 1px dashed var(--grey-4); border-left: 1px dashed var(--grey-4);
color: var(--grey-4); color: var(--grey-4);
/* center horizontally */
align-self: center; align-self: center;
} }
.blockTitle { .blockTitle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-s); gap: var(--spacing-s);
} }
.drag-placeholder {
height: calc(var(--psheight) - 2px);
width: var(--pswidth);
background-color: rgba(92, 92, 92, 0.1);
border: 1px dashed #5c5c5c;
border-radius: 4px;
display: block;
}
.block-core {
flex: 1;
}
.block-core.dragging {
pointer-events: none;
}
.block-content.dragging {
position: absolute;
z-index: 3;
top: var(--blockPosY);
left: var(--blockPosX);
}
</style> </style>

View File

@ -0,0 +1,51 @@
<script>
import { Icon, TooltipPosition, TooltipType, Modal } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import ActionModal from "./ActionModal.svelte"
export let block
const dispatch = createEventDispatcher()
let actionModal
</script>
<Modal bind:this={actionModal} width="30%">
<ActionModal modal={actionModal} {block} />
</Modal>
<div class="action-bar">
{#if !block.branchNode}
<Icon
hoverable
name="Branch3"
on:click={() => {
dispatch("branch")
}}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Left}
tooltip={"Create branch"}
size={"S"}
/>
{/if}
<Icon
hoverable
name="AddCircle"
on:click={() => {
actionModal.show()
}}
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Right}
tooltip={"Add a step"}
size={"S"}
/>
</div>
<style>
.action-bar {
background-color: var(--background);
border-radius: 4px 4px 4px 4px;
display: flex;
gap: var(--spacing-m);
padding: 8px 12px;
}
</style>

View File

@ -10,21 +10,25 @@
export let showTestStatus = false export let showTestStatus = false
export let testResult export let testResult
export let isTrigger export let isTrigger
export let idx
export let addLooping export let addLooping
export let deleteStep export let deleteStep
export let enableNaming = true export let enableNaming = true
export let itemName
export let automation
let validRegex = /^[A-Za-z0-9_\s]+$/ let validRegex = /^[A-Za-z0-9_\s]+$/
let typing = false let typing = false
let editing = false let editing = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: stepNames = $selectedAutomation?.definition.stepNames $: blockRefs = $selectedAutomation?.blockRefs || {}
$: allSteps = $selectedAutomation?.definition.steps || [] $: stepNames = automation?.definition.stepNames
$: automationName = stepNames?.[block.id] || block?.name || "" $: allSteps = automation?.definition.steps || []
$: automationName = itemName || stepNames?.[block.id] || block?.name || ""
$: automationNameError = getAutomationNameError(automationName) $: automationNameError = getAutomationNameError(automationName)
$: status = updateStatus(testResult) $: status = updateStatus(testResult)
$: isHeaderTrigger = isTrigger || block.type === "TRIGGER" $: isHeaderTrigger = isTrigger || block.type === "TRIGGER"
$: isBranch = block.stepId === "BRANCH"
$: { $: {
if (!testResult) { if (!testResult) {
@ -33,12 +37,12 @@
)?.[0] )?.[0]
} }
} }
$: loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block?.id $: blockRef = blockRefs[block.id]
) $: isLooped = blockRef?.looped
async function onSelect(block) { async function onSelect(block) {
await automationStore.update(state => { automationStore.update(state => {
state.selectedBlock = block state.selectedBlock = block
return state return state
}) })
@ -84,30 +88,18 @@
return null return null
} }
const saveName = async () => {
if (automationNameError || block.name === automationName) {
return
}
if (automationName.length === 0) {
await automationStore.actions.deleteAutomationName(block.id)
} else {
await automationStore.actions.saveAutomationName(block.id, automationName)
}
}
const startEditing = () => { const startEditing = () => {
editing = true editing = true
typing = true typing = true
} }
const stopEditing = async () => { const stopEditing = () => {
editing = false editing = false
typing = false typing = false
if (automationNameError) { if (automationNameError) {
automationName = stepNames[block.id] || block?.name automationName = stepNames[block.id] || block?.name
} else { } else {
await saveName() dispatch("update", automationName)
} }
} }
</script> </script>
@ -118,7 +110,6 @@
class:typing={typing && !automationNameError && editing} class:typing={typing && !automationNameError && editing}
class:typing-error={automationNameError && editing} class:typing-error={automationNameError && editing}
class="blockSection" class="blockSection"
on:click={() => dispatch("toggle")}
> >
<div class="splitHeader"> <div class="splitHeader">
<div class="center-items"> <div class="center-items">
@ -144,16 +135,14 @@
{#if isHeaderTrigger} {#if isHeaderTrigger}
<Body size="XS"><b>Trigger</b></Body> <Body size="XS"><b>Trigger</b></Body>
{:else} {:else}
<div style="margin-left: 2px;"> <Body size="XS"><b>{isBranch ? "Branch" : "Step"}</b></Body>
<Body size="XS"><b>Step {idx}</b></Body>
</div>
{/if} {/if}
{#if enableNaming} {#if enableNaming}
<input <input
class="input-text" class="input-text"
disabled={!enableNaming} disabled={!enableNaming}
placeholder="Enter step name" placeholder={`Enter ${isBranch ? "branch" : "step"} name`}
name="name" name="name"
autocomplete="off" autocomplete="off"
value={automationName} value={automationName}
@ -208,8 +197,9 @@
onSelect(block) onSelect(block)
}} }}
> >
<slot name="custom-actions" />
{#if !showTestStatus} {#if !showTestStatus}
{#if !isHeaderTrigger && !loopBlock && (block?.features?.[Features.LOOPING] || !block.features)} {#if !isHeaderTrigger && !isLooped && !isBranch && (block?.features?.[Features.LOOPING] || !block.features)}
<AbsTooltip type="info" text="Add looping"> <AbsTooltip type="info" text="Add looping">
<Icon on:click={addLooping} hoverable name="RotateCW" /> <Icon on:click={addLooping} hoverable name="RotateCW" />
</AbsTooltip> </AbsTooltip>
@ -220,6 +210,9 @@
</AbsTooltip> </AbsTooltip>
{/if} {/if}
{/if} {/if}
{#if !showTestStatus && !isHeaderTrigger}
<span class="action-spacer" />
{/if}
{#if !showTestStatus} {#if !showTestStatus}
<Icon <Icon
on:click={e => { on:click={e => {
@ -245,6 +238,9 @@
</div> </div>
<style> <style>
.action-spacer {
border-left: 1px solid var(--spectrum-global-color-gray-300);
}
.status-container { .status-container {
display: flex; display: flex;
align-items: center; align-items: center;
@ -298,6 +294,8 @@
font-size: var(--spectrum-alias-font-size-default); font-size: var(--spectrum-alias-font-size-default);
font-family: var(--font-sans); font-family: var(--font-sans);
text-overflow: ellipsis; text-overflow: ellipsis;
padding-left: 0px;
border: 0px;
} }
input:focus { input:focus {

View File

@ -0,0 +1,227 @@
<script>
import FlowItem from "./FlowItem.svelte"
import BranchNode from "./BranchNode.svelte"
import { AutomationActionStepId } from "@budibase/types"
import { ActionButton, notifications } from "@budibase/bbui"
import { automationStore } from "stores/builder"
import { environment } from "stores/portal"
import { cloneDeep } from "lodash"
import { memo } from "@budibase/frontend-core"
import { getContext, onMount } from "svelte"
export let step = {}
export let stepIdx
export let automation
export let blocks
export let isLast = false
const memoEnvVariables = memo($environment.variables)
const view = getContext("draggableView")
let stepEle
$: memoEnvVariables.set($environment.variables)
$: blockRef = blocks?.[step.id]
$: pathToCurrentNode = blockRef?.pathTo
$: isBranch = step.stepId === AutomationActionStepId.BRANCH
$: branches = step.inputs?.branches
// All bindings available to this point
$: availableBindings = automationStore.actions.getPathBindings(
step.id,
automation
)
// Fetch the env bindings
$: environmentBindings =
automationStore.actions.buildEnvironmentBindings($memoEnvVariables)
$: userBindings = automationStore.actions.buildUserBindings()
$: settingBindings = automationStore.actions.buildSettingBindings()
// Combine all bindings for the step
$: bindings = [
...availableBindings,
...environmentBindings,
...userBindings,
...settingBindings,
]
onMount(() => {
// Register the trigger as the focus element for the automation
// Onload, the canvas will use the dimensions to center the step
if (stepEle && step.type === "TRIGGER" && !$view.focusEle) {
view.update(state => ({
...state,
focusEle: stepEle.getBoundingClientRect(),
}))
}
})
</script>
{#if isBranch}
<div class="split-branch-btn">
<ActionButton
icon="AddCircle"
on:click={() => {
automationStore.actions.branchAutomation(pathToCurrentNode, automation)
}}
>
Add branch
</ActionButton>
</div>
<div class="branched">
{#each branches as branch, bIdx}
{@const leftMost = bIdx === 0}
{@const rightMost = branches?.length - 1 === bIdx}
<div class="branch-wrap">
<div
class="branch"
class:left={leftMost}
class:right={rightMost}
class:middle={!leftMost && !rightMost}
>
<div class="branch-node">
<BranchNode
{automation}
{step}
{bindings}
pathTo={pathToCurrentNode}
branchIdx={bIdx}
isLast={rightMost}
on:change={async e => {
const updatedBranch = { ...branch, ...e.detail }
if (!step?.inputs?.branches?.[bIdx]) {
console.error(`Cannot load target branch: ${bIdx}`)
return
}
let branchStepUpdate = cloneDeep(step)
branchStepUpdate.inputs.branches[bIdx] = updatedBranch
// Ensure valid base configuration for all branches
// Reinitialise empty branch conditions on update
branchStepUpdate.inputs.branches.forEach(
(branch, i, branchArray) => {
if (!Object.keys(branch.condition).length) {
branchArray[i] = {
...branch,
...automationStore.actions.generateDefaultConditions(),
}
}
}
)
const updated = automationStore.actions.updateStep(
blockRef?.pathTo,
automation,
branchStepUpdate
)
try {
await automationStore.actions.save(updated)
} catch (e) {
notifications.error("Error saving branch update")
console.error("Error saving automation branch", e)
}
}}
/>
</div>
<!-- Branch steps -->
{#each step.inputs?.children[branch.id] || [] as bStep, sIdx}
<!-- Recursive StepNode -->
<svelte:self
step={bStep}
stepIdx={sIdx}
branchIdx={bIdx}
isLast={blockRef.terminating}
pathTo={pathToCurrentNode}
{automation}
{blocks}
/>
{/each}
</div>
</div>
{/each}
</div>
{:else}
<div class="block" bind:this={stepEle}>
<FlowItem
block={step}
idx={stepIdx}
{blockRef}
{isLast}
{automation}
{bindings}
draggable={step.type !== "TRIGGER"}
/>
</div>
{/if}
<style>
.branch-wrap {
width: inherit;
}
.branch {
display: flex;
align-items: center;
flex-direction: column;
position: relative;
width: inherit;
}
.branched {
display: flex;
gap: 64px;
}
.branch::before {
height: 64px;
border-left: 1px dashed var(--grey-4);
border-top: 1px dashed var(--grey-4);
content: "";
color: var(--grey-4);
width: 50%;
position: absolute;
left: 50%;
top: -16px;
}
.branch.left::before {
color: var(--grey-4);
width: calc(50% + 62px);
}
.branch.middle::after {
height: 64px;
border-top: 1px dashed var(--grey-4);
content: "";
color: var(--grey-4);
width: calc(50% + 62px);
position: absolute;
left: 50%;
top: -16px;
}
.branch.right::before {
left: 0px;
border-right: 1px dashed var(--grey-4);
border-left: none;
}
.branch.middle::before {
left: 0px;
border-right: 1px dashed var(--grey-4);
border-left: none;
}
.branch-node {
margin-top: 48px;
}
.split-branch-btn {
z-index: 2;
}
</style>

View File

@ -29,7 +29,7 @@
* @todo Parse *all* data for each trigger type and relay adequate feedback * @todo Parse *all* data for each trigger type and relay adequate feedback
*/ */
const parseTestData = testData => { const parseTestData = testData => {
const autoTrigger = $selectedAutomation?.definition?.trigger const autoTrigger = $selectedAutomation.data?.definition?.trigger
const { tableId } = autoTrigger?.inputs || {} const { tableId } = autoTrigger?.inputs || {}
// Ensure the tableId matches the trigger table for row trigger automations // Ensure the tableId matches the trigger table for row trigger automations
@ -63,12 +63,12 @@
return true return true
} }
const memoTestData = memo(parseTestData($selectedAutomation.testData)) const memoTestData = memo(parseTestData($selectedAutomation.data.testData))
$: memoTestData.set(parseTestData($selectedAutomation.testData)) $: memoTestData.set(parseTestData($selectedAutomation.data.testData))
$: { $: {
// clone the trigger so we're not mutating the reference // clone the trigger so we're not mutating the reference
trigger = cloneDeep($selectedAutomation.definition.trigger) trigger = cloneDeep($selectedAutomation.data.definition.trigger)
// get the outputs so we can define the fields // get the outputs so we can define the fields
let schema = Object.entries(trigger.schema?.outputs?.properties || {}) let schema = Object.entries(trigger.schema?.outputs?.properties || {})
@ -111,7 +111,10 @@
const testAutomation = async () => { const testAutomation = async () => {
try { try {
await automationStore.actions.test($selectedAutomation, $memoTestData) await automationStore.actions.test(
$selectedAutomation.data,
$memoTestData
)
$automationStore.showTestPanel = true $automationStore.showTestPanel = true
} catch (error) { } catch (error) {
notifications.error(error) notifications.error(error)
@ -159,7 +162,7 @@
{#if selectedJSON} {#if selectedJSON}
<div class="text-area-container"> <div class="text-area-container">
<TextArea <TextArea
value={JSON.stringify($selectedAutomation.testData, null, 2)} value={JSON.stringify($selectedAutomation.data.testData, null, 2)}
error={failedParse} error={failedParse}
on:change={e => parseTestJSON(e)} on:change={e => parseTestJSON(e)}
/> />

View File

@ -3,10 +3,12 @@
import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte" import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte"
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
import { JsonView } from "@zerodevx/svelte-json-view" import { JsonView } from "@zerodevx/svelte-json-view"
import { automationStore } from "stores/builder"
import { AutomationActionStepId } from "@budibase/types"
export let automation export let automation
export let automationBlockRefs = {}
export let testResults export let testResults
export let width = "400px"
let openBlocks = {} let openBlocks = {}
let blocks let blocks
@ -28,21 +30,28 @@
} }
} }
$: filteredResults = prepTestResults(testResults) const getBranchName = (step, id) => {
if (!step || !id) {
return
}
return step.inputs.branches.find(branch => branch.id === id)?.name
}
$: filteredResults = prepTestResults(testResults)
$: { $: {
if (testResults.message) { if (testResults.message) {
blocks = automation?.definition?.trigger const trigger = automation?.definition?.trigger
? [automation.definition.trigger] blocks = trigger ? [trigger] : []
: []
} else if (automation) { } else if (automation) {
blocks = [] const terminatingStep = filteredResults.at(-1)
if (automation.definition.trigger) { const terminatingBlockRef = automationBlockRefs[terminatingStep.id]
blocks.push(automation.definition.trigger) if (terminatingBlockRef) {
const pathSteps = automationStore.actions.getPathSteps(
terminatingBlockRef.pathTo,
automation
)
blocks = [...pathSteps].filter(x => x.stepId !== ActionStepID.LOOP)
} }
blocks = blocks
.concat(automation.definition.steps || [])
.filter(x => x.stepId !== ActionStepID.LOOP)
} else if (filteredResults) { } else if (filteredResults) {
blocks = filteredResults || [] blocks = filteredResults || []
// make sure there is an ID for each block being displayed // make sure there is an ID for each block being displayed
@ -56,10 +65,14 @@
<div class="container"> <div class="container">
{#each blocks as block, idx} {#each blocks as block, idx}
<div class="block" style={width ? `width: ${width}` : ""}> <div class="block">
{#if block.stepId !== ActionStepID.LOOP} {#if block.stepId !== ActionStepID.LOOP}
<FlowItemHeader <FlowItemHeader
{automation}
enableNaming={false} enableNaming={false}
itemName={block.stepId === AutomationActionStepId.BRANCH
? getBranchName(block, filteredResults?.[idx].outputs?.branchId)
: null}
open={!!openBlocks[block.id]} open={!!openBlocks[block.id]}
on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])} on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
isTrigger={idx === 0} isTrigger={idx === 0}
@ -133,7 +146,10 @@
.container { .container {
padding: 0 30px 30px 30px; padding: 0 30px 30px 30px;
height: 100%; height: 100%;
overflow: auto; overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
} }
.wrap { .wrap {
@ -175,17 +191,17 @@
.block { .block {
display: inline-block; display: inline-block;
width: 400px; height: fit-content;
height: auto;
font-size: 16px; font-size: 16px;
background-color: var(--background); background-color: var(--background);
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px 4px 4px 4px; border-radius: 4px 4px 4px 4px;
min-width: 425px;
} }
.separator { .separator {
width: 1px; width: 1px;
height: 40px; flex: 0 0 40px;
border-left: 1px dashed var(--grey-4); border-left: 1px dashed var(--grey-4);
color: var(--grey-4); color: var(--grey-4);
/* center horizontally */ /* center horizontally */

View File

@ -1,7 +1,7 @@
<script> <script>
import { Icon, Divider } from "@budibase/bbui" import { Icon, Divider } from "@budibase/bbui"
import TestDisplay from "./TestDisplay.svelte" import TestDisplay from "./TestDisplay.svelte"
import { automationStore } from "stores/builder" import { automationStore, selectedAutomation } from "stores/builder"
export let automation export let automation
</script> </script>
@ -9,9 +9,9 @@
<div class="title"> <div class="title">
<div class="title-text"> <div class="title-text">
<Icon name="MultipleCheck" /> <Icon name="MultipleCheck" />
<div style="padding-left: var(--spacing-l); ">Test Details</div> <div>Test Details</div>
</div> </div>
<div style="padding-right: var(--spacing-xl)"> <div>
<Icon <Icon
on:click={async () => { on:click={async () => {
$automationStore.showTestPanel = false $automationStore.showTestPanel = false
@ -24,7 +24,11 @@
<Divider /> <Divider />
<TestDisplay {automation} testResults={$automationStore.testResults} /> <TestDisplay
{automation}
testResults={$automationStore.testResults}
automationBlockRefs={$selectedAutomation.blockRefs}
/>
<style> <style>
.title { .title {
@ -32,7 +36,8 @@
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xs);
padding-left: var(--spacing-xl); padding: 0px 30px;
padding-top: var(--spacing-l);
justify-content: space-between; justify-content: space-between;
} }
@ -40,7 +45,7 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
padding-top: var(--spacing-s); gap: var(--spacing-l);
} }
.title :global(h1) { .title :global(h1) {

View File

@ -113,7 +113,7 @@
? "var(--spectrum-global-color-gray-600)" ? "var(--spectrum-global-color-gray-600)"
: "var(--spectrum-global-color-gray-900)"} : "var(--spectrum-global-color-gray-900)"}
text={automation.name} text={automation.name}
selected={automation._id === $selectedAutomation?._id} selected={automation._id === $selectedAutomation?.data?._id}
hovering={automation._id === $contextMenuStore.id} hovering={automation._id === $contextMenuStore.id}
on:click={() => automationStore.actions.select(automation._id)} on:click={() => automationStore.actions.select(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]} selectedBy={$userSelectedResourceMap[automation._id]}

View File

@ -21,8 +21,8 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation, tables } from "stores/builder" import { automationStore, tables } from "stores/builder"
import { environment, licensing } from "stores/portal" import { environment } from "stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte" import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import { import {
BindingSidePanel, BindingSidePanel,
@ -46,10 +46,7 @@
} from "components/common/CodeEditor" } from "components/common/CodeEditor"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { QueryUtils, Utils, search, memo } from "@budibase/frontend-core" import { QueryUtils, Utils, search, memo } from "@budibase/frontend-core"
import { import { getSchemaForDatasourcePlus } from "dataBinding"
getSchemaForDatasourcePlus,
getEnvironmentBindings,
} from "dataBinding"
import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte" import { onMount } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"
@ -60,18 +57,18 @@
AutomationActionStepId, AutomationActionStepId,
AutomationCustomIOType, AutomationCustomIOType,
} from "@budibase/types" } from "@budibase/types"
import { FIELDS } from "constants/backend"
import PropField from "./PropField.svelte" import PropField from "./PropField.svelte"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
export let automation
export let block export let block
export let testData export let testData
export let schemaProperties export let schemaProperties
export let isTestModal = false export let isTestModal = false
export let bindings = []
// Stop unnecessary rendering // Stop unnecessary rendering
const memoBlock = memo(block) const memoBlock = memo(block)
const memoEnvVariables = memo($environment.variables)
const rowTriggers = [ const rowTriggers = [
TriggerStepID.ROW_UPDATED, TriggerStepID.ROW_UPDATED,
@ -94,7 +91,6 @@
let insertAtPos, getCaretPosition let insertAtPos, getCaretPosition
let stepLayouts = {} let stepLayouts = {}
$: memoEnvVariables.set($environment.variables)
$: memoBlock.set(block) $: memoBlock.set(block)
$: filters = lookForFilters(schemaProperties) $: filters = lookForFilters(schemaProperties)
@ -107,13 +103,6 @@
$: tempFilters = cloneDeep(filters) $: tempFilters = cloneDeep(filters)
$: stepId = $memoBlock.stepId $: stepId = $memoBlock.stepId
$: automationBindings = getAvailableBindings(
$memoBlock,
$selectedAutomation?.definition
)
$: environmentBindings = buildEnvironmentBindings($memoEnvVariables)
$: bindings = [...automationBindings, ...environmentBindings]
$: getInputData(testData, $memoBlock.inputs) $: getInputData(testData, $memoBlock.inputs)
$: tableId = inputData ? inputData.tableId : null $: tableId = inputData ? inputData.tableId : null
$: table = tableId $: table = tableId
@ -146,21 +135,6 @@
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: [] : []
const buildEnvironmentBindings = () => {
if ($licensing.environmentVariablesEnabled) {
return getEnvironmentBindings().map(binding => {
return {
...binding,
display: {
...binding.display,
rank: 98,
},
}
})
}
return []
}
const getInputData = (testData, blockInputs) => { const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity // Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs) let newInputData = testData || cloneDeep(blockInputs)
@ -437,7 +411,7 @@
if ( if (
Object.hasOwn(update, "tableId") && Object.hasOwn(update, "tableId") &&
$selectedAutomation.testData?.row?.tableId !== update.tableId automation.testData?.row?.tableId !== update.tableId
) { ) {
const reqSchema = getSchemaForDatasourcePlus(update.tableId, { const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
searchableSchema: true, searchableSchema: true,
@ -566,7 +540,7 @@
...newTestData, ...newTestData,
body: { body: {
...update, ...update,
...$selectedAutomation.testData?.body, ...automation.testData?.body,
}, },
} }
} }
@ -574,6 +548,7 @@
...newTestData, ...newTestData,
...request, ...request,
} }
await automationStore.actions.addTestDataToAutomation(newTestData) await automationStore.actions.addTestDataToAutomation(newTestData)
} else { } else {
const data = { schema, ...request } const data = { schema, ...request }
@ -585,201 +560,6 @@
} }
}) })
function getAvailableBindings(block, automation) {
if (!block || !automation) {
return []
}
// Find previous steps to the selected one
let allSteps = [...automation.steps]
if (automation.trigger) {
allSteps = [automation.trigger, ...allSteps]
}
let blockIdx = allSteps.findIndex(step => step.id === block.id)
// Extract all outputs from all previous steps as available bindingsx§x
let bindings = []
let loopBlockCount = 0
const addBinding = (name, value, icon, idx, isLoopBlock, bindingName) => {
if (!name) return
const runtimeBinding = determineRuntimeBinding(
name,
idx,
isLoopBlock,
bindingName
)
const categoryName = determineCategoryName(idx, isLoopBlock, bindingName)
bindings.push(
createBindingObject(
name,
value,
icon,
idx,
loopBlockCount,
isLoopBlock,
runtimeBinding,
categoryName,
bindingName
)
)
}
const determineRuntimeBinding = (name, idx, isLoopBlock, bindingName) => {
let runtimeName
/* Begin special cases for generating custom schemas based on triggers */
if (
idx === 0 &&
automation.trigger?.event === AutomationEventType.APP_TRIGGER
) {
return `trigger.fields.${name}`
}
if (
idx === 0 &&
(automation.trigger?.event === AutomationEventType.ROW_UPDATE ||
automation.trigger?.event === AutomationEventType.ROW_SAVE)
) {
let noRowKeywordBindings = ["id", "revision", "oldRow"]
if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}`
}
/* End special cases for generating custom schemas based on triggers */
let hasUserDefinedName = automation.stepNames?.[allSteps[idx]?.id]
if (isLoopBlock) {
runtimeName = `loop.${name}`
} else if (idx === 0) {
runtimeName = `trigger.${name}`
} else if (block.name.startsWith("JS")) {
runtimeName = hasUserDefinedName
? `stepsByName["${bindingName}"].${name}`
: `steps["${idx - loopBlockCount}"].${name}`
} else {
runtimeName = hasUserDefinedName
? `stepsByName.${bindingName}.${name}`
: `steps.${idx - loopBlockCount}.${name}`
}
return runtimeName
}
const determineCategoryName = (idx, isLoopBlock, bindingName) => {
if (idx === 0) return "Trigger outputs"
if (isLoopBlock) return "Loop Outputs"
return bindingName
? `${bindingName} outputs`
: `Step ${idx - loopBlockCount} outputs`
}
const createBindingObject = (
name,
value,
icon,
idx,
loopBlockCount,
isLoopBlock,
runtimeBinding,
categoryName,
bindingName
) => {
const field = Object.values(FIELDS).find(
field => field.type === value.type && field.subtype === value.subtype
)
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,
},
}
}
for (let idx = 0; idx < blockIdx; idx++) {
let wasLoopBlock = allSteps[idx - 1]?.stepId === ActionStepID.LOOP
let isLoopBlock =
allSteps[idx]?.stepId === ActionStepID.LOOP &&
allSteps.some(x => x.blockToLoop === block.id)
let schema = cloneDeep(allSteps[idx]?.schema?.outputs?.properties) ?? {}
if (allSteps[idx]?.name.includes("Looping")) {
isLoopBlock = true
loopBlockCount++
}
let bindingName =
automation.stepNames?.[allSteps[idx].id] || allSteps[idx].name
if (isLoopBlock) {
schema = {
currentItem: {
type: "string",
description: "the item currently being executed",
},
}
}
if (
idx === 0 &&
automation.trigger?.event === AutomationEventType.APP_TRIGGER
) {
schema = Object.fromEntries(
Object.keys(automation.trigger.inputs.fields || []).map(key => [
key,
{ type: automation.trigger.inputs.fields[key] },
])
)
}
if (
(idx === 0 &&
automation.trigger.event === AutomationEventType.ROW_UPDATE) ||
(idx === 0 && automation.trigger.event === AutomationEventType.ROW_SAVE)
) {
let table = $tables.list.find(
table => table._id === automation.trigger.inputs.tableId
)
// We want to generate our own schema for the bindings from the table schema itself
for (const key in table?.schema) {
schema[key] = {
type: table.schema[key].type,
subtype: table.schema[key].subtype,
}
}
// remove the original binding
delete schema.row
}
let icon =
idx === 0
? automation.trigger.icon
: isLoopBlock
? "Reuse"
: allSteps[idx].icon
if (wasLoopBlock) {
schema = cloneDeep(allSteps[idx - 1]?.schema?.outputs?.properties)
}
Object.entries(schema).forEach(([name, value]) => {
addBinding(name, value, icon, idx, isLoopBlock, bindingName)
})
}
if (
allSteps[blockIdx - 1]?.stepId !== ActionStepID.LOOP &&
allSteps
.slice(0, blockIdx)
.some(step => step.stepId === ActionStepID.LOOP)
) {
bindings = bindings.filter(x => !x.readableBinding.includes("loop"))
}
return bindings
}
function lookForFilters(properties) { function lookForFilters(properties) {
if (!properties) { if (!properties) {
return [] return []
@ -912,7 +692,7 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<div class:field-width={shouldRenderField(value)}> <div>
{#if value.type === "string" && value.enum && canShowField(value)} {#if value.type === "string" && value.enum && canShowField(value)}
<Select <Select
on:change={e => onChange({ [key]: e.detail })} on:change={e => onChange({ [key]: e.detail })}
@ -1208,8 +988,9 @@
align-items: center; align-items: center;
gap: var(--spacing-s); gap: var(--spacing-s);
} }
.field-width {
width: 320px; .label-container :global(label) {
white-space: unset;
} }
.step-fields { .step-fields {
@ -1221,12 +1002,9 @@
} }
.block-field { .block-field {
display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: row; display: grid;
align-items: center; grid-template-columns: 1fr 320px;
gap: 10px;
flex: 1;
} }
.attachment-field-width { .attachment-field-width {

View File

@ -29,7 +29,7 @@
$: filteredAutomations = $automationStore.automations.filter( $: filteredAutomations = $automationStore.automations.filter(
automation => automation =>
automation.definition.trigger.stepId === TriggerStepID.APP && automation.definition.trigger.stepId === TriggerStepID.APP &&
automation._id !== $selectedAutomation._id automation._id !== $selectedAutomation.data._id
) )
</script> </script>

View File

@ -11,7 +11,7 @@
let schemaURL let schemaURL
let propCount = 0 let propCount = 0
$: automation = $selectedAutomation $: automation = $selectedAutomation?.data
onMount(async () => { onMount(async () => {
if (!automation?.definition?.trigger?.inputs.schemaUrl) { if (!automation?.definition?.trigger?.inputs.schemaUrl) {

View File

@ -1,9 +1,10 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon, ActionButton } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { isBuilderInputFocused } from "helpers" import { isBuilderInputFocused } from "helpers"
export let store export let store
export let showButtonGroup = false
const handleKeyPress = e => { const handleKeyPress = e => {
if (!(e.ctrlKey || e.metaKey)) { if (!(e.ctrlKey || e.metaKey)) {
@ -32,19 +33,36 @@
}) })
</script> </script>
<div class="undo-redo"> <div class="undo-redo" class:buttons={showButtonGroup}>
<Icon {#if showButtonGroup}
name="Undo" <div class="group">
hoverable <ActionButton
on:click={store.undo} icon="Undo"
disabled={!$store.canUndo} quiet
/> on:click={store.undo}
<Icon disabled={!$store.canUndo}
name="Redo" />
hoverable <ActionButton
on:click={store.redo} icon="Redo"
disabled={!$store.canRedo} quiet
/> on:click={store.redo}
disabled={!$store.canRedo}
/>
</div>
{:else}
<Icon
name="Undo"
hoverable
on:click={store.undo}
disabled={!$store.canUndo}
/>
<Icon
name="Redo"
hoverable
on:click={store.redo}
disabled={!$store.canRedo}
/>
{/if}
</div> </div>
<style> <style>
@ -60,4 +78,36 @@
.undo-redo :global(svg) { .undo-redo :global(svg) {
padding: 6px; padding: 6px;
} }
.undo-redo.buttons :global(svg) {
padding: 0px;
}
.undo-redo.buttons {
padding-right: 0px;
}
.group {
border-radius: 4px;
display: flex;
flex-direction: row;
}
.group :global(> *:not(:first-child)) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 2px solid var(--spectrum-global-color-gray-300);
}
.group :global(> *:not(:last-child)) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.group :global(.spectrum-Button),
.group :global(.spectrum-ActionButton) {
background: var(--spectrum-global-color-gray-200) !important;
}
.group :global(.spectrum-Button:hover),
.group :global(.spectrum-ActionButton:hover) {
background: var(--spectrum-global-color-gray-300) !important;
}
</style> </style>

View File

@ -12,8 +12,10 @@
export let bindings = [] export let bindings = []
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
export let allowBindings = true export let allowBindings = true
export let allowOnEmpty
export let datasource export let datasource
export let showFilterEmptyDropdown export let builderType
export let docsURL
</script> </script>
<CoreFilterBuilder <CoreFilterBuilder
@ -26,7 +28,9 @@
{schemaFields} {schemaFields}
{datasource} {datasource}
{allowBindings} {allowBindings}
{showFilterEmptyDropdown}
{bindings} {bindings}
{allowOnEmpty}
{builderType}
{docsURL}
on:change on:change
/> />

View File

@ -614,6 +614,40 @@ const getDeviceBindings = () => {
return bindings return bindings
} }
export const getSettingBindings = () => {
let bindings = []
const safeSetting = makePropSafe("settings")
bindings = [
{
type: "context",
runtimeBinding: `${safeSetting}.${makePropSafe("url")}`,
readableBinding: `Settings.url`,
category: "Settings",
icon: "Settings",
display: { type: "string", name: "url" },
},
{
type: "context",
runtimeBinding: `${safeSetting}.${makePropSafe("logo")}`,
readableBinding: `Settings.logo`,
category: "Settings",
icon: "Settings",
display: { type: "string", name: "logo" },
},
{
type: "context",
runtimeBinding: `${safeSetting}.${makePropSafe("company")}`,
readableBinding: `Settings.company`,
category: "Settings",
icon: "Settings",
display: { type: "string", name: "company" },
},
]
return bindings
}
/** /**
* Gets all selected rows bindings for tables in the current asset. * Gets all selected rows bindings for tables in the current asset.
* TODO: remove in future because we don't need a separate store for this * TODO: remove in future because we don't need a separate store for this
@ -1469,3 +1503,31 @@ export const updateReferencesInObject = ({
} }
} }
} }
// Migrate references
// Switch all bindings to reference their ids
export const migrateReferencesInObject = ({ obj, label = "steps", steps }) => {
const stepIndexRegex = new RegExp(`{{\\s*${label}\\.(\\d+)\\.`, "g")
const updateActionStep = (str, index, replaceWith) =>
str.replace(`{{ ${label}.${index}.`, `{{ ${label}.${replaceWith}.`)
for (const key in obj) {
if (typeof obj[key] === "string") {
let matches
while ((matches = stepIndexRegex.exec(obj[key])) !== null) {
const referencedStep = parseInt(matches[1])
obj[key] = updateActionStep(
obj[key],
referencedStep,
steps[referencedStep]?.id
)
}
} else if (typeof obj[key] === "object" && obj[key] !== null) {
migrateReferencesInObject({
obj: obj[key],
steps,
})
}
}
}

View File

@ -13,7 +13,7 @@
selectedAutomation, selectedAutomation,
} from "stores/builder" } from "stores/builder"
$: automationId = $selectedAutomation?._id $: automationId = $selectedAutomation?.data?._id
$: builderStore.selectResource(automationId) $: builderStore.selectResource(automationId)
// Keep URL and state in sync for selected screen ID // Keep URL and state in sync for selected screen ID
@ -68,7 +68,7 @@
{#if $automationStore.showTestPanel} {#if $automationStore.showTestPanel}
<div class="setup"> <div class="setup">
<TestPanel automation={$selectedAutomation} /> <TestPanel automation={$selectedAutomation.data} />
</div> </div>
{/if} {/if}
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

@ -56,7 +56,7 @@
{/if} {/if}
{#key history} {#key history}
<div class="history"> <div class="history">
<TestDisplay testResults={history} width="320px" /> <TestDisplay testResults={history} />
</div> </div>
{/key} {/key}
</Layout> </Layout>
@ -65,6 +65,14 @@
{/if} {/if}
<style> <style>
.history :global(.block) {
min-width: unset;
}
.history :global(> .container) {
max-width: 320px;
width: 320px;
padding: 0px;
}
.controls { .controls {
display: flex; display: flex;
gap: var(--spacing-s); gap: var(--spacing-s);
@ -76,7 +84,4 @@
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
.history {
margin: 0 -30px;
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,177 @@
<script>
import { Input, Icon, Drawer, Button } from "@budibase/bbui"
import { isJSBinding } from "@budibase/string-templates"
import { createEventDispatcher } from "svelte"
export let filter
export let disabled = false
export let bindings = []
export let panel
export let drawerTitle
export let toReadable
export let toRuntime
const dispatch = createEventDispatcher()
let bindingDrawer
let fieldValue
$: fieldValue = filter?.field
$: readableValue = toReadable ? toReadable(bindings, fieldValue) : fieldValue
$: drawerValue = toDrawerValue(fieldValue)
$: isJS = isJSBinding(fieldValue)
const drawerOnChange = e => {
drawerValue = e.detail
}
const onChange = e => {
fieldValue = e.detail
dispatch("change", {
field: toRuntime ? toRuntime(bindings, fieldValue) : fieldValue,
})
}
const onConfirmBinding = () => {
dispatch("change", {
field: toRuntime ? toRuntime(bindings, drawerValue) : drawerValue,
})
}
/**
* Converts arrays into strings. The CodeEditor expects a string or encoded JS
*
* @param{string} fieldValue
*/
const toDrawerValue = fieldValue => {
return Array.isArray(fieldValue) ? fieldValue.join(",") : readableValue
}
</script>
<div>
<Drawer
on:drawerHide
on:drawerShow
bind:this={bindingDrawer}
title={drawerTitle || ""}
forceModal
>
<Button
cta
slot="buttons"
on:click={() => {
onConfirmBinding()
bindingDrawer.hide()
}}
>
Confirm
</Button>
<svelte:component
this={panel}
slot="body"
value={drawerValue}
allowJS
allowHelpers
allowHBS
on:change={drawerOnChange}
{bindings}
/>
</Drawer>
<div class="field-wrap" class:bindings={true}>
<div class="field">
<Input
disabled={filter.noValue}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
on:change={onChange}
/>
</div>
<div class="binding-control">
{#if !disabled}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="icon binding"
on:click={() => {
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</div>
</div>
</div>
<style>
.field-wrap {
display: flex;
}
.field {
flex: 1;
}
.field-wrap.bindings .field :global(.spectrum-Form-itemField),
.field-wrap.bindings .field :global(input),
.field-wrap.bindings .field :global(.spectrum-Picker) {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
}
.field-wrap.bindings
.field
:global(.spectrum-InputGroup.spectrum-Datepicker) {
min-width: unset;
border-radius: 0px;
}
.field-wrap.bindings
.field
:global(
.spectrum-InputGroup.spectrum-Datepicker
.spectrum-Textfield-input.spectrum-InputGroup-input
) {
width: 100%;
}
.binding-control .icon {
border: 1px solid
var(
--spectrum-textfield-m-border-color,
var(--spectrum-alias-border-color)
);
border-left: 0px;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m));
}
.binding-control .icon.binding {
color: var(--yellow);
}
.binding-control .icon:hover {
cursor: pointer;
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
color: var(--spectrum-alias-text-color-hover);
}
.binding-control .icon.binding:hover {
color: var(--yellow);
}
</style>

View File

@ -16,6 +16,7 @@
import { QueryUtils, Constants } from "@budibase/frontend-core" import { QueryUtils, Constants } from "@budibase/frontend-core"
import { getContext, createEventDispatcher } from "svelte" import { getContext, createEventDispatcher } from "svelte"
import FilterField from "./FilterField.svelte" import FilterField from "./FilterField.svelte"
import ConditionField from "./ConditionField.svelte"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -33,8 +34,10 @@
export let datasource export let datasource
export let behaviourFilters = false export let behaviourFilters = false
export let allowBindings = false export let allowBindings = false
export let allowOnEmpty = true
export let builderType = "filter"
export let docsURL = "https://docs.budibase.com/docs/searchfilter-data"
// Review
export let bindings export let bindings
export let panel export let panel
export let toReadable export let toReadable
@ -53,6 +56,10 @@
schemaFields = [...schemaFields, { name: "_id", type: "string" }] schemaFields = [...schemaFields, { name: "_id", type: "string" }]
} }
} }
$: prefix =
builderType === "filter"
? "Show data which matches"
: "Run branch when matching"
// We still may need to migrate this even though the backend does it automatically now // We still may need to migrate this even though the backend does it automatically now
// for query definitions. This is because we might be editing saved filter definitions // for query definitions. This is because we might be editing saved filter definitions
@ -103,6 +110,10 @@
} }
const getValidOperatorsForType = filter => { const getValidOperatorsForType = filter => {
if (builderType === "condition") {
return [OperatorOptions.Equals, OperatorOptions.NotEquals]
}
if (!filter?.field && !filter?.name) { if (!filter?.field && !filter?.name) {
return [] return []
} }
@ -222,6 +233,9 @@
} else if (addFilter) { } else if (addFilter) {
targetGroup.filters.push({ targetGroup.filters.push({
valueType: FilterValueType.VALUE, valueType: FilterValueType.VALUE,
...(builderType === "condition"
? { operator: OperatorOptions.Equals.value, type: FieldType.STRING }
: {}),
}) })
} else if (group) { } else if (group) {
editable.groups[groupIdx] = { editable.groups[groupIdx] = {
@ -242,6 +256,11 @@
filters: [ filters: [
{ {
valueType: FilterValueType.VALUE, valueType: FilterValueType.VALUE,
...(builderType === "condition"
? {
operator: OperatorOptions.Equals.value,
}
: {}),
}, },
], ],
}) })
@ -271,7 +290,7 @@
{#if editableFilters?.groups?.length} {#if editableFilters?.groups?.length}
<div class="global-filter-header"> <div class="global-filter-header">
<span>Show data which matches</span> <span>{prefix}</span>
<span class="operator-picker"> <span class="operator-picker">
<Select <Select
value={editableFilters?.logicalOperator} value={editableFilters?.logicalOperator}
@ -286,7 +305,7 @@
placeholder={false} placeholder={false}
/> />
</span> </span>
<span>of the following filter groups:</span> <span>of the following {builderType} groups:</span>
</div> </div>
{/if} {/if}
{#if editableFilters?.groups?.length} {#if editableFilters?.groups?.length}
@ -315,7 +334,7 @@
placeholder={false} placeholder={false}
/> />
</span> </span>
<span>of the following filters are matched:</span> <span>of the following {builderType}s are matched:</span>
</div> </div>
<div class="group-actions"> <div class="group-actions">
<Icon <Icon
@ -346,20 +365,38 @@
<div class="filters"> <div class="filters">
{#each group.filters as filter, filterIdx} {#each group.filters as filter, filterIdx}
<div class="filter"> <div class="filter">
<Select {#if builderType === "filter"}
value={filter.field} <Select
options={fieldOptions} value={filter.field}
on:change={e => { options={fieldOptions}
const updated = { ...filter, field: e.detail } on:change={e => {
onFieldChange(updated) const updated = { ...filter, field: e.detail }
onFilterFieldUpdate(updated, groupIdx, filterIdx) onFieldChange(updated)
}} onFilterFieldUpdate(updated, groupIdx, filterIdx)
placeholder="Column" }}
/> placeholder="Column"
/>
{:else}
<ConditionField
placeholder="Value"
{filter}
drawerTitle={"Edit Binding"}
{bindings}
{panel}
{toReadable}
{toRuntime}
on:change={e => {
const updated = {
...filter,
field: e.detail.field,
}
onFilterFieldUpdate(updated, groupIdx, filterIdx)
}}
/>
{/if}
<Select <Select
value={filter.operator} value={filter.operator}
disabled={!filter.field} disabled={!filter.field && builderType === "filter"}
options={getValidOperatorsForType(filter)} options={getValidOperatorsForType(filter)}
on:change={e => { on:change={e => {
const updated = { ...filter, operator: e.detail } const updated = { ...filter, operator: e.detail }
@ -368,11 +405,18 @@
}} }}
placeholder={false} placeholder={false}
/> />
<FilterField <FilterField
placeholder="Value" placeholder="Value"
drawerTitle={builderType === "condition"
? "Edit binding"
: null}
{allowBindings} {allowBindings}
{filter} filter={{
...filter,
...(builderType === "condition"
? { type: FieldType.STRING }
: {}),
}}
{schemaFields} {schemaFields}
{bindings} {bindings}
{panel} {panel}
@ -408,7 +452,7 @@
<div class="filters-footer"> <div class="filters-footer">
<Layout noPadding> <Layout noPadding>
{#if behaviourFilters && editableFilters?.groups?.length} {#if behaviourFilters && allowOnEmpty && editableFilters?.groups?.length}
<div class="empty-filter"> <div class="empty-filter">
<span>Return</span> <span>Return</span>
<span class="empty-filter-picker"> <span class="empty-filter-picker">
@ -425,7 +469,7 @@
placeholder={false} placeholder={false}
/> />
</span> </span>
<span>when all filters are empty</span> <span>when all {builderType}s are empty</span>
</div> </div>
{/if} {/if}
<div class="add-group"> <div class="add-group">
@ -439,17 +483,16 @@
}) })
}} }}
> >
Add filter group Add {builderType} group
</Button> </Button>
<a {#if docsURL}
href="https://docs.budibase.com/docs/searchfilter-data" <a href={docsURL} target="_blank">
target="_blank" <Icon
> name="HelpOutline"
<Icon color="var(--spectrum-global-color-gray-600)"
name="HelpOutline" />
color="var(--spectrum-global-color-gray-600)" </a>
/> {/if}
</a>
</div> </div>
</Layout> </Layout>
</div> </div>

View File

@ -21,6 +21,7 @@
export let allowBindings = false export let allowBindings = false
export let schemaFields export let schemaFields
export let panel export let panel
export let drawerTitle
export let toReadable export let toReadable
export let toRuntime export let toRuntime
@ -28,7 +29,6 @@
const { OperatorOptions, FilterValueType } = Constants const { OperatorOptions, FilterValueType } = Constants
let bindingDrawer let bindingDrawer
let fieldValue
$: fieldValue = filter?.value $: fieldValue = filter?.value
$: readableValue = toReadable ? toReadable(bindings, fieldValue) : fieldValue $: readableValue = toReadable ? toReadable(bindings, fieldValue) : fieldValue
@ -133,7 +133,7 @@
on:drawerHide on:drawerHide
on:drawerShow on:drawerShow
bind:this={bindingDrawer} bind:this={bindingDrawer}
title={filter.field} title={drawerTitle || filter.field}
forceModal forceModal
> >
<Button <Button
@ -168,7 +168,7 @@
{#if filter.valueType === FilterValueType.BINDING} {#if filter.valueType === FilterValueType.BINDING}
<Input <Input
disabled={filter.noValue} disabled={filter.noValue}
readonly={true} readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue} value={isJS ? "(JavaScript function)" : readableValue}
on:change={onChange} on:change={onChange}
/> />
@ -231,7 +231,7 @@
<div class="binding-control"> <div class="binding-control">
<!-- needs field, operator --> <!-- needs field, operator -->
{#if !disabled && allowBindings && filter.field && !filter.noValue} {#if !disabled && allowBindings && !filter.noValue}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div

View File

@ -278,8 +278,10 @@ export function screenValidator() {
function generateStepSchema(allowStepTypes: string[]) { function generateStepSchema(allowStepTypes: string[]) {
const branchSchema = Joi.object({ const branchSchema = Joi.object({
id: Joi.string().required(),
name: Joi.string().required(), name: Joi.string().required(),
condition: filterObject({ unknown: false }).required().min(1), condition: filterObject({ unknown: false }).required().min(1),
conditionUI: Joi.object(),
}) })
return Joi.object({ return Joi.object({

View File

@ -39,9 +39,17 @@ export const definition: AutomationStepDefinition = {
branchName: { branchName: {
type: AutomationIOType.STRING, type: AutomationIOType.STRING,
}, },
result: { status: {
type: AutomationIOType.STRING,
description: "Branch result",
},
branchId: {
type: AutomationIOType.STRING,
description: "Branch ID",
},
success: {
type: AutomationIOType.BOOLEAN, type: AutomationIOType.BOOLEAN,
description: "Whether the condition was met", description: "Branch success",
}, },
}, },
required: ["output"], required: ["output"],

View File

@ -48,7 +48,9 @@ describe("Branching automations", () => {
{ stepId: branch1LogId } { stepId: branch1LogId }
), ),
condition: { condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: true }, equal: {
[`{{ literal steps.${firstLogId}.success }}`]: true,
},
}, },
}, },
branch2: { branch2: {
@ -58,19 +60,21 @@ describe("Branching automations", () => {
{ stepId: branch2LogId } { stepId: branch2LogId }
), ),
condition: { condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: false }, equal: {
[`{{ literal steps.${firstLogId}.success }}`]: false,
},
}, },
}, },
}), }),
condition: { condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: true }, equal: { [`{{ literal steps.${firstLogId}.success }}`]: true },
}, },
}, },
topLevelBranch2: { topLevelBranch2: {
steps: stepBuilder => steps: stepBuilder =>
stepBuilder.serverLog({ text: "Branch 2" }, { stepId: branch2Id }), stepBuilder.serverLog({ text: "Branch 2" }, { stepId: branch2Id }),
condition: { condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: false }, equal: { [`{{ literal steps.${firstLogId}.success }}`]: false },
}, },
}, },
}) })
@ -217,4 +221,90 @@ describe("Branching automations", () => {
) )
expect(results.steps[2]).toBeUndefined() expect(results.steps[2]).toBeUndefined()
}) })
it("evaluate multiple conditions", async () => {
const builder = createAutomationBuilder({
name: "evaluate multiple conditions",
})
const results = await builder
.appAction({ fields: { test_trigger: true } })
.serverLog({ text: "Starting automation" }, { stepId: "aN6znRYHG" })
.branch({
specialBranch: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Special user" }),
condition: {
$or: {
conditions: [
{
equal: {
'{{ js "cmV0dXJuICQoInRyaWdnZXIuZmllbGRzLnRlc3RfdHJpZ2dlciIp" }}':
"{{ literal trigger.fields.test_trigger}}",
},
},
],
},
},
},
regularBranch: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Regular user" }),
condition: {
$and: {
conditions: [
{
equal: { "{{ literal trigger.fields.test_trigger}}": "blah" },
},
{
equal: { "{{ literal trigger.fields.test_trigger}}": "123" },
},
],
},
},
},
})
.run()
expect(results.steps[2].outputs.message).toContain("Special user")
})
it("evaluate multiple conditions with interpolated text", async () => {
const builder = createAutomationBuilder({
name: "evaluate multiple conditions",
})
const results = await builder
.appAction({ fields: { test_trigger: true } })
.serverLog({ text: "Starting automation" }, { stepId: "aN6znRYHG" })
.branch({
specialBranch: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Special user" }),
condition: {
$or: {
conditions: [
{
equal: {
"{{ trigger.fields.test_trigger }} 5":
"{{ trigger.fields.test_trigger }} 5",
},
},
],
},
},
},
regularBranch: {
steps: stepBuilder => stepBuilder.serverLog({ text: "Regular user" }),
condition: {
$and: {
conditions: [
{ equal: { "{{ trigger.fields.test_trigger }}": "blah" } },
{ equal: { "{{ trigger.fields.test_trigger }}": "123" } },
],
},
},
},
})
.run()
expect(results.steps[2].outputs.message).toContain("Special user")
})
}) })

View File

@ -88,12 +88,13 @@ class BaseStepBuilder {
Object.entries(branchConfig).forEach(([key, branch]) => { Object.entries(branchConfig).forEach(([key, branch]) => {
const stepBuilder = new StepBuilder() const stepBuilder = new StepBuilder()
branch.steps(stepBuilder) branch.steps(stepBuilder)
let branchId = uuidv4()
branchStepInputs.branches.push({ branchStepInputs.branches.push({
name: key, name: key,
condition: branch.condition, condition: branch.condition,
id: branchId,
}) })
branchStepInputs.children![key] = stepBuilder.build() branchStepInputs.children![branchId] = stepBuilder.build()
}) })
const branchStep: AutomationStep = { const branchStep: AutomationStep = {
...definition, ...definition,

View File

@ -8,7 +8,7 @@ import {
import * as actions from "../automations/actions" import * as actions from "../automations/actions"
import * as automationUtils from "../automations/automationUtils" import * as automationUtils from "../automations/automationUtils"
import { replaceFakeBindings } from "../automations/loopUtils" import { replaceFakeBindings } from "../automations/loopUtils"
import { dataFilters, helpers } from "@budibase/shared-core" import { dataFilters, helpers, utils } from "@budibase/shared-core"
import { default as AutomationEmitter } from "../events/AutomationEmitter" import { default as AutomationEmitter } from "../events/AutomationEmitter"
import { generateAutomationMetadataID, isProdAppID } from "../db/utils" import { generateAutomationMetadataID, isProdAppID } from "../db/utils"
import { definitions as triggerDefs } from "../automations/triggerInfo" import { definitions as triggerDefs } from "../automations/triggerInfo"
@ -23,15 +23,21 @@ import {
AutomationStatus, AutomationStatus,
AutomationStep, AutomationStep,
AutomationStepStatus, AutomationStepStatus,
BranchSearchFilters,
BranchStep, BranchStep,
isLogicalSearchOperator,
LoopStep, LoopStep,
SearchFilters,
UserBindings, UserBindings,
isBasicSearchOperator,
} from "@budibase/types" } from "@budibase/types"
import { AutomationContext, TriggerOutput } from "../definitions/automations" import { AutomationContext, TriggerOutput } from "../definitions/automations"
import { WorkerCallback } from "./definitions" import { WorkerCallback } from "./definitions"
import { context, logging, configs } from "@budibase/backend-core" import { context, logging, configs } from "@budibase/backend-core"
import { processObject, processStringSync } from "@budibase/string-templates" import {
findHBSBlocks,
processObject,
processStringSync,
} from "@budibase/string-templates"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { performance } from "perf_hooks" import { performance } from "perf_hooks"
import * as sdkUtils from "../sdk/utils" import * as sdkUtils from "../sdk/utils"
@ -324,7 +330,10 @@ class Orchestrator {
) )
} }
private async executeSteps(steps: AutomationStep[]): Promise<void> { private async executeSteps(
steps: AutomationStep[],
pathIdx?: number
): Promise<void> {
return tracer.trace( return tracer.trace(
"Orchestrator.executeSteps", "Orchestrator.executeSteps",
{ resource: "automation" }, { resource: "automation" },
@ -340,10 +349,17 @@ class Orchestrator {
while (stepIndex < steps.length) { while (stepIndex < steps.length) {
const step = steps[stepIndex] const step = steps[stepIndex]
if (step.stepId === AutomationActionStepId.BRANCH) { if (step.stepId === AutomationActionStepId.BRANCH) {
await this.executeBranchStep(step) // stepIndex for current step context offset
// pathIdx relating to the full list of steps in the run
await this.executeBranchStep(step, stepIndex + (pathIdx || 0))
stepIndex++ stepIndex++
} else if (step.stepId === AutomationActionStepId.LOOP) { } else if (step.stepId === AutomationActionStepId.LOOP) {
stepIndex = await this.executeLoopStep(step, steps, stepIndex) stepIndex = await this.executeLoopStep(
step,
steps,
stepIndex,
pathIdx
)
} else { } else {
if (!this.stopped) { if (!this.stopped) {
await this.executeStep(step) await this.executeStep(step)
@ -366,11 +382,14 @@ class Orchestrator {
private async executeLoopStep( private async executeLoopStep(
loopStep: LoopStep, loopStep: LoopStep,
steps: AutomationStep[], steps: AutomationStep[],
currentIndex: number stepIdx: number,
pathIdx?: number
): Promise<number> { ): Promise<number> {
await processObject(loopStep.inputs, this.context) await processObject(loopStep.inputs, this.context)
const iterations = getLoopIterations(loopStep) const iterations = getLoopIterations(loopStep)
let stepToLoopIndex = currentIndex + 1 let stepToLoopIndex = stepIdx + 1
let pathStepIdx = (pathIdx || stepIdx) + 1
let iterationCount = 0 let iterationCount = 0
let shouldCleanup = true let shouldCleanup = true
@ -381,7 +400,7 @@ class Orchestrator {
) )
} catch (err) { } catch (err) {
this.updateContextAndOutput( this.updateContextAndOutput(
stepToLoopIndex, pathStepIdx + 1,
steps[stepToLoopIndex], steps[stepToLoopIndex],
{}, {},
{ {
@ -401,7 +420,7 @@ class Orchestrator {
(loopStep.inputs.iterations && loopStepIndex === maxIterations) (loopStep.inputs.iterations && loopStepIndex === maxIterations)
) { ) {
this.updateContextAndOutput( this.updateContextAndOutput(
stepToLoopIndex, pathStepIdx + 1,
steps[stepToLoopIndex], steps[stepToLoopIndex],
{ {
items: this.loopStepOutputs, items: this.loopStepOutputs,
@ -428,7 +447,7 @@ class Orchestrator {
if (isFailure) { if (isFailure) {
this.updateContextAndOutput( this.updateContextAndOutput(
loopStepIndex, pathStepIdx + 1,
steps[stepToLoopIndex], steps[stepToLoopIndex],
{ {
items: this.loopStepOutputs, items: this.loopStepOutputs,
@ -443,11 +462,11 @@ class Orchestrator {
break break
} }
this.context.steps[currentIndex + 1] = { this.context.steps[pathStepIdx] = {
currentItem: this.getCurrentLoopItem(loopStep, loopStepIndex), currentItem: this.getCurrentLoopItem(loopStep, loopStepIndex),
} }
stepToLoopIndex = currentIndex + 1 stepToLoopIndex = stepIdx + 1
await this.executeStep(steps[stepToLoopIndex], stepToLoopIndex) await this.executeStep(steps[stepToLoopIndex], stepToLoopIndex)
iterationCount++ iterationCount++
@ -467,7 +486,7 @@ class Orchestrator {
} }
// Loop Step clean up // Loop Step clean up
this.executionOutput.steps.splice(currentIndex + 1, 0, { this.executionOutput.steps.splice(pathStepIdx, 0, {
id: steps[stepToLoopIndex].id, id: steps[stepToLoopIndex].id,
stepId: steps[stepToLoopIndex].stepId, stepId: steps[stepToLoopIndex].stepId,
outputs: tempOutput, outputs: tempOutput,
@ -487,14 +506,19 @@ class Orchestrator {
return stepToLoopIndex + 1 return stepToLoopIndex + 1
} }
private async executeBranchStep(branchStep: BranchStep): Promise<void> { private async executeBranchStep(
branchStep: BranchStep,
pathIdx?: number
): Promise<void> {
const { branches, children } = branchStep.inputs const { branches, children } = branchStep.inputs
for (const branch of branches) { for (const branch of branches) {
const condition = await this.evaluateBranchCondition(branch.condition) const condition = await this.evaluateBranchCondition(branch.condition)
if (condition) { if (condition) {
const branchStatus = { const branchStatus = {
branchName: branch.name,
status: `${branch.name} branch taken`, status: `${branch.name} branch taken`,
branchId: `${branch.id}`,
success: true, success: true,
} }
@ -505,9 +529,11 @@ class Orchestrator {
branchStatus branchStatus
) )
this.context.steps[this.context.steps.length] = branchStatus this.context.steps[this.context.steps.length] = branchStatus
this.context.stepsById[branchStep.id] = branchStatus
const branchSteps = children?.[branch.name] || [] const branchSteps = children?.[branch.id] || []
await this.executeSteps(branchSteps) // A final +1 to accomodate the branch step itself
await this.executeSteps(branchSteps, (pathIdx || 0) + 1)
return return
} }
} }
@ -525,26 +551,51 @@ class Orchestrator {
} }
private async evaluateBranchCondition( private async evaluateBranchCondition(
conditions: SearchFilters conditions: BranchSearchFilters
): Promise<boolean> { ): Promise<boolean> {
const toFilter: Record<string, any> = {} const toFilter: Record<string, any> = {}
const processedConditions = dataFilters.recurseSearchFilters( const recurseSearchFilters = (
conditions, filters: BranchSearchFilters
filter => { ): BranchSearchFilters => {
Object.entries(filter).forEach(([_, value]) => { for (const filterKey of Object.keys(
Object.entries(value).forEach(([field, _]) => { filters
const updatedField = field.replace("{{", "{{ literal ") ) as (keyof typeof filters)[]) {
if (!filters[filterKey]) {
continue
}
if (isLogicalSearchOperator(filterKey)) {
filters[filterKey].conditions = filters[filterKey].conditions.map(
condition => recurseSearchFilters(condition)
)
} else if (isBasicSearchOperator(filterKey)) {
for (const [field, value] of Object.entries(filters[filterKey])) {
const fromContext = processStringSync( const fromContext = processStringSync(
updatedField, field,
this.processContext(this.context) this.processContext(this.context)
) )
toFilter[field] = fromContext toFilter[field] = fromContext
})
}) if (typeof value === "string" && findHBSBlocks(value).length > 0) {
return filter const processedVal = processStringSync(
value,
this.processContext(this.context)
)
filters[filterKey][field] = processedVal
}
}
} else {
// We want to types to complain if we extend BranchSearchFilters, but not to throw if the request comes with some extra data. It will just be ignored
utils.unreachable(filterKey, { doNotThrow: true })
}
} }
)
return filters
}
const processedConditions = recurseSearchFilters(conditions)
const result = dataFilters.runQuery([toFilter], processedConditions) const result = dataFilters.runQuery([toFilter], processedConditions)
return result.length > 0 return result.length > 0

View File

@ -142,25 +142,6 @@ export function recurseLogicalOperators(
return filters return filters
} }
export function recurseSearchFilters(
filters: SearchFilters,
processFn: (filter: SearchFilters) => SearchFilters
): SearchFilters {
// Process the current level
filters = processFn(filters)
// Recurse through logical operators
for (const logical of LOGICAL_OPERATORS) {
if (filters[logical]) {
filters[logical]!.conditions = filters[logical]!.conditions.map(
condition => recurseSearchFilters(condition, processFn)
)
}
}
return filters
}
/** /**
* Removes any fields that contain empty strings that would cause inconsistent * Removes any fields that contain empty strings that would cause inconsistent
* behaviour with how backend tables are filtered (no value means no filter). * behaviour with how backend tables are filtered (no value means no filter).

View File

@ -26,9 +26,16 @@ const FILTER_ALLOWED_KEYS: (keyof SearchFilter)[] = [
export function unreachable( export function unreachable(
value: never, value: never,
message = `No such case in exhaustive switch: ${value}` opts?: {
message?: string
doNotThrow?: boolean
}
) { ) {
throw new Error(message) const message = opts?.message || `No such case in exhaustive switch: ${value}`
const doNotThrow = !!opts?.doNotThrow
if (!doNotThrow) {
throw new Error(message)
}
} }
export async function parallelForeach<T>( export async function parallelForeach<T>(

View File

@ -1,5 +1,10 @@
import { SortOrder } from "../../../api" import { SortOrder } from "../../../api"
import { SearchFilters, EmptyFilterOption } from "../../../sdk" import {
SearchFilters,
EmptyFilterOption,
BasicOperator,
LogicalOperator,
} from "../../../sdk"
import { HttpMethod } from "../query" import { HttpMethod } from "../query"
import { Row } from "../row" import { Row } from "../row"
import { LoopStepType, EmailAttachment, AutomationResults } from "./automation" import { LoopStepType, EmailAttachment, AutomationResults } from "./automation"
@ -116,10 +121,19 @@ export type BranchStepInputs = {
} }
export type Branch = { export type Branch = {
id: any
name: string name: string
condition: SearchFilters condition: BranchSearchFilters
} }
export type BranchSearchFilters = Pick<
SearchFilters,
| BasicOperator.EQUAL
| BasicOperator.NOT_EQUAL
| LogicalOperator.AND
| LogicalOperator.OR
>
export type MakeIntegrationInputs = { export type MakeIntegrationInputs = {
url: string url: string
body: any body: any