1
0
Fork 0
mirror of synced 2024-07-04 22:11:23 +12:00

Undo/Redo for Design and Automate sections + automations refactor (#9714)

* Add full undo/redo support for screens

* Add loading states to disable spamming undo/redo

* Add keyboard shortcuts for undo and redo

* Fix modals not closing in design section when escape is pressed

* Remove log

* Add smart metadata saving to undo/redo

* Add error handling to undo/redo

* Add active state to hoverable icons

* Fix screen deletion

* Always attempt to get latest doc version before deleting in case rev has changed

* Move undo listener top level, hide controls when on certain tabs, and improve selection state

* Add tooltips to undo/redo control

* Update automation section nav to match other sections

* Fix automation list padding

* Fix some styles in create automation modal

* Improve automation section styles and add undo/redo

* Update styles in add action modal

* Fix button size when creating admin user

* Fix styles in add automation step modal

* Fix issue selecting disabled automation steps

* Reset automation history store when changing app

* Reduce spammy unnecessary API calls when editing cron trigger

* WIP automation refactor

* Rewrite most automation state

* Rewrite most of the rest of automation state

* Finish refactor of automation state

* Fix selection state when selecting new doc after history recreates it

* Prune nullish or empty block inputs from automations and avoid sending API requests when no changes have been made

* Fix animation issues with automations

* Sort automations and refetch list when adding or deleting

* Fix formatting

* Add back in ability to swap between values and bindings for block inputs

* Lint

* Format

* Fix potential issue in design section when selected screen is unset

* Fix automation arrow directions everywhere, tidy up logic and fix crash when using invalid looping

* Lint

* Fix more cases of automation errors

* Fix implicity any TS error

* Respect _id specified when creating automations

* Fix crash in history store when reverting a change on a doc whose ID has changed

* Lint

* Ensure cloneDeep helper doesn't crash when a nullish value is passed in

* Remove deprecated frontend automation test

---------

Co-authored-by: Rory Powell <rory.codes@gmail.com>
This commit is contained in:
Andrew Kingston 2023-02-23 13:55:18 +00:00 committed by GitHub
parent 351ea232f7
commit 8cd7ba1fdf
43 changed files with 926 additions and 722 deletions

View file

@ -67,6 +67,9 @@
color: var(--spectrum-alias-icon-color-selected-hover) !important;
cursor: pointer;
}
svg.hoverable:active {
color: var(--spectrum-global-color-blue-400) !important;
}
svg.disabled {
color: var(--spectrum-global-color-gray-500) !important;

View file

@ -57,5 +57,7 @@
--spectrum-semantic-negative-icon-color: #e34850;
min-width: 100px;
margin: 0;
border-color: var(--spectrum-global-color-gray-400);
border-width: 1px;
}
</style>

View file

@ -21,7 +21,7 @@
label {
padding: 0;
white-space: nowrap;
color: var(--spectrum-global-color-gray-600);
color: var(--spectrum-global-color-gray-700);
}
.muted {

View file

@ -1,7 +1,7 @@
<script>
import "@spectrum-css/modal/dist/index-vars.css"
import "@spectrum-css/underlay/dist/index-vars.css"
import { createEventDispatcher, setContext, tick } from "svelte"
import { createEventDispatcher, setContext, tick, onMount } from "svelte"
import { fade, fly } from "svelte/transition"
import Portal from "svelte-portal"
import Context from "../context"
@ -62,9 +62,14 @@
}
setContext(Context.Modal, { show, hide, cancel })
</script>
<svelte:window on:keydown={handleKey} />
onMount(() => {
document.addEventListener("keydown", handleKey)
return () => {
document.removeEventListener("keydown", handleKey)
}
})
</script>
{#if inline}
{#if visible}

View file

@ -104,6 +104,9 @@ export const deepSet = (obj, key, value) => {
* @param obj the object to clone
*/
export const cloneDeep = obj => {
if (!obj) {
return obj
}
return JSON.parse(JSON.stringify(obj))
}

View file

@ -72,6 +72,7 @@
"codemirror": "^5.59.0",
"dayjs": "^1.11.2",
"downloadjs": "1.4.7",
"fast-json-patch": "^3.1.1",
"lodash": "4.17.21",
"posthog-js": "^1.36.0",
"remixicon": "2.5.0",

View file

@ -5,12 +5,47 @@ import { getThemeStore } from "./store/theme"
import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core"
import { createHistoryStore } from "builderStore/store/history"
import { get } from "svelte/store"
export const store = getFrontendStore()
export const automationStore = getAutomationStore()
export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore()
// Setup history for screens
export const screenHistoryStore = createHistoryStore({
getDoc: id => get(store).screens?.find(screen => screen._id === id),
selectDoc: store.actions.screens.select,
afterAction: () => {
// Ensure a valid component is selected
if (!get(selectedComponent)) {
store.update(state => ({
...state,
selectedComponentId: get(selectedScreen)?.props._id,
}))
}
},
})
store.actions.screens.save = screenHistoryStore.wrapSaveDoc(
store.actions.screens.save
)
store.actions.screens.delete = screenHistoryStore.wrapDeleteDoc(
store.actions.screens.delete
)
// Setup history for automations
export const automationHistoryStore = createHistoryStore({
getDoc: automationStore.actions.getDefinition,
selectDoc: automationStore.actions.select,
})
automationStore.actions.save = automationHistoryStore.wrapSaveDoc(
automationStore.actions.save
)
automationStore.actions.delete = automationHistoryStore.wrapDeleteDoc(
automationStore.actions.delete
)
export const selectedScreen = derived(store, $store => {
return $store.screens.find(screen => screen._id === $store.selectedScreenId)
})
@ -71,3 +106,13 @@ export const selectedComponentPath = derived(
).map(component => component._id)
}
)
// Derived automation state
export const selectedAutomation = derived(automationStore, $automationStore => {
if (!$automationStore.selectedAutomationId) {
return null
}
return $automationStore.automations?.find(
x => x._id === $automationStore.selectedAutomationId
)
})

View file

@ -1,69 +0,0 @@
import { generate } from "shortid"
/**
* Class responsible for the traversing of the automation definition.
* Automation definitions are stored in linked lists.
*/
export default class Automation {
constructor(automation) {
this.automation = automation
}
hasTrigger() {
return this.automation.definition.trigger
}
addTestData(data) {
this.automation.testData = { ...this.automation.testData, ...data }
}
addBlock(block, idx) {
// Make sure to add trigger if doesn't exist
if (!this.hasTrigger() && block.type === "TRIGGER") {
const trigger = { id: generate(), ...block }
this.automation.definition.trigger = trigger
return trigger
}
const newBlock = { id: generate(), ...block }
this.automation.definition.steps.splice(idx, 0, newBlock)
return newBlock
}
updateBlock(updatedBlock, id) {
const { steps, trigger } = this.automation.definition
if (trigger && trigger.id === id) {
this.automation.definition.trigger = updatedBlock
return
}
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1, updatedBlock)
this.automation.definition.steps = steps
}
deleteBlock(id) {
const { steps, trigger } = this.automation.definition
if (trigger && trigger.id === id) {
this.automation.definition.trigger = null
return
}
const stepIdx = steps.findIndex(step => step.id === id)
if (stepIdx < 0) throw new Error("Block not found.")
steps.splice(stepIdx, 1)
this.automation.definition.steps = steps
}
constructBlock(type, stepId, blockDefinition) {
return {
...blockDefinition,
inputs: blockDefinition.inputs || {},
stepId,
type,
}
}
}

View file

@ -1,16 +1,18 @@
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import { API } from "api"
import Automation from "./Automation"
import { cloneDeep } from "lodash/fp"
import { generate } from "shortid"
import { selectedAutomation } from "builderStore"
const initialAutomationState = {
automations: [],
testResults: null,
showTestPanel: false,
blockDefinitions: {
TRIGGER: [],
ACTION: [],
},
selectedAutomation: null,
selectedAutomationId: null,
}
export const getAutomationStore = () => {
@ -37,49 +39,41 @@ const automationActions = store => ({
API.getAutomationDefinitions(),
])
store.update(state => {
let selected = state.selectedAutomation?.automation
state.automations = responses[0]
state.automations.sort((a, b) => {
return a.name < b.name ? -1 : 1
})
state.blockDefinitions = {
TRIGGER: responses[1].trigger,
ACTION: responses[1].action,
}
// If previously selected find the new obj and select it
if (selected) {
selected = responses[0].filter(
automation => automation._id === selected._id
)
state.selectedAutomation = new Automation(selected[0])
}
return state
})
},
create: async ({ name }) => {
create: async (name, trigger) => {
const automation = {
name,
type: "automation",
definition: {
steps: [],
trigger,
},
}
const response = await API.createAutomation(automation)
store.update(state => {
state.automations = [...state.automations, response.automation]
store.actions.select(response.automation)
return state
})
const response = await store.actions.save(automation)
await store.actions.fetch()
store.actions.select(response._id)
return response
},
duplicate: async automation => {
const response = await API.createAutomation({
const response = await store.actions.save({
...automation,
name: `${automation.name} - copy`,
_id: undefined,
_ref: undefined,
})
store.update(state => {
state.automations = [...state.automations, response.automation]
store.actions.select(response.automation)
return state
})
await store.actions.fetch()
store.actions.select(response._id)
return response
},
save: async automation => {
const response = await API.updateAutomation(automation)
@ -90,11 +84,13 @@ const automationActions = store => ({
)
if (existingIdx !== -1) {
state.automations.splice(existingIdx, 1, updatedAutomation)
state.automations = [...state.automations]
store.actions.select(updatedAutomation)
return state
} else {
state.automations = [...state.automations, updatedAutomation]
}
return state
})
return response.automation
},
delete: async automation => {
await API.deleteAutomation({
@ -102,34 +98,83 @@ const automationActions = store => ({
automationRev: automation?._rev,
})
store.update(state => {
const existingIdx = state.automations.findIndex(
existing => existing._id === automation?._id
// Remove the automation
state.automations = state.automations.filter(
x => x._id !== automation._id
)
state.automations.splice(existingIdx, 1)
state.automations = [...state.automations]
state.selectedAutomation = null
state.selectedBlock = null
// Select a new automation if required
if (automation._id === state.selectedAutomationId) {
store.actions.select(state.automations[0]?._id)
}
return state
})
await store.actions.fetch()
},
updateBlockInputs: async (block, data) => {
// Create new modified block
let newBlock = {
...block,
inputs: {
...block.inputs,
...data,
},
}
// Remove any nullish or empty string values
Object.keys(newBlock.inputs).forEach(key => {
const val = newBlock.inputs[key]
if (val == null || val === "") {
delete newBlock.inputs[key]
}
})
// Create new modified automation
const automation = get(selectedAutomation)
const newAutomation = store.actions.getUpdatedDefinition(
automation,
newBlock
)
// Don't save if no changes were made
if (JSON.stringify(newAutomation) === JSON.stringify(automation)) {
return
}
await store.actions.save(newAutomation)
},
test: async (automation, testData) => {
store.update(state => {
state.selectedAutomation.testResults = null
return state
})
const result = await API.testAutomation({
automationId: automation?._id,
testData,
})
if (!result?.trigger && !result?.steps?.length) {
throw "Something went wrong testing your automation"
}
store.update(state => {
state.selectedAutomation.testResults = result
state.testResults = result
return state
})
},
select: automation => {
getDefinition: id => {
return get(store).automations?.find(x => x._id === id)
},
getUpdatedDefinition: (automation, block) => {
let newAutomation = cloneDeep(automation)
if (automation.definition.trigger?.id === block.id) {
newAutomation.definition.trigger = block
} else {
const idx = automation.definition.steps.findIndex(x => x.id === block.id)
newAutomation.definition.steps.splice(idx, 1, block)
}
return newAutomation
},
select: id => {
if (!id || id === get(store).selectedAutomationId) {
return
}
store.update(state => {
state.selectedAutomation = new Automation(cloneDeep(automation))
state.selectedBlock = null
state.selectedAutomationId = id
state.testResults = null
state.showTestPanel = false
return state
})
},
@ -147,48 +192,57 @@ const automationActions = store => ({
appId,
})
},
addTestDataToAutomation: data => {
store.update(state => {
state.selectedAutomation.addTestData(data)
return state
})
addTestDataToAutomation: async data => {
let newAutomation = cloneDeep(get(selectedAutomation))
newAutomation.testData = {
...newAutomation.testData,
...data,
}
await store.actions.save(newAutomation)
},
addBlockToAutomation: (block, blockIdx) => {
store.update(state => {
state.selectedBlock = state.selectedAutomation.addBlock(
cloneDeep(block),
blockIdx
)
return state
})
constructBlock(type, stepId, blockDefinition) {
return {
...blockDefinition,
inputs: blockDefinition.inputs || {},
stepId,
type,
id: generate(),
}
},
toggleFieldControl: value => {
store.update(state => {
state.selectedBlock.rowControl = value
return state
})
addBlockToAutomation: async (block, blockIdx) => {
const automation = get(selectedAutomation)
let newAutomation = cloneDeep(automation)
if (!automation) {
return
}
newAutomation.definition.steps.splice(blockIdx, 0, block)
await store.actions.save(newAutomation)
},
deleteAutomationBlock: block => {
store.update(state => {
const idx =
state.selectedAutomation.automation.definition.steps.findIndex(
x => x.id === block.id
)
state.selectedAutomation.deleteBlock(block.id)
/**
* "rowControl" appears to be the name of the flag used to determine whether
* a certain automation block uses values or bindings as inputs
*/
toggleRowControl: async (block, rowControl) => {
const newBlock = { ...block, rowControl }
const newAutomation = store.actions.getUpdatedDefinition(
get(selectedAutomation),
newBlock
)
await store.actions.save(newAutomation)
},
deleteAutomationBlock: async block => {
const automation = get(selectedAutomation)
let newAutomation = cloneDeep(automation)
// Select next closest step
const steps = state.selectedAutomation.automation.definition.steps
let nextSelectedBlock
if (steps[idx] != null) {
nextSelectedBlock = steps[idx]
} else if (steps[idx - 1] != null) {
nextSelectedBlock = steps[idx - 1]
} else {
nextSelectedBlock =
state.selectedAutomation.automation.definition.trigger || null
}
state.selectedBlock = nextSelectedBlock
return state
})
// 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
)
}
await store.actions.save(newAutomation)
},
})

View file

@ -1,48 +0,0 @@
import Automation from "../Automation"
import TEST_AUTOMATION from "./testAutomation"
const TEST_BLOCK = {
id: "AUXJQGZY7",
name: "Delay",
icon: "ri-time-fill",
tagline: "Delay for <b>{{time}}</b> milliseconds",
description: "Delay the automation until an amount of time has passed.",
params: { time: "number" },
type: "LOGIC",
args: { time: "5000" },
stepId: "DELAY",
}
describe("Automation Data Object", () => {
let automation
beforeEach(() => {
automation = new Automation({ ...TEST_AUTOMATION })
})
it("adds a automation block to the automation", () => {
automation.addBlock(TEST_BLOCK)
expect(automation.automation.definition)
})
it("updates a automation block with new attributes", () => {
const firstBlock = automation.automation.definition.steps[0]
const updatedBlock = {
...firstBlock,
name: "UPDATED",
}
automation.updateBlock(updatedBlock, firstBlock.id)
expect(automation.automation.definition.steps[0]).toEqual(updatedBlock)
})
it("deletes a automation block successfully", () => {
const { steps } = automation.automation.definition
const originalLength = steps.length
const lastBlock = steps[steps.length - 1]
automation.deleteBlock(lastBlock.id)
expect(automation.automation.definition.steps.length).toBeLessThan(
originalLength
)
})
})

View file

@ -1,78 +0,0 @@
export default {
name: "Test automation",
definition: {
steps: [
{
id: "ANBDINAPS",
description: "Send an email.",
tagline: "Send email to <b>{{to}}</b>",
icon: "ri-mail-open-fill",
name: "Send Email",
params: {
to: "string",
from: "string",
subject: "longText",
text: "longText",
},
type: "ACTION",
args: {
text: "A user was created!",
subject: "New Budibase User",
from: "budimaster@budibase.com",
to: "test@test.com",
},
stepId: "SEND_EMAIL",
},
],
trigger: {
id: "iRzYMOqND",
name: "Row Saved",
event: "row:save",
icon: "ri-save-line",
tagline: "Row is added to <b>{{table.name}}</b>",
description: "Fired when a row is saved to your database.",
params: { table: "table" },
type: "TRIGGER",
args: {
table: {
type: "table",
views: {},
name: "users",
schema: {
name: {
type: "string",
constraints: {
type: "string",
length: { maximum: 123 },
presence: { allowEmpty: false },
},
name: "name",
},
age: {
type: "number",
constraints: {
type: "number",
presence: { allowEmpty: false },
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
name: "age",
},
},
_id: "c6b4e610cd984b588837bca27188a451",
_rev: "7-b8aa1ce0b53e88928bb88fc11bdc0aff",
},
},
stepId: "ROW_SAVED",
},
},
type: "automation",
ok: true,
id: "b384f861f4754e1693835324a7fcca62",
rev: "1-aa1c2cbd868ef02e26f8fad531dd7e37",
live: false,
_id: "b384f861f4754e1693835324a7fcca62",
_rev: "108-4116829ec375e0481d0ecab9e83a2caf",
}

View file

@ -1,6 +1,11 @@
import { get, writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
import { selectedScreen, selectedComponent } from "builderStore"
import {
selectedScreen,
selectedComponent,
screenHistoryStore,
automationHistoryStore,
} from "builderStore"
import {
datasources,
integrations,
@ -122,6 +127,8 @@ export const getFrontendStore = () => {
navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [],
}))
screenHistoryStore.reset()
automationHistoryStore.reset()
// Initialise backend stores
database.set(application.instance)
@ -179,10 +186,7 @@ export const getFrontendStore = () => {
}
// Check screen isn't already selected
if (
state.selectedScreenId === screen._id &&
state.selectedComponentId === screen.props?._id
) {
if (state.selectedScreenId === screen._id) {
return
}
@ -256,7 +260,7 @@ export const getFrontendStore = () => {
}
},
save: async screen => {
/*
/*
Temporarily disabled to accomodate migration issues.
store.actions.screens.validate(screen)
*/
@ -347,6 +351,7 @@ export const getFrontendStore = () => {
return state
})
return null
},
updateSetting: async (screen, name, value) => {
if (!screen || !name) {

View file

@ -0,0 +1,319 @@
import * as jsonpatch from "fast-json-patch/index.mjs"
import { writable, derived, get } from "svelte/store"
const Operations = {
Add: "Add",
Delete: "Delete",
Change: "Change",
}
const initialState = {
history: [],
position: 0,
loading: false,
}
export const createHistoryStore = ({
getDoc,
selectDoc,
beforeAction,
afterAction,
}) => {
// Use a derived store to check if we are able to undo or redo any operations
const store = writable(initialState)
const derivedStore = derived(store, $store => {
return {
...$store,
canUndo: $store.position > 0,
canRedo: $store.position < $store.history.length,
}
})
// Wrapped versions of essential functions which we call ourselves when using
// undo and redo
let saveFn
let deleteFn
/**
* Internal util to set the loading flag
*/
const startLoading = () => {
store.update(state => {
state.loading = true
return state
})
}
/**
* Internal util to unset the loading flag
*/
const stopLoading = () => {
store.update(state => {
state.loading = false
return state
})
}
/**
* Resets history state
*/
const reset = () => {
store.set(initialState)
}
/**
* Adds or updates an operation in history.
* For internal use only.
* @param operation the operation to save
*/
const saveOperation = operation => {
store.update(state => {
// Update history
let history = state.history
let position = state.position
if (!operation.id) {
// Every time a new operation occurs we discard any redo potential
operation.id = Math.random()
history = [...history.slice(0, state.position), operation]
position += 1
} else {
// If this is a redo/undo of an existing operation, just update history
// to replace the doc object as revisions may have changed
const idx = history.findIndex(op => op.id === operation.id)
history[idx].doc = operation.doc
}
return { history, position }
})
}
/**
* Wraps the save function, which asynchronously updates a doc.
* The returned function is an enriched version of the real save function so
* that we can control history.
* @param fn the save function
* @returns {function} a wrapped version of the save function
*/
const wrapSaveDoc = fn => {
saveFn = async (doc, operationId) => {
// Only works on a single doc at a time
if (!doc || Array.isArray(doc)) {
return
}
startLoading()
try {
const oldDoc = getDoc(doc._id)
const newDoc = jsonpatch.deepClone(await fn(doc))
// Store the change
if (!oldDoc) {
// If no old doc, this is an add operation
saveOperation({
type: Operations.Add,
doc: newDoc,
id: operationId,
})
} else {
// Otherwise this is a change operation
saveOperation({
type: Operations.Change,
forwardPatch: jsonpatch.compare(oldDoc, doc),
backwardsPatch: jsonpatch.compare(doc, oldDoc),
doc: newDoc,
id: operationId,
})
}
stopLoading()
return newDoc
} catch (error) {
// We want to allow errors to propagate up to normal handlers, but we
// want to stop loading first
stopLoading()
throw error
}
}
return saveFn
}
/**
* Wraps the delete function, which asynchronously deletes a doc.
* The returned function is an enriched version of the real delete function so
* that we can control history.
* @param fn the delete function
* @returns {function} a wrapped version of the delete function
*/
const wrapDeleteDoc = fn => {
deleteFn = async (doc, operationId) => {
// Only works on a single doc at a time
if (!doc || Array.isArray(doc)) {
return
}
startLoading()
try {
const oldDoc = jsonpatch.deepClone(doc)
await fn(doc)
saveOperation({
type: Operations.Delete,
doc: oldDoc,
id: operationId,
})
stopLoading()
} catch (error) {
// We want to allow errors to propagate up to normal handlers, but we
// want to stop loading first
stopLoading()
throw error
}
}
return deleteFn
}
/**
* Asynchronously undoes the previous operation.
* Optionally selects the changed document so that changes are visible.
* @returns {Promise<void>}
*/
const undo = async () => {
// Sanity checks
const { canUndo, history, position, loading } = get(derivedStore)
if (!canUndo || loading) {
return
}
const operation = history[position - 1]
if (!operation) {
return
}
startLoading()
// Before hook
await beforeAction?.(operation)
// Update state immediately to prevent further clicks and to prevent bad
// history in the event of an update failing
store.update(state => {
return {
...state,
position: state.position - 1,
}
})
// Undo the operation
try {
// Undo ADD
if (operation.type === Operations.Add) {
// Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id)
const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id)
}
// Undo DELETE
else if (operation.type === Operations.Delete) {
// Delete the _rev from the deleted doc so that we can save it as a new
// doc again without conflicts
let doc = jsonpatch.deepClone(operation.doc)
delete doc._rev
const created = await saveFn(doc, operation.id)
selectDoc?.(created?._id || doc._id)
}
// Undo CHANGE
else {
// Get the current doc and apply the backwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
if (doc) {
jsonpatch.applyPatch(
doc,
jsonpatch.deepClone(operation.backwardsPatch)
)
await saveFn(doc, operation.id)
selectDoc?.(doc._id)
}
}
stopLoading()
} catch (error) {
stopLoading()
throw error
}
// After hook
await afterAction?.(operation)
}
/**
* Asynchronously redoes the previous undo.
* Optionally selects the changed document so that changes are visible.
* @returns {Promise<void>}
*/
const redo = async () => {
// Sanity checks
const { canRedo, history, position, loading } = get(derivedStore)
if (!canRedo || loading) {
return
}
const operation = history[position]
if (!operation) {
return
}
startLoading()
// Before hook
await beforeAction?.(operation)
// Update state immediately to prevent further clicks and to prevent bad
// history in the event of an update failing
store.update(state => {
return {
...state,
position: state.position + 1,
}
})
// Redo the operation
try {
// Redo ADD
if (operation.type === Operations.Add) {
// Delete the _rev from the deleted doc so that we can save it as a new
// doc again without conflicts
let doc = jsonpatch.deepClone(operation.doc)
delete doc._rev
const created = await saveFn(doc, operation.id)
selectDoc?.(created?._id || doc._id)
}
// Redo DELETE
else if (operation.type === Operations.Delete) {
// Try to get the latest doc version to delete
const latestDoc = getDoc(operation.doc._id)
const doc = latestDoc || operation.doc
await deleteFn(doc, operation.id)
}
// Redo CHANGE
else {
// Get the current doc and apply the forwards patch on top of it
let doc = jsonpatch.deepClone(getDoc(operation.doc._id))
if (doc) {
jsonpatch.applyPatch(doc, jsonpatch.deepClone(operation.forwardPatch))
await saveFn(doc, operation.id)
selectDoc?.(doc._id)
}
}
stopLoading()
} catch (error) {
stopLoading()
throw error
}
// After hook
await afterAction?.(operation)
}
return {
subscribe: derivedStore.subscribe,
wrapSaveDoc,
wrapDeleteDoc,
reset,
undo,
redo,
}
}

View file

@ -1,10 +1,10 @@
<script>
import { automationStore } from "builderStore"
import { selectedAutomation } from "builderStore"
import Flowchart from "./FlowChart/FlowChart.svelte"
$: automation = $automationStore.selectedAutomation?.automation
</script>
{#if automation}
<Flowchart {automation} />
{#if $selectedAutomation}
{#key $selectedAutomation._id}
<Flowchart automation={$selectedAutomation} />
{/key}
{/if}

View file

@ -5,7 +5,6 @@
Detail,
Body,
Icon,
Tooltip,
notifications,
} from "@budibase/bbui"
import { automationStore } from "builderStore"
@ -13,7 +12,6 @@
import { externalActions } from "./ExternalActions"
export let blockIdx
export let blockComplete
const disabled = {
SEND_EMAIL_SMTP: {
@ -50,15 +48,12 @@
async function addBlockToAutomation() {
try {
const newBlock = $automationStore.selectedAutomation.constructBlock(
const newBlock = automationStore.actions.constructBlock(
"ACTION",
actionVal.stepId,
actionVal
)
automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
} catch (error) {
notifications.error("Error saving automation")
}
@ -66,20 +61,14 @@
</script>
<ModalContent
title="Create Automation"
title="Add automation step"
confirmText="Save"
size="M"
disabled={!selectedAction}
onConfirm={() => {
blockComplete = true
addBlockToAutomation()
}}
onConfirm={addBlockToAutomation}
>
<Body size="XS">Select an app or event.</Body>
<Layout noPadding>
<Body size="S">Apps</Body>
<Layout noPadding gap="XS">
<Detail size="S">Apps</Detail>
<div class="item-list">
{#each Object.entries(external) as [idx, action]}
<div
@ -95,64 +84,45 @@
alt="zapier"
/>
<span class="icon-spacing">
<Body size="XS">{idx.charAt(0).toUpperCase() + idx.slice(1)}</Body
></span
>
<Body size="XS">
{idx.charAt(0).toUpperCase() + idx.slice(1)}
</Body>
</span>
</div>
</div>
{/each}
</div>
</Layout>
<Layout noPadding gap="XS">
<Detail size="S">Actions</Detail>
<div class="item-list">
{#each Object.entries(internal) as [idx, action]}
{#if disabled[idx] && disabled[idx].disabled}
<Tooltip text={disabled[idx].message} direction="bottom">
<div
class="item"
class:selected={selectedAction === action.name}
class:disabled={true}
on:click={() => selectAction(action)}
>
<div class="item-body">
<Icon name={action.icon} />
<span class="icon-spacing">
<Body size="XS">{action.name}</Body></span
>
</div>
</div>
</Tooltip>
{:else}
<div
class="item"
class:selected={selectedAction === action.name}
on:click={() => selectAction(action)}
>
<div class="item-body">
<Icon name={action.icon} />
<span class="icon-spacing">
<Body size="XS">{action.name}</Body></span
>
</div>
{@const isDisabled = disabled[idx] && disabled[idx].disabled}
<div
class="item"
class:disabled={isDisabled}
class:selected={selectedAction === action.name}
on:click={isDisabled ? null : () => selectAction(action)}
>
<div class="item-body">
<Icon name={action.icon} />
<Body size="XS">{action.name}</Body>
{#if isDisabled}
<Icon name="Help" tooltip={disabled[idx].message} />
{/if}
</div>
{/if}
</div>
{/each}
</div>
</Layout>
</ModalContent>
<style>
.disabled {
opacity: 0.3;
pointer-events: none;
}
.icon-spacing {
margin-left: var(--spacing-m);
}
.item-body {
display: flex;
margin-left: var(--spacing-m);
gap: var(--spacing-m);
}
.item-list {
display: grid;
@ -171,8 +141,15 @@
box-sizing: border-box;
border-width: 2px;
}
.item:hover,
.item:not(.disabled):hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.disabled {
background: var(--spectrum-global-color-gray-200);
color: var(--spectrum-global-color-gray-500);
}
.disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600);
}
</style>

View file

@ -1,5 +1,5 @@
<script>
import { automationStore } from "builderStore"
import { automationStore, selectedAutomation } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import FlowItem from "./FlowItem.svelte"
import TestDataModal from "./TestDataModal.svelte"
@ -13,27 +13,28 @@
Modal,
} from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import { automationHistoryStore } from "builderStore"
export let automation
let testDataModal
let blocks
let confirmDeleteDialog
$: {
blocks = []
if (automation) {
if (automation.definition.trigger) {
blocks.push(automation.definition.trigger)
}
blocks = blocks.concat(automation.definition.steps || [])
$: blocks = getBlocks(automation)
const getBlocks = automation => {
let blocks = []
if (automation.definition.trigger) {
blocks.push(automation.definition.trigger)
}
blocks = blocks.concat(automation.definition.steps || [])
return blocks
}
async function deleteAutomation() {
try {
await automationStore.actions.delete(
$automationStore.selectedAutomation?.automation
)
await automationStore.actions.delete($selectedAutomation)
} catch (error) {
notifications.error("Error deleting automation")
}
@ -41,20 +42,17 @@
</script>
<div class="canvas">
<div style="float: left; padding-left: var(--spacing-xl);">
<div class="header">
<Heading size="S">{automation.name}</Heading>
</div>
<div style="float: right; padding-right: var(--spacing-xl);" class="title">
<div class="subtitle">
<div style="display:flex; align-items: center;">
<div class="icon">
<Icon
on:click={confirmDeleteDialog.show}
hoverable
size="M"
name="DeleteOutline"
/>
</div>
<div class="controls">
<UndoRedoControl store={automationHistoryStore} />
<Icon
on:click={confirmDeleteDialog.show}
hoverable
size="M"
name="DeleteOutline"
/>
<div class="buttons">
<ActionButton
on:click={() => {
testDataModal.show()
@ -62,15 +60,13 @@
icon="MultipleCheck"
size="M">Run test</ActionButton
>
<div style="padding-left: var(--spacing-m);">
<ActionButton
disabled={!$automationStore.selectedAutomation?.testResults}
on:click={() => {
$automationStore.showTestPanel = true
}}
size="M">Test Details</ActionButton
>
</div>
<ActionButton
disabled={!$automationStore.testResults}
on:click={() => {
$automationStore.showTestPanel = true
}}
size="M">Test Details</ActionButton
>
</div>
</div>
</div>
@ -80,7 +76,7 @@
<div
class="block"
animate:flip={{ duration: 500 }}
in:fly|local={{ x: 500, duration: 500 }}
in:fly={{ x: 500, duration: 500 }}
out:fly|local={{ x: 500, duration: 500 }}
>
{#if block.stepId !== ActionStepID.LOOP}
@ -105,6 +101,9 @@
</Modal>
<style>
.canvas {
padding: var(--spacing-l) var(--spacing-xl);
}
/* Fix for firefox not respecting bottom padding in scrolling containers */
.canvas > *:last-child {
padding-bottom: 40px;
@ -122,18 +121,19 @@
text-align: left;
}
.title {
padding-bottom: var(--spacing-xl);
}
.subtitle {
padding-bottom: var(--spacing-xl);
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.icon {
cursor: pointer;
padding-right: var(--spacing-m);
.controls,
.buttons {
display: flex;
justify-content: flex-end;
align-items: center;
gap: var(--spacing-xl);
}
.buttons {
gap: var(--spacing-s);
}
</style>

View file

@ -1,5 +1,5 @@
<script>
import { automationStore } from "builderStore"
import { automationStore, selectedAutomation } from "builderStore"
import {
Icon,
Divider,
@ -23,36 +23,26 @@
export let block
export let testDataModal
export let idx
let selected
let webhookModal
let actionModal
let blockComplete
let open = true
let showLooping = false
let role
$: automationId = $automationStore.selectedAutomation?.automation._id
$: automationId = $selectedAutomation?._id
$: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW ||
block.stepId === ActionStepID.UPDATE_ROW
$: isTrigger = block.type === "TRIGGER"
$: selected = $automationStore.selectedBlock?.id === block.id
$: steps =
$automationStore.selectedAutomation?.automation?.definition?.steps ?? []
$: steps = $selectedAutomation?.definition?.steps ?? []
$: blockIdx = steps.findIndex(step => step.id === block.id)
$: lastStep = !isTrigger && blockIdx + 1 === steps.length
$: totalBlocks =
$automationStore.selectedAutomation?.automation?.definition?.steps.length +
1
$: loopingSelected =
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
$: totalBlocks = $selectedAutomation?.definition?.steps.length + 1
$: loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block.id
)
$: isAppAction = block?.stepId === TriggerStepID.APP
$: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId)
@ -81,76 +71,54 @@
}
async function removeLooping() {
loopingSelected = false
let loopBlock =
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
automationStore.actions.deleteAutomationBlock(loopBlock)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
let loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block.id
)
try {
await automationStore.actions.deleteAutomationBlock(loopBlock)
} catch (error) {
notifications.error("Error saving automation")
}
}
async function deleteStep() {
let loopBlock =
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
let loopBlock = $selectedAutomation?.definition.steps.find(
x => x.blockToLoop === block.id
)
try {
if (loopBlock) {
automationStore.actions.deleteAutomationBlock(loopBlock)
await automationStore.actions.deleteAutomationBlock(loopBlock)
}
automationStore.actions.deleteAutomationBlock(block)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
await automationStore.actions.deleteAutomationBlock(block)
} catch (error) {
notifications.error("Error saving notification")
notifications.error("Error saving automation")
}
}
function toggleFieldControl(evt) {
onSelect(block)
let rowControl
if (evt.detail === "Use values") {
rowControl = false
} else {
rowControl = true
}
automationStore.actions.toggleFieldControl(rowControl)
automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
/**
* "rowControl" appears to be the name of the flag used to determine whether
* a certain automation block uses values or bindings as inputs
*/
function toggleRowControl(evt) {
const rowControl = evt.detail !== "Use values"
automationStore.actions.toggleRowControl(block, rowControl)
}
async function addLooping() {
loopingSelected = true
const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP
const loopBlock = $automationStore.selectedAutomation.constructBlock(
const loopBlock = automationStore.actions.constructBlock(
"ACTION",
"LOOP",
loopDefinition
)
loopBlock.blockToLoop = block.id
block.loopBlock = loopBlock.id
automationStore.actions.addBlockToAutomation(loopBlock, blockIdx)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
}
async function onSelect(block) {
await automationStore.update(state => {
state.selectedBlock = block
return state
})
await automationStore.actions.addBlockToAutomation(loopBlock, blockIdx)
}
</script>
<div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}>
{#if loopingSelected}
{#if loopBlock}
<div class="blockSection">
<div
on:click={() => {
@ -174,13 +142,8 @@
</div>
<div class="blockTitle">
<div
style="margin-left: 10px;"
on:click={() => {
onSelect(block)
}}
>
<Icon name={showLooping ? "ChevronUp" : "ChevronDown"} />
<div style="margin-left: 10px;" on:click={() => {}}>
<Icon hoverable name={showLooping ? "ChevronDown" : "ChevronUp"} />
</div>
</div>
</div>
@ -198,9 +161,7 @@
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs
.properties
)}
block={$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)}
block={loopBlock}
{webhookModal}
/>
</Layout>
@ -209,22 +170,28 @@
{/if}
{/if}
<FlowItemHeader bind:blockComplete {block} {testDataModal} {idx} />
{#if !blockComplete}
<FlowItemHeader
{open}
{block}
{testDataModal}
{idx}
on:toggle={() => (open = !open)}
/>
{#if open}
<Divider noMargin />
<div class="blockSection">
<Layout noPadding gap="S">
{#if !isTrigger}
<div>
<div class="block-options">
{#if !loopingSelected}
<ActionButton on:click={() => addLooping()} icon="Reuse"
>Add Looping</ActionButton
>
{#if !loopBlock}
<ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping
</ActionButton>
{/if}
{#if showBindingPicker}
<Select
on:change={toggleFieldControl}
on:change={toggleRowControl}
defaultValue="Use values"
autoWidth
value={block.rowControl ? "Use bindings" : "Use values"}
@ -250,16 +217,16 @@
{webhookModal}
/>
{#if lastStep}
<Button on:click={() => testDataModal.show()} cta
>Finish and test automation</Button
>
<Button on:click={() => testDataModal.show()} cta>
Finish and test automation
</Button>
{/if}
</Layout>
</div>
{/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {blockIdx} bind:blockComplete />
<ActionModal {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">

View file

@ -2,21 +2,22 @@
import { automationStore } from "builderStore"
import { Icon, Body, Detail, StatusLight } from "@budibase/bbui"
import { externalActions } from "./ExternalActions"
import { createEventDispatcher } from "svelte"
export let block
export let blockComplete
export let open
export let showTestStatus = false
export let showParameters = {}
export let testResult
export let isTrigger
export let idx
const dispatch = createEventDispatcher()
$: {
if (!testResult) {
testResult =
$automationStore.selectedAutomation?.testResults?.steps.filter(step =>
block.id ? step.id === block.id : step.stepId === block.stepId
)[0]
testResult = $automationStore.testResults?.steps?.filter(step =>
block.id ? step.id === block.id : step.stepId === block.stepId
)?.[0]
}
}
$: isTrigger = isTrigger || block.type === "TRIGGER"
@ -45,13 +46,7 @@
</script>
<div class="blockSection">
<div
on:click={() => {
blockComplete = !blockComplete
showParameters[block.id] = blockComplete
}}
class="splitHeader"
>
<div on:click={() => dispatch("toggle")} class="splitHeader">
<div class="center-items">
{#if externalActions[block.stepId]}
<img
@ -99,7 +94,7 @@
onSelect(block)
}}
>
<Icon hoverable name={blockComplete ? "ChevronUp" : "ChevronDown"} />
<Icon hoverable name={open ? "ChevronUp" : "ChevronDown"} />
</div>
</div>
</div>

View file

@ -7,7 +7,7 @@
Label,
notifications,
} from "@budibase/bbui"
import { automationStore } from "builderStore"
import { automationStore, selectedAutomation } from "builderStore"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import { cloneDeep } from "lodash/fp"
@ -17,9 +17,7 @@
$: {
// clone the trigger so we're not mutating the reference
trigger = cloneDeep(
$automationStore.selectedAutomation.automation.definition.trigger
)
trigger = cloneDeep($selectedAutomation.definition.trigger)
// get the outputs so we can define the fields
let schema = Object.entries(trigger.schema?.outputs?.properties || {})
@ -32,7 +30,7 @@
}
// check to see if there is existing test data in the store
$: testData = $automationStore.selectedAutomation.automation.testData || {}
$: testData = $selectedAutomation.testData || {}
// Check the schema to see if required fields have been entered
$: isError = !trigger.schema.outputs.required.every(
@ -51,10 +49,7 @@
const testAutomation = async () => {
try {
await automationStore.actions.test(
$automationStore.selectedAutomation?.automation,
testData
)
await automationStore.actions.test($selectedAutomation, testData)
$automationStore.showTestPanel = true
} catch (error) {
notifications.error("Error testing automation")
@ -70,8 +65,8 @@
onConfirm={testAutomation}
cancelText="Cancel"
>
<Tabs selected="Form" quiet
><Tab icon="Form" title="Form">
<Tabs selected="Form" quiet>
<Tab icon="Form" title="Form">
<div class="tab-content-padding">
<AutomationBlockSetup
{testData}
@ -86,11 +81,7 @@
<Label>JSON</Label>
<div class="text-area-container">
<TextArea
value={JSON.stringify(
$automationStore.selectedAutomation.automation.testData,
null,
2
)}
value={JSON.stringify($selectedAutomation.testData, null, 2)}
error={failedParse}
on:change={e => parseTestJSON(e)}
/>

View file

@ -7,7 +7,7 @@
export let testResults
export let width = "400px"
let showParameters
let openBlocks = {}
let blocks
function prepTestResults(results) {
@ -48,14 +48,15 @@
<div class="block" style={width ? `width: ${width}` : ""}>
{#if block.stepId !== ActionStepID.LOOP}
<FlowItemHeader
showTestStatus={true}
bind:showParameters
{block}
open={!!openBlocks[block.id]}
on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
isTrigger={idx === 0}
{idx}
testResult={filteredResults?.[idx]}
showTestStatus
{block}
{idx}
/>
{#if showParameters && showParameters[block.id]}
{#if openBlocks[block.id]}
<Divider noMargin />
{#if filteredResults?.[idx]?.outputs.iterations}
<div style="display: flex; padding: 10px 10px 0px 12px;">

View file

@ -2,26 +2,8 @@
import { Icon, Divider } from "@budibase/bbui"
import TestDisplay from "./TestDisplay.svelte"
import { automationStore } from "builderStore"
import { ActionStepID } from "constants/backend/automations"
export let automation
let blocks, testResults
$: {
blocks = []
if (automation) {
if (automation.definition.trigger) {
blocks.push(automation.definition.trigger)
}
blocks = blocks
.concat(automation.definition.steps || [])
.filter(x => x.stepId !== ActionStepID.LOOP)
} else if ($automationStore.selectedAutomation) {
automation = $automationStore.selectedAutomation
}
}
$: testResults = $automationStore.selectedAutomation?.testResults
</script>
<div class="title">
@ -42,7 +24,7 @@
<Divider />
<TestDisplay {automation} {testResults} />
<TestDisplay {automation} testResults={$automationStore.testResults} />
<style>
.title {

View file

@ -1,12 +1,11 @@
<script>
import { onMount } from "svelte"
import { goto } from "@roxi/routify"
import { automationStore } from "builderStore"
import { automationStore, selectedAutomation } from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import EditAutomationPopover from "./EditAutomationPopover.svelte"
import { notifications } from "@budibase/bbui"
$: selectedAutomationId = $automationStore.selectedAutomation?.automation?._id
$: selectedAutomationId = $selectedAutomation?._id
onMount(async () => {
try {
@ -16,9 +15,8 @@
}
})
function selectAutomation(automation) {
automationStore.actions.select(automation)
$goto(`./${automation._id}`)
function selectAutomation(id) {
automationStore.actions.select(id)
}
</script>
@ -29,7 +27,7 @@
icon="ShareAndroid"
text={automation.name}
selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation)}
on:click={() => selectAutomation(automation._id)}
>
<EditAutomationPopover {automation} />
</NavItem>
@ -42,5 +40,6 @@
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
margin: 0 calc(-1 * var(--spacing-xl));
}
</style>

View file

@ -1,36 +1,20 @@
<script>
import AutomationList from "./AutomationList.svelte"
import CreateAutomationModal from "./CreateAutomationModal.svelte"
import { Modal, Tabs, Tab, Button, Layout } from "@budibase/bbui"
import { Modal, Button, Layout } from "@budibase/bbui"
import Panel from "components/design/Panel.svelte"
export let modal
export let webhookModal
</script>
<div class="nav">
<Tabs selected="Automations">
<Tab title="Automations">
<Layout paddingX="L" paddingY="L" gap="S">
<Button cta wide on:click={modal.show}>Add automation</Button>
</Layout>
<AutomationList />
<Modal bind:this={modal}>
<CreateAutomationModal {webhookModal} />
</Modal>
</Tab>
</Tabs>
</div>
<Panel title="Automations" borderRight>
<Layout paddingX="L" paddingY="XL" gap="S">
<Button cta on:click={modal.show}>Add automation</Button>
<AutomationList />
</Layout>
</Panel>
<style>
.nav {
overflow-y: auto;
background: var(--background);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
position: relative;
border-right: var(--border-light);
padding-bottom: 60px;
}
</style>
<Modal bind:this={modal}>
<CreateAutomationModal {webhookModal} />
</Modal>

View file

@ -1,6 +1,4 @@
<script>
import { goto } from "@roxi/routify"
import { database } from "stores/backend"
import { automationStore } from "builderStore"
import { notifications } from "@budibase/bbui"
import {
@ -10,48 +8,37 @@
Layout,
Body,
Icon,
Label,
} from "@budibase/bbui"
import { TriggerStepID } from "constants/backend/automations"
export let webhookModal
let name
let selectedTrigger
let nameTouched = false
let triggerVal
export let webhookModal
$: instanceId = $database._id
$: nameError =
nameTouched && !name ? "Please specify a name for the automation." : null
$: triggers = Object.entries($automationStore.blockDefinitions.TRIGGER)
async function createAutomation() {
try {
await automationStore.actions.create({
name,
instanceId,
})
const newBlock = $automationStore.selectedAutomation.constructBlock(
const trigger = automationStore.actions.constructBlock(
"TRIGGER",
triggerVal.stepId,
triggerVal
)
automationStore.actions.addBlockToAutomation(newBlock)
await automationStore.actions.create(name, trigger)
if (triggerVal.stepId === TriggerStepID.WEBHOOK) {
webhookModal.show
webhookModal.show()
}
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
notifications.success(`Automation ${name} created`)
$goto(`./${$automationStore.selectedAutomation.automation._id}`)
} catch (error) {
notifications.error("Error creating automation")
}
}
$: triggers = Object.entries($automationStore.blockDefinitions.TRIGGER)
const selectTrigger = trigger => {
triggerVal = trigger
@ -70,9 +57,9 @@
header="You must publish your app to activate your automations."
message="To test your automation before publishing, you can use the 'Run Test' functionality on the next screen."
/>
<Body size="XS"
>Please name your automation, then select a trigger. Every automation must
start with a trigger.
<Body size="S">
Please name your automation, then select a trigger.<br />
Every automation must start with a trigger.
</Body>
<Input
bind:value={name}
@ -81,9 +68,8 @@
label="Name"
/>
<Layout noPadding>
<Body size="S">Triggers</Body>
<Layout noPadding gap="XS">
<Label size="S">Trigger</Label>
<div class="item-list">
{#each triggers as [idx, trigger]}
<div

View file

@ -1,5 +1,4 @@
<script>
import { goto } from "@roxi/routify"
import { automationStore } from "builderStore"
import { ActionMenu, MenuItem, notifications, Icon } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -14,7 +13,6 @@
try {
await automationStore.actions.delete(automation)
notifications.success("Automation deleted successfully")
$goto("../automate")
} catch (error) {
notifications.error("Error deleting automation")
}
@ -24,7 +22,6 @@
try {
await automationStore.actions.duplicate(automation)
notifications.success("Automation has been duplicated successfully")
$goto(`./${$automationStore.selectedAutomation.automation._id}`)
} catch (error) {
notifications.error("Error duplicating automation")
}

View file

@ -3,13 +3,13 @@
import { notifications } from "@budibase/bbui"
import { Icon, Input, ModalContent, Modal } from "@budibase/bbui"
export let automation
export let onCancel = undefined
let name
let error = ""
let modal
export let automation
export let onCancel = undefined
export const show = () => {
name = automation?.name
modal.show()

View file

@ -15,8 +15,7 @@
notifications,
} from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore } from "builderStore"
import { automationStore, selectedAutomation } from "builderStore"
import { tables } from "stores/backend"
import { environment, licensing } from "stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
@ -50,22 +49,8 @@
$: filters = lookForFilters(schemaProperties) || []
$: tempFilters = filters
$: stepId = block.stepId
$: bindings = getAvailableBindings(
block || $automationStore.selectedBlock,
$automationStore.selectedAutomation?.automation?.definition
)
$: bindings = getAvailableBindings(block, $selectedAutomation?.definition)
$: getInputData(testData, block.inputs)
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}
inputData = newInputData
}
$: tableId = inputData ? inputData.tableId : null
$: table = tableId
? $tables.list.find(table => table._id === inputData.tableId)
@ -76,39 +61,48 @@
$: isTrigger = block?.type === "TRIGGER"
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}
inputData = newInputData
}
const onChange = Utils.sequential(async (e, key) => {
// We need to cache the schema as part of the definition because it is
// used in the server to detect relationships. It would be far better to
// instead fetch the schema in the backend at runtime.
let schema
if (e.detail?.tableId) {
const tableSchema = getSchemaForTable(e.detail.tableId, {
schema = getSchemaForTable(e.detail.tableId, {
searchableSchema: true,
}).schema
if (isTestModal) {
testData.schema = tableSchema
} else {
block.inputs.schema = tableSchema
}
}
try {
if (isTestModal) {
let newTestData = { schema }
// Special case for webhook, as it requires a body, but the schema already brings back the body's contents
if (stepId === TriggerStepID.WEBHOOK) {
automationStore.actions.addTestDataToAutomation({
newTestData = {
...newTestData,
body: {
[key]: e.detail,
...$automationStore.selectedAutomation.automation.testData?.body,
...$selectedAutomation.testData?.body,
},
})
}
}
automationStore.actions.addTestDataToAutomation({
newTestData = {
...newTestData,
[key]: e.detail,
})
testData[key] = e.detail
}
await automationStore.actions.addTestDataToAutomation(newTestData)
} else {
block.inputs[key] = e.detail
const data = { schema, [key]: e.detail }
await automationStore.actions.updateBlockInputs(block, data)
}
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
)
} catch (error) {
notifications.error("Error saving automation")
}

View file

@ -5,7 +5,11 @@
const dispatch = createEventDispatcher()
export let value
const onChange = e => {
if (e.detail === value) {
return
}
value = e.detail
dispatch("change", e.detail)
}
@ -43,7 +47,12 @@
</script>
<div class="block-field">
<Input on:change={onChange} {value} on:blur={() => (touched = true)} />
<Input
on:change={onChange}
{value}
on:blur={() => (touched = true)}
updateOnChange={false}
/>
{#if touched && !value}
<Label><div class="error">Please specify a CRON expression</div></Label>
{/if}

View file

@ -1,17 +1,18 @@
<script>
import { Icon, notifications } from "@budibase/bbui"
import { automationStore } from "builderStore"
import { automationStore, selectedAutomation } from "builderStore"
import WebhookDisplay from "./WebhookDisplay.svelte"
import { ModalContent } from "@budibase/bbui"
import { onMount, onDestroy } from "svelte"
const POLL_RATE_MS = 2500
let interval
let finished = false
let schemaURL
let propCount = 0
$: automation = $automationStore.selectedAutomation?.automation
$: automation = $selectedAutomation
onMount(async () => {
if (!automation?.definition?.trigger?.inputs.schemaUrl) {

View file

@ -0,0 +1,57 @@
<script>
import { Icon } from "@budibase/bbui"
import { onMount } from "svelte"
export let store
const handleKeyPress = e => {
if (!(e.ctrlKey || e.metaKey)) {
return
}
if (e.shiftKey && e.key === "Z") {
store.redo()
} else if (e.key === "z") {
store.undo()
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyPress)
return () => {
document.removeEventListener("keydown", handleKeyPress)
}
})
</script>
<div class="undo-redo">
<Icon
name="Undo"
hoverable
on:click={store.undo}
disabled={!$store.canUndo}
tooltip="Undo latest change"
/>
<Icon
name="Redo"
hoverable
on:click={store.redo}
disabled={!$store.canRedo}
tooltip="Redo latest undo"
/>
</div>
<style>
.undo-redo {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xs);
padding-right: var(--spacing-xl);
border-right: var(--border-light);
}
.undo-redo :global(svg) {
padding: 6px;
}
</style>

View file

@ -42,29 +42,22 @@
return
}
try {
await automationStore.actions.create({
name: parameters.newAutomationName,
})
const appActionDefinition = $automationStore.blockDefinitions.TRIGGER.APP
const newBlock = $automationStore.selectedAutomation.constructBlock(
let trigger = automationStore.actions.constructBlock(
"TRIGGER",
"APP",
appActionDefinition
$automationStore.blockDefinitions.TRIGGER.APP
)
newBlock.inputs = {
trigger.inputs = {
fields: Object.keys(parameters.fields ?? {}).reduce((fields, key) => {
fields[key] = "string"
return fields
}, {}),
}
automationStore.actions.addBlockToAutomation(newBlock)
await automationStore.actions.save(
$automationStore.selectedAutomation?.automation
const automation = await automationStore.actions.create(
parameters.newAutomationName,
trigger
)
parameters.automationId =
$automationStore.selectedAutomation.automation._id
parameters.automationId = automation._id
delete parameters.newAutomationName
} catch (error) {
notifications.error("Error creating automation")

View file

@ -149,6 +149,7 @@
<Layout gap="XS" noPadding justifyItems="center">
<Button
cta
size="L"
disabled={Object.keys(errors).length > 0 || submitted}
on:click={save}
>

View file

@ -1,30 +1,40 @@
<script>
import { Heading, Body, Layout, Button, Modal } from "@budibase/bbui"
import { automationStore } from "builderStore"
import { automationStore, selectedAutomation } from "builderStore"
import AutomationPanel from "components/automation/AutomationPanel/AutomationPanel.svelte"
import CreateAutomationModal from "components/automation/AutomationPanel/CreateAutomationModal.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import TestPanel from "components/automation/AutomationBuilder/TestPanel.svelte"
import { onMount } from "svelte"
import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify"
$: automation =
$automationStore.selectedAutomation?.automation ||
$automationStore.automations[0]
// Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({
urlParam: "automationId",
stateKey: "selectedAutomationId",
validate: id => $automationStore.automations.some(x => x._id === id),
fallbackUrl: "./index",
store: automationStore,
up: automationStore.actions.select,
routify,
})
let modal
let webhookModal
onMount(() => {
$automationStore.showTestPanel = false
})
onDestroy(stopSyncing)
</script>
<!-- routify:options index=3 -->
<div class="root">
<div class="nav">
<AutomationPanel {modal} {webhookModal} />
</div>
<AutomationPanel {modal} {webhookModal} />
<div class="content">
{#if automation}
{#if $automationStore.automations?.length}
<slot />
{:else}
<div class="centered">
@ -40,9 +50,9 @@
</svg>
<Heading size="M">You have no automations</Heading>
<Body size="M">Let's fix that. Call the bots!</Body>
<Button on:click={() => modal.show()} size="M" cta
>Create automation</Button
>
<Button on:click={() => modal.show()} size="M" cta>
Create automation
</Button>
</Layout>
</div>
</div>
@ -51,7 +61,7 @@
{#if $automationStore.showTestPanel}
<div class="setup">
<TestPanel {automation} />
<TestPanel automation={$selectedAutomation} />
</div>
{/if}
<Modal bind:this={modal}>
@ -71,22 +81,8 @@
grid-template-columns: 260px minmax(510px, 1fr) fit-content(500px);
overflow: hidden;
}
.nav {
overflow-y: auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
border-right: var(--border-light);
background-color: var(--background);
padding-bottom: 60px;
overflow: hidden;
}
.content {
position: relative;
padding-top: var(--spacing-l);
display: flex;
flex-direction: column;
justify-content: flex-start;

View file

@ -1,17 +1,10 @@
<script>
import { redirect, leftover } from "@roxi/routify"
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
import { automationStore } from "builderStore"
onMount(async () => {
// navigate to first automation in list, if not already selected
if (
!$leftover &&
$automationStore.automations.length > 0 &&
(!$automationStore.selectedAutomation ||
!$automationStore.selectedAutomation?.automation?._id)
) {
$: {
if ($automationStore.automations?.length) {
$redirect(`./${$automationStore.automations[0]._id}`)
}
})
}
</script>

View file

@ -1,9 +1,11 @@
<script>
import DevicePreviewSelect from "./DevicePreviewSelect.svelte"
import AppPreview from "./AppPreview.svelte"
import { store, sortedScreens } from "builderStore"
import { store, sortedScreens, screenHistoryStore } from "builderStore"
import { Select } from "@budibase/bbui"
import { RoleUtils } from "@budibase/frontend-core"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import { isActive } from "@roxi/routify"
</script>
<div class="app-panel">
@ -22,6 +24,9 @@
/>
</div>
<div class="header-right">
{#if $isActive("./screens") || $isActive("./components")}
<UndoRedoControl store={screenHistoryStore} />
{/if}
{#if $store.clientFeatures.devicePreview}
<DevicePreviewSelect />
{/if}
@ -52,6 +57,7 @@
align-items: flex-start;
gap: var(--spacing-l);
margin: 0 2px;
z-index: 1;
}
.header-left,
.header-right {
@ -59,7 +65,7 @@
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-l);
gap: var(--spacing-xl);
}
.header-left {
flex: 1 1 auto;

View file

@ -12,30 +12,30 @@
let componentToEject
const keyHandlers = {
["^ArrowUp"]: async component => {
["Ctrl+ArrowUp"]: async component => {
await store.actions.components.moveUp(component)
},
["^ArrowDown"]: async component => {
["Ctrl+ArrowDown"]: async component => {
await store.actions.components.moveDown(component)
},
["^c"]: component => {
["Ctrl+c"]: component => {
store.actions.components.copy(component, false)
},
["^x"]: component => {
["Ctrl+x"]: component => {
store.actions.components.copy(component, true)
},
["^v"]: async component => {
["Ctrl+v"]: async component => {
await store.actions.components.paste(component, "inside")
},
["^d"]: async component => {
["Ctrl+d"]: async component => {
store.actions.components.copy(component)
await store.actions.components.paste(component, "below")
},
["^e"]: component => {
["Ctrl+e"]: component => {
componentToEject = component
confirmEjectDialog.show()
},
["^Enter"]: () => {
["Ctrl+Enter"]: () => {
$goto("./new")
},
["Delete"]: component => {
@ -53,14 +53,19 @@
store.actions.components.selectNext()
},
["Escape"]: () => {
if (!$isActive("/new")) {
return false
if ($isActive("./new")) {
$goto("./")
}
$goto("./")
},
}
const handleKeyAction = async (event, component, key, ctrlKey = false) => {
const handleKeyAction = async ({
event,
component,
key,
ctrlKey = false,
shiftKey = false,
}) => {
if (!component || !key) {
return false
}
@ -69,9 +74,12 @@
if (key === "Backspace") {
key = "Delete"
}
// Prefix key with a caret for ctrl modifier
// Prefix keys for modifiers
if (shiftKey) {
key = "Shift+" + key
}
if (ctrlKey) {
key = "^" + key
key = "Ctrl+" + key
}
const handler = keyHandlers[key]
if (!handler) {
@ -97,19 +105,26 @@
return
}
// Key events are always for the selected component
return await handleKeyAction(
e,
$selectedComponent,
e.key,
e.ctrlKey || e.metaKey
)
return await handleKeyAction({
event: e,
component: $selectedComponent,
key: e.key,
ctrlKey: e.ctrlKey || e.metaKey,
shiftKey: e.shiftKey,
})
}
const handleComponentMenu = async e => {
// Menu events can be for any component
const { id, key, ctrlKey } = e.detail
const { id, key, ctrlKey, shiftKey } = e.detail
const component = findComponent($selectedScreen.props, id)
return await handleKeyAction(null, component, key, ctrlKey)
return await handleKeyAction({
event: null,
component,
key,
ctrlKey,
shiftKey,
})
}
onMount(() => {

View file

@ -25,7 +25,7 @@
let errors = {}
const routeTaken = url => {
const roleId = get(selectedScreen)?.routing.roleId || "BASIC"
const roleId = get(selectedScreen).routing.roleId || "BASIC"
return get(store).screens.some(
screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() &&
@ -34,7 +34,7 @@
}
const roleTaken = roleId => {
const url = get(selectedScreen)?.routing.route
const url = get(selectedScreen).routing.route
return get(store).screens.some(
screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() &&
@ -95,7 +95,7 @@
return sanitizeUrl(val)
},
validate: route => {
const existingRoute = get(selectedScreen)?.routing.route
const existingRoute = get(selectedScreen).routing.route
if (route !== existingRoute && routeTaken(route)) {
return "That URL is already in use for this role"
}
@ -107,7 +107,7 @@
label: "Access",
control: RoleSelect,
validate: role => {
const existingRole = get(selectedScreen)?.routing.roleId
const existingRole = get(selectedScreen).routing.roleId
if (role !== existingRole && roleTaken(role)) {
return "That role is already in use for this URL"
}
@ -146,7 +146,7 @@
</script>
<Panel
title={$selectedScreen?.routing.route}
title={$selectedScreen.routing.route}
icon={$selectedScreen.routing.route === "/" ? "Home" : "WebPage"}
borderLeft
>

View file

@ -5,6 +5,8 @@
</script>
<ScreenListPanel />
{#key $selectedScreen?._id}
<ScreenSettingsPanel />
{/key}
{#if $selectedScreen}
{#key $selectedScreen._id}
<ScreenSettingsPanel />
{/key}
{/if}

View file

@ -3178,6 +3178,11 @@ fast-glob@^3.0.3:
merge2 "^1.3.0"
micromatch "^4.0.4"
fast-json-patch@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/fast-json-patch/-/fast-json-patch-3.1.1.tgz#85064ea1b1ebf97a3f7ad01e23f9337e72c66947"
integrity sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==
fast-json-stable-stringify@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"

View file

@ -65,10 +65,14 @@ export async function create(ctx: BBContext) {
// call through to update if already exists
if (automation._id && automation._rev) {
return update(ctx)
await update(ctx)
return
}
automation._id = generateAutomationID()
// Respect existing IDs if recreating a deleted automation
if (!automation._id) {
automation._id = generateAutomationID()
}
automation.type = "automation"
automation = cleanAutomationInputs(automation)
@ -126,6 +130,13 @@ export async function update(ctx: BBContext) {
const db = context.getAppDB()
let automation = ctx.request.body
automation.appId = ctx.appId
// Call through to create if it doesn't exist
if (!automation._id || !automation._rev) {
await create(ctx)
return
}
const oldAutomation = await db.get(automation._id)
automation = cleanAutomationInputs(automation)
automation = await checkForWebhooks({

View file

@ -38,7 +38,7 @@ router
"/api/automations",
bodyResource("_id"),
authorized(permissions.BUILDER),
automationValidator(true),
automationValidator(false),
controller.update
)
.post(