Merge pull request #15736 from Budibase/feature/sql-attachments

Support attachment and signature columns for SQL databases
This commit is contained in:
Michael Drury 2025-03-18 17:32:24 +00:00 committed by GitHub
commit 8c92117cc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 501 additions and 345 deletions

View File

@ -141,19 +141,23 @@ function generateSchema(
.references(`${tableName}.${relatedPrimary}`)
}
break
case FieldType.SIGNATURE_SINGLE:
case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE:
// single attachments are stored as an object, multi attachments
// are stored as an array
schema.json(key)
break
case FieldType.FORMULA:
// This is allowed, but nothing to do on the external datasource
break
case FieldType.AI:
// This is allowed, but nothing to do on the external datasource
break
case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE:
case FieldType.SIGNATURE_SINGLE:
case FieldType.AUTO:
case FieldType.JSON:
case FieldType.INTERNAL:
throw `${column.type} is not a valid SQL type`
throw new Error(`${column.type} is not a valid SQL type`)
default:
utils.unreachable(columnType)

View File

@ -20,7 +20,7 @@
export let searchTerm: string | null = null
export let customPopoverHeight: string | undefined = undefined
export let open: boolean = false
export let loading: boolean
export let loading: boolean = false
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}

View File

@ -3,7 +3,7 @@
import DatePicker from "./Core/DatePicker/DatePicker.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let value = undefined
export let label = null
export let labelPosition = "above"
export let disabled = false

View File

