Merge branch 'master' into sqs-test-fixes
This commit is contained in:
commit
9caf001746
|
@ -3,7 +3,7 @@ name: Deploy QA
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- feature/automation-branching-ux
|
- master
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
@ -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/*",
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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]}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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"],
|
||||||
|
|
|
@ -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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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).
|
||||||
|
|
|
@ -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>(
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue