Expose old row binding in automations (#13931)
* expose old row through the emitter * accidentally added oldRow to step * fix row fetch in external datasources * add test for new / old row comparison * add testing for old row update event * allow function overloading in test files * update tests per comments * handle event race condition * update test data modal to account for old row output * switch icon positioning
This commit is contained in:
parent
e88ffea1a4
commit
2b96cbcad7
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"}
|
||||
<RowSelector
|
||||
value={inputData[key]}
|
||||
meta={inputData["meta"] || {}}
|
||||
on:change={e => {
|
||||
if (e.detail?.key) {
|
||||
onChange(e, e.detail.key)
|
||||
} else {
|
||||
onChange(e, key)
|
||||
}
|
||||
}}
|
||||
{bindings}
|
||||
{isTestModal}
|
||||
{isUpdateRow}
|
||||
/>
|
||||
{#if isTestModal}
|
||||
<div class="align-horizontally">
|
||||
<Icon
|
||||
name={testDataRowVisibility[key] ? "Remove" : "Add"}
|
||||
hoverable
|
||||
on:click={() => toggleTestDataRowVisibility(key)}
|
||||
/>
|
||||
<Label size="XL">{label}</Label>
|
||||
</div>
|
||||
{#if testDataRowVisibility[key]}
|
||||
<RowSelector
|
||||
value={inputData[key]}
|
||||
meta={inputData["meta"] || {}}
|
||||
on:change={e => {
|
||||
if (e.detail?.key) {
|
||||
onChange(e, e.detail.key)
|
||||
} else {
|
||||
onChange(e, key)
|
||||
}
|
||||
}}
|
||||
{bindings}
|
||||
{isTestModal}
|
||||
{isUpdateRow}
|
||||
/>
|
||||
{/if}
|
||||
<Divider />
|
||||
{:else}
|
||||
<RowSelector
|
||||
value={inputData[key]}
|
||||
meta={inputData["meta"] || {}}
|
||||
on:change={e => {
|
||||
if (e.detail?.key) {
|
||||
onChange(e, e.detail.key)
|
||||
} else {
|
||||
onChange(e, key)
|
||||
}
|
||||
}}
|
||||
{bindings}
|
||||
{isTestModal}
|
||||
{isUpdateRow}
|
||||
/>
|
||||
{/if}
|
||||
{:else if value.customType === "webhookUrl"}
|
||||
<WebhookDisplay
|
||||
on:change={e => 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;
|
||||
|
|
|
@ -39,9 +39,10 @@ export async function handleRequest<T extends Operation>(
|
|||
|
||||
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
||||
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<PatchRowRequest, PatchRowResponse>) {
|
|||
...response,
|
||||
row: enrichedRow,
|
||||
table,
|
||||
oldRow: beforeRow,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -85,13 +85,15 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
|||
// 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<Row> {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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<void>
|
||||
): Promise<any> {
|
||||
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()
|
||||
|
|
|
@ -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/)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue