Fixes and refactoring for deleteBlock functionality. moveBlock added, drag and drop connected. UX updates for the test data section. Fixes for layout in automation step UI

This commit is contained in:
Dean 2024-10-24 09:26:57 +01:00
parent 071a5b1d46
commit cf7e00eb33
12 changed files with 1139 additions and 312 deletions

View File

@ -0,0 +1,451 @@
<script>
import { onDestroy, tick } from "svelte"
import { writable } from "svelte/store"
import { setContext, onMount } from "svelte"
import { Utils } from "@budibase/frontend-core"
import { selectedAutomation, automationStore } from "stores/builder"
export function zoomIn() {
view.update(state => ({
...state,
scale: Math.min(state.scale + 0.1, 1),
}))
}
export function zoomOut() {
view.update(state => ({
...state,
scale: Math.max(state.scale - 0.1, 0),
}))
}
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),
}))
}
// View State
const view = writable({
dragging: false,
moveStep: null,
dragSpot: null,
dragging: false,
scale: 1,
dropzones: {},
//focus - node to center on?
})
setContext("view", 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 })
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
// Auto scroll
let scrollInterval
const onScale = async () => {
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,
}))
} else if (e.ctrlKey || e.metaKey) {
// Scale
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: updatedScale,
}))
} else {
yBump = scrollIncrement * (e.deltaY < 0 ? -1 : 1)
contentPos.update(state => ({
...state,
x: state.x,
y: state.y - yBump,
}))
}
}
// Optimization options
const onViewMouseMove = async e => {
const { x, y } = eleXY(e, viewPort)
internalPos.update(() => ({
x,
y,
}))
if (down && !$view.dragging) {
contentPos.update(state => ({
...state,
x: x - dragOffset[0],
y: y - dragOffset[1],
}))
}
// Needs to handle when the mouse leaves the screen
// Needs to know the direction of movement and accelerate/decelerate
if (y < (viewDims.height / 100) * 20 && $view.dragging) {
if (!scrollInterval) {
// scrollInterval = setInterval(() => {
// contentPos = { x: contentPos.x, y: contentPos.y + 35 }
// }, 100)
}
} else {
if (scrollInterval) {
clearInterval(scrollInterval)
scrollInterval = undefined
}
}
}
const onViewDragEnd = e => {
down = false
dragOffset = []
}
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,
}))
}
const onMouseMove = async e => {
// 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) {
// TODO - Need to adjust content pos
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) {
return
}
const { x, y } = eleXY(e, viewPort)
dragOffset = [Math.abs(x - $contentPos.x), Math.abs(y - $contentPos.y)]
}
// Update dims after scaling
$: {
$view.scale
onScale()
}
// 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(entries => {
if (!entries?.[0]) {
return
}
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)}
>
<div
class="view"
bind:this={viewPort}
on:wheel={Utils.domDebounce(onViewScroll)}
on:mousemove={Utils.domDebounce(onViewMouseMove)}
on:mouseup={onViewDragEnd}
on:mouseleave={onViewDragEnd}
>
<div class="actions">
<!-- debug -->
<!-- <span>
View Pos [{viewPos.x}, {viewPos.y}]
</span>
<span>Down {down}</span>
<span>Drag {$view.dragging}</span>
<span>DragOffset {JSON.stringify(dragOffset)}</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,
dragging: false,
dropzones: {},
}))
}}
>
<slot name="content" />
</div>
</div>
</div>
</div>
<style>
.actions {
display: flex;
align-items: center;
gap: 8px;
position: fixed;
padding: 8px;
z-index: 2;
}
.draggable-canvas {
width: 100%;
height: 100%;
}
.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;
}
.content {
/* transition: all 0.1s ease-out; */
transform: translate(var(--posX), var(--posY)) scale(var(--scale));
user-select: none;
padding: 200px;
padding-top: 200px;
}
.content-wrap.dragging {
cursor: grabbing;
}
</style>

View File

