Merge branch 'master' into table-improvements-2

This commit is contained in:
Andrew Kingston 2024-07-22 08:00:36 +01:00 committed by GitHub
commit c2c799e473
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2100 additions and 377 deletions

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.29.20",
"version": "2.29.22",
"npmClient": "yarn",
"packages": [
"packages/*",

View File

@ -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)
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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>

View File

@ -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%">

View File

@ -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)) {

View File

@ -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"
)

View File

@ -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>

View File

@ -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;
}

View File

@ -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")

View File

@ -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" />

View File

@ -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"
}

View File

@ -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}

View File

@ -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)
}

View File

@ -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
}

View File

@ -0,0 +1,2 @@
export * from "./crud"
export * from "./run"

View File

@ -0,0 +1,3 @@
export function run() {
throw new Error("Function not implemented.")
}

View File

@ -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) {

View File

@ -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,

View File

@ -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

View File

@ -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)
}
}
)
})
})

View File

@ -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", () => {

View File

@ -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,
})
})
})
})

View File

@ -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" }])
})
})
})

View File

@ -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 () => {

View File

@ -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 () => {

View File

@ -23,6 +23,11 @@ export const definition: AutomationTriggerSchema = {
customType: AutomationCustomIOType.TABLE,
title: "Table",
},
filters: {
type: AutomationIOType.OBJECT,
customType: AutomationCustomIOType.FILTERS,
title: "Filtering",
},
},
required: ["tableId"],
},

View File

@ -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"],
},

View File

@ -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
}

View File

@ -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.

View File

@ -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}`
}

View File

@ -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) {

View File

@ -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
}

View File

@ -1,7 +1,9 @@
import * as crud from "./crud"
import * as webhook from "./webhook"
import * as utils from "./utils"
export default {
...crud,
webhook,
utils,
}

View File

@ -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)
}

View File

@ -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}\``)

View File

@ -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

View File

@ -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)
}
}

View File

@ -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,
}
)
}
}

View File

@ -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(

View File

@ -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 }
}
/**

View File

@ -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]: {

View File

@ -7,3 +7,4 @@ export * from "./table"
export * from "./permission"
export * from "./attachment"
export * from "./user"
export * from "./rowAction"

View File

@ -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>
}

View File

@ -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

View File

@ -16,3 +16,4 @@ export * from "./links"
export * from "./component"
export * from "./sqlite"
export * from "./snippet"
export * from "./rowAction"

View File

@ -0,0 +1,11 @@
import { Document } from "../document"
export interface TableRowActions extends Document {
_id: string
actions: Record<
string,
{
name: string
}
>
}

View File

@ -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

View File

@ -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 {

View File

@ -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 {