@ -1,29 +1,31 @@
<script>
<script lang="ts" generics="Option">
import { createEventDispatcher } from "svelte"
import Multiselect from "./Core/Multiselect.svelte"
import Field from "./Field.svelte"
export let value = []
export let label = null
export let value: string[] | string = []
export let label: string | undefined = undefined
export let disabled = false
export let readonly = false
export let labelPosition = "above"
export let error = null
export let placeholder = null
export let options = []
export let getOptionLabel = option => option
export let getOptionValue = option => option
export let error: string | undefined = undefined
export let placeholder: string | undefined = undefined
export let options: Option[] = []
export let getOptionLabel = (option: Option) => option
export let getOptionValue = (option: Option) => option
export let sort = false
export let autoWidth = false
export let autocomplete = false
export let searchTerm = null
export let customPopoverHeight
export let helpText = null
export let searchTerm: string | undefined = undefined
export let customPopoverHeight: string | undefined = undefined
export let helpText: string | undefined = undefined
export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {}
$: arrayValue = value && !Array.isArray(value) ? [value] : (value as string[])
const dispatch = createEventDispatcher()
const onChange = e => {
const onChange = (e: any) => {
value = e.detail
dispatch("change", e.detail)
}
@ -31,10 +33,9 @@
<Field {helpText} {label} {labelPosition} {error}>
<Multiselect
{error}
{disabled}
{readonly}
{value}
bind:value={arrayValue}
{options}
{placeholder}
{sort}

View File

@ -3,10 +3,10 @@
import Switch from "./Core/Switch.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let value = undefined
export let label = null
export let labelPosition = "above"
export let text = null
export let text = undefined
export let disabled = false
export let error = null
export let helpText = null

View File

@ -1,40 +1,42 @@
<script>
<script lang="ts">
import {
Input,
Button,
Label,
Select,
Multiselect,
Toggle,
Icon,
DatePicker,
Modal,
notifications,
Layout,
AbsTooltip,
Button,
DatePicker,
Icon,
Input,
Label,
Layout,
Modal,
Multiselect,
notifications,
ProgressCircle,
Select,
Toggle,
TooltipPosition,
TooltipType,
} from "@budibase/bbui"
import {
canHaveDefaultColumn,
helpers,
PROTECTED_EXTERNAL_COLUMNS,
PROTECTED_INTERNAL_COLUMNS,
SWITCHABLE_TYPES,
ValidColumnNameRegex,
helpers,
PROTECTED_INTERNAL_COLUMNS,
PROTECTED_EXTERNAL_COLUMNS,
canHaveDefaultColumn,
} from "@budibase/shared-core"
import { makePropSafe } from "@budibase/string-templates"
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "@/stores/builder"
import { datasources, tables } from "@/stores/builder"
import { licensing } from "@/stores/portal"
import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants"
import {
FIELDS,
RelationshipType,
PrettyRelationshipDefinitions,
DB_TYPE_EXTERNAL,
FIELDS,
PrettyRelationshipDefinitions,
RelationshipType,
} from "@/constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "@/helpers/utils"
import { buildAutoColumn, getAutoColumnInformation } from "@/helpers/utils"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import AIFieldConfiguration from "@/components/common/AIFieldConfiguration.svelte"
import ModalBindableInput from "@/components/common/bindings/ModalBindableInput.svelte"
@ -43,42 +45,52 @@
import {
BBReferenceFieldSubType,
FieldType,
FormulaType,
SourceName,
} from "@budibase/types"
import RelationshipSelector from "@/components/common/RelationshipSelector.svelte"
import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core"
import { canBeDisplayColumn, RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte"
import { getUserBindings } from "@/dataBinding"
import type {
Table,
Datasource,
FieldSchema,
UIField,
AutoFieldSubType,
FormulaResponseType,
FieldSchemaConfig,
} from "@budibase/types"
export let field
export let field: FieldSchema
const dispatch = createEventDispatcher()
const { dispatch: gridDispatch, rows } = getContext("grid")
const { dispatch: gridDispatch, rows } = getContext("grid") as any
const SafeID = `${makePropSafe("user")}.${makePropSafe("_id")}`
const SingleUserDefault = `{{ ${SafeID} }}`
const MultiUserDefault = `{{ js "${btoa(`return [$("${SafeID}")]`)}" }}`
let mounted = false
let originalName
let linkEditDisabled
let primaryDisplay
let indexes = [...($tables.selected.indexes || [])]
let isCreating = undefined
let originalName: string | undefined
let linkEditDisabled: boolean = false
let hasPrimaryDisplay: boolean
let isCreating: boolean | undefined
let relationshipPart1 = PrettyRelationshipDefinitions.MANY
let relationshipPart2 = PrettyRelationshipDefinitions.ONE
let relationshipTableIdPrimary = null
let relationshipTableIdSecondary = null
let table = $tables.selected
let confirmDeleteDialog
let savingColumn
let deleteColName
let jsonSchemaModal
let editableColumn = {
type: FIELDS.STRING.type,
constraints: FIELDS.STRING.constraints,
let relationshipTableIdPrimary: string | undefined
let relationshipTableIdSecondary: string | undefined
let table: Table | undefined = $tables.selected
let confirmDeleteDialog: any
let savingColumn: boolean
let deleteColName: string | undefined
let jsonSchemaModal: any
let editableColumn: FieldSchemaConfig = {
type: FieldType.STRING,
constraints: FIELDS.STRING.constraints as any,
name: "",
// Initial value for column name in other table for linked records
fieldName: $tables.selected.name,
fieldName: $tables.selected?.name || "",
}
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
@ -99,17 +111,42 @@
const autoColumnInfo = getAutoColumnInformation()
let optionsValid = true
// a fixed order for the types to stop them moving around
// we've never really guaranteed an order to these, which means that
// they can move around very easily
const fixedTypeOrder = [
FIELDS.STRING,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
FIELDS.AI,
FIELDS.LONGFORM,
FIELDS.USER,
FIELDS.USERS,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.FORMULA,
FIELDS.JSON,
FIELDS.BARCODEQR,
FIELDS.SIGNATURE_SINGLE,
FIELDS.BIGINT,
FIELDS.AUTO,
]
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: aiEnabled =
$licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled
$: if (primaryDisplay) {
$licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
$: if (hasPrimaryDisplay && editableColumn.constraints) {
editableColumn.constraints.presence = { allowEmpty: false }
}
$: {
// this parses any changes the user has made when creating a new internal relationship
// into what we expect the schema to look like
if (editableColumn.type === FieldType.LINK) {
relationshipTableIdPrimary = table._id
relationshipTableIdPrimary = table?._id
if (relationshipPart1 === PrettyRelationshipDefinitions.ONE) {
relationshipOpts2 = relationshipOpts2.filter(
opt => opt !== PrettyRelationshipDefinitions.ONE
@ -129,36 +166,44 @@
editableColumn.relationshipType = Object.entries(relationshipMap).find(
([_, parts]) =>
parts.part1 === relationshipPart1 && parts.part2 === relationshipPart2
)?.[0]
// Set the tableId based on the selected table
editableColumn.tableId = relationshipTableIdSecondary
)?.[0] as RelationshipType
if (relationshipTableIdSecondary) {
// Set the tableId based on the selected table
editableColumn.tableId = relationshipTableIdSecondary
}
}
}
$: initialiseField(field, savingColumn)
$: checkConstraints(editableColumn)
$: required =
primaryDisplay ||
hasPrimaryDisplay ||
editableColumn?.constraints?.presence === true ||
editableColumn?.constraints?.presence?.allowEmpty === false
(editableColumn?.constraints?.presence as any)?.allowEmpty === false
$: uneditable =
$tables.selected?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
UNEDITABLE_USER_FIELDS.includes(editableColumn.name || "")
$: invalid =
!editableColumn?.name ||
(editableColumn?.type === FieldType.LINK && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0 ||
Object.keys(errors || {}).length !== 0 ||
!optionsValid
$: errors = checkErrors(editableColumn)
$: datasource = $datasources.list.find(
source => source._id === table?.sourceId
)
) as Datasource | undefined
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => {
if (!tableAutoColumnsTypes.includes(key)) {
acc[key] = autoColumnInfo[key]
}
return acc
}, {})
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce(
(acc: Record<string, { enabled: boolean; name: string }>, key: string) => {
if (!tableAutoColumnsTypes.includes(key)) {
const subtypeKey = key as AutoFieldSubType
if (autoColumnInfo[subtypeKey]) {
acc[key] = autoColumnInfo[subtypeKey]
}
}
return acc
},
{}
)
$: availableAutoColumnKeys = availableAutoColumns
? Object.keys(availableAutoColumns)
: []
@ -176,21 +221,26 @@
!editableColumn.autocolumn
$: hasDefault =
editableColumn?.default != null && editableColumn?.default !== ""
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL
$: isExternalTable = table?.sourceType === DB_TYPE_EXTERNAL
// in the case of internal tables the sourceId will just be undefined
$: tableOptions = $tables.list.filter(
opt =>
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId
opt.sourceType === table?.sourceType && table.sourceId === opt.sourceId
)
$: typeEnabled =
!originalName ||
(originalName &&
SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
$: allowedTypes = getAllowedTypes(datasource, table)
$: orderedAllowedTypes = fixedTypeOrder
.filter(ordered =>
allowedTypes.find(allowed => allowed.type === ordered.type)
)
.map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
$: defaultValueBindings = [
{
type: "context",
@ -210,7 +260,23 @@
editableColumn.default
)
const fieldDefinitions = Object.values(FIELDS).reduce(
const allTableFields = [
FIELDS.STRING,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
FIELDS.LONGFORM,
FIELDS.FORMULA,
FIELDS.BARCODEQR,
FIELDS.BIGINT,
]
const fieldDefinitions: Record<string, UIField> = Object.values(
FIELDS
).reduce(
// Storing the fields by complex field id
(acc, field) => ({
...acc,
@ -219,7 +285,7 @@
{}
)
function makeFieldId(type, subtype, autocolumn) {
function makeFieldId(type: string, subtype?: string, autocolumn?: boolean) {
// don't make field IDs for auto types
if (type === FieldType.AUTO || autocolumn) {
return type.toUpperCase()
@ -233,23 +299,29 @@
}
}
const initialiseField = (field, savingColumn) => {
const initialiseField = (
field: FieldSchema | undefined,
savingColumn: boolean
) => {
isCreating = !field
if (field && !savingColumn) {
editableColumn = cloneDeep(field)
originalName = editableColumn.name ? editableColumn.name + "" : null
editableColumn = cloneDeep(field) as FieldSchemaConfig
originalName = editableColumn.name ? editableColumn.name + "" : undefined
linkEditDisabled = originalName != null
primaryDisplay =
$tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name
hasPrimaryDisplay =
$tables.selected?.primaryDisplay == null ||
$tables.selected?.primaryDisplay === editableColumn.name
// Here we are setting the relationship values based on the editableColumn
// This part of the code is used when viewing an existing field hence the check
// for the tableId
if (editableColumn.type === FieldType.LINK && editableColumn.tableId) {
relationshipTableIdPrimary = table._id
relationshipTableIdPrimary = table?._id
relationshipTableIdSecondary = editableColumn.tableId
if (editableColumn.relationshipType in relationshipMap) {
if (
editableColumn.relationshipType &&
editableColumn.relationshipType in relationshipMap
) {
const { part1, part2 } =
relationshipMap[editableColumn.relationshipType]
relationshipPart1 = part1
@ -267,18 +339,21 @@
}
}
const getTableAutoColumnTypes = table => {
return Object.keys(table?.schema).reduce((acc, key) => {
let fieldSchema = table?.schema[key]
if (fieldSchema.autocolumn) {
acc.push(fieldSchema.subtype)
}
return acc
}, [])
const getTableAutoColumnTypes = (table: Table | undefined) => {
return Object.keys(table?.schema || {}).reduce(
(acc: string[], key: string) => {
let fieldSchema = table?.schema[key]
if (fieldSchema?.autocolumn && fieldSchema?.subtype) {
acc.push(fieldSchema.subtype)
}
return acc
},
[]
)
}
async function saveColumn() {
if (errors?.length) {
if (Object.keys(errors || {}).length) {
return
}
@ -287,14 +362,18 @@
delete saveColumn.fieldId
if (saveColumn.type === FieldType.AUTO) {
if (
$tables.selected &&
saveColumn.name &&
saveColumn.type === FieldType.AUTO
) {
saveColumn = buildAutoColumn(
$tables.selected.name,
saveColumn.name,
saveColumn.subtype
)
saveColumn.subtype as AutoFieldSubType
) as FieldSchemaConfig
}
if (saveColumn.type !== FieldType.LINK) {
if ("fieldName" in saveColumn && saveColumn.type !== FieldType.LINK) {
delete saveColumn.fieldName
}
@ -304,22 +383,21 @@
}
// Ensure primary display columns are always required and don't have default values
if (primaryDisplay) {
saveColumn.constraints.presence = { allowEmpty: false }
if (hasPrimaryDisplay) {
saveColumn.constraints!.presence = { allowEmpty: false }
delete saveColumn.default
}
// Ensure the field is not required if we have a default value
if (saveColumn.default) {
saveColumn.constraints.presence = false
saveColumn.constraints!.presence = false
}
try {
await tables.saveField({
originalName,
field: saveColumn,
primaryDisplay,
indexes,
field: saveColumn as FieldSchema,
hasPrimaryDisplay,
})
dispatch("updatecolumns")
gridDispatch("close-edit-column")
@ -329,7 +407,7 @@
} else {
notifications.success("Column created successfully")
}
} catch (err) {
} catch (err: any) {
notifications.error(`Error saving column: ${err.message}`)
} finally {
savingColumn = false
@ -337,65 +415,83 @@
}
function cancelEdit() {
editableColumn.name = originalName
if (originalName) {
editableColumn.name = originalName
}
gridDispatch("close-edit-column")
}
async function deleteColumn() {
try {
editableColumn.name = deleteColName
if (editableColumn.name === $tables.selected.primaryDisplay) {
if (deleteColName) {
editableColumn.name = deleteColName
}
if (editableColumn.name === $tables.selected?.primaryDisplay) {
notifications.error("You cannot delete the display column")
} else {
await tables.deleteField(editableColumn)
await tables.deleteField({ name: editableColumn.name! })
notifications.success(`Column ${editableColumn.name} deleted`)
confirmDeleteDialog.hide()
dispatch("updatecolumns")
gridDispatch("close-edit-column")
}
} catch (error) {
} catch (error: any) {
notifications.error(`Error deleting column: ${error.message}`)
}
}
function onHandleTypeChange(event) {
function onHandleTypeChange(event: any) {
handleTypeChange(event.detail)
}
function handleTypeChange(type) {
function handleTypeChange(type?: string) {
// remove any extra fields that may not be related to this type
delete editableColumn.autocolumn
delete editableColumn.subtype
delete editableColumn.tableId
delete editableColumn.relationshipType
delete editableColumn.formulaType
delete editableColumn.constraints
delete editableColumn.responseType
const columnsToClear = [
"autocolumn",
"subtype",
"tableId",
"relationshipType",
"formulaType",
"responseType",
]
for (let column of columnsToClear) {
if (column in editableColumn) {
delete editableColumn[column as keyof FieldSchema]
}
}
editableColumn.constraints = {}
// Add in defaults and initial definition
const definition = fieldDefinitions[type?.toUpperCase()]
const definition = fieldDefinitions[type?.toUpperCase() || ""]
if (definition?.constraints) {
editableColumn.constraints = cloneDeep(definition.constraints)
}
editableColumn.type = definition.type
editableColumn.subtype = definition.subtype
if (definition.subtype) {
// @ts-expect-error the setting of sub-type here doesn't fit our definition with
// FieldSchema, there is no type checking, it simply sets it if it is provided
editableColumn.subtype = definition.subtype
}
// Default relationships many to many
if (editableColumn.type === FieldType.LINK) {
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
} else if (editableColumn.type === FieldType.FORMULA) {
editableColumn.formulaType = "dynamic"
editableColumn.responseType = field?.responseType || FIELDS.STRING.type
editableColumn.formulaType = FormulaType.DYNAMIC
editableColumn.responseType =
field && "responseType" in field
? field.responseType
: (FIELDS.STRING.type as FormulaResponseType)
}
}
function setRequired(req) {
editableColumn.constraints.presence = req ? { allowEmpty: false } : false
function setRequired(req: boolean) {
editableColumn.constraints!.presence = req ? { allowEmpty: false } : false
required = req
}
function onChangeRequired(e) {
function onChangeRequired(e: any) {
setRequired(e.detail)
}
@ -412,10 +508,21 @@
deleteColName = ""
}
function getAllowedTypes(datasource) {
function getAllowedTypes(
datasource: Datasource | undefined,
table: Table | undefined
): UIField[] {
const isSqlTable = table?.sql
const isGoogleSheet =
table?.sourceType === DB_TYPE_EXTERNAL &&
datasource?.source === SourceName.GOOGLE_SHEETS
if (originalName) {
let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type]
if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) {
if (
helpers.schema.isDeprecatedSingleUserColumn(
editableColumn as FieldSchema
)
) {
// This will handle old single users columns
return [
{
@ -442,60 +549,43 @@
.map(([_, fieldDefinition]) => fieldDefinition)
}
if (!externalTable) {
return [
FIELDS.STRING,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
...(aiEnabled ? [FIELDS.AI] : []),
FIELDS.LONGFORM,
if (!isExternalTable) {
const fields = [
...allTableFields,
FIELDS.USER,
FIELDS.USERS,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.FORMULA,
FIELDS.JSON,
FIELDS.BARCODEQR,
FIELDS.SIGNATURE_SINGLE,
FIELDS.BIGINT,
FIELDS.JSON,
FIELDS.AUTO,
]
} else {
let fields = [
FIELDS.STRING,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
FIELDS.LONGFORM,
FIELDS.USER,
FIELDS.USERS,
FIELDS.FORMULA,
FIELDS.BARCODEQR,
FIELDS.BIGINT,
]
// Filter out multiple users for google sheets
if (datasource?.source === SourceName.GOOGLE_SHEETS) {
fields = fields.filter(x => x !== FIELDS.USERS)
if (aiEnabled) {
fields.push(FIELDS.AI)
}
// Filter out SQL-specific types for non-SQL datasources
if (!table.sql) {
fields = fields.filter(x => x !== FIELDS.LINK && x !== FIELDS.ARRAY)
}
return fields
}
if (isExternalTable && isSqlTable) {
return [
...allTableFields,
FIELDS.USER,
FIELDS.USERS,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.SIGNATURE_SINGLE,
]
} else if (isExternalTable && isGoogleSheet) {
// google-sheets supports minimum set (no attachments or user references)
return allTableFields
} else if (isExternalTable && !isSqlTable) {
// filter out SQL-specific types for non-SQL datasources
return allTableFields.filter(x => x !== FIELDS.LINK && x !== FIELDS.ARRAY)
}
throw new Error("No valid allowed types found")
}
function checkConstraints(fieldToCheck) {
function checkConstraints(fieldToCheck: FieldSchema) {
if (!fieldToCheck) {
return
}
@ -526,11 +616,11 @@
}
}
function checkErrors(fieldInfo) {
function checkErrors(fieldInfo: FieldSchema) {
if (!editableColumn) {
return {}
return
}
function inUse(tbl, column, ogName = null) {
function inUse(tbl?: Table, column?: string, ogName?: string) {
const parsedColumn = column ? column.toLowerCase().trim() : column
return Object.keys(tbl?.schema || {}).some(key => {
@ -538,11 +628,12 @@
return lowerKey !== ogName?.toLowerCase() && lowerKey === parsedColumn
})
}
const newError = {}
const prohibited = externalTable
const newError: { name?: string; subtype?: string; relatedName?: string } =
{}
const prohibited = isExternalTable
? PROTECTED_EXTERNAL_COLUMNS
: PROTECTED_INTERNAL_COLUMNS
if (!externalTable && fieldInfo.name?.startsWith("_")) {
if (!isExternalTable && fieldInfo.name?.startsWith("_")) {
newError.name = `Column name cannot start with an underscore.`
} else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) {
newError.name = `Illegal character; must be alpha-numeric.`
@ -558,31 +649,52 @@
newError.subtype = `Auto Column requires a type.`
}
if (fieldInfo.fieldName && fieldInfo.tableId) {
if (
fieldInfo.type === FieldType.LINK &&
fieldInfo.fieldName &&
fieldInfo.tableId
) {
const relatedTable = $tables.list.find(
tbl => tbl._id === fieldInfo.tableId
)
if (inUse(relatedTable, fieldInfo.fieldName) && !originalName) {
newError.relatedName = `Column name already in use in table ${relatedTable.name}`
newError.relatedName = `Column name already in use in table ${relatedTable?.name}`
}
}
return newError
}
const sanitiseDefaultValue = (type, options, defaultValue) => {
const sanitiseDefaultValue = (
type: FieldType,
options: string[],
defaultValue?: string[] | string
) => {
if (!defaultValue?.length) {
return
}
// Delete default value for options fields if the option is no longer available
if (type === FieldType.OPTIONS && !options.includes(defaultValue)) {
if (
type === FieldType.OPTIONS &&
typeof defaultValue === "string" &&
!options.includes(defaultValue)
) {
delete editableColumn.default
}
// Filter array default values to only valid options
if (type === FieldType.ARRAY) {
if (type === FieldType.ARRAY && Array.isArray(defaultValue)) {
editableColumn.default = defaultValue.filter(x => options.includes(x))
}
}
function handleNameInput(evt: any) {
if (
!uneditable &&
!(linkEditDisabled && editableColumn.type === FieldType.LINK)
) {
editableColumn.name = evt.target.value
}
}
onMount(() => {
mounted = true
})
@ -593,25 +705,18 @@
<Input
value={editableColumn.name}
autofocus
on:input={e => {
if (
!uneditable &&
!(linkEditDisabled && editableColumn.type === FieldType.LINK)
) {
editableColumn.name = e.target.value
}
}}
on:input={handleNameInput}
disabled={uneditable ||
(linkEditDisabled && editableColumn.type === FieldType.LINK)}
error={errors?.name}
/>
{/if}
<Select
placeholder={null}
placeholder={undefined}
disabled={!typeEnabled}
bind:value={editableColumn.fieldId}
on:change={onHandleTypeChange}
options={allowedTypes}
options={orderedAllowedTypes}
getOptionLabel={field => field.name}
getOptionValue={field => field.fieldId}
getOptionIcon={field => field.icon}
@ -623,7 +728,7 @@
}}
/>
{#if editableColumn.type === FieldType.STRING}
{#if editableColumn.type === FieldType.STRING && editableColumn.constraints.length}
<Input
type="number"
label="Max Length"
@ -640,8 +745,8 @@
<div class="tooltip-alignment">
<Label size="M">Formatting</Label>
<AbsTooltip
position="top"
type="info"
position={TooltipPosition.Top}
type={TooltipType.Info}
text={"Rich text includes support for images, link"}
>
<Icon size="XS" name="InfoOutline" />
@ -664,26 +769,30 @@
<div class="label-length">
<Label size="M">Earliest</Label>
</div>
<div class="input-length">
<DatePicker
bind:value={editableColumn.constraints.datetime.earliest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div>
{#if editableColumn.constraints.datetime}
<div class="input-length">
<DatePicker
bind:value={editableColumn.constraints.datetime.earliest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div>
{/if}
</div>
<div class="split-label">
<div class="label-length">
<Label size="M">Latest</Label>
</div>
<div class="input-length">
<DatePicker
bind:value={editableColumn.constraints.datetime.latest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div>
{#if editableColumn.constraints.datetime}
<div class="input-length">
<DatePicker
bind:value={editableColumn.constraints.datetime.latest}
enableTime={!editableColumn.dateOnly}
timeOnly={editableColumn.timeOnly}
/>
</div>
{/if}
</div>
{#if !editableColumn.timeOnly}
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
@ -691,10 +800,10 @@
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
type="info"
position={TooltipPosition.Top}
type={TooltipType.Info}
text={isCreating
? null
? undefined
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
>
<Icon size="XS" name="InfoOutline" />
@ -713,25 +822,30 @@
<div class="label-length">
<Label size="M">Min Value</Label>
</div>
<div class="input-length">
<Input
type="number"
bind:value={editableColumn.constraints.numericality
.greaterThanOrEqualTo}
/>
</div>
{#if editableColumn.constraints.numericality}
<div class="input-length">
<Input
type="number"
bind:value={editableColumn.constraints.numericality
.greaterThanOrEqualTo}
/>
</div>
{/if}
</div>
<div class="split-label">
<div class="label-length">
<Label size="M">Max Value</Label>
</div>
<div class="input-length">
<Input
type="number"
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo}
/>
</div>
{#if editableColumn.constraints.numericality}
<div class="input-length">
<Input
type="number"
bind:value={editableColumn.constraints.numericality
.lessThanOrEqualTo}
/>
</div>
{/if}
</div>
{:else if editableColumn.type === FieldType.LINK && !editableColumn.autocolumn}
<RelationshipSelector
@ -747,7 +861,7 @@
{errors}
/>
{:else if editableColumn.type === FieldType.FORMULA}
{#if !externalTable}
{#if !isExternalTable}
<div class="split-label">
<div class="label-length">
<Label size="M">Formula Type</Label>
@ -797,9 +911,11 @@
title="Formula"
value={editableColumn.formula}
on:change={e => {
editableColumn = {
...editableColumn,
formula: e.detail,
if (editableColumn.type === FieldType.FORMULA) {
editableColumn = {
...editableColumn,
formula: e.detail,
}
}
}}
bindings={getBindings({ table })}
@ -808,7 +924,7 @@
/>
</div>
</div>
{:else if editableColumn.type === FieldType.AI}
{:else if editableColumn.type === FieldType.AI && table}
<AIFieldConfiguration
aiField={editableColumn}
context={rowGoldenSample}
@ -816,9 +932,7 @@
schema={table.schema}
/>
{:else if editableColumn.type === FieldType.JSON}
<Button primary text on:click={openJsonSchemaEditor}>
Open schema editor
</Button>
<Button primary on:click={openJsonSchemaEditor}>Open schema editor</Button>
{/if}
{#if editableColumn.type === FieldType.AUTO || editableColumn.autocolumn}
<Select
@ -839,8 +953,7 @@
<Toggle
value={required}
on:change={onChangeRequired}
disabled={primaryDisplay || hasDefault}
thin
disabled={hasPrimaryDisplay || hasDefault}
text="Required"
/>
{/if}
@ -895,7 +1008,7 @@
<div class="action-buttons">
{#if !uneditable && originalName != null}
<Button quiet warning text on:click={confirmDelete}>Delete</Button>
<Button quiet warning on:click={confirmDelete}>Delete</Button>
{/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
<Button

View File

@ -11,8 +11,8 @@
export let errors
export let relationshipOpts1
export let relationshipOpts2
export let primaryTableChanged
export let secondaryTableChanged
export let primaryTableChanged = undefined
export let secondaryTableChanged = undefined
export let primaryDisabled = true
</script>

View File

@ -6,6 +6,7 @@ import {
Hosting,
} from "@budibase/types"
import { Constants } from "@budibase/frontend-core"
import { UIField } from "@budibase/types"
const { TypeIconMap } = Constants
@ -27,7 +28,7 @@ export const AUTO_COLUMN_DISPLAY_NAMES: Record<
UPDATED_AT: "Updated At",
}
export const FIELDS = {
export const FIELDS: Record<string, UIField> = {
STRING: {
name: "Text",
type: FieldType.STRING,

View File

@ -148,13 +148,11 @@ export class TableStore extends DerivedBudiStore<
async saveField({
originalName,
field,
primaryDisplay = false,
indexes,
hasPrimaryDisplay = false,
}: {
originalName: string
originalName?: string
field: FieldSchema
primaryDisplay: boolean
indexes: Record<string, any>
hasPrimaryDisplay: boolean
}) {
const draft: SaveTableRequest = cloneDeep(get(this.derivedStore).selected!)
@ -169,7 +167,7 @@ export class TableStore extends DerivedBudiStore<
}
// Optionally set display column
if (primaryDisplay) {
if (hasPrimaryDisplay) {
draft.primaryDisplay = field.name
} else if (draft.primaryDisplay === originalName) {
const fields = Object.keys(draft.schema)
@ -178,9 +176,6 @@ export class TableStore extends DerivedBudiStore<
name => name !== originalName || name !== field.name
)[0]
}
if (indexes) {
draft.indexes = indexes
}
draft.schema = {
...draft.schema,
[field.name]: cloneDeep(field),

View File

@ -1,10 +1,9 @@
<script context="module" lang="ts">
type ValueType = string | string[]
type BasicRelatedRow = { _id: string; primaryDisplay: string }
type OptionsMap = Record<string, BasicRelatedRow>
</script>
<script lang="ts">
<script lang="ts" generics="ValueType extends string | string[]">
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import {
BasicOperator,
@ -67,7 +66,7 @@
fieldSchema?.relationshipType !== "one-to-many"
// Get the proper string representation of the value
$: realValue = fieldState?.value
$: realValue = fieldState?.value as ValueType
$: selectedValue = parseSelectedValue(realValue, multiselect)
$: selectedIDs = getSelectedIDs(selectedValue)

View File

@ -2195,95 +2195,94 @@ if (descriptions.length) {
})
})
isInternal &&
describe("attachments and signatures", () => {
const coreAttachmentEnrichment = async (
schema: TableSchema,
field: string,
attachmentCfg: string | string[]
) => {
const testTable = await config.api.table.save(
defaultTable({
schema,
})
)
const attachmentToStoreKey = (attachmentId: string) => {
return {
key: `${config.getAppId()}/attachments/${attachmentId}`,
}
}
const draftRow = {
name: "test",
description: "test",
[field]:
typeof attachmentCfg === "string"
? attachmentToStoreKey(attachmentCfg)
: attachmentCfg.map(attachmentToStoreKey),
tableId: testTable._id,
}
const row = await config.api.row.save(testTable._id!, draftRow)
await withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => {
const enriched: Row[] = await outputProcessing(testTable, [row])
const [targetRow] = enriched
const attachmentEntries = Array.isArray(targetRow[field])
? targetRow[field]
: [targetRow[field]]
for (const entry of attachmentEntries) {
const attachmentId = entry.key.split("/").pop()
expect(entry.url.split("?")[0]).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
)
}
})
describe("attachments and signatures", () => {
const coreAttachmentEnrichment = async (
schema: TableSchema,
field: string,
attachmentCfg: string | string[]
) => {
const testTable = await config.api.table.save(
defaultTable({
schema,
})
)
const attachmentToStoreKey = (attachmentId: string) => {
return {
key: `${config.getAppId()}/attachments/${attachmentId}`,
}
}
const draftRow = {
name: "test",
description: "test",
[field]:
typeof attachmentCfg === "string"
? attachmentToStoreKey(attachmentCfg)
: attachmentCfg.map(attachmentToStoreKey),
tableId: testTable._id,
}
const row = await config.api.row.save(testTable._id!, draftRow)
it("should allow enriching single attachment rows", async () => {
await coreAttachmentEnrichment(
{
attachment: {
type: FieldType.ATTACHMENT_SINGLE,
name: "attachment",
constraints: { presence: false },
},
},
"attachment",
`${uuid.v4()}.csv`
)
})
await withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => {
const enriched: Row[] = await outputProcessing(testTable, [row])
const [targetRow] = enriched
const attachmentEntries = Array.isArray(targetRow[field])
? targetRow[field]
: [targetRow[field]]
it("should allow enriching attachment list rows", async () => {
await coreAttachmentEnrichment(
{
attachments: {
type: FieldType.ATTACHMENTS,
name: "attachments",
constraints: { type: "array", presence: false },
},
},
"attachments",
[`${uuid.v4()}.csv`]
)
for (const entry of attachmentEntries) {
const attachmentId = entry.key.split("/").pop()
expect(entry.url.split("?")[0]).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
)
}
})
})
}
it("should allow enriching signature rows", async () => {
await coreAttachmentEnrichment(
{
signature: {
type: FieldType.SIGNATURE_SINGLE,
name: "signature",
constraints: { presence: false },
},
it("should allow enriching single attachment rows", async () => {
await coreAttachmentEnrichment(
{
attachment: {
type: FieldType.ATTACHMENT_SINGLE,
name: "attachment",
constraints: { presence: false },
},
"signature",
`${uuid.v4()}.png`
)
})
},
"attachment",
`${uuid.v4()}.csv`
)
})
it("should allow enriching attachment list rows", async () => {
await coreAttachmentEnrichment(
{
attachments: {
type: FieldType.ATTACHMENTS,
name: "attachments",
constraints: { type: "array", presence: false },
},
},
"attachments",
[`${uuid.v4()}.csv`]
)
})
it("should allow enriching signature rows", async () => {
await coreAttachmentEnrichment(
{
signature: {
type: FieldType.SIGNATURE_SINGLE,
name: "signature",
constraints: { presence: false },
},
},
"signature",
`${uuid.v4()}.png`
)
})
})
describe("exportRows", () => {
beforeEach(async () => {
table = await config.api.table.save(defaultTable())

View File

@ -156,8 +156,8 @@ export interface FieldConstraints {
message?: string
}
numericality?: {
greaterThanOrEqualTo: string | null
lessThanOrEqualTo: string | null
greaterThanOrEqualTo?: string | null
lessThanOrEqualTo?: string | null
}
presence?:
| boolean
@ -165,8 +165,8 @@ export interface FieldConstraints {
allowEmpty?: boolean
}
datetime?: {
latest: string
earliest: string
latest?: string
earliest?: string
}
}
@ -197,7 +197,7 @@ export interface BigIntFieldMetadata extends BaseFieldSchema {
default?: string
}
interface BaseFieldSchema extends UIFieldMetadata {
export interface BaseFieldSchema extends UIFieldMetadata {
type: FieldType
name: string
sortable?: boolean

View File

@ -0,0 +1,43 @@
import {
FieldType,
FieldConstraints,
type FieldSchema,
type FormulaResponseType,
} from "../"
export interface UIField {
name: string
type: FieldType
subtype?: string
icon: string
constraints?: {
type?: string
presence?: boolean
length?: any
inclusion?: string[]
numericality?: {
greaterThanOrEqualTo?: string
lessThanOrEqualTo?: string
}
datetime?: {
latest?: string
earliest?: string
}
}
}
// an empty/partial field schema which is used when building new columns in the UI
// the current construction process of a column means that it is never certain what
// this object contains, or what type it is currently set to, meaning that our
// strict FieldSchema isn't really usable here, the strict fieldSchema only occurs
// when the table is saved, but in the UI in can be in a real mix of states
export type FieldSchemaConfig = FieldSchema & {
constraints: FieldConstraints
fieldName?: string
responseType?: FormulaResponseType
default?: any
fieldId?: string
optionColors?: string[]
schema?: any
json?: string
}

View File

@ -5,3 +5,4 @@ export * from "./dataFetch"
export * from "./datasource"
export * from "./common"
export * from "./BudibaseApp"
export * from "./fields"