Merge branch 'master' into table-improvements-2
This commit is contained in:
commit
c2c799e473
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||||
"version": "2.29.20",
|
"version": "2.29.22",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -4,8 +4,9 @@ import { Ctx } from "@budibase/types"
|
||||||
function validate(
|
function validate(
|
||||||
schema: Joi.ObjectSchema | Joi.ArraySchema,
|
schema: Joi.ObjectSchema | Joi.ArraySchema,
|
||||||
property: string,
|
property: string,
|
||||||
opts: { errorPrefix: string } = { errorPrefix: `Invalid ${property}` }
|
opts?: { errorPrefix?: string; allowUnknown?: boolean }
|
||||||
) {
|
) {
|
||||||
|
const errorPrefix = opts?.errorPrefix ?? `Invalid ${property}`
|
||||||
// Return a Koa middleware function
|
// Return a Koa middleware function
|
||||||
return (ctx: Ctx, next: any) => {
|
return (ctx: Ctx, next: any) => {
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
|
@ -28,10 +29,12 @@ function validate(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = schema.validate(params)
|
const { error } = schema.validate(params, {
|
||||||
|
allowUnknown: opts?.allowUnknown,
|
||||||
|
})
|
||||||
if (error) {
|
if (error) {
|
||||||
let message = error.message
|
let message = error.message
|
||||||
if (opts.errorPrefix) {
|
if (errorPrefix) {
|
||||||
message = `Invalid ${property} - ${message}`
|
message = `Invalid ${property} - ${message}`
|
||||||
}
|
}
|
||||||
ctx.throw(400, message)
|
ctx.throw(400, message)
|
||||||
|
@ -42,7 +45,7 @@ function validate(
|
||||||
|
|
||||||
export function body(
|
export function body(
|
||||||
schema: Joi.ObjectSchema | Joi.ArraySchema,
|
schema: Joi.ObjectSchema | Joi.ArraySchema,
|
||||||
opts?: { errorPrefix: string }
|
opts?: { errorPrefix?: string; allowUnknown?: boolean }
|
||||||
) {
|
) {
|
||||||
return validate(schema, "body", opts)
|
return validate(schema, "body", opts)
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,27 +42,28 @@ const envLimit = environment.SQL_MAX_ROWS
|
||||||
: null
|
: null
|
||||||
const BASE_LIMIT = envLimit || 5000
|
const BASE_LIMIT = envLimit || 5000
|
||||||
|
|
||||||
function likeKey(client: string | string[], key: string): string {
|
// Takes a string like foo and returns a quoted string like [foo] for SQL Server
|
||||||
let start: string, end: string
|
// and "foo" for Postgres.
|
||||||
|
function quote(client: SqlClient, str: string): string {
|
||||||
switch (client) {
|
switch (client) {
|
||||||
case SqlClient.MY_SQL:
|
|
||||||
start = end = "`"
|
|
||||||
break
|
|
||||||
case SqlClient.SQL_LITE:
|
case SqlClient.SQL_LITE:
|
||||||
case SqlClient.ORACLE:
|
case SqlClient.ORACLE:
|
||||||
case SqlClient.POSTGRES:
|
case SqlClient.POSTGRES:
|
||||||
start = end = '"'
|
return `"${str}"`
|
||||||
break
|
|
||||||
case SqlClient.MS_SQL:
|
case SqlClient.MS_SQL:
|
||||||
start = "["
|
return `[${str}]`
|
||||||
end = "]"
|
case SqlClient.MY_SQL:
|
||||||
break
|
return `\`${str}\``
|
||||||
default:
|
|
||||||
throw new Error("Unknown client generating like key")
|
|
||||||
}
|
}
|
||||||
const parts = key.split(".")
|
}
|
||||||
key = parts.map(part => `${start}${part}${end}`).join(".")
|
|
||||||
|
// Takes a string like a.b.c and returns a quoted identifier like [a].[b].[c]
|
||||||
|
// for SQL Server and `a`.`b`.`c` for MySQL.
|
||||||
|
function quotedIdentifier(client: SqlClient, key: string): string {
|
||||||
return key
|
return key
|
||||||
|
.split(".")
|
||||||
|
.map(part => quote(client, part))
|
||||||
|
.join(".")
|
||||||
}
|
}
|
||||||
|
|
||||||
function parse(input: any) {
|
function parse(input: any) {
|
||||||
|
@ -113,34 +114,81 @@ function generateSelectStatement(
|
||||||
knex: Knex
|
knex: Knex
|
||||||
): (string | Knex.Raw)[] | "*" {
|
): (string | Knex.Raw)[] | "*" {
|
||||||
const { resource, meta } = json
|
const { resource, meta } = json
|
||||||
|
const client = knex.client.config.client as SqlClient
|
||||||
|
|
||||||
if (!resource || !resource.fields || resource.fields.length === 0) {
|
if (!resource || !resource.fields || resource.fields.length === 0) {
|
||||||
return "*"
|
return "*"
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = meta?.table?.schema
|
const schema = meta.table.schema
|
||||||
return resource.fields.map(field => {
|
return resource.fields.map(field => {
|
||||||
const fieldNames = field.split(/\./g)
|
const parts = field.split(/\./g)
|
||||||
const tableName = fieldNames[0]
|
let table: string | undefined = undefined
|
||||||
const columnName = fieldNames[1]
|
let column: string | undefined = undefined
|
||||||
const columnSchema = schema?.[columnName]
|
|
||||||
if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) {
|
// Just a column name, e.g.: "column"
|
||||||
const externalType = schema[columnName].externalType
|
if (parts.length === 1) {
|
||||||
if (externalType?.includes("money")) {
|
column = parts[0]
|
||||||
return knex.raw(
|
|
||||||
`"${tableName}"."${columnName}"::money::numeric as "${field}"`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A table name and a column name, e.g.: "table.column"
|
||||||
|
if (parts.length === 2) {
|
||||||
|
table = parts[0]
|
||||||
|
column = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link doc, e.g.: "table.doc1.fieldName"
|
||||||
|
if (parts.length > 2) {
|
||||||
|
table = parts[0]
|
||||||
|
column = parts.slice(1).join(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!column) {
|
||||||
|
throw new Error(`Invalid field name: ${field}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnSchema = schema[column]
|
||||||
|
|
||||||
if (
|
if (
|
||||||
knex.client.config.client === SqlClient.MS_SQL &&
|
client === SqlClient.POSTGRES &&
|
||||||
|
columnSchema?.externalType?.includes("money")
|
||||||
|
) {
|
||||||
|
return knex.raw(
|
||||||
|
`${quotedIdentifier(
|
||||||
|
client,
|
||||||
|
[table, column].join(".")
|
||||||
|
)}::money::numeric as ${quote(client, field)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
client === SqlClient.MS_SQL &&
|
||||||
columnSchema?.type === FieldType.DATETIME &&
|
columnSchema?.type === FieldType.DATETIME &&
|
||||||
columnSchema.timeOnly
|
columnSchema.timeOnly
|
||||||
) {
|
) {
|
||||||
// Time gets returned as timestamp from mssql, not matching the expected HH:mm format
|
// Time gets returned as timestamp from mssql, not matching the expected
|
||||||
|
// HH:mm format
|
||||||
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
|
return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
|
||||||
}
|
}
|
||||||
return `${field} as ${field}`
|
|
||||||
|
// There's at least two edge cases being handled in the expression below.
|
||||||
|
// 1. The column name could start/end with a space, and in that case we
|
||||||
|
// want to preseve that space.
|
||||||
|
// 2. Almost all column names are specified in the form table.column, except
|
||||||
|
// in the case of relationships, where it's table.doc1.column. In that
|
||||||
|
// case, we want to split it into `table`.`doc1.column` for reasons that
|
||||||
|
// aren't actually clear to me, but `table`.`doc1` breaks things with the
|
||||||
|
// sample data tests.
|
||||||
|
if (table) {
|
||||||
|
return knex.raw(
|
||||||
|
`${quote(client, table)}.${quote(client, column)} as ${quote(
|
||||||
|
client,
|
||||||
|
field
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return knex.raw(`${quote(client, field)} as ${quote(client, field)}`)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,9 +221,9 @@ function convertBooleans(query: SqlQuery | SqlQuery[]): SqlQuery | SqlQuery[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
class InternalBuilder {
|
class InternalBuilder {
|
||||||
private readonly client: string
|
private readonly client: SqlClient
|
||||||
|
|
||||||
constructor(client: string) {
|
constructor(client: SqlClient) {
|
||||||
this.client = client
|
this.client = client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -250,9 +298,10 @@ class InternalBuilder {
|
||||||
} else {
|
} else {
|
||||||
const rawFnc = `${fnc}Raw`
|
const rawFnc = `${fnc}Raw`
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
|
query = query[rawFnc](
|
||||||
`%${value.toLowerCase()}%`,
|
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
|
||||||
])
|
[`%${value.toLowerCase()}%`]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -302,7 +351,10 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
statement +=
|
statement +=
|
||||||
(statement ? andOr : "") +
|
(statement ? andOr : "") +
|
||||||
`COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?`
|
`COALESCE(LOWER(${quotedIdentifier(
|
||||||
|
this.client,
|
||||||
|
key
|
||||||
|
)}), '') LIKE ?`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statement === "") {
|
if (statement === "") {
|
||||||
|
@ -336,9 +388,10 @@ class InternalBuilder {
|
||||||
} else {
|
} else {
|
||||||
const rawFnc = `${fnc}Raw`
|
const rawFnc = `${fnc}Raw`
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
|
query = query[rawFnc](
|
||||||
`${value.toLowerCase()}%`,
|
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
|
||||||
])
|
[`${value.toLowerCase()}%`]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -376,12 +429,15 @@ class InternalBuilder {
|
||||||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
if (this.client === SqlClient.MS_SQL) {
|
if (this.client === SqlClient.MS_SQL) {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 1`,
|
`CASE WHEN ${quotedIdentifier(
|
||||||
|
this.client,
|
||||||
|
key
|
||||||
|
)} = ? THEN 1 ELSE 0 END = 1`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`COALESCE(${likeKey(this.client, key)} = ?, FALSE)`,
|
`COALESCE(${quotedIdentifier(this.client, key)} = ?, FALSE)`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -392,12 +448,15 @@ class InternalBuilder {
|
||||||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||||
if (this.client === SqlClient.MS_SQL) {
|
if (this.client === SqlClient.MS_SQL) {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 0`,
|
`CASE WHEN ${quotedIdentifier(
|
||||||
|
this.client,
|
||||||
|
key
|
||||||
|
)} = ? THEN 1 ELSE 0 END = 0`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
query = query[fnc](
|
query = query[fnc](
|
||||||
`COALESCE(${likeKey(this.client, key)} != ?, TRUE)`,
|
`COALESCE(${quotedIdentifier(this.client, key)} != ?, TRUE)`,
|
||||||
[value]
|
[value]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -769,7 +828,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
||||||
private readonly limit: number
|
private readonly limit: number
|
||||||
|
|
||||||
// pass through client to get flavour of SQL
|
// pass through client to get flavour of SQL
|
||||||
constructor(client: string, limit: number = BASE_LIMIT) {
|
constructor(client: SqlClient, limit: number = BASE_LIMIT) {
|
||||||
super(client)
|
super(client)
|
||||||
this.limit = limit
|
this.limit = limit
|
||||||
}
|
}
|
||||||
|
|
|
@ -195,14 +195,14 @@ function buildDeleteTable(knex: SchemaBuilder, table: Table): SchemaBuilder {
|
||||||
}
|
}
|
||||||
|
|
||||||
class SqlTableQueryBuilder {
|
class SqlTableQueryBuilder {
|
||||||
private readonly sqlClient: string
|
private readonly sqlClient: SqlClient
|
||||||
|
|
||||||
// pass through client to get flavour of SQL
|
// pass through client to get flavour of SQL
|
||||||
constructor(client: string) {
|
constructor(client: SqlClient) {
|
||||||
this.sqlClient = client
|
this.sqlClient = client
|
||||||
}
|
}
|
||||||
|
|
||||||
getSqlClient(): string {
|
getSqlClient(): SqlClient {
|
||||||
return this.sqlClient
|
return this.sqlClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
|
||||||
const ROW_ID_REGEX = /^\[.*]$/g
|
const ROW_ID_REGEX = /^\[.*]$/g
|
||||||
const ENCODED_SPACE = encodeURIComponent(" ")
|
const ENCODED_SPACE = encodeURIComponent(" ")
|
||||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/
|
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/
|
||||||
|
const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
|
||||||
|
|
||||||
export function isExternalTableID(tableId: string) {
|
export function isExternalTableID(tableId: string) {
|
||||||
return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR)
|
return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR)
|
||||||
|
@ -147,6 +148,10 @@ export function isValidFilter(value: any) {
|
||||||
return value != null && value !== ""
|
return value != null && value !== ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isValidTime(value: string) {
|
||||||
|
return TIME_REGEX.test(value)
|
||||||
|
}
|
||||||
|
|
||||||
export function sqlLog(client: string, query: string, values?: any[]) {
|
export function sqlLog(client: string, query: string, values?: any[]) {
|
||||||
if (!environment.SQL_LOGGING_ENABLE) {
|
if (!environment.SQL_LOGGING_ENABLE) {
|
||||||
return
|
return
|
||||||
|
|
|
@ -17,12 +17,12 @@
|
||||||
|
|
||||||
export let blockIdx
|
export let blockIdx
|
||||||
export let lastStep
|
export let lastStep
|
||||||
|
export let modal
|
||||||
|
|
||||||
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
|
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
|
||||||
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
|
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
|
||||||
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
|
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
|
||||||
let selectedAction
|
let selectedAction
|
||||||
let actionVal
|
|
||||||
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
|
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
|
||||||
let lockedFeatures = [
|
let lockedFeatures = [
|
||||||
ActionStepID.COLLECT,
|
ActionStepID.COLLECT,
|
||||||
|
@ -91,19 +91,17 @@
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
const selectAction = action => {
|
const selectAction = async action => {
|
||||||
actionVal = action
|
|
||||||
selectedAction = action.name
|
selectedAction = action.name
|
||||||
}
|
|
||||||
|
|
||||||
async function addBlockToAutomation() {
|
|
||||||
try {
|
try {
|
||||||
const newBlock = automationStore.actions.constructBlock(
|
const newBlock = automationStore.actions.constructBlock(
|
||||||
"ACTION",
|
"ACTION",
|
||||||
actionVal.stepId,
|
action.stepId,
|
||||||
actionVal
|
action
|
||||||
)
|
)
|
||||||
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
|
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
|
||||||
|
modal.hide()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error saving automation")
|
notifications.error("Error saving automation")
|
||||||
}
|
}
|
||||||
|
@ -114,10 +112,10 @@
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<ModalContent
|
<ModalContent
|
||||||
title="Add automation step"
|
title="Add automation step"
|
||||||
confirmText="Save"
|
|
||||||
size="L"
|
size="L"
|
||||||
|
showConfirmButton={false}
|
||||||
|
showCancelButton={false}
|
||||||
disabled={!selectedAction}
|
disabled={!selectedAction}
|
||||||
onConfirm={addBlockToAutomation}
|
|
||||||
>
|
>
|
||||||
<Layout noPadding gap="XS">
|
<Layout noPadding gap="XS">
|
||||||
<Detail size="S">Apps</Detail>
|
<Detail size="S">Apps</Detail>
|
||||||
|
|
|
@ -206,7 +206,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<Modal bind:this={actionModal} width="30%">
|
<Modal bind:this={actionModal} width="30%">
|
||||||
<ActionModal {lastStep} {blockIdx} />
|
<ActionModal modal={actionModal} {lastStep} {blockIdx} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal bind:this={webhookModal} width="30%">
|
<Modal bind:this={webhookModal} width="30%">
|
||||||
|
|
|
@ -16,13 +16,12 @@
|
||||||
export let enableNaming = true
|
export let enableNaming = true
|
||||||
let validRegex = /^[A-Za-z0-9_\s]+$/
|
let validRegex = /^[A-Za-z0-9_\s]+$/
|
||||||
let typing = false
|
let typing = false
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
$: stepNames = $selectedAutomation?.definition.stepNames
|
$: stepNames = $selectedAutomation?.definition.stepNames
|
||||||
$: automationName = stepNames?.[block.id] || block?.name || ""
|
$: automationName = stepNames?.[block.id] || block?.name || ""
|
||||||
$: automationNameError = getAutomationNameError(automationName)
|
$: automationNameError = getAutomationNameError(automationName)
|
||||||
$: status = updateStatus(testResult, isTrigger)
|
$: status = updateStatus(testResult)
|
||||||
$: isHeaderTrigger = isTrigger || block.type === "TRIGGER"
|
$: isHeaderTrigger = isTrigger || block.type === "TRIGGER"
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
|
@ -43,7 +42,7 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateStatus(results, isTrigger) {
|
function updateStatus(results) {
|
||||||
if (!results) {
|
if (!results) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
@ -56,7 +55,6 @@
|
||||||
return { negative: true, message: "Error" }
|
return { negative: true, message: "Error" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAutomationNameError = name => {
|
const getAutomationNameError = name => {
|
||||||
if (stepNames) {
|
if (stepNames) {
|
||||||
for (const [key, value] of Object.entries(stepNames)) {
|
for (const [key, value] of Object.entries(stepNames)) {
|
||||||
|
|
|
@ -81,7 +81,7 @@
|
||||||
// Check the schema to see if required fields have been entered
|
// Check the schema to see if required fields have been entered
|
||||||
$: isError =
|
$: isError =
|
||||||
!isTriggerValid(trigger) ||
|
!isTriggerValid(trigger) ||
|
||||||
!trigger.schema.outputs.required.every(
|
!trigger.schema.outputs.required?.every(
|
||||||
required => $memoTestData?.[required] || required !== "row"
|
required => $memoTestData?.[required] || required !== "row"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -12,14 +12,31 @@
|
||||||
let blocks
|
let blocks
|
||||||
|
|
||||||
function prepTestResults(results) {
|
function prepTestResults(results) {
|
||||||
return results?.steps.filter(x => x.stepId !== ActionStepID.LOOP || [])
|
if (results.message) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
inputs: {},
|
||||||
|
outputs: {
|
||||||
|
success: results.outputs?.success || false,
|
||||||
|
status: results.outputs?.status || "unknown",
|
||||||
|
message: results.message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
return results?.steps?.filter(x => x.stepId !== ActionStepID.LOOP) || []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: filteredResults = prepTestResults(testResults)
|
$: filteredResults = prepTestResults(testResults)
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
blocks = []
|
if (testResults.message) {
|
||||||
if (automation) {
|
blocks = automation?.definition?.trigger
|
||||||
|
? [automation.definition.trigger]
|
||||||
|
: []
|
||||||
|
} else if (automation) {
|
||||||
|
blocks = []
|
||||||
if (automation.definition.trigger) {
|
if (automation.definition.trigger) {
|
||||||
blocks.push(automation.definition.trigger)
|
blocks.push(automation.definition.trigger)
|
||||||
}
|
}
|
||||||
|
@ -46,7 +63,9 @@
|
||||||
open={!!openBlocks[block.id]}
|
open={!!openBlocks[block.id]}
|
||||||
on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
|
on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
|
||||||
isTrigger={idx === 0}
|
isTrigger={idx === 0}
|
||||||
testResult={filteredResults?.[idx]}
|
testResult={testResults.message
|
||||||
|
? testResults
|
||||||
|
: filteredResults?.[idx]}
|
||||||
showTestStatus
|
showTestStatus
|
||||||
{block}
|
{block}
|
||||||
{idx}
|
{idx}
|
||||||
|
@ -68,7 +87,9 @@
|
||||||
<Tabs quiet noHorizPadding selected="Input">
|
<Tabs quiet noHorizPadding selected="Input">
|
||||||
<Tab title="Input">
|
<Tab title="Input">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
{#if filteredResults?.[idx]?.inputs}
|
{#if testResults.message}
|
||||||
|
No input
|
||||||
|
{:else if filteredResults?.[idx]?.inputs}
|
||||||
<JsonView depth={2} json={filteredResults?.[idx]?.inputs} />
|
<JsonView depth={2} json={filteredResults?.[idx]?.inputs} />
|
||||||
{:else}
|
{:else}
|
||||||
No input
|
No input
|
||||||
|
@ -77,13 +98,22 @@
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab title="Output">
|
<Tab title="Output">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
{#if filteredResults?.[idx]?.outputs}
|
{#if testResults.message}
|
||||||
|
<JsonView
|
||||||
|
depth={2}
|
||||||
|
json={{
|
||||||
|
success: testResults.outputs?.success || false,
|
||||||
|
status: testResults.outputs?.status || "unknown",
|
||||||
|
message: testResults.message,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else if filteredResults?.[idx]?.outputs}
|
||||||
<JsonView
|
<JsonView
|
||||||
depth={2}
|
depth={2}
|
||||||
json={filteredResults?.[idx]?.outputs}
|
json={filteredResults?.[idx]?.outputs}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
No input
|
No output
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
|
|
|
@ -17,7 +17,9 @@
|
||||||
Helpers,
|
Helpers,
|
||||||
Toggle,
|
Toggle,
|
||||||
Divider,
|
Divider,
|
||||||
|
Icon,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
|
|
||||||
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
||||||
import { automationStore, selectedAutomation, tables } from "stores/builder"
|
import { automationStore, selectedAutomation, tables } from "stores/builder"
|
||||||
import { environment, licensing } from "stores/portal"
|
import { environment, licensing } from "stores/portal"
|
||||||
|
@ -365,41 +367,74 @@
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for row trigger automation updates.
|
* Handler for row trigger automation updates.
|
||||||
@param {object} update - An automation block.inputs update object
|
* @param {object} update - An automation block.inputs update object
|
||||||
@example
|
* @param {string} [update.tableId] - The ID of the table
|
||||||
onRowTriggerUpdate({
|
* @param {object} [update.filters] - Filter configuration for the row trigger
|
||||||
"tableId" : "ta_bb_employee"
|
* @param {object} [update.filters-def] - Filter definitions for the row trigger
|
||||||
})
|
* @example
|
||||||
|
* // Example with tableId
|
||||||
|
* onRowTriggerUpdate({
|
||||||
|
* "tableId" : "ta_bb_employee"
|
||||||
|
* })
|
||||||
|
* @example
|
||||||
|
* // Example with filters
|
||||||
|
* onRowTriggerUpdate({
|
||||||
|
* filters: {
|
||||||
|
* equal: { "1:Approved": "true" }
|
||||||
|
* },
|
||||||
|
* "filters-def": [{
|
||||||
|
* id: "oH1T4S49n",
|
||||||
|
* field: "1:Approved",
|
||||||
|
* operator: "equal",
|
||||||
|
* value: "true",
|
||||||
|
* valueType: "Value",
|
||||||
|
* type: "string"
|
||||||
|
* }]
|
||||||
|
* })
|
||||||
*/
|
*/
|
||||||
const onRowTriggerUpdate = async update => {
|
const onRowTriggerUpdate = async update => {
|
||||||
if (
|
if (
|
||||||
Object.hasOwn(update, "tableId") &&
|
["tableId", "filters", "meta"].some(key => Object.hasOwn(update, key))
|
||||||
$selectedAutomation.testData?.row?.tableId !== update.tableId
|
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
|
let updatedAutomation
|
||||||
searchableSchema: true,
|
|
||||||
}).schema
|
|
||||||
|
|
||||||
// Parse the block inputs as usual
|
if (
|
||||||
const updatedAutomation =
|
Object.hasOwn(update, "tableId") &&
|
||||||
await automationStore.actions.processBlockInputs(block, {
|
$selectedAutomation.testData?.row?.tableId !== update.tableId
|
||||||
schema: reqSchema,
|
) {
|
||||||
...update,
|
const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
|
||||||
})
|
searchableSchema: true,
|
||||||
|
}).schema
|
||||||
|
|
||||||
// Save the entire automation and reset the testData
|
updatedAutomation = await automationStore.actions.processBlockInputs(
|
||||||
await automationStore.actions.save({
|
block,
|
||||||
...updatedAutomation,
|
{
|
||||||
testData: {
|
schema: reqSchema,
|
||||||
// Reset Core fields
|
...update,
|
||||||
row: { tableId: update.tableId },
|
}
|
||||||
oldRow: { tableId: update.tableId },
|
)
|
||||||
meta: {},
|
|
||||||
id: "",
|
// Reset testData when tableId changes
|
||||||
revision: "",
|
updatedAutomation = {
|
||||||
},
|
...updatedAutomation,
|
||||||
})
|
testData: {
|
||||||
|
row: { tableId: update.tableId },
|
||||||
|
oldRow: { tableId: update.tableId },
|
||||||
|
meta: {},
|
||||||
|
id: "",
|
||||||
|
revision: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For filters update, just process block inputs without resetting testData
|
||||||
|
updatedAutomation = await automationStore.actions.processBlockInputs(
|
||||||
|
block,
|
||||||
|
update
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await automationStore.actions.save(updatedAutomation)
|
||||||
|
|
||||||
return
|
return
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -408,7 +443,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for App trigger automation updates.
|
* Handler for App trigger automation updates.
|
||||||
* Ensure updates to the field list are reflected in testData
|
* Ensure updates to the field list are reflected in testData
|
||||||
|
@ -743,6 +777,7 @@
|
||||||
value.customType !== "triggerSchema" &&
|
value.customType !== "triggerSchema" &&
|
||||||
value.customType !== "automationFields" &&
|
value.customType !== "automationFields" &&
|
||||||
value.customType !== "fields" &&
|
value.customType !== "fields" &&
|
||||||
|
value.customType !== "trigger_filter_setting" &&
|
||||||
value.type !== "signature_single" &&
|
value.type !== "signature_single" &&
|
||||||
value.type !== "attachment" &&
|
value.type !== "attachment" &&
|
||||||
value.type !== "attachment_single"
|
value.type !== "attachment_single"
|
||||||
|
@ -807,13 +842,23 @@
|
||||||
{@const label = getFieldLabel(key, value)}
|
{@const label = getFieldLabel(key, value)}
|
||||||
<div class:block-field={shouldRenderField(value)}>
|
<div class:block-field={shouldRenderField(value)}>
|
||||||
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
|
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
|
||||||
<Label
|
<div class="label-container">
|
||||||
tooltip={value.title === "Binding / Value"
|
<Label>
|
||||||
? "If using the String input type, please use a comma or newline separated string"
|
{label}
|
||||||
: null}
|
</Label>
|
||||||
>
|
{#if value.customType === "trigger_filter"}
|
||||||
{label}
|
<Icon
|
||||||
</Label>
|
hoverable
|
||||||
|
on:click={() =>
|
||||||
|
window.open(
|
||||||
|
"https://docs.budibase.com/docs/row-trigger-filters",
|
||||||
|
"_blank"
|
||||||
|
)}
|
||||||
|
size="XS"
|
||||||
|
name="InfoOutline"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class:field-width={shouldRenderField(value)}>
|
<div class:field-width={shouldRenderField(value)}>
|
||||||
{#if value.type === "string" && value.enum && canShowField(key, value)}
|
{#if value.type === "string" && value.enum && canShowField(key, value)}
|
||||||
|
@ -932,8 +977,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if value.customType === "filters"}
|
{:else if value.customType === "filters" || value.customType === "trigger_filter"}
|
||||||
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
|
<ActionButton fullWidth on:click={drawer.show}
|
||||||
|
>{filters.length > 0
|
||||||
|
? "Update Filter"
|
||||||
|
: "No Filter set"}</ActionButton
|
||||||
|
>
|
||||||
<Drawer bind:this={drawer} title="Filtering">
|
<Drawer bind:this={drawer} title="Filtering">
|
||||||
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
|
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
|
||||||
Save
|
Save
|
||||||
|
@ -945,6 +994,7 @@
|
||||||
{schemaFields}
|
{schemaFields}
|
||||||
datasource={{ type: "table", tableId }}
|
datasource={{ type: "table", tableId }}
|
||||||
panel={AutomationBindingPanel}
|
panel={AutomationBindingPanel}
|
||||||
|
showFilterEmptyDropdown={!rowTriggers.includes(stepId)}
|
||||||
on:change={e => (tempFilters = e.detail)}
|
on:change={e => (tempFilters = e.detail)}
|
||||||
/>
|
/>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
|
@ -1085,6 +1135,11 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.label-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-s);
|
||||||
|
}
|
||||||
.field-width {
|
.field-width {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,7 @@
|
||||||
await API.deleteApp(appId)
|
await API.deleteApp(appId)
|
||||||
appsStore.load()
|
appsStore.load()
|
||||||
notifications.success("App deleted successfully")
|
notifications.success("App deleted successfully")
|
||||||
|
deleting = false
|
||||||
onDeleteSuccess()
|
onDeleteSuccess()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.error("Error deleting app")
|
notifications.error("Error deleting app")
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
export let panel = ClientBindingPanel
|
export let panel = ClientBindingPanel
|
||||||
export let allowBindings = true
|
export let allowBindings = true
|
||||||
export let datasource
|
export let datasource
|
||||||
|
export let showFilterEmptyDropdown
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let rawFilters
|
let rawFilters
|
||||||
|
@ -63,6 +63,7 @@
|
||||||
{schemaFields}
|
{schemaFields}
|
||||||
{datasource}
|
{datasource}
|
||||||
{allowBindings}
|
{allowBindings}
|
||||||
|
{showFilterEmptyDropdown}
|
||||||
>
|
>
|
||||||
<div slot="filtering-hero-content" />
|
<div slot="filtering-hero-content" />
|
||||||
|
|
||||||
|
|
|
@ -208,7 +208,7 @@ const automationActions = store => ({
|
||||||
const message = err.message || err.status || JSON.stringify(err)
|
const message = err.message || err.status || JSON.stringify(err)
|
||||||
throw `Automation test failed - ${message}`
|
throw `Automation test failed - ${message}`
|
||||||
}
|
}
|
||||||
if (!result?.trigger && !result?.steps?.length) {
|
if (!result?.trigger && !result?.steps?.length && !result?.message) {
|
||||||
if (result?.err?.code === "usage_limit_exceeded") {
|
if (result?.err?.code === "usage_limit_exceeded") {
|
||||||
throw "You have exceeded your automation quota"
|
throw "You have exceeded your automation quota"
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
export let behaviourFilters = false
|
export let behaviourFilters = false
|
||||||
export let allowBindings = false
|
export let allowBindings = false
|
||||||
export let filtersLabel = "Filters"
|
export let filtersLabel = "Filters"
|
||||||
|
export let showFilterEmptyDropdown = true
|
||||||
$: {
|
$: {
|
||||||
if (
|
if (
|
||||||
tables.find(
|
tables.find(
|
||||||
|
@ -218,7 +218,7 @@
|
||||||
on:change={e => handleAllOr(e.detail)}
|
on:change={e => handleAllOr(e.detail)}
|
||||||
placeholder={null}
|
placeholder={null}
|
||||||
/>
|
/>
|
||||||
{#if datasource?.type === "table"}
|
{#if datasource?.type === "table" && showFilterEmptyDropdown}
|
||||||
<Select
|
<Select
|
||||||
label="When filter empty"
|
label="When filter empty"
|
||||||
value={onEmptyFilter}
|
value={onEmptyFilter}
|
||||||
|
|
|
@ -1,16 +1,6 @@
|
||||||
import * as triggers from "../../automations/triggers"
|
import * as triggers from "../../automations/triggers"
|
||||||
import {
|
import { DocumentType } from "../../db/utils"
|
||||||
getAutomationParams,
|
import { updateTestHistory, removeDeprecated } from "../../automations/utils"
|
||||||
generateAutomationID,
|
|
||||||
DocumentType,
|
|
||||||
} from "../../db/utils"
|
|
||||||
import {
|
|
||||||
checkForWebhooks,
|
|
||||||
updateTestHistory,
|
|
||||||
removeDeprecated,
|
|
||||||
} from "../../automations/utils"
|
|
||||||
import { deleteEntityMetadata } from "../../utilities"
|
|
||||||
import { MetadataTypes } from "../../constants"
|
|
||||||
import { setTestFlag, clearTestFlag } from "../../utilities/redis"
|
import { setTestFlag, clearTestFlag } from "../../utilities/redis"
|
||||||
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
|
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
|
||||||
import { automations, features } from "@budibase/pro"
|
import { automations, features } from "@budibase/pro"
|
||||||
|
@ -41,42 +31,9 @@ function getTriggerDefinitions() {
|
||||||
* *
|
* *
|
||||||
*************************/
|
*************************/
|
||||||
|
|
||||||
async function cleanupAutomationMetadata(automationId: string) {
|
|
||||||
await deleteEntityMetadata(MetadataTypes.AUTOMATION_TEST_INPUT, automationId)
|
|
||||||
await deleteEntityMetadata(
|
|
||||||
MetadataTypes.AUTOMATION_TEST_HISTORY,
|
|
||||||
automationId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanAutomationInputs(automation: Automation) {
|
|
||||||
if (automation == null) {
|
|
||||||
return automation
|
|
||||||
}
|
|
||||||
let steps = automation.definition.steps
|
|
||||||
let trigger = automation.definition.trigger
|
|
||||||
let allSteps = [...steps, trigger]
|
|
||||||
// live is not a property used anymore
|
|
||||||
if (automation.live != null) {
|
|
||||||
delete automation.live
|
|
||||||
}
|
|
||||||
for (let step of allSteps) {
|
|
||||||
if (step == null) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for (let inputName of Object.keys(step.inputs)) {
|
|
||||||
if (!step.inputs[inputName] || step.inputs[inputName] === "") {
|
|
||||||
delete step.inputs[inputName]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return automation
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function create(
|
export async function create(
|
||||||
ctx: UserCtx<Automation, { message: string; automation: Automation }>
|
ctx: UserCtx<Automation, { message: string; automation: Automation }>
|
||||||
) {
|
) {
|
||||||
const db = context.getAppDB()
|
|
||||||
let automation = ctx.request.body
|
let automation = ctx.request.body
|
||||||
automation.appId = ctx.appId
|
automation.appId = ctx.appId
|
||||||
|
|
||||||
|
@ -86,66 +43,17 @@ export async function create(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Respect existing IDs if recreating a deleted automation
|
const createdAutomation = await sdk.automations.create(automation)
|
||||||
if (!automation._id) {
|
|
||||||
automation._id = generateAutomationID()
|
|
||||||
}
|
|
||||||
|
|
||||||
automation.type = "automation"
|
|
||||||
automation = cleanAutomationInputs(automation)
|
|
||||||
automation = await checkForWebhooks({
|
|
||||||
newAuto: automation,
|
|
||||||
})
|
|
||||||
const response = await db.put(automation)
|
|
||||||
await events.automation.created(automation)
|
|
||||||
for (let step of automation.definition.steps) {
|
|
||||||
await events.automation.stepCreated(automation, step)
|
|
||||||
}
|
|
||||||
automation._rev = response.rev
|
|
||||||
|
|
||||||
ctx.status = 200
|
ctx.status = 200
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: "Automation created successfully",
|
message: "Automation created successfully",
|
||||||
automation: {
|
automation: createdAutomation,
|
||||||
...automation,
|
|
||||||
...response,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
builderSocket?.emitAutomationUpdate(ctx, automation)
|
builderSocket?.emitAutomationUpdate(ctx, automation)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getNewSteps(oldAutomation: Automation, automation: Automation) {
|
|
||||||
const oldStepIds = oldAutomation.definition.steps.map(s => s.id)
|
|
||||||
return automation.definition.steps.filter(s => !oldStepIds.includes(s.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDeletedSteps(
|
|
||||||
oldAutomation: Automation,
|
|
||||||
automation: Automation
|
|
||||||
) {
|
|
||||||
const stepIds = automation.definition.steps.map(s => s.id)
|
|
||||||
return oldAutomation.definition.steps.filter(s => !stepIds.includes(s.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleStepEvents(
|
|
||||||
oldAutomation: Automation,
|
|
||||||
automation: Automation
|
|
||||||
) {
|
|
||||||
// new steps
|
|
||||||
const newSteps = getNewSteps(oldAutomation, automation)
|
|
||||||
for (let step of newSteps) {
|
|
||||||
await events.automation.stepCreated(automation, step)
|
|
||||||
}
|
|
||||||
|
|
||||||
// old steps
|
|
||||||
const deletedSteps = getDeletedSteps(oldAutomation, automation)
|
|
||||||
for (let step of deletedSteps) {
|
|
||||||
await events.automation.stepDeleted(automation, step)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function update(ctx: UserCtx) {
|
export async function update(ctx: UserCtx) {
|
||||||
const db = context.getAppDB()
|
|
||||||
let automation = ctx.request.body
|
let automation = ctx.request.body
|
||||||
automation.appId = ctx.appId
|
automation.appId = ctx.appId
|
||||||
|
|
||||||
|
@ -155,72 +63,28 @@ export async function update(ctx: UserCtx) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldAutomation = await db.get<Automation>(automation._id)
|
const updatedAutomation = await sdk.automations.update(automation)
|
||||||
automation = cleanAutomationInputs(automation)
|
|
||||||
automation = await checkForWebhooks({
|
|
||||||
oldAuto: oldAutomation,
|
|
||||||
newAuto: automation,
|
|
||||||
})
|
|
||||||
const response = await db.put(automation)
|
|
||||||
automation._rev = response.rev
|
|
||||||
|
|
||||||
const oldAutoTrigger =
|
|
||||||
oldAutomation && oldAutomation.definition.trigger
|
|
||||||
? oldAutomation.definition.trigger
|
|
||||||
: undefined
|
|
||||||
const newAutoTrigger =
|
|
||||||
automation && automation.definition.trigger
|
|
||||||
? automation.definition.trigger
|
|
||||||
: {}
|
|
||||||
// trigger has been updated, remove the test inputs
|
|
||||||
if (oldAutoTrigger && oldAutoTrigger.id !== newAutoTrigger.id) {
|
|
||||||
await events.automation.triggerUpdated(automation)
|
|
||||||
await deleteEntityMetadata(
|
|
||||||
MetadataTypes.AUTOMATION_TEST_INPUT,
|
|
||||||
automation._id!
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleStepEvents(oldAutomation, automation)
|
|
||||||
|
|
||||||
ctx.status = 200
|
ctx.status = 200
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
message: `Automation ${automation._id} updated successfully.`,
|
message: `Automation ${automation._id} updated successfully.`,
|
||||||
automation: {
|
automation: updatedAutomation,
|
||||||
...automation,
|
|
||||||
_rev: response.rev,
|
|
||||||
_id: response.id,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
builderSocket?.emitAutomationUpdate(ctx, automation)
|
builderSocket?.emitAutomationUpdate(ctx, automation)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetch(ctx: UserCtx) {
|
export async function fetch(ctx: UserCtx) {
|
||||||
const db = context.getAppDB()
|
ctx.body = await sdk.automations.fetch()
|
||||||
const response = await db.allDocs(
|
|
||||||
getAutomationParams(null, {
|
|
||||||
include_docs: true,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
ctx.body = response.rows.map(row => row.doc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function find(ctx: UserCtx) {
|
export async function find(ctx: UserCtx) {
|
||||||
const db = context.getAppDB()
|
ctx.body = await sdk.automations.get(ctx.params.id)
|
||||||
ctx.body = await db.get(ctx.params.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) {
|
export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) {
|
||||||
const db = context.getAppDB()
|
|
||||||
const automationId = ctx.params.id
|
const automationId = ctx.params.id
|
||||||
const oldAutomation = await db.get<Automation>(automationId)
|
|
||||||
await checkForWebhooks({
|
ctx.body = await sdk.automations.remove(automationId, ctx.params.rev)
|
||||||
oldAuto: oldAutomation,
|
|
||||||
})
|
|
||||||
// delete metadata first
|
|
||||||
await cleanupAutomationMetadata(automationId)
|
|
||||||
ctx.body = await db.remove(automationId, ctx.params.rev)
|
|
||||||
await events.automation.deleted(oldAutomation)
|
|
||||||
builderSocket?.emitAutomationDeletion(ctx, automationId)
|
builderSocket?.emitAutomationDeletion(ctx, automationId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
import {
|
||||||
|
CreateRowActionRequest,
|
||||||
|
Ctx,
|
||||||
|
RowActionResponse,
|
||||||
|
RowActionsResponse,
|
||||||
|
UpdateRowActionRequest,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import sdk from "../../../sdk"
|
||||||
|
|
||||||
|
async function getTable(ctx: Ctx) {
|
||||||
|
const { tableId } = ctx.params
|
||||||
|
const table = await sdk.tables.getTable(tableId)
|
||||||
|
if (!table) {
|
||||||
|
ctx.throw(404)
|
||||||
|
}
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function find(ctx: Ctx<void, RowActionsResponse>) {
|
||||||
|
const table = await getTable(ctx)
|
||||||
|
|
||||||
|
if (!(await sdk.rowActions.docExists(table._id!))) {
|
||||||
|
ctx.body = {
|
||||||
|
actions: {},
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actions } = await sdk.rowActions.get(table._id!)
|
||||||
|
const result: RowActionsResponse = {
|
||||||
|
actions: Object.entries(actions).reduce<Record<string, RowActionResponse>>(
|
||||||
|
(acc, [key, action]) => ({
|
||||||
|
...acc,
|
||||||
|
[key]: { id: key, tableId: table._id!, ...action },
|
||||||
|
}),
|
||||||
|
{}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
ctx.body = result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(
|
||||||
|
ctx: Ctx<CreateRowActionRequest, RowActionResponse>
|
||||||
|
) {
|
||||||
|
const table = await getTable(ctx)
|
||||||
|
|
||||||
|
const createdAction = await sdk.rowActions.create(table._id!, {
|
||||||
|
name: ctx.request.body.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
tableId: table._id!,
|
||||||
|
...createdAction,
|
||||||
|
}
|
||||||
|
ctx.status = 201
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(
|
||||||
|
ctx: Ctx<UpdateRowActionRequest, RowActionResponse>
|
||||||
|
) {
|
||||||
|
const table = await getTable(ctx)
|
||||||
|
const { actionId } = ctx.params
|
||||||
|
|
||||||
|
const actions = await sdk.rowActions.update(table._id!, actionId, {
|
||||||
|
name: ctx.request.body.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
tableId: table._id!,
|
||||||
|
...actions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(ctx: Ctx<void, void>) {
|
||||||
|
const table = await getTable(ctx)
|
||||||
|
const { actionId } = ctx.params
|
||||||
|
|
||||||
|
await sdk.rowActions.remove(table._id!, actionId)
|
||||||
|
ctx.status = 204
|
||||||
|
}
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from "./crud"
|
||||||
|
export * from "./run"
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function run() {
|
||||||
|
throw new Error("Function not implemented.")
|
||||||
|
}
|
|
@ -10,7 +10,7 @@ import {
|
||||||
isExternalTableID,
|
isExternalTableID,
|
||||||
isSQL,
|
isSQL,
|
||||||
} from "../../../integrations/utils"
|
} from "../../../integrations/utils"
|
||||||
import { events } from "@budibase/backend-core"
|
import { events, HTTPError } from "@budibase/backend-core"
|
||||||
import {
|
import {
|
||||||
BulkImportRequest,
|
BulkImportRequest,
|
||||||
BulkImportResponse,
|
BulkImportResponse,
|
||||||
|
@ -29,6 +29,7 @@ import sdk from "../../../sdk"
|
||||||
import { jsonFromCsvString } from "../../../utilities/csv"
|
import { jsonFromCsvString } from "../../../utilities/csv"
|
||||||
import { builderSocket } from "../../../websockets"
|
import { builderSocket } from "../../../websockets"
|
||||||
import { cloneDeep, isEqual } from "lodash"
|
import { cloneDeep, isEqual } from "lodash"
|
||||||
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
|
||||||
function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
|
function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
|
||||||
if (table && isExternalTable(table)) {
|
if (table && isExternalTable(table)) {
|
||||||
|
@ -40,6 +41,20 @@ function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
|
||||||
return internal
|
return internal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkDefaultFields(table: Table) {
|
||||||
|
for (const [key, field] of Object.entries(table.schema)) {
|
||||||
|
if (!("default" in field) || field.default == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (helpers.schema.isRequired(field.constraints)) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Cannot make field "${key}" required, it has a default value.`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// covers both internal and external
|
// covers both internal and external
|
||||||
export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
|
export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
|
||||||
const internal = await sdk.tables.getAllInternalTables()
|
const internal = await sdk.tables.getAllInternalTables()
|
||||||
|
@ -76,6 +91,8 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
|
||||||
const isImport = table.rows
|
const isImport = table.rows
|
||||||
const renaming = ctx.request.body._rename
|
const renaming = ctx.request.body._rename
|
||||||
|
|
||||||
|
checkDefaultFields(table)
|
||||||
|
|
||||||
const api = pickApi({ table })
|
const api = pickApi({ table })
|
||||||
let savedTable = await api.save(ctx, renaming)
|
let savedTable = await api.save(ctx, renaming)
|
||||||
if (!table._id) {
|
if (!table._id) {
|
||||||
|
|
|
@ -28,6 +28,7 @@ import opsRoutes from "./ops"
|
||||||
import debugRoutes from "./debug"
|
import debugRoutes from "./debug"
|
||||||
import Router from "@koa/router"
|
import Router from "@koa/router"
|
||||||
import { api as pro } from "@budibase/pro"
|
import { api as pro } from "@budibase/pro"
|
||||||
|
import rowActionRoutes from "./rowAction"
|
||||||
|
|
||||||
export { default as staticRoutes } from "./static"
|
export { default as staticRoutes } from "./static"
|
||||||
export { default as publicRoutes } from "./public"
|
export { default as publicRoutes } from "./public"
|
||||||
|
@ -65,6 +66,7 @@ export const mainRoutes: Router[] = [
|
||||||
opsRoutes,
|
opsRoutes,
|
||||||
debugRoutes,
|
debugRoutes,
|
||||||
environmentVariableRoutes,
|
environmentVariableRoutes,
|
||||||
|
rowActionRoutes,
|
||||||
// these need to be handled last as they still use /api/:tableId
|
// these need to be handled last as they still use /api/:tableId
|
||||||
// this could be breaking as koa may recognise other routes as this
|
// this could be breaking as koa may recognise other routes as this
|
||||||
tableRoutes,
|
tableRoutes,
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
import Router from "@koa/router"
|
||||||
|
import * as rowActionController from "../controllers/rowAction"
|
||||||
|
import { authorizedResource } from "../../middleware/authorized"
|
||||||
|
|
||||||
|
import { middleware, permissions } from "@budibase/backend-core"
|
||||||
|
import Joi from "joi"
|
||||||
|
|
||||||
|
const { PermissionLevel, PermissionType } = permissions
|
||||||
|
|
||||||
|
export function rowActionValidator() {
|
||||||
|
return middleware.joiValidator.body(
|
||||||
|
Joi.object({
|
||||||
|
name: Joi.string().required(),
|
||||||
|
}),
|
||||||
|
{ allowUnknown: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const router: Router = new Router()
|
||||||
|
|
||||||
|
// CRUD endpoints
|
||||||
|
router
|
||||||
|
.get(
|
||||||
|
"/api/tables/:tableId/actions",
|
||||||
|
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
|
||||||
|
rowActionController.find
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/api/tables/:tableId/actions",
|
||||||
|
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
|
||||||
|
rowActionValidator(),
|
||||||
|
rowActionController.create
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
"/api/tables/:tableId/actions/:actionId",
|
||||||
|
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
|
||||||
|
rowActionValidator(),
|
||||||
|
rowActionController.update
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/api/tables/:tableId/actions/:actionId",
|
||||||
|
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
|
||||||
|
rowActionController.remove
|
||||||
|
)
|
||||||
|
|
||||||
|
// Other endpoints
|
||||||
|
.post(
|
||||||
|
"/api/tables/:tableId/actions/:actionId/run",
|
||||||
|
authorizedResource(PermissionType.TABLE, PermissionLevel.READ, "tableId"),
|
||||||
|
rowActionController.run
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} from "../../../automations"
|
} from "../../../automations"
|
||||||
import { events } from "@budibase/backend-core"
|
import { events } from "@budibase/backend-core"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import { Automation } from "@budibase/types"
|
import { Automation, FieldType, Table } from "@budibase/types"
|
||||||
import { mocks } from "@budibase/backend-core/tests"
|
import { mocks } from "@budibase/backend-core/tests"
|
||||||
import { FilterConditions } from "../../../automations/steps/filter"
|
import { FilterConditions } from "../../../automations/steps/filter"
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ let {
|
||||||
automationStep,
|
automationStep,
|
||||||
collectAutomation,
|
collectAutomation,
|
||||||
filterAutomation,
|
filterAutomation,
|
||||||
|
updateRowAutomationWithFilters,
|
||||||
} = setup.structures
|
} = setup.structures
|
||||||
|
|
||||||
describe("/automations", () => {
|
describe("/automations", () => {
|
||||||
|
@ -154,7 +155,7 @@ describe("/automations", () => {
|
||||||
tableId: table._id,
|
tableId: table._id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
automation.appId = config.appId
|
automation.appId = config.getAppId()
|
||||||
automation = await config.createAutomation(automation)
|
automation = await config.createAutomation(automation)
|
||||||
await setup.delay(500)
|
await setup.delay(500)
|
||||||
const res = await testAutomation(config, automation, {
|
const res = await testAutomation(config, automation, {
|
||||||
|
@ -267,8 +268,7 @@ describe("/automations", () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
it("updates a automations name", async () => {
|
it("updates a automations name", async () => {
|
||||||
let automation = newAutomation()
|
const automation = await config.createAutomation(newAutomation())
|
||||||
await config.createAutomation(automation)
|
|
||||||
automation.name = "Updated Name"
|
automation.name = "Updated Name"
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
@ -294,8 +294,7 @@ describe("/automations", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("updates a automations name using POST request", async () => {
|
it("updates a automations name using POST request", async () => {
|
||||||
let automation = newAutomation()
|
const automation = await config.createAutomation(newAutomation())
|
||||||
await config.createAutomation(automation)
|
|
||||||
automation.name = "Updated Name"
|
automation.name = "Updated Name"
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
|
||||||
|
@ -392,8 +391,7 @@ describe("/automations", () => {
|
||||||
describe("fetch", () => {
|
describe("fetch", () => {
|
||||||
it("return all the automations for an instance", async () => {
|
it("return all the automations for an instance", async () => {
|
||||||
await clearAllAutomations(config)
|
await clearAllAutomations(config)
|
||||||
const autoConfig = basicAutomation()
|
const autoConfig = await config.createAutomation(basicAutomation())
|
||||||
await config.createAutomation(autoConfig)
|
|
||||||
const res = await request
|
const res = await request
|
||||||
.get(`/api/automations`)
|
.get(`/api/automations`)
|
||||||
.set(config.defaultHeaders())
|
.set(config.defaultHeaders())
|
||||||
|
@ -455,14 +453,13 @@ describe("/automations", () => {
|
||||||
|
|
||||||
let table = await config.createTable()
|
let table = await config.createTable()
|
||||||
|
|
||||||
let automation = await filterAutomation()
|
let automation = await filterAutomation(config.getAppId())
|
||||||
automation.definition.trigger.inputs.tableId = table._id
|
automation.definition.trigger.inputs.tableId = table._id
|
||||||
automation.definition.steps[0].inputs = {
|
automation.definition.steps[0].inputs = {
|
||||||
condition: FilterConditions.EQUAL,
|
condition: FilterConditions.EQUAL,
|
||||||
field: "{{ trigger.row.City }}",
|
field: "{{ trigger.row.City }}",
|
||||||
value: "{{ trigger.oldRow.City }}",
|
value: "{{ trigger.oldRow.City }}",
|
||||||
}
|
}
|
||||||
automation.appId = config.appId!
|
|
||||||
automation = await config.createAutomation(automation)
|
automation = await config.createAutomation(automation)
|
||||||
let triggerInputs = {
|
let triggerInputs = {
|
||||||
oldRow: {
|
oldRow: {
|
||||||
|
@ -477,4 +474,91 @@ describe("/automations", () => {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
describe("Automation Update / Creator row trigger filtering", () => {
|
||||||
|
let table: Table
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.createTable({
|
||||||
|
name: "table",
|
||||||
|
type: "table",
|
||||||
|
schema: {
|
||||||
|
Approved: {
|
||||||
|
name: "Approved",
|
||||||
|
type: FieldType.BOOLEAN,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
description: "should run when Approved changes from false to true",
|
||||||
|
filters: {
|
||||||
|
equal: { "1:Approved": true },
|
||||||
|
},
|
||||||
|
row: { Approved: "true" },
|
||||||
|
oldRow: { Approved: "false" },
|
||||||
|
expectToRun: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "should run when Approved is true in both old and new row",
|
||||||
|
filters: { equal: { "1:Approved": true } },
|
||||||
|
row: { Approved: "true" },
|
||||||
|
oldRow: { Approved: "true" },
|
||||||
|
expectToRun: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"should run when a contains filter matches the correct options",
|
||||||
|
filters: {
|
||||||
|
contains: { "1:opts": ["Option 1", "Option 3"] },
|
||||||
|
},
|
||||||
|
row: { opts: ["Option 1", "Option 3"] },
|
||||||
|
oldRow: { opts: ["Option 3"] },
|
||||||
|
expectToRun: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"should not run when opts doesn't contain any specified option",
|
||||||
|
filters: {
|
||||||
|
contains: { "1:opts": ["Option 1", "Option 2"] },
|
||||||
|
},
|
||||||
|
row: { opts: ["Option 3", "Option 4"] },
|
||||||
|
oldRow: { opts: ["Option 3", "Option 4"] },
|
||||||
|
expectToRun: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it.each(testCases)(
|
||||||
|
"$description",
|
||||||
|
async ({ filters, row, oldRow, expectToRun }) => {
|
||||||
|
let automation = await updateRowAutomationWithFilters(config.getAppId())
|
||||||
|
automation.definition.trigger.inputs = {
|
||||||
|
tableId: table._id,
|
||||||
|
filters,
|
||||||
|
}
|
||||||
|
automation = await config.createAutomation(automation)
|
||||||
|
|
||||||
|
const inputs = {
|
||||||
|
row: {
|
||||||
|
tableId: table._id,
|
||||||
|
...row,
|
||||||
|
},
|
||||||
|
oldRow: {
|
||||||
|
tableId: table._id,
|
||||||
|
...oldRow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await testAutomation(config, automation, inputs)
|
||||||
|
|
||||||
|
if (expectToRun) {
|
||||||
|
expect(res.body.steps[1].outputs.success).toEqual(true)
|
||||||
|
} else {
|
||||||
|
expect(res.body.outputs.success).toEqual(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -550,6 +550,239 @@ describe.each([
|
||||||
|
|
||||||
expect(row.name).toEqual(`{ "foo": "2023-01-26T11:48:57.000Z" }`)
|
expect(row.name).toEqual(`{ "foo": "2023-01-26T11:48:57.000Z" }`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("default values", () => {
|
||||||
|
let table: Table
|
||||||
|
|
||||||
|
describe("string column", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
description: {
|
||||||
|
name: "description",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: "default description",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("creates a new row with a default value successfully", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
expect(row.description).toEqual("default description")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not use default value if value specified", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
description: "specified description",
|
||||||
|
})
|
||||||
|
expect(row.description).toEqual("specified description")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses the default value if value is null", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
description: null,
|
||||||
|
})
|
||||||
|
expect(row.description).toEqual("default description")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses the default value if value is undefined", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
description: undefined,
|
||||||
|
})
|
||||||
|
expect(row.description).toEqual("default description")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("number column", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
age: {
|
||||||
|
name: "age",
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
default: "25",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("creates a new row with a default value successfully", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
expect(row.age).toEqual(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not use default value if value specified", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
age: 30,
|
||||||
|
})
|
||||||
|
expect(row.age).toEqual(30)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("date column", () => {
|
||||||
|
it("creates a row with a default value successfully", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
date: {
|
||||||
|
name: "date",
|
||||||
|
type: FieldType.DATETIME,
|
||||||
|
default: "2023-01-26T11:48:57.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
expect(row.date).toEqual("2023-01-26T11:48:57.000Z")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gives an error if the default value is invalid", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
date: {
|
||||||
|
name: "date",
|
||||||
|
type: FieldType.DATETIME,
|
||||||
|
default: "invalid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
await config.api.row.save(
|
||||||
|
table._id!,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: `Invalid default value for field 'date' - Invalid date value: "invalid"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("bindings", () => {
|
||||||
|
describe("string column", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
description: {
|
||||||
|
name: "description",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: `{{ date now "YYYY-MM-DDTHH:mm:ss" }}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can use bindings in default values", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
expect(row.description).toMatch(
|
||||||
|
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not use default value if value specified", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
description: "specified description",
|
||||||
|
})
|
||||||
|
expect(row.description).toEqual("specified description")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can bind the current user", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
user: {
|
||||||
|
name: "user",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: `{{ [Current User]._id }}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
expect(row.user).toEqual(config.getUser()._id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cannot access current user password", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
user: {
|
||||||
|
name: "user",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: `{{ user.password }}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
// For some reason it's null for internal tables, and undefined for
|
||||||
|
// external.
|
||||||
|
expect(row.user == null).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("number column", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
age: {
|
||||||
|
name: "age",
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
default: `{{ sum 10 10 5 }}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can use bindings in default values", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
expect(row.age).toEqual(25)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("invalid default value", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
age: {
|
||||||
|
name: "age",
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
default: `{{ capitalize "invalid" }}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws an error when invalid default value", async () => {
|
||||||
|
await config.api.row.save(
|
||||||
|
table._id!,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message:
|
||||||
|
"Invalid default value for field 'age' - Invalid number value \"Invalid\"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("get", () => {
|
describe("get", () => {
|
||||||
|
|
|
@ -0,0 +1,412 @@
|
||||||
|
import _ from "lodash"
|
||||||
|
import tk from "timekeeper"
|
||||||
|
|
||||||
|
import { CreateRowActionRequest, RowActionResponse } from "@budibase/types"
|
||||||
|
import * as setup from "./utilities"
|
||||||
|
import { generator } from "@budibase/backend-core/tests"
|
||||||
|
|
||||||
|
describe("/rowsActions", () => {
|
||||||
|
const config = setup.getConfig()
|
||||||
|
|
||||||
|
let tableId: string
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tk.freeze(new Date())
|
||||||
|
await config.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const table = await config.api.table.save(setup.structures.basicTable())
|
||||||
|
tableId = table._id!
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(setup.afterAll)
|
||||||
|
|
||||||
|
const createRowAction = config.api.rowAction.save
|
||||||
|
|
||||||
|
function createRowActionRequest(): CreateRowActionRequest {
|
||||||
|
return {
|
||||||
|
name: generator.string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRowActionRequests(count: number): CreateRowActionRequest[] {
|
||||||
|
return generator
|
||||||
|
.unique(() => generator.string(), count)
|
||||||
|
.map(name => ({ name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function unauthorisedTests() {
|
||||||
|
it("returns unauthorised (401) for unauthenticated requests", async () => {
|
||||||
|
await createRowAction(
|
||||||
|
tableId,
|
||||||
|
createRowActionRequest(),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
message: "Session not authenticated",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ publicUser: true }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns forbidden (403) for non-builder users", async () => {
|
||||||
|
const user = await config.createUser({
|
||||||
|
builder: {},
|
||||||
|
})
|
||||||
|
await config.withUser(user, async () => {
|
||||||
|
await createRowAction(generator.guid(), createRowActionRequest(), {
|
||||||
|
status: 403,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects (404) for a non-existing table", async () => {
|
||||||
|
await createRowAction(generator.guid(), createRowActionRequest(), {
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
unauthorisedTests()
|
||||||
|
|
||||||
|
it("creates new row actions for tables without existing actions", async () => {
|
||||||
|
const rowAction = createRowActionRequest()
|
||||||
|
const res = await createRowAction(tableId, rowAction, {
|
||||||
|
status: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res).toEqual({
|
||||||
|
id: expect.stringMatching(/^row_action_\w+/),
|
||||||
|
tableId: tableId,
|
||||||
|
...rowAction,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||||
|
actions: {
|
||||||
|
[res.id]: {
|
||||||
|
...rowAction,
|
||||||
|
id: res.id,
|
||||||
|
tableId: tableId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("trims row action names", async () => {
|
||||||
|
const name = " action name "
|
||||||
|
const res = await createRowAction(
|
||||||
|
tableId,
|
||||||
|
{ name },
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(res).toEqual({
|
||||||
|
id: expect.stringMatching(/^row_action_\w+/),
|
||||||
|
tableId: tableId,
|
||||||
|
name: "action name",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||||
|
actions: {
|
||||||
|
[res.id]: expect.objectContaining({
|
||||||
|
name: "action name",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can create multiple row actions for the same table", async () => {
|
||||||
|
const rowActions = createRowActionRequests(3)
|
||||||
|
const responses: RowActionResponse[] = []
|
||||||
|
for (const action of rowActions) {
|
||||||
|
responses.push(await createRowAction(tableId, action))
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||||
|
actions: {
|
||||||
|
[responses[0].id]: { ...rowActions[0], id: responses[0].id, tableId },
|
||||||
|
[responses[1].id]: { ...rowActions[1], id: responses[1].id, tableId },
|
||||||
|
[responses[2].id]: { ...rowActions[2], id: responses[2].id, tableId },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects with bad request when creating with no name", async () => {
|
||||||
|
const rowAction: CreateRowActionRequest = {
|
||||||
|
name: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
await createRowAction(tableId, rowAction, {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message: 'Invalid body - "name" is not allowed to be empty',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores not valid row action data", async () => {
|
||||||
|
const rowAction = createRowActionRequest()
|
||||||
|
const dirtyRowAction = {
|
||||||
|
...rowAction,
|
||||||
|
id: generator.guid(),
|
||||||
|
valueToIgnore: generator.string(),
|
||||||
|
}
|
||||||
|
const res = await createRowAction(tableId, dirtyRowAction, {
|
||||||
|
status: 201,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res).toEqual({
|
||||||
|
id: expect.any(String),
|
||||||
|
tableId,
|
||||||
|
...rowAction,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await config.api.rowAction.find(tableId)).toEqual({
|
||||||
|
actions: {
|
||||||
|
[res.id]: {
|
||||||
|
id: res.id,
|
||||||
|
tableId: tableId,
|
||||||
|
...rowAction,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can not create multiple row actions with the same name (for the same table)", async () => {
|
||||||
|
const action = await createRowAction(tableId, {
|
||||||
|
name: "Row action name ",
|
||||||
|
})
|
||||||
|
|
||||||
|
await createRowAction(
|
||||||
|
tableId,
|
||||||
|
{ name: action.name },
|
||||||
|
{
|
||||||
|
status: 409,
|
||||||
|
body: {
|
||||||
|
message: "A row action with the same name already exists.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await createRowAction(
|
||||||
|
tableId,
|
||||||
|
{ name: "row action name" },
|
||||||
|
{
|
||||||
|
status: 409,
|
||||||
|
body: {
|
||||||
|
message: "A row action with the same name already exists.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can reuse row action names between different tables", async () => {
|
||||||
|
const otherTable = await config.api.table.save(
|
||||||
|
setup.structures.basicTable()
|
||||||
|
)
|
||||||
|
|
||||||
|
const action = await createRowAction(tableId, createRowActionRequest())
|
||||||
|
|
||||||
|
await createRowAction(otherTable._id!, { name: action.name })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("find", () => {
|
||||||
|
unauthorisedTests()
|
||||||
|
|
||||||
|
it("returns only the actions for the requested table", async () => {
|
||||||
|
const rowActions: RowActionResponse[] = []
|
||||||
|
for (const action of createRowActionRequests(3)) {
|
||||||
|
rowActions.push(await createRowAction(tableId, action))
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherTable = await config.api.table.save(
|
||||||
|
setup.structures.basicTable()
|
||||||
|
)
|
||||||
|
await createRowAction(otherTable._id!, createRowActionRequest())
|
||||||
|
|
||||||
|
const response = await config.api.rowAction.find(tableId)
|
||||||
|
expect(response).toEqual({
|
||||||
|
actions: {
|
||||||
|
[rowActions[0].id]: expect.any(Object),
|
||||||
|
[rowActions[1].id]: expect.any(Object),
|
||||||
|
[rowActions[2].id]: expect.any(Object),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty for tables without row actions", async () => {
|
||||||
|
const response = await config.api.rowAction.find(tableId)
|
||||||
|
expect(response).toEqual({
|
||||||
|
actions: {},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("update", () => {
|
||||||
|
unauthorisedTests()
|
||||||
|
|
||||||
|
it("can update existing actions", async () => {
|
||||||
|
for (const rowAction of createRowActionRequests(3)) {
|
||||||
|
await createRowAction(tableId, rowAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = await config.api.rowAction.find(tableId)
|
||||||
|
|
||||||
|
const [actionId, actionData] = _.sample(
|
||||||
|
Object.entries(persisted.actions)
|
||||||
|
)!
|
||||||
|
|
||||||
|
const updatedName = generator.string()
|
||||||
|
|
||||||
|
const res = await config.api.rowAction.update(tableId, actionId, {
|
||||||
|
...actionData,
|
||||||
|
name: updatedName,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res).toEqual({
|
||||||
|
id: actionId,
|
||||||
|
tableId,
|
||||||
|
name: updatedName,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await config.api.rowAction.find(tableId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: expect.objectContaining({
|
||||||
|
[actionId]: {
|
||||||
|
...actionData,
|
||||||
|
name: updatedName,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("trims row action names", async () => {
|
||||||
|
const rowAction = await createRowAction(
|
||||||
|
tableId,
|
||||||
|
createRowActionRequest(),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await config.api.rowAction.update(tableId, rowAction.id, {
|
||||||
|
...rowAction,
|
||||||
|
name: " action name ",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(res).toEqual(expect.objectContaining({ name: "action name" }))
|
||||||
|
|
||||||
|
expect(await config.api.rowAction.find(tableId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: expect.objectContaining({
|
||||||
|
[rowAction.id]: expect.objectContaining({
|
||||||
|
name: "action name",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws Bad Request when trying to update by a non-existing id", async () => {
|
||||||
|
await createRowAction(tableId, createRowActionRequest())
|
||||||
|
|
||||||
|
await config.api.rowAction.update(
|
||||||
|
tableId,
|
||||||
|
generator.guid(),
|
||||||
|
createRowActionRequest(),
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws Bad Request when trying to update by a via another table id", async () => {
|
||||||
|
const otherTable = await config.api.table.save(
|
||||||
|
setup.structures.basicTable()
|
||||||
|
)
|
||||||
|
await createRowAction(otherTable._id!, createRowActionRequest())
|
||||||
|
|
||||||
|
const action = await createRowAction(tableId, createRowActionRequest())
|
||||||
|
await config.api.rowAction.update(
|
||||||
|
otherTable._id!,
|
||||||
|
action.id,
|
||||||
|
createRowActionRequest(),
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("can not use existing row action names (for the same table)", async () => {
|
||||||
|
const action1 = await createRowAction(tableId, createRowActionRequest())
|
||||||
|
const action2 = await createRowAction(tableId, createRowActionRequest())
|
||||||
|
|
||||||
|
await config.api.rowAction.update(
|
||||||
|
tableId,
|
||||||
|
action1.id,
|
||||||
|
{ name: action2.name },
|
||||||
|
{
|
||||||
|
status: 409,
|
||||||
|
body: {
|
||||||
|
message: "A row action with the same name already exists.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not throw with name conflicts for the same row action", async () => {
|
||||||
|
const action1 = await createRowAction(tableId, createRowActionRequest())
|
||||||
|
|
||||||
|
await config.api.rowAction.update(tableId, action1.id, {
|
||||||
|
name: action1.name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("delete", () => {
|
||||||
|
unauthorisedTests()
|
||||||
|
|
||||||
|
it("can delete existing actions", async () => {
|
||||||
|
const actions: RowActionResponse[] = []
|
||||||
|
for (const rowAction of createRowActionRequests(3)) {
|
||||||
|
actions.push(await createRowAction(tableId, rowAction))
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionToDelete = _.sample(actions)!
|
||||||
|
|
||||||
|
await config.api.rowAction.delete(tableId, actionToDelete.id, {
|
||||||
|
status: 204,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await config.api.rowAction.find(tableId)).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
actions: actions
|
||||||
|
.filter(a => a.id !== actionToDelete.id)
|
||||||
|
.reduce((acc, c) => ({ ...acc, [c.id]: expect.any(Object) }), {}),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws Bad Request when trying to delete by a non-existing id", async () => {
|
||||||
|
await createRowAction(tableId, createRowActionRequest())
|
||||||
|
|
||||||
|
await config.api.rowAction.delete(tableId, generator.guid(), {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws Bad Request when trying to delete by a via another table id", async () => {
|
||||||
|
const otherTable = await config.api.table.save(
|
||||||
|
setup.structures.basicTable()
|
||||||
|
)
|
||||||
|
await createRowAction(otherTable._id!, createRowActionRequest())
|
||||||
|
|
||||||
|
const action = await createRowAction(tableId, createRowActionRequest())
|
||||||
|
await config.api.rowAction.delete(otherTable._id!, action.id, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -49,7 +49,7 @@ describe.each([
|
||||||
const isSqs = name === "sqs"
|
const isSqs = name === "sqs"
|
||||||
const isLucene = name === "lucene"
|
const isLucene = name === "lucene"
|
||||||
const isInMemory = name === "in-memory"
|
const isInMemory = name === "in-memory"
|
||||||
const isInternal = isSqs || isLucene
|
const isInternal = isSqs || isLucene || isInMemory
|
||||||
const config = setup.getConfig()
|
const config = setup.getConfig()
|
||||||
|
|
||||||
let envCleanup: (() => void) | undefined
|
let envCleanup: (() => void) | undefined
|
||||||
|
@ -115,10 +115,7 @@ describe.each([
|
||||||
if (isInMemory) {
|
if (isInMemory) {
|
||||||
return dataFilters.search(_.cloneDeep(rows), this.query)
|
return dataFilters.search(_.cloneDeep(rows), this.query)
|
||||||
} else {
|
} else {
|
||||||
return config.api.row.search(table._id!, {
|
return config.api.row.search(this.query.tableId, this.query)
|
||||||
...this.query,
|
|
||||||
tableId: table._id!,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2182,8 +2179,7 @@ describe.each([
|
||||||
}).toContainExactly([{ name: "baz", productCat: undefined }])
|
}).toContainExactly([{ name: "baz", productCat: undefined }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
;(isSqs || isLucene) &&
|
||||||
isInternal &&
|
|
||||||
describe("relations to same table", () => {
|
describe("relations to same table", () => {
|
||||||
let relatedTable: Table, relatedRows: Row[]
|
let relatedTable: Table, relatedRows: Row[]
|
||||||
|
|
||||||
|
@ -2371,6 +2367,7 @@ describe.each([
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.api.application.addSampleData(config.appId!)
|
await config.api.application.addSampleData(config.appId!)
|
||||||
table = DEFAULT_EMPLOYEE_TABLE_SCHEMA
|
table = DEFAULT_EMPLOYEE_TABLE_SCHEMA
|
||||||
|
rows = await config.api.row.fetch(table._id!)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to search sample data", async () => {
|
it("should be able to search sample data", async () => {
|
||||||
|
@ -2455,4 +2452,76 @@ describe.each([
|
||||||
}).toContainExactly([{ [name]: "a" }])
|
}).toContainExactly([{ [name]: "a" }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// This is currently not supported in external datasources, it produces SQL
|
||||||
|
// errors at time of writing. We supported it (potentially by accident) in
|
||||||
|
// Lucene, though, so we need to make sure it's supported in SQS as well. We
|
||||||
|
// found real cases in production of column names ending in a space.
|
||||||
|
isInternal &&
|
||||||
|
describe("space at end of column name", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await createTable({
|
||||||
|
"name ": {
|
||||||
|
name: "name ",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createRows([{ ["name "]: "foo" }, { ["name "]: "bar" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that ends with a space", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
"name ": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ ["name "]: "foo" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that ends with a space using numeric notation", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
"1:name ": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ ["name "]: "foo" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// This was never actually supported in Lucene but SQS does support it, so may
|
||||||
|
// as well have a test for it.
|
||||||
|
;(isSqs || isInMemory) &&
|
||||||
|
describe("space at start of column name", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await createTable({
|
||||||
|
" name": {
|
||||||
|
name: " name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await createRows([{ [" name"]: "foo" }, { [" name"]: "bar" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that starts with a space", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
" name": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ [" name"]: "foo" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to query a column that starts with a space using numeric notation", async () => {
|
||||||
|
await expectSearch({
|
||||||
|
query: {
|
||||||
|
string: {
|
||||||
|
"1: name": "foo",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).toContainExactly([{ [" name"]: "foo" }])
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -86,6 +86,30 @@ describe.each([
|
||||||
expect(events.rows.imported).toHaveBeenCalledWith(res, 1)
|
expect(events.rows.imported).toHaveBeenCalledWith(res, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should not allow a column to have a default value and be required", async () => {
|
||||||
|
await config.api.table.save(
|
||||||
|
tableForDatasource(datasource, {
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: "default",
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message:
|
||||||
|
'Cannot make field "name" required, it has a default value.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it("should apply authorization to endpoint", async () => {
|
it("should apply authorization to endpoint", async () => {
|
||||||
await checkBuilderEndpoint({
|
await checkBuilderEndpoint({
|
||||||
config,
|
config,
|
||||||
|
@ -225,6 +249,142 @@ describe.each([
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("default field validation", () => {
|
||||||
|
it("should error if an existing column is set to required and has a default value", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
tableForDatasource(datasource, {
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await config.api.table.save(
|
||||||
|
{
|
||||||
|
...table,
|
||||||
|
schema: {
|
||||||
|
...table.schema,
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: "default",
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message:
|
||||||
|
'Cannot make field "name" required, it has a default value.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should error if an existing column is given a default value and is required", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
tableForDatasource(datasource, {
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await config.api.table.save(
|
||||||
|
{
|
||||||
|
...table,
|
||||||
|
schema: {
|
||||||
|
...table.schema,
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: "default",
|
||||||
|
constraints: {
|
||||||
|
presence: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
message:
|
||||||
|
'Cannot make field "name" required, it has a default value.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to set an existing column to have a default value if it's not required", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
tableForDatasource(datasource, {
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await config.api.table.save(
|
||||||
|
{
|
||||||
|
...table,
|
||||||
|
schema: {
|
||||||
|
...table.schema,
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to remove a default value if the column is not required", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
tableForDatasource(datasource, {
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
default: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await config.api.table.save(
|
||||||
|
{
|
||||||
|
...table,
|
||||||
|
schema: {
|
||||||
|
...table.schema,
|
||||||
|
name: {
|
||||||
|
name: "name",
|
||||||
|
type: FieldType.STRING,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("external table validation", () => {
|
describe("external table validation", () => {
|
||||||
!isInternal &&
|
!isInternal &&
|
||||||
it("should error if column is of type auto", async () => {
|
it("should error if column is of type auto", async () => {
|
||||||
|
|
|
@ -1022,6 +1022,11 @@ describe.each([
|
||||||
schema: {
|
schema: {
|
||||||
one: { type: FieldType.STRING, name: "one" },
|
one: { type: FieldType.STRING, name: "one" },
|
||||||
two: { type: FieldType.STRING, name: "two" },
|
two: { type: FieldType.STRING, name: "two" },
|
||||||
|
default: {
|
||||||
|
type: FieldType.STRING,
|
||||||
|
name: "default",
|
||||||
|
default: "default",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
@ -1042,11 +1047,13 @@ describe.each([
|
||||||
_viewId: view.id,
|
_viewId: view.id,
|
||||||
one: "foo",
|
one: "foo",
|
||||||
two: "bar",
|
two: "bar",
|
||||||
|
default: "ohnoes",
|
||||||
})
|
})
|
||||||
|
|
||||||
const row = await config.api.row.get(table._id!, newRow._id!)
|
const row = await config.api.row.get(table._id!, newRow._id!)
|
||||||
expect(row.one).toBeUndefined()
|
expect(row.one).toBeUndefined()
|
||||||
expect(row.two).toEqual("bar")
|
expect(row.two).toEqual("bar")
|
||||||
|
expect(row.default).toEqual("default")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("can't persist readonly columns", async () => {
|
it("can't persist readonly columns", async () => {
|
||||||
|
|
|
@ -23,6 +23,11 @@ export const definition: AutomationTriggerSchema = {
|
||||||
customType: AutomationCustomIOType.TABLE,
|
customType: AutomationCustomIOType.TABLE,
|
||||||
title: "Table",
|
title: "Table",
|
||||||
},
|
},
|
||||||
|
filters: {
|
||||||
|
type: AutomationIOType.OBJECT,
|
||||||
|
customType: AutomationCustomIOType.FILTERS,
|
||||||
|
title: "Filtering",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ["tableId"],
|
required: ["tableId"],
|
||||||
},
|
},
|
||||||
|
|
|
@ -23,6 +23,11 @@ export const definition: AutomationTriggerSchema = {
|
||||||
customType: AutomationCustomIOType.TABLE,
|
customType: AutomationCustomIOType.TABLE,
|
||||||
title: "Table",
|
title: "Table",
|
||||||
},
|
},
|
||||||
|
filters: {
|
||||||
|
type: AutomationIOType.OBJECT,
|
||||||
|
customType: AutomationCustomIOType.TRIGGER_FILTER,
|
||||||
|
title: "Filtering",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ["tableId"],
|
required: ["tableId"],
|
||||||
},
|
},
|
||||||
|
|
|
@ -15,14 +15,19 @@ import {
|
||||||
AutomationJob,
|
AutomationJob,
|
||||||
AutomationEventType,
|
AutomationEventType,
|
||||||
UpdatedRowEventEmitter,
|
UpdatedRowEventEmitter,
|
||||||
|
SearchFilters,
|
||||||
|
AutomationStoppedReason,
|
||||||
|
AutomationStatus,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { executeInThread } from "../threads/automation"
|
import { executeInThread } from "../threads/automation"
|
||||||
|
import { dataFilters } from "@budibase/shared-core"
|
||||||
|
|
||||||
export const TRIGGER_DEFINITIONS = definitions
|
export const TRIGGER_DEFINITIONS = definitions
|
||||||
const JOB_OPTS = {
|
const JOB_OPTS = {
|
||||||
removeOnComplete: true,
|
removeOnComplete: true,
|
||||||
removeOnFail: true,
|
removeOnFail: true,
|
||||||
}
|
}
|
||||||
|
import * as automationUtils from "../automations/automationUtils"
|
||||||
|
|
||||||
async function getAllAutomations() {
|
async function getAllAutomations() {
|
||||||
const db = context.getAppDB()
|
const db = context.getAppDB()
|
||||||
|
@ -33,7 +38,7 @@ async function getAllAutomations() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queueRelevantRowAutomations(
|
async function queueRelevantRowAutomations(
|
||||||
event: { appId: string; row: Row },
|
event: { appId: string; row: Row; oldRow: Row },
|
||||||
eventType: string
|
eventType: string
|
||||||
) {
|
) {
|
||||||
if (event.appId == null) {
|
if (event.appId == null) {
|
||||||
|
@ -62,9 +67,15 @@ async function queueRelevantRowAutomations(
|
||||||
) {
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldTrigger = await checkTriggerFilters(automation, {
|
||||||
|
row: event.row,
|
||||||
|
oldRow: event.oldRow,
|
||||||
|
})
|
||||||
if (
|
if (
|
||||||
automationTrigger?.inputs &&
|
automationTrigger?.inputs &&
|
||||||
automationTrigger.inputs.tableId === event.row.tableId
|
automationTrigger.inputs.tableId === event.row.tableId &&
|
||||||
|
shouldTrigger
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await automationQueue.add({ automation, event }, JOB_OPTS)
|
await automationQueue.add({ automation, event }, JOB_OPTS)
|
||||||
|
@ -103,6 +114,11 @@ emitter.on(AutomationEventType.ROW_DELETE, async function (event) {
|
||||||
await queueRelevantRowAutomations(event, AutomationEventType.ROW_DELETE)
|
await queueRelevantRowAutomations(event, AutomationEventType.ROW_DELETE)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function rowPassesFilters(row: Row, filters: SearchFilters) {
|
||||||
|
const filteredRows = dataFilters.runQuery([row], filters)
|
||||||
|
return filteredRows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
export async function externalTrigger(
|
export async function externalTrigger(
|
||||||
automation: Automation,
|
automation: Automation,
|
||||||
params: { fields: Record<string, any>; timeout?: number },
|
params: { fields: Record<string, any>; timeout?: number },
|
||||||
|
@ -126,7 +142,23 @@ export async function externalTrigger(
|
||||||
}
|
}
|
||||||
params.fields = coercedFields
|
params.fields = coercedFields
|
||||||
}
|
}
|
||||||
const data: AutomationData = { automation, event: params as any }
|
const data: AutomationData = { automation, event: params }
|
||||||
|
|
||||||
|
const shouldTrigger = await checkTriggerFilters(automation, {
|
||||||
|
row: data.event?.row ?? {},
|
||||||
|
oldRow: data.event?.oldRow ?? {},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!shouldTrigger) {
|
||||||
|
return {
|
||||||
|
outputs: {
|
||||||
|
success: false,
|
||||||
|
status: AutomationStatus.STOPPED,
|
||||||
|
},
|
||||||
|
message: AutomationStoppedReason.TRIGGER_FILTER_NOT_MET,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (getResponses) {
|
if (getResponses) {
|
||||||
data.event = {
|
data.event = {
|
||||||
...data.event,
|
...data.event,
|
||||||
|
@ -171,3 +203,25 @@ export async function rebootTrigger() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkTriggerFilters(
|
||||||
|
automation: Automation,
|
||||||
|
event: { row: Row; oldRow: Row }
|
||||||
|
): Promise<boolean> {
|
||||||
|
const trigger = automation.definition.trigger
|
||||||
|
const filters = trigger?.inputs?.filters
|
||||||
|
const tableId = trigger?.inputs?.tableId
|
||||||
|
|
||||||
|
if (!filters) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trigger.stepId === definitions.ROW_UPDATED.stepId ||
|
||||||
|
trigger.stepId === definitions.ROW_SAVED.stepId
|
||||||
|
) {
|
||||||
|
const newRow = await automationUtils.cleanUpRow(tableId, event.row)
|
||||||
|
return rowPassesFilters(newRow, filters)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
@ -7,18 +7,11 @@ import { db as dbCore, context, utils } from "@budibase/backend-core"
|
||||||
import { getAutomationMetadataParams } from "../db/utils"
|
import { getAutomationMetadataParams } from "../db/utils"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { quotas } from "@budibase/pro"
|
import { quotas } from "@budibase/pro"
|
||||||
import {
|
import { Automation, AutomationJob } from "@budibase/types"
|
||||||
Automation,
|
|
||||||
AutomationJob,
|
|
||||||
Webhook,
|
|
||||||
WebhookActionType,
|
|
||||||
} from "@budibase/types"
|
|
||||||
import sdk from "../sdk"
|
|
||||||
import { automationsEnabled } from "../features"
|
import { automationsEnabled } from "../features"
|
||||||
import { helpers, REBOOT_CRON } from "@budibase/shared-core"
|
import { helpers, REBOOT_CRON } from "@budibase/shared-core"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
|
|
||||||
const WH_STEP_ID = definitions.WEBHOOK.stepId
|
|
||||||
const CRON_STEP_ID = definitions.CRON.stepId
|
const CRON_STEP_ID = definitions.CRON.stepId
|
||||||
let Runner: Thread
|
let Runner: Thread
|
||||||
if (automationsEnabled()) {
|
if (automationsEnabled()) {
|
||||||
|
@ -229,76 +222,6 @@ export async function enableCronTrigger(appId: any, automation: Automation) {
|
||||||
return { enabled, automation }
|
return { enabled, automation }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This function handles checking if any webhooks need to be created or deleted for automations.
|
|
||||||
* @param appId The ID of the app in which we are checking for webhooks
|
|
||||||
* @param oldAuto The old automation object if updating/deleting
|
|
||||||
* @param newAuto The new automation object if creating/updating
|
|
||||||
* @returns After this is complete the new automation object may have been updated and should be
|
|
||||||
* written to DB (this does not write to DB as it would be wasteful to repeat).
|
|
||||||
*/
|
|
||||||
export async function checkForWebhooks({ oldAuto, newAuto }: any) {
|
|
||||||
const appId = context.getAppId()
|
|
||||||
if (!appId) {
|
|
||||||
throw new Error("Unable to check webhooks - no app ID in context.")
|
|
||||||
}
|
|
||||||
const oldTrigger = oldAuto ? oldAuto.definition.trigger : null
|
|
||||||
const newTrigger = newAuto ? newAuto.definition.trigger : null
|
|
||||||
const triggerChanged =
|
|
||||||
oldTrigger && newTrigger && oldTrigger.id !== newTrigger.id
|
|
||||||
function isWebhookTrigger(auto: any) {
|
|
||||||
return (
|
|
||||||
auto &&
|
|
||||||
auto.definition.trigger &&
|
|
||||||
auto.definition.trigger.stepId === WH_STEP_ID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// need to delete webhook
|
|
||||||
if (
|
|
||||||
isWebhookTrigger(oldAuto) &&
|
|
||||||
(!isWebhookTrigger(newAuto) || triggerChanged) &&
|
|
||||||
oldTrigger.webhookId
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const db = context.getAppDB()
|
|
||||||
// need to get the webhook to get the rev
|
|
||||||
const webhook = await db.get<Webhook>(oldTrigger.webhookId)
|
|
||||||
// might be updating - reset the inputs to remove the URLs
|
|
||||||
if (newTrigger) {
|
|
||||||
delete newTrigger.webhookId
|
|
||||||
newTrigger.inputs = {}
|
|
||||||
}
|
|
||||||
await sdk.automations.webhook.destroy(webhook._id!, webhook._rev!)
|
|
||||||
} catch (err) {
|
|
||||||
// don't worry about not being able to delete, if it doesn't exist all good
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// need to create webhook
|
|
||||||
if (
|
|
||||||
(!isWebhookTrigger(oldAuto) || triggerChanged) &&
|
|
||||||
isWebhookTrigger(newAuto)
|
|
||||||
) {
|
|
||||||
const webhook = await sdk.automations.webhook.save(
|
|
||||||
sdk.automations.webhook.newDoc(
|
|
||||||
"Automation webhook",
|
|
||||||
WebhookActionType.AUTOMATION,
|
|
||||||
newAuto._id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const id = webhook._id
|
|
||||||
newTrigger.webhookId = id
|
|
||||||
// the app ID has to be development for this endpoint
|
|
||||||
// it can only be used when building the app
|
|
||||||
// but the trigger endpoint will always be used in production
|
|
||||||
const prodAppId = dbCore.getProdAppID(appId)
|
|
||||||
newTrigger.inputs = {
|
|
||||||
schemaUrl: `api/webhooks/schema/${appId}/${id}`,
|
|
||||||
triggerUrl: `api/webhooks/trigger/${prodAppId}/${id}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newAuto
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When removing an app/unpublishing it need to make sure automations are cleaned up (cron).
|
* When removing an app/unpublishing it need to make sure automations are cleaned up (cron).
|
||||||
* @param appId the app that is being removed.
|
* @param appId the app that is being removed.
|
||||||
|
|
|
@ -349,3 +349,11 @@ export function isRelationshipColumn(
|
||||||
): column is RelationshipFieldMetadata {
|
): column is RelationshipFieldMetadata {
|
||||||
return column.type === FieldType.LINK
|
return column.type === FieldType.LINK
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new row actions ID.
|
||||||
|
* @returns The new row actions ID which the row actions doc can be stored under.
|
||||||
|
*/
|
||||||
|
export function generateRowActionsID(tableId: string) {
|
||||||
|
return `${DocumentType.ROW_ACTIONS}${SEPARATOR}${tableId}`
|
||||||
|
}
|
||||||
|
|
|
@ -272,9 +272,9 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
let readableMessage = getReadableErrorMessage(SourceName.MYSQL, err.errno)
|
let readableMessage = getReadableErrorMessage(SourceName.MYSQL, err.errno)
|
||||||
if (readableMessage) {
|
if (readableMessage) {
|
||||||
throw new Error(readableMessage)
|
throw new Error(readableMessage, { cause: err })
|
||||||
} else {
|
} else {
|
||||||
throw new Error(err.message as string)
|
throw err
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (opts?.connect && this.client) {
|
if (opts?.connect && this.client) {
|
||||||
|
|
|
@ -0,0 +1,248 @@
|
||||||
|
import { Automation, Webhook, WebhookActionType } from "@budibase/types"
|
||||||
|
import { generateAutomationID, getAutomationParams } from "../../../db/utils"
|
||||||
|
import { deleteEntityMetadata } from "../../../utilities"
|
||||||
|
import { MetadataTypes } from "../../../constants"
|
||||||
|
import {
|
||||||
|
context,
|
||||||
|
events,
|
||||||
|
HTTPError,
|
||||||
|
db as dbCore,
|
||||||
|
} from "@budibase/backend-core"
|
||||||
|
import { definitions } from "../../../automations/triggerInfo"
|
||||||
|
import automations from "."
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
return context.getAppDB()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanAutomationInputs(automation: Automation) {
|
||||||
|
if (automation == null) {
|
||||||
|
return automation
|
||||||
|
}
|
||||||
|
let steps = automation.definition.steps
|
||||||
|
let trigger = automation.definition.trigger
|
||||||
|
let allSteps = [...steps, trigger]
|
||||||
|
// live is not a property used anymore
|
||||||
|
if (automation.live != null) {
|
||||||
|
delete automation.live
|
||||||
|
}
|
||||||
|
for (let step of allSteps) {
|
||||||
|
if (step == null) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (let inputName of Object.keys(step.inputs)) {
|
||||||
|
if (!step.inputs[inputName] || step.inputs[inputName] === "") {
|
||||||
|
delete step.inputs[inputName]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return automation
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStepEvents(
|
||||||
|
oldAutomation: Automation,
|
||||||
|
automation: Automation
|
||||||
|
) {
|
||||||
|
const getNewSteps = (oldAutomation: Automation, automation: Automation) => {
|
||||||
|
const oldStepIds = oldAutomation.definition.steps.map(s => s.id)
|
||||||
|
return automation.definition.steps.filter(s => !oldStepIds.includes(s.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDeletedSteps = (
|
||||||
|
oldAutomation: Automation,
|
||||||
|
automation: Automation
|
||||||
|
) => {
|
||||||
|
const stepIds = automation.definition.steps.map(s => s.id)
|
||||||
|
return oldAutomation.definition.steps.filter(s => !stepIds.includes(s.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// new steps
|
||||||
|
const newSteps = getNewSteps(oldAutomation, automation)
|
||||||
|
for (let step of newSteps) {
|
||||||
|
await events.automation.stepCreated(automation, step)
|
||||||
|
}
|
||||||
|
|
||||||
|
// old steps
|
||||||
|
const deletedSteps = getDeletedSteps(oldAutomation, automation)
|
||||||
|
for (let step of deletedSteps) {
|
||||||
|
await events.automation.stepDeleted(automation, step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetch() {
|
||||||
|
const db = getDb()
|
||||||
|
const response = await db.allDocs<Automation>(
|
||||||
|
getAutomationParams(null, {
|
||||||
|
include_docs: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return response.rows.map(row => row.doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(automationId: string) {
|
||||||
|
const db = getDb()
|
||||||
|
const result = await db.get<Automation>(automationId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(automation: Automation) {
|
||||||
|
automation = { ...automation }
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// Respect existing IDs if recreating a deleted automation
|
||||||
|
if (!automation._id) {
|
||||||
|
automation._id = generateAutomationID()
|
||||||
|
}
|
||||||
|
|
||||||
|
automation.type = "automation"
|
||||||
|
automation = cleanAutomationInputs(automation)
|
||||||
|
automation = await checkForWebhooks({
|
||||||
|
newAuto: automation,
|
||||||
|
})
|
||||||
|
const response = await db.put(automation)
|
||||||
|
await events.automation.created(automation)
|
||||||
|
for (let step of automation.definition.steps) {
|
||||||
|
await events.automation.stepCreated(automation, step)
|
||||||
|
}
|
||||||
|
automation._rev = response.rev
|
||||||
|
automation._id = response.id
|
||||||
|
|
||||||
|
return automation
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(automation: Automation) {
|
||||||
|
automation = { ...automation }
|
||||||
|
|
||||||
|
if (!automation._id || !automation._rev) {
|
||||||
|
throw new HTTPError("_id or _rev fields missing", 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
const oldAutomation = await db.get<Automation>(automation._id)
|
||||||
|
automation = cleanAutomationInputs(automation)
|
||||||
|
automation = await checkForWebhooks({
|
||||||
|
oldAuto: oldAutomation,
|
||||||
|
newAuto: automation,
|
||||||
|
})
|
||||||
|
const response = await db.put(automation)
|
||||||
|
automation._rev = response.rev
|
||||||
|
|
||||||
|
const oldAutoTrigger =
|
||||||
|
oldAutomation && oldAutomation.definition.trigger
|
||||||
|
? oldAutomation.definition.trigger
|
||||||
|
: undefined
|
||||||
|
const newAutoTrigger =
|
||||||
|
automation && automation.definition.trigger
|
||||||
|
? automation.definition.trigger
|
||||||
|
: undefined
|
||||||
|
// trigger has been updated, remove the test inputs
|
||||||
|
if (oldAutoTrigger && oldAutoTrigger.id !== newAutoTrigger?.id) {
|
||||||
|
await events.automation.triggerUpdated(automation)
|
||||||
|
await deleteEntityMetadata(
|
||||||
|
MetadataTypes.AUTOMATION_TEST_INPUT,
|
||||||
|
automation._id!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleStepEvents(oldAutomation, automation)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...automation,
|
||||||
|
_rev: response.rev,
|
||||||
|
_id: response.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(automationId: string, rev: string) {
|
||||||
|
const db = getDb()
|
||||||
|
const existing = await db.get<Automation>(automationId)
|
||||||
|
await checkForWebhooks({
|
||||||
|
oldAuto: existing,
|
||||||
|
})
|
||||||
|
|
||||||
|
// delete metadata first
|
||||||
|
await deleteEntityMetadata(MetadataTypes.AUTOMATION_TEST_INPUT, automationId)
|
||||||
|
await deleteEntityMetadata(
|
||||||
|
MetadataTypes.AUTOMATION_TEST_HISTORY,
|
||||||
|
automationId
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await db.remove(automationId, rev)
|
||||||
|
|
||||||
|
await events.automation.deleted(existing)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function handles checking if any webhooks need to be created or deleted for automations.
|
||||||
|
* @param appId The ID of the app in which we are checking for webhooks
|
||||||
|
* @param oldAuto The old automation object if updating/deleting
|
||||||
|
* @param newAuto The new automation object if creating/updating
|
||||||
|
* @returns After this is complete the new automation object may have been updated and should be
|
||||||
|
* written to DB (this does not write to DB as it would be wasteful to repeat).
|
||||||
|
*/
|
||||||
|
async function checkForWebhooks({ oldAuto, newAuto }: any) {
|
||||||
|
const WH_STEP_ID = definitions.WEBHOOK.stepId
|
||||||
|
|
||||||
|
const appId = context.getAppId()
|
||||||
|
if (!appId) {
|
||||||
|
throw new Error("Unable to check webhooks - no app ID in context.")
|
||||||
|
}
|
||||||
|
const oldTrigger = oldAuto ? oldAuto.definition.trigger : null
|
||||||
|
const newTrigger = newAuto ? newAuto.definition.trigger : null
|
||||||
|
const triggerChanged =
|
||||||
|
oldTrigger && newTrigger && oldTrigger.id !== newTrigger.id
|
||||||
|
function isWebhookTrigger(auto: any) {
|
||||||
|
return (
|
||||||
|
auto &&
|
||||||
|
auto.definition.trigger &&
|
||||||
|
auto.definition.trigger.stepId === WH_STEP_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// need to delete webhook
|
||||||
|
if (
|
||||||
|
isWebhookTrigger(oldAuto) &&
|
||||||
|
(!isWebhookTrigger(newAuto) || triggerChanged) &&
|
||||||
|
oldTrigger.webhookId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
// need to get the webhook to get the rev
|
||||||
|
const webhook = await db.get<Webhook>(oldTrigger.webhookId)
|
||||||
|
// might be updating - reset the inputs to remove the URLs
|
||||||
|
if (newTrigger) {
|
||||||
|
delete newTrigger.webhookId
|
||||||
|
newTrigger.inputs = {}
|
||||||
|
}
|
||||||
|
await automations.webhook.destroy(webhook._id!, webhook._rev!)
|
||||||
|
} catch (err) {
|
||||||
|
// don't worry about not being able to delete, if it doesn't exist all good
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// need to create webhook
|
||||||
|
if (
|
||||||
|
(!isWebhookTrigger(oldAuto) || triggerChanged) &&
|
||||||
|
isWebhookTrigger(newAuto)
|
||||||
|
) {
|
||||||
|
const webhook = await automations.webhook.save(
|
||||||
|
automations.webhook.newDoc(
|
||||||
|
"Automation webhook",
|
||||||
|
WebhookActionType.AUTOMATION,
|
||||||
|
newAuto._id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const id = webhook._id
|
||||||
|
newTrigger.webhookId = id
|
||||||
|
// the app ID has to be development for this endpoint
|
||||||
|
// it can only be used when building the app
|
||||||
|
// but the trigger endpoint will always be used in production
|
||||||
|
const prodAppId = dbCore.getProdAppID(appId)
|
||||||
|
newTrigger.inputs = {
|
||||||
|
schemaUrl: `api/webhooks/schema/${appId}/${id}`,
|
||||||
|
triggerUrl: `api/webhooks/trigger/${prodAppId}/${id}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newAuto
|
||||||
|
}
|
|
@ -1,7 +1,9 @@
|
||||||
|
import * as crud from "./crud"
|
||||||
import * as webhook from "./webhook"
|
import * as webhook from "./webhook"
|
||||||
import * as utils from "./utils"
|
import * as utils from "./utils"
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
...crud,
|
||||||
webhook,
|
webhook,
|
||||||
utils,
|
utils,
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { context, HTTPError, utils } from "@budibase/backend-core"
|
||||||
|
|
||||||
|
import { generateRowActionsID } from "../../db/utils"
|
||||||
|
import {
|
||||||
|
SEPARATOR,
|
||||||
|
TableRowActions,
|
||||||
|
VirtualDocumentType,
|
||||||
|
} from "@budibase/types"
|
||||||
|
|
||||||
|
function ensureUniqueAndThrow(
|
||||||
|
doc: TableRowActions,
|
||||||
|
name: string,
|
||||||
|
existingRowActionId?: string
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
Object.entries(doc.actions).find(
|
||||||
|
([id, a]) =>
|
||||||
|
a.name.toLowerCase() === name.toLowerCase() &&
|
||||||
|
id !== existingRowActionId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HTTPError("A row action with the same name already exists.", 409)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function create(tableId: string, rowAction: { name: string }) {
|
||||||
|
const action = { name: rowAction.name.trim() }
|
||||||
|
|
||||||
|
const db = context.getAppDB()
|
||||||
|
const rowActionsId = generateRowActionsID(tableId)
|
||||||
|
let doc: TableRowActions
|
||||||
|
try {
|
||||||
|
doc = await db.get<TableRowActions>(rowActionsId)
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status !== 404) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
doc = { _id: rowActionsId, actions: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureUniqueAndThrow(doc, action.name)
|
||||||
|
|
||||||
|
const newId = `${VirtualDocumentType.ROW_ACTION}${SEPARATOR}${utils.newid()}`
|
||||||
|
doc.actions[newId] = action
|
||||||
|
await db.put(doc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: newId,
|
||||||
|
...action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function get(tableId: string) {
|
||||||
|
const db = context.getAppDB()
|
||||||
|
const rowActionsId = generateRowActionsID(tableId)
|
||||||
|
return await db.get<TableRowActions>(rowActionsId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function docExists(tableId: string) {
|
||||||
|
const db = context.getAppDB()
|
||||||
|
const rowActionsId = generateRowActionsID(tableId)
|
||||||
|
const result = await db.exists(rowActionsId)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function update(
|
||||||
|
tableId: string,
|
||||||
|
rowActionId: string,
|
||||||
|
rowAction: { name: string }
|
||||||
|
) {
|
||||||
|
const action = { name: rowAction.name.trim() }
|
||||||
|
const actionsDoc = await get(tableId)
|
||||||
|
|
||||||
|
if (!actionsDoc.actions[rowActionId]) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Row action '${rowActionId}' not found in '${tableId}'`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureUniqueAndThrow(actionsDoc, action.name, rowActionId)
|
||||||
|
|
||||||
|
actionsDoc.actions[rowActionId] = action
|
||||||
|
|
||||||
|
const db = context.getAppDB()
|
||||||
|
await db.put(actionsDoc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: rowActionId,
|
||||||
|
...action,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function remove(tableId: string, rowActionId: string) {
|
||||||
|
const actionsDoc = await get(tableId)
|
||||||
|
|
||||||
|
if (!actionsDoc.actions[rowActionId]) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Row action '${rowActionId}' not found in '${tableId}'`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete actionsDoc.actions[rowActionId]
|
||||||
|
|
||||||
|
const db = context.getAppDB()
|
||||||
|
await db.put(actionsDoc)
|
||||||
|
}
|
|
@ -210,7 +210,6 @@ async function runSqlQuery(
|
||||||
let bindings = query.bindings
|
let bindings = query.bindings
|
||||||
|
|
||||||
// quick hack for docIds
|
// quick hack for docIds
|
||||||
|
|
||||||
const fixJunctionDocs = (field: string) =>
|
const fixJunctionDocs = (field: string) =>
|
||||||
["doc1", "doc2"].forEach(doc => {
|
["doc1", "doc2"].forEach(doc => {
|
||||||
sql = sql.replaceAll(`\`${doc}\`.\`${field}\``, `\`${doc}.${field}\``)
|
sql = sql.replaceAll(`\`${doc}\`.\`${field}\``, `\`${doc}.${field}\``)
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { default as users } from "./users"
|
||||||
import { default as plugins } from "./plugins"
|
import { default as plugins } from "./plugins"
|
||||||
import * as views from "./app/views"
|
import * as views from "./app/views"
|
||||||
import * as permissions from "./app/permissions"
|
import * as permissions from "./app/permissions"
|
||||||
|
import * as rowActions from "./app/rowActions"
|
||||||
|
|
||||||
const sdk = {
|
const sdk = {
|
||||||
backups,
|
backups,
|
||||||
|
@ -24,6 +25,7 @@ const sdk = {
|
||||||
views,
|
views,
|
||||||
permissions,
|
permissions,
|
||||||
links,
|
links,
|
||||||
|
rowActions,
|
||||||
}
|
}
|
||||||
|
|
||||||
// default export for TS
|
// default export for TS
|
||||||
|
|
|
@ -13,6 +13,7 @@ import { UserAPI } from "./user"
|
||||||
import { QueryAPI } from "./query"
|
import { QueryAPI } from "./query"
|
||||||
import { RoleAPI } from "./role"
|
import { RoleAPI } from "./role"
|
||||||
import { TemplateAPI } from "./template"
|
import { TemplateAPI } from "./template"
|
||||||
|
import { RowActionAPI } from "./rowAction"
|
||||||
|
|
||||||
export default class API {
|
export default class API {
|
||||||
table: TableAPI
|
table: TableAPI
|
||||||
|
@ -29,6 +30,7 @@ export default class API {
|
||||||
query: QueryAPI
|
query: QueryAPI
|
||||||
roles: RoleAPI
|
roles: RoleAPI
|
||||||
templates: TemplateAPI
|
templates: TemplateAPI
|
||||||
|
rowAction: RowActionAPI
|
||||||
|
|
||||||
constructor(config: TestConfiguration) {
|
constructor(config: TestConfiguration) {
|
||||||
this.table = new TableAPI(config)
|
this.table = new TableAPI(config)
|
||||||
|
@ -45,5 +47,6 @@ export default class API {
|
||||||
this.query = new QueryAPI(config)
|
this.query = new QueryAPI(config)
|
||||||
this.roles = new RoleAPI(config)
|
this.roles = new RoleAPI(config)
|
||||||
this.templates = new TemplateAPI(config)
|
this.templates = new TemplateAPI(config)
|
||||||
|
this.rowAction = new RowActionAPI(config)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import {
|
||||||
|
CreateRowActionRequest,
|
||||||
|
RowActionResponse,
|
||||||
|
RowActionsResponse,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { Expectations, TestAPI } from "./base"
|
||||||
|
|
||||||
|
export class RowActionAPI extends TestAPI {
|
||||||
|
save = async (
|
||||||
|
tableId: string,
|
||||||
|
rowAction: CreateRowActionRequest,
|
||||||
|
expectations?: Expectations,
|
||||||
|
config?: { publicUser?: boolean }
|
||||||
|
) => {
|
||||||
|
return await this._post<RowActionResponse>(
|
||||||
|
`/api/tables/${tableId}/actions`,
|
||||||
|
{
|
||||||
|
body: rowAction,
|
||||||
|
expectations: {
|
||||||
|
...expectations,
|
||||||
|
status: expectations?.status || 201,
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
find = async (
|
||||||
|
tableId: string,
|
||||||
|
expectations?: Expectations,
|
||||||
|
config?: { publicUser?: boolean }
|
||||||
|
) => {
|
||||||
|
return await this._get<RowActionsResponse>(
|
||||||
|
`/api/tables/${tableId}/actions`,
|
||||||
|
{
|
||||||
|
expectations,
|
||||||
|
...config,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
update = async (
|
||||||
|
tableId: string,
|
||||||
|
rowActionId: string,
|
||||||
|
rowAction: CreateRowActionRequest,
|
||||||
|
expectations?: Expectations,
|
||||||
|
config?: { publicUser?: boolean }
|
||||||
|
) => {
|
||||||
|
return await this._put<RowActionResponse>(
|
||||||
|
`/api/tables/${tableId}/actions/${rowActionId}`,
|
||||||
|
{
|
||||||
|
body: rowAction,
|
||||||
|
expectations,
|
||||||
|
...config,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete = async (
|
||||||
|
tableId: string,
|
||||||
|
rowActionId: string,
|
||||||
|
expectations?: Expectations,
|
||||||
|
config?: { publicUser?: boolean }
|
||||||
|
) => {
|
||||||
|
return await this._delete<RowActionResponse>(
|
||||||
|
`/api/tables/${tableId}/actions/${rowActionId}`,
|
||||||
|
{
|
||||||
|
expectations,
|
||||||
|
...config,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -159,7 +159,7 @@ export function automationTrigger(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newAutomation({ steps, trigger }: any = {}) {
|
export function newAutomation({ steps, trigger }: any = {}) {
|
||||||
const automation: any = basicAutomation()
|
const automation = basicAutomation()
|
||||||
|
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
automation.definition.trigger = trigger
|
automation.definition.trigger = trigger
|
||||||
|
@ -357,18 +357,23 @@ export function collectAutomation(tableId?: string): Automation {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return automation as Automation
|
return automation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function filterAutomation(tableId?: string): Automation {
|
export function filterAutomation(appId: string, tableId?: string): Automation {
|
||||||
const automation: any = {
|
const automation: Automation = {
|
||||||
name: "looping",
|
name: "looping",
|
||||||
type: "automation",
|
type: "automation",
|
||||||
|
appId,
|
||||||
definition: {
|
definition: {
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
name: "Filter Step",
|
||||||
|
tagline: "An automation filter step",
|
||||||
|
description: "A filter automation",
|
||||||
id: "b",
|
id: "b",
|
||||||
type: "ACTION",
|
icon: "Icon",
|
||||||
|
type: AutomationStepType.ACTION,
|
||||||
internal: true,
|
internal: true,
|
||||||
stepId: AutomationActionStepId.FILTER,
|
stepId: AutomationActionStepId.FILTER,
|
||||||
inputs: {},
|
inputs: {},
|
||||||
|
@ -376,8 +381,12 @@ export function filterAutomation(tableId?: string): Automation {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
trigger: {
|
trigger: {
|
||||||
|
name: "trigger Step",
|
||||||
|
tagline: "An automation trigger",
|
||||||
|
description: "A trigger",
|
||||||
|
icon: "Icon",
|
||||||
id: "a",
|
id: "a",
|
||||||
type: "TRIGGER",
|
type: AutomationStepType.TRIGGER,
|
||||||
event: "row:save",
|
event: "row:save",
|
||||||
stepId: AutomationTriggerStepId.ROW_SAVED,
|
stepId: AutomationTriggerStepId.ROW_SAVED,
|
||||||
inputs: {
|
inputs: {
|
||||||
|
@ -387,7 +396,45 @@ export function filterAutomation(tableId?: string): Automation {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return automation as Automation
|
return automation
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRowAutomationWithFilters(appId: string): Automation {
|
||||||
|
const automation: Automation = {
|
||||||
|
name: "updateRowWithFilters",
|
||||||
|
type: "automation",
|
||||||
|
appId,
|
||||||
|
definition: {
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
name: "Filter Step",
|
||||||
|
tagline: "An automation filter step",
|
||||||
|
description: "A filter automation",
|
||||||
|
icon: "Icon",
|
||||||
|
id: "b",
|
||||||
|
type: AutomationStepType.ACTION,
|
||||||
|
internal: true,
|
||||||
|
stepId: AutomationActionStepId.SERVER_LOG,
|
||||||
|
inputs: {},
|
||||||
|
schema: BUILTIN_ACTION_DEFINITIONS.SERVER_LOG.schema,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trigger: {
|
||||||
|
name: "trigger Step",
|
||||||
|
tagline: "An automation trigger",
|
||||||
|
description: "A trigger",
|
||||||
|
icon: "Icon",
|
||||||
|
|
||||||
|
id: "a",
|
||||||
|
type: AutomationStepType.TRIGGER,
|
||||||
|
event: "row:update",
|
||||||
|
stepId: AutomationTriggerStepId.ROW_UPDATED,
|
||||||
|
inputs: {},
|
||||||
|
schema: TRIGGER_DEFINITIONS.ROW_UPDATED.schema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return automation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function basicAutomationResults(
|
export function basicAutomationResults(
|
||||||
|
|
|
@ -1,14 +1,22 @@
|
||||||
import * as linkRows from "../../db/linkedRows"
|
import * as linkRows from "../../db/linkedRows"
|
||||||
import { fixAutoColumnSubType, processFormulas } from "./utils"
|
import { fixAutoColumnSubType, processFormulas } from "./utils"
|
||||||
import { objectStore, utils } from "@budibase/backend-core"
|
import {
|
||||||
|
cache,
|
||||||
|
context,
|
||||||
|
HTTPError,
|
||||||
|
objectStore,
|
||||||
|
utils,
|
||||||
|
} from "@budibase/backend-core"
|
||||||
import { InternalTables } from "../../db/utils"
|
import { InternalTables } from "../../db/utils"
|
||||||
import { TYPE_TRANSFORM_MAP } from "./map"
|
import { TYPE_TRANSFORM_MAP } from "./map"
|
||||||
import {
|
import {
|
||||||
AutoFieldSubType,
|
AutoFieldSubType,
|
||||||
FieldType,
|
FieldType,
|
||||||
|
IdentityType,
|
||||||
Row,
|
Row,
|
||||||
RowAttachment,
|
RowAttachment,
|
||||||
Table,
|
Table,
|
||||||
|
User,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import {
|
import {
|
||||||
|
@ -19,6 +27,7 @@ import {
|
||||||
} from "./bbReferenceProcessor"
|
} from "./bbReferenceProcessor"
|
||||||
import { isExternalTableID } from "../../integrations/utils"
|
import { isExternalTableID } from "../../integrations/utils"
|
||||||
import { helpers } from "@budibase/shared-core"
|
import { helpers } from "@budibase/shared-core"
|
||||||
|
import { processString } from "@budibase/string-templates"
|
||||||
|
|
||||||
export * from "./utils"
|
export * from "./utils"
|
||||||
export * from "./attachments"
|
export * from "./attachments"
|
||||||
|
@ -88,7 +97,34 @@ export async function processAutoColumn(
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { table, row }
|
}
|
||||||
|
|
||||||
|
async function processDefaultValues(table: Table, row: Row) {
|
||||||
|
const ctx: { ["Current User"]?: User; user?: User } = {}
|
||||||
|
|
||||||
|
const identity = context.getIdentity()
|
||||||
|
if (identity?._id && identity.type === IdentityType.USER) {
|
||||||
|
const user = await cache.user.getUser(identity._id)
|
||||||
|
delete user.password
|
||||||
|
|
||||||
|
ctx["Current User"] = user
|
||||||
|
ctx.user = user
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let [key, schema] of Object.entries(table.schema)) {
|
||||||
|
if ("default" in schema && schema.default != null && row[key] == null) {
|
||||||
|
const processed = await processString(schema.default, ctx)
|
||||||
|
|
||||||
|
try {
|
||||||
|
row[key] = coerce(processed, schema.type)
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new HTTPError(
|
||||||
|
`Invalid default value for field '${key}' - ${err.message}`,
|
||||||
|
400
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -182,8 +218,10 @@ export async function inputProcessing(
|
||||||
clonedRow._rev = row._rev
|
clonedRow._rev = row._rev
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle auto columns - this returns an object like {table, row}
|
await processAutoColumn(userId, table, clonedRow, opts)
|
||||||
return processAutoColumn(userId, table, clonedRow, opts)
|
await processDefaultValues(table, clonedRow)
|
||||||
|
|
||||||
|
return { table, row: clonedRow }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { sql } from "@budibase/backend-core"
|
||||||
import { FieldType } from "@budibase/types"
|
import { FieldType } from "@budibase/types"
|
||||||
|
|
||||||
const parseArrayString = (value: any) => {
|
const parseArrayString = (value: any) => {
|
||||||
|
@ -91,7 +92,13 @@ export const TYPE_TRANSFORM_MAP: any = {
|
||||||
[null]: null,
|
[null]: null,
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
[undefined]: undefined,
|
[undefined]: undefined,
|
||||||
parse: (n: any) => parseFloat(n),
|
parse: (n: any) => {
|
||||||
|
const parsed = parseFloat(n)
|
||||||
|
if (isNaN(parsed)) {
|
||||||
|
throw new Error(`Invalid number value "${n}"`)
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
},
|
||||||
},
|
},
|
||||||
[FieldType.BIGINT]: {
|
[FieldType.BIGINT]: {
|
||||||
"": null,
|
"": null,
|
||||||
|
@ -109,8 +116,15 @@ export const TYPE_TRANSFORM_MAP: any = {
|
||||||
parse: (date: any) => {
|
parse: (date: any) => {
|
||||||
if (date instanceof Date) {
|
if (date instanceof Date) {
|
||||||
return date.toISOString()
|
return date.toISOString()
|
||||||
|
} else if (typeof date === "string" && sql.utils.isValidTime(date)) {
|
||||||
|
return date
|
||||||
|
} else {
|
||||||
|
const parsed = new Date(date)
|
||||||
|
if (isNaN(parsed.getTime())) {
|
||||||
|
throw new Error(`Invalid date value: "${date}"`)
|
||||||
|
}
|
||||||
|
return date
|
||||||
}
|
}
|
||||||
return date
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[FieldType.ATTACHMENTS]: {
|
[FieldType.ATTACHMENTS]: {
|
||||||
|
|
|
@ -7,3 +7,4 @@ export * from "./table"
|
||||||
export * from "./permission"
|
export * from "./permission"
|
||||||
export * from "./attachment"
|
export * from "./attachment"
|
||||||
export * from "./user"
|
export * from "./user"
|
||||||
|
export * from "./rowAction"
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
interface RowActionData {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
export interface CreateRowActionRequest extends RowActionData {}
|
||||||
|
export interface UpdateRowActionRequest extends RowActionData {}
|
||||||
|
|
||||||
|
export interface RowActionResponse extends RowActionData {
|
||||||
|
id: string
|
||||||
|
tableId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowActionsResponse {
|
||||||
|
actions: Record<string, RowActionResponse>
|
||||||
|
}
|
|
@ -35,6 +35,7 @@ export enum AutomationCustomIOType {
|
||||||
AUTOMATION = "automation",
|
AUTOMATION = "automation",
|
||||||
AUTOMATION_FIELDS = "automationFields",
|
AUTOMATION_FIELDS = "automationFields",
|
||||||
MULTI_ATTACHMENTS = "multi_attachments",
|
MULTI_ATTACHMENTS = "multi_attachments",
|
||||||
|
TRIGGER_FILTER = "trigger_filter",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AutomationTriggerStepId {
|
export enum AutomationTriggerStepId {
|
||||||
|
@ -128,6 +129,15 @@ export interface Automation extends Document {
|
||||||
internal?: boolean
|
internal?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
testData?: {
|
||||||
|
row?: Row
|
||||||
|
meta: {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
id: string
|
||||||
|
revision: string
|
||||||
|
oldRow?: Row
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseIOStructure {
|
interface BaseIOStructure {
|
||||||
|
@ -201,6 +211,10 @@ export enum AutomationStatus {
|
||||||
STOPPED_ERROR = "stopped_error",
|
STOPPED_ERROR = "stopped_error",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AutomationStoppedReason {
|
||||||
|
TRIGGER_FILTER_NOT_MET = "Automation did not run. Filter conditions in trigger were not met.",
|
||||||
|
}
|
||||||
|
|
||||||
export interface AutomationResults {
|
export interface AutomationResults {
|
||||||
automationId?: string
|
automationId?: string
|
||||||
status?: AutomationStatus
|
status?: AutomationStatus
|
||||||
|
|
|
@ -16,3 +16,4 @@ export * from "./links"
|
||||||
export * from "./component"
|
export * from "./component"
|
||||||
export * from "./sqlite"
|
export * from "./sqlite"
|
||||||
export * from "./snippet"
|
export * from "./snippet"
|
||||||
|
export * from "./rowAction"
|
||||||
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { Document } from "../document"
|
||||||
|
|
||||||
|
export interface TableRowActions extends Document {
|
||||||
|
_id: string
|
||||||
|
actions: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
|
@ -81,11 +81,13 @@ export interface NumberFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
toTable: string
|
toTable: string
|
||||||
toKey: string
|
toKey: string
|
||||||
}
|
}
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JsonFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
export interface JsonFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
type: FieldType.JSON
|
type: FieldType.JSON
|
||||||
subtype?: JsonFieldSubType.ARRAY
|
subtype?: JsonFieldSubType.ARRAY
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
|
@ -94,17 +96,25 @@ export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
timeOnly?: boolean
|
timeOnly?: boolean
|
||||||
dateOnly?: boolean
|
dateOnly?: boolean
|
||||||
subtype?: AutoFieldSubType.CREATED_AT | AutoFieldSubType.UPDATED_AT
|
subtype?: AutoFieldSubType.CREATED_AT | AutoFieldSubType.UPDATED_AT
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LongFormFieldMetadata extends BaseFieldSchema {
|
export interface LongFormFieldMetadata extends BaseFieldSchema {
|
||||||
type: FieldType.LONGFORM
|
type: FieldType.LONGFORM
|
||||||
useRichText?: boolean | null
|
useRichText?: boolean | null
|
||||||
|
default?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StringFieldMetadata extends BaseFieldSchema {
|
||||||
|
type: FieldType.STRING
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FormulaFieldMetadata extends BaseFieldSchema {
|
export interface FormulaFieldMetadata extends BaseFieldSchema {
|
||||||
type: FieldType.FORMULA
|
type: FieldType.FORMULA
|
||||||
formula: string
|
formula: string
|
||||||
formulaType?: FormulaType
|
formulaType?: FormulaType
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BBReferenceFieldMetadata
|
export interface BBReferenceFieldMetadata
|
||||||
|
@ -171,6 +181,7 @@ interface OtherFieldMetadata extends BaseFieldSchema {
|
||||||
| FieldType.BB_REFERENCE
|
| FieldType.BB_REFERENCE
|
||||||
| FieldType.BB_REFERENCE_SINGLE
|
| FieldType.BB_REFERENCE_SINGLE
|
||||||
| FieldType.ATTACHMENTS
|
| FieldType.ATTACHMENTS
|
||||||
|
| FieldType.STRING
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -182,6 +193,7 @@ export type FieldSchema =
|
||||||
| FormulaFieldMetadata
|
| FormulaFieldMetadata
|
||||||
| NumberFieldMetadata
|
| NumberFieldMetadata
|
||||||
| LongFormFieldMetadata
|
| LongFormFieldMetadata
|
||||||
|
| StringFieldMetadata
|
||||||
| BBReferenceFieldMetadata
|
| BBReferenceFieldMetadata
|
||||||
| JsonFieldMetadata
|
| JsonFieldMetadata
|
||||||
| AttachmentFieldMetadata
|
| AttachmentFieldMetadata
|
||||||
|
|
|
@ -39,6 +39,7 @@ export enum DocumentType {
|
||||||
AUDIT_LOG = "al",
|
AUDIT_LOG = "al",
|
||||||
APP_MIGRATION_METADATA = "_design/migrations",
|
APP_MIGRATION_METADATA = "_design/migrations",
|
||||||
SCIM_LOG = "scimlog",
|
SCIM_LOG = "scimlog",
|
||||||
|
ROW_ACTIONS = "ra",
|
||||||
}
|
}
|
||||||
|
|
||||||
// these are the core documents that make up the data, design
|
// these are the core documents that make up the data, design
|
||||||
|
@ -68,6 +69,7 @@ export enum InternalTable {
|
||||||
// documents or enriched into existence as part of get requests
|
// documents or enriched into existence as part of get requests
|
||||||
export enum VirtualDocumentType {
|
export enum VirtualDocumentType {
|
||||||
VIEW = "view",
|
VIEW = "view",
|
||||||
|
ROW_ACTION = "row_action",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Automation, AutomationMetadata } from "../../documents"
|
import { Automation, AutomationMetadata, Row } from "../../documents"
|
||||||
import { Job } from "bull"
|
import { Job } from "bull"
|
||||||
|
|
||||||
export interface AutomationDataEvent {
|
export interface AutomationDataEvent {
|
||||||
|
@ -6,6 +6,8 @@ export interface AutomationDataEvent {
|
||||||
metadata?: AutomationMetadata
|
metadata?: AutomationMetadata
|
||||||
automation?: Automation
|
automation?: Automation
|
||||||
timeout?: number
|
timeout?: number
|
||||||
|
row?: Row
|
||||||
|
oldRow?: Row
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutomationData {
|
export interface AutomationData {
|
||||||
|
|
Loading…
Reference in New Issue