Merge branch 'master' of github.com:Budibase/budibase into feature/sqs-arm-support

This commit is contained in:
mike12345567 2024-04-10 13:07:39 +01:00
commit 5ea07146cb
42 changed files with 845 additions and 305 deletions

View File

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

View File

@ -9,7 +9,7 @@ const MAX_DEPTH = 1
const TYPES_TO_SKIP = [ const TYPES_TO_SKIP = [
FieldType.FORMULA, FieldType.FORMULA,
FieldType.LONGFORM, FieldType.LONGFORM,
FieldType.ATTACHMENT, FieldType.ATTACHMENTS,
//https://github.com/Budibase/budibase/issues/3030 //https://github.com/Budibase/budibase/issues/3030
FieldType.INTERNAL, FieldType.INTERNAL,
] ]

View File

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

View File

@ -1,6 +1,7 @@
<script> <script>
import { FieldType, FieldSubtype } from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui" 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 { API } from "api"
import { parseFile } from "./utils" import { parseFile } from "./utils"
@ -23,43 +24,47 @@
const typeOptions = [ const typeOptions = [
{ {
label: "Text", label: "Text",
value: FIELDS.STRING.type, value: FieldType.STRING,
}, },
{ {
label: "Number", label: "Number",
value: FIELDS.NUMBER.type, value: FieldType.NUMBER,
}, },
{ {
label: "Date", label: "Date",
value: FIELDS.DATETIME.type, value: FieldType.DATETIME,
}, },
{ {
label: "Options", label: "Options",
value: FIELDS.OPTIONS.type, value: FieldType.OPTIONS,
}, },
{ {
label: "Multi-select", label: "Multi-select",
value: FIELDS.ARRAY.type, value: FieldType.ARRAY.type,
}, },
{ {
label: "Barcode/QR", label: "Barcode/QR",
value: FIELDS.BARCODEQR.type, value: FieldType.BARCODEQR,
}, },
{ {
label: "Long Form Text", label: "Long Form Text",
value: FIELDS.LONGFORM.type, value: FieldType.LONGFORM,
}, },
{ {
label: "Attachment", label: "Attachment",
value: FIELDS.ATTACHMENT.type, value: FieldType.ATTACHMENT_SINGLE,
},
{
label: "Attachment list",
value: FieldType.ATTACHMENTS,
}, },
{ {
label: "User", label: "User",
value: `${FIELDS.USER.type}${FIELDS.USER.subtype}`, value: `${FieldType.BB_REFERENCE}${FieldSubtype.USER}`,
}, },
{ {
label: "Users", 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/longform": FormFieldSelect,
"field/datetime": FormFieldSelect, "field/datetime": FormFieldSelect,
"field/attachment": FormFieldSelect, "field/attachment": FormFieldSelect,
"field/attachment_single": FormFieldSelect,
"field/s3": Input, "field/s3": Input,
"field/link": FormFieldSelect, "field/link": FormFieldSelect,
"field/array": FormFieldSelect, "field/array": FormFieldSelect,

View File

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

View File

@ -34,7 +34,7 @@
$selectedScreen, $selectedScreen,
datasource datasource
)?.table?.primaryDisplay )?.table?.primaryDisplay
$: schema = getSchema(selectedScreen, datasource) $: schema = getSchema($selectedScreen, datasource)
$: columns = getColumns({ $: columns = getColumns({
columns: value, columns: value,
schema, schema,

View File

@ -5,6 +5,9 @@ import {
AutoFieldSubType, AutoFieldSubType,
Hosting, Hosting,
} from "@budibase/types" } from "@budibase/types"
import { Constants } from "@budibase/frontend-core"
const { TypeIconMap } = Constants
export { RelationshipType } from "@budibase/types" export { RelationshipType } from "@budibase/types"
@ -22,7 +25,7 @@ export const FIELDS = {
STRING: { STRING: {
name: "Text", name: "Text",
type: FieldType.STRING, type: FieldType.STRING,
icon: "Text", icon: TypeIconMap[FieldType.STRING],
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -32,7 +35,7 @@ export const FIELDS = {
BARCODEQR: { BARCODEQR: {
name: "Barcode/QR", name: "Barcode/QR",
type: FieldType.BARCODEQR, type: FieldType.BARCODEQR,
icon: "Camera", icon: TypeIconMap[FieldType.BARCODEQR],
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -42,7 +45,7 @@ export const FIELDS = {
LONGFORM: { LONGFORM: {
name: "Long Form Text", name: "Long Form Text",
type: FieldType.LONGFORM, type: FieldType.LONGFORM,
icon: "TextAlignLeft", icon: TypeIconMap[FieldType.LONGFORM],
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -52,7 +55,7 @@ export const FIELDS = {
OPTIONS: { OPTIONS: {
name: "Options", name: "Options",
type: FieldType.OPTIONS, type: FieldType.OPTIONS,
icon: "Dropdown", icon: TypeIconMap[FieldType.OPTIONS],
constraints: { constraints: {
type: "string", type: "string",
presence: false, presence: false,
@ -62,7 +65,7 @@ export const FIELDS = {
ARRAY: { ARRAY: {
name: "Multi-select", name: "Multi-select",
type: FieldType.ARRAY, type: FieldType.ARRAY,
icon: "Duplicate", icon: TypeIconMap[FieldType.ARRAY],
constraints: { constraints: {
type: "array", type: "array",
presence: false, presence: false,
@ -72,7 +75,7 @@ export const FIELDS = {
NUMBER: { NUMBER: {
name: "Number", name: "Number",
type: FieldType.NUMBER, type: FieldType.NUMBER,
icon: "123", icon: TypeIconMap[FieldType.NUMBER],
constraints: { constraints: {
type: "number", type: "number",
presence: false, presence: false,
@ -82,12 +85,12 @@ export const FIELDS = {
BIGINT: { BIGINT: {
name: "BigInt", name: "BigInt",
type: FieldType.BIGINT, type: FieldType.BIGINT,
icon: "TagBold", icon: TypeIconMap[FieldType.BIGINT],
}, },
BOOLEAN: { BOOLEAN: {
name: "Boolean", name: "Boolean",
type: FieldType.BOOLEAN, type: FieldType.BOOLEAN,
icon: "Boolean", icon: TypeIconMap[FieldType.BOOLEAN],
constraints: { constraints: {
type: "boolean", type: "boolean",
presence: false, presence: false,
@ -96,7 +99,7 @@ export const FIELDS = {
DATETIME: { DATETIME: {
name: "Date/Time", name: "Date/Time",
type: FieldType.DATETIME, type: FieldType.DATETIME,
icon: "Calendar", icon: TypeIconMap[FieldType.DATETIME],
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -107,10 +110,18 @@ export const FIELDS = {
}, },
}, },
}, },
ATTACHMENT: { ATTACHMENT_SINGLE: {
name: "Attachment", name: "Attachment",
type: FieldType.ATTACHMENT, type: FieldType.ATTACHMENT_SINGLE,
icon: "Folder", icon: TypeIconMap[FieldType.ATTACHMENT_SINGLE],
constraints: {
presence: false,
},
},
ATTACHMENTS: {
name: "Attachment List",
type: FieldType.ATTACHMENTS,
icon: TypeIconMap[FieldType.ATTACHMENTS],
constraints: { constraints: {
type: "array", type: "array",
presence: false, presence: false,
@ -119,7 +130,7 @@ export const FIELDS = {
LINK: { LINK: {
name: "Relationship", name: "Relationship",
type: FieldType.LINK, type: FieldType.LINK,
icon: "Link", icon: TypeIconMap[FieldType.LINK],
constraints: { constraints: {
type: "array", type: "array",
presence: false, presence: false,
@ -128,19 +139,19 @@ export const FIELDS = {
AUTO: { AUTO: {
name: "Auto Column", name: "Auto Column",
type: FieldType.AUTO, type: FieldType.AUTO,
icon: "MagicWand", icon: TypeIconMap[FieldType.AUTO],
constraints: {}, constraints: {},
}, },
FORMULA: { FORMULA: {
name: "Formula", name: "Formula",
type: FieldType.FORMULA, type: FieldType.FORMULA,
icon: "Calculator", icon: TypeIconMap[FieldType.FORMULA],
constraints: {}, constraints: {},
}, },
JSON: { JSON: {
name: "JSON", name: "JSON",
type: FieldType.JSON, type: FieldType.JSON,
icon: "Brackets", icon: TypeIconMap[FieldType.JSON],
constraints: { constraints: {
type: "object", type: "object",
presence: false, presence: false,
@ -150,13 +161,13 @@ export const FIELDS = {
name: "User", name: "User",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USER, subtype: FieldSubtype.USER,
icon: "User", icon: TypeIconMap[FieldType.USER],
}, },
USERS: { USERS: {
name: "Users", name: "Users",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: FieldSubtype.USERS, subtype: FieldSubtype.USERS,
icon: "User", icon: TypeIconMap[FieldType.USERS],
constraints: { constraints: {
type: "array", type: "array",
}, },
@ -299,7 +310,7 @@ export const PaginationLocations = [
export const BannedSearchTypes = [ export const BannedSearchTypes = [
FieldType.LINK, FieldType.LINK,
FieldType.ATTACHMENT, FieldType.ATTACHMENTS,
FieldType.FORMULA, FieldType.FORMULA,
FieldType.JSON, FieldType.JSON,
"jsonarray", "jsonarray",

View File

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

View File

@ -6,7 +6,10 @@ import { derived } from "svelte/store"
import { integrations } from "stores/builder/integrations" import { integrations } from "stores/builder/integrations"
vi.mock("svelte/store", () => ({ vi.mock("svelte/store", () => ({
derived: vi.fn(() => {}), derived: vi.fn(),
writable: vi.fn(() => ({
subscribe: vi.fn(),
})),
})) }))
vi.mock("stores/builder/integrations", () => ({ integrations: vi.fn() })) vi.mock("stores/builder/integrations", () => ({ integrations: vi.fn() }))

View File

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

View File

@ -4226,7 +4226,7 @@
] ]
}, },
"attachmentfield": { "attachmentfield": {
"name": "Attachment", "name": "Attachment list",
"icon": "Attach", "icon": "Attach",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "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": { "relationshipfield": {
"name": "Relationship Picker", "name": "Relationship Picker",
"icon": "TaskList", "icon": "TaskList",
@ -6011,7 +6102,7 @@
"block": true, "block": true,
"name": "Repeater Block", "name": "Repeater Block",
"icon": "ViewList", "icon": "ViewList",
"illegalChildren": ["section"], "illegalChildren": ["section", "rowexplorer"],
"hasChildren": true, "hasChildren": true,
"size": { "size": {
"width": 400, "width": 400,

View File

@ -147,7 +147,8 @@
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
min-height: 410px; min-height: 230px;
height: 410px;
} }
div.in-builder :global(*) { div.in-builder :global(*) {
pointer-events: none; pointer-events: none;

View File

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

View File

@ -1,6 +1,7 @@
<script> <script>
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { CoreDropzone } from "@budibase/bbui" import { CoreDropzone } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import { getContext } from "svelte" import { getContext } from "svelte"
export let field export let field
@ -14,6 +15,12 @@
export let maximum = undefined export let maximum = undefined
export let span export let span
export let helpText = null export let helpText = null
export let type = FieldType.ATTACHMENTS
export let fieldApiMapper = {
get: value => value,
set: value => value,
}
export let defaultValue = []
let fieldState let fieldState
let fieldApi let fieldApi
@ -63,9 +70,10 @@
} }
const handleChange = e => { const handleChange = e => {
const changed = fieldApi.setValue(e.detail) const value = fieldApiMapper.set(e.detail)
const changed = fieldApi.setValue(value)
if (onChange && changed) { if (onChange && changed) {
onChange({ value: e.detail }) onChange({ value })
} }
} }
</script> </script>
@ -78,14 +86,14 @@
{validation} {validation}
{span} {span}
{helpText} {helpText}
type="attachment" {type}
bind:fieldState bind:fieldState
bind:fieldApi bind:fieldApi
defaultValue={[]} {defaultValue}
> >
{#if fieldState} {#if fieldState}
<CoreDropzone <CoreDropzone
value={fieldState.value} value={fieldApiMapper.get(fieldState.value)}
disabled={fieldState.disabled || fieldState.readonly} disabled={fieldState.disabled || fieldState.readonly}
error={fieldState.error} error={fieldState.error}
on:change={handleChange} 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 longformfield } from "./LongFormField.svelte"
export { default as datetimefield } from "./DateTimeField.svelte" export { default as datetimefield } from "./DateTimeField.svelte"
export { default as attachmentfield } from "./AttachmentField.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 relationshipfield } from "./RelationshipField.svelte"
export { default as passwordfield } from "./PasswordField.svelte" export { default as passwordfield } from "./PasswordField.svelte"
export { default as formstep } from "./FormStep.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 // Parse attachments, treating no elements as null
if (type === FieldTypes.ATTACHMENT) { if (type === FieldTypes.ATTACHMENTS) {
if (!Array.isArray(value) || !value.length) { if (!Array.isArray(value) || !value.length) {
return null return null
} }

View File

@ -10,6 +10,7 @@
export let invertX = false export let invertX = false
export let invertY = false export let invertY = false
export let schema export let schema
export let maximum
const { API, notifications } = getContext("grid") const { API, notifications } = getContext("grid")
const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"] const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"]
@ -98,7 +99,7 @@
{value} {value}
compact compact
on:change={e => onChange(e.detail)} on:change={e => onChange(e.detail)}
maximum={schema.constraints?.length?.maximum} maximum={maximum || schema.constraints?.length?.maximum}
{processFiles} {processFiles}
{deleteAttachments} {deleteAttachments}
{handleFileTooLarge} {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 FormulaCell from "../cells/FormulaCell.svelte"
import JSONCell from "../cells/JSONCell.svelte" import JSONCell from "../cells/JSONCell.svelte"
import AttachmentCell from "../cells/AttachmentCell.svelte" import AttachmentCell from "../cells/AttachmentCell.svelte"
import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte"
import BBReferenceCell from "../cells/BBReferenceCell.svelte" import BBReferenceCell from "../cells/BBReferenceCell.svelte"
const TypeComponentMap = { const TypeComponentMap = {
@ -22,7 +23,8 @@ const TypeComponentMap = {
[FieldType.ARRAY]: MultiSelectCell, [FieldType.ARRAY]: MultiSelectCell,
[FieldType.NUMBER]: NumberCell, [FieldType.NUMBER]: NumberCell,
[FieldType.BOOLEAN]: BooleanCell, [FieldType.BOOLEAN]: BooleanCell,
[FieldType.ATTACHMENT]: AttachmentCell, [FieldType.ATTACHMENTS]: AttachmentCell,
[FieldType.ATTACHMENT_SINGLE]: AttachmentSingleCell,
[FieldType.LINK]: RelationshipCell, [FieldType.LINK]: RelationshipCell,
[FieldType.FORMULA]: FormulaCell, [FieldType.FORMULA]: FormulaCell,
[FieldType.JSON]: JSONCell, [FieldType.JSON]: JSONCell,

View File

@ -1,4 +1,4 @@
import { FieldType, FieldTypeSubtypes } from "@budibase/types" import { TypeIconMap } from "../../../constants"
export const getColor = (idx, opacity = 0.3) => { export const getColor = (idx, opacity = 0.3) => {
if (idx == null || idx === -1) { if (idx == null || idx === -1) {
@ -7,26 +7,6 @@ export const getColor = (idx, opacity = 0.3) => {
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, ${opacity})` return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, ${opacity})`
} }
const TypeIconMap = {
[FieldType.STRING]: "Text",
[FieldType.OPTIONS]: "Dropdown",
[FieldType.DATETIME]: "Date",
[FieldType.BARCODEQR]: "Camera",
[FieldType.LONGFORM]: "TextAlignLeft",
[FieldType.ARRAY]: "Dropdown",
[FieldType.NUMBER]: "123",
[FieldType.BOOLEAN]: "Boolean",
[FieldType.ATTACHMENT]: "AppleFiles",
[FieldType.LINK]: "DataCorrelated",
[FieldType.FORMULA]: "Calculator",
[FieldType.JSON]: "Brackets",
[FieldType.BIGINT]: "TagBold",
[FieldType.BB_REFERENCE]: {
[FieldTypeSubtypes.BB_REFERENCE.USER]: "User",
[FieldTypeSubtypes.BB_REFERENCE.USERS]: "UserGroup",
},
}
export const getColumnIcon = column => { export const getColumnIcon = column => {
if (column.schema.autocolumn) { if (column.schema.autocolumn) {
return "MagicWand" return "MagicWand"

View File

@ -4,6 +4,7 @@
export { OperatorOptions, SqlNumberTypeRangeMap } from "@budibase/shared-core" export { OperatorOptions, SqlNumberTypeRangeMap } from "@budibase/shared-core"
export { Feature as Features } from "@budibase/types" export { Feature as Features } from "@budibase/types"
import { BpmCorrelationKey } from "@budibase/shared-core" import { BpmCorrelationKey } from "@budibase/shared-core"
import { FieldType, FieldTypeSubtypes } from "@budibase/types"
// Cookie names // Cookie names
export const Cookies = { export const Cookies = {
@ -113,3 +114,27 @@ export const ContextScopes = {
Local: "local", Local: "local",
Global: "global", Global: "global",
} }
export const TypeIconMap = {
[FieldType.STRING]: "Text",
[FieldType.OPTIONS]: "Dropdown",
[FieldType.DATETIME]: "Calendar",
[FieldType.BARCODEQR]: "Camera",
[FieldType.LONGFORM]: "TextAlignLeft",
[FieldType.ARRAY]: "Duplicate",
[FieldType.NUMBER]: "123",
[FieldType.BOOLEAN]: "Boolean",
[FieldType.ATTACHMENTS]: "Attach",
[FieldType.ATTACHMENT_SINGLE]: "Attach",
[FieldType.LINK]: "DataCorrelated",
[FieldType.FORMULA]: "Calculator",
[FieldType.JSON]: "Brackets",
[FieldType.BIGINT]: "TagBold",
[FieldType.AUTO]: "MagicWand",
[FieldType.USER]: "User",
[FieldType.USERS]: "UserGroup",
[FieldType.BB_REFERENCE]: {
[FieldTypeSubtypes.BB_REFERENCE.USER]: "User",
[FieldTypeSubtypes.BB_REFERENCE.USERS]: "UserGroup",
},
}

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

View File

@ -30,8 +30,6 @@ import {
View, View,
RelationshipFieldMetadata, RelationshipFieldMetadata,
FieldType, FieldType,
FieldTypeSubtypes,
AttachmentFieldMetadata,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import env from "../../../environment" import env from "../../../environment"
@ -93,26 +91,6 @@ export async function checkForColumnUpdates(
await checkForViewUpdates(updatedTable, deletedColumns, columnRename) 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 } 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 { context, InternalTable, tenancy } from "@budibase/backend-core"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { import {
AttachmentFieldMetadata,
AutoFieldSubType, AutoFieldSubType,
Datasource, Datasource,
DateFieldMetadata,
DeleteRow, DeleteRow,
FieldSchema, FieldSchema,
FieldType, FieldType,
FieldTypeSubtypes, FieldTypeSubtypes,
FormulaType, FormulaType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
NumberFieldMetadata,
QuotaUsageType, QuotaUsageType,
RelationshipType, RelationshipType,
Row, Row,
@ -232,9 +235,14 @@ describe.each([
name: "str", name: "str",
constraints: { type: "string", presence: false }, constraints: { type: "string", presence: false },
} }
const attachment: FieldSchema = { const singleAttachment: FieldSchema = {
type: FieldType.ATTACHMENT, type: FieldType.ATTACHMENT_SINGLE,
name: "attachment", name: "single attachment",
constraints: { presence: false },
}
const attachmentList: AttachmentFieldMetadata = {
type: FieldType.ATTACHMENTS,
name: "attachments",
constraints: { type: "array", presence: false }, constraints: { type: "array", presence: false },
} }
const bool: FieldSchema = { const bool: FieldSchema = {
@ -242,12 +250,12 @@ describe.each([
name: "boolean", name: "boolean",
constraints: { type: "boolean", presence: false }, constraints: { type: "boolean", presence: false },
} }
const number: FieldSchema = { const number: NumberFieldMetadata = {
type: FieldType.NUMBER, type: FieldType.NUMBER,
name: "str", name: "str",
constraints: { type: "number", presence: false }, constraints: { type: "number", presence: false },
} }
const datetime: FieldSchema = { const datetime: DateFieldMetadata = {
type: FieldType.DATETIME, type: FieldType.DATETIME,
name: "datetime", name: "datetime",
constraints: { constraints: {
@ -297,10 +305,12 @@ describe.each([
boolUndefined: bool, boolUndefined: bool,
boolString: bool, boolString: bool,
boolBool: bool, boolBool: bool,
attachmentNull: attachment, singleAttachmentNull: singleAttachment,
attachmentUndefined: attachment, singleAttachmentUndefined: singleAttachment,
attachmentEmpty: attachment, attachmentListNull: attachmentList,
attachmentEmptyArrayStr: attachment, attachmentListUndefined: attachmentList,
attachmentListEmpty: attachmentList,
attachmentListEmptyArrayStr: attachmentList,
arrayFieldEmptyArrayStr: arrayField, arrayFieldEmptyArrayStr: arrayField,
arrayFieldArrayStrKnown: arrayField, arrayFieldArrayStrKnown: arrayField,
arrayFieldNull: arrayField, arrayFieldNull: arrayField,
@ -336,10 +346,12 @@ describe.each([
boolString: "true", boolString: "true",
boolBool: true, boolBool: true,
tableId: table._id, tableId: table._id,
attachmentNull: null, singleAttachmentNull: null,
attachmentUndefined: undefined, singleAttachmentUndefined: undefined,
attachmentEmpty: "", attachmentListNull: null,
attachmentEmptyArrayStr: "[]", attachmentListUndefined: undefined,
attachmentListEmpty: "",
attachmentListEmptyArrayStr: "[]",
arrayFieldEmptyArrayStr: "[]", arrayFieldEmptyArrayStr: "[]",
arrayFieldUndefined: undefined, arrayFieldUndefined: undefined,
arrayFieldNull: null, arrayFieldNull: null,
@ -368,10 +380,12 @@ describe.each([
expect(row.boolUndefined).toBe(undefined) expect(row.boolUndefined).toBe(undefined)
expect(row.boolString).toBe(true) expect(row.boolString).toBe(true)
expect(row.boolBool).toBe(true) expect(row.boolBool).toBe(true)
expect(row.attachmentNull).toEqual([]) expect(row.singleAttachmentNull).toEqual(null)
expect(row.attachmentUndefined).toBe(undefined) expect(row.singleAttachmentUndefined).toBe(undefined)
expect(row.attachmentEmpty).toEqual([]) expect(row.attachmentListNull).toEqual([])
expect(row.attachmentEmptyArrayStr).toEqual([]) expect(row.attachmentListUndefined).toBe(undefined)
expect(row.attachmentListEmpty).toEqual([])
expect(row.attachmentListEmptyArrayStr).toEqual([])
expect(row.arrayFieldEmptyArrayStr).toEqual([]) expect(row.arrayFieldEmptyArrayStr).toEqual([])
expect(row.arrayFieldNull).toEqual([]) expect(row.arrayFieldNull).toEqual([])
expect(row.arrayFieldUndefined).toEqual(undefined) expect(row.arrayFieldUndefined).toEqual(undefined)
@ -817,12 +831,44 @@ describe.each([
isInternal && isInternal &&
describe("attachments", () => { describe("attachments", () => {
it("should allow enriching attachment rows", async () => { it("should allow enriching single attachment rows", async () => {
const table = await config.api.table.save( const table = await config.api.table.save(
defaultTable({ defaultTable({
schema: { schema: {
attachment: { 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", name: "attachment",
constraints: { type: "array", presence: false }, constraints: { type: "array", presence: false },
}, },

View File

@ -2,7 +2,13 @@ import { tableForDatasource } from "../../../tests/utilities/structures"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import * as setup from "./utilities" import * as setup from "./utilities"
import { Datasource, FieldType, Table } from "@budibase/types" import {
Datasource,
EmptyFilterOption,
FieldType,
SearchFilters,
Table,
} from "@budibase/types"
jest.unmock("mssql") jest.unmock("mssql")
@ -40,35 +46,237 @@ describe.each([
} }
}) })
beforeEach(async () => { describe("strings", () => {
table = await config.api.table.save( beforeEach(async () => {
tableForDatasource(datasource, { table = await config.api.table.save(
schema: { tableForDatasource(datasource, {
name: { schema: {
name: "name", name: {
type: FieldType.STRING, name: "name",
type: FieldType.STRING,
},
}, },
}, })
}) )
})
const rows = [{ name: "foo" }, { name: "bar" }]
interface StringSearchTest {
query: SearchFilters
expected: (typeof rows)[number][]
}
const stringSearchTests: StringSearchTest[] = [
{ query: {}, expected: rows },
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
expected: rows,
},
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
expected: [],
},
{ query: { string: { name: "foo" } }, expected: [rows[0]] },
{ query: { string: { name: "none" } }, expected: [] },
{ query: { fuzzy: { name: "oo" } }, expected: [rows[0]] },
{ query: { equal: { name: "foo" } }, expected: [rows[0]] },
{ query: { notEqual: { name: "foo" } }, expected: [rows[1]] },
{ query: { oneOf: { name: ["foo"] } }, expected: [rows[0]] },
// { query: { contains: { name: "f" } }, expected: [0] },
// { query: { notContains: { name: ["f"] } }, expected: [1] },
// { query: { containsAny: { name: ["f"] } }, expected: [0] },
]
it.each(stringSearchTests)(
`should be able to run query: $query`,
async ({ query, expected }) => {
const savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
const { rows: foundRows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query,
})
expect(foundRows).toEqual(
expect.arrayContaining(
expected.map(r =>
expect.objectContaining(savedRows.find(sr => sr.name === r.name)!)
)
)
)
}
) )
}) })
it("should return rows", async () => { describe("number", () => {
const rows = await Promise.all([ beforeEach(async () => {
config.api.row.save(table._id!, { name: "foo" }), table = await config.api.table.save(
config.api.row.save(table._id!, { name: "bar" }), tableForDatasource(datasource, {
]) schema: {
age: {
const result = await config.api.row.search(table._id!, { name: "age",
tableId: table._id!, type: FieldType.NUMBER,
query: {}, },
},
})
)
}) })
expect(result.rows).toEqual( const rows = [{ age: 1 }, { age: 10 }]
expect.arrayContaining([
expect.objectContaining({ _id: rows[0]._id }), interface NumberSearchTest {
expect.objectContaining({ _id: rows[1]._id }), query: SearchFilters
]) expected: (typeof rows)[number][]
}
const numberSearchTests: NumberSearchTest[] = [
{ query: {}, expected: rows },
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
expected: rows,
},
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
expected: [],
},
{ query: { equal: { age: 1 } }, expected: [rows[0]] },
{ query: { equal: { age: 2 } }, expected: [] },
{ query: { notEqual: { age: 1 } }, expected: [rows[1]] },
{ query: { oneOf: { age: [1] } }, expected: [rows[0]] },
{ query: { range: { age: { low: 1, high: 5 } } }, expected: [rows[0]] },
{ query: { range: { age: { low: 0, high: 1 } } }, expected: [rows[0]] },
{ query: { range: { age: { low: 3, high: 4 } } }, expected: [] },
{ query: { range: { age: { low: 0, high: 11 } } }, expected: rows },
]
it.each(numberSearchTests)(
`should be able to run query: $query`,
async ({ query, expected }) => {
const savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
const { rows: foundRows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query,
})
expect(foundRows).toEqual(
expect.arrayContaining(
expected.map(r =>
expect.objectContaining(savedRows.find(sr => sr.age === r.age)!)
)
)
)
}
)
})
describe("dates", () => {
beforeEach(async () => {
table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
dob: {
name: "dob",
type: FieldType.DATETIME,
},
},
})
)
})
const rows = [
{ dob: new Date("2020-01-01") },
{ dob: new Date("2020-01-10") },
]
interface DateSearchTest {
query: SearchFilters
expected: (typeof rows)[number][]
}
const dateSearchTests: DateSearchTest[] = [
{ query: {}, expected: rows },
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
expected: rows,
},
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
expected: [],
},
{
query: { equal: { dob: new Date("2020-01-01") } },
expected: [rows[0]],
},
{ query: { equal: { dob: new Date("2020-01-02") } }, expected: [] },
{
query: { notEqual: { dob: new Date("2020-01-01") } },
expected: [rows[1]],
},
{
query: { oneOf: { dob: [new Date("2020-01-01")] } },
expected: [rows[0]],
},
{
query: {
range: {
dob: {
low: new Date("2020-01-01").toISOString(),
high: new Date("2020-01-05").toISOString(),
},
},
},
expected: [rows[0]],
},
{
query: {
range: {
dob: {
low: new Date("2020-01-01").toISOString(),
high: new Date("2020-01-10").toISOString(),
},
},
},
expected: rows,
},
{
query: {
range: {
dob: {
low: new Date("2020-01-05").toISOString(),
high: new Date("2020-01-10").toISOString(),
},
},
},
expected: [rows[1]],
},
]
it.each(dateSearchTests)(
`should be able to run query: $query`,
async ({ query, expected }) => {
// TODO(samwho): most of these work for SQS, but not all. Fix 'em.
if (isSqs) {
return
}
const savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
const { rows: foundRows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query,
})
expect(foundRows).toEqual(
expect.arrayContaining(
expected.map(r =>
expect.objectContaining(
savedRows.find(sr => sr.dob === r.dob.toISOString())!
)
)
)
)
}
) )
}) })
}) })

View File

@ -299,7 +299,7 @@ export const DEFAULT_EMPLOYEE_TABLE_SCHEMA: Table = {
sortable: false, sortable: false,
}, },
"Badge Photo": { "Badge Photo": {
type: FieldType.ATTACHMENT, type: FieldType.ATTACHMENTS,
constraints: { constraints: {
type: FieldType.ARRAY, type: FieldType.ARRAY,
presence: false, presence: false,
@ -607,7 +607,7 @@ export const DEFAULT_EXPENSES_TABLE_SCHEMA: Table = {
ignoreTimezones: true, ignoreTimezones: true,
}, },
Attachment: { Attachment: {
type: FieldType.ATTACHMENT, type: FieldType.ATTACHMENTS,
constraints: { constraints: {
type: FieldType.ARRAY, type: FieldType.ARRAY,
presence: false, presence: false,

View File

@ -5,6 +5,7 @@ import {
Automation, Automation,
AutomationTriggerStepId, AutomationTriggerStepId,
RowAttachment, RowAttachment,
FieldType,
} from "@budibase/types" } from "@budibase/types"
import { getAutomationParams } from "../../../db/utils" import { getAutomationParams } from "../../../db/utils"
import { budibaseTempDir } from "../../../utilities/budibaseDir" import { budibaseTempDir } from "../../../utilities/budibaseDir"
@ -58,10 +59,19 @@ export async function updateAttachmentColumns(prodAppId: string, db: Database) {
updatedRows = updatedRows.concat( updatedRows = updatedRows.concat(
rows.map(row => { rows.map(row => {
for (let column of columns) { 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) => row[column] = row[column].map((attachment: RowAttachment) =>
rewriteAttachmentUrl(prodAppId, attachment) rewriteAttachmentUrl(prodAppId, attachment)
) )
} else if (
columnType === FieldType.ATTACHMENT_SINGLE &&
row[column]
) {
row[column] = rewriteAttachmentUrl(prodAppId, row[column])
} }
} }
return row return row

View File

@ -30,7 +30,10 @@ export async function getRowsWithAttachments(appId: string, table: Table) {
const db = dbCore.getDB(appId) const db = dbCore.getDB(appId)
const attachmentCols: string[] = [] const attachmentCols: string[] = []
for (let [key, column] of Object.entries(table.schema)) { 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) attachmentCols.push(key)
} }
} }

View File

@ -175,13 +175,13 @@ export async function validate({
errors[fieldName] = [`${fieldName} is required`] errors[fieldName] = [`${fieldName} is required`]
} }
} else if ( } else if (
(type === FieldType.ATTACHMENT || type === FieldType.JSON) && (type === FieldType.ATTACHMENTS || type === FieldType.JSON) &&
typeof row[fieldName] === "string" typeof row[fieldName] === "string"
) { ) {
// this should only happen if there is an error // this should only happen if there is an error
try { try {
const json = JSON.parse(row[fieldName]) const json = JSON.parse(row[fieldName])
if (type === FieldType.ATTACHMENT) { if (type === FieldType.ATTACHMENTS) {
if (Array.isArray(json)) { if (Array.isArray(json)) {
row[fieldName] = json row[fieldName] = json
} else { } else {

View File

@ -27,7 +27,8 @@ const FieldTypeMap: Record<FieldType, SQLiteType> = {
[FieldType.JSON]: SQLiteType.BLOB, [FieldType.JSON]: SQLiteType.BLOB,
[FieldType.INTERNAL]: SQLiteType.BLOB, [FieldType.INTERNAL]: SQLiteType.BLOB,
[FieldType.BARCODEQR]: SQLiteType.BLOB, [FieldType.BARCODEQR]: SQLiteType.BLOB,
[FieldType.ATTACHMENT]: SQLiteType.BLOB, [FieldType.ATTACHMENTS]: SQLiteType.BLOB,
[FieldType.ATTACHMENT_SINGLE]: SQLiteType.BLOB,
[FieldType.ARRAY]: SQLiteType.BLOB, [FieldType.ARRAY]: SQLiteType.BLOB,
[FieldType.LINK]: SQLiteType.BLOB, [FieldType.LINK]: SQLiteType.BLOB,
[FieldType.BIGINT]: SQLiteType.REAL, [FieldType.BIGINT]: SQLiteType.REAL,

View File

@ -31,9 +31,13 @@ describe("should be able to re-write attachment URLs", () => {
sourceType: TableSourceType.INTERNAL, sourceType: TableSourceType.INTERNAL,
schema: { schema: {
photo: { photo: {
type: FieldType.ATTACHMENT, type: FieldType.ATTACHMENT_SINGLE,
name: "photo", name: "photo",
}, },
gallery: {
type: FieldType.ATTACHMENTS,
name: "gallery",
},
otherCol: { otherCol: {
type: FieldType.STRING, type: FieldType.STRING,
name: "otherCol", name: "otherCol",
@ -43,7 +47,8 @@ describe("should be able to re-write attachment URLs", () => {
for (let i = 0; i < FIND_LIMIT * 4; i++) { for (let i = 0; i < FIND_LIMIT * 4; i++) {
await config.api.row.save(table._id!, { await config.api.row.save(table._id!, {
photo: [attachment], photo: { ...attachment },
gallery: [{ ...attachment }, { ...attachment }],
otherCol: "string", otherCol: "string",
}) })
} }
@ -56,8 +61,12 @@ describe("should be able to re-write attachment URLs", () => {
) )
for (const row of rows) { for (const row of rows) {
expect(row.otherCol).toBe("string") expect(row.otherCol).toBe("string")
expect(row.photo[0].url).toBe("") expect(row.photo.url).toBe("")
expect(row.photo[0].key).toBe(`${db.name}/attachments/a.png`) 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 { ObjectStoreBuckets } from "../../constants"
import { context, db as dbCore, objectStore } from "@budibase/backend-core" import { context, db as dbCore, objectStore } from "@budibase/backend-core"
import { import { FieldType, RenameColumn, Row, Table } from "@budibase/types"
FieldType,
RenameColumn,
Row,
RowAttachment,
Table,
} from "@budibase/types"
export class AttachmentCleanup { export class AttachmentCleanup {
static async coreCleanup(fileListFn: () => string[]): Promise<void> { 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( private static async tableChange(
table: Table, table: Table,
rows: Row[], rows: Row[],
@ -34,16 +49,20 @@ export class AttachmentCleanup {
let files: string[] = [] let files: string[] = []
const tableSchema = opts.oldTable?.schema || table.schema const tableSchema = opts.oldTable?.schema || table.schema
for (let [key, schema] of Object.entries(tableSchema)) { for (let [key, schema] of Object.entries(tableSchema)) {
if (schema.type !== FieldType.ATTACHMENT) { if (
schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE
) {
continue continue
} }
const columnRemoved = opts.oldTable && !table.schema[key] const columnRemoved = opts.oldTable && !table.schema[key]
const renaming = opts.rename?.old === key const renaming = opts.rename?.old === key
// old table had this column, new table doesn't - delete it // old table had this column, new table doesn't - delete it
if ((columnRemoved && !renaming) || opts.deleting) { if ((columnRemoved && !renaming) || opts.deleting) {
rows.forEach(row => { rows.forEach(row => {
files = files.concat( 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(() => { return AttachmentCleanup.coreCleanup(() => {
let files: string[] = [] let files: string[] = []
for (let [key, schema] of Object.entries(table.schema)) { 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 continue
} }
rows.forEach(row => { rows.forEach(row => {
if (!Array.isArray(row[key])) {
return
}
files = files.concat( 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(() => { return AttachmentCleanup.coreCleanup(() => {
let files: string[] = [] let files: string[] = []
for (let [key, schema] of Object.entries(table.schema)) { 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 continue
} }
const oldKeys =
opts.oldRow[key]?.map( const oldKeys = AttachmentCleanup.extractAttachmentKeys(
(attachment: RowAttachment) => attachment.key schema.type,
) || [] opts.oldRow[key]
const newKeys = )
opts.row[key]?.map((attachment: RowAttachment) => attachment.key) || const newKeys = AttachmentCleanup.extractAttachmentKeys(
[] schema.type,
opts.row[key]
)
files = files.concat( files = files.concat(
oldKeys.filter((key: string) => newKeys.indexOf(key) === -1) 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 // remove any attachment urls, they are generated on read
if (field.type === FieldType.ATTACHMENT) { if (field.type === FieldType.ATTACHMENTS) {
const attachments = clonedRow[key] const attachments = clonedRow[key]
if (attachments?.length) { if (attachments?.length) {
attachments.forEach((attachment: RowAttachment) => { attachments.forEach((attachment: RowAttachment) => {
delete attachment.url 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) { 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... // process complex types: attachements, bb references...
for (let [property, column] of Object.entries(table.schema)) { for (let [property, column] of Object.entries(table.schema)) {
if (column.type === FieldType.ATTACHMENT) { if (column.type === FieldType.ATTACHMENTS) {
for (let row of enriched) { for (let row of enriched) {
if (row[property] == null || !Array.isArray(row[property])) { if (row[property] == null || !Array.isArray(row[property])) {
continue 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 ( } else if (
!opts.skipBBReferences && !opts.skipBBReferences &&
column.type == FieldType.BB_REFERENCE column.type == FieldType.BB_REFERENCE

View File

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

View File

@ -25,121 +25,155 @@ const mockedDeleteFiles = objectStore.deleteFiles as jest.MockedFunction<
typeof objectStore.deleteFiles typeof objectStore.deleteFiles
> >
function table(): Table { const rowGenerators: [
return { string,
name: "table", FieldType.ATTACHMENT_SINGLE | FieldType.ATTACHMENTS,
sourceId: DEFAULT_BB_DATASOURCE_ID, (fileKey?: string) => Row
sourceType: TableSourceType.INTERNAL, ][] = [
type: "table", [
schema: { "row with a attachment list column",
attach: { FieldType.ATTACHMENTS,
name: "attach", function rowWithAttachments(fileKey: string = FILE_NAME): Row {
type: FieldType.ATTACHMENT, return {
constraints: {}, attach: [
}, {
size: 1,
extension: "jpg",
key: fileKey,
},
],
}
}, },
} ],
} [
"row with a single attachment column",
function row(fileKey: string = FILE_NAME): Row { FieldType.ATTACHMENT_SINGLE,
return { function rowWithAttachments(fileKey: string = FILE_NAME): Row {
attach: [ return {
{ attach: {
size: 1, size: 1,
extension: "jpg", extension: "jpg",
key: fileKey, 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(),
} }
) },
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 () => { describe.each(rowGenerators)(
const originalTable = table() "attachment cleanup",
delete originalTable.schema["attach"] (_, attachmentFieldType, rowGenerator) => {
await AttachmentCleanup.tableUpdate( function tableGenerator(): Table {
originalTable, return {
[{}, { attach: undefined }], name: "table",
{ sourceId: DEFAULT_BB_DATASOURCE_ID,
oldTable: table(), 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 = { const table: Table = {
_id: generator.guid(), _id: generator.guid(),
name: "TestTable", name: "TestTable",
@ -82,7 +82,7 @@ describe("rowProcessor - outputProcessing", () => {
sourceType: TableSourceType.INTERNAL, sourceType: TableSourceType.INTERNAL,
schema: { schema: {
attach: { attach: {
type: FieldType.ATTACHMENT, type: FieldType.ATTACHMENTS,
name: "attach", name: "attach",
constraints: {}, constraints: {},
}, },
@ -116,6 +116,47 @@ describe("rowProcessor - outputProcessing", () => {
expect(output3.attach[0].url).toBe("aaaa") 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 () => { it("process output even when the field is not empty", async () => {
const table: Table = { const table: Table = {
_id: generator.guid(), _id: generator.guid(),

View File

@ -147,6 +147,12 @@ export function parse(rows: Rows, schema: TableSchema): Rows {
utils.unreachable(columnSubtype) utils.unreachable(columnSubtype)
} }
} }
} else if (
(columnType === FieldType.ATTACHMENTS ||
columnType === FieldType.ATTACHMENT_SINGLE) &&
typeof columnData === "string"
) {
parsedRow[columnName] = parseCsvExport(columnData)
} else { } else {
parsedRow[columnName] = columnData parsedRow[columnName] = columnData
} }

View File

@ -11,10 +11,10 @@ const allowDisplayColumnByType: Record<FieldType, boolean> = {
[FieldType.INTERNAL]: true, [FieldType.INTERNAL]: true,
[FieldType.BARCODEQR]: true, [FieldType.BARCODEQR]: true,
[FieldType.BIGINT]: true, [FieldType.BIGINT]: true,
[FieldType.BOOLEAN]: false, [FieldType.BOOLEAN]: false,
[FieldType.ARRAY]: false, [FieldType.ARRAY]: false,
[FieldType.ATTACHMENT]: false, [FieldType.ATTACHMENTS]: false,
[FieldType.ATTACHMENT_SINGLE]: false,
[FieldType.LINK]: false, [FieldType.LINK]: false,
[FieldType.JSON]: false, [FieldType.JSON]: false,
[FieldType.BB_REFERENCE]: false, [FieldType.BB_REFERENCE]: false,
@ -34,7 +34,8 @@ const allowSortColumnByType: Record<FieldType, boolean> = {
[FieldType.JSON]: true, [FieldType.JSON]: true,
[FieldType.FORMULA]: false, [FieldType.FORMULA]: false,
[FieldType.ATTACHMENT]: false, [FieldType.ATTACHMENTS]: false,
[FieldType.ATTACHMENT_SINGLE]: false,
[FieldType.ARRAY]: false, [FieldType.ARRAY]: false,
[FieldType.LINK]: false, [FieldType.LINK]: false,
[FieldType.BB_REFERENCE]: false, [FieldType.BB_REFERENCE]: false,

View File

@ -8,7 +8,8 @@ export enum FieldType {
BOOLEAN = "boolean", BOOLEAN = "boolean",
ARRAY = "array", ARRAY = "array",
DATETIME = "datetime", DATETIME = "datetime",
ATTACHMENT = "attachment", ATTACHMENTS = "attachment",
ATTACHMENT_SINGLE = "attachment_single",
LINK = "link", LINK = "link",
FORMULA = "formula", FORMULA = "formula",
AUTO = "auto", AUTO = "auto",
@ -38,7 +39,6 @@ export interface Row extends Document {
export enum FieldSubtype { export enum FieldSubtype {
USER = "user", USER = "user",
USERS = "users", USERS = "users",
SINGLE = "single",
} }
// The 'as' are required for typescript not to type the outputs as generic FieldSubtype // 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, USER: FieldSubtype.USER as FieldSubtype.USER,
USERS: FieldSubtype.USERS as FieldSubtype.USERS, USERS: FieldSubtype.USERS as FieldSubtype.USERS,
}, },
ATTACHMENT: {
SINGLE: FieldSubtype.SINGLE as FieldSubtype.SINGLE,
},
} }

View File

@ -112,10 +112,8 @@ export interface BBReferenceFieldMetadata
relationshipType?: RelationshipType relationshipType?: RelationshipType
} }
export interface AttachmentFieldMetadata export interface AttachmentFieldMetadata extends BaseFieldSchema {
extends Omit<BaseFieldSchema, "subtype"> { type: FieldType.ATTACHMENTS
type: FieldType.ATTACHMENT
subtype?: FieldSubtype.SINGLE
} }
export interface FieldConstraints { export interface FieldConstraints {
@ -164,7 +162,7 @@ interface OtherFieldMetadata extends BaseFieldSchema {
| FieldType.NUMBER | FieldType.NUMBER
| FieldType.LONGFORM | FieldType.LONGFORM
| FieldType.BB_REFERENCE | FieldType.BB_REFERENCE
| FieldType.ATTACHMENT | FieldType.ATTACHMENTS
> >
} }
@ -217,5 +215,5 @@ export function isBBReferenceField(
export function isAttachmentField( export function isAttachmentField(
field: FieldSchema field: FieldSchema
): field is AttachmentFieldMetadata { ): field is AttachmentFieldMetadata {
return field.type === FieldType.ATTACHMENT return field.type === FieldType.ATTACHMENTS
} }