diff --git a/.eslintrc.json b/.eslintrc.json index 9dab2f1a88..f614f1ad91 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -92,7 +92,8 @@ // differs to external, but the API is broadly the same "jest/no-conditional-expect": "off", // have to turn this off to allow function overloading in typescript - "no-dupe-class-members": "off" + "no-dupe-class-members": "off", + "no-redeclare": "off" } }, { diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index b8b7c5ae54..57ca19ddb2 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -16,6 +16,8 @@ DatePicker, DrawerContent, Toggle, + Icon, + Divider, } from "@budibase/bbui" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import { automationStore, selectedAutomation, tables } from "stores/builder" @@ -89,6 +91,8 @@ ? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])] : [] + let testDataRowVisibility = {} + const getInputData = (testData, blockInputs) => { // Test data is not cloned for reactivity let newInputData = testData || cloneDeep(blockInputs) @@ -196,7 +200,8 @@ (automation.trigger?.event === "row:update" || automation.trigger?.event === "row:save") ) { - if (name !== "id" && name !== "revision") return `trigger.row.${name}` + let noRowKeywordBindings = ["id", "revision", "oldRow"] + if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}` } /* End special cases for generating custom schemas based on triggers */ @@ -372,7 +377,11 @@ function getFieldLabel(key, value) { const requiredSuffix = requiredProperties.includes(key) ? "*" : "" - return `${value.title || (key === "row" ? "Table" : key)} ${requiredSuffix}` + return `${value.title || (key === "row" ? "Row" : key)} ${requiredSuffix}` + } + + function toggleTestDataRowVisibility(key) { + testDataRowVisibility[key] = !testDataRowVisibility[key] } function handleAttachmentParams(keyValueObj) { @@ -607,20 +616,48 @@ on:change={e => onChange(e, key)} /> {:else if value.customType === "row"} - { - if (e.detail?.key) { - onChange(e, e.detail.key) - } else { - onChange(e, key) - } - }} - {bindings} - {isTestModal} - {isUpdateRow} - /> + {#if isTestModal} +
+ toggleTestDataRowVisibility(key)} + /> + +
+ {#if testDataRowVisibility[key]} + { + if (e.detail?.key) { + onChange(e, e.detail.key) + } else { + onChange(e, key) + } + }} + {bindings} + {isTestModal} + {isUpdateRow} + /> + {/if} + + {:else} + { + if (e.detail?.key) { + onChange(e, e.detail.key) + } else { + onChange(e, key) + } + }} + {bindings} + {isTestModal} + {isUpdateRow} + /> + {/if} {:else if value.customType === "webhookUrl"} onChange(e, key)} @@ -736,6 +773,12 @@ width: 320px; } + .align-horizontally { + display: flex; + gap: var(--spacing-s); + align-items: center; + } + .fields { display: flex; flex-direction: column; diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index d301155231..5b12b5c207 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -39,9 +39,10 @@ export async function handleRequest( export async function patch(ctx: UserCtx) { const tableId = utils.getTableId(ctx) - const { _id, ...rowData } = ctx.request.body + const { _id, ...rowData } = ctx.request.body const table = await sdk.tables.getTable(tableId) + const { row: dataToUpdate } = await inputProcessing( ctx.user?._id, cloneDeep(table), @@ -79,6 +80,7 @@ export async function patch(ctx: UserCtx) { ...response, row: enrichedRow, table, + oldRow: beforeRow, } } diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 945b7ca847..760b73f404 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -55,13 +55,13 @@ export async function patch( return save(ctx) } try { - const { row, table } = await pickApi(tableId).patch(ctx) + const { row, table, oldRow } = await pickApi(tableId).patch(ctx) if (!row) { ctx.throw(404, "Row not found") } ctx.status = 200 ctx.eventEmitter && - ctx.eventEmitter.emitRow(`row:update`, appId, row, table) + ctx.eventEmitter.emitRow(`row:update`, appId, row, table, oldRow) ctx.message = `${table.name} updated successfully.` ctx.body = row gridSocket?.emitRowUpdate(ctx, row) diff --git a/packages/server/src/api/controllers/row/internal.ts b/packages/server/src/api/controllers/row/internal.ts index cc903bd74a..54d9b6a536 100644 --- a/packages/server/src/api/controllers/row/internal.ts +++ b/packages/server/src/api/controllers/row/internal.ts @@ -85,13 +85,15 @@ export async function patch(ctx: UserCtx) { // the row has been updated, need to put it into the ctx ctx.request.body = row as any await userController.updateMetadata(ctx as any) - return { row: ctx.body as Row, table } + return { row: ctx.body as Row, table, oldRow } } - return finaliseRow(table, row, { + const result = await finaliseRow(table, row, { oldTable: dbTable, updateFormula: true, }) + + return { ...result, oldRow } } export async function find(ctx: UserCtx): Promise { diff --git a/packages/server/src/api/routes/tests/automation.spec.ts b/packages/server/src/api/routes/tests/automation.spec.ts index 711cfb8d4f..8cbd14d8b3 100644 --- a/packages/server/src/api/routes/tests/automation.spec.ts +++ b/packages/server/src/api/routes/tests/automation.spec.ts @@ -13,6 +13,7 @@ import { events } from "@budibase/backend-core" import sdk from "../../../sdk" import { Automation } from "@budibase/types" import { mocks } from "@budibase/backend-core/tests" +import { FilterConditions } from "../../../automations/steps/filter" const MAX_RETRIES = 4 let { @@ -21,6 +22,7 @@ let { automationTrigger, automationStep, collectAutomation, + filterAutomation, } = setup.structures describe("/automations", () => { @@ -155,7 +157,12 @@ describe("/automations", () => { automation.appId = config.appId automation = await config.createAutomation(automation) await setup.delay(500) - const res = await testAutomation(config, automation) + const res = await testAutomation(config, automation, { + row: { + name: "Test", + description: "TEST", + }, + }) expect(events.automation.tested).toHaveBeenCalledTimes(1) // this looks a bit mad but we don't actually have a way to wait for a response from the automation to // know that it has finished all of its actions - this is currently the best way @@ -436,4 +443,38 @@ describe("/automations", () => { expect(res).toEqual(true) }) }) + + describe("Update Row Old / New Row comparison", () => { + it.each([ + { oldCity: "asdsadsadsad", newCity: "new" }, + { oldCity: "Belfast", newCity: "Belfast" }, + ])( + "triggers an update row automation and compares new to old rows with old city '%s' and new city '%s'", + async ({ oldCity, newCity }) => { + const expectedResult = oldCity === newCity + + let table = await config.createTable() + + let automation = await filterAutomation() + automation.definition.trigger.inputs.tableId = table._id + automation.definition.steps[0].inputs = { + condition: FilterConditions.EQUAL, + field: "{{ trigger.row.City }}", + value: "{{ trigger.oldRow.City }}", + } + automation.appId = config.appId! + automation = await config.createAutomation(automation) + let triggerInputs = { + oldRow: { + City: oldCity, + }, + row: { + City: newCity, + }, + } + const res = await testAutomation(config, automation, triggerInputs) + expect(res.body.steps[1].outputs.result).toEqual(expectedResult) + } + ) + }) }) diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index f822615a87..e5599c6d81 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -1,6 +1,7 @@ import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import tk from "timekeeper" +import emitter from "../../../../src/events" import { outputProcessing } from "../../../utilities/rowProcessor" import * as setup from "./utilities" import { context, InternalTable, tenancy } from "@budibase/backend-core" @@ -24,6 +25,7 @@ import { StaticQuotaName, Table, TableSourceType, + UpdatedRowEventEmitter, } from "@budibase/types" import { generator, mocks } from "@budibase/backend-core/tests" import _, { merge } from "lodash" @@ -31,6 +33,28 @@ import * as uuid from "uuid" const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() tk.freeze(timestamp) +interface WaitOptions { + name: string + matchFn?: (event: any) => boolean +} +async function waitForEvent( + opts: WaitOptions, + callback: () => Promise +): Promise { + const p = new Promise((resolve: any) => { + const listener = (event: any) => { + if (opts.matchFn && !opts.matchFn(event)) { + return + } + resolve(event) + emitter.off(opts.name, listener) + } + emitter.on(opts.name, listener) + }) + + await callback() + return await p +} describe.each([ ["internal", undefined], @@ -608,6 +632,31 @@ describe.each([ await assertRowUsage(rowUsage) }) + it("should update only the fields that are supplied and emit the correct oldRow", async () => { + let beforeRow = await config.api.row.save(table._id!, { + name: "test", + description: "test", + }) + const opts = { + name: "row:update", + matchFn: (event: UpdatedRowEventEmitter) => + event.row._id === beforeRow._id, + } + const event = await waitForEvent(opts, async () => { + await config.api.row.patch(table._id!, { + _id: beforeRow._id!, + _rev: beforeRow._rev!, + tableId: table._id!, + name: "Updated Name", + }) + }) + + expect(event.oldRow).toBeDefined() + expect(event.oldRow.name).toEqual("test") + expect(event.row.name).toEqual("Updated Name") + expect(event.oldRow.description).toEqual(beforeRow.description) + expect(event.row.description).toEqual(beforeRow.description) + }) it("should throw an error when given improper types", async () => { const existing = await config.api.row.save(table._id!, {}) const rowUsage = await getRowUsage() diff --git a/packages/server/src/api/routes/tests/utilities/TestFunctions.ts b/packages/server/src/api/routes/tests/utilities/TestFunctions.ts index 8a843551ac..27d8592849 100644 --- a/packages/server/src/api/routes/tests/utilities/TestFunctions.ts +++ b/packages/server/src/api/routes/tests/utilities/TestFunctions.ts @@ -158,15 +158,16 @@ export const getDB = () => { return context.getAppDB() } -export const testAutomation = async (config: any, automation: any) => { +export const testAutomation = async ( + config: any, + automation: any, + triggerInputs: any +) => { return runRequest(automation.appId, async () => { return await config.request .post(`/api/automations/${automation._id}/test`) .send({ - row: { - name: "Test", - description: "TEST", - }, + ...triggerInputs, }) .set(config.defaultHeaders()) .expect("Content-Type", /json/) diff --git a/packages/server/src/automations/triggerInfo/rowUpdated.ts b/packages/server/src/automations/triggerInfo/rowUpdated.ts index 5e60015808..eab7c40a09 100644 --- a/packages/server/src/automations/triggerInfo/rowUpdated.ts +++ b/packages/server/src/automations/triggerInfo/rowUpdated.ts @@ -27,10 +27,17 @@ export const definition: AutomationTriggerSchema = { }, outputs: { properties: { - row: { + oldRow: { type: AutomationIOType.OBJECT, customType: AutomationCustomIOType.ROW, description: "The row that was updated", + title: "Old Row", + }, + row: { + type: AutomationIOType.OBJECT, + customType: AutomationCustomIOType.ROW, + description: "The row before it was updated", + title: "Row", }, id: { type: AutomationIOType.STRING, diff --git a/packages/server/src/automations/triggers.ts b/packages/server/src/automations/triggers.ts index 223b8d2eb6..9aa80035bd 100644 --- a/packages/server/src/automations/triggers.ts +++ b/packages/server/src/automations/triggers.ts @@ -8,7 +8,13 @@ import { checkTestFlag } from "../utilities/redis" import * as utils from "./utils" import env from "../environment" import { context, db as dbCore } from "@budibase/backend-core" -import { Automation, Row, AutomationData, AutomationJob } from "@budibase/types" +import { + Automation, + Row, + AutomationData, + AutomationJob, + UpdatedRowEventEmitter, +} from "@budibase/types" import { executeInThread } from "../threads/automation" export const TRIGGER_DEFINITIONS = definitions @@ -65,7 +71,7 @@ async function queueRelevantRowAutomations( }) } -emitter.on("row:save", async function (event) { +emitter.on("row:save", async function (event: UpdatedRowEventEmitter) { /* istanbul ignore next */ if (!event || !event.row || !event.row.tableId) { return diff --git a/packages/server/src/events/BudibaseEmitter.ts b/packages/server/src/events/BudibaseEmitter.ts index 43871d8754..8feb36bbf5 100644 --- a/packages/server/src/events/BudibaseEmitter.ts +++ b/packages/server/src/events/BudibaseEmitter.ts @@ -13,8 +13,14 @@ import { Table, Row } from "@budibase/types" * This is specifically quite important for template strings used in automations. */ class BudibaseEmitter extends EventEmitter { - emitRow(eventName: string, appId: string, row: Row, table?: Table) { - rowEmission({ emitter: this, eventName, appId, row, table }) + emitRow( + eventName: string, + appId: string, + row: Row, + table?: Table, + oldRow?: Row + ) { + rowEmission({ emitter: this, eventName, appId, row, table, oldRow }) } emitTable(eventName: string, appId: string, table?: Table) { diff --git a/packages/server/src/events/utils.ts b/packages/server/src/events/utils.ts index 20efb453f2..b972c8e473 100644 --- a/packages/server/src/events/utils.ts +++ b/packages/server/src/events/utils.ts @@ -7,6 +7,7 @@ type BBEventOpts = { appId: string table?: Table row?: Row + oldRow?: Row metadata?: any } @@ -18,6 +19,7 @@ type BBEvent = { appId: string tableId?: string row?: Row + oldRow?: Row table?: BBEventTable id?: string revision?: string @@ -31,9 +33,11 @@ export function rowEmission({ row, table, metadata, + oldRow, }: BBEventOpts) { let event: BBEvent = { row, + oldRow, appId, tableId: row?.tableId, } diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index 7213cc66f1..a59719ab2c 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -359,6 +359,36 @@ export function collectAutomation(tableId?: string): Automation { return automation as Automation } +export function filterAutomation(tableId?: string): Automation { + const automation: any = { + name: "looping", + type: "automation", + definition: { + steps: [ + { + id: "b", + type: "ACTION", + internal: true, + stepId: AutomationActionStepId.FILTER, + inputs: {}, + schema: BUILTIN_ACTION_DEFINITIONS.EXECUTE_SCRIPT.schema, + }, + ], + trigger: { + id: "a", + type: "TRIGGER", + event: "row:save", + stepId: AutomationTriggerStepId.ROW_SAVED, + inputs: { + tableId, + }, + schema: TRIGGER_DEFINITIONS.ROW_SAVED.schema, + }, + }, + } + return automation as Automation +} + export function basicAutomationResults( automationId: string ): AutomationResults { diff --git a/packages/types/src/documents/app/automation.ts b/packages/types/src/documents/app/automation.ts index 63291fa3bb..5954a47151 100644 --- a/packages/types/src/documents/app/automation.ts +++ b/packages/types/src/documents/app/automation.ts @@ -2,6 +2,8 @@ import { Document } from "../document" import { EventEmitter } from "events" import { User } from "../global" import { ReadStream } from "fs" +import { Row } from "./row" +import { Table } from "./table" export enum AutomationIOType { OBJECT = "object", @@ -252,3 +254,10 @@ export type BucketedContent = AutomationAttachmentContent & { bucket: string path: string } + +export type UpdatedRowEventEmitter = { + row: Row + oldRow: Row + table: Table + appId: string +}