diff --git a/packages/bbui/src/Form/Core/Dropzone.svelte b/packages/bbui/src/Form/Core/Dropzone.svelte index 678b7efa0e..c69bf0d6bb 100644 --- a/packages/bbui/src/Form/Core/Dropzone.svelte +++ b/packages/bbui/src/Form/Core/Dropzone.svelte @@ -68,7 +68,7 @@ } $: showDropzone = - (!maximum || (maximum && value?.length < maximum)) && !disabled + (!maximum || (maximum && (value?.length || 0) < maximum)) && !disabled async function processFileList(fileList) { if ( diff --git a/packages/builder/src/components/backend/DataTable/formula.js b/packages/builder/src/components/backend/DataTable/formula.js index e3da4249bc..b339729391 100644 --- a/packages/builder/src/components/backend/DataTable/formula.js +++ b/packages/builder/src/components/backend/DataTable/formula.js @@ -9,7 +9,7 @@ const MAX_DEPTH = 1 const TYPES_TO_SKIP = [ FieldType.FORMULA, FieldType.LONGFORM, - FieldType.ATTACHMENT, + FieldType.ATTACHMENTS, //https://github.com/Budibase/budibase/issues/3030 FieldType.INTERNAL, ] diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index cfc6e9a7be..92501bec3b 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -394,7 +394,8 @@ FIELDS.BIGINT, FIELDS.BOOLEAN, FIELDS.DATETIME, - FIELDS.ATTACHMENT, + FIELDS.ATTACHMENT_SINGLE, + FIELDS.ATTACHMENTS, FIELDS.LINK, FIELDS.FORMULA, FIELDS.JSON, diff --git a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte index efbfd26565..6901503071 100644 --- a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte +++ b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte @@ -1,6 +1,7 @@ @@ -78,14 +86,14 @@ {validation} {span} {helpText} - type="attachment" + {type} bind:fieldState bind:fieldApi - defaultValue={[]} + {defaultValue} > {#if fieldState} + import { FieldType } from "@budibase/types" + import AttachmentField from "./AttachmentField.svelte" + + const fieldApiMapper = { + get: value => (!Array.isArray(value) && value ? [value] : value) || [], + set: value => value[0] || null, + } + + + diff --git a/packages/client/src/components/app/forms/index.js b/packages/client/src/components/app/forms/index.js index 5804d3a79d..aa54204454 100644 --- a/packages/client/src/components/app/forms/index.js +++ b/packages/client/src/components/app/forms/index.js @@ -9,6 +9,7 @@ export { default as booleanfield } from "./BooleanField.svelte" export { default as longformfield } from "./LongFormField.svelte" export { default as datetimefield } from "./DateTimeField.svelte" export { default as attachmentfield } from "./AttachmentField.svelte" +export { default as attachmentsinglefield } from "./AttachmentSingleField.svelte" export { default as relationshipfield } from "./RelationshipField.svelte" export { default as passwordfield } from "./PasswordField.svelte" export { default as formstep } from "./FormStep.svelte" diff --git a/packages/client/src/components/app/forms/validation.js b/packages/client/src/components/app/forms/validation.js index 3b3a5d6e1d..cdedd85cf2 100644 --- a/packages/client/src/components/app/forms/validation.js +++ b/packages/client/src/components/app/forms/validation.js @@ -192,7 +192,7 @@ const parseType = (value, type) => { } // Parse attachments, treating no elements as null - if (type === FieldTypes.ATTACHMENT) { + if (type === FieldTypes.ATTACHMENTS) { if (!Array.isArray(value) || !value.length) { return null } diff --git a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte index a1f5c4f2aa..3a1f165b6e 100644 --- a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte @@ -10,6 +10,7 @@ export let invertX = false export let invertY = false export let schema + export let maximum const { API, notifications } = getContext("grid") const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"] @@ -98,7 +99,7 @@ {value} compact on:change={e => onChange(e.detail)} - maximum={schema.constraints?.length?.maximum} + maximum={maximum || schema.constraints?.length?.maximum} {processFiles} {deleteAttachments} {handleFileTooLarge} diff --git a/packages/frontend-core/src/components/grid/cells/AttachmentSingleCell.svelte b/packages/frontend-core/src/components/grid/cells/AttachmentSingleCell.svelte new file mode 100644 index 0000000000..e3e7891b7c --- /dev/null +++ b/packages/frontend-core/src/components/grid/cells/AttachmentSingleCell.svelte @@ -0,0 +1,22 @@ + + + diff --git a/packages/frontend-core/src/components/grid/lib/renderers.js b/packages/frontend-core/src/components/grid/lib/renderers.js index 19bf63312d..c3ee276ff9 100644 --- a/packages/frontend-core/src/components/grid/lib/renderers.js +++ b/packages/frontend-core/src/components/grid/lib/renderers.js @@ -11,6 +11,7 @@ import BooleanCell from "../cells/BooleanCell.svelte" import FormulaCell from "../cells/FormulaCell.svelte" import JSONCell from "../cells/JSONCell.svelte" import AttachmentCell from "../cells/AttachmentCell.svelte" +import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte" import BBReferenceCell from "../cells/BBReferenceCell.svelte" const TypeComponentMap = { @@ -22,7 +23,8 @@ const TypeComponentMap = { [FieldType.ARRAY]: MultiSelectCell, [FieldType.NUMBER]: NumberCell, [FieldType.BOOLEAN]: BooleanCell, - [FieldType.ATTACHMENT]: AttachmentCell, + [FieldType.ATTACHMENTS]: AttachmentCell, + [FieldType.ATTACHMENT_SINGLE]: AttachmentSingleCell, [FieldType.LINK]: RelationshipCell, [FieldType.FORMULA]: FormulaCell, [FieldType.JSON]: JSONCell, diff --git a/packages/frontend-core/src/constants.js b/packages/frontend-core/src/constants.js index 79b35bfb8e..95228c3bdc 100644 --- a/packages/frontend-core/src/constants.js +++ b/packages/frontend-core/src/constants.js @@ -124,7 +124,8 @@ export const TypeIconMap = { [FieldType.ARRAY]: "Duplicate", [FieldType.NUMBER]: "123", [FieldType.BOOLEAN]: "Boolean", - [FieldType.ATTACHMENT]: "AppleFiles", + [FieldType.ATTACHMENTS]: "Attach", + [FieldType.ATTACHMENT_SINGLE]: "Attach", [FieldType.LINK]: "DataCorrelated", [FieldType.FORMULA]: "Calculator", [FieldType.JSON]: "Brackets", diff --git a/packages/pro b/packages/pro index f8e8f87bd5..ef186d0024 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit f8e8f87bd52081e1303a5ae92c432ea5b38f3bb4 +Subproject commit ef186d00241f96037f9fd34d7a3826041977ab3a diff --git a/packages/server/src/api/controllers/table/utils.ts b/packages/server/src/api/controllers/table/utils.ts index c00a8c1bc2..f496c686f3 100644 --- a/packages/server/src/api/controllers/table/utils.ts +++ b/packages/server/src/api/controllers/table/utils.ts @@ -30,8 +30,6 @@ import { View, RelationshipFieldMetadata, FieldType, - FieldTypeSubtypes, - AttachmentFieldMetadata, } from "@budibase/types" import sdk from "../../../sdk" import env from "../../../environment" @@ -93,26 +91,6 @@ export async function checkForColumnUpdates( await checkForViewUpdates(updatedTable, deletedColumns, columnRename) } - const changedAttachmentSubtypeColumns = Object.values( - updatedTable.schema - ).filter( - (column): column is AttachmentFieldMetadata => - column.type === FieldType.ATTACHMENT && - column.subtype !== oldTable?.schema[column.name]?.subtype - ) - for (const attachmentColumn of changedAttachmentSubtypeColumns) { - if (attachmentColumn.subtype === FieldTypeSubtypes.ATTACHMENT.SINGLE) { - attachmentColumn.constraints ??= { length: {} } - attachmentColumn.constraints.length ??= {} - attachmentColumn.constraints.length.maximum = 1 - attachmentColumn.constraints.length.message = - "cannot contain multiple files" - } else { - delete attachmentColumn.constraints?.length?.maximum - delete attachmentColumn.constraints?.length?.message - } - } - return { rows: updatedRows, table: updatedTable } } diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index db10901367..8e1014f825 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -6,14 +6,17 @@ import * as setup from "./utilities" import { context, InternalTable, tenancy } from "@budibase/backend-core" import { quotas } from "@budibase/pro" import { + AttachmentFieldMetadata, AutoFieldSubType, Datasource, + DateFieldMetadata, DeleteRow, FieldSchema, FieldType, FieldTypeSubtypes, FormulaType, INTERNAL_TABLE_SOURCE_ID, + NumberFieldMetadata, QuotaUsageType, RelationshipType, Row, @@ -232,9 +235,14 @@ describe.each([ name: "str", constraints: { type: "string", presence: false }, } - const attachment: FieldSchema = { - type: FieldType.ATTACHMENT, - name: "attachment", + const singleAttachment: FieldSchema = { + type: FieldType.ATTACHMENT_SINGLE, + name: "single attachment", + constraints: { presence: false }, + } + const attachmentList: AttachmentFieldMetadata = { + type: FieldType.ATTACHMENTS, + name: "attachments", constraints: { type: "array", presence: false }, } const bool: FieldSchema = { @@ -242,12 +250,12 @@ describe.each([ name: "boolean", constraints: { type: "boolean", presence: false }, } - const number: FieldSchema = { + const number: NumberFieldMetadata = { type: FieldType.NUMBER, name: "str", constraints: { type: "number", presence: false }, } - const datetime: FieldSchema = { + const datetime: DateFieldMetadata = { type: FieldType.DATETIME, name: "datetime", constraints: { @@ -297,10 +305,12 @@ describe.each([ boolUndefined: bool, boolString: bool, boolBool: bool, - attachmentNull: attachment, - attachmentUndefined: attachment, - attachmentEmpty: attachment, - attachmentEmptyArrayStr: attachment, + singleAttachmentNull: singleAttachment, + singleAttachmentUndefined: singleAttachment, + attachmentListNull: attachmentList, + attachmentListUndefined: attachmentList, + attachmentListEmpty: attachmentList, + attachmentListEmptyArrayStr: attachmentList, arrayFieldEmptyArrayStr: arrayField, arrayFieldArrayStrKnown: arrayField, arrayFieldNull: arrayField, @@ -336,10 +346,12 @@ describe.each([ boolString: "true", boolBool: true, tableId: table._id, - attachmentNull: null, - attachmentUndefined: undefined, - attachmentEmpty: "", - attachmentEmptyArrayStr: "[]", + singleAttachmentNull: null, + singleAttachmentUndefined: undefined, + attachmentListNull: null, + attachmentListUndefined: undefined, + attachmentListEmpty: "", + attachmentListEmptyArrayStr: "[]", arrayFieldEmptyArrayStr: "[]", arrayFieldUndefined: undefined, arrayFieldNull: null, @@ -368,10 +380,12 @@ describe.each([ expect(row.boolUndefined).toBe(undefined) expect(row.boolString).toBe(true) expect(row.boolBool).toBe(true) - expect(row.attachmentNull).toEqual([]) - expect(row.attachmentUndefined).toBe(undefined) - expect(row.attachmentEmpty).toEqual([]) - expect(row.attachmentEmptyArrayStr).toEqual([]) + expect(row.singleAttachmentNull).toEqual(null) + expect(row.singleAttachmentUndefined).toBe(undefined) + expect(row.attachmentListNull).toEqual([]) + expect(row.attachmentListUndefined).toBe(undefined) + expect(row.attachmentListEmpty).toEqual([]) + expect(row.attachmentListEmptyArrayStr).toEqual([]) expect(row.arrayFieldEmptyArrayStr).toEqual([]) expect(row.arrayFieldNull).toEqual([]) expect(row.arrayFieldUndefined).toEqual(undefined) @@ -817,12 +831,44 @@ describe.each([ isInternal && describe("attachments", () => { - it("should allow enriching attachment rows", async () => { + it("should allow enriching single attachment rows", async () => { const table = await config.api.table.save( defaultTable({ schema: { attachment: { - type: FieldType.ATTACHMENT, + type: FieldType.ATTACHMENT_SINGLE, + name: "attachment", + constraints: { presence: false }, + }, + }, + }) + ) + const attachmentId = `${uuid.v4()}.csv` + const row = await config.api.row.save(table._id!, { + name: "test", + description: "test", + attachment: { + key: `${config.getAppId()}/attachments/${attachmentId}`, + }, + + tableId: table._id, + }) + await config.withEnv({ SELF_HOSTED: "true" }, async () => { + return context.doInAppContext(config.getAppId(), async () => { + const enriched = await outputProcessing(table, [row]) + expect((enriched as Row[])[0].attachment.url).toBe( + `/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` + ) + }) + }) + }) + + it("should allow enriching attachment list rows", async () => { + const table = await config.api.table.save( + defaultTable({ + schema: { + attachment: { + type: FieldType.ATTACHMENTS, name: "attachment", constraints: { type: "array", presence: false }, }, diff --git a/packages/server/src/db/defaultData/datasource_bb_default.ts b/packages/server/src/db/defaultData/datasource_bb_default.ts index 03aed3c118..68d49b2d8b 100644 --- a/packages/server/src/db/defaultData/datasource_bb_default.ts +++ b/packages/server/src/db/defaultData/datasource_bb_default.ts @@ -299,7 +299,7 @@ export const DEFAULT_EMPLOYEE_TABLE_SCHEMA: Table = { sortable: false, }, "Badge Photo": { - type: FieldType.ATTACHMENT, + type: FieldType.ATTACHMENTS, constraints: { type: FieldType.ARRAY, presence: false, @@ -607,7 +607,7 @@ export const DEFAULT_EXPENSES_TABLE_SCHEMA: Table = { ignoreTimezones: true, }, Attachment: { - type: FieldType.ATTACHMENT, + type: FieldType.ATTACHMENTS, constraints: { type: FieldType.ARRAY, presence: false, diff --git a/packages/server/src/sdk/app/backups/imports.ts b/packages/server/src/sdk/app/backups/imports.ts index 977dea5b63..1c08c8c3bf 100644 --- a/packages/server/src/sdk/app/backups/imports.ts +++ b/packages/server/src/sdk/app/backups/imports.ts @@ -5,6 +5,7 @@ import { Automation, AutomationTriggerStepId, RowAttachment, + FieldType, } from "@budibase/types" import { getAutomationParams } from "../../../db/utils" import { budibaseTempDir } from "../../../utilities/budibaseDir" @@ -58,10 +59,19 @@ export async function updateAttachmentColumns(prodAppId: string, db: Database) { updatedRows = updatedRows.concat( rows.map(row => { for (let column of columns) { - if (Array.isArray(row[column])) { + const columnType = table.schema[column].type + if ( + columnType === FieldType.ATTACHMENTS && + Array.isArray(row[column]) + ) { row[column] = row[column].map((attachment: RowAttachment) => rewriteAttachmentUrl(prodAppId, attachment) ) + } else if ( + columnType === FieldType.ATTACHMENT_SINGLE && + row[column] + ) { + row[column] = rewriteAttachmentUrl(prodAppId, row[column]) } } return row diff --git a/packages/server/src/sdk/app/rows/attachments.ts b/packages/server/src/sdk/app/rows/attachments.ts index 2ab9e83c47..ee816ea1fc 100644 --- a/packages/server/src/sdk/app/rows/attachments.ts +++ b/packages/server/src/sdk/app/rows/attachments.ts @@ -30,7 +30,10 @@ export async function getRowsWithAttachments(appId: string, table: Table) { const db = dbCore.getDB(appId) const attachmentCols: string[] = [] for (let [key, column] of Object.entries(table.schema)) { - if (column.type === FieldType.ATTACHMENT) { + if ( + column.type === FieldType.ATTACHMENTS || + column.type === FieldType.ATTACHMENT_SINGLE + ) { attachmentCols.push(key) } } diff --git a/packages/server/src/sdk/app/rows/utils.ts b/packages/server/src/sdk/app/rows/utils.ts index b7adf7131c..d307b17947 100644 --- a/packages/server/src/sdk/app/rows/utils.ts +++ b/packages/server/src/sdk/app/rows/utils.ts @@ -175,13 +175,13 @@ export async function validate({ errors[fieldName] = [`${fieldName} is required`] } } else if ( - (type === FieldType.ATTACHMENT || type === FieldType.JSON) && + (type === FieldType.ATTACHMENTS || type === FieldType.JSON) && typeof row[fieldName] === "string" ) { // this should only happen if there is an error try { const json = JSON.parse(row[fieldName]) - if (type === FieldType.ATTACHMENT) { + if (type === FieldType.ATTACHMENTS) { if (Array.isArray(json)) { row[fieldName] = json } else { diff --git a/packages/server/src/sdk/app/tables/internal/sqs.ts b/packages/server/src/sdk/app/tables/internal/sqs.ts index da947c62c2..79d9be2348 100644 --- a/packages/server/src/sdk/app/tables/internal/sqs.ts +++ b/packages/server/src/sdk/app/tables/internal/sqs.ts @@ -27,7 +27,8 @@ const FieldTypeMap: Record = { [FieldType.JSON]: SQLiteType.BLOB, [FieldType.INTERNAL]: SQLiteType.BLOB, [FieldType.BARCODEQR]: SQLiteType.BLOB, - [FieldType.ATTACHMENT]: SQLiteType.BLOB, + [FieldType.ATTACHMENTS]: SQLiteType.BLOB, + [FieldType.ATTACHMENT_SINGLE]: SQLiteType.BLOB, [FieldType.ARRAY]: SQLiteType.BLOB, [FieldType.LINK]: SQLiteType.BLOB, [FieldType.BIGINT]: SQLiteType.REAL, diff --git a/packages/server/src/sdk/tests/attachments.spec.ts b/packages/server/src/sdk/tests/attachments.spec.ts index c1736e6f8e..46165f68fc 100644 --- a/packages/server/src/sdk/tests/attachments.spec.ts +++ b/packages/server/src/sdk/tests/attachments.spec.ts @@ -31,9 +31,13 @@ describe("should be able to re-write attachment URLs", () => { sourceType: TableSourceType.INTERNAL, schema: { photo: { - type: FieldType.ATTACHMENT, + type: FieldType.ATTACHMENT_SINGLE, name: "photo", }, + gallery: { + type: FieldType.ATTACHMENTS, + name: "gallery", + }, otherCol: { type: FieldType.STRING, name: "otherCol", @@ -43,7 +47,8 @@ describe("should be able to re-write attachment URLs", () => { for (let i = 0; i < FIND_LIMIT * 4; i++) { await config.api.row.save(table._id!, { - photo: [attachment], + photo: { ...attachment }, + gallery: [{ ...attachment }, { ...attachment }], otherCol: "string", }) } @@ -56,8 +61,12 @@ describe("should be able to re-write attachment URLs", () => { ) for (const row of rows) { expect(row.otherCol).toBe("string") - expect(row.photo[0].url).toBe("") - expect(row.photo[0].key).toBe(`${db.name}/attachments/a.png`) + expect(row.photo.url).toBe("") + expect(row.photo.key).toBe(`${db.name}/attachments/a.png`) + expect(row.gallery[0].url).toBe("") + expect(row.gallery[0].key).toBe(`${db.name}/attachments/a.png`) + expect(row.gallery[1].url).toBe("") + expect(row.gallery[1].key).toBe(`${db.name}/attachments/a.png`) } }) }) diff --git a/packages/server/src/utilities/rowProcessor/attachments.ts b/packages/server/src/utilities/rowProcessor/attachments.ts index e1c83352d4..da52d6a631 100644 --- a/packages/server/src/utilities/rowProcessor/attachments.ts +++ b/packages/server/src/utilities/rowProcessor/attachments.ts @@ -1,12 +1,6 @@ import { ObjectStoreBuckets } from "../../constants" import { context, db as dbCore, objectStore } from "@budibase/backend-core" -import { - FieldType, - RenameColumn, - Row, - RowAttachment, - Table, -} from "@budibase/types" +import { FieldType, RenameColumn, Row, Table } from "@budibase/types" export class AttachmentCleanup { static async coreCleanup(fileListFn: () => string[]): Promise { @@ -25,6 +19,27 @@ export class AttachmentCleanup { } } + private static extractAttachmentKeys( + type: FieldType, + rowData: any + ): string[] { + if ( + type !== FieldType.ATTACHMENTS && + type !== FieldType.ATTACHMENT_SINGLE + ) { + return [] + } + + if (!rowData) { + return [] + } + + if (type === FieldType.ATTACHMENTS) { + return rowData.map((attachment: any) => attachment.key) + } + return [rowData.key] + } + private static async tableChange( table: Table, rows: Row[], @@ -34,16 +49,20 @@ export class AttachmentCleanup { let files: string[] = [] const tableSchema = opts.oldTable?.schema || table.schema for (let [key, schema] of Object.entries(tableSchema)) { - if (schema.type !== FieldType.ATTACHMENT) { + if ( + schema.type !== FieldType.ATTACHMENTS && + schema.type !== FieldType.ATTACHMENT_SINGLE + ) { continue } + const columnRemoved = opts.oldTable && !table.schema[key] const renaming = opts.rename?.old === key // old table had this column, new table doesn't - delete it if ((columnRemoved && !renaming) || opts.deleting) { rows.forEach(row => { files = files.concat( - (row[key] || []).map((attachment: any) => attachment.key) + AttachmentCleanup.extractAttachmentKeys(schema.type, row[key]) ) }) } @@ -68,15 +87,15 @@ export class AttachmentCleanup { return AttachmentCleanup.coreCleanup(() => { let files: string[] = [] for (let [key, schema] of Object.entries(table.schema)) { - if (schema.type !== FieldType.ATTACHMENT) { + if ( + schema.type !== FieldType.ATTACHMENTS && + schema.type !== FieldType.ATTACHMENT_SINGLE + ) { continue } rows.forEach(row => { - if (!Array.isArray(row[key])) { - return - } files = files.concat( - row[key].map((attachment: any) => attachment.key) + AttachmentCleanup.extractAttachmentKeys(schema.type, row[key]) ) }) } @@ -88,16 +107,21 @@ export class AttachmentCleanup { return AttachmentCleanup.coreCleanup(() => { let files: string[] = [] for (let [key, schema] of Object.entries(table.schema)) { - if (schema.type !== FieldType.ATTACHMENT) { + if ( + schema.type !== FieldType.ATTACHMENTS && + schema.type !== FieldType.ATTACHMENT_SINGLE + ) { continue } - const oldKeys = - opts.oldRow[key]?.map( - (attachment: RowAttachment) => attachment.key - ) || [] - const newKeys = - opts.row[key]?.map((attachment: RowAttachment) => attachment.key) || - [] + + const oldKeys = AttachmentCleanup.extractAttachmentKeys( + schema.type, + opts.oldRow[key] + ) + const newKeys = AttachmentCleanup.extractAttachmentKeys( + schema.type, + opts.row[key] + ) files = files.concat( oldKeys.filter((key: string) => newKeys.indexOf(key) === -1) ) diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index 0015680e77..e69cfa471a 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -148,13 +148,18 @@ export async function inputProcessing( } // remove any attachment urls, they are generated on read - if (field.type === FieldType.ATTACHMENT) { + if (field.type === FieldType.ATTACHMENTS) { const attachments = clonedRow[key] if (attachments?.length) { attachments.forEach((attachment: RowAttachment) => { delete attachment.url }) } + } else if (field.type === FieldType.ATTACHMENT_SINGLE) { + const attachment = clonedRow[key] + if (attachment?.url) { + delete clonedRow[key].url + } } if (field.type === FieldType.BB_REFERENCE && value) { @@ -216,7 +221,7 @@ export async function outputProcessing( // process complex types: attachements, bb references... for (let [property, column] of Object.entries(table.schema)) { - if (column.type === FieldType.ATTACHMENT) { + if (column.type === FieldType.ATTACHMENTS) { for (let row of enriched) { if (row[property] == null || !Array.isArray(row[property])) { continue @@ -227,6 +232,16 @@ export async function outputProcessing( } }) } + } else if (column.type === FieldType.ATTACHMENT_SINGLE) { + for (let row of enriched) { + if (!row[property]) { + continue + } + + if (!row[property].url) { + row[property].url = objectStore.getAppFileUrl(row[property].key) + } + } } else if ( !opts.skipBBReferences && column.type == FieldType.BB_REFERENCE diff --git a/packages/server/src/utilities/rowProcessor/map.ts b/packages/server/src/utilities/rowProcessor/map.ts index 60fe5a001b..2e0ac9efe1 100644 --- a/packages/server/src/utilities/rowProcessor/map.ts +++ b/packages/server/src/utilities/rowProcessor/map.ts @@ -106,7 +106,7 @@ export const TYPE_TRANSFORM_MAP: any = { return date }, }, - [FieldType.ATTACHMENT]: { + [FieldType.ATTACHMENTS]: { //@ts-ignore [null]: [], //@ts-ignore diff --git a/packages/server/src/utilities/rowProcessor/tests/attachments.spec.ts b/packages/server/src/utilities/rowProcessor/tests/attachments.spec.ts index cefea7e504..3ef8c71afc 100644 --- a/packages/server/src/utilities/rowProcessor/tests/attachments.spec.ts +++ b/packages/server/src/utilities/rowProcessor/tests/attachments.spec.ts @@ -25,121 +25,155 @@ const mockedDeleteFiles = objectStore.deleteFiles as jest.MockedFunction< typeof objectStore.deleteFiles > -function table(): Table { - return { - name: "table", - sourceId: DEFAULT_BB_DATASOURCE_ID, - sourceType: TableSourceType.INTERNAL, - type: "table", - schema: { - attach: { - name: "attach", - type: FieldType.ATTACHMENT, - constraints: {}, - }, +const rowGenerators: [ + string, + FieldType.ATTACHMENT_SINGLE | FieldType.ATTACHMENTS, + (fileKey?: string) => Row +][] = [ + [ + "row with a attachment list column", + FieldType.ATTACHMENTS, + function rowWithAttachments(fileKey: string = FILE_NAME): Row { + return { + attach: [ + { + size: 1, + extension: "jpg", + key: fileKey, + }, + ], + } }, - } -} - -function row(fileKey: string = FILE_NAME): Row { - return { - attach: [ - { - size: 1, - extension: "jpg", - key: fileKey, - }, - ], - } -} - -describe("attachment cleanup", () => { - beforeEach(() => { - mockedDeleteFiles.mockClear() - }) - - it("should be able to cleanup a table update", async () => { - const originalTable = table() - delete originalTable.schema["attach"] - await AttachmentCleanup.tableUpdate(originalTable, [row()], { - oldTable: table(), - }) - expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) - }) - - it("should be able to cleanup a table deletion", async () => { - await AttachmentCleanup.tableDelete(table(), [row()]) - expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) - }) - - it("should handle table column renaming", async () => { - const updatedTable = table() - updatedTable.schema.attach2 = updatedTable.schema.attach - delete updatedTable.schema.attach - await AttachmentCleanup.tableUpdate(updatedTable, [row()], { - oldTable: table(), - rename: { old: "attach", updated: "attach2" }, - }) - expect(mockedDeleteFiles).not.toHaveBeenCalled() - }) - - it("shouldn't cleanup if no table changes", async () => { - await AttachmentCleanup.tableUpdate(table(), [row()], { oldTable: table() }) - expect(mockedDeleteFiles).not.toHaveBeenCalled() - }) - - it("should handle row updates", async () => { - const updatedRow = row() - delete updatedRow.attach - await AttachmentCleanup.rowUpdate(table(), { - row: updatedRow, - oldRow: row(), - }) - expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) - }) - - it("should handle row deletion", async () => { - await AttachmentCleanup.rowDelete(table(), [row()]) - expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) - }) - - it("should handle row deletion and not throw when attachments are undefined", async () => { - await AttachmentCleanup.rowDelete(table(), [ - { - attach: undefined, - }, - ]) - }) - - it("shouldn't cleanup attachments if row not updated", async () => { - await AttachmentCleanup.rowUpdate(table(), { row: row(), oldRow: row() }) - expect(mockedDeleteFiles).not.toHaveBeenCalled() - }) - - it("should be able to cleanup a column and not throw when attachments are undefined", async () => { - const originalTable = table() - delete originalTable.schema["attach"] - await AttachmentCleanup.tableUpdate( - originalTable, - [row("file 1"), { attach: undefined }, row("file 2")], - { - oldTable: table(), + ], + [ + "row with a single attachment column", + FieldType.ATTACHMENT_SINGLE, + function rowWithAttachments(fileKey: string = FILE_NAME): Row { + return { + attach: { + size: 1, + extension: "jpg", + key: fileKey, + }, } - ) - expect(mockedDeleteFiles).toHaveBeenCalledTimes(1) - expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, ["file 1", "file 2"]) - }) + }, + ], +] - it("should be able to cleanup a column and not throw when ALL attachments are undefined", async () => { - const originalTable = table() - delete originalTable.schema["attach"] - await AttachmentCleanup.tableUpdate( - originalTable, - [{}, { attach: undefined }], - { - oldTable: table(), +describe.each(rowGenerators)( + "attachment cleanup", + (_, attachmentFieldType, rowGenerator) => { + function tableGenerator(): Table { + return { + name: "table", + sourceId: DEFAULT_BB_DATASOURCE_ID, + sourceType: TableSourceType.INTERNAL, + type: "table", + schema: { + attach: { + name: "attach", + type: attachmentFieldType, + constraints: {}, + }, + }, } - ) - expect(mockedDeleteFiles).not.toHaveBeenCalled() - }) -}) + } + + beforeEach(() => { + mockedDeleteFiles.mockClear() + }) + + it("should be able to cleanup a table update", async () => { + const originalTable = tableGenerator() + delete originalTable.schema["attach"] + await AttachmentCleanup.tableUpdate(originalTable, [rowGenerator()], { + oldTable: tableGenerator(), + }) + expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) + }) + + it("should be able to cleanup a table deletion", async () => { + await AttachmentCleanup.tableDelete(tableGenerator(), [rowGenerator()]) + expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) + }) + + it("should handle table column renaming", async () => { + const updatedTable = tableGenerator() + updatedTable.schema.attach2 = updatedTable.schema.attach + delete updatedTable.schema.attach + await AttachmentCleanup.tableUpdate(updatedTable, [rowGenerator()], { + oldTable: tableGenerator(), + rename: { old: "attach", updated: "attach2" }, + }) + expect(mockedDeleteFiles).not.toHaveBeenCalled() + }) + + it("shouldn't cleanup if no table changes", async () => { + await AttachmentCleanup.tableUpdate(tableGenerator(), [rowGenerator()], { + oldTable: tableGenerator(), + }) + expect(mockedDeleteFiles).not.toHaveBeenCalled() + }) + + it("should handle row updates", async () => { + const updatedRow = rowGenerator() + delete updatedRow.attach + await AttachmentCleanup.rowUpdate(tableGenerator(), { + row: updatedRow, + oldRow: rowGenerator(), + }) + expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) + }) + + it("should handle row deletion", async () => { + await AttachmentCleanup.rowDelete(tableGenerator(), [rowGenerator()]) + expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [FILE_NAME]) + }) + + it("should handle row deletion and not throw when attachments are undefined", async () => { + await AttachmentCleanup.rowDelete(tableGenerator(), [ + { + multipleAttachments: undefined, + }, + ]) + }) + + it("shouldn't cleanup attachments if row not updated", async () => { + await AttachmentCleanup.rowUpdate(tableGenerator(), { + row: rowGenerator(), + oldRow: rowGenerator(), + }) + expect(mockedDeleteFiles).not.toHaveBeenCalled() + }) + + it("should be able to cleanup a column and not throw when attachments are undefined", async () => { + const originalTable = tableGenerator() + delete originalTable.schema["attach"] + await AttachmentCleanup.tableUpdate( + originalTable, + [rowGenerator("file 1"), { attach: undefined }, rowGenerator("file 2")], + { + oldTable: tableGenerator(), + } + ) + expect(mockedDeleteFiles).toHaveBeenCalledTimes(1) + expect(mockedDeleteFiles).toHaveBeenCalledWith(BUCKET, [ + "file 1", + "file 2", + ]) + }) + + it("should be able to cleanup a column and not throw when ALL attachments are undefined", async () => { + const originalTable = tableGenerator() + delete originalTable.schema["attach"] + await AttachmentCleanup.tableUpdate( + originalTable, + [{}, { attach: undefined }], + { + oldTable: tableGenerator(), + } + ) + expect(mockedDeleteFiles).not.toHaveBeenCalled() + }) + } +) diff --git a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts index a17bd5f393..74d55aff36 100644 --- a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts +++ b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts @@ -73,7 +73,7 @@ describe("rowProcessor - outputProcessing", () => { ) }) - it("should handle attachments correctly", async () => { + it("should handle attachment list correctly", async () => { const table: Table = { _id: generator.guid(), name: "TestTable", @@ -82,7 +82,7 @@ describe("rowProcessor - outputProcessing", () => { sourceType: TableSourceType.INTERNAL, schema: { attach: { - type: FieldType.ATTACHMENT, + type: FieldType.ATTACHMENTS, name: "attach", constraints: {}, }, @@ -116,6 +116,47 @@ describe("rowProcessor - outputProcessing", () => { expect(output3.attach[0].url).toBe("aaaa") }) + it("should handle single attachment correctly", async () => { + const table: Table = { + _id: generator.guid(), + name: "TestTable", + type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + schema: { + attach: { + type: FieldType.ATTACHMENT_SINGLE, + name: "attach", + constraints: {}, + }, + }, + } + + const row: { attach: RowAttachment } = { + attach: { + size: 10, + name: "test", + extension: "jpg", + key: "test.jpg", + }, + } + + const output = await outputProcessing(table, row, { squash: false }) + expect(output.attach.url).toBe( + "/files/signed/prod-budi-app-assets/test.jpg" + ) + + row.attach.url = "" + const output2 = await outputProcessing(table, row, { squash: false }) + expect(output2.attach.url).toBe( + "/files/signed/prod-budi-app-assets/test.jpg" + ) + + row.attach.url = "aaaa" + const output3 = await outputProcessing(table, row, { squash: false }) + expect(output3.attach.url).toBe("aaaa") + }) + it("process output even when the field is not empty", async () => { const table: Table = { _id: generator.guid(), diff --git a/packages/server/src/utilities/schema.ts b/packages/server/src/utilities/schema.ts index 34113759ed..4c7f0b7423 100644 --- a/packages/server/src/utilities/schema.ts +++ b/packages/server/src/utilities/schema.ts @@ -147,6 +147,12 @@ export function parse(rows: Rows, schema: TableSchema): Rows { utils.unreachable(columnSubtype) } } + } else if ( + (columnType === FieldType.ATTACHMENTS || + columnType === FieldType.ATTACHMENT_SINGLE) && + typeof columnData === "string" + ) { + parsedRow[columnName] = parseCsvExport(columnData) } else { parsedRow[columnName] = columnData } diff --git a/packages/shared-core/src/table.ts b/packages/shared-core/src/table.ts index 5eab2fc340..26a7e77cd0 100644 --- a/packages/shared-core/src/table.ts +++ b/packages/shared-core/src/table.ts @@ -11,10 +11,10 @@ const allowDisplayColumnByType: Record = { [FieldType.INTERNAL]: true, [FieldType.BARCODEQR]: true, [FieldType.BIGINT]: true, - [FieldType.BOOLEAN]: false, [FieldType.ARRAY]: false, - [FieldType.ATTACHMENT]: false, + [FieldType.ATTACHMENTS]: false, + [FieldType.ATTACHMENT_SINGLE]: false, [FieldType.LINK]: false, [FieldType.JSON]: false, [FieldType.BB_REFERENCE]: false, @@ -34,7 +34,8 @@ const allowSortColumnByType: Record = { [FieldType.JSON]: true, [FieldType.FORMULA]: false, - [FieldType.ATTACHMENT]: false, + [FieldType.ATTACHMENTS]: false, + [FieldType.ATTACHMENT_SINGLE]: false, [FieldType.ARRAY]: false, [FieldType.LINK]: false, [FieldType.BB_REFERENCE]: false, diff --git a/packages/types/src/documents/app/row.ts b/packages/types/src/documents/app/row.ts index aa8f50d4a8..222c346591 100644 --- a/packages/types/src/documents/app/row.ts +++ b/packages/types/src/documents/app/row.ts @@ -8,7 +8,8 @@ export enum FieldType { BOOLEAN = "boolean", ARRAY = "array", DATETIME = "datetime", - ATTACHMENT = "attachment", + ATTACHMENTS = "attachment", + ATTACHMENT_SINGLE = "attachment_single", LINK = "link", FORMULA = "formula", AUTO = "auto", @@ -38,7 +39,6 @@ export interface Row extends Document { export enum FieldSubtype { USER = "user", USERS = "users", - SINGLE = "single", } // The 'as' are required for typescript not to type the outputs as generic FieldSubtype @@ -47,7 +47,4 @@ export const FieldTypeSubtypes = { USER: FieldSubtype.USER as FieldSubtype.USER, USERS: FieldSubtype.USERS as FieldSubtype.USERS, }, - ATTACHMENT: { - SINGLE: FieldSubtype.SINGLE as FieldSubtype.SINGLE, - }, } diff --git a/packages/types/src/documents/app/table/schema.ts b/packages/types/src/documents/app/table/schema.ts index 45e39268ac..86c34b6a5c 100644 --- a/packages/types/src/documents/app/table/schema.ts +++ b/packages/types/src/documents/app/table/schema.ts @@ -112,10 +112,8 @@ export interface BBReferenceFieldMetadata relationshipType?: RelationshipType } -export interface AttachmentFieldMetadata - extends Omit { - type: FieldType.ATTACHMENT - subtype?: FieldSubtype.SINGLE +export interface AttachmentFieldMetadata extends BaseFieldSchema { + type: FieldType.ATTACHMENTS } export interface FieldConstraints { @@ -164,7 +162,7 @@ interface OtherFieldMetadata extends BaseFieldSchema { | FieldType.NUMBER | FieldType.LONGFORM | FieldType.BB_REFERENCE - | FieldType.ATTACHMENT + | FieldType.ATTACHMENTS > } @@ -217,5 +215,5 @@ export function isBBReferenceField( export function isAttachmentField( field: FieldSchema ): field is AttachmentFieldMetadata { - return field.type === FieldType.ATTACHMENT + return field.type === FieldType.ATTACHMENTS }