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:
push:
branches:
- feature/automation-branching-ux
- master
workflow_dispatch:
jobs:

View File

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

View File

@ -3,8 +3,8 @@
import Flowchart from "./FlowChart/FlowChart.svelte"
</script>
{#if $selectedAutomation}
{#key $selectedAutomation._id}
<Flowchart automation={$selectedAutomation} />
{#if $selectedAutomation?.data}
{#key $selectedAutomation.data._id}
<Flowchart automation={$selectedAutomation.data} />
{/key}
{/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,
Tag,
} from "@budibase/bbui"
import { AutomationActionStepId } from "@budibase/types"
import { automationStore, selectedAutomation } from "stores/builder"
import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { checkForCollectStep } from "helpers/utils"
export let blockIdx
export let lastStep
export let block
export let modal
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
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 = [
ActionStepID.COLLECT,
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 = () => {
return {
@ -75,7 +90,7 @@
// Filter out Collect block if not App Action or Webhook
if (
!collectBlockAllowedSteps.includes(
$selectedAutomation.definition.trigger.stepId
$selectedAutomation.data.definition.trigger.stepId
)
) {
delete acc.COLLECT
@ -100,9 +115,14 @@
action.stepId,
action
)
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
await automationStore.actions.addBlockToAutomation(
newBlock,
blockRef ? blockRef.pathTo : block.pathTo
)
modal.hide()
} catch (error) {
console.error(error)
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>
import {
automationStore,
selectedAutomation,
automationHistoryStore,
selectedAutomation,
} from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import FlowItem from "./FlowItem.svelte"
import TestDataModal from "./TestDataModal.svelte"
import { flip } from "svelte/animate"
import { fly } from "svelte/transition"
import { Icon, notifications, Modal, Toggle } from "@budibase/bbui"
import {
Icon,
notifications,
Modal,
Toggle,
Button,
ActionButton,
} from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations"
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 DraggableCanvas from "../DraggableCanvas.svelte"
export let automation
const memoAutomation = memo(automation)
let testDataModal
let confirmDeleteDialog
let scrolling = false
let blockRefs = {}
let treeEle
let draggable
$: blocks = getBlocks(automation).filter(x => x.stepId !== ActionStepID.LOOP)
$: isRowAction = sdk.automations.isRowAction(automation)
// Memo auto - selectedAutomation
$: 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 => {
let blocks = []
@ -34,39 +70,46 @@
const deleteAutomation = async () => {
try {
await automationStore.actions.delete($selectedAutomation)
await automationStore.actions.delete(automation)
} catch (error) {
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>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="header" class:scrolling>
<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 class="controls">
<div
class:disabled={!$selectedAutomation?.definition?.trigger}
<Button
icon={"Play"}
cta
disabled={!automation?.definition?.trigger}
on:click={() => {
testDataModal.show()
}}
class="buttons"
>
<Icon size="M" name="Play" />
<div>Run test</div>
</div>
Run test
</Button>
<div class="buttons">
<Icon disabled={!$automationStore.testResults} size="M" name="Multiple" />
<div
@ -79,36 +122,39 @@
</div>
</div>
{#if !isRowAction}
<div class="setting-spacing">
<div class="toggle-active setting-spacing">
<Toggle
text={automation.disabled ? "Paused" : "Activated"}
on:change={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
disabled={!$selectedAutomation?.definition?.trigger}
disabled={!automation?.definition?.trigger}
value={!automation.disabled}
/>
</div>
{/if}
</div>
</div>
<div class="canvas" on:scroll={handleScroll}>
<div class="content">
{#each blocks as block, idx (block.id)}
<div
class="block"
animate:flip={{ duration: 500 }}
in:fly={{ x: 500, duration: 500 }}
out:fly|local={{ x: 500, duration: 500 }}
>
{#if block.stepId !== ActionStepID.LOOP}
<FlowItem {testDataModal} {block} {idx} />
{/if}
</div>
{/each}
</div>
<div class="root" bind:this={treeEle}>
<DraggableCanvas bind:this={draggable}>
<span class="main-content" slot="content">
{#if Object.keys(blockRefs).length}
{#each blocks as block, idx (block.id)}
<StepNode
step={blocks[idx]}
stepIdx={idx}
isLast={blocks?.length - 1 === idx}
automation={$memoAutomation}
blocks={blockRefs}
/>
{/each}
{/if}
</span>
</DraggableCanvas>
</div>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Automation"
@ -125,32 +171,43 @@
</Modal>
<style>
.canvas {
padding: var(--spacing-l) var(--spacing-xl);
overflow-y: auto;
.toggle-active :global(.spectrum-Switch) {
margin: 0px;
}
.root :global(.main-content) {
display: flex;
flex-direction: column;
align-items: center;
max-height: 100%;
height: 100%;
width: 100%;
}
.header-left {
display: flex;
gap: var(--spacing-l);
}
.header-left :global(div) {
border-right: none;
}
/* Fix for firefox not respecting bottom padding in scrolling containers */
.canvas > *:last-child {
padding-bottom: 40px;
.root {
height: 100%;
width: 100%;
}
.block {
.root :global(.block) {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
.content {
flex-grow: 1;
padding: 23px 23px 80px;
.root :global(.blockSection) {
width: 100%;
box-sizing: border-box;
overflow-x: hidden;
}
.header.scrolling {
@ -166,13 +223,15 @@
align-items: center;
padding-left: var(--spacing-l);
transition: background 130ms ease-out;
flex: 0 0 48px;
flex: 0 0 60px;
padding-right: var(--spacing-xl);
}
.controls {
display: flex;
gap: var(--spacing-xl);
}
.buttons {
display: flex;
justify-content: flex-end;
@ -188,4 +247,33 @@
pointer-events: none;
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>

View File

@ -1,8 +1,8 @@
<script>
import {
automationStore,
selectedAutomation,
permissions,
selectedAutomation,
tables,
} from "stores/builder"
import {
@ -11,54 +11,115 @@
Layout,
Detail,
Modal,
Button,
notifications,
Label,
AbsTooltip,
InlineAlert,
} from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import ActionModal from "./ActionModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
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 blockRef
export let testDataModal
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 actionModal
let open = true
let showLooping = false
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
)
$: automationId = $selectedAutomation?._id
$: isTrigger = block.type === "TRIGGER"
$: steps = $selectedAutomation?.definition?.steps ?? []
$: blockIdx = steps.findIndex(step => step.id === block.id)
$: lastStep = !isTrigger && blockIdx + 1 === steps.length
$: totalBlocks = $selectedAutomation?.definition?.steps.length + 1
$: loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block.id
)
$: automationId = automation?._id
$: isTrigger = block.type === AutomationStepType.TRIGGER
$: lastStep = blockRef?.terminating
$: loopBlock = pathSteps.find(x => x.blockToLoop === block.id)
$: isAppAction = block?.stepId === TriggerStepID.APP
$: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId)
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation) && {
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation?.data) && {
title: "Automation trigger",
tableName: $tables.list.find(
x => x._id === $selectedAutomation.definition.trigger.inputs?.tableId
x =>
x._id === $selectedAutomation.data?.definition?.trigger?.inputs?.tableId
)?.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) {
if (!role || !automationId) {
return
@ -82,23 +143,13 @@
}
}
async function removeLooping() {
try {
await automationStore.actions.deleteAutomationBlock(loopBlock)
} catch (error) {
notifications.error("Error saving automation")
}
async function deleteStep() {
await automationStore.actions.deleteAutomationBlock(blockRef.pathTo)
}
async function deleteStep() {
try {
if (loopBlock) {
await automationStore.actions.deleteAutomationBlock(loopBlock)
}
await automationStore.actions.deleteAutomationBlock(block, blockIdx)
} catch (error) {
notifications.error("Error saving automation")
}
async function removeLooping() {
let loopBlockRef = $selectedAutomation.blockRefs[blockRef.looped]
await automationStore.actions.deleteAutomationBlock(loopBlockRef.pathTo)
}
async function addLooping() {
@ -109,122 +160,201 @@
loopDefinition
)
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>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}>
{#if loopBlock}
<div class="blockSection">
{#if block.stepId !== "LOOP"}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
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
on:click={() => {
showLooping = !showLooping
bind:this={blockEle}
class="block-content"
class:dragging={$view.dragging && selected}
style={positionStyles}
on:mousedown={e => {
e.stopPropagation()
}}
class="splitHeader"
>
<div class="center-items">
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
{#if draggable}
<div
class="handle"
class:grabbing={selected}
on:mousedown={onHandleMouseDown}
>
<use xlink:href="#spectrum-icon-18-Reuse" />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
<DragHandle />
</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">
<AbsTooltip type="negative" text="Remove looping">
<Icon on:click={removeLooping} hoverable name="DeleteOutline" />
</AbsTooltip>
<div class="blockTitle">
<AbsTooltip type="negative" text="Remove looping">
<Icon
on:click={removeLooping}
hoverable
name="DeleteOutline"
/>
</AbsTooltip>
<div style="margin-left: 10px;" on:click={() => {}}>
<Icon hoverable name={showLooping ? "ChevronDown" : "ChevronUp"} />
</div>
<div style="margin-left: 10px;" on:click={() => {}}>
<Icon
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>
<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}
/>
</Layout>
</div>
<Divider noMargin />
</div>
{#if !collectBlockExists || !lastStep}
<div class="separator" />
{#if $view.dragging}
<DragZone path={blockRef?.pathTo} />
{:else}
<FlowItemActions
{block}
on:branch={() => {
automationStore.actions.branchAutomation(
$selectedAutomation.blockRefs[block.id].pathTo,
automation
)
}}
/>
{/if}
{#if !lastStep}
<div class="separator" />
{/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}
<Modal bind:this={actionModal} width="30%">
<ActionModal modal={actionModal} {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
@ -255,27 +385,73 @@
.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;
border-radius: 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 {
padding: var(--spacing-xl);
}
.separator {
width: 1px;
height: 25px;
border-left: 1px dashed var(--grey-4);
color: var(--grey-4);
/* center horizontally */
align-self: center;
}
.blockTitle {
display: flex;
align-items: center;
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>

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 testResult
export let isTrigger
export let idx
export let addLooping
export let deleteStep
export let enableNaming = true
export let itemName
export let automation
let validRegex = /^[A-Za-z0-9_\s]+$/
let typing = false
let editing = false
const dispatch = createEventDispatcher()
$: stepNames = $selectedAutomation?.definition.stepNames
$: allSteps = $selectedAutomation?.definition.steps || []
$: automationName = stepNames?.[block.id] || block?.name || ""
$: blockRefs = $selectedAutomation?.blockRefs || {}
$: stepNames = automation?.definition.stepNames
$: allSteps = automation?.definition.steps || []
$: automationName = itemName || stepNames?.[block.id] || block?.name || ""
$: automationNameError = getAutomationNameError(automationName)
$: status = updateStatus(testResult)
$: isHeaderTrigger = isTrigger || block.type === "TRIGGER"
$: isBranch = block.stepId === "BRANCH"
$: {
if (!testResult) {
@ -33,12 +37,12 @@
)?.[0]
}
}
$: loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block?.id
)
$: blockRef = blockRefs[block.id]
$: isLooped = blockRef?.looped
async function onSelect(block) {
await automationStore.update(state => {
automationStore.update(state => {
state.selectedBlock = block
return state
})
@ -84,30 +88,18 @@
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 = () => {
editing = true
typing = true
}
const stopEditing = async () => {
const stopEditing = () => {
editing = false
typing = false
if (automationNameError) {
automationName = stepNames[block.id] || block?.name
} else {
await saveName()
dispatch("update", automationName)
}
}
</script>
@ -118,7 +110,6 @@
class:typing={typing && !automationNameError && editing}
class:typing-error={automationNameError && editing}
class="blockSection"
on:click={() => dispatch("toggle")}
>
<div class="splitHeader">
<div class="center-items">
@ -144,16 +135,14 @@
{#if isHeaderTrigger}
<Body size="XS"><b>Trigger</b></Body>
{:else}
<div style="margin-left: 2px;">
<Body size="XS"><b>Step {idx}</b></Body>
</div>
<Body size="XS"><b>{isBranch ? "Branch" : "Step"}</b></Body>
{/if}
{#if enableNaming}
<input
class="input-text"
disabled={!enableNaming}
placeholder="Enter step name"
placeholder={`Enter ${isBranch ? "branch" : "step"} name`}
name="name"
autocomplete="off"
value={automationName}
@ -208,8 +197,9 @@
onSelect(block)
}}
>
<slot name="custom-actions" />
{#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">
<Icon on:click={addLooping} hoverable name="RotateCW" />
</AbsTooltip>
@ -220,6 +210,9 @@
</AbsTooltip>
{/if}
{/if}
{#if !showTestStatus && !isHeaderTrigger}
<span class="action-spacer" />
{/if}
{#if !showTestStatus}
<Icon
on:click={e => {
@ -245,6 +238,9 @@
</div>
<style>
.action-spacer {
border-left: 1px solid var(--spectrum-global-color-gray-300);
}
.status-container {
display: flex;
align-items: center;
@ -298,6 +294,8 @@
font-size: var(--spectrum-alias-font-size-default);
font-family: var(--font-sans);
text-overflow: ellipsis;
padding-left: 0px;
border: 0px;
}
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
*/
const parseTestData = testData => {
const autoTrigger = $selectedAutomation?.definition?.trigger
const autoTrigger = $selectedAutomation.data?.definition?.trigger
const { tableId } = autoTrigger?.inputs || {}
// Ensure the tableId matches the trigger table for row trigger automations
@ -63,12 +63,12 @@
return true
}
const memoTestData = memo(parseTestData($selectedAutomation.testData))
$: memoTestData.set(parseTestData($selectedAutomation.testData))
const memoTestData = memo(parseTestData($selectedAutomation.data.testData))
$: memoTestData.set(parseTestData($selectedAutomation.data.testData))
$: {
// 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
let schema = Object.entries(trigger.schema?.outputs?.properties || {})
@ -111,7 +111,10 @@
const testAutomation = async () => {
try {
await automationStore.actions.test($selectedAutomation, $memoTestData)
await automationStore.actions.test(
$selectedAutomation.data,
$memoTestData
)
$automationStore.showTestPanel = true
} catch (error) {
notifications.error(error)
@ -159,7 +162,7 @@
{#if selectedJSON}
<div class="text-area-container">
<TextArea
value={JSON.stringify($selectedAutomation.testData, null, 2)}
value={JSON.stringify($selectedAutomation.data.testData, null, 2)}
error={failedParse}
on:change={e => parseTestJSON(e)}
/>

View File

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

View File

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

View File

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

View File

@ -21,8 +21,8 @@
} from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation, tables } from "stores/builder"
import { environment, licensing } from "stores/portal"
import { automationStore, tables } from "stores/builder"
import { environment } from "stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import {
BindingSidePanel,
@ -46,10 +46,7 @@
} from "components/common/CodeEditor"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { QueryUtils, Utils, search, memo } from "@budibase/frontend-core"
import {
getSchemaForDatasourcePlus,
getEnvironmentBindings,
} from "dataBinding"
import { getSchemaForDatasourcePlus } from "dataBinding"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte"
import { writable } from "svelte/store"
@ -60,18 +57,18 @@
AutomationActionStepId,
AutomationCustomIOType,
} from "@budibase/types"
import { FIELDS } from "constants/backend"
import PropField from "./PropField.svelte"
import { utils } from "@budibase/shared-core"
export let automation
export let block
export let testData
export let schemaProperties
export let isTestModal = false
export let bindings = []
// Stop unnecessary rendering
const memoBlock = memo(block)
const memoEnvVariables = memo($environment.variables)
const rowTriggers = [
TriggerStepID.ROW_UPDATED,
@ -94,7 +91,6 @@
let insertAtPos, getCaretPosition
let stepLayouts = {}
$: memoEnvVariables.set($environment.variables)
$: memoBlock.set(block)
$: filters = lookForFilters(schemaProperties)
@ -107,13 +103,6 @@
$: tempFilters = cloneDeep(filters)
$: stepId = $memoBlock.stepId
$: automationBindings = getAvailableBindings(
$memoBlock,
$selectedAutomation?.definition
)
$: environmentBindings = buildEnvironmentBindings($memoEnvVariables)
$: bindings = [...automationBindings, ...environmentBindings]
$: getInputData(testData, $memoBlock.inputs)
$: tableId = inputData ? inputData.tableId : null
$: table = tableId
@ -146,21 +135,6 @@
? [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) => {
// Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs)
@ -437,7 +411,7 @@
if (
Object.hasOwn(update, "tableId") &&
$selectedAutomation.testData?.row?.tableId !== update.tableId
automation.testData?.row?.tableId !== update.tableId
) {
const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
searchableSchema: true,
@ -566,7 +540,7 @@
...newTestData,
body: {
...update,
...$selectedAutomation.testData?.body,
...automation.testData?.body,
},
}
}
@ -574,6 +548,7 @@
...newTestData,
...request,
}
await automationStore.actions.addTestDataToAutomation(newTestData)
} else {
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) {
if (!properties) {
return []
@ -912,7 +692,7 @@
{/if}
</div>
{/if}
<div class:field-width={shouldRenderField(value)}>
<div>
{#if value.type === "string" && value.enum && canShowField(value)}
<Select
on:change={e => onChange({ [key]: e.detail })}
@ -1208,8 +988,9 @@
align-items: center;
gap: var(--spacing-s);
}
.field-width {
width: 320px;
.label-container :global(label) {
white-space: unset;
}
.step-fields {
@ -1221,12 +1002,9 @@
}
.block-field {
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
display: grid;
grid-template-columns: 1fr 320px;
}
.attachment-field-width {

View File

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

View File

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

View File

@ -1,9 +1,10 @@
<script>
import { Icon } from "@budibase/bbui"
import { Icon, ActionButton } from "@budibase/bbui"
import { onMount } from "svelte"
import { isBuilderInputFocused } from "helpers"
export let store
export let showButtonGroup = false
const handleKeyPress = e => {
if (!(e.ctrlKey || e.metaKey)) {
@ -32,19 +33,36 @@
})
</script>
<div class="undo-redo">
<Icon
name="Undo"
hoverable
on:click={store.undo}
disabled={!$store.canUndo}
/>
<Icon
name="Redo"
hoverable
on:click={store.redo}
disabled={!$store.canRedo}
/>
<div class="undo-redo" class:buttons={showButtonGroup}>
{#if showButtonGroup}
<div class="group">
<ActionButton
icon="Undo"
quiet
on:click={store.undo}
disabled={!$store.canUndo}
/>
<ActionButton
icon="Redo"
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>
<style>
@ -60,4 +78,36 @@
.undo-redo :global(svg) {
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>

View File

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

View File

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

View File

@ -56,7 +56,7 @@
{/if}
{#key history}
<div class="history">
<TestDisplay testResults={history} width="320px" />
<TestDisplay testResults={history} />
</div>
{/key}
</Layout>
@ -65,6 +65,14 @@
{/if}
<style>
.history :global(.block) {
min-width: unset;
}
.history :global(> .container) {
max-width: 320px;
width: 320px;
padding: 0px;
}
.controls {
display: flex;
gap: var(--spacing-s);
@ -76,7 +84,4 @@
width: 100%;
justify-content: center;
}
.history {
margin: 0 -30px;
}
</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 { getContext, createEventDispatcher } from "svelte"
import FilterField from "./FilterField.svelte"
import ConditionField from "./ConditionField.svelte"
import { utils } from "@budibase/shared-core"
const dispatch = createEventDispatcher()
@ -33,8 +34,10 @@
export let datasource
export let behaviourFilters = 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 panel
export let toReadable
@ -53,6 +56,10 @@
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
// for query definitions. This is because we might be editing saved filter definitions
@ -103,6 +110,10 @@
}
const getValidOperatorsForType = filter => {
if (builderType === "condition") {
return [OperatorOptions.Equals, OperatorOptions.NotEquals]
}
if (!filter?.field && !filter?.name) {
return []
}
@ -222,6 +233,9 @@
} else if (addFilter) {
targetGroup.filters.push({
valueType: FilterValueType.VALUE,
...(builderType === "condition"
? { operator: OperatorOptions.Equals.value, type: FieldType.STRING }
: {}),
})
} else if (group) {
editable.groups[groupIdx] = {
@ -242,6 +256,11 @@
filters: [
{
valueType: FilterValueType.VALUE,
...(builderType === "condition"
? {
operator: OperatorOptions.Equals.value,
}
: {}),
},
],
})
@ -271,7 +290,7 @@
{#if editableFilters?.groups?.length}
<div class="global-filter-header">
<span>Show data which matches</span>
<span>{prefix}</span>
<span class="operator-picker">
<Select
value={editableFilters?.logicalOperator}
@ -286,7 +305,7 @@
placeholder={false}
/>
</span>
<span>of the following filter groups:</span>
<span>of the following {builderType} groups:</span>
</div>
{/if}
{#if editableFilters?.groups?.length}
@ -315,7 +334,7 @@
placeholder={false}
/>
</span>
<span>of the following filters are matched:</span>
<span>of the following {builderType}s are matched:</span>
</div>
<div class="group-actions">
<Icon
@ -346,20 +365,38 @@
<div class="filters">
{#each group.filters as filter, filterIdx}
<div class="filter">
<Select
value={filter.field}
options={fieldOptions}
on:change={e => {
const updated = { ...filter, field: e.detail }
onFieldChange(updated)
onFilterFieldUpdate(updated, groupIdx, filterIdx)
}}
placeholder="Column"
/>
{#if builderType === "filter"}
<Select
value={filter.field}
options={fieldOptions}
on:change={e => {
const updated = { ...filter, field: e.detail }
onFieldChange(updated)
onFilterFieldUpdate(updated, groupIdx, filterIdx)
}}
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
value={filter.operator}
disabled={!filter.field}
disabled={!filter.field && builderType === "filter"}
options={getValidOperatorsForType(filter)}
on:change={e => {
const updated = { ...filter, operator: e.detail }
@ -368,11 +405,18 @@
}}
placeholder={false}
/>
<FilterField
placeholder="Value"
drawerTitle={builderType === "condition"
? "Edit binding"
: null}
{allowBindings}
{filter}
filter={{
...filter,
...(builderType === "condition"
? { type: FieldType.STRING }
: {}),
}}
{schemaFields}
{bindings}
{panel}
@ -408,7 +452,7 @@
<div class="filters-footer">
<Layout noPadding>
{#if behaviourFilters && editableFilters?.groups?.length}
{#if behaviourFilters && allowOnEmpty && editableFilters?.groups?.length}
<div class="empty-filter">
<span>Return</span>
<span class="empty-filter-picker">
@ -425,7 +469,7 @@
placeholder={false}
/>
</span>
<span>when all filters are empty</span>
<span>when all {builderType}s are empty</span>
</div>
{/if}
<div class="add-group">
@ -439,17 +483,16 @@
})
}}
>
Add filter group
Add {builderType} group
</Button>
<a
href="https://docs.budibase.com/docs/searchfilter-data"
target="_blank"
>
<Icon
name="HelpOutline"
color="var(--spectrum-global-color-gray-600)"
/>
</a>
{#if docsURL}
<a href={docsURL} target="_blank">
<Icon
name="HelpOutline"
color="var(--spectrum-global-color-gray-600)"
/>
</a>
{/if}
</div>
</Layout>
</div>

View File

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

View File

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

View File

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

View File

@ -48,7 +48,9 @@ describe("Branching automations", () => {
{ stepId: branch1LogId }
),
condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: true },
equal: {
[`{{ literal steps.${firstLogId}.success }}`]: true,
},
},
},
branch2: {
@ -58,19 +60,21 @@ describe("Branching automations", () => {
{ stepId: branch2LogId }
),
condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: false },
equal: {
[`{{ literal steps.${firstLogId}.success }}`]: false,
},
},
},
}),
condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: true },
equal: { [`{{ literal steps.${firstLogId}.success }}`]: true },
},
},
topLevelBranch2: {
steps: stepBuilder =>
stepBuilder.serverLog({ text: "Branch 2" }, { stepId: branch2Id }),
condition: {
equal: { [`{{ steps.${firstLogId}.success }}`]: false },
equal: { [`{{ literal steps.${firstLogId}.success }}`]: false },
},
},
})
@ -217,4 +221,90 @@ describe("Branching automations", () => {
)
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]) => {
const stepBuilder = new StepBuilder()
branch.steps(stepBuilder)
let branchId = uuidv4()
branchStepInputs.branches.push({
name: key,
condition: branch.condition,
id: branchId,
})
branchStepInputs.children![key] = stepBuilder.build()
branchStepInputs.children![branchId] = stepBuilder.build()
})
const branchStep: AutomationStep = {
...definition,

View File

@ -8,7 +8,7 @@ import {
import * as actions from "../automations/actions"
import * as automationUtils from "../automations/automationUtils"
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 { generateAutomationMetadataID, isProdAppID } from "../db/utils"
import { definitions as triggerDefs } from "../automations/triggerInfo"
@ -23,15 +23,21 @@ import {
AutomationStatus,
AutomationStep,
AutomationStepStatus,
BranchSearchFilters,
BranchStep,
isLogicalSearchOperator,
LoopStep,
SearchFilters,
UserBindings,
isBasicSearchOperator,
} from "@budibase/types"
import { AutomationContext, TriggerOutput } from "../definitions/automations"
import { WorkerCallback } from "./definitions"
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 { performance } from "perf_hooks"
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(
"Orchestrator.executeSteps",
{ resource: "automation" },
@ -340,10 +349,17 @@ class Orchestrator {
while (stepIndex < steps.length) {
const step = steps[stepIndex]
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++
} else if (step.stepId === AutomationActionStepId.LOOP) {
stepIndex = await this.executeLoopStep(step, steps, stepIndex)
stepIndex = await this.executeLoopStep(
step,
steps,
stepIndex,
pathIdx
)
} else {
if (!this.stopped) {
await this.executeStep(step)
@ -366,11 +382,14 @@ class Orchestrator {
private async executeLoopStep(
loopStep: LoopStep,
steps: AutomationStep[],
currentIndex: number
stepIdx: number,
pathIdx?: number
): Promise<number> {
await processObject(loopStep.inputs, this.context)
const iterations = getLoopIterations(loopStep)
let stepToLoopIndex = currentIndex + 1
let stepToLoopIndex = stepIdx + 1
let pathStepIdx = (pathIdx || stepIdx) + 1
let iterationCount = 0
let shouldCleanup = true
@ -381,7 +400,7 @@ class Orchestrator {
)
} catch (err) {
this.updateContextAndOutput(
stepToLoopIndex,
pathStepIdx + 1,
steps[stepToLoopIndex],
{},
{
@ -401,7 +420,7 @@ class Orchestrator {
(loopStep.inputs.iterations && loopStepIndex === maxIterations)
) {
this.updateContextAndOutput(
stepToLoopIndex,
pathStepIdx + 1,
steps[stepToLoopIndex],
{
items: this.loopStepOutputs,
@ -428,7 +447,7 @@ class Orchestrator {
if (isFailure) {
this.updateContextAndOutput(
loopStepIndex,
pathStepIdx + 1,
steps[stepToLoopIndex],
{
items: this.loopStepOutputs,
@ -443,11 +462,11 @@ class Orchestrator {
break
}
this.context.steps[currentIndex + 1] = {
this.context.steps[pathStepIdx] = {
currentItem: this.getCurrentLoopItem(loopStep, loopStepIndex),
}
stepToLoopIndex = currentIndex + 1
stepToLoopIndex = stepIdx + 1
await this.executeStep(steps[stepToLoopIndex], stepToLoopIndex)
iterationCount++
@ -467,7 +486,7 @@ class Orchestrator {
}
// Loop Step clean up
this.executionOutput.steps.splice(currentIndex + 1, 0, {
this.executionOutput.steps.splice(pathStepIdx, 0, {
id: steps[stepToLoopIndex].id,
stepId: steps[stepToLoopIndex].stepId,
outputs: tempOutput,
@ -487,14 +506,19 @@ class Orchestrator {
return stepToLoopIndex + 1
}
private async executeBranchStep(branchStep: BranchStep): Promise<void> {
private async executeBranchStep(
branchStep: BranchStep,
pathIdx?: number
): Promise<void> {
const { branches, children } = branchStep.inputs
for (const branch of branches) {
const condition = await this.evaluateBranchCondition(branch.condition)
if (condition) {
const branchStatus = {
branchName: branch.name,
status: `${branch.name} branch taken`,
branchId: `${branch.id}`,
success: true,
}
@ -505,9 +529,11 @@ class Orchestrator {
branchStatus
)
this.context.steps[this.context.steps.length] = branchStatus
this.context.stepsById[branchStep.id] = branchStatus
const branchSteps = children?.[branch.name] || []
await this.executeSteps(branchSteps)
const branchSteps = children?.[branch.id] || []
// A final +1 to accomodate the branch step itself
await this.executeSteps(branchSteps, (pathIdx || 0) + 1)
return
}
}
@ -525,26 +551,51 @@ class Orchestrator {
}
private async evaluateBranchCondition(
conditions: SearchFilters
conditions: BranchSearchFilters
): Promise<boolean> {
const toFilter: Record<string, any> = {}
const processedConditions = dataFilters.recurseSearchFilters(
conditions,
filter => {
Object.entries(filter).forEach(([_, value]) => {
Object.entries(value).forEach(([field, _]) => {
const updatedField = field.replace("{{", "{{ literal ")
const recurseSearchFilters = (
filters: BranchSearchFilters
): BranchSearchFilters => {
for (const filterKey of Object.keys(
filters
) 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(
updatedField,
field,
this.processContext(this.context)
)
toFilter[field] = fromContext
})
})
return filter
if (typeof value === "string" && findHBSBlocks(value).length > 0) {
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)
return result.length > 0

View File

@ -142,25 +142,6 @@ export function recurseLogicalOperators(
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
* 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(
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>(

View File

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