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",
|
||||
"version": "2.29.20",
|
||||
"version": "2.29.22",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -4,8 +4,9 @@ import { Ctx } from "@budibase/types"
|
|||
function validate(
|
||||
schema: Joi.ObjectSchema | Joi.ArraySchema,
|
||||
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 (ctx: Ctx, next: any) => {
|
||||
if (!schema) {
|
||||
|
@ -28,10 +29,12 @@ function validate(
|
|||
})
|
||||
}
|
||||
|
||||
const { error } = schema.validate(params)
|
||||
const { error } = schema.validate(params, {
|
||||
allowUnknown: opts?.allowUnknown,
|
||||
})
|
||||
if (error) {
|
||||
let message = error.message
|
||||
if (opts.errorPrefix) {
|
||||
if (errorPrefix) {
|
||||
message = `Invalid ${property} - ${message}`
|
||||
}
|
||||
ctx.throw(400, message)
|
||||
|
@ -42,7 +45,7 @@ function validate(
|
|||
|
||||
export function body(
|
||||
schema: Joi.ObjectSchema | Joi.ArraySchema,
|
||||
opts?: { errorPrefix: string }
|
||||
opts?: { errorPrefix?: string; allowUnknown?: boolean }
|
||||
) {
|
||||
return validate(schema, "body", opts)
|
||||
}
|
||||
|
|
|
@ -42,27 +42,28 @@ const envLimit = environment.SQL_MAX_ROWS
|
|||
: null
|
||||
const BASE_LIMIT = envLimit || 5000
|
||||
|
||||
function likeKey(client: string | string[], key: string): string {
|
||||
let start: string, end: string
|
||||
// Takes a string like foo and returns a quoted string like [foo] for SQL Server
|
||||
// and "foo" for Postgres.
|
||||
function quote(client: SqlClient, str: string): string {
|
||||
switch (client) {
|
||||
case SqlClient.MY_SQL:
|
||||
start = end = "`"
|
||||
break
|
||||
case SqlClient.SQL_LITE:
|
||||
case SqlClient.ORACLE:
|
||||
case SqlClient.POSTGRES:
|
||||
start = end = '"'
|
||||
break
|
||||
return `"${str}"`
|
||||
case SqlClient.MS_SQL:
|
||||
start = "["
|
||||
end = "]"
|
||||
break
|
||||
default:
|
||||
throw new Error("Unknown client generating like key")
|
||||
return `[${str}]`
|
||||
case SqlClient.MY_SQL:
|
||||
return `\`${str}\``
|
||||
}
|
||||
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
|
||||
.split(".")
|
||||
.map(part => quote(client, part))
|
||||
.join(".")
|
||||
}
|
||||
|
||||
function parse(input: any) {
|
||||
|
@ -113,34 +114,81 @@ function generateSelectStatement(
|
|||
knex: Knex
|
||||
): (string | Knex.Raw)[] | "*" {
|
||||
const { resource, meta } = json
|
||||
const client = knex.client.config.client as SqlClient
|
||||
|
||||
if (!resource || !resource.fields || resource.fields.length === 0) {
|
||||
return "*"
|
||||
}
|
||||
|
||||
const schema = meta?.table?.schema
|
||||
const schema = meta.table.schema
|
||||
return resource.fields.map(field => {
|
||||
const fieldNames = field.split(/\./g)
|
||||
const tableName = fieldNames[0]
|
||||
const columnName = fieldNames[1]
|
||||
const columnSchema = schema?.[columnName]
|
||||
if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) {
|
||||
const externalType = schema[columnName].externalType
|
||||
if (externalType?.includes("money")) {
|
||||
return knex.raw(
|
||||
`"${tableName}"."${columnName}"::money::numeric as "${field}"`
|
||||
)
|
||||
}
|
||||
const parts = field.split(/\./g)
|
||||
let table: string | undefined = undefined
|
||||
let column: string | undefined = undefined
|
||||
|
||||
// Just a column name, e.g.: "column"
|
||||
if (parts.length === 1) {
|
||||
column = parts[0]
|
||||
}
|
||||
|
||||
// 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 (
|
||||
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.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 `${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 {
|
||||
private readonly client: string
|
||||
private readonly client: SqlClient
|
||||
|
||||
constructor(client: string) {
|
||||
constructor(client: SqlClient) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
|
@ -250,9 +298,10 @@ class InternalBuilder {
|
|||
} else {
|
||||
const rawFnc = `${fnc}Raw`
|
||||
// @ts-ignore
|
||||
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
|
||||
`%${value.toLowerCase()}%`,
|
||||
])
|
||||
query = query[rawFnc](
|
||||
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
|
||||
[`%${value.toLowerCase()}%`]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -302,7 +351,10 @@ class InternalBuilder {
|
|||
}
|
||||
statement +=
|
||||
(statement ? andOr : "") +
|
||||
`COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?`
|
||||
`COALESCE(LOWER(${quotedIdentifier(
|
||||
this.client,
|
||||
key
|
||||
)}), '') LIKE ?`
|
||||
}
|
||||
|
||||
if (statement === "") {
|
||||
|
@ -336,9 +388,10 @@ class InternalBuilder {
|
|||
} else {
|
||||
const rawFnc = `${fnc}Raw`
|
||||
// @ts-ignore
|
||||
query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
|
||||
`${value.toLowerCase()}%`,
|
||||
])
|
||||
query = query[rawFnc](
|
||||
`LOWER(${quotedIdentifier(this.client, key)}) LIKE ?`,
|
||||
[`${value.toLowerCase()}%`]
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -376,12 +429,15 @@ class InternalBuilder {
|
|||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||
if (this.client === SqlClient.MS_SQL) {
|
||||
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]
|
||||
)
|
||||
} else {
|
||||
query = query[fnc](
|
||||
`COALESCE(${likeKey(this.client, key)} = ?, FALSE)`,
|
||||
`COALESCE(${quotedIdentifier(this.client, key)} = ?, FALSE)`,
|
||||
[value]
|
||||
)
|
||||
}
|
||||
|
@ -392,12 +448,15 @@ class InternalBuilder {
|
|||
const fnc = allOr ? "orWhereRaw" : "whereRaw"
|
||||
if (this.client === SqlClient.MS_SQL) {
|
||||
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]
|
||||
)
|
||||
} else {
|
||||
query = query[fnc](
|
||||
`COALESCE(${likeKey(this.client, key)} != ?, TRUE)`,
|
||||
`COALESCE(${quotedIdentifier(this.client, key)} != ?, TRUE)`,
|
||||
[value]
|
||||
)
|
||||
}
|
||||
|
@ -769,7 +828,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
|
|||
private readonly limit: number
|
||||
|
||||
// pass through client to get flavour of SQL
|
||||
constructor(client: string, limit: number = BASE_LIMIT) {
|
||||
constructor(client: SqlClient, limit: number = BASE_LIMIT) {
|
||||
super(client)
|
||||
this.limit = limit
|
||||
}
|
||||
|
|
|
@ -195,14 +195,14 @@ function buildDeleteTable(knex: SchemaBuilder, table: Table): SchemaBuilder {
|
|||
}
|
||||
|
||||
class SqlTableQueryBuilder {
|
||||
private readonly sqlClient: string
|
||||
private readonly sqlClient: SqlClient
|
||||
|
||||
// pass through client to get flavour of SQL
|
||||
constructor(client: string) {
|
||||
constructor(client: SqlClient) {
|
||||
this.sqlClient = client
|
||||
}
|
||||
|
||||
getSqlClient(): string {
|
||||
getSqlClient(): SqlClient {
|
||||
return this.sqlClient
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
|
|||
const ROW_ID_REGEX = /^\[.*]$/g
|
||||
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 TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
|
||||
|
||||
export function isExternalTableID(tableId: string) {
|
||||
return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR)
|
||||
|
@ -147,6 +148,10 @@ export function isValidFilter(value: any) {
|
|||
return value != null && value !== ""
|
||||
}
|
||||
|
||||
export function isValidTime(value: string) {
|
||||
return TIME_REGEX.test(value)
|
||||
}
|
||||
|
||||
export function sqlLog(client: string, query: string, values?: any[]) {
|
||||
if (!environment.SQL_LOGGING_ENABLE) {
|
||||
return
|
||||
|
|
|
@ -17,12 +17,12 @@
|
|||
|
||||
export let blockIdx
|
||||
export let lastStep
|
||||
export let modal
|
||||
|
||||
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
|
||||
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
|
||||
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
|
||||
let selectedAction
|
||||
let actionVal
|
||||
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
|
||||
let lockedFeatures = [
|
||||
ActionStepID.COLLECT,
|
||||
|
@ -91,19 +91,17 @@
|
|||
return acc
|
||||
}, {})
|
||||
|
||||
const selectAction = action => {
|
||||
actionVal = action
|
||||
const selectAction = async action => {
|
||||
selectedAction = action.name
|
||||
}
|
||||
|
||||
async function addBlockToAutomation() {
|
||||
try {
|
||||
const newBlock = automationStore.actions.constructBlock(
|
||||
"ACTION",
|
||||
actionVal.stepId,
|
||||
actionVal
|
||||
action.stepId,
|
||||
action
|
||||
)
|
||||
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
|
||||
modal.hide()
|
||||
} catch (error) {
|
||||
notifications.error("Error saving automation")
|
||||
}
|
||||
|
@ -114,10 +112,10 @@
|
|||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<ModalContent
|
||||
title="Add automation step"
|
||||
confirmText="Save"
|
||||
size="L"
|
||||
showConfirmButton={false}
|
||||
showCancelButton={false}
|
||||
disabled={!selectedAction}
|
||||
onConfirm={addBlockToAutomation}
|
||||
>
|
||||
<Layout noPadding gap="XS">
|
||||
<Detail size="S">Apps</Detail>
|
||||
|
|
|
@ -206,7 +206,7 @@
|
|||
{/if}
|
||||
|
||||
<Modal bind:this={actionModal} width="30%">
|
||||
<ActionModal {lastStep} {blockIdx} />
|
||||
<ActionModal modal={actionModal} {lastStep} {blockIdx} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={webhookModal} width="30%">
|
||||
|
|
|
@ -16,13 +16,12 @@
|
|||
export let enableNaming = true
|
||||
let validRegex = /^[A-Za-z0-9_\s]+$/
|
||||
let typing = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: stepNames = $selectedAutomation?.definition.stepNames
|
||||
$: automationName = stepNames?.[block.id] || block?.name || ""
|
||||
$: automationNameError = getAutomationNameError(automationName)
|
||||
$: status = updateStatus(testResult, isTrigger)
|
||||
$: status = updateStatus(testResult)
|
||||
$: isHeaderTrigger = isTrigger || block.type === "TRIGGER"
|
||||
|
||||
$: {
|
||||
|
@ -43,7 +42,7 @@
|
|||
})
|
||||
}
|
||||
|
||||
function updateStatus(results, isTrigger) {
|
||||
function updateStatus(results) {
|
||||
if (!results) {
|
||||
return {}
|
||||
}
|
||||
|
@ -56,7 +55,6 @@
|
|||
return { negative: true, message: "Error" }
|
||||
}
|
||||
}
|
||||
|
||||
const getAutomationNameError = name => {
|
||||
if (stepNames) {
|
||||
for (const [key, value] of Object.entries(stepNames)) {
|
||||
|
|
|
@ -81,7 +81,7 @@
|
|||
// Check the schema to see if required fields have been entered
|
||||
$: isError =
|
||||
!isTriggerValid(trigger) ||
|
||||
!trigger.schema.outputs.required.every(
|
||||
!trigger.schema.outputs.required?.every(
|
||||
required => $memoTestData?.[required] || required !== "row"
|
||||
)
|
||||
|
||||
|
|
|
@ -12,14 +12,31 @@
|
|||
let blocks
|
||||
|
||||
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)
|
||||
|
||||
$: {
|
||||
blocks = []
|
||||
if (automation) {
|
||||
if (testResults.message) {
|
||||
blocks = automation?.definition?.trigger
|
||||
? [automation.definition.trigger]
|
||||
: []
|
||||
} else if (automation) {
|
||||
blocks = []
|
||||
if (automation.definition.trigger) {
|
||||
blocks.push(automation.definition.trigger)
|
||||
}
|
||||
|
@ -46,7 +63,9 @@
|
|||
open={!!openBlocks[block.id]}
|
||||
on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
|
||||
isTrigger={idx === 0}
|
||||
testResult={filteredResults?.[idx]}
|
||||
testResult={testResults.message
|
||||
? testResults
|
||||
: filteredResults?.[idx]}
|
||||
showTestStatus
|
||||
{block}
|
||||
{idx}
|
||||
|
@ -68,7 +87,9 @@
|
|||
<Tabs quiet noHorizPadding selected="Input">
|
||||
<Tab title="Input">
|
||||
<div class="wrap">
|
||||
{#if filteredResults?.[idx]?.inputs}
|
||||
{#if testResults.message}
|
||||
No input
|
||||
{:else if filteredResults?.[idx]?.inputs}
|
||||
<JsonView depth={2} json={filteredResults?.[idx]?.inputs} />
|
||||
{:else}
|
||||
No input
|
||||
|
@ -77,13 +98,22 @@
|
|||
</Tab>
|
||||
<Tab title="Output">
|
||||
<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
|
||||
depth={2}
|
||||
json={filteredResults?.[idx]?.outputs}
|
||||
/>
|
||||
{:else}
|
||||
No input
|
||||
No output
|
||||
{/if}
|
||||
</div>
|
||||
</Tab>
|
||||
|
|
|
@ -17,7 +17,9 @@
|
|||
Helpers,
|
||||
Toggle,
|
||||
Divider,
|
||||
Icon,
|
||||
} from "@budibase/bbui"
|
||||
|
||||
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
||||
import { automationStore, selectedAutomation, tables } from "stores/builder"
|
||||
import { environment, licensing } from "stores/portal"
|
||||
|
@ -365,41 +367,74 @@
|
|||
|
||||
/**
|
||||
* Handler for row trigger automation updates.
|
||||
@param {object} update - An automation block.inputs update object
|
||||
@example
|
||||
onRowTriggerUpdate({
|
||||
"tableId" : "ta_bb_employee"
|
||||
})
|
||||
* @param {object} update - An automation block.inputs update object
|
||||
* @param {string} [update.tableId] - The ID of the table
|
||||
* @param {object} [update.filters] - Filter configuration for the row trigger
|
||||
* @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 => {
|
||||
if (
|
||||
Object.hasOwn(update, "tableId") &&
|
||||
$selectedAutomation.testData?.row?.tableId !== update.tableId
|
||||
["tableId", "filters", "meta"].some(key => Object.hasOwn(update, key))
|
||||
) {
|
||||
try {
|
||||
const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
|
||||
searchableSchema: true,
|
||||
}).schema
|
||||
let updatedAutomation
|
||||
|
||||
// Parse the block inputs as usual
|
||||
const updatedAutomation =
|
||||
await automationStore.actions.processBlockInputs(block, {
|
||||
schema: reqSchema,
|
||||
...update,
|
||||
})
|
||||
if (
|
||||
Object.hasOwn(update, "tableId") &&
|
||||
$selectedAutomation.testData?.row?.tableId !== update.tableId
|
||||
) {
|
||||
const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
|
||||
searchableSchema: true,
|
||||
}).schema
|
||||
|
||||
// Save the entire automation and reset the testData
|
||||
await automationStore.actions.save({
|
||||
...updatedAutomation,
|
||||
testData: {
|
||||
// Reset Core fields
|
||||
row: { tableId: update.tableId },
|
||||
oldRow: { tableId: update.tableId },
|
||||
meta: {},
|
||||
id: "",
|
||||
revision: "",
|
||||
},
|
||||
})
|
||||
updatedAutomation = await automationStore.actions.processBlockInputs(
|
||||
block,
|
||||
{
|
||||
schema: reqSchema,
|
||||
...update,
|
||||
}
|
||||
)
|
||||
|
||||
// Reset testData when tableId changes
|
||||
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
|
||||
} catch (e) {
|
||||
|
@ -408,7 +443,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for App trigger automation updates.
|
||||
* Ensure updates to the field list are reflected in testData
|
||||
|
@ -743,6 +777,7 @@
|
|||
value.customType !== "triggerSchema" &&
|
||||
value.customType !== "automationFields" &&
|
||||
value.customType !== "fields" &&
|
||||
value.customType !== "trigger_filter_setting" &&
|
||||
value.type !== "signature_single" &&
|
||||
value.type !== "attachment" &&
|
||||
value.type !== "attachment_single"
|
||||
|
@ -807,13 +842,23 @@
|
|||
{@const label = getFieldLabel(key, value)}
|
||||
<div class:block-field={shouldRenderField(value)}>
|
||||
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
|
||||
<Label
|
||||
tooltip={value.title === "Binding / Value"
|
||||
? "If using the String input type, please use a comma or newline separated string"
|
||||
: null}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<div class="label-container">
|
||||
<Label>
|
||||
{label}
|
||||
</Label>
|
||||
{#if value.customType === "trigger_filter"}
|
||||
<Icon
|
||||
hoverable
|
||||
on:click={() =>
|
||||
window.open(
|
||||
"https://docs.budibase.com/docs/row-trigger-filters",
|
||||
"_blank"
|
||||
)}
|
||||
size="XS"
|
||||
name="InfoOutline"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class:field-width={shouldRenderField(value)}>
|
||||
{#if value.type === "string" && value.enum && canShowField(key, value)}
|
||||
|
@ -932,8 +977,12 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if value.customType === "filters"}
|
||||
<ActionButton on:click={drawer.show}>Define filters</ActionButton>
|
||||
{:else if value.customType === "filters" || value.customType === "trigger_filter"}
|
||||
<ActionButton fullWidth on:click={drawer.show}
|
||||
>{filters.length > 0
|
||||
? "Update Filter"
|
||||
: "No Filter set"}</ActionButton
|
||||
>
|
||||
<Drawer bind:this={drawer} title="Filtering">
|
||||
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
|
||||
Save
|
||||
|
@ -945,6 +994,7 @@
|
|||
{schemaFields}
|
||||
datasource={{ type: "table", tableId }}
|
||||
panel={AutomationBindingPanel}
|
||||
showFilterEmptyDropdown={!rowTriggers.includes(stepId)}
|
||||
on:change={e => (tempFilters = e.detail)}
|
||||
/>
|
||||
</DrawerContent>
|
||||
|
@ -1085,6 +1135,11 @@
|
|||
{/if}
|
||||
|
||||
<style>
|
||||
.label-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
.field-width {
|
||||
width: 320px;
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
await API.deleteApp(appId)
|
||||
appsStore.load()
|
||||
notifications.success("App deleted successfully")
|
||||
deleting = false
|
||||
onDeleteSuccess()
|
||||
} catch (err) {
|
||||
notifications.error("Error deleting app")
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
export let panel = ClientBindingPanel
|
||||
export let allowBindings = true
|
||||
export let datasource
|
||||
|
||||
export let showFilterEmptyDropdown
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let rawFilters
|
||||
|
@ -63,6 +63,7 @@
|
|||
{schemaFields}
|
||||
{datasource}
|
||||
{allowBindings}
|
||||
{showFilterEmptyDropdown}
|
||||
>
|
||||
<div slot="filtering-hero-content" />
|
||||
|
||||
|
|
|
@ -208,7 +208,7 @@ const automationActions = store => ({
|
|||
const message = err.message || err.status || JSON.stringify(err)
|
||||
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") {
|
||||
throw "You have exceeded your automation quota"
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
export let behaviourFilters = false
|
||||
export let allowBindings = false
|
||||
export let filtersLabel = "Filters"
|
||||
|
||||
export let showFilterEmptyDropdown = true
|
||||
$: {
|
||||
if (
|
||||
tables.find(
|
||||
|
@ -218,7 +218,7 @@
|
|||
on:change={e => handleAllOr(e.detail)}
|
||||
placeholder={null}
|
||||
/>
|
||||
{#if datasource?.type === "table"}
|
||||
{#if datasource?.type === "table" && showFilterEmptyDropdown}
|
||||
<Select
|
||||
label="When filter empty"
|
||||
value={onEmptyFilter}
|
||||
|
|
|
@ -1,16 +1,6 @@
|
|||
import * as triggers from "../../automations/triggers"
|
||||
import {
|
||||
getAutomationParams,
|
||||
generateAutomationID,
|
||||
DocumentType,
|
||||
} from "../../db/utils"
|
||||
import {
|
||||
checkForWebhooks,
|
||||
updateTestHistory,
|
||||
removeDeprecated,
|
||||
} from "../../automations/utils"
|
||||
import { deleteEntityMetadata } from "../../utilities"
|
||||
import { MetadataTypes } from "../../constants"
|
||||
import { DocumentType } from "../../db/utils"
|
||||
import { updateTestHistory, removeDeprecated } from "../../automations/utils"
|
||||
import { setTestFlag, clearTestFlag } from "../../utilities/redis"
|
||||
import { context, cache, events, db as dbCore } from "@budibase/backend-core"
|
||||
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(
|
||||
ctx: UserCtx<Automation, { message: string; automation: Automation }>
|
||||
) {
|
||||
const db = context.getAppDB()
|
||||
let automation = ctx.request.body
|
||||
automation.appId = ctx.appId
|
||||
|
||||
|
@ -86,66 +43,17 @@ export async function create(
|
|||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
const createdAutomation = await sdk.automations.create(automation)
|
||||
|
||||
ctx.status = 200
|
||||
ctx.body = {
|
||||
message: "Automation created successfully",
|
||||
automation: {
|
||||
...automation,
|
||||
...response,
|
||||
},
|
||||
automation: createdAutomation,
|
||||
}
|
||||
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) {
|
||||
const db = context.getAppDB()
|
||||
let automation = ctx.request.body
|
||||
automation.appId = ctx.appId
|
||||
|
||||
|
@ -155,72 +63,28 @@ export async function update(ctx: UserCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
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
|
||||
: {}
|
||||
// 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)
|
||||
const updatedAutomation = await sdk.automations.update(automation)
|
||||
|
||||
ctx.status = 200
|
||||
ctx.body = {
|
||||
message: `Automation ${automation._id} updated successfully.`,
|
||||
automation: {
|
||||
...automation,
|
||||
_rev: response.rev,
|
||||
_id: response.id,
|
||||
},
|
||||
automation: updatedAutomation,
|
||||
}
|
||||
builderSocket?.emitAutomationUpdate(ctx, automation)
|
||||
}
|
||||
|
||||
export async function fetch(ctx: UserCtx) {
|
||||
const db = context.getAppDB()
|
||||
const response = await db.allDocs(
|
||||
getAutomationParams(null, {
|
||||
include_docs: true,
|
||||
})
|
||||
)
|
||||
ctx.body = response.rows.map(row => row.doc)
|
||||
ctx.body = await sdk.automations.fetch()
|
||||
}
|
||||
|
||||
export async function find(ctx: UserCtx) {
|
||||
const db = context.getAppDB()
|
||||
ctx.body = await db.get(ctx.params.id)
|
||||
ctx.body = await sdk.automations.get(ctx.params.id)
|
||||
}
|
||||
|
||||
export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) {
|
||||
const db = context.getAppDB()
|
||||
const automationId = ctx.params.id
|
||||
const oldAutomation = await db.get<Automation>(automationId)
|
||||
await checkForWebhooks({
|
||||
oldAuto: oldAutomation,
|
||||
})
|
||||
// delete metadata first
|
||||
await cleanupAutomationMetadata(automationId)
|
||||
ctx.body = await db.remove(automationId, ctx.params.rev)
|
||||
await events.automation.deleted(oldAutomation)
|
||||
|
||||
ctx.body = await sdk.automations.remove(automationId, ctx.params.rev)
|
||||
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,
|
||||
isSQL,
|
||||
} from "../../../integrations/utils"
|
||||
import { events } from "@budibase/backend-core"
|
||||
import { events, HTTPError } from "@budibase/backend-core"
|
||||
import {
|
||||
BulkImportRequest,
|
||||
BulkImportResponse,
|
||||
|
@ -29,6 +29,7 @@ import sdk from "../../../sdk"
|
|||
import { jsonFromCsvString } from "../../../utilities/csv"
|
||||
import { builderSocket } from "../../../websockets"
|
||||
import { cloneDeep, isEqual } from "lodash"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
|
||||
function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
|
||||
if (table && isExternalTable(table)) {
|
||||
|
@ -40,6 +41,20 @@ function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
|
|||
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
|
||||
export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) {
|
||||
const internal = await sdk.tables.getAllInternalTables()
|
||||
|
@ -76,6 +91,8 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
|
|||
const isImport = table.rows
|
||||
const renaming = ctx.request.body._rename
|
||||
|
||||
checkDefaultFields(table)
|
||||
|
||||
const api = pickApi({ table })
|
||||
let savedTable = await api.save(ctx, renaming)
|
||||
if (!table._id) {
|
||||
|
|
|
@ -28,6 +28,7 @@ import opsRoutes from "./ops"
|
|||
import debugRoutes from "./debug"
|
||||
import Router from "@koa/router"
|
||||
import { api as pro } from "@budibase/pro"
|
||||
import rowActionRoutes from "./rowAction"
|
||||
|
||||
export { default as staticRoutes } from "./static"
|
||||
export { default as publicRoutes } from "./public"
|
||||
|
@ -65,6 +66,7 @@ export const mainRoutes: Router[] = [
|
|||
opsRoutes,
|
||||
debugRoutes,
|
||||
environmentVariableRoutes,
|
||||
rowActionRoutes,
|
||||
// these need to be handled last as they still use /api/:tableId
|
||||
// this could be breaking as koa may recognise other routes as this
|
||||
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"
|
||||
import { events } from "@budibase/backend-core"
|
||||
import sdk from "../../../sdk"
|
||||
import { Automation } from "@budibase/types"
|
||||
import { Automation, FieldType, Table } from "@budibase/types"
|
||||
import { mocks } from "@budibase/backend-core/tests"
|
||||
import { FilterConditions } from "../../../automations/steps/filter"
|
||||
|
||||
|
@ -23,6 +23,7 @@ let {
|
|||
automationStep,
|
||||
collectAutomation,
|
||||
filterAutomation,
|
||||
updateRowAutomationWithFilters,
|
||||
} = setup.structures
|
||||
|
||||
describe("/automations", () => {
|
||||
|
@ -154,7 +155,7 @@ describe("/automations", () => {
|
|||
tableId: table._id,
|
||||
},
|
||||
}
|
||||
automation.appId = config.appId
|
||||
automation.appId = config.getAppId()
|
||||
automation = await config.createAutomation(automation)
|
||||
await setup.delay(500)
|
||||
const res = await testAutomation(config, automation, {
|
||||
|
@ -267,8 +268,7 @@ describe("/automations", () => {
|
|||
}
|
||||
|
||||
it("updates a automations name", async () => {
|
||||
let automation = newAutomation()
|
||||
await config.createAutomation(automation)
|
||||
const automation = await config.createAutomation(newAutomation())
|
||||
automation.name = "Updated Name"
|
||||
jest.clearAllMocks()
|
||||
|
||||
|
@ -294,8 +294,7 @@ describe("/automations", () => {
|
|||
})
|
||||
|
||||
it("updates a automations name using POST request", async () => {
|
||||
let automation = newAutomation()
|
||||
await config.createAutomation(automation)
|
||||
const automation = await config.createAutomation(newAutomation())
|
||||
automation.name = "Updated Name"
|
||||
jest.clearAllMocks()
|
||||
|
||||
|
@ -392,8 +391,7 @@ describe("/automations", () => {
|
|||
describe("fetch", () => {
|
||||
it("return all the automations for an instance", async () => {
|
||||
await clearAllAutomations(config)
|
||||
const autoConfig = basicAutomation()
|
||||
await config.createAutomation(autoConfig)
|
||||
const autoConfig = await config.createAutomation(basicAutomation())
|
||||
const res = await request
|
||||
.get(`/api/automations`)
|
||||
.set(config.defaultHeaders())
|
||||
|
@ -455,14 +453,13 @@ describe("/automations", () => {
|
|||
|
||||
let table = await config.createTable()
|
||||
|
||||
let automation = await filterAutomation()
|
||||
let automation = await filterAutomation(config.getAppId())
|
||||
automation.definition.trigger.inputs.tableId = table._id
|
||||
automation.definition.steps[0].inputs = {
|
||||
condition: FilterConditions.EQUAL,
|
||||
field: "{{ trigger.row.City }}",
|
||||
value: "{{ trigger.oldRow.City }}",
|
||||
}
|
||||
automation.appId = config.appId!
|
||||
automation = await config.createAutomation(automation)
|
||||
let triggerInputs = {
|
||||
oldRow: {
|
||||
|
@ -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" }`)
|
||||
})
|
||||
|
||||
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", () => {
|
||||
|
|
|
@ -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 isLucene = name === "lucene"
|
||||
const isInMemory = name === "in-memory"
|
||||
const isInternal = isSqs || isLucene
|
||||
const isInternal = isSqs || isLucene || isInMemory
|
||||
const config = setup.getConfig()
|
||||
|
||||
let envCleanup: (() => void) | undefined
|
||||
|
@ -115,10 +115,7 @@ describe.each([
|
|||
if (isInMemory) {
|
||||
return dataFilters.search(_.cloneDeep(rows), this.query)
|
||||
} else {
|
||||
return config.api.row.search(table._id!, {
|
||||
...this.query,
|
||||
tableId: table._id!,
|
||||
})
|
||||
return config.api.row.search(this.query.tableId, this.query)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2182,8 +2179,7 @@ describe.each([
|
|||
}).toContainExactly([{ name: "baz", productCat: undefined }])
|
||||
})
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
;(isSqs || isLucene) &&
|
||||
describe("relations to same table", () => {
|
||||
let relatedTable: Table, relatedRows: Row[]
|
||||
|
||||
|
@ -2371,6 +2367,7 @@ describe.each([
|
|||
beforeAll(async () => {
|
||||
await config.api.application.addSampleData(config.appId!)
|
||||
table = DEFAULT_EMPLOYEE_TABLE_SCHEMA
|
||||
rows = await config.api.row.fetch(table._id!)
|
||||
})
|
||||
|
||||
it("should be able to search sample data", async () => {
|
||||
|
@ -2455,4 +2452,76 @@ describe.each([
|
|||
}).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)
|
||||
})
|
||||
|
||||
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 () => {
|
||||
await checkBuilderEndpoint({
|
||||
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", () => {
|
||||
!isInternal &&
|
||||
it("should error if column is of type auto", async () => {
|
||||
|
|
|
@ -1022,6 +1022,11 @@ describe.each([
|
|||
schema: {
|
||||
one: { type: FieldType.STRING, name: "one" },
|
||||
two: { type: FieldType.STRING, name: "two" },
|
||||
default: {
|
||||
type: FieldType.STRING,
|
||||
name: "default",
|
||||
default: "default",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
@ -1042,11 +1047,13 @@ describe.each([
|
|||
_viewId: view.id,
|
||||
one: "foo",
|
||||
two: "bar",
|
||||
default: "ohnoes",
|
||||
})
|
||||
|
||||
const row = await config.api.row.get(table._id!, newRow._id!)
|
||||
expect(row.one).toBeUndefined()
|
||||
expect(row.two).toEqual("bar")
|
||||
expect(row.default).toEqual("default")
|
||||
})
|
||||
|
||||
it("can't persist readonly columns", async () => {
|
||||
|
|
|
@ -23,6 +23,11 @@ export const definition: AutomationTriggerSchema = {
|
|||
customType: AutomationCustomIOType.TABLE,
|
||||
title: "Table",
|
||||
},
|
||||
filters: {
|
||||
type: AutomationIOType.OBJECT,
|
||||
customType: AutomationCustomIOType.FILTERS,
|
||||
title: "Filtering",
|
||||
},
|
||||
},
|
||||
required: ["tableId"],
|
||||
},
|
||||
|
|
|
@ -23,6 +23,11 @@ export const definition: AutomationTriggerSchema = {
|
|||
customType: AutomationCustomIOType.TABLE,
|
||||
title: "Table",
|
||||
},
|
||||
filters: {
|
||||
type: AutomationIOType.OBJECT,
|
||||
customType: AutomationCustomIOType.TRIGGER_FILTER,
|
||||
title: "Filtering",
|
||||
},
|
||||
},
|
||||
required: ["tableId"],
|
||||
},
|
||||
|
|
|
@ -15,14 +15,19 @@ import {
|
|||
AutomationJob,
|
||||
AutomationEventType,
|
||||
UpdatedRowEventEmitter,
|
||||
SearchFilters,
|
||||
AutomationStoppedReason,
|
||||
AutomationStatus,
|
||||
} from "@budibase/types"
|
||||
import { executeInThread } from "../threads/automation"
|
||||
import { dataFilters } from "@budibase/shared-core"
|
||||
|
||||
export const TRIGGER_DEFINITIONS = definitions
|
||||
const JOB_OPTS = {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}
|
||||
import * as automationUtils from "../automations/automationUtils"
|
||||
|
||||
async function getAllAutomations() {
|
||||
const db = context.getAppDB()
|
||||
|
@ -33,7 +38,7 @@ async function getAllAutomations() {
|
|||
}
|
||||
|
||||
async function queueRelevantRowAutomations(
|
||||
event: { appId: string; row: Row },
|
||||
event: { appId: string; row: Row; oldRow: Row },
|
||||
eventType: string
|
||||
) {
|
||||
if (event.appId == null) {
|
||||
|
@ -62,9 +67,15 @@ async function queueRelevantRowAutomations(
|
|||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const shouldTrigger = await checkTriggerFilters(automation, {
|
||||
row: event.row,
|
||||
oldRow: event.oldRow,
|
||||
})
|
||||
if (
|
||||
automationTrigger?.inputs &&
|
||||
automationTrigger.inputs.tableId === event.row.tableId
|
||||
automationTrigger.inputs.tableId === event.row.tableId &&
|
||||
shouldTrigger
|
||||
) {
|
||||
try {
|
||||
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)
|
||||
})
|
||||
|
||||
function rowPassesFilters(row: Row, filters: SearchFilters) {
|
||||
const filteredRows = dataFilters.runQuery([row], filters)
|
||||
return filteredRows.length > 0
|
||||
}
|
||||
|
||||
export async function externalTrigger(
|
||||
automation: Automation,
|
||||
params: { fields: Record<string, any>; timeout?: number },
|
||||
|
@ -126,7 +142,23 @@ export async function externalTrigger(
|
|||
}
|
||||
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) {
|
||||
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 { cloneDeep } from "lodash/fp"
|
||||
import { quotas } from "@budibase/pro"
|
||||
import {
|
||||
Automation,
|
||||
AutomationJob,
|
||||
Webhook,
|
||||
WebhookActionType,
|
||||
} from "@budibase/types"
|
||||
import sdk from "../sdk"
|
||||
import { Automation, AutomationJob } from "@budibase/types"
|
||||
import { automationsEnabled } from "../features"
|
||||
import { helpers, REBOOT_CRON } from "@budibase/shared-core"
|
||||
import tracer from "dd-trace"
|
||||
|
||||
const WH_STEP_ID = definitions.WEBHOOK.stepId
|
||||
const CRON_STEP_ID = definitions.CRON.stepId
|
||||
let Runner: Thread
|
||||
if (automationsEnabled()) {
|
||||
|
@ -229,76 +222,6 @@ export async function enableCronTrigger(appId: any, automation: 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).
|
||||
* @param appId the app that is being removed.
|
||||
|
|
|
@ -349,3 +349,11 @@ export function isRelationshipColumn(
|
|||
): column is RelationshipFieldMetadata {
|
||||
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) {
|
||||
let readableMessage = getReadableErrorMessage(SourceName.MYSQL, err.errno)
|
||||
if (readableMessage) {
|
||||
throw new Error(readableMessage)
|
||||
throw new Error(readableMessage, { cause: err })
|
||||
} else {
|
||||
throw new Error(err.message as string)
|
||||
throw err
|
||||
}
|
||||
} finally {
|
||||
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 utils from "./utils"
|
||||
|
||||
export default {
|
||||
...crud,
|
||||
webhook,
|
||||
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
|
||||
|
||||
// quick hack for docIds
|
||||
|
||||
const fixJunctionDocs = (field: string) =>
|
||||
["doc1", "doc2"].forEach(doc => {
|
||||
sql = sql.replaceAll(`\`${doc}\`.\`${field}\``, `\`${doc}.${field}\``)
|
||||
|
|
|
@ -10,6 +10,7 @@ import { default as users } from "./users"
|
|||
import { default as plugins } from "./plugins"
|
||||
import * as views from "./app/views"
|
||||
import * as permissions from "./app/permissions"
|
||||
import * as rowActions from "./app/rowActions"
|
||||
|
||||
const sdk = {
|
||||
backups,
|
||||
|
@ -24,6 +25,7 @@ const sdk = {
|
|||
views,
|
||||
permissions,
|
||||
links,
|
||||
rowActions,
|
||||
}
|
||||
|
||||
// default export for TS
|
||||
|
|
|
@ -13,6 +13,7 @@ import { UserAPI } from "./user"
|
|||
import { QueryAPI } from "./query"
|
||||
import { RoleAPI } from "./role"
|
||||
import { TemplateAPI } from "./template"
|
||||
import { RowActionAPI } from "./rowAction"
|
||||
|
||||
export default class API {
|
||||
table: TableAPI
|
||||
|
@ -29,6 +30,7 @@ export default class API {
|
|||
query: QueryAPI
|
||||
roles: RoleAPI
|
||||
templates: TemplateAPI
|
||||
rowAction: RowActionAPI
|
||||
|
||||
constructor(config: TestConfiguration) {
|
||||
this.table = new TableAPI(config)
|
||||
|
@ -45,5 +47,6 @@ export default class API {
|
|||
this.query = new QueryAPI(config)
|
||||
this.roles = new RoleAPI(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 = {}) {
|
||||
const automation: any = basicAutomation()
|
||||
const automation = basicAutomation()
|
||||
|
||||
if (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 {
|
||||
const automation: any = {
|
||||
export function filterAutomation(appId: string, tableId?: string): Automation {
|
||||
const automation: Automation = {
|
||||
name: "looping",
|
||||
type: "automation",
|
||||
appId,
|
||||
definition: {
|
||||
steps: [
|
||||
{
|
||||
name: "Filter Step",
|
||||
tagline: "An automation filter step",
|
||||
description: "A filter automation",
|
||||
id: "b",
|
||||
type: "ACTION",
|
||||
icon: "Icon",
|
||||
type: AutomationStepType.ACTION,
|
||||
internal: true,
|
||||
stepId: AutomationActionStepId.FILTER,
|
||||
inputs: {},
|
||||
|
@ -376,8 +381,12 @@ export function filterAutomation(tableId?: string): Automation {
|
|||
},
|
||||
],
|
||||
trigger: {
|
||||
name: "trigger Step",
|
||||
tagline: "An automation trigger",
|
||||
description: "A trigger",
|
||||
icon: "Icon",
|
||||
id: "a",
|
||||
type: "TRIGGER",
|
||||
type: AutomationStepType.TRIGGER,
|
||||
event: "row:save",
|
||||
stepId: AutomationTriggerStepId.ROW_SAVED,
|
||||
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(
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
import * as linkRows from "../../db/linkedRows"
|
||||
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 { TYPE_TRANSFORM_MAP } from "./map"
|
||||
import {
|
||||
AutoFieldSubType,
|
||||
FieldType,
|
||||
IdentityType,
|
||||
Row,
|
||||
RowAttachment,
|
||||
Table,
|
||||
User,
|
||||
} from "@budibase/types"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import {
|
||||
|
@ -19,6 +27,7 @@ import {
|
|||
} from "./bbReferenceProcessor"
|
||||
import { isExternalTableID } from "../../integrations/utils"
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
import { processString } from "@budibase/string-templates"
|
||||
|
||||
export * from "./utils"
|
||||
export * from "./attachments"
|
||||
|
@ -88,7 +97,34 @@ export async function processAutoColumn(
|
|||
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
|
||||
}
|
||||
|
||||
// handle auto columns - this returns an object like {table, row}
|
||||
return processAutoColumn(userId, table, clonedRow, opts)
|
||||
await 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"
|
||||
|
||||
const parseArrayString = (value: any) => {
|
||||
|
@ -91,7 +92,13 @@ export const TYPE_TRANSFORM_MAP: any = {
|
|||
[null]: null,
|
||||
//@ts-ignore
|
||||
[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]: {
|
||||
"": null,
|
||||
|
@ -109,8 +116,15 @@ export const TYPE_TRANSFORM_MAP: any = {
|
|||
parse: (date: any) => {
|
||||
if (date instanceof Date) {
|
||||
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]: {
|
||||
|
|
|
@ -7,3 +7,4 @@ export * from "./table"
|
|||
export * from "./permission"
|
||||
export * from "./attachment"
|
||||
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_FIELDS = "automationFields",
|
||||
MULTI_ATTACHMENTS = "multi_attachments",
|
||||
TRIGGER_FILTER = "trigger_filter",
|
||||
}
|
||||
|
||||
export enum AutomationTriggerStepId {
|
||||
|
@ -128,6 +129,15 @@ export interface Automation extends Document {
|
|||
internal?: boolean
|
||||
type?: string
|
||||
disabled?: boolean
|
||||
testData?: {
|
||||
row?: Row
|
||||
meta: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
id: string
|
||||
revision: string
|
||||
oldRow?: Row
|
||||
}
|
||||
}
|
||||
|
||||
interface BaseIOStructure {
|
||||
|
@ -201,6 +211,10 @@ export enum AutomationStatus {
|
|||
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 {
|
||||
automationId?: string
|
||||
status?: AutomationStatus
|
||||
|
|
|
@ -16,3 +16,4 @@ export * from "./links"
|
|||
export * from "./component"
|
||||
export * from "./sqlite"
|
||||
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
|
||||
toKey: string
|
||||
}
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface JsonFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||
type: FieldType.JSON
|
||||
subtype?: JsonFieldSubType.ARRAY
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
||||
|
@ -94,17 +96,25 @@ export interface DateFieldMetadata extends Omit<BaseFieldSchema, "subtype"> {
|
|||
timeOnly?: boolean
|
||||
dateOnly?: boolean
|
||||
subtype?: AutoFieldSubType.CREATED_AT | AutoFieldSubType.UPDATED_AT
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface LongFormFieldMetadata extends BaseFieldSchema {
|
||||
type: FieldType.LONGFORM
|
||||
useRichText?: boolean | null
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface StringFieldMetadata extends BaseFieldSchema {
|
||||
type: FieldType.STRING
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface FormulaFieldMetadata extends BaseFieldSchema {
|
||||
type: FieldType.FORMULA
|
||||
formula: string
|
||||
formulaType?: FormulaType
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface BBReferenceFieldMetadata
|
||||
|
@ -171,6 +181,7 @@ interface OtherFieldMetadata extends BaseFieldSchema {
|
|||
| FieldType.BB_REFERENCE
|
||||
| FieldType.BB_REFERENCE_SINGLE
|
||||
| FieldType.ATTACHMENTS
|
||||
| FieldType.STRING
|
||||
>
|
||||
}
|
||||
|
||||
|
@ -182,6 +193,7 @@ export type FieldSchema =
|
|||
| FormulaFieldMetadata
|
||||
| NumberFieldMetadata
|
||||
| LongFormFieldMetadata
|
||||
| StringFieldMetadata
|
||||
| BBReferenceFieldMetadata
|
||||
| JsonFieldMetadata
|
||||
| AttachmentFieldMetadata
|
||||
|
|
|
@ -39,6 +39,7 @@ export enum DocumentType {
|
|||
AUDIT_LOG = "al",
|
||||
APP_MIGRATION_METADATA = "_design/migrations",
|
||||
SCIM_LOG = "scimlog",
|
||||
ROW_ACTIONS = "ra",
|
||||
}
|
||||
|
||||
// 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
|
||||
export enum VirtualDocumentType {
|
||||
VIEW = "view",
|
||||
ROW_ACTION = "row_action",
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Automation, AutomationMetadata } from "../../documents"
|
||||
import { Automation, AutomationMetadata, Row } from "../../documents"
|
||||
import { Job } from "bull"
|
||||
|
||||
export interface AutomationDataEvent {
|
||||
|
@ -6,6 +6,8 @@ export interface AutomationDataEvent {
|
|||
metadata?: AutomationMetadata
|
||||
automation?: Automation
|
||||
timeout?: number
|
||||
row?: Row
|
||||
oldRow?: Row
|
||||
}
|
||||
|
||||
export interface AutomationData {
|
||||
|
|
Loading…
Reference in New Issue