diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 998c95be27..e2fa9f2515 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -33,6 +33,6 @@ jobs: { "adrinr": "firestorm", "samwho": "firestorm", - "pclmnt": "firestorm", + "PClmnt": "firestorm", "mike12345567": "firestorm" } diff --git a/lerna.json b/lerna.json index 8b1fa18d23..c551cf6bd9 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.28.3", + "version": "2.28.4", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index 8194d1aabf..ed79a14340 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -328,7 +328,14 @@ export class DatabaseImpl implements Database { async sqlDiskCleanup(): Promise { const dbName = this.name const url = `/${dbName}/_cleanup` - return await this._sqlQuery(url, "POST") + try { + await this._sqlQuery(url, "POST") + } catch (err: any) { + // hack for now - SQS throws a 500 when there is nothing to clean-up + if (err.status !== 500) { + throw err + } + } } // removes a document from sqlite @@ -352,18 +359,15 @@ export class DatabaseImpl implements Database { } async destroy() { + if (env.SQS_SEARCH_ENABLE && (await this.exists(SQLITE_DESIGN_DOC_ID))) { + // delete the design document, then run the cleanup operation + const definition = await this.get(SQLITE_DESIGN_DOC_ID) + // remove all tables - save the definition then trigger a cleanup + definition.sql.tables = {} + await this.put(definition) + await this.sqlDiskCleanup() + } try { - if (env.SQS_SEARCH_ENABLE) { - // delete the design document, then run the cleanup operation - try { - const definition = await this.get( - SQLITE_DESIGN_DOC_ID - ) - await this.remove(SQLITE_DESIGN_DOC_ID, definition._rev) - } finally { - await this.sqlDiskCleanup() - } - } return await this.nano().db.destroy(this.name) } catch (err: any) { // didn't exist, don't worry diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 85ae1924d0..5d2e4ee28d 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -15,6 +15,7 @@ Checkbox, DatePicker, DrawerContent, + Toggle, } from "@budibase/bbui" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import { automationStore, selectedAutomation, tables } from "stores/builder" @@ -118,7 +119,6 @@ searchableSchema: true, }).schema } - try { if (isTestModal) { let newTestData = { schema } @@ -385,6 +385,16 @@ return params } + function toggleAttachmentBinding(e, key) { + onChange( + { + detail: "", + }, + key + ) + onChange({ detail: { useAttachmentBinding: e.detail } }, "meta") + } + onMount(async () => { try { await environment.loadVariables() @@ -462,27 +472,64 @@
-
- - onChange( - { - detail: e.detail.map(({ name, value }) => ({ - url: name, - filename: value, - })), - }, - key - )} - object={handleAttachmentParams(inputData[key])} - allowJS - {bindings} - keyBindings - customButtonText={"Add attachment"} - keyPlaceholder={"URL"} - valuePlaceholder={"Filename"} +
+ toggleAttachmentBinding(e, key)} />
+ +
+ {#if !inputData?.meta?.useAttachmentBinding} + + onChange( + { + detail: e.detail.map(({ name, value }) => ({ + url: name, + filename: value, + })), + }, + key + )} + object={handleAttachmentParams(inputData[key])} + allowJS + {bindings} + keyBindings + customButtonText={"Add attachment"} + keyPlaceholder={"URL"} + valuePlaceholder={"Filename"} + /> + {:else if isTestModal} + onChange(e, key)} + {bindings} + updateOnChange={false} + /> + {:else} +
+ onChange(e, key)} + {bindings} + updateOnChange={false} + placeholder={value.customType === "queryLimit" + ? queryLimit + : ""} + drawerLeft="260px" + /> +
+ {/if} +
{:else if value.customType === "filters"} Define filters diff --git a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte index b5a54138ca..bd3bcda774 100644 --- a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte +++ b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte @@ -10,12 +10,12 @@ import { TableNames } from "constants" const dispatch = createEventDispatcher() - export let value export let meta export let bindings export let isTestModal export let isUpdateRow + $: parsedBindings = bindings.map(binding => { let clone = Object.assign({}, binding) clone.icon = "ShareAndroid" @@ -94,17 +94,22 @@ dispatch("change", newValue) } - const onChangeSetting = (e, field) => { - let fields = {} - fields[field] = { - clearRelationships: e.detail, + const onChangeSetting = (field, key, value) => { + let newField = {} + newField[field] = { + [key]: value, } + + let updatedFields = { + ...meta?.fields, + ...newField, + } + dispatch("change", { key: "meta", - fields, + fields: updatedFields, }) } - // Ensure any nullish tableId values get set to empty string so // that the select works $: if (value?.tableId == null) value = { tableId: "" } @@ -157,6 +162,9 @@ bindings={parsedBindings} {value} {onChange} + useAttachmentBinding={meta?.fields?.[field] + ?.useAttachmentBinding} + {onChangeSetting} /> {/if} @@ -167,7 +175,8 @@ value={meta.fields?.[field]?.clearRelationships} text={"Clear relationships if empty?"} size={"S"} - on:change={e => onChangeSetting(e, field)} + on:change={e => + onChangeSetting(field, "clearRelationships", e.detail)} /> {/if} diff --git a/packages/builder/src/components/automation/SetupPanel/RowSelectorTypes.svelte b/packages/builder/src/components/automation/SetupPanel/RowSelectorTypes.svelte index 0a27360347..a43ff35c80 100644 --- a/packages/builder/src/components/automation/SetupPanel/RowSelectorTypes.svelte +++ b/packages/builder/src/components/automation/SetupPanel/RowSelectorTypes.svelte @@ -1,5 +1,11 @@ {#if schemaHasOptions(schema) && schema.type !== "array"} @@ -108,38 +131,65 @@ useLabel={false} /> {:else if attachmentTypes.includes(schema.type)} -
- - onChange( - { - detail: - schema.type === FieldType.ATTACHMENT_SINGLE || - schema.type === FieldType.SIGNATURE_SINGLE - ? e.detail.length > 0 - ? { - url: e.detail[0].name, - filename: e.detail[0].value, - } - : {} - : e.detail.map(({ name, value }) => ({ - url: name, - filename: value, - })), - }, - field - )} - object={handleAttachmentParams(value[field])} - allowJS - {bindings} - keyBindings - customButtonText={"Add attachment"} - keyPlaceholder={"URL"} - valuePlaceholder={"Filename"} - actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE || - schema.type === FieldType.SIGNATURE) && - Object.keys(value[field]).length >= 1} - /> +
+
+ handleToggleChange(field, e)} + /> +
+ {#if !useAttachmentBinding} +
+ { + onChange( + { + detail: + schema.type === FieldType.ATTACHMENT_SINGLE || + schema.type === FieldType.SIGNATURE_SINGLE + ? e.detail.length > 0 + ? { + url: e.detail[0].name, + filename: e.detail[0].value, + } + : {} + : e.detail.map(({ name, value }) => ({ + url: name, + filename: value, + })), + }, + field + ) + }} + object={handleAttachmentParams(value[field])} + allowJS + {bindings} + keyBindings + customButtonText={"Add attachment"} + keyPlaceholder={"URL"} + valuePlaceholder={"Filename"} + actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE || + schema.type === FieldType.SIGNATURE) && + Object.keys(value[field]).length >= 1} + /> +
+ {:else} +
+ onChange(e, field)} + type="string" + bindings={parsedBindings} + allowJS={true} + updateOnChange={false} + title={schema.name} + /> +
+ {/if}
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)} - .attachment-field-spacinng { + .attachment-field-spacing, + .json-input-spacing { margin-top: var(--spacing-s); margin-bottom: var(--spacing-l); } diff --git a/packages/server/src/api/controllers/row/external.ts b/packages/server/src/api/controllers/row/external.ts index e0e3cb6c18..d301155231 100644 --- a/packages/server/src/api/controllers/row/external.ts +++ b/packages/server/src/api/controllers/row/external.ts @@ -25,6 +25,7 @@ import { outputProcessing, } from "../../../utilities/rowProcessor" import { cloneDeep } from "lodash" +import { generateIdForRow } from "./utils" export async function handleRequest( operation: T, @@ -55,11 +56,19 @@ export async function patch(ctx: UserCtx) { throw { validation: validateResult.errors } } + const beforeRow = await sdk.rows.external.getRow(tableId, _id, { + relationships: true, + }) + const response = await handleRequest(Operation.UPDATE, tableId, { id: breakRowIdField(_id), row: dataToUpdate, }) - const row = await sdk.rows.external.getRow(tableId, _id, { + + // The id might have been changed, so the refetching would fail. Recalculating the id just in case + const updatedId = + generateIdForRow({ ...beforeRow, ...dataToUpdate }, table) || _id + const row = await sdk.rows.external.getRow(tableId, updatedId, { relationships: true, }) const enrichedRow = await outputProcessing(table, row, { diff --git a/packages/server/src/api/routes/tests/application.spec.ts b/packages/server/src/api/routes/tests/application.spec.ts index 7f89a5cac2..13b7451a7e 100644 --- a/packages/server/src/api/routes/tests/application.spec.ts +++ b/packages/server/src/api/routes/tests/application.spec.ts @@ -334,6 +334,12 @@ describe("/applications", () => { expect(events.app.deleted).toHaveBeenCalledTimes(1) expect(events.app.unpublished).toHaveBeenCalledTimes(1) }) + + it("should be able to delete an app after SQS_SEARCH_ENABLE has been set but app hasn't been migrated", async () => { + await config.withCoreEnv({ SQS_SEARCH_ENABLE: "true" }, async () => { + await config.api.application.delete(app.appId) + }) + }) }) describe("POST /api/applications/:appId/duplicate", () => { diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 4801ac4c55..ccfd3891c5 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -38,7 +38,7 @@ describe.each([ [DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)], [DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)], [DatabaseName.MARIADB, getDatasource(DatabaseName.MARIADB)], -])("/rows (%s)", (__, dsProvider) => { +])("/rows (%s)", (providerType, dsProvider) => { const isInternal = dsProvider === undefined const config = setup.getConfig() @@ -693,6 +693,49 @@ describe.each([ }) expect(resp.relationship.length).toBe(1) }) + + !isInternal && + // TODO: SQL is having issues creating composite keys + providerType !== DatabaseName.SQL_SERVER && + it("should support updating fields that are part of a composite key", async () => { + const tableRequest = saveTableRequest({ + primary: ["number", "string"], + schema: { + string: { + type: FieldType.STRING, + name: "string", + }, + number: { + type: FieldType.NUMBER, + name: "number", + }, + }, + }) + + delete tableRequest.schema.id + + const table = await config.api.table.save(tableRequest) + + const stringValue = generator.word() + const naturalValue = generator.integer({ min: 0, max: 1000 }) + + const existing = await config.api.row.save(table._id!, { + string: stringValue, + number: naturalValue, + }) + + expect(existing._id).toEqual(`%5B${naturalValue}%2C'${stringValue}'%5D`) + + const row = await config.api.row.patch(table._id!, { + _id: existing._id!, + _rev: existing._rev!, + tableId: table._id!, + string: stringValue, + number: 1500, + }) + + expect(row._id).toEqual(`%5B${"1500"}%2C'${stringValue}'%5D`) + }) }) describe("destroy", () => { diff --git a/packages/server/src/automations/automationUtils.ts b/packages/server/src/automations/automationUtils.ts index de6e1b3d88..5467e0757c 100644 --- a/packages/server/src/automations/automationUtils.ts +++ b/packages/server/src/automations/automationUtils.ts @@ -99,6 +99,15 @@ export function getError(err: any) { return typeof err !== "string" ? err.toString() : err } +export function guardAttachment(attachmentObject: any) { + if (!("url" in attachmentObject) || !("filename" in attachmentObject)) { + const providedKeys = Object.keys(attachmentObject).join(", ") + throw new Error( + `Attachments must have both "url" and "filename" keys. You have provided: ${providedKeys}` + ) + } +} + export async function sendAutomationAttachmentsToStorage( tableId: string, row: Row @@ -116,9 +125,15 @@ export async function sendAutomationAttachmentsToStorage( schema?.type === FieldType.ATTACHMENT_SINGLE || schema?.type === FieldType.SIGNATURE_SINGLE ) { + if (Array.isArray(value)) { + value.forEach(item => guardAttachment(item)) + } else { + guardAttachment(value) + } attachmentRows[prop] = value } } + for (const [prop, attachments] of Object.entries(attachmentRows)) { if (Array.isArray(attachments)) { if (attachments.length) { @@ -133,7 +148,6 @@ export async function sendAutomationAttachmentsToStorage( return row } - async function generateAttachmentRow(attachment: AutomationAttachment) { const prodAppId = context.getProdAppId() diff --git a/packages/server/src/automations/steps/createRow.ts b/packages/server/src/automations/steps/createRow.ts index 5b5084b465..c7f5fcff3b 100644 --- a/packages/server/src/automations/steps/createRow.ts +++ b/packages/server/src/automations/steps/createRow.ts @@ -90,7 +90,6 @@ export async function run({ inputs, appId, emitter }: AutomationStepInput) { tableId: inputs.row.tableId, }, }) - try { inputs.row = await cleanUpRow(inputs.row.tableId, inputs.row) inputs.row = await sendAutomationAttachmentsToStorage( diff --git a/packages/server/src/automations/steps/sendSmtpEmail.ts b/packages/server/src/automations/steps/sendSmtpEmail.ts index 31a7759dea..bcb1699c6b 100644 --- a/packages/server/src/automations/steps/sendSmtpEmail.ts +++ b/packages/server/src/automations/steps/sendSmtpEmail.ts @@ -118,6 +118,14 @@ export async function run({ inputs }: AutomationStepInput) { } to = to || undefined + if (attachments) { + if (Array.isArray(attachments)) { + attachments.forEach(item => automationUtils.guardAttachment(item)) + } else { + automationUtils.guardAttachment(attachments) + } + } + try { let response = await sendSmtpEmail({ to, diff --git a/packages/server/src/automations/tests/createRow.spec.ts b/packages/server/src/automations/tests/createRow.spec.ts index e78236c5ac..62e9e24f9e 100644 --- a/packages/server/src/automations/tests/createRow.spec.ts +++ b/packages/server/src/automations/tests/createRow.spec.ts @@ -128,4 +128,31 @@ describe("test the create row action", () => { expect(objectData).toBeDefined() expect(objectData.ContentLength).toBeGreaterThan(0) }) + + it("should check that attachment without the correct keys throws an error", async () => { + let attachmentTable = await config.createTable( + basicTableWithAttachmentField() + ) + + let attachmentRow: any = { + tableId: attachmentTable._id, + } + + let filename = "test2.txt" + let presignedUrl = await uploadTestFile(filename) + let attachmentObject = { + wrongKey: presignedUrl, + anotherWrongKey: filename, + } + + attachmentRow.single_file_attachment = attachmentObject + const res = await setup.runStep(setup.actions.CREATE_ROW.stepId, { + row: attachmentRow, + }) + + expect(res.success).toEqual(false) + expect(res.response).toEqual( + 'Error: Attachments must have both "url" and "filename" keys. You have provided: wrongKey, anotherWrongKey' + ) + }) })