From 9382ca4c0b9897209b9f6d44e882da967ce76b5e Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 11 Oct 2024 16:20:26 +0100 Subject: [PATCH] Branching UX updates, fix for looping results and general failure results for automations. Added fix for stacking currentItem loop bindings --- .../FlowChart/ActionModal.svelte | 21 +- .../FlowChart/BranchNode.svelte | 239 +++++ .../FlowChart/FlowChart.svelte | 150 +-- .../FlowChart/FlowItem.svelte | 265 ++--- .../FlowChart/FlowItemActions.svelte | 49 + .../FlowChart/FlowItemHeader.svelte | 46 +- .../FlowChart/StepNode.svelte | 177 ++++ .../AutomationBuilder/TestDisplay.svelte | 27 +- .../SetupPanel/AutomationBlockSetup.svelte | 34 +- .../FilterEditor/FilterBuilder.svelte | 8 +- packages/builder/src/dataBinding.js | 28 + .../builder/src/stores/builder/automations.js | 927 +++++++++++++----- .../src/components/ConditionField.svelte | 177 ++++ .../src/components/CoreFilterBuilder.svelte | 86 +- .../src/components/FilterField.svelte | 3 +- packages/server/src/threads/automation.ts | 46 +- .../app/automation/StepInputsOutputs.ts | 1 + 17 files changed, 1736 insertions(+), 548 deletions(-) create mode 100644 packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte create mode 100644 packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemActions.svelte create mode 100644 packages/builder/src/components/automation/AutomationBuilder/FlowChart/StepNode.svelte create mode 100644 packages/frontend-core/src/components/ConditionField.svelte diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index 9899c454fc..653d567b13 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -13,10 +13,8 @@ import { admin, licensing } from "stores/portal" import { externalActions } from "./ExternalActions" import { TriggerStepID, ActionStepID } from "constants/backend/automations" - import { checkForCollectStep } from "helpers/utils" - export let blockIdx - export let lastStep + export let block export let modal let syncAutomationsEnabled = $licensing.syncAutomationsEnabled @@ -29,7 +27,15 @@ ActionStepID.TRIGGER_AUTOMATION_RUN, ] - $: collectBlockExists = checkForCollectStep($selectedAutomation) + $: blockRef = $automationStore.blocks?.[block.id] + $: lastStep = blockRef?.terminating + $: pathSteps = block.id + ? automationStore.actions.getPathSteps(blockRef.pathTo, $selectedAutomation) + : [] + + $: collectBlockExists = pathSteps?.some( + step => step.stepId === ActionStepID.COLLECT + ) const disabled = () => { return { @@ -100,9 +106,14 @@ action.stepId, action ) - await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1) + + await automationStore.actions.addBlockToAutomation( + newBlock, + blockRef ? blockRef.pathTo : block.pathTo + ) modal.hide() } catch (error) { + console.error(error) notifications.error("Error saving automation") } } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte new file mode 100644 index 0000000000..86c6263b70 --- /dev/null +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte @@ -0,0 +1,239 @@ + + + + + + { + editableConditionUI = e.detail + }} + allowOnEmpty={false} + builderType={"condition"} + docsURL={null} + /> + + + +
+
+ { + await automationStore.actions.deleteBranch( + branchBlockRef.pathTo, + $selectedAutomation + ) + }} + 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, + stepUpdate + ) + await automationStore.actions.save(updatedAuto) + }} + on:toggle={() => (open = !open)} + > +
+ { + automationStore.actions.branchLeft( + branchBlockRef.pathTo, + $selectedAutomation, + step + ) + }} + tooltip={"Move left"} + tooltipType={TooltipType.Info} + tooltipPosition={TooltipPosition.Top} + hoverable + disabled={branchIdx == 0} + name="ArrowLeft" + /> + { + automationStore.actions.branchRight( + branchBlockRef.pathTo, + $selectedAutomation, + step + ) + }} + tooltip={"Move right"} + tooltipType={TooltipType.Info} + tooltipPosition={TooltipPosition.Top} + hoverable + disabled={isLast} + name="ArrowRight" + /> +
+
+ {#if open} + +
+ + + + + {editableConditionUI?.groups?.length + ? "Update condition" + : "Add condition"} + + + + +
+ {/if} +
+ +
+ + + + {#if step.inputs.children[branch.id]?.length} +
+ {/if} +
+ + diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte index 068d7e477f..2361fc2ba3 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte @@ -5,37 +5,82 @@ automationHistoryStore, } from "stores/builder" import ConfirmDialog from "components/common/ConfirmDialog.svelte" - import FlowItem from "./FlowItem.svelte" import TestDataModal from "./TestDataModal.svelte" - import { flip } from "svelte/animate" - import { fly } from "svelte/transition" import { Icon, notifications, Modal, Toggle } from "@budibase/bbui" import { ActionStepID } from "constants/backend/automations" import UndoRedoControl from "components/common/UndoRedoControl.svelte" import StepNode from "./StepNode.svelte" - - // Test test test - import { FIELDS } from "constants/backend" - import { tables } from "stores/builder" - import { AutomationEventType } from "@budibase/types" - import { writable } from "svelte/store" - import { setContext } from "svelte" - - const test = writable({ - someupdate: () => { - console.log("updated") - }, - }) + import { memo } from "@budibase/frontend-core" import { sdk } from "@budibase/shared-core" + import { migrateReferencesInObject } from "dataBinding" + import { cloneDeep } from "lodash/fp" + import { onMount } from "svelte" export let automation + const memoAutomation = memo(automation) + let testDataModal let confirmDeleteDialog let scrolling = false + let blockRefs = {} + let treeEle - $: blocks = getBlocks(automation).filter(x => x.stepId !== ActionStepID.LOOP) - $: isRowAction = sdk.automations.isRowAction(automation) + // Memo auto + $: 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 = auto => { + automationStore.update(state => { + return { + ...state, + blocks: {}, + } + }) + + // Traverse the automation and build metadata + automationStore.actions.traverse(auto) + + blockRefs = $automationStore.blocks + + // Build global automation bindings. + const environmentBindings = + automationStore.actions.buildEnvironmentBindings() + + // Push common bindings globally + automationStore.update(state => ({ + ...state, + bindings: [...environmentBindings], + })) + + // Parse the steps for references to sequential binding + const updatedAuto = cloneDeep(auto) + + // Parse and migrate all bindings + Object.values(blockRefs) + .filter(blockRef => { + // Pulls out all distinct terminating nodes + return blockRef.terminating + }) + .forEach(blockRef => { + automationStore.actions + .getPathSteps(blockRef.pathTo, updatedAuto) + .forEach((step, idx, steps) => { + migrateReferencesInObject({ + obj: step, + originalIndex: idx, + steps, + }) + }) + }) + } const getBlocks = automation => { let blocks = [] @@ -62,19 +107,21 @@ 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", + }) + })
-
@@ -117,32 +164,21 @@
- -
- {#each blocks as block, idx (block.id)} - - {/each} -
- - +
{ + return blockRef + ? automationStore.actions.getPathSteps(blockRef.pathTo, automation) + : [] + } + + $: collectBlockExists = pathSteps.some( step => step.stepId === ActionStepID.COLLECT ) - $: automationId = $selectedAutomation?._id - $: isTrigger = block.type === "TRIGGER" + $: automationId = automation?._id + $: isTrigger = block.type === AutomationStepType.TRIGGER + $: lastStep = blockRef?.terminating - $: steps = $selectedAutomation?.definition?.steps ?? [] - $: blockIdx = steps.findIndex(step => step.id === block.id) - $: lastStep = !isTrigger && blockIdx + 1 === steps.length - $: totalBlocks = $selectedAutomation?.definition?.steps.length + 1 - $: loopBlock = $selectedAutomation?.definition.steps.find( - x => x.blockToLoop === block.id - ) + $: loopBlock = pathSteps.find(x => x.blockToLoop === block.id) $: isAppAction = block?.stepId === TriggerStepID.APP $: isAppAction && setPermissions(role) $: isAppAction && getPermissions(automationId) @@ -79,23 +81,13 @@ } } - async function removeLooping() { - try { - await automationStore.actions.deleteAutomationBlock(loopBlock) - } catch (error) { - notifications.error("Error saving automation") - } + async function deleteStep() { + await automationStore.actions.deleteAutomationBlock(blockRef.pathTo) } - async function deleteStep() { - try { - if (loopBlock) { - await automationStore.actions.deleteAutomationBlock(loopBlock) - } - await automationStore.actions.deleteAutomationBlock(block, blockIdx) - } catch (error) { - notifications.error("Error saving automation") - } + async function removeLooping() { + let loopBlockRef = $automationStore.blocks[blockRef.looped] + await automationStore.actions.deleteAutomationBlock(loopBlockRef.pathTo) } async function addLooping() { @@ -106,128 +98,143 @@ loopDefinition ) loopBlock.blockToLoop = block.id - await automationStore.actions.addBlockToAutomation(loopBlock, blockIdx) + await automationStore.actions.addBlockToAutomation( + loopBlock, + blockRef.pathTo + ) } - - -
{}}> - {#if loopBlock} -
-
{ - showLooping = !showLooping - }} - class="splitHeader" - > -
- - - -
- Looping +{#if block.stepId !== "LOOP"} + + +
{}} + > + {#if loopBlock} +
+
{ + showLooping = !showLooping + }} + class="splitHeader" + > +
+ + + +
+ Looping +
-
-
- - - +
+ + + -
{}}> - +
{}}> + +
-
- - {#if !showLooping} + + {#if !showLooping} +
+ + + +
+ + {/if} + {/if} + + (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} +
+ {#if isAppAction} +
+ + +
+ {/if} + {#if isTrigger && triggerInfo} + + {/if}
- + {/if} +
+ {#if !collectBlockExists || !lastStep} +
+ { + automationStore.actions.branchAutomation( + $automationStore.blocks[block.id].pathTo, + $selectedAutomation + ) + }} + /> + {#if !lastStep} +
{/if} {/if} - - (open = !open)} - /> - {#if open} - -
- - {#if isAppAction} -
- - -
- {/if} - - {#if isTrigger && triggerInfo} - - {/if} - {#if lastStep} - - {/if} -
-
- {/if} -
-{#if !collectBlockExists || !lastStep} -
- - - - - - {#if isTrigger ? !isLast || totalBlocks > 1 : blockIdx !== totalBlocks - 2} -
- {/if} {/if} - - - - diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemActions.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemActions.svelte new file mode 100644 index 0000000000..b96132af52 --- /dev/null +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemActions.svelte @@ -0,0 +1,49 @@ + + + + + + +
+ {#if !block.branchNode} + { + dispatch("branch") + }} + tooltipType={TooltipType.Info} + tooltipPosition={TooltipPosition.Left} + tooltip={"Create branch"} + /> + {/if} + { + actionModal.show() + }} + tooltipType={TooltipType.Info} + tooltipPosition={TooltipPosition.Right} + tooltip={"Add a step"} + /> +
+ + diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte index a98c597142..4b3f7368db 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte @@ -10,10 +10,11 @@ export let showTestStatus = false export let testResult export let isTrigger - export let idx export let addLooping export let deleteStep export let enableNaming = true + export let itemName + let validRegex = /^[A-Za-z0-9_\s]+$/ let typing = false let editing = false @@ -21,10 +22,11 @@ $: stepNames = $selectedAutomation?.definition.stepNames $: allSteps = $selectedAutomation?.definition.steps || [] - $: automationName = stepNames?.[block.id] || block?.name || "" + $: automationName = itemName || stepNames?.[block.id] || block?.name || "" $: automationNameError = getAutomationNameError(automationName) $: status = updateStatus(testResult) $: isHeaderTrigger = isTrigger || block.type === "TRIGGER" + $: isBranch = block.stepId === "BRANCH" $: { if (!testResult) { @@ -33,9 +35,9 @@ )?.[0] } } - $: loopBlock = $selectedAutomation?.definition.steps.find( - x => x.blockToLoop === block?.id - ) + + $: blockRef = $automationStore.blocks[block.id] + $: isLooped = blockRef?.looped async function onSelect(block) { await automationStore.update(state => { @@ -84,30 +86,18 @@ return null } - const saveName = async () => { - if (automationNameError || block.name === automationName) { - return - } - - if (automationName.length === 0) { - await automationStore.actions.deleteAutomationName(block.id) - } else { - await automationStore.actions.saveAutomationName(block.id, automationName) - } - } - const startEditing = () => { editing = true typing = true } - const stopEditing = async () => { + const stopEditing = () => { editing = false typing = false if (automationNameError) { automationName = stepNames[block.id] || block?.name } else { - await saveName() + dispatch("update", automationName) } } @@ -118,7 +108,6 @@ class:typing={typing && !automationNameError && editing} class:typing-error={automationNameError && editing} class="blockSection" - on:click={() => dispatch("toggle")} >
@@ -144,16 +133,14 @@ {#if isHeaderTrigger} Trigger {:else} -
- Step {idx} -
+ {isBranch ? "Branch" : "Step"} {/if} {#if enableNaming} + {#if !showTestStatus} - {#if !isHeaderTrigger && !loopBlock && (block?.features?.[Features.LOOPING] || !block.features)} + {#if !isHeaderTrigger && !isLooped && !isBranch && (block?.features?.[Features.LOOPING] || !block.features)} @@ -220,6 +208,9 @@ {/if} {/if} + {#if !showTestStatus && !isHeaderTrigger} + + {/if} {#if !showTestStatus} { @@ -245,6 +236,9 @@
diff --git a/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte index 8487b7a519..50e82a6f0f 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte @@ -3,6 +3,8 @@ import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte" import { ActionStepID } from "constants/backend/automations" import { JsonView } from "@zerodevx/svelte-json-view" + import { automationStore } from "stores/builder" + import { AutomationActionStepId } from "@budibase/types" export let automation export let testResults @@ -28,21 +30,27 @@ } } - $: filteredResults = prepTestResults(testResults) + const getBranchName = (step, id) => { + if (!step || !id) { + return + } + return step.inputs.branches.find(branch => branch.id === id)?.name + } + $: filteredResults = prepTestResults(testResults) $: { if (testResults.message) { blocks = automation?.definition?.trigger ? [automation.definition.trigger] : [] } else if (automation) { - blocks = [] - if (automation.definition.trigger) { - blocks.push(automation.definition.trigger) - } - blocks = blocks - .concat(automation.definition.steps || []) - .filter(x => x.stepId !== ActionStepID.LOOP) + const terminatingStep = filteredResults.at(-1) + const terminatingBlockRef = $automationStore.blocks[terminatingStep.id] + const pathSteps = automationStore.actions.getPathSteps( + terminatingBlockRef.pathTo, + automation + ) + blocks = [...pathSteps].filter(x => x.stepId !== ActionStepID.LOOP) } else if (filteredResults) { blocks = filteredResults || [] // make sure there is an ID for each block being displayed @@ -60,6 +68,9 @@ {#if block.stepId !== ActionStepID.LOOP} (openBlocks[block.id] = !openBlocks[block.id])} isTrigger={idx === 0} diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 9dddaad578..cc11fa6b63 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -21,8 +21,8 @@ } from "@budibase/bbui" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" - import { automationStore, selectedAutomation, tables } from "stores/builder" - import { environment, licensing } from "stores/portal" + import { automationStore, tables } from "stores/builder" + import { environment } from "stores/portal" import WebhookDisplay from "../Shared/WebhookDisplay.svelte" import { BindingSidePanel, @@ -46,10 +46,7 @@ } from "components/common/CodeEditor" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import { QueryUtils, Utils, search, memo } from "@budibase/frontend-core" - import { - getSchemaForDatasourcePlus, - getEnvironmentBindings, - } from "dataBinding" + import { getSchemaForDatasourcePlus } from "dataBinding" import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { onMount } from "svelte" import { writable } from "svelte/store" @@ -110,7 +107,8 @@ $memoBlock.id, automation ) - $: environmentBindings = buildEnvironmentBindings($memoEnvVariables) + $: environmentBindings = + automationStore.actions.buildEnvironmentBindings($memoEnvVariables) $: bindings = [...automationBindings, ...environmentBindings] $: getInputData(testData, $memoBlock.inputs) @@ -145,21 +143,6 @@ ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] : [] - const buildEnvironmentBindings = () => { - if ($licensing.environmentVariablesEnabled) { - return getEnvironmentBindings().map(binding => { - return { - ...binding, - display: { - ...binding.display, - rank: 98, - }, - } - }) - } - return [] - } - const getInputData = (testData, blockInputs) => { // Test data is not cloned for reactivity let newInputData = testData || cloneDeep(blockInputs) @@ -529,9 +512,6 @@ }) */ const onChange = Utils.sequential(async update => { - if (1 == 1) { - console.error("ABORT UPDATE") - } const request = cloneDeep(update) // Process app trigger updates if (isTrigger && !isTestModal) { @@ -576,8 +556,8 @@ ...newTestData, ...request, } - // TO DO - uncomment - // await automationStore.actions.addTestDataToAutomation(newTestData) + + await automationStore.actions.addTestDataToAutomation(newTestData) } else { const data = { schema, ...request } await automationStore.actions.updateBlockInputs(block, data) diff --git a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte index 1e79f61bae..7e3cf5d3ec 100644 --- a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte +++ b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte @@ -12,8 +12,10 @@ export let bindings = [] export let panel = ClientBindingPanel export let allowBindings = true + export let allowOnEmpty export let datasource - export let showFilterEmptyDropdown + export let builderType + export let docsURL diff --git a/packages/builder/src/dataBinding.js b/packages/builder/src/dataBinding.js index 3eefb373ca..ecbcc44e0f 100644 --- a/packages/builder/src/dataBinding.js +++ b/packages/builder/src/dataBinding.js @@ -1469,3 +1469,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, + }) + } + } +} diff --git a/packages/builder/src/stores/builder/automations.js b/packages/builder/src/stores/builder/automations.js index d21a66bf4b..939af9c3d1 100644 --- a/packages/builder/src/stores/builder/automations.js +++ b/packages/builder/src/stores/builder/automations.js @@ -3,10 +3,16 @@ import { API } from "api" import { cloneDeep } from "lodash/fp" import { generate } from "shortid" import { createHistoryStore } from "stores/builder/history" -import { environment, licensing } from "stores/portal" +import { licensing } from "stores/portal" +import { tables } from "stores/builder" import { notifications } from "@budibase/bbui" -import { updateReferencesInObject, getEnvironmentBindings } from "dataBinding" -import { AutomationTriggerStepId, AutomationEventType } from "@budibase/types" +import { getEnvironmentBindings } from "dataBinding" +import { + AutomationTriggerStepId, + AutomationEventType, + AutomationStepType, +} from "@budibase/types" +import { ActionStepID } from "constants/backend/automations" import { FIELDS } from "constants/backend" import { sdk } from "@budibase/shared-core" import { rowActions } from "./rowActions" @@ -14,6 +20,7 @@ import { updateBindingsInSteps, getNewStepName, } from "helpers/automations/nameHelpers" +import { QueryUtils } from "@budibase/frontend-core" const initialAutomationState = { automations: [], @@ -26,9 +33,6 @@ const initialAutomationState = { }, selectedAutomationId: null, automationDisplayData: {}, - - // registered on screen. - // may not be the right store blocks: {}, } @@ -49,17 +53,6 @@ export const createAutomationStore = () => { return { store, history } } -const updateStepReferences = (steps, modifiedIndex, action) => { - steps.forEach(step => { - updateReferencesInObject({ - obj: step.inputs, - modifiedIndex, - action, - label: "steps", - }) - }) -} - const getFinalDefinitions = (triggers, actions) => { const creatable = {} Object.entries(triggers).forEach(entry => { @@ -76,37 +69,119 @@ const getFinalDefinitions = (triggers, actions) => { } const automationActions = store => ({ - // Should this be in the store? - // or just a context item. - registerBlock: (block, pathTo) => { - // console.log("Register ", block) - /* - Traverse before even rendering - Push all data into a core location i.e here - - Derived store? - blocks: - { - [step.id]:{ - path: [{step:2, id: abc123 },{branch:0, step:1, id: xyz789}] - bindings: [] - // byName - // byStepIdx - } - } - */ + /** + * Build metadata for the automation tree. Store the path and + * note any loop information used when rendering + * + * @param {Object} block + * @param {Array} pathTo + */ + registerBlock: (block, pathTo, terminating) => { store.update(state => { state.blocks = { ...state.blocks, [block.id]: { - bindings: block.inputs.text, + ...state.blocks[block.id], + pathTo, + bindings: [], + terminating: terminating || false, + // Register the looped block + ...(block.blockToLoop ? { blockToLoop: block.blockToLoop } : {}), }, } + + // If this is a loop block, add a reference to the block being looped + if (block.blockToLoop) { + state.blocks[block.blockToLoop] = { + ...(state.blocks[block.blockToLoop] || {}), + looped: block.id, + } + } return state }) }, - // build and store ONCE - // make it derived? + /** + * Build a sequential list of all steps on the step path provided + * + * @param {Array} pathWay e.g. [{stepIdx:2},{branchIdx:0, stepIdx:2},...] + * @returns {Array} all steps encountered on the provided path + */ + getPathSteps: (pathWay, automation) => { + // Base Steps, including trigger + const steps = [ + automation.definition.trigger, + ...automation.definition.steps, + ] + + let result + pathWay.forEach(path => { + const { stepIdx, branchIdx } = path + let last = result ? result[result.length - 1] : [] + if (!result) { + // Preceeding steps. + result = steps.slice(0, stepIdx + 1) + return + } + + if (Number.isInteger(branchIdx)) { + const branchId = last.inputs.branches[branchIdx].id + const children = last.inputs.children[branchId] + const stepChildren = children.slice(0, stepIdx + 1) + // Preceeding steps. + result = result.concat(stepChildren) + } + }) + return result + }, + + /** + * Take an updated step and replace it in the specified location + * on the automation + * + * @param {Array} 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 + * @returns + */ + updateStep: (pathWay, automation, update) => { + let newAutomation = cloneDeep(automation) + + let cache = null + pathWay.forEach((path, idx, array) => { + const { stepIdx, branchIdx } = path + let final = idx === array.length - 1 + + if (!cache) { + // Trigger offset + let idx = Math.max(stepIdx - 1, 0) + if (final) { + newAutomation.definition.steps[idx] = update + return + } + cache = newAutomation.definition.steps[idx] + } + + if (Number.isInteger(branchIdx)) { + const branchId = cache.inputs.branches[branchIdx].id + const children = cache.inputs.children[branchId] + if (final) { + // replace the node and return it + children[stepIdx] = update + } else { + cache = children[stepIdx] + } + } + }) + + return newAutomation + }, + + /** + * If the current license covers Environment variables, + * all environment variables will be output as bindings + * + * @returns {Array} - all available environment bindings + */ buildEnvironmentBindings: () => { if (get(licensing).environmentVariablesEnabled) { return getEnvironmentBindings().map(binding => { @@ -121,27 +196,99 @@ const automationActions = store => ({ } return [] }, - //TESTING retrieve all preceding + /** + * @param {string} id - the step id of the target + * @returns {Array} - all bindings on the path to this step + */ + getPathBindings: id => { + const block = get(store).blocks[id] + const bindings = store.actions.getAvailableBindings( + block, + get(selectedAutomation) + ) + + return bindings + }, + /** + * Takes the provided automation and traverses all possible paths. + * References to all nodes/steps encountered on the way are stored + * in state under 'blocks'. These references are used to store tree related + * metadata like the tree path or whether the node is terminating. + * + * @param {Object} automation + */ + traverse: automation => { + let blocks = [] + if (automation.definition.trigger) { + blocks.push(automation.definition.trigger) + } + blocks = blocks.concat(automation.definition.steps || []) + + const treeTraverse = (block, pathTo, stepIdx, branchIdx, terminating) => { + const pathToCurrentNode = [ + ...(pathTo || []), + { + ...(Number.isInteger(branchIdx) ? { branchIdx } : {}), + stepIdx, + id: block.id, + }, + ] + const branches = block.inputs?.branches || [] + + branches.forEach((branch, bIdx) => { + 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) + }) + }) + + store.actions.registerBlock( + block, + pathToCurrentNode, + terminating && !branches.length + ) + } + + // Purge refs + store.update(state => { + state.blocks = {} + return state + }) + + // Traverse the entire tree. + blocks.forEach((block, idx, array) => { + treeTraverse(block, null, idx, null, array.length - 1 === idx) + }) + }, + + /** + * Build a list of all bindings specifically on the path + * preceding the provided block. + * + * @param {Object} block step object + * @param {Object} automation The complete automation + * @returns + */ + getAvailableBindings: (block, automation) => { - if (!block || !automation) { + if (!block || !automation?.definition) { return [] } - // Find previous steps to the selected one - let allSteps = [...automation.steps] + // Registered blocks + const blocks = get(store).blocks - if (automation.trigger) { - allSteps = [automation.trigger, ...allSteps] - } - - if (1 == 1) { - return - } - let blockIdx = allSteps.findIndex(step => step.id === block.id) + // Get all preceeding steps, including the trigger + // Filter out the target step as we don't want to include itself + const pathSteps = store.actions + .getPathSteps(block.pathTo, automation) + .slice(0, -1) // 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( @@ -166,40 +313,37 @@ const automationActions = store => ({ ) } - const determineRuntimeBinding = (name, idx, isLoopBlock, bindingName) => { + const determineRuntimeBinding = (name, idx, isLoopBlock) => { let runtimeName /* Begin special cases for generating custom schemas based on triggers */ if ( idx === 0 && - automation.trigger?.event === AutomationEventType.APP_TRIGGER + automation.definition.trigger?.event === AutomationEventType.APP_TRIGGER ) { return `trigger.fields.${name}` } if ( idx === 0 && - (automation.trigger?.event === AutomationEventType.ROW_UPDATE || - automation.trigger?.event === AutomationEventType.ROW_SAVE) + (automation.definition.trigger?.event === + AutomationEventType.ROW_UPDATE || + automation.definition.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] + //let hasUserDefinedName = automation.stepNames?.[pathSteps[idx]?.id] if (isLoopBlock) { runtimeName = `loop.${name}` - } else if (block.name.startsWith("JS")) { - runtimeName = hasUserDefinedName - ? `stepsByName[${bindingName}].${name}` - : `steps[${idx - loopBlockCount}].${name}` + } else if (idx === 0) { + runtimeName = `trigger.${name}` } else { - runtimeName = hasUserDefinedName - ? `stepsByName.${bindingName}.${name}` - : `steps.${idx - loopBlockCount}.${name}` + runtimeName = `steps.${pathSteps[idx]?.id}.${name}` } - return idx === 0 ? `trigger.${name}` : runtimeName + return runtimeName } const determineCategoryName = (idx, isLoopBlock, bindingName) => { @@ -226,7 +370,7 @@ const automationActions = store => ({ ) return { readableBinding: - bindingName && !isLoopBlock + bindingName && !isLoopBlock && idx !== 0 ? `steps.${bindingName}.${name}` : runtimeBinding, runtimeBinding, @@ -242,20 +386,25 @@ const automationActions = store => ({ } } - 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 + let loopBlockCount = 0 - if (isLoopBlock) { + for (let blockIdx = 0; blockIdx < pathSteps.length; blockIdx++) { + const pathBlock = pathSteps[blockIdx] + const bindingName = + automation.definition.stepNames?.[pathBlock.id] || pathBlock.name + + let schema = cloneDeep(pathBlock?.schema?.outputs?.properties) ?? {} + + const isLoopBlock = + pathBlock.stepId === ActionStepID.LOOP && + pathBlock.blockToLoop in blocks + + const isTrigger = pathBlock.type === AutomationStepType.TRIGGER + + // Add the loop schema + // Should only be visible for blocks[pathBlock.id].looped + // Only a once otherwise there will be 1 per loop block + if (isLoopBlock && loopBlockCount == 0) { schema = { currentItem: { type: "string", @@ -264,168 +413,63 @@ const automationActions = store => ({ } } - if ( - idx === 0 && - automation.trigger?.event === AutomationEventType.APP_TRIGGER - ) { + const icon = isTrigger + ? pathBlock.icon + : isLoopBlock + ? "Reuse" + : pathBlock.icon + + if (blockIdx === 0 && isTrigger) { schema = Object.fromEntries( - Object.keys(automation.trigger.inputs.fields || []).map(key => [ + Object.keys(pathBlock.inputs.fields || []).map(key => [ key, - { type: automation.trigger.inputs.fields[key] }, + { type: pathBlock.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, + + if ( + pathBlock.event === AutomationEventType.ROW_UPDATE || + pathBlock.event === AutomationEventType.ROW_SAVE + ) { + let table = get(tables).list.find( + table => table._id === pathBlock.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 } - // remove the original binding - delete schema.row } - let icon = - idx === 0 - ? automation.trigger.icon - : isLoopBlock - ? "Reuse" - : allSteps[idx].icon - if (wasLoopBlock) { + // Add the loop outputs to a looped block + if (blocks[pathBlock.id]?.looped) { loopBlockCount++ - schema = cloneDeep(allSteps[idx - 1]?.schema?.outputs?.properties) + + const loopBlockId = blocks[pathBlock.id].looped + const loopBlock = pathSteps.find(step => step.id === loopBlockId) + if (loopBlock) { + schema = cloneDeep(loopBlock.schema?.outputs?.properties) + } else { + console.error("Loop block missing.") + } } + Object.entries(schema).forEach(([name, value]) => { - addBinding(name, value, icon, idx, isLoopBlock, bindingName) + addBinding(name, value, icon, blockIdx, isLoopBlock, bindingName) }) } - // 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) { - // loopBlockCount++ - // schema = cloneDeep(allSteps[idx - 1]?.schema?.outputs?.properties) - // } - // Object.entries(schema).forEach(([name, value]) => { - // addBinding(name, value, icon, idx, isLoopBlock, bindingName) - // }) - // } + // Remove loop items + if (!block.looped) { + bindings = bindings.filter(x => !x.readableBinding.includes("loop")) + } return bindings }, - - // $: automationStore.actions.traverse($selectedAutomation) - getPathBindings: step => { - console.log("Hello world", step) - // get(store) - // build the full path worth of bindings - /* - [..globals], - blocks[...step.id].binding, - ppath. - */ - return [] - }, - traverse: automation => { - let blocks = [] - if (automation.definition.trigger) { - blocks.push(automation.definition.trigger) - } - blocks = blocks.concat(automation.definition.steps || []) - - const treeTraverse = (block, pathTo, stepIdx, branchIdx) => { - const pathToCurrentNode = [ - ...(pathTo || []), - { - ...(Number.isInteger(branchIdx) ? { branchIdx } : {}), - stepIdx, - id: block.id, - ...(block.stepId === "TRIGGER" ? { isTrigger: true } : {}), - }, - ] - const branches = block.inputs?.branches || [] - - branches.forEach((branch, bIdx) => { - block.inputs?.children[branch.name].forEach((bBlock, sIdx) => { - treeTraverse(bBlock, pathToCurrentNode, sIdx, bIdx) - }) - }) - - if (block.stepId !== "BRANCH") { - //dev, log.text - console.log(pathToCurrentNode, block.inputs.text) - //store.actions.registerBlock(block) - } - store.actions.registerBlock(block) - } - - //maybe a cfg block for cleanliness - blocks.forEach((block, idx) => treeTraverse(block, null, idx)) - }, definitions: async () => { const response = await API.getAutomationDefinitions() store.update(state => { @@ -602,12 +646,17 @@ const automationActions = store => ({ return get(store).automations?.find(x => x._id === id) }, getUpdatedDefinition: (automation, block) => { - let newAutomation = cloneDeep(automation) + let newAutomation if (automation.definition.trigger?.id === block.id) { + newAutomation = cloneDeep(automation) newAutomation.definition.trigger = block } else { - const idx = automation.definition.steps.findIndex(x => x.id === block.id) - newAutomation.definition.steps.splice(idx, 1, block) + const pathToStep = get(automationStore).blocks[block.id].pathTo + newAutomation = automationStore.actions.updateStep( + pathToStep, + automation, + block + ) } return newAutomation }, @@ -651,27 +700,336 @@ const automationActions = store => ({ inputs: blockDefinition.inputs || {}, stepId, type, - id: generate(), // this can this be relied on + id: generate(), } newName = getNewStepName(get(selectedAutomation), newStep) newStep.name = newName return newStep }, - addBlockToAutomation: async (block, blockIdx) => { + /** + * Generate a new branch block for adding to the automation + * There are a minimum of 2 empty branches by default. + * + * @returns {Object} - a default branch block + */ + generateBranchBlock: () => { + const branchDefinition = get(automationStore).blockDefinitions.ACTION.BRANCH + const branchBlock = automationStore.actions.constructBlock( + "ACTION", + "BRANCH", + branchDefinition + ) + return branchBlock + }, + + /** + * Take a newly constructed block and insert it in the automation tree + * at the specified location. + * + * @param {Object} block the new block + * @param {Array} pathWay location of insert point + */ + addBlockToAutomation: async (block, pathWay) => { const automation = get(selectedAutomation) let newAutomation = cloneDeep(automation) - if (!automation) { + + const steps = [ + newAutomation.definition.trigger, + ...newAutomation.definition.steps, + ] + + let cache + pathWay.forEach((path, pathIdx, array) => { + const { stepIdx, branchIdx } = path + const final = pathIdx === array.length - 1 + + const insertBlock = (steps, stepIdx) => { + const isBranchNode = !Number.isInteger(stepIdx) + + // If it's a loop block, insert at the looped block stepIdx + const insertIdx = + block.blockToLoop || isBranchNode ? stepIdx : stepIdx + 1 + + steps.splice(insertIdx, 0, block) + } + + if (!cache) { + if (final) { + // Offset path to accommodate the + insertBlock(newAutomation.definition.steps, stepIdx - 1) + cache = block + } else { + cache = steps[stepIdx] + } + return + } + + if (Number.isInteger(branchIdx)) { + const branchId = cache.inputs.branches[branchIdx].id + const children = cache.inputs.children[branchId] + + if (final) { + insertBlock(children, stepIdx) + cache = children + } else { + cache = children[stepIdx] + } + } + }) + + try { + await store.actions.save(newAutomation) + } catch (e) { + notifications.error("Error adding automation block") + console.error("Automation adding block ", e) + } + }, + + /** + * Generates a new branch in the tree at the given location. + * All steps below the path, if any, are added to a new default branch + * 2 branch nodes are created by default. + * + * @param {Array} path - the insertion point on the tree. + * @param {Object} automation - the target automation to update. + */ + branchAutomation: async (path, automation) => { + const insertPoint = path.at(-1) + let newAutomation = cloneDeep(automation) + let cache = null + let atRoot = false + + // Generate a default empty branch + const createBranch = name => { + const baseConditionUI = { + logicalOperator: "all", + onEmptyFilter: "none", + groups: [], + } + return { + name: name, + condition: QueryUtils.buildQuery(baseConditionUI), + conditionUI: baseConditionUI, + id: generate(), + } + } + + path.forEach((path, pathIdx, array) => { + const { stepIdx, branchIdx } = path + const final = pathIdx === array.length - 1 + + if (!cache) { + if (final) { + cache = newAutomation.definition.steps + atRoot = true + } else { + // Initial trigger offset + cache = newAutomation.definition.steps[stepIdx - 1] + } + } + + if (Number.isInteger(branchIdx)) { + const branchId = cache.inputs.branches[branchIdx].id + const children = cache.inputs.children[branchId] + + // return all step siblings + cache = final ? children : children[stepIdx] + } + }) + + // Trigger offset when inserting + const rootIdx = Math.max(insertPoint.stepIdx - 1, 0) + const insertIdx = atRoot ? rootIdx : insertPoint.stepIdx + + // Check if the branch point is a on a branch step + // Create an empty branch instead and append it + if (cache[insertIdx]?.stepId == "BRANCH") { + let branches = cache[insertIdx].inputs.branches + const branchEntry = createBranch(`Branch ${branches.length + 1}`) + + // Splice the branch entry in + branches.splice(branches.length, 0, branchEntry) + + // Add default children entry for the new branch + cache[insertIdx].inputs.children[branchEntry.id] = [] + + try { + await store.actions.save(newAutomation) + } catch (e) { + notifications.error("Error adding branch to automation") + console.error("Error adding automation branch", e) + } return } + // Creating a new branch block + const newBranch = store.actions.generateBranchBlock() + + // Default branch node count is 2. Build 2 default entries + newBranch.inputs.branches = Array.from({ length: 2 }).map((_, idx) => { + return createBranch(`Branch ${idx + 1}`) + }) + + // Init the branch children. Shift all steps following the new branch + // into the 0th branch. + newBranch.inputs.children = newBranch.inputs.branches.reduce( + (acc, branch, idx) => { + acc[branch.id] = idx == 0 ? cache.slice(insertIdx + 1) : [] + return acc + }, + {} + ) + + // Purge siblings that were branched + cache.splice(insertIdx + 1) + + // Add the new branch to the end. + cache.push(newBranch) + try { - updateStepReferences(newAutomation.definition.steps, blockIdx, "add") + await store.actions.save(newAutomation) } catch (e) { - notifications.error("Error adding automation block") + notifications.error("Error adding branch to automation") + console.error("Error adding automation branch", e) } - newAutomation.definition.steps.splice(blockIdx, 0, block) - await store.actions.save(newAutomation) }, + + /** + * Take a block and move the provided branch to the left + * + * @param {Array} pathTo + * @param {Object} automation + * @param {Object} block + */ + branchLeft: async (pathTo, automation, block) => { + const update = store.actions.shiftBranch(pathTo, block) + const updatedAuto = store.actions.updateStep( + pathTo.slice(0, -1), + automation, + update + ) + await store.actions.save(updatedAuto) + }, + + /** + * Take a block and move the provided branch right + * + * @param {Array} pathTo + * @param {Object} automation + * @param {Object} block + */ + branchRight: async (pathTo, automation, block) => { + const update = store.actions.shiftBranch(pathTo, block, 1) + const updatedAuto = store.actions.updateStep( + pathTo.slice(0, -1), + automation, + update + ) + await store.actions.save(updatedAuto) + }, + + /** + * Shift swap a branch with its immediate neighbour. + * @param {Array} pathTo - address of the branch to be moved. + * @param {Object} block - the step the branch belongs to + * @param {Number} direction - the direction of the swap. Defaults to -1 for left, add 1 for right + * @returns + */ + shiftBranch(pathTo, block, direction = -1) { + let newBlock = cloneDeep(block) + const branchPath = pathTo.at(-1) + const targetIdx = branchPath.branchIdx + + if (!newBlock.inputs.branches[targetIdx + direction]) { + console.error("Invalid index") + return + } + + let [neighbour] = newBlock.inputs.branches.splice(targetIdx + direction, 1) + + // Put it back in the previous position. + newBlock.inputs.branches.splice(targetIdx, 0, neighbour) + + return newBlock + }, + + /** + * Delete a branch at the given path + * When branch count reaches 1, the branch children are removed + * and replace the parent branch step at its index. + * + * @param {Array} path + * @param {Array} automation + */ + deleteBranch: async (path, automation) => { + let newAutomation = cloneDeep(automation) + let cache = [] + + path.forEach((path, pathIdx, array) => { + const { stepIdx, branchIdx } = path + const final = pathIdx === array.length - 1 + + // The first poi + if (!cache.length) { + if (final) { + cache = newAutomation.definition.steps + return + } + // Trigger offset + cache = [ + { + node: newAutomation.definition.steps[stepIdx - 1], + context: newAutomation.definition.steps, + }, + ] + } + + const current = cache.at(-1) + + if (Number.isInteger(branchIdx)) { + // data.inputs.branches.length + const branchId = current.node.inputs.branches[branchIdx].id + const children = current.node.inputs.children[branchId] + + if (final) { + // 2 is the minimum amount of nodes on a branch + const minBranches = current.node.inputs.branches.length == 2 + + // Delete the target branch and its contents. + current.node.inputs.branches.splice(branchIdx, 1) + delete current.node.inputs.children[branchId] + + // If deleting with only 2 branches, the entire branch step + // will be deleted, with its contents placed onto the parent. + if (minBranches) { + const lastBranchId = current.node.inputs.branches[0].id + const lastBranchContent = current.node.inputs.children[lastBranchId] + + // Take the remaining branch and push all children onto the context + const parentContext = cache.at(-1).context + + // Remove the branch node. + parentContext.pop() + + // Splice in the remaining branch content into the parent. + parentContext.splice(parentContext.length, 0, ...lastBranchContent) + } + + return + } + + cache.push({ node: children[stepIdx], context: children }) + } + }) + + try { + await store.actions.save(newAutomation) + } catch (e) { + notifications.error("Error deleting automation branch") + console.error("Error deleting automation branch", e) + } + }, + saveAutomationName: async (blockId, name) => { const automation = get(selectedAutomation) let newAutomation = cloneDeep(automation) @@ -717,28 +1075,94 @@ const automationActions = store => ({ await store.actions.save(newAutomation) }, - deleteAutomationBlock: async (block, blockIdx) => { + /** + * Delete the block at a given path. + * Any related blocks, like loops, are purged at the same time + * + * @param {Array} pathTo + */ + deleteAutomationBlock: async pathTo => { const automation = get(selectedAutomation) let newAutomation = cloneDeep(automation) - // Delete trigger if required - if (newAutomation.definition.trigger?.id === block.id) { - delete newAutomation.definition.trigger - } else { - // Otherwise remove step - newAutomation.definition.steps = newAutomation.definition.steps.filter( - step => step.id !== block.id - ) - delete newAutomation.definition.stepNames?.[block.id] - } + 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 + } + } + }) + try { - updateStepReferences(newAutomation.definition.steps, blockIdx, "delete") + await store.actions.save(newAutomation) } catch (e) { notifications.error("Error deleting automation block") + console.error("Automation deleting block ", e) } - - await store.actions.save(newAutomation) }, + replace: async (automationId, automation) => { if (!automation) { store.update(state => { @@ -784,9 +1208,12 @@ export const selectedAutomation = derived(automationStore, $automationStore => { if (!$automationStore.selectedAutomationId) { return null } - return $automationStore.automations?.find( + + const selected = $automationStore.automations?.find( x => x._id === $automationStore.selectedAutomationId ) + + return selected }) export const selectedAutomationDisplayData = derived( diff --git a/packages/frontend-core/src/components/ConditionField.svelte b/packages/frontend-core/src/components/ConditionField.svelte new file mode 100644 index 0000000000..32ade43995 --- /dev/null +++ b/packages/frontend-core/src/components/ConditionField.svelte @@ -0,0 +1,177 @@ + + +
+ + + + + + +
+
+ +
+ +
+ {#if !disabled} + + +
{ + bindingDrawer.show() + }} + > + +
+ {/if} +
+
+
+ + diff --git a/packages/frontend-core/src/components/CoreFilterBuilder.svelte b/packages/frontend-core/src/components/CoreFilterBuilder.svelte index c711a57e1c..a68ab994af 100644 --- a/packages/frontend-core/src/components/CoreFilterBuilder.svelte +++ b/packages/frontend-core/src/components/CoreFilterBuilder.svelte @@ -16,6 +16,7 @@ import { QueryUtils, Constants } from "@budibase/frontend-core" import { getContext, createEventDispatcher } from "svelte" import FilterField from "./FilterField.svelte" + import ConditionField from "./ConditionField.svelte" const dispatch = createEventDispatcher() const { @@ -32,8 +33,10 @@ export let datasource export let behaviourFilters = false export let allowBindings = false + export let allowOnEmpty = true + export let builderType = "filter" + export let docsURL = "https://docs.budibase.com/docs/searchfilter-data" - // Review export let bindings export let panel export let toReadable @@ -91,6 +94,10 @@ } const getValidOperatorsForType = filter => { + if (builderType === "condition") { + return [OperatorOptions.Equals, OperatorOptions.NotEquals] + } + if (!filter?.field && !filter?.name) { return [] } @@ -210,6 +217,9 @@ } else if (addFilter) { targetGroup.filters.push({ valueType: FilterValueType.VALUE, + ...(builderType === "condition" + ? { operator: OperatorOptions.Equals.value } + : {}), }) } else if (group) { editable.groups[groupIdx] = { @@ -274,7 +284,7 @@ placeholder={false} /> - of the following filter groups: + of the following {builderType} groups: {/if} {#if editableFilters?.groups?.length} @@ -303,7 +313,7 @@ placeholder={false} /> - of the following filters are matched: + of the following {builderType}s are matched:
{#each group.filters as filter, filterIdx}
- { + const updated = { ...filter, field: e.detail } + onFieldChange(updated) + onFilterFieldUpdate(updated, groupIdx, filterIdx) + }} + placeholder="Column" + /> + {:else} + { + const updated = { + ...filter, + field: e.detail.field, + } + delete updated.valueType + onFilterFieldUpdate(updated, groupIdx, filterIdx) + }} + /> + {/if}