diff --git a/lerna.json b/lerna.json index efcbb7694c..843addc63c 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "2.29.20", + "version": "2.29.22", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/middleware/joi-validator.ts b/packages/backend-core/src/middleware/joi-validator.ts index 5047cdbbc1..a85c0e7108 100644 --- a/packages/backend-core/src/middleware/joi-validator.ts +++ b/packages/backend-core/src/middleware/joi-validator.ts @@ -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) } diff --git a/packages/backend-core/src/sql/sql.ts b/packages/backend-core/src/sql/sql.ts index 4936e4da68..a4b924bf54 100644 --- a/packages/backend-core/src/sql/sql.ts +++ b/packages/backend-core/src/sql/sql.ts @@ -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 } diff --git a/packages/backend-core/src/sql/sqlTable.ts b/packages/backend-core/src/sql/sqlTable.ts index bdc8a3dd69..02acc8af85 100644 --- a/packages/backend-core/src/sql/sqlTable.ts +++ b/packages/backend-core/src/sql/sqlTable.ts @@ -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 } diff --git a/packages/backend-core/src/sql/utils.ts b/packages/backend-core/src/sql/utils.ts index e73c6ac445..67b5d2081b 100644 --- a/packages/backend-core/src/sql/utils.ts +++ b/packages/backend-core/src/sql/utils.ts @@ -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 diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index ccb1369c57..9899c454fc 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -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 @@ Apps diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte index 48b630cb62..811909845a 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte @@ -206,7 +206,7 @@ {/if} - + diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte index a409d85305..5533572511 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte @@ -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)) { diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte index 7d223299c7..2a553f8b48 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte @@ -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" ) diff --git a/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte index 2cad22c820..8487b7a519 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte @@ -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 @@
- {#if filteredResults?.[idx]?.inputs} + {#if testResults.message} + No input + {:else if filteredResults?.[idx]?.inputs} {:else} No input @@ -77,13 +98,22 @@
- {#if filteredResults?.[idx]?.outputs} + {#if testResults.message} + + {:else if filteredResults?.[idx]?.outputs} {:else} - No input + No output {/if}
diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 283bd14fc0..8a9d1e59ea 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -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)}
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)} - +
+ + {#if value.customType === "trigger_filter"} + + window.open( + "https://docs.budibase.com/docs/row-trigger-filters", + "_blank" + )} + size="XS" + name="InfoOutline" + /> + {/if} +
{/if}
{#if value.type === "string" && value.enum && canShowField(key, value)} @@ -932,8 +977,12 @@ {/if}
- {:else if value.customType === "filters"} - Define filters + {:else if value.customType === "filters" || value.customType === "trigger_filter"} + {filters.length > 0 + ? "Update Filter" + : "No Filter set"}