@ -19,7 +19,8 @@
import { automationStore, selectedAutomation } from "stores/builder"
import { QueryUtils } from "@budibase/frontend-core"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, getContext } from "svelte"
import DragZone from "./DragZone.svelte"
const dispatch = createEventDispatcher()
@ -30,6 +31,8 @@
export let bindings
export let automation
const view = getContext("view")
let drawer
let condition
let open = true
@ -48,7 +51,7 @@
})
$: branchBlockRef = {
branchNode: true,
pathTo: (pathTo || []).concat({ branchIdx }),
pathTo: (pathTo || []).concat({ branchIdx, branchStepId: step.id }),
}
</script>
@ -170,7 +173,11 @@
<div class="separator" />
<FlowItemActions block={branchBlockRef} />
{#if $view.dragging}
<DragZone path={branchBlockRef.pathTo} />
{:else}
<FlowItemActions block={branchBlockRef} />
{/if}
{#if step.inputs.children[branch.id]?.length}
<div class="separator" />

View File

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

View File

@ -6,13 +6,20 @@
} from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import TestDataModal from "./TestDataModal.svelte"
import { Icon, notifications, Modal, Toggle } from "@budibase/bbui"
import {
Icon,
notifications,
Modal,
Toggle,
Button,
ActionButton,
} from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import StepNode from "./StepNode.svelte"
import { memo } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core"
import { onMount } from "svelte"
import DraggableCanvas from "../DraggableCanvas.svelte"
export let automation
@ -23,6 +30,7 @@
let scrolling = false
let blockRefs = {}
let treeEle
let draggable
// Memo auto - selectedAutomation
$: memoAutomation.set(automation)
@ -67,44 +75,37 @@
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
}
}
onMount(() => {
// Ensure the trigger element is centered in the view on load.
const triggerBlock = treeEle?.querySelector(".block.TRIGGER")
triggerBlock?.scrollIntoView({
behavior: "instant",
block: "nearest",
inline: "center",
})
})
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="header" class:scrolling>
<div class="header-left">
<UndoRedoControl store={automationHistoryStore} />
<UndoRedoControl store={automationHistoryStore} showButtonGroup />
<div class="group">
<ActionButton icon="Add" quiet on:click={draggable.zoomIn} />
<ActionButton icon="Remove" quiet on:click={draggable.zoomOut} />
</div>
<Button
secondary
on:click={() => {
draggable.zoomToFit()
}}
>
Zoom to fit
</Button>
</div>
<div class="controls">
<div
class:disabled={!automation?.definition?.trigger}
<Button
icon={"Play"}
cta
disabled={!automation?.definition?.trigger}
on:click={() => {
testDataModal.show()
}}
class="buttons"
>
<Icon size="M" name="Play" />
<div>Run test</div>
</div>
Run test
</Button>
<div class="buttons">
<Icon disabled={!$automationStore.testResults} size="M" name="Multiple" />
<div
@ -117,7 +118,7 @@
</div>
</div>
{#if !isRowAction}
<div class="setting-spacing">
<div class="toggle-active setting-spacing">
<Toggle
text={automation.disabled ? "Paused" : "Activated"}
on:change={automationStore.actions.toggleDisabled(
@ -131,25 +132,25 @@
{/if}
</div>
</div>
<div class="canvas" on:scroll={handleScroll}>
<div class="content">
<div class="tree">
<div class="root" bind:this={treeEle}>
{#if Object.keys(blockRefs).length}
{#each blocks as block, idx (block.id)}
<StepNode
step={blocks[idx]}
stepIdx={idx}
isLast={blocks?.length - 1 === idx}
automation={$memoAutomation}
blocks={blockRefs}
/>
{/each}
{/if}
</div>
</div>
</div>
<div class="root" bind:this={treeEle}>
<DraggableCanvas bind:this={draggable}>
<span class="main-content" slot="content">
{#if Object.keys(blockRefs).length}
{#each blocks as block, idx (block.id)}
<StepNode
step={blocks[idx]}
stepIdx={idx}
isLast={blocks?.length - 1 === idx}
automation={$memoAutomation}
blocks={blockRefs}
/>
{/each}
{/if}
</span>
</DraggableCanvas>
</div>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Automation"
@ -166,24 +167,31 @@
</Modal>
<style>
.canvas {
padding: var(--spacing-l) var(--spacing-xl);
overflow-y: auto;
.toggle-active :global(.spectrum-Switch) {
margin: 0px;
}
.root :global(.main-content) {
display: flex;
flex-direction: column;
align-items: center;
max-height: 100%;
height: 100%;
width: 100%;
}
.header-left {
display: flex;
gap: var(--spacing-l);
}
.header-left :global(div) {
border-right: none;
}
/* Fix for firefox not respecting bottom padding in scrolling containers */
.canvas > *:last-child {
padding-bottom: 40px;
}
.root {
display: inline-flex;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%;
}
.root :global(.block) {
@ -198,22 +206,12 @@
box-sizing: border-box;
}
.content {
padding: 23px 23px 80px;
box-sizing: border-box;
/* overflow-x: hidden; */
}
.header.scrolling {
background: var(--background);
border-bottom: var(--border-light);
z-index: 1;
}
.tree {
justify-content: center;
display: inline-flex;
min-width: 100%;
}
.header {
z-index: 1;
display: flex;
@ -221,7 +219,7 @@
align-items: center;
padding-left: var(--spacing-l);
transition: background 130ms ease-out;
flex: 0 0 48px;
flex: 0 0 60px;
padding-right: var(--spacing-xl);
}
@ -245,4 +243,33 @@
pointer-events: none;
color: var(--spectrum-global-color-gray-500) !important;
}
.group {
border-radius: 4px;
display: flex;
flex-direction: row;
}
.group :global(> *:not(:first-child)) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 2px solid var(--spectrum-global-color-gray-300);
}
.group :global(> *:not(:last-child)) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.header-left .group :global(.spectrum-Button),
.header-left .group :global(.spectrum-ActionButton),
.header-left .group :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-900) !important;
}
.header-left .group :global(.spectrum-Button),
.header-left .group :global(.spectrum-ActionButton) {
background: var(--spectrum-global-color-gray-200) !important;
}
.header-left .group :global(.spectrum-Button:hover),
.header-left .group :global(.spectrum-ActionButton:hover) {
background: var(--spectrum-global-color-gray-300) !important;
}
</style>

View File

@ -14,6 +14,7 @@
Label,
AbsTooltip,
InlineAlert,
Helpers,
} from "@budibase/bbui"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
@ -22,6 +23,9 @@
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"
export let block
export let blockRef
@ -29,12 +33,17 @@
export let idx
export let automation
export let bindings
export let draggable = true
const view = getContext("view")
const pos = getContext("viewPos")
const contentPos = getContext("contentPos")
let selected
let webhookModal
let open = true
let showLooping = false
let role
let blockEle
$: pathSteps = loadSteps(blockRef)
@ -55,9 +64,36 @@
$: isAppAction = block?.stepId === TriggerStepID.APP
$: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId)
$: triggerInfo = $selectedAutomationDisplayData?.triggerInfo
$: selected = $view?.moveStep && $view?.moveStep?.id === block.id
$: blockDims = blockEle?.getBoundingClientRect()
$: placeholderDims = buildPlaceholderStyles(blockDims)
let positionStyles
// Move the selected item
$: move(blockEle, $view?.dragSpot, selected)
const move = (block, dragPos, selected) => {
if ((!block && !selected) || !dragPos) {
return
}
positionStyles = `
--blockPosX: ${Math.round(dragPos.x)}px;
--blockPosY: ${Math.round(dragPos.y)}px;
`
}
const buildPlaceholderStyles = dims => {
if (!dims) {
return ""
}
const { width, height } = dims
return `--pswidth: ${Math.round(width)}px;
--psheight: ${Math.round(height)}px;`
}
async function setPermissions(role) {
if (!role || !automationId) {
return
@ -103,6 +139,24 @@
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>
{#if block.stepId !== "LOOP"}
@ -112,124 +166,159 @@
id={`block-${block.id}`}
class={`block ${block.type} hoverable`}
class:selected
on:click={() => {}}
class:draggable
>
{#if loopBlock}
<div class="blockSection">
<div
on:click={() => {
showLooping = !showLooping
}}
class="splitHeader"
>
<div class="center-items">
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Reuse" />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
</div>
</div>
<div class="blockTitle">
<AbsTooltip type="negative" text="Remove looping">
<Icon on:click={removeLooping} hoverable name="DeleteOutline" />
</AbsTooltip>
<div style="margin-left: 10px;" on:click={() => {}}>
<Icon
hoverable
name={showLooping ? "ChevronDown" : "ChevronUp"}
/>
</div>
</div>
</div>
</div>
<Divider noMargin />
{#if !showLooping}
<div class="blockSection">
<Layout noPadding gap="S">
<AutomationBlockSetup
schemaProperties={Object.entries(
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs
.properties
)}
{webhookModal}
block={loopBlock}
{automation}
{bindings}
/>
</Layout>
</div>
<Divider noMargin />
<div class="wrap">
{#if $view.dragging && selected}
<div class="drag-placeholder" style={placeholderDims} />
{/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
bind:this={blockEle}
class="block-content"
class:dragging={$view.dragging && selected}
style={positionStyles}
>
{#if draggable}
<div
class="handle"
class:grabbing={selected}
on:mousedown={onHandleMouseDown}
>
<DragHandle />
</div>
{/if}
<div class="block-core">
{#if loopBlock}
<div class="blockSection">
<div
on:click={() => {
showLooping = !showLooping
}}
class="splitHeader"
>
<div class="center-items">
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:var(--spectrum-global-color-gray-700);"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Reuse" />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
</div>
</div>
<div class="blockTitle">
<AbsTooltip type="negative" text="Remove looping">
<Icon
on:click={removeLooping}
hoverable
name="DeleteOutline"
/>
</AbsTooltip>
<div style="margin-left: 10px;" on:click={() => {}}>
<Icon
hoverable
name={showLooping ? "ChevronDown" : "ChevronUp"}
/>
</div>
</div>
</div>
</div>
<Divider noMargin />
{#if !showLooping}
<div class="blockSection">
<Layout noPadding gap="S">
<AutomationBlockSetup
schemaProperties={Object.entries(
$automationStore.blockDefinitions.ACTION.LOOP.schema
.inputs.properties
)}
{webhookModal}
block={loopBlock}
{automation}
{bindings}
/>
</Layout>
</div>
<Divider noMargin />
{/if}
{/if}
<FlowItemHeader
{automation}
{open}
{block}
{testDataModal}
{idx}
{addLooping}
{deleteStep}
on:toggle={() => (open = !open)}
on:update={async e => {
const newName = e.detail
if (newName.length === 0) {
await automationStore.actions.deleteAutomationName(block.id)
} else {
await automationStore.actions.saveAutomationName(
block.id,
newName
)
}
}}
/>
{#if open}
<Divider noMargin />
<div class="blockSection">
<Layout noPadding gap="S">
{#if isAppAction}
<div>
<Label>Role</Label>
<RoleSelect bind:value={role} />
</div>
{/if}
<AutomationBlockSetup
schemaProperties={Object.entries(
block?.schema?.inputs?.properties || {}
)}
{block}
{webhookModal}
{automation}
{bindings}
/>
{#if isTrigger && triggerInfo}
<InlineAlert
header={triggerInfo.type}
message={`This trigger is tied to the "${triggerInfo.rowAction.name}" row action in your ${triggerInfo.table.name} table`}
/>
{/if}
</Layout>
</div>
{/if}
<AutomationBlockSetup
schemaProperties={Object.entries(
block?.schema?.inputs?.properties || {}
)}
{block}
{webhookModal}
{automation}
{bindings}
/>
{#if isTrigger && triggerInfo}
<InlineAlert
header={triggerInfo.type}
message={`This trigger is tied to the "${triggerInfo.rowAction.name}" row action in your ${triggerInfo.table.name} table`}
/>
{/if}
</Layout>
</div>
</div>
{/if}
</div>
</div>
{#if !collectBlockExists || !lastStep}
<div class="separator" />
<FlowItemActions
{block}
on:branch={() => {
automationStore.actions.branchAutomation(
$selectedAutomation.blockRefs[block.id].pathTo,
automation
)
}}
/>
{#if $view.dragging}
<DragZone path={blockRef?.pathTo} />
{:else}
<FlowItemActions
{block}
on:branch={() => {
automationStore.actions.branchAutomation(
$selectedAutomation.blockRefs[block.id].pathTo,
automation
)
}}
/>
{/if}
{#if !lastStep}
<div class="separator" />
{/if}
@ -266,27 +355,73 @@
.block {
width: 480px;
font-size: 16px;
background-color: var(--background);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px 4px 4px 4px;
border-radius: 4px;
}
.block .wrap {
width: 100%;
position: relative;
}
.block.draggable .wrap {
display: flex;
flex-direction: row;
}
.block.draggable .wrap .handle {
height: auto;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--grey-3);
padding: 6px;
color: var(--grey-6);
cursor: grab;
}
.block.draggable .wrap .handle.grabbing {
cursor: grabbing;
}
.block.draggable .wrap .handle :global(.drag-handle) {
width: 6px;
}
.block .wrap .block-content {
width: 100%;
display: flex;
flex-direction: row;
background-color: var(--background);
border: 1px solid var(--grey-3);
border-radius: 4px;
}
.blockSection {
padding: var(--spacing-xl);
}
.separator {
width: 1px;
height: 25px;
border-left: 1px dashed var(--grey-4);
color: var(--grey-4);
/* center horizontally */
align-self: center;
}
.blockTitle {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.drag-placeholder {
height: calc(var(--psheight) - 2px);
width: var(--pswidth);
background-color: rgba(92, 92, 92, 0.1);
border: 1px dashed #5c5c5c;
border-radius: 4px;
display: block;
}
.block-core {
flex: 1;
}
.block-core.dragging {
pointer-events: none;
}
.block-content.dragging {
position: absolute;
z-index: 3;
top: var(--blockPosY);
left: var(--blockPosX);
}
</style>

View File

@ -24,6 +24,7 @@
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Left}
tooltip={"Create branch"}
size={"S"}
/>
{/if}
<Icon
@ -35,6 +36,7 @@
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Right}
tooltip={"Add a step"}
size={"S"}
/>
</div>
@ -44,6 +46,6 @@
border-radius: 4px 4px 4px 4px;
display: flex;
gap: var(--spacing-m);
padding: var(--spacing-m);
padding: 8px 12px;
}
</style>

View File

@ -44,7 +44,7 @@
automationStore.actions.branchAutomation(pathToCurrentNode, automation)
}}
>
Add additional branch
Add branch
</ActionButton>
</div>
<div class="branched">
@ -105,7 +105,6 @@
{/each}
</div>
{:else}
<!--Drop Zone-->
<div class="block">
<FlowItem
block={step}
@ -114,9 +113,9 @@
{isLast}
{automation}
{bindings}
draggable={step.type !== "TRIGGER"}
/>
</div>
<!--Drop Zone-->
{/if}
<style>

View File

@ -8,7 +8,6 @@
export let automation
export let testResults
export let width = "400px"
let openBlocks = {}
let blocks
@ -65,7 +64,7 @@
<div class="container">
{#each blocks as block, idx}
<div class="block" style={width ? `width: ${width}` : ""}>
<div class="block">
{#if block.stepId !== ActionStepID.LOOP}
<FlowItemHeader
{automation}
@ -146,7 +145,10 @@
.container {
padding: 0 30px 30px 30px;
height: 100%;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
}
.wrap {
@ -188,17 +190,17 @@
.block {
display: inline-block;
width: 400px;
height: auto;
height: fit-content;
font-size: 16px;
background-color: var(--background);
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px 4px 4px 4px;
min-width: 425px;
}
.separator {
width: 1px;
height: 40px;
flex: 0 0 40px;
border-left: 1px dashed var(--grey-4);
color: var(--grey-4);
/* center horizontally */

View File

@ -9,9 +9,9 @@
<div class="title">
<div class="title-text">
<Icon name="MultipleCheck" />
<div style="padding-left: var(--spacing-l); ">Test Details</div>
<div>Test Details</div>
</div>
<div style="padding-right: var(--spacing-xl)">
<div>
<Icon
on:click={async () => {
$automationStore.showTestPanel = false
@ -32,7 +32,8 @@
flex-direction: row;
align-items: center;
gap: var(--spacing-xs);
padding-left: var(--spacing-xl);
padding: 0px 30px;
padding-top: var(--spacing-l);
justify-content: space-between;
}
@ -40,7 +41,7 @@
display: flex;
flex-direction: row;
align-items: center;
padding-top: var(--spacing-s);
gap: var(--spacing-l);
}
.title :global(h1) {

View File

@ -690,7 +690,7 @@
{/if}
</div>
{/if}
<div class:field-width={shouldRenderField(value)}>
<div>
{#if value.type === "string" && value.enum && canShowField(key, value)}
<Select
on:change={e => onChange({ [key]: e.detail })}
@ -986,6 +986,11 @@
align-items: center;
gap: var(--spacing-s);
}
.label-container :global(label) {
white-space: unset;
}
.field-width {
width: 320px;
}
@ -999,12 +1004,9 @@
}
.block-field {
display: flex;
justify-content: space-between;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
display: grid;
grid-template-columns: 1fr 320px;
}
.attachment-field-width {

View File

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

View File

@ -30,7 +30,6 @@ const initialAutomationState = {
},
selectedAutomationId: null,
automationDisplayData: {},
blocks: {},
}
// If this functions, remove the actions elements
@ -66,6 +65,147 @@ const getFinalDefinitions = (triggers, actions) => {
}
const automationActions = store => ({
/**
* Move a given block from one location on the tree to another.
*
* @param {Object} sourcePath path to the block to be moved
* @param {Object} destPath the destinationPart
* @param {Object} automation the automaton to be mutated
*/
moveBlock: async (sourcePath, destPath, automation) => {
// Use core delete to remove and return the deleted block
// from the automation
const { deleted, newAutomation } = store.actions.deleteBlock(
sourcePath,
automation
)
// Traverse again as deleting the node from its original location
// will redefine all proceding node locations
const newRefs = {}
store.actions.traverse(newRefs, newAutomation)
// The last part of the destination node address, containing the id.
const pathEnd = destPath.at(-1)
let finalPath
// If dropping in a branch-step dropzone you need to find
// the updated parent step route then add the branch details again
if (pathEnd.branchStepId) {
const branchStepRef = newRefs[pathEnd.branchStepId]
finalPath = branchStepRef.pathTo
finalPath.push(pathEnd)
} else {
// Place the target 1 after the drop
finalPath = newRefs[pathEnd.id].pathTo
finalPath.at(-1).stepIdx += 1
}
// Uses the updated tree refs to resolve the new position
// for the moved element.
const updated = store.actions.updateStep(
finalPath,
newAutomation,
deleted,
true
)
try {
await store.actions.save(updated)
} catch (e) {
notifications.error("Error moving automation block")
console.error("Error moving automation block ", e)
}
},
/**
* Core delete function that will delete the node at the provided
* location. Loops require 2 deletes so the function returns an array.
* The passed in automation is not mutated
*
* @param {*} pathTo the tree path to the target node
* @param {*} automation the automation to alter.
* @returns {Object} contains the deleted nodes and new updated automation
*/
deleteBlock: (pathTo, automation) => {
let newAutomation = cloneDeep(automation)
const steps = [
newAutomation.definition.trigger,
...newAutomation.definition.steps,
]
let cache
pathTo.forEach((path, pathIdx, array) => {
const final = pathIdx === array.length - 1
const { stepIdx, branchIdx } = path
const deleteCore = (steps, idx) => {
const targetBlock = steps[idx]
// By default, include the id of the target block
const idsToDelete = [targetBlock.id]
const blocksDeleted = []
// If deleting a looped block, ensure all related block references are
// collated beforehand. Delete can then be handled atomically
const loopSteps = {}
steps.forEach(child => {
const { blockToLoop, id: loopBlockId } = child
if (blockToLoop) {
// The loop block > the block it loops
loopSteps[blockToLoop] = loopBlockId
}
})
// Check if there is a related loop block to remove
const loopStep = loopSteps[targetBlock.id]
if (loopStep) {
idsToDelete.push(loopStep)
}
// Purge all ids related to the block being deleted
for (let i = steps.length - 1; i >= 0; i--) {
if (idsToDelete.includes(steps[i].id)) {
const [deletedBlock] = steps.splice(i, 1)
blocksDeleted.unshift(deletedBlock)
}
}
return { deleted: blocksDeleted, newAutomation }
}
if (!cache) {
// If the path history is empty and on the final step
// delete the specified target
if (final) {
cache = deleteCore(
newAutomation.definition.steps,
stepIdx > 0 ? stepIdx - 1 : 0
)
} else {
// Return the root node
cache = steps[stepIdx]
}
return
}
if (Number.isInteger(branchIdx)) {
const branchId = cache.inputs.branches[branchIdx].id
const children = cache.inputs.children[branchId]
const currentBlock = children[stepIdx]
if (final) {
cache = deleteCore(children, stepIdx)
} else {
cache = currentBlock
}
}
})
// should be 1-2 blocks in an array
return cache
},
/**
* Build metadata for the automation tree. Store the path and
* note any loop information used when rendering
@ -78,7 +218,6 @@ const automationActions = store => ({
blocks[block.id] = {
...(blocks[block.id] || {}),
pathTo,
bindings: [],
terminating: terminating || false,
...(block.blockToLoop ? { blockToLoop: block.blockToLoop } : {}),
}
@ -127,16 +266,26 @@ const automationActions = store => ({
/**
* Take an updated step and replace it in the specified location
* on the automation
* on the automation. If `insert` is set to true, the supplied block/s
* will be inserted instead.
*
* @param {Array<Object>} pathWay - the full path to the tree node and the step
* @param {Object} automation - the automation to be mutated
* @param {Object} update - the block to replace
* @param {Array<Object>} pathWay the full path to the tree node and the step
* @param {Object} automation the automation to be mutated
* @param {Object} update the block to replace
* @param {Boolean} insert defaults to false
* @returns
*/
updateStep: (pathWay, automation, update) => {
updateStep: (pathWay, automation, update, insert = false) => {
let newAutomation = cloneDeep(automation)
const finalise = (dest, idx, update) => {
dest.splice(
idx,
insert ? 0 : update.length || 1,
...(Array.isArray(update) ? update : [update])
)
}
let cache = null
pathWay.forEach((path, idx, array) => {
const { stepIdx, branchIdx } = path
@ -146,7 +295,7 @@ const automationActions = store => ({
// Trigger offset
let idx = Math.max(stepIdx - 1, 0)
if (final) {
newAutomation.definition.steps[idx] = update
finalise(newAutomation.definition.steps, idx, update)
return
}
cache = newAutomation.definition.steps[idx]
@ -156,8 +305,7 @@ const automationActions = store => ({
const branchId = cache.inputs.branches[branchIdx].id
const children = cache.inputs.children[branchId]
if (final) {
// replace the node and return it
children[stepIdx] = update
finalise(children, stepIdx, update)
} else {
cache = children[stepIdx]
}
@ -171,7 +319,7 @@ const automationActions = store => ({
* If the current license covers Environment variables,
* all environment variables will be output as bindings
*
* @returns {Array<Object>} - all available environment bindings
* @returns {Array<Object>} all available environment bindings
*/
buildEnvironmentBindings: () => {
if (get(licensing).environmentVariablesEnabled) {
@ -188,8 +336,11 @@ const automationActions = store => ({
return []
},
/**
* @param {string} id - the step id of the target
* @returns {Array<Object>} - all bindings on the path to this step
* Take the supplied step id and aggregate all bindings for every
* step preceding it.
*
* @param {string} id the step id of the target
* @returns {Array<Object>} all bindings on the path to this step
*/
getPathBindings: id => {
const block = get(selectedAutomation).blockRefs[id]
@ -210,6 +361,10 @@ const automationActions = store => ({
*/
traverse: (blockRefs, automation) => {
let blocks = []
if (!automation || !blockRefs) {
console.error("Need a valid automation")
return
}
if (automation.definition?.trigger) {
blocks.push(automation.definition.trigger)
}
@ -230,7 +385,6 @@ const automationActions = store => ({
block.inputs?.children[branch.id].forEach((bBlock, sIdx, array) => {
const ended =
array.length - 1 === sIdx && !bBlock.inputs?.branches?.length
treeTraverse(bBlock, pathToCurrentNode, sIdx, bIdx, ended)
})
})
@ -259,7 +413,6 @@ const automationActions = store => ({
* @param {Object} automation The complete automation
* @returns
*/
getAvailableBindings: (block, automation) => {
if (!block || !automation?.definition) {
return []
@ -1047,84 +1200,15 @@ const automationActions = store => ({
},
/**
* Delete the block at a given path.
* Delete the block at a given path and save.
* Any related blocks, like loops, are purged at the same time
*
* @param {Array<Object>} pathTo
* @param {Array<Object>} pathTo the path to the target node
*/
deleteAutomationBlock: async pathTo => {
const automation = get(selectedAutomation).data
let newAutomation = cloneDeep(automation)
const automation = get(selectedAutomation)?.data
const steps = [
newAutomation.definition.trigger,
...newAutomation.definition.steps,
]
let cache
pathTo.forEach((path, pathIdx, array) => {
const final = pathIdx === array.length - 1
const { stepIdx, branchIdx } = path
const deleteBlock = (steps, idx) => {
const targetBlock = steps[idx]
// By default, include the id of the target block
const idsToDelete = [targetBlock.id]
// If deleting a looped block, ensure all related block references are
// collated beforehand. Delete can then be handled atomically
const loopSteps = {}
steps.forEach(child => {
const { blockToLoop, id: loopBlockId } = child
if (blockToLoop) {
// The loop block > the block it loops
loopSteps[blockToLoop] = loopBlockId
}
})
// Check if there is a related loop block to remove
const loopStep = loopSteps[targetBlock.id]
if (loopStep) {
idsToDelete.push(loopStep)
}
// Purge all ids related to the block being deleted
for (let i = steps.length - 1; i >= 0; i--) {
if (idsToDelete.includes(steps[i].id)) {
steps.splice(i, 1)
}
}
return idsToDelete
}
if (!cache) {
// If the path history is empty and on the final step
// delete the specified target
if (final) {
cache = deleteBlock(
newAutomation.definition.steps,
stepIdx > 0 ? stepIdx - 1 : 0
)
} else {
// Return the root node
cache = steps[stepIdx]
}
return
}
if (Number.isInteger(branchIdx)) {
const branchId = cache.inputs.branches[branchIdx].id
const children = cache.inputs.children[branchId]
const currentBlock = children[stepIdx]
if (final) {
cache = deleteBlock(children, stepIdx)
} else {
cache = currentBlock
}
}
})
const { newAutomation } = store.actions.deleteBlock(pathTo, automation)
try {
await store.actions.save(newAutomation)