diff --git a/README.md b/README.md
index 64492b97e4..26ad9f80c2 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,7 @@ Budibase is open-source - licensed as GPL v3. This should fill you with confiden
### Load data or start from scratch
-Budibase pulls data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
+Budibase pulls data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MariaDB, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
diff --git a/packages/builder/src/pages/builder/portal/_components/LockedFeature.svelte b/packages/builder/src/pages/builder/portal/_components/LockedFeature.svelte
index e6f4075e2e..1df724099b 100644
--- a/packages/builder/src/pages/builder/portal/_components/LockedFeature.svelte
+++ b/packages/builder/src/pages/builder/portal/_components/LockedFeature.svelte
@@ -1,10 +1,12 @@
@@ -36,8 +40,9 @@
{:else}
{/if}
@@ -67,7 +82,11 @@
justify-content: flex-start;
gap: var(--spacing-m);
}
-
+ .icon {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ }
.buttons {
display: flex;
flex-direction: row;
diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte
index b91b2129e6..a1d5496ff6 100644
--- a/packages/builder/src/pages/builder/portal/users/users/index.svelte
+++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte
@@ -127,7 +127,10 @@
name: user.firstName ? user.firstName + " " + user.lastName : "",
userGroups,
__selectable:
- role.value === Constants.BudibaseRoles.Owner ? false : undefined,
+ role.value === Constants.BudibaseRoles.Owner ||
+ $auth.user?.email === user.email
+ ? false
+ : true,
apps: [...new Set(Object.keys(user.roles))],
access: role.sortOrder,
}
@@ -392,7 +395,7 @@
allowSelectRows={!readonly}
{customRenderers}
loading={!$fetch.loaded || !groupsLoaded}
- defaultSortColumn={"access"}
+ defaultSortColumn={"__selectable"}
/>
{/if}
-
-
- {#if !empty && mounted}
+ {#if mounted}
{/if}
diff --git a/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte
index 0ad4280e07..61cecc885b 100644
--- a/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte
+++ b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte
@@ -6,8 +6,11 @@
let left, top, height, width
const updatePosition = () => {
- const node =
- document.getElementsByClassName(DNDPlaceholderID)[0]?.childNodes[0]
+ let node = document.getElementsByClassName(DNDPlaceholderID)[0]
+ const insideGrid = node?.dataset.insideGrid === "true"
+ if (!insideGrid) {
+ node = document.getElementsByClassName(`${DNDPlaceholderID}-dom`)[0]
+ }
if (!node) {
height = 0
width = 0
diff --git a/packages/client/src/components/preview/HoverIndicator.svelte b/packages/client/src/components/preview/HoverIndicator.svelte
index d204d77f49..f1c151ce16 100644
--- a/packages/client/src/components/preview/HoverIndicator.svelte
+++ b/packages/client/src/components/preview/HoverIndicator.svelte
@@ -19,7 +19,7 @@
newId = e.target.dataset.id
} else {
// Handle normal components
- const element = e.target.closest(".interactive.component")
+ const element = e.target.closest(".interactive.component:not(.root)")
newId = element?.dataset?.id
}
diff --git a/packages/client/src/stores/dnd.js b/packages/client/src/stores/dnd.js
index 1bdd510eb7..43d4eeeed8 100644
--- a/packages/client/src/stores/dnd.js
+++ b/packages/client/src/stores/dnd.js
@@ -1,5 +1,5 @@
import { writable } from "svelte/store"
-import { computed } from "../utils/computed.js"
+import { derivedMemo } from "@budibase/frontend-core"
const createDndStore = () => {
const initialState = {
@@ -78,11 +78,11 @@ export const dndStore = createDndStore()
// performance by deriving any state that needs to be externally observed.
// By doing this and using primitives, we can avoid invalidating other stores
// or components which depend on DND state unless values actually change.
-export const dndParent = computed(dndStore, x => x.drop?.parent)
-export const dndIndex = computed(dndStore, x => x.drop?.index)
-export const dndBounds = computed(dndStore, x => x.source?.bounds)
-export const dndIsDragging = computed(dndStore, x => !!x.source)
-export const dndIsNewComponent = computed(
+export const dndParent = derivedMemo(dndStore, x => x.drop?.parent)
+export const dndIndex = derivedMemo(dndStore, x => x.drop?.index)
+export const dndBounds = derivedMemo(dndStore, x => x.source?.bounds)
+export const dndIsDragging = derivedMemo(dndStore, x => !!x.source)
+export const dndIsNewComponent = derivedMemo(
dndStore,
x => x.source?.newComponentType != null
)
diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js
index 3c5ece0a6c..bc87216660 100644
--- a/packages/client/src/stores/screens.js
+++ b/packages/client/src/stores/screens.js
@@ -92,6 +92,8 @@ const createScreenStore = () => {
width: `${$dndBounds?.width || 400}px`,
height: `${$dndBounds?.height || 200}px`,
opacity: 0,
+ "--default-width": $dndBounds?.width || 400,
+ "--default-height": $dndBounds?.height || 200,
},
},
static: true,
diff --git a/packages/client/src/utils/computed.js b/packages/client/src/utils/computed.js
deleted file mode 100644
index aa89e7ad1b..0000000000
--- a/packages/client/src/utils/computed.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { writable } from "svelte/store"
-
-/**
- * Extension of Svelte's built in "derived" stores, which the addition of deep
- * comparison of non-primitives. Falls back to using shallow comparison for
- * primitive types to avoid performance penalties.
- * Useful for instances where a deep comparison is cheaper than an additional
- * store invalidation.
- * @param store the store to observer
- * @param deriveValue the derivation function
- * @returns {Writable<*>} a derived svelte store containing just the derived value
- */
-export const computed = (store, deriveValue) => {
- const initialValue = deriveValue(store)
- const computedStore = writable(initialValue)
- let lastKey = getKey(initialValue)
-
- store.subscribe(state => {
- const value = deriveValue(state)
- const key = getKey(value)
- if (key !== lastKey) {
- lastKey = key
- computedStore.set(value)
- }
- })
-
- return computedStore
-}
-
-// Helper function to serialise any value into a primitive which can be cheaply
-// and shallowly compared
-const getKey = value => {
- if (value == null || typeof value !== "object") {
- return value
- } else {
- return JSON.stringify(value)
- }
-}
diff --git a/packages/client/src/utils/grid.js b/packages/client/src/utils/grid.js
index 1727b904ca..142a7ed55a 100644
--- a/packages/client/src/utils/grid.js
+++ b/packages/client/src/utils/grid.js
@@ -92,8 +92,12 @@ export const gridLayout = (node, metadata) => {
}
// Determine default width and height of component
- let width = errored ? 500 : definition?.size?.width || 200
- let height = errored ? 60 : definition?.size?.height || 200
+ let width = styles["--default-width"] ?? definition?.size?.width ?? 200
+ let height = styles["--default-height"] ?? definition?.size?.height ?? 200
+ if (errored) {
+ width = 500
+ height = 60
+ }
width += 2 * GridSpacing
height += 2 * GridSpacing
let vars = {
diff --git a/packages/client/src/utils/styleable.js b/packages/client/src/utils/styleable.js
index 0f484a9ab9..884420a1fd 100644
--- a/packages/client/src/utils/styleable.js
+++ b/packages/client/src/utils/styleable.js
@@ -93,7 +93,7 @@ export const styleable = (node, styles = {}) => {
node.addEventListener("mouseout", applyNormalStyles)
// Add builder preview click listener
- if (newStyles.interactive) {
+ if (newStyles.interactive && !newStyles.isRoot) {
node.addEventListener("click", selectComponent, false)
node.addEventListener("dblclick", editComponent, false)
}
diff --git a/packages/server/src/api/routes/tests/automation.spec.ts b/packages/server/src/api/routes/tests/automation.spec.ts
index 1ade332f0a..b6233b24ad 100644
--- a/packages/server/src/api/routes/tests/automation.spec.ts
+++ b/packages/server/src/api/routes/tests/automation.spec.ts
@@ -15,6 +15,7 @@ import { Automation, FieldType, Table } from "@budibase/types"
import { mocks } from "@budibase/backend-core/tests"
import { FilterConditions } from "../../../automations/steps/filter"
import { removeDeprecated } from "../../../automations/utils"
+import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
const MAX_RETRIES = 4
let {
@@ -121,6 +122,104 @@ describe("/automations", () => {
expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
})
+ it("Should ensure you can't have a branch as not a last step", async () => {
+ const automation = createAutomationBuilder({
+ name: "String Equality Branching",
+ appId: config.getAppId(),
+ })
+ .appAction({ fields: { status: "active" } })
+ .branch({
+ activeBranch: {
+ steps: stepBuilder =>
+ stepBuilder.serverLog({ text: "Active user" }),
+ condition: {
+ equal: { "trigger.fields.status": "active" },
+ },
+ },
+ })
+ .serverLog({ text: "Inactive user" })
+ .build()
+
+ await config.api.automation.post(automation, {
+ status: 400,
+ body: {
+ message:
+ "Invalid body - Branch steps are only allowed as the last step",
+ },
+ })
+ })
+
+ it("Should check validation on an automation that has a branch step with no children", async () => {
+ const automation = createAutomationBuilder({
+ name: "String Equality Branching",
+ appId: config.getAppId(),
+ })
+ .appAction({ fields: { status: "active" } })
+ .branch({})
+ .serverLog({ text: "Inactive user" })
+ .build()
+
+ await config.api.automation.post(automation, {
+ status: 400,
+ body: {
+ message:
+ 'Invalid body - "definition.steps[0].inputs.branches" must contain at least 1 items',
+ },
+ })
+ })
+
+ it("Should check validation on a branch step with empty conditions", async () => {
+ const automation = createAutomationBuilder({
+ name: "String Equality Branching",
+ appId: config.getAppId(),
+ })
+ .appAction({ fields: { status: "active" } })
+ .branch({
+ activeBranch: {
+ steps: stepBuilder =>
+ stepBuilder.serverLog({ text: "Active user" }),
+ condition: {},
+ },
+ })
+ .build()
+
+ await config.api.automation.post(automation, {
+ status: 400,
+ body: {
+ message:
+ 'Invalid body - "definition.steps[0].inputs.branches[0].condition" must have at least 1 key',
+ },
+ })
+ })
+
+ it("Should check validation on an branch that has a condition that is not valid", async () => {
+ const automation = createAutomationBuilder({
+ name: "String Equality Branching",
+ appId: config.getAppId(),
+ })
+ .appAction({ fields: { status: "active" } })
+ .branch({
+ activeBranch: {
+ steps: stepBuilder =>
+ stepBuilder.serverLog({ text: "Active user" }),
+ condition: {
+ //@ts-ignore
+ INCORRECT: { "trigger.fields.status": "active" },
+ },
+ },
+ })
+ .serverLog({ text: "Inactive user" })
+ .build()
+
+ await config.api.automation.post(automation, {
+ status: 400,
+ body: {
+ message:
+ 'Invalid body - "definition.steps[0].inputs.branches[0].condition.INCORRECT" is not allowed',
+ },
+ })
+ })
+
it("should apply authorization to endpoint", async () => {
const automation = newAutomation()
await checkBuilderEndpoint({
diff --git a/packages/server/src/api/routes/utils/validators.ts b/packages/server/src/api/routes/utils/validators.ts
index 9aa112cf4d..5e2a585b4a 100644
--- a/packages/server/src/api/routes/utils/validators.ts
+++ b/packages/server/src/api/routes/utils/validators.ts
@@ -1,6 +1,9 @@
import { auth, permissions } from "@budibase/backend-core"
import { DataSourceOperation } from "../../../constants"
import {
+ AutomationActionStepId,
+ AutomationStep,
+ AutomationStepType,
EmptyFilterOption,
SearchFilters,
Table,
@@ -88,7 +91,8 @@ export function datasourceValidator() {
)
}
-function filterObject() {
+function filterObject(opts?: { unknown: boolean }) {
+ const { unknown = true } = opts || {}
const conditionalFilteringObject = () =>
Joi.object({
conditions: Joi.array().items(Joi.link("#schema")).required(),
@@ -115,7 +119,7 @@ function filterObject() {
fuzzyOr: Joi.forbidden(),
documentType: Joi.forbidden(),
}
- return Joi.object(filtersValidators).unknown(true).id("schema")
+ return Joi.object(filtersValidators).unknown(unknown).id("schema")
}
export function internalSearchValidator() {
@@ -259,6 +263,11 @@ export function screenValidator() {
}
function generateStepSchema(allowStepTypes: string[]) {
+ const branchSchema = Joi.object({
+ name: Joi.string().required(),
+ condition: filterObject({ unknown: false }).required().min(1),
+ })
+
return Joi.object({
stepId: Joi.string().required(),
id: Joi.string().required(),
@@ -267,11 +276,35 @@ function generateStepSchema(allowStepTypes: string[]) {
tagline: Joi.string().required(),
icon: Joi.string().required(),
params: Joi.object(),
+ inputs: Joi.when("stepId", {
+ is: AutomationActionStepId.BRANCH,
+ then: Joi.object({
+ branches: Joi.array().items(branchSchema).min(1).required(),
+ children: Joi.object()
+ .pattern(Joi.string(), Joi.array().items(Joi.link("#step")))
+ .required(),
+ }).required(),
+ otherwise: Joi.object(),
+ }),
+
args: Joi.object(),
type: Joi.string()
.required()
.valid(...allowStepTypes),
- }).unknown(true)
+ })
+ .unknown(true)
+ .id("step")
+}
+
+const validateStepsArray = (
+ steps: AutomationStep[],
+ helpers: Joi.CustomHelpers
+) => {
+ for (const step of steps.slice(0, -1)) {
+ if (step.stepId === AutomationActionStepId.BRANCH) {
+ return helpers.error("branchStepPosition")
+ }
+ }
}
export function automationValidator(existing = false) {
@@ -284,9 +317,20 @@ export function automationValidator(existing = false) {
definition: Joi.object({
steps: Joi.array()
.required()
- .items(generateStepSchema(["ACTION", "LOGIC"])),
- trigger: generateStepSchema(["TRIGGER"]).allow(null),
+ .items(
+ generateStepSchema([
+ AutomationStepType.ACTION,
+ AutomationStepType.LOGIC,
+ ])
+ )
+ .custom(validateStepsArray)
+ .messages({
+ branchStepPosition:
+ "Branch steps are only allowed as the last step",
+ }),
+ trigger: generateStepSchema([AutomationStepType.TRIGGER]).allow(null),
})
+
.required()
.unknown(true),
}).unknown(true)
diff --git a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts
index a0dab7f177..7fe4776d54 100644
--- a/packages/server/src/automations/tests/scenarios/scenarios.spec.ts
+++ b/packages/server/src/automations/tests/scenarios/scenarios.spec.ts
@@ -63,8 +63,8 @@ describe("Automation Scenarios", () => {
},
})
.run()
-
- expect(results.steps[2].outputs.message).toContain("Branch 1.1")
+ expect(results.steps[3].outputs.status).toContain("branch1 branch taken")
+ expect(results.steps[4].outputs.message).toContain("Branch 1.1")
})
it("should execute correct branch based on string equality", async () => {
@@ -91,8 +91,10 @@ describe("Automation Scenarios", () => {
},
})
.run()
-
- expect(results.steps[0].outputs.message).toContain("Active user")
+ expect(results.steps[0].outputs.status).toContain(
+ "activeBranch branch taken"
+ )
+ expect(results.steps[1].outputs.message).toContain("Active user")
})
it("should handle multiple conditions with AND operator", async () => {
@@ -124,7 +126,7 @@ describe("Automation Scenarios", () => {
})
.run()
- expect(results.steps[0].outputs.message).toContain("Active admin user")
+ expect(results.steps[1].outputs.message).toContain("Active admin user")
})
it("should handle multiple conditions with OR operator", async () => {
@@ -162,7 +164,7 @@ describe("Automation Scenarios", () => {
})
.run()
- expect(results.steps[0].outputs.message).toContain("Special user")
+ expect(results.steps[1].outputs.message).toContain("Special user")
})
})
@@ -362,6 +364,32 @@ describe("Automation Scenarios", () => {
}
)
})
+
+ it("should run an automation where a loop is used twice to ensure context correctness further down the tree", async () => {
+ const builder = createAutomationBuilder({
+ name: "Test Trigger with Loop and Create Row",
+ })
+
+ const results = await builder
+ .appAction({ fields: {} })
+ .loop({
+ option: LoopStepType.ARRAY,
+ binding: [1, 2, 3],
+ })
+ .serverLog({ text: "Message {{loop.currentItem}}" })
+ .serverLog({ text: "{{steps.1.iterations}}" })
+ .loop({
+ option: LoopStepType.ARRAY,
+ binding: [1, 2, 3],
+ })
+ .serverLog({ text: "{{loop.currentItem}}" })
+ .serverLog({ text: "{{steps.3.iterations}}" })
+ .run()
+
+ // We want to ensure that bindings are corr
+ expect(results.steps[1].outputs.message).toContain("- 3")
+ expect(results.steps[3].outputs.message).toContain("- 3")
+ })
})
describe("Row Automations", () => {
diff --git a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts
index 16cab73b75..f477efabe4 100644
--- a/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts
+++ b/packages/server/src/automations/tests/utilities/AutomationTestBuilder.ts
@@ -179,7 +179,7 @@ class AutomationBuilder extends BaseStepBuilder {
private triggerOutputs: any
private triggerSet: boolean = false
- constructor(options: { name?: string } = {}) {
+ constructor(options: { name?: string; appId?: string } = {}) {
super()
this.automationConfig = {
name: options.name || `Test Automation ${uuidv4()}`,
@@ -188,7 +188,7 @@ class AutomationBuilder extends BaseStepBuilder {
trigger: {} as AutomationTrigger,
},
type: "automation",
- appId: setup.getConfig().getAppId(),
+ appId: options.appId ?? setup.getConfig().getAppId(),
}
this.config = setup.getConfig()
}
@@ -261,13 +261,14 @@ class AutomationBuilder extends BaseStepBuilder {
return this
}
- branch(branchConfig: BranchConfig): {
- run: () => Promise
- } {
+ branch(branchConfig: BranchConfig): this {
this.addBranchStep(branchConfig)
- return {
- run: () => this.run(),
- }
+ return this
+ }
+
+ build(): Automation {
+ this.automationConfig.definition.steps = this.steps
+ return this.automationConfig
}
async run() {
@@ -275,7 +276,7 @@ class AutomationBuilder extends BaseStepBuilder {
throw new Error("Please add a trigger to this automation test")
}
this.automationConfig.definition.steps = this.steps
- const automation = await this.config.createAutomation(this.automationConfig)
+ const automation = await this.config.createAutomation(this.build())
const results = await testAutomation(
this.config,
automation,
@@ -295,6 +296,9 @@ class AutomationBuilder extends BaseStepBuilder {
}
}
-export function createAutomationBuilder(options?: { name?: string }) {
+export function createAutomationBuilder(options?: {
+ name?: string
+ appId?: string
+}) {
return new AutomationBuilder(options)
}
diff --git a/packages/server/src/tests/utilities/api/automation.ts b/packages/server/src/tests/utilities/api/automation.ts
index 9620e2011c..61bd915647 100644
--- a/packages/server/src/tests/utilities/api/automation.ts
+++ b/packages/server/src/tests/utilities/api/automation.ts
@@ -14,4 +14,14 @@ export class AutomationAPI extends TestAPI {
)
return result
}
+ post = async (
+ body: Automation,
+ expectations?: Expectations
+ ): Promise => {
+ const result = await this._post(`/api/automations`, {
+ body,
+ expectations,
+ })
+ return result
+ }
}
diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts
index eff8407104..f374ff159a 100644
--- a/packages/server/src/threads/automation.ts
+++ b/packages/server/src/threads/automation.ts
@@ -449,7 +449,11 @@ class Orchestrator {
outputs: tempOutput,
inputs: steps[stepToLoopIndex].inputs,
})
- this.context.steps[currentIndex + 1] = tempOutput
+ this.context.steps[this.context.steps.length] = tempOutput
+ this.context.steps = this.context.steps.filter(
+ item => !item.hasOwnProperty.call(item, "currentItem")
+ )
+
this.loopStepOutputs = []
}
@@ -461,6 +465,19 @@ class Orchestrator {
for (const branch of branches) {
const condition = await this.evaluateBranchCondition(branch.condition)
if (condition) {
+ let branchStatus = {
+ status: `${branch.name} branch taken`,
+ success: true,
+ }
+
+ this.updateExecutionOutput(
+ branchStep.id,
+ branchStep.stepId,
+ branchStep.inputs,
+ branchStatus
+ )
+ this.context.steps[this.context.steps.length] = branchStatus
+
const branchSteps = children?.[branch.name] || []
await this.executeSteps(branchSteps)
break
@@ -569,8 +586,8 @@ class Orchestrator {
this.loopStepOutputs.push(outputs)
} else {
this.updateExecutionOutput(step.id, step.stepId, step.inputs, outputs)
+ this.context.steps[this.context.steps.length] = outputs
}
- this.context.steps[this.context.steps.length] = outputs
}
}