1
0
Fork 0
mirror of synced 2024-10-03 19:43:32 +13:00

Merge pull request #13399 from Budibase/revert-13398-revert-13356-BUDI-8122/single-attachment-column-type

Single attachment column type
This commit is contained in:
Adria Navarro 2024-04-10 13:49:44 +02:00 committed by GitHub
commit 8c5b62eefe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 563 additions and 242 deletions

View file

@ -68,7 +68,7 @@
}
$: showDropzone =
(!maximum || (maximum && value?.length < maximum)) && !disabled
(!maximum || (maximum && (value?.length || 0) < maximum)) && !disabled
async function processFileList(fileList) {
if (

View file

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

View file

@ -394,7 +394,8 @@
FIELDS.BIGINT,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.ATTACHMENT,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.LINK,
FIELDS.FORMULA,
FIELDS.JSON,

View file

@ -1,6 +1,7 @@
<script>
import { FieldType, FieldSubtype } from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { DB_TYPE_INTERNAL, FIELDS } from "constants/backend"
import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api"
import { parseFile } from "./utils"
@ -23,43 +24,47 @@
const typeOptions = [
{
label: "Text",
value: FIELDS.STRING.type,
value: FieldType.STRING,
},
{
label: "Number",
value: FIELDS.NUMBER.type,
value: FieldType.NUMBER,
},
{
label: "Date",
value: FIELDS.DATETIME.type,
value: FieldType.DATETIME,
},
{
label: "Options",
value: FIELDS.OPTIONS.type,
value: FieldType.OPTIONS,
},
{
label: "Multi-select",
value: FIELDS.ARRAY.type,
value: FieldType.ARRAY.type,
},
{
label: "Barcode/QR",
value: FIELDS.BARCODEQR.type,
value: FieldType.BARCODEQR,
},
{
label: "Long Form Text",
value: FIELDS.LONGFORM.type,
value: FieldType.LONGFORM,
},
{
label: "Attachment",
value: FIELDS.ATTACHMENT.type,
value: FieldType.ATTACHMENT_SINGLE,
},
{
label: "Attachment list",
value: FieldType.ATTACHMENTS,
},
{
label: "User",
value: `${FIELDS.USER.type}${FIELDS.USER.subtype}`,
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USER}`,
},
{
label: "Users",
value: `${FIELDS.USERS.type}${FIELDS.USERS.subtype}`,
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USERS}`,
},
]

View file

@ -70,6 +70,7 @@ const componentMap = {
"field/longform": FormFieldSelect,
"field/datetime": FormFieldSelect,
"field/attachment": FormFieldSelect,
"field/attachment_single": FormFieldSelect,
"field/s3": Input,
"field/link": FormFieldSelect,
"field/array": FormFieldSelect,

View file

@ -41,7 +41,8 @@ export const FieldTypeToComponentMap = {
[FieldType.BOOLEAN]: "booleanfield",
[FieldType.LONGFORM]: "longformfield",
[FieldType.DATETIME]: "datetimefield",
[FieldType.ATTACHMENT]: "attachmentfield",
[FieldType.ATTACHMENTS]: "attachmentfield",
[FieldType.ATTACHMENT_SINGLE]: "attachmentsinglefield",
[FieldType.LINK]: "relationshipfield",
[FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner",

View file

@ -110,10 +110,18 @@ export const FIELDS = {
},
},
},
ATTACHMENT: {
ATTACHMENT_SINGLE: {
name: "Attachment",
type: FieldType.ATTACHMENT,
icon: TypeIconMap[FieldType.ATTACHMENT],
type: FieldType.ATTACHMENT_SINGLE,
icon: TypeIconMap[FieldType.ATTACHMENT_SINGLE],
constraints: {
presence: false,
},
},
ATTACHMENTS: {
name: "Attachment List",
type: FieldType.ATTACHMENTS,
icon: TypeIconMap[FieldType.ATTACHMENTS],
constraints: {
type: "array",
presence: false,
@ -302,7 +310,7 @@ export const PaginationLocations = [
export const BannedSearchTypes = [
FieldType.LINK,
FieldType.ATTACHMENT,
FieldType.ATTACHMENTS,
FieldType.FORMULA,
FieldType.JSON,
"jsonarray",

View file

@ -63,6 +63,7 @@
"optionsfield",
"booleanfield",
"longformfield",
"attachmentsinglefield",
"attachmentfield",
"jsonfield",
"relationshipfield",

View file

@ -1,9 +1,6 @@
{
"extends": "./tsconfig.build.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"sourceMap": true,
"baseUrl": ".",
"paths": {
"assets/*": ["./assets/*"],

View file

@ -4226,7 +4226,7 @@
]
},
"attachmentfield": {
"name": "Attachment",
"name": "Attachment list",
"icon": "Attach",
"styles": ["size"],
"requiredAncestors": ["form"],
@ -4322,6 +4322,97 @@
}
]
},
"attachmentsinglefield": {
"name": "Single Attachment",
"icon": "Attach",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
"size": {
"width": 400,
"height": 200
},
"settings": [
{
"type": "field/attachment_single",
"label": "Field",
"key": "field",
"required": true
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Help text",
"key": "helpText"
},
{
"type": "text",
"label": "Extensions",
"key": "extensions"
},
{
"type": "event",
"label": "On change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{
"type": "boolean",
"label": "Compact",
"key": "compact",
"defaultValue": false
},
{
"type": "boolean",
"label": "Read only",
"key": "disabled",
"defaultValue": false
},
{
"type": "validation/attachment",
"label": "Validation",
"key": "validation"
},
{
"type": "select",
"label": "Layout",
"key": "span",
"defaultValue": 6,
"hidden": true,
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "1 column",
"value": 6,
"barIcon": "Stop",
"barTitle": "1 column"
},
{
"label": "2 columns",
"value": 3,
"barIcon": "ColumnTwoA",
"barTitle": "2 columns"
},
{
"label": "3 columns",
"value": 2,
"barIcon": "ViewColumn",
"barTitle": "3 columns"
}
]
}
]
},
"relationshipfield": {
"name": "Relationship Picker",
"icon": "TaskList",

View file

@ -15,7 +15,8 @@
[FieldType.BOOLEAN]: "booleanfield",
[FieldType.LONGFORM]: "longformfield",
[FieldType.DATETIME]: "datetimefield",
[FieldType.ATTACHMENT]: "attachmentfield",
[FieldType.ATTACHMENTS]: "attachmentfield",
[FieldType.ATTACHMENT_SINGLE]: "attachmentsinglefield",
[FieldType.LINK]: "relationshipfield",
[FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner",
@ -60,7 +61,7 @@
function getPropsByType(field) {
const propsMapByType = {
[FieldType.ATTACHMENT]: (_field, schema) => {
[FieldType.ATTACHMENTS]: (_field, schema) => {
return {
maximum: schema?.constraints?.length?.maximum,
}

View file

@ -1,6 +1,7 @@
<script>
import Field from "./Field.svelte"
import { CoreDropzone } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import { getContext } from "svelte"
export let field
@ -14,6 +15,12 @@
export let maximum = undefined
export let span
export let helpText = null
export let type = FieldType.ATTACHMENTS
export let fieldApiMapper = {
get: value => value,
set: value => value,
}
export let defaultValue = []
let fieldState
let fieldApi
@ -63,9 +70,10 @@
}
const handleChange = e => {
const changed = fieldApi.setValue(e.detail)
const value = fieldApiMapper.set(e.detail)
const changed = fieldApi.setValue(value)
if (onChange && changed) {
onChange({ value: e.detail })
onChange({ value })
}
}
</script>
@ -78,14 +86,14 @@
{validation}
{span}
{helpText}
type="attachment"
{type}
bind:fieldState
bind:fieldApi
defaultValue={[]}
{defaultValue}
>
{#if fieldState}
<CoreDropzone
value={fieldState.value}
value={fieldApiMapper.get(fieldState.value)}
disabled={fieldState.disabled || fieldState.readonly}
error={fieldState.error}
on:change={handleChange}

View file

@ -0,0 +1,17 @@
<script>
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,
}
</script>
<AttachmentField
{...$$restProps}
type={FieldType.ATTACHMENT_SINGLE}
maximum={1}
defaultValue={null}
{fieldApiMapper}
/>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,22 @@
<script>
import AttachmentCell from "./AttachmentCell.svelte"
export let value
export let onChange
export let api
$: arrayValue = (!Array.isArray(value) && value ? [value] : value) || []
$: onFileChange = value => {
value = value[0] || null
onChange(value)
}
</script>
<AttachmentCell
bind:api
{...$$restProps}
maximum={1}
value={arrayValue}
onChange={onFileChange}
/>

View file

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

View file

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

@ -1 +1 @@
Subproject commit f8e8f87bd52081e1303a5ae92c432ea5b38f3bb4
Subproject commit ef186d00241f96037f9fd34d7a3826041977ab3a

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,7 +27,8 @@ const FieldTypeMap: Record<FieldType, SQLiteType> = {
[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,

View file

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

View file

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

View file

@ -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<T extends Row[] | Row>(
// 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<T extends Row[] | Row>(
}
})
}
} 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

View file

@ -106,7 +106,7 @@ export const TYPE_TRANSFORM_MAP: any = {
return date
},
},
[FieldType.ATTACHMENT]: {
[FieldType.ATTACHMENTS]: {
//@ts-ignore
[null]: [],
//@ts-ignore

View file

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

View file

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

View file

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

View file

@ -11,10 +11,10 @@ const allowDisplayColumnByType: Record<FieldType, boolean> = {
[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, boolean> = {
[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,

View file

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

View file

@ -112,10 +112,8 @@ export interface BBReferenceFieldMetadata
relationshipType?: RelationshipType
}
export interface AttachmentFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> {
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
}