Merge branch 'master' into fix/builder-tabs-underline

This commit is contained in:
deanhannigan 2024-05-13 12:58:24 +01:00 committed by GitHub
commit ab3624b3bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 1227 additions and 468 deletions

View File

@ -54,7 +54,8 @@
"ignoreRestSiblings": true
}
],
"local-rules/no-budibase-imports": "error"
"no-redeclare": "off",
"@typescript-eslint/no-redeclare": "error"
}
},
{

View File

@ -69,7 +69,7 @@ async function populateUsersFromDB(
export async function getUser(
userId: string,
tenantId?: string,
populateUser?: any
populateUser?: (userId: string, tenantId: string) => Promise<User>
) {
if (!populateUser) {
populateUser = populateFromDB
@ -83,7 +83,7 @@ export async function getUser(
}
const client = await redis.getUserClient()
// try cache
let user = await client.get(userId)
let user: User = await client.get(userId)
if (!user) {
user = await populateUser(userId, tenantId)
await client.store(userId, user, EXPIRY_SECONDS)

View File

@ -13,7 +13,7 @@ import { getGlobalDB, doInTenant } from "../context"
import { decrypt } from "../security/encryption"
import * as identity from "../context/identity"
import env from "../environment"
import { Ctx, EndpointMatcher, SessionCookie } from "@budibase/types"
import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types"
import { InvalidAPIKeyError, ErrorCode } from "../errors"
import tracer from "dd-trace"
@ -41,7 +41,10 @@ function finalise(ctx: any, opts: FinaliseOpts = {}) {
ctx.version = opts.version
}
async function checkApiKey(apiKey: string, populateUser?: Function) {
async function checkApiKey(
apiKey: string,
populateUser?: (userId: string, tenantId: string) => Promise<User>
) {
// check both the primary and the fallback internal api keys
// this allows for rotation
if (isValidInternalAPIKey(apiKey)) {
@ -128,6 +131,7 @@ export default function (
} else {
user = await getUser(userId, session.tenantId)
}
// @ts-ignore
user.csrfToken = session.csrfToken
if (session?.lastAccessedAt < timeMinusOneMinute()) {
@ -167,19 +171,25 @@ export default function (
authenticated = false
}
if (user) {
const isUser = (
user: any
): user is User & { budibaseAccess?: string } => {
return user && user.email
}
if (isUser(user)) {
tracer.setUser({
id: user?._id,
tenantId: user?.tenantId,
budibaseAccess: user?.budibaseAccess,
status: user?.status,
id: user._id!,
tenantId: user.tenantId,
budibaseAccess: user.budibaseAccess,
status: user.status,
})
}
// isAuthenticated is a function, so use a variable to be able to check authed state
finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
if (user && user.email) {
if (isUser(user)) {
return identity.doInUserContext(user, ctx, next)
} else {
return next()

View File

@ -48,6 +48,7 @@
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { FIELDS } from "constants/backend"
export let block
export let testData
@ -228,6 +229,10 @@
categoryName,
bindingName
) => {
const field = Object.values(FIELDS).find(
field => field.type === value.type && field.subtype === value.subtype
)
return {
readableBinding: bindingName
? `${bindingName}.${name}`
@ -238,7 +243,7 @@
icon,
category: categoryName,
display: {
type: value.type,
type: field?.name || value.type,
name,
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
},
@ -282,6 +287,7 @@
for (const key in table?.schema) {
schema[key] = {
type: table.schema[key].type,
subtype: table.schema[key].subtype,
}
}
// remove the original binding

View File

@ -55,7 +55,7 @@ export function getBindings({
)
}
const field = Object.values(FIELDS).find(
field => field.type === schema.type
field => field.type === schema.type && field.subtype === schema.subtype
)
const label = path == null ? column : `${path}.0.${column}`

View File

@ -12,8 +12,13 @@
OptionSelectDnD,
Layout,
AbsTooltip,
ProgressCircle,
} from "@budibase/bbui"
import { SWITCHABLE_TYPES, ValidColumnNameRegex } from "@budibase/shared-core"
import {
SWITCHABLE_TYPES,
ValidColumnNameRegex,
helpers,
} from "@budibase/shared-core"
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/builder"
@ -30,8 +35,8 @@
import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte"
import {
FieldType,
BBReferenceFieldSubType,
FieldType,
SourceName,
} from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
@ -67,7 +72,6 @@
let savingColumn
let deleteColName
let jsonSchemaModal
let allowedTypes = []
let editableColumn = {
type: FIELDS.STRING.type,
constraints: FIELDS.STRING.constraints,
@ -175,6 +179,11 @@
SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id
(acc, field) => ({
@ -188,7 +197,10 @@
// don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) {
return type.toUpperCase()
} else if (type === FieldType.BB_REFERENCE) {
} else if (
type === FieldType.BB_REFERENCE ||
type === FieldType.BB_REFERENCE_SINGLE
) {
return `${type}${subtype || ""}`.toUpperCase()
} else {
return type.toUpperCase()
@ -226,11 +238,6 @@
editableColumn.subtype,
editableColumn.autocolumn
)
allowedTypes = getAllowedTypes().map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
}
}
@ -245,11 +252,11 @@
}
async function saveColumn() {
savingColumn = true
if (errors?.length) {
return
}
savingColumn = true
let saveColumn = cloneDeep(editableColumn)
delete saveColumn.fieldId
@ -264,13 +271,6 @@
if (saveColumn.type !== LINK_TYPE) {
delete saveColumn.fieldName
}
if (isUsersColumn(saveColumn)) {
if (saveColumn.subtype === BBReferenceFieldSubType.USER) {
saveColumn.relationshipType = RelationshipType.ONE_TO_MANY
} else if (saveColumn.subtype === BBReferenceFieldSubType.USERS) {
saveColumn.relationshipType = RelationshipType.MANY_TO_MANY
}
}
try {
await tables.saveField({
@ -289,6 +289,8 @@
}
} catch (err) {
notifications.error(`Error saving column: ${err.message}`)
} finally {
savingColumn = false
}
}
@ -363,20 +365,36 @@
deleteColName = ""
}
function getAllowedTypes() {
function getAllowedTypes(datasource) {
if (originalName) {
const possibleTypes = SWITCHABLE_TYPES[field.type] || [
editableColumn.type,
]
let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type]
if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) {
// This will handle old single users columns
return [
{
...FIELDS.USER,
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
]
} else if (
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === BBReferenceFieldSubType.USERS
) {
// This will handle old multi users columns
return [
{
...FIELDS.USERS,
subtype: BBReferenceFieldSubType.USERS,
},
]
}
return Object.entries(FIELDS)
.filter(([_, field]) => possibleTypes.includes(field.type))
.map(([_, fieldDefinition]) => fieldDefinition)
}
const isUsers =
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === BBReferenceFieldSubType.USERS
if (!externalTable) {
return [
FIELDS.STRING,
@ -393,7 +411,8 @@
FIELDS.LINK,
FIELDS.FORMULA,
FIELDS.JSON,
isUsers ? FIELDS.USERS : FIELDS.USER,
FIELDS.USER,
FIELDS.USERS,
FIELDS.AUTO,
]
} else {
@ -407,8 +426,12 @@
FIELDS.BOOLEAN,
FIELDS.FORMULA,
FIELDS.BIGINT,
isUsers ? FIELDS.USERS : FIELDS.USER,
FIELDS.USER,
]
if (datasource && datasource.source !== SourceName.GOOGLE_SHEETS) {
fields.push(FIELDS.USERS)
}
// no-sql or a spreadsheet
if (!externalTable || table.sql) {
fields = [...fields, FIELDS.LINK, FIELDS.ARRAY]
@ -482,15 +505,6 @@
return newError
}
function isUsersColumn(column) {
return (
column.type === FieldType.BB_REFERENCE &&
[BBReferenceFieldSubType.USER, BBReferenceFieldSubType.USERS].includes(
column.subtype
)
)
}
onMount(() => {
mounted = true
})
@ -689,22 +703,6 @@
<Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button
>
{:else if isUsersColumn(editableColumn) && datasource?.source !== SourceName.GOOGLE_SHEETS}
<Toggle
value={editableColumn.subtype === BBReferenceFieldSubType.USERS}
on:change={e =>
handleTypeChange(
makeFieldId(
FieldType.BB_REFERENCE,
e.detail
? BBReferenceFieldSubType.USERS
: BBReferenceFieldSubType.USER
)
)}
disabled={!isCreating}
thin
text="Allow multiple users"
/>
{/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select
@ -739,7 +737,20 @@
<Button quiet warning text on:click={confirmDelete}>Delete</Button>
{/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
<Button disabled={invalid} newStyles cta on:click={saveColumn}>Save</Button>
<Button
disabled={invalid || savingColumn}
newStyles
cta
on:click={saveColumn}
>
{#if savingColumn}
<div class="save-loading">
<ProgressCircle overBackground={true} size="S" />
</div>
{:else}
Save
{/if}
</Button>
</div>
<Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal
@ -804,4 +815,9 @@
cursor: pointer;
color: var(--spectrum-global-color-gray-900);
}
.save-loading {
display: flex;
justify-content: center;
}
</style>

View File

@ -13,7 +13,9 @@
onMount(() => subscribe("edit-column", editColumn))
</script>
<CreateEditColumn
field={editableColumn}
on:updatecolumns={rows.actions.refreshData}
/>
{#if editableColumn}
<CreateEditColumn
field={editableColumn}
on:updatecolumns={rows.actions.refreshData}
/>
{/if}

View File

@ -59,13 +59,17 @@
value: FieldType.ATTACHMENTS,
},
{
label: "User",
label: "Users",
value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USER}`,
},
{
label: "Users",
value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USERS}`,
},
{
label: "User",
value: `${FieldType.BB_REFERENCE_SINGLE}${BBReferenceFieldSubType.USER}`,
},
]
$: {

View File

@ -47,4 +47,5 @@ export const FieldTypeToComponentMap = {
[FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner",
[FieldType.BB_REFERENCE]: "bbreferencefield",
[FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield",
}

View File

@ -159,15 +159,17 @@ export const FIELDS = {
},
USER: {
name: "User",
type: FieldType.BB_REFERENCE,
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.USER],
icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][
BBReferenceFieldSubType.USER
],
},
USERS: {
name: "Users",
name: "User List",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
icon: TypeIconMap[FieldType.USERS],
subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER],
constraints: {
type: "array",
},

View File

@ -29,6 +29,7 @@ import { JSONUtils, Constants } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
import { FIELDS } from "constants/backend"
const { ContextScopes } = Constants
@ -491,7 +492,7 @@ const generateComponentContextBindings = (asset, componentContext) => {
icon: bindingCategory.icon,
display: {
name: `${fieldSchema.name || key}`,
type: fieldSchema.type,
type: fieldSchema.display?.type || fieldSchema.type,
},
})
})
@ -1019,15 +1020,23 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// are objects
let fixedSchema = {}
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
const field = Object.values(FIELDS).find(
field =>
field.type === fieldSchema.type &&
field.subtype === fieldSchema.subtype
)
if (typeof fieldSchema === "string") {
fixedSchema[fieldName] = {
type: fieldSchema,
name: fieldName,
display: { type: fieldSchema },
}
} else {
fixedSchema[fieldName] = {
...fieldSchema,
name: fieldName,
display: { type: field?.name || fieldSchema.type },
}
}
})

View File

@ -71,6 +71,7 @@
"multifieldselect",
"s3upload",
"codescanner",
"bbreferencesinglefield",
"bbreferencefield"
]
},

View File

@ -7025,8 +7025,8 @@
},
"bbreferencefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field",
"icon": "User",
"name": "User List Field",
"icon": "UserGroup",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
@ -7130,5 +7130,113 @@
]
}
]
},
"bbreferencesinglefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field",
"icon": "User",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
"size": {
"width": 400,
"height": 50
},
"settings": [
{
"type": "field/bb_reference_single",
"label": "Field",
"key": "field",
"required": true
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
},
{
"type": "text",
"label": "Help text",
"key": "helpText"
},
{
"type": "event",
"label": "On change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{
"type": "validation/link",
"label": "Validation",
"key": "validation"
},
{
"type": "boolean",
"label": "Search",
"key": "autocomplete",
"defaultValue": true
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "boolean",
"label": "Read only",
"key": "readonly",
"defaultValue": false,
"dependsOn": {
"setting": "disabled",
"value": true,
"invert": true
}
},
{
"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"
}
]
}
]
}
}

View File

@ -21,6 +21,7 @@
[FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner",
[FieldType.BB_REFERENCE]: "bbreferencefield",
[FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield",
}
const getFieldSchema = field => {

View File

@ -1,8 +1,10 @@
<script>
import RelationshipField from "./RelationshipField.svelte"
import { sdk } from "@budibase/shared-core"
import { FieldType } from "@budibase/types"
import RelationshipField from "./RelationshipField.svelte"
export let defaultValue
export let type = FieldType.BB_REFERENCE
function updateUserIDs(value) {
if (Array.isArray(value)) {
@ -22,6 +24,7 @@
<RelationshipField
{...$$props}
{type}
datasourceType={"user"}
primaryDisplay={"email"}
defaultValue={updateReferences(defaultValue)}

View File

@ -0,0 +1,6 @@
<script>
import { FieldType } from "@budibase/types"
import BBReferenceField from "./BBReferenceField.svelte"
</script>
<BBReferenceField {...$$restProps} type={FieldType.BB_REFERENCE_SINGLE} />

View File

@ -1,9 +1,9 @@
<script>
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext, onMount } from "svelte"
import { getContext } from "svelte"
import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants"
const { API } = getContext("sdk")
@ -21,6 +21,7 @@
export let primaryDisplay
export let span
export let helpText = null
export let type = FieldType.LINK
let fieldState
let fieldApi
@ -28,12 +29,10 @@
let tableDefinition
let searchTerm
let open
let initialValue
$: type =
datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE
$: multiselect = fieldSchema?.relationshipType !== "one-to-many"
$: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many"
$: linkedTableId = fieldSchema?.tableId
$: fetch = fetchData({
API,
@ -52,18 +51,19 @@
? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0]
$: component = multiselect ? CoreMultiselect : CoreSelect
$: expandedDefaultValue = expand(defaultValue)
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
let optionsObj = {}
let initialValuesProcessed
let optionsObj
$: {
if (!initialValuesProcessed && primaryDisplay) {
if (primaryDisplay && fieldState && !optionsObj) {
// Persist the initial values as options, allowing them to be present in the dropdown,
// even if they are not in the inital fetch results
initialValuesProcessed = true
optionsObj = (fieldState?.value || []).reduce((accumulator, value) => {
let valueAsSafeArray = fieldState.value || []
if (!Array.isArray(valueAsSafeArray)) {
valueAsSafeArray = [fieldState.value]
}
optionsObj = valueAsSafeArray.reduce((accumulator, value) => {
// fieldState has to be an array of strings to be valid for an update
// therefore we cannot guarantee value will be an object
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
@ -75,7 +75,7 @@
[primaryDisplay]: value.primaryDisplay,
}
return accumulator
}, optionsObj)
}, {})
}
}
@ -86,7 +86,7 @@
accumulator[row._id] = row
}
return accumulator
}, optionsObj)
}, optionsObj || {})
return Object.values(result)
}
@ -110,17 +110,10 @@
}
$: forceFetchRows(filter)
$: debouncedFetchRows(
searchTerm,
primaryDisplay,
initialValue || defaultValue
)
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
const forceFetchRows = async () => {
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
optionsObj = {}
fieldApi?.setValue([])
selectedValue = []
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
}
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
@ -136,7 +129,7 @@
if (defaultVal && !Array.isArray(defaultVal)) {
defaultVal = defaultVal.split(",")
}
if (defaultVal && defaultVal.some(val => !optionsObj[val])) {
if (defaultVal && optionsObj && defaultVal.some(val => !optionsObj[val])) {
await fetch.update({
query: { oneOf: { _id: defaultVal } },
})
@ -162,16 +155,13 @@
if (!values) {
return []
}
if (!Array.isArray(values)) {
values = [values]
}
values = values.map(value =>
typeof value === "object" ? value._id : value
)
// Make sure field state is valid
if (values?.length > 0) {
fieldApi.setValue(values)
}
return values
}
@ -179,25 +169,20 @@
return row?.[primaryDisplay] || "-"
}
const singleHandler = e => {
handleChange(e.detail == null ? [] : [e.detail])
}
const multiHandler = e => {
handleChange(e.detail)
}
const expand = values => {
if (!values) {
return []
const handleChange = e => {
let value = e.detail
if (!multiselect) {
value = value == null ? [] : [value]
}
if (
type === FieldType.BB_REFERENCE_SINGLE &&
value &&
Array.isArray(value)
) {
value = value[0] || null
}
if (Array.isArray(values)) {
return values
}
return values.split(",").map(value => value.trim())
}
const handleChange = value => {
const changed = fieldApi.setValue(value)
if (onChange && changed) {
onChange({
@ -211,16 +196,6 @@
fetch.nextPage()
}
}
onMount(() => {
// if the form is in 'Update' mode, then we need to fetch the matching row so that the value is correctly set
if (fieldState?.value) {
initialValue =
fieldSchema?.relationshipType !== "one-to-many"
? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0]
}
})
</script>
<Field
@ -229,7 +204,7 @@
{disabled}
{readonly}
{validation}
defaultValue={expandedDefaultValue}
{defaultValue}
{type}
{span}
{helpText}
@ -243,7 +218,7 @@
options={enrichedOptions}
{autocomplete}
value={selectedValue}
on:change={multiselect ? multiHandler : singleHandler}
on:change={handleChange}
on:loadMore={loadMore}
id={fieldState.fieldId}
disabled={fieldState.disabled}

View File

@ -17,3 +17,4 @@ export { default as jsonfield } from "./JSONField.svelte"
export { default as s3upload } from "./S3Upload.svelte"
export { default as codescanner } from "./CodeScannerField.svelte"
export { default as bbreferencefield } from "./BBReferenceField.svelte"
export { default as bbreferencesinglefield } from "./BBReferenceSingleField.svelte"

View File

@ -125,6 +125,7 @@
filter.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype
filter.formulaType = fieldSchema?.formulaType
filter.constraints = fieldSchema?.constraints
// Update external type based on field
filter.externalType = getSchema(filter)?.externalType
@ -281,7 +282,7 @@
timeOnly={getSchema(filter)?.timeOnly}
bind:value={filter.value}
/>
{:else if filter.type === FieldType.BB_REFERENCE}
{:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)}
<FilterUsers
bind:value={filter.value}
multiselect={[

View File

@ -1,19 +1,27 @@
<script>
import { getContext } from "svelte"
import { helpers } from "@budibase/shared-core"
import RelationshipCell from "./RelationshipCell.svelte"
import { BBReferenceFieldSubType, RelationshipType } from "@budibase/types"
import {
BBReferenceFieldSubType,
FieldType,
RelationshipType,
} from "@budibase/types"
export let api
export let hideCounter = false
export let schema
const { API } = getContext("grid")
const { subtype } = $$props.schema
const { type, subtype } = schema
const schema = {
$: schema = {
...$$props.schema,
// This is not really used, just adding some content to be able to render the relationship cell
tableId: "external",
relationshipType:
subtype === BBReferenceFieldSubType.USER
type === FieldType.BB_REFERENCE_SINGLE ||
helpers.schema.isDeprecatedSingleUserColumn(schema)
? RelationshipType.ONE_TO_MANY
: RelationshipType.MANY_TO_MANY,
}
@ -44,8 +52,9 @@
<RelationshipCell
bind:api
{...$$props}
{...$$restProps}
{schema}
{searchFunction}
primaryDisplay={"email"}
{hideCounter}
/>

View File

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

View File

@ -17,6 +17,7 @@
export let contentLines = 1
export let searchFunction = API.searchTable
export let primaryDisplay
export let hideCounter = false
const color = getColor(0)
@ -263,7 +264,7 @@
</div>
{/if}
</div>
{#if value?.length}
{#if !hideCounter && value?.length}
<div class="count">
{value?.length || 0}
</div>

View File

@ -7,11 +7,6 @@
} from "@budibase/bbui"
import { getContext } from "svelte"
import { ValidColumnNameRegex } from "@budibase/shared-core"
import {
BBReferenceFieldSubType,
FieldType,
RelationshipType,
} from "@budibase/types"
const { API, definition, rows } = getContext("grid")
@ -33,20 +28,11 @@
}
const migrateUserColumn = async () => {
let subtype = BBReferenceFieldSubType.USERS
if (column.schema.relationshipType === RelationshipType.ONE_TO_MANY) {
subtype = BBReferenceFieldSubType.USER
}
try {
await API.migrateColumn({
tableId: $definition._id,
oldColumn: column.schema,
newColumn: {
name: newColumnName,
type: FieldType.BB_REFERENCE,
subtype,
},
oldColumn: column.schema.name,
newColumn: newColumnName,
})
notifications.success("Column migrated")
} catch (e) {

View File

@ -1,6 +1,7 @@
<script>
import { getContext } from "svelte"
import DataCell from "../cells/DataCell.svelte"
import { getCellID } from "../lib/utils"
export let row
export let top = false
@ -38,7 +39,7 @@
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
>
{#each $visibleColumns as column, columnIdx}
{@const cellId = `${row._id}-${column.name}`}
{@const cellId = getCellID(row._id, column.name)}
<DataCell
{cellId}
{column}

View File

@ -7,6 +7,7 @@
import { GutterWidth, NewRowID } from "../lib/constants"
import GutterCell from "../cells/GutterCell.svelte"
import KeyboardShortcut from "./KeyboardShortcut.svelte"
import { getCellID } from "../lib/utils"
const {
hoveredRowId,
@ -70,7 +71,7 @@
// Select the first cell if possible
if (firstColumn) {
$focusedCellId = `${savedRow._id}-${firstColumn.name}`
$focusedCellId = getCellID(savedRow._id, firstColumn.name)
}
}
isAdding = false
@ -118,7 +119,7 @@
visible = true
$hoveredRowId = NewRowID
if (firstColumn) {
$focusedCellId = `${NewRowID}-${firstColumn.name}`
$focusedCellId = getCellID(NewRowID, firstColumn.name)
}
// Attach key listener
@ -194,7 +195,7 @@
{/if}
</GutterCell>
{#if $stickyColumn}
{@const cellId = `${NewRowID}-${$stickyColumn.name}`}
{@const cellId = getCellID(NewRowID, $stickyColumn.name)}
<DataCell
{cellId}
rowFocused

View File

@ -8,6 +8,7 @@
import { GutterWidth, BlankRowID } from "../lib/constants"
import GutterCell from "../cells/GutterCell.svelte"
import KeyboardShortcut from "./KeyboardShortcut.svelte"
import { getCellID } from "../lib/utils"
const {
rows,
@ -71,7 +72,7 @@
{@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id}
{@const rowFocused = $focusedRow?._id === row._id}
{@const cellId = `${row._id}-${$stickyColumn?.name}`}
{@const cellId = getCellID(row._id, $stickyColumn?.name)}
<div
class="row"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}

View File

@ -13,6 +13,7 @@ import JSONCell from "../cells/JSONCell.svelte"
import AttachmentCell from "../cells/AttachmentCell.svelte"
import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte"
import BBReferenceCell from "../cells/BBReferenceCell.svelte"
import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte"
const TypeComponentMap = {
[FieldType.STRING]: TextCell,
@ -29,6 +30,7 @@ const TypeComponentMap = {
[FieldType.FORMULA]: FormulaCell,
[FieldType.JSON]: JSONCell,
[FieldType.BB_REFERENCE]: BBReferenceCell,
[FieldType.BB_REFERENCE_SINGLE]: BBReferenceSingleCell,
}
export const getCellRenderer = column => {
return TypeComponentMap[column?.schema?.type] || TextCell

View File

@ -1,5 +1,23 @@
import { helpers } from "@budibase/shared-core"
import { TypeIconMap } from "../../../constants"
// we can't use "-" for joining the ID/field, as this can be present in the ID or column name
// using something very unusual to avoid this problem
const JOINING_CHARACTER = "‽‽"
export const parseCellID = rowId => {
if (!rowId) {
return undefined
}
const parts = rowId.split(JOINING_CHARACTER)
const field = parts.pop()
return { id: parts.join(JOINING_CHARACTER), field }
}
export const getCellID = (rowId, fieldName) => {
return `${rowId}${JOINING_CHARACTER}${fieldName}`
}
export const getColor = (idx, opacity = 0.3) => {
if (idx == null || idx === -1) {
idx = 0
@ -11,8 +29,12 @@ export const getColumnIcon = column => {
if (column.schema.autocolumn) {
return "MagicWand"
}
const { type, subtype } = column.schema
if (helpers.schema.isDeprecatedSingleUserColumn(column.schema)) {
return "User"
}
const { type, subtype } = column.schema
const result =
typeof TypeIconMap[type] === "object" && subtype
? TypeIconMap[type][subtype]

View File

@ -2,6 +2,7 @@
import { getContext, onMount } from "svelte"
import { debounce } from "../../../utils/utils"
import { NewRowID } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
const {
rows,
@ -154,7 +155,7 @@
if (!firstColumn) {
return
}
focusedCellId.set(`${firstRow._id}-${firstColumn.name}`)
focusedCellId.set(getCellID(firstRow._id, firstColumn.name))
}
// Changes the focused cell by moving it left or right to a different column
@ -163,8 +164,7 @@
return
}
const cols = $visibleColumns
const split = $focusedCellId.split("-")
const columnName = split[1]
const { id, field: columnName } = parseCellID($focusedCellId)
let newColumnName
if (columnName === $stickyColumn?.name) {
const index = delta - 1
@ -178,7 +178,7 @@
}
}
if (newColumnName) {
$focusedCellId = `${split[0]}-${newColumnName}`
$focusedCellId = getCellID(id, newColumnName)
}
}
@ -189,8 +189,8 @@
}
const newRow = $rows[$focusedRow.__idx + delta]
if (newRow) {
const split = $focusedCellId.split("-")
$focusedCellId = `${newRow._id}-${split[1]}`
const { field } = parseCellID($focusedCellId)
$focusedCellId = getCellID(newRow._id, field)
}
}

View File

@ -3,6 +3,7 @@
import { getContext } from "svelte"
import { NewRowID } from "../lib/constants"
import GridPopover from "./GridPopover.svelte"
import { getCellID } from "../lib/utils"
const {
focusedRow,
@ -41,7 +42,7 @@
const newRow = await rows.actions.duplicateRow($focusedRow)
if (newRow) {
const column = $stickyColumn?.name || $columns[0].name
$focusedCellId = `${newRow._id}-${column}`
$focusedCellId = getCellID(newRow._id, column)
}
}

View File

@ -1,6 +1,7 @@
import { writable, derived, get } from "svelte/store"
import { fetchData } from "../../../fetch"
import { NewRowID, RowPageSize } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
import { tick } from "svelte"
import { Helpers } from "@budibase/bbui"
@ -206,7 +207,7 @@ export const createActions = context => {
// If the server doesn't reply with a valid error, assume that the source
// of the error is the focused cell's column
if (!error?.json?.validationErrors && errorString) {
const focusedColumn = get(focusedCellId)?.split("-")[1]
const { field: focusedColumn } = parseCellID(get(focusedCellId))
if (focusedColumn) {
error = {
json: {
@ -245,7 +246,7 @@ export const createActions = context => {
}
// Set error against the cell
validation.actions.setError(
`${rowId}-${column}`,
getCellID(rowId, column),
Helpers.capitalise(err)
)
// Ensure the column is visible
@ -265,7 +266,7 @@ export const createActions = context => {
// Focus the first cell with an error
if (erroredColumns.length) {
focusedCellId.set(`${rowId}-${erroredColumns[0]}`)
focusedCellId.set(getCellID(rowId, erroredColumns[0]))
}
} else {
get(notifications).error(errorString || "An unknown error occurred")
@ -571,9 +572,10 @@ export const initialise = context => {
return
}
// Stop if we changed row
const oldRowId = id.split("-")[0]
const oldColumn = id.split("-")[1]
const newRowId = get(focusedCellId)?.split("-")[0]
const split = parseCellID(id)
const oldRowId = split.id
const oldColumn = split.field
const { id: newRowId } = parseCellID(get(focusedCellId))
if (oldRowId !== newRowId) {
return
}

View File

@ -1,6 +1,7 @@
import { writable, derived, get } from "svelte/store"
import { tick } from "svelte"
import { Padding, GutterWidth, FocusedCellMinOffset } from "../lib/constants"
import { parseCellID } from "../lib/utils"
export const createStores = () => {
const scroll = writable({
@ -176,7 +177,7 @@ export const initialise = context => {
// Ensure horizontal position is viewable
// Check horizontal position of columns next
const $visibleColumns = get(visibleColumns)
const columnName = $focusedCellId?.split("-")[1]
const { field: columnName } = parseCellID($focusedCellId)
const column = $visibleColumns.find(col => col.name === columnName)
if (!column) {
return

View File

@ -7,6 +7,7 @@ import {
MediumRowHeight,
NewRowID,
} from "../lib/constants"
import { parseCellID } from "../lib/utils"
export const createStores = context => {
const { props } = context
@ -25,7 +26,7 @@ export const createStores = context => {
const focusedRowId = derived(
focusedCellId,
$focusedCellId => {
return $focusedCellId?.split("-")[0]
return parseCellID($focusedCellId)?.id
},
null
)
@ -72,7 +73,7 @@ export const deriveStores = context => {
const focusedRow = derived(
[focusedCellId, rowLookupMap, rows],
([$focusedCellId, $rowLookupMap, $rows]) => {
const rowId = $focusedCellId?.split("-")[0]
const rowId = parseCellID($focusedCellId)?.id
// Edge case for new rows
if (rowId === NewRowID) {
@ -152,7 +153,7 @@ export const initialise = context => {
const hasRow = rows.actions.hasRow
// Check selected cell
const selectedRowId = $focusedCellId?.split("-")[0]
const selectedRowId = parseCellID($focusedCellId)?.id
if (selectedRowId && !hasRow(selectedRowId)) {
focusedCellId.set(null)
}

View File

@ -1,4 +1,5 @@
import { writable, get, derived } from "svelte/store"
import { getCellID, parseCellID } from "../lib/utils"
// Normally we would break out actions into the explicit "createActions"
// function, but for validation all these actions are pure so can go into
@ -12,7 +13,7 @@ export const createStores = () => {
Object.entries($validation).forEach(([key, error]) => {
// Extract row ID from all errored cell IDs
if (error) {
map[key.split("-")[0]] = true
map[parseCellID(key).id] = true
}
})
return map
@ -53,10 +54,10 @@ export const initialise = context => {
const $stickyColumn = get(stickyColumn)
validation.update(state => {
$columns.forEach(column => {
state[`${id}-${column.name}`] = null
state[getCellID(id, column.name)] = null
})
if ($stickyColumn) {
state[`${id}-${$stickyColumn.name}`] = null
state[getCellID(id, stickyColumn.name)] = null
}
return state
})

View File

@ -132,10 +132,11 @@ export const TypeIconMap = {
[FieldType.JSON]: "Brackets",
[FieldType.BIGINT]: "TagBold",
[FieldType.AUTO]: "MagicWand",
[FieldType.USER]: "User",
[FieldType.USERS]: "UserGroup",
[FieldType.BB_REFERENCE]: {
[BBReferenceFieldSubType.USER]: "User",
[BBReferenceFieldSubType.USER]: "UserGroup",
[BBReferenceFieldSubType.USERS]: "UserGroup",
},
[FieldType.BB_REFERENCE_SINGLE]: {
[BBReferenceFieldSubType.USER]: "User",
},
}

View File

@ -1,5 +1,6 @@
// need to handle table name + field or just field, depending on if relationships used
import { FieldType, Row, Table } from "@budibase/types"
import { helpers } from "@budibase/shared-core"
import { generateRowIdField } from "../../../../integrations/utils"
import { CONSTANT_INTERNAL_ROW_COLS } from "../../../../db/utils"
@ -107,12 +108,17 @@ export function basicProcessing({
export function fixArrayTypes(row: Row, table: Table) {
for (let [fieldName, schema] of Object.entries(table.schema)) {
if (schema.type === FieldType.ARRAY && typeof row[fieldName] === "string") {
if (
[FieldType.ARRAY, FieldType.BB_REFERENCE].includes(schema.type) &&
typeof row[fieldName] === "string"
) {
try {
row[fieldName] = JSON.parse(row[fieldName])
} catch (err) {
// couldn't convert back to array, ignore
delete row[fieldName]
if (!helpers.schema.isDeprecatedSingleUserColumn(schema)) {
// couldn't convert back to array, ignore
delete row[fieldName]
}
}
}
}

View File

@ -180,5 +180,5 @@ export async function migrate(ctx: UserCtx<MigrateRequest, MigrateResponse>) {
}
ctx.status = 200
ctx.body = { message: `Column ${oldColumn.name} migrated.` }
ctx.body = { message: `Column ${oldColumn} migrated.` }
}

View File

@ -16,6 +16,7 @@ import {
SourceName,
Table,
TableSchema,
SupportedSqlTypes,
} from "@budibase/types"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import { tableForDatasource } from "../../../tests/utilities/structures"
@ -261,20 +262,6 @@ describe("/datasources", () => {
})
)
type SupportedSqlTypes =
| FieldType.STRING
| FieldType.BARCODEQR
| FieldType.LONGFORM
| FieldType.OPTIONS
| FieldType.DATETIME
| FieldType.NUMBER
| FieldType.BOOLEAN
| FieldType.FORMULA
| FieldType.BIGINT
| FieldType.BB_REFERENCE
| FieldType.LINK
| FieldType.ARRAY
const fullSchema: {
[type in SupportedSqlTypes]: FieldSchema & { type: type }
} = {
@ -337,7 +324,12 @@ describe("/datasources", () => {
[FieldType.BB_REFERENCE]: {
name: "bb_reference",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
subtype: BBReferenceFieldSubType.USER,
},
[FieldType.BB_REFERENCE_SINGLE]: {
name: "bb_reference_single",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
},
}

View File

@ -44,7 +44,7 @@ describe.each([
const snippets = [
{
name: "WeeksAgo",
code: "return function (weeks) {\n const currentTime = new Date();\n currentTime.setDate(currentTime.getDate()-(7 * (weeks || 1)));\n return currentTime.toISOString();\n}",
code: `return function (weeks) {\n const currentTime = new Date(${Date.now()});\n currentTime.setDate(currentTime.getDate()-(7 * (weeks || 1)));\n return currentTime.toISOString();\n}`,
},
]
@ -262,11 +262,19 @@ describe.each([
{ name: "serverDate", appointment: serverTime.toISOString() },
{
name: "single user, session user",
single_user: JSON.stringify([currentUser]),
single_user: JSON.stringify(currentUser),
},
{
name: "single user",
single_user: JSON.stringify([globalUsers[0]]),
single_user: JSON.stringify(globalUsers[0]),
},
{
name: "deprecated single user, session user",
deprecated_single_user: JSON.stringify([currentUser]),
},
{
name: "deprecated single user",
deprecated_single_user: JSON.stringify([globalUsers[0]]),
},
{
name: "multi user",
@ -276,6 +284,14 @@ describe.each([
name: "multi user with session user",
multi_user: JSON.stringify([...globalUsers, currentUser]),
},
{
name: "deprecated multi user",
deprecated_multi_user: JSON.stringify(globalUsers),
},
{
name: "deprecated multi user with session user",
deprecated_multi_user: JSON.stringify([...globalUsers, currentUser]),
},
]
}
@ -301,13 +317,29 @@ describe.each([
appointment: { name: "appointment", type: FieldType.DATETIME },
single_user: {
name: "single_user",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
},
deprecated_single_user: {
name: "deprecated_single_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
multi_user: {
name: "multi_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: {
type: "array",
},
},
deprecated_multi_user: {
name: "deprecated_multi_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
constraints: {
type: "array",
},
},
})
await createRows(rows(config.getUser()))
@ -398,7 +430,18 @@ describe.each([
}).toContainExactly([
{
name: "single user, session user",
single_user: [{ _id: config.getUser()._id }],
single_user: { _id: config.getUser()._id },
},
])
})
it("should match a deprecated single user row by the session user id", async () => {
await expectQuery({
equal: { deprecated_single_user: "{{ [user]._id }}" },
}).toContainExactly([
{
name: "deprecated single user, session user",
deprecated_single_user: [{ _id: config.getUser()._id }],
},
])
})
@ -420,6 +463,23 @@ describe.each([
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should match the session user id in a deprecated multi user field", async () => {
const allUsers = [...globalUsers, config.getUser()].map((user: any) => {
return { _id: user._id }
})
await expectQuery({
contains: { deprecated_multi_user: ["{{ [user]._id }}"] },
}).toContainExactly([
{
name: "deprecated multi user with session user",
deprecated_multi_user: allUsers,
},
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should not match the session user id in a multi user field", async () => {
@ -436,6 +496,22 @@ describe.each([
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should not match the session user id in a deprecated multi user field", async () => {
await expectQuery({
notContains: { deprecated_multi_user: ["{{ [user]._id }}"] },
notEmpty: { deprecated_multi_user: true },
}).toContainExactly([
{
name: "deprecated multi user",
deprecated_multi_user: globalUsers.map((user: any) => {
return { _id: user._id }
}),
},
])
})
it("should match the session user id and a user table row id using helpers, user binding and a static user id.", async () => {
await expectQuery({
oneOf: {
@ -447,11 +523,31 @@ describe.each([
}).toContainExactly([
{
name: "single user, session user",
single_user: [{ _id: config.getUser()._id }],
single_user: { _id: config.getUser()._id },
},
{
name: "single user",
single_user: [{ _id: globalUsers[0]._id }],
single_user: { _id: globalUsers[0]._id },
},
])
})
it("should match the session user id and a user table row id using helpers, user binding and a static user id. (deprecated single user)", async () => {
await expectQuery({
oneOf: {
deprecated_single_user: [
"{{ default [user]._id '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "deprecated single user, session user",
deprecated_single_user: [{ _id: config.getUser()._id }],
},
{
name: "deprecated single user",
deprecated_single_user: [{ _id: globalUsers[0]._id }],
},
])
})
@ -467,7 +563,23 @@ describe.each([
}).toContainExactly([
{
name: "single user",
single_user: [{ _id: globalUsers[0]._id }],
single_user: { _id: globalUsers[0]._id },
},
])
})
it("should resolve 'default' helper to '_empty_' when binding resolves to nothing (deprecated single user)", async () => {
await expectQuery({
oneOf: {
deprecated_single_user: [
"{{ default [user]._idx '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "deprecated single user",
deprecated_single_user: [{ _id: globalUsers[0]._id }],
},
])
})

View File

@ -545,16 +545,16 @@ describe.each([
)
await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"],
newColumn: {
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
oldColumn: "user relationship",
newColumn: "user column",
})
const migratedTable = await config.api.table.get(table._id!)
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user column"]).toEqual({
name: "user column",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
})
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const migratedRows = await config.api.row.fetch(table._id!)
@ -567,7 +567,7 @@ describe.each([
expect(migratedRow["user column"]).toBeDefined()
expect(migratedRow["user relationship"]).not.toBeDefined()
expect(row["user relationship"][0]._id).toEqual(
migratedRow["user column"][0]._id
migratedRow["user column"]._id
)
}
})
@ -610,16 +610,19 @@ describe.each([
)
await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"],
newColumn: {
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
oldColumn: "user relationship",
newColumn: "user column",
})
const migratedTable = await config.api.table.get(table._id!)
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user column"]).toEqual({
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: {
type: "array",
},
})
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const migratedRow = await config.api.row.get(table._id!, testRow._id!)
@ -662,16 +665,19 @@ describe.each([
})
await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"],
newColumn: {
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
oldColumn: "user relationship",
newColumn: "user column",
})
const migratedTable = await config.api.table.get(table._id!)
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user column"]).toEqual({
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: {
type: "array",
},
})
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const row1Migrated = await config.api.row.get(table._id!, row1._id!)
@ -717,16 +723,19 @@ describe.each([
})
await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"],
newColumn: {
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
oldColumn: "user relationship",
newColumn: "user column",
})
const migratedTable = await config.api.table.get(table._id!)
expect(migratedTable.schema["user column"]).toBeDefined()
expect(migratedTable.schema["user column"]).toEqual({
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: {
type: "array",
},
})
expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const row1Migrated = await config.api.row.get(table._id!, row1._id!)
@ -776,12 +785,8 @@ describe.each([
await config.api.table.migrate(
table._id!,
{
oldColumn: table.schema["user relationship"],
newColumn: {
name: "",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
oldColumn: "user relationship",
newColumn: "",
},
{ status: 400 }
)
@ -791,12 +796,8 @@ describe.each([
await config.api.table.migrate(
table._id!,
{
oldColumn: table.schema["user relationship"],
newColumn: {
name: "_id",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
oldColumn: "user relationship",
newColumn: "_id",
},
{ status: 400 }
)
@ -806,12 +807,8 @@ describe.each([
await config.api.table.migrate(
table._id!,
{
oldColumn: table.schema["user relationship"],
newColumn: {
name: "num",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
oldColumn: "user relationship",
newColumn: "num",
},
{ status: 400 }
)
@ -821,16 +818,8 @@ describe.each([
await config.api.table.migrate(
table._id!,
{
oldColumn: {
name: "not a column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
newColumn: {
name: "new column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
oldColumn: "not a column",
newColumn: "new column",
},
{ status: 400 }
)

View File

@ -88,7 +88,7 @@ describe.each(
let res = await setup.runStep(setup.actions.EXECUTE_QUERY.stepId, {
query: { queryId: "wrong_id" },
})
expect(res.response).toEqual("Error: missing")
expect(res.response).toEqual("Error: CouchDB error: missing")
expect(res.success).toEqual(false)
})
})

View File

@ -12,7 +12,6 @@ import SqlTableQueryBuilder from "./sqlTable"
import {
BBReferenceFieldMetadata,
FieldSchema,
BBReferenceFieldSubType,
FieldType,
JsonFieldMetadata,
Operation,
@ -27,6 +26,7 @@ import {
INTERNAL_TABLE_SOURCE_ID,
} from "@budibase/types"
import environment from "../../environment"
import { helpers } from "@budibase/shared-core"
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
@ -787,7 +787,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
return (
field.type === FieldType.JSON ||
(field.type === FieldType.BB_REFERENCE &&
field.subtype === BBReferenceFieldSubType.USERS)
!helpers.schema.isDeprecatedSingleUserColumn(field))
)
}

View File

@ -1,6 +1,5 @@
import { Knex, knex } from "knex"
import {
BBReferenceFieldSubType,
FieldType,
NumberFieldMetadata,
Operation,
@ -12,7 +11,7 @@ import {
TableSourceType,
} from "@budibase/types"
import { breakExternalTableId, getNativeSql, SqlClient } from "../utils"
import { utils } from "@budibase/shared-core"
import { helpers, utils } from "@budibase/shared-core"
import SchemaBuilder = Knex.SchemaBuilder
import CreateTableBuilder = Knex.CreateTableBuilder
@ -54,27 +53,15 @@ function generateSchema(
) {
continue
}
switch (column.type) {
const columnType = column.type
switch (columnType) {
case FieldType.STRING:
case FieldType.OPTIONS:
case FieldType.LONGFORM:
case FieldType.BARCODEQR:
case FieldType.BB_REFERENCE_SINGLE:
schema.text(key)
break
case FieldType.BB_REFERENCE: {
const subtype = column.subtype
switch (subtype) {
case BBReferenceFieldSubType.USER:
schema.text(key)
break
case BBReferenceFieldSubType.USERS:
schema.json(key)
break
default:
throw utils.unreachable(subtype)
}
break
}
case FieldType.NUMBER:
// if meta is specified then this is a junction table entry
if (column.meta && column.meta.toKey && column.meta.toTable) {
@ -97,7 +84,13 @@ function generateSchema(
})
break
case FieldType.ARRAY:
schema.json(key)
case FieldType.BB_REFERENCE:
if (helpers.schema.isDeprecatedSingleUserColumn(column)) {
// This is still required for unit testing, in order to create "deprecated" schemas
schema.text(key)
} else {
schema.json(key)
}
break
case FieldType.LINK:
// this side of the relationship doesn't need any SQL work
@ -127,6 +120,18 @@ function generateSchema(
.references(`${tableName}.${relatedPrimary}`)
}
break
case FieldType.FORMULA:
// This is allowed, but nothing to do on the external datasource
break
case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE:
case FieldType.AUTO:
case FieldType.JSON:
case FieldType.INTERNAL:
throw `${column.type} is not a valid SQL type`
default:
utils.unreachable(columnType)
}
}

View File

@ -17,6 +17,7 @@ import {
TableRequest,
TableSourceType,
DatasourcePlusQueryResponse,
BBReferenceFieldSubType,
} from "@budibase/types"
import { OAuth2Client } from "google-auth-library"
import {
@ -52,17 +53,30 @@ interface AuthTokenResponse {
access_token: string
}
const ALLOWED_TYPES = [
FieldType.STRING,
FieldType.FORMULA,
FieldType.NUMBER,
FieldType.LONGFORM,
FieldType.DATETIME,
FieldType.OPTIONS,
FieldType.BOOLEAN,
FieldType.BARCODEQR,
FieldType.BB_REFERENCE,
]
const isTypeAllowed: Record<FieldType, boolean> = {
[FieldType.STRING]: true,
[FieldType.FORMULA]: true,
[FieldType.NUMBER]: true,
[FieldType.LONGFORM]: true,
[FieldType.DATETIME]: true,
[FieldType.OPTIONS]: true,
[FieldType.BOOLEAN]: true,
[FieldType.BARCODEQR]: true,
[FieldType.BB_REFERENCE]: true,
[FieldType.BB_REFERENCE_SINGLE]: true,
[FieldType.ARRAY]: false,
[FieldType.ATTACHMENTS]: false,
[FieldType.ATTACHMENT_SINGLE]: false,
[FieldType.LINK]: false,
[FieldType.AUTO]: false,
[FieldType.JSON]: false,
[FieldType.INTERNAL]: false,
[FieldType.BIGINT]: false,
}
const ALLOWED_TYPES = Object.entries(isTypeAllowed)
.filter(([_, allowed]) => allowed)
.map(([type]) => type as FieldType)
const SCHEMA: Integration = {
plus: true,
@ -350,6 +364,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
rowIndex: json.extra?.idFilter?.equal?.rowNumber,
sheet,
row: json.body,
table: json.meta.table,
})
case Operation.DELETE:
return this.delete({
@ -371,9 +386,11 @@ class GoogleSheetsIntegration implements DatasourcePlus {
}
buildRowObject(headers: string[], values: string[], rowNumber: number) {
const rowObject: { rowNumber: number; [key: string]: any } = { rowNumber }
const rowObject: { rowNumber: number } & Row = {
rowNumber,
_id: rowNumber.toString(),
}
for (let i = 0; i < headers.length; i++) {
rowObject._id = rowNumber
rowObject[headers[i]] = values[i]
}
return rowObject
@ -430,14 +447,6 @@ class GoogleSheetsIntegration implements DatasourcePlus {
}
}
// clear out deleted columns
for (let key of sheet.headerValues) {
if (!Object.keys(table.schema).includes(key)) {
const idx = updatedHeaderValues.indexOf(key)
updatedHeaderValues.splice(idx, 1)
}
}
try {
await sheet.setHeaderRow(updatedHeaderValues)
} catch (err) {
@ -458,7 +467,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
}
}
async create(query: { sheet: string; row: any }) {
async create(query: { sheet: string; row: Row }) {
try {
await this.connect()
const sheet = this.client.sheetsByTitle[query.sheet]
@ -474,7 +483,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
}
}
async createBulk(query: { sheet: string; rows: any[] }) {
async createBulk(query: { sheet: string; rows: Row[] }) {
try {
await this.connect()
const sheet = this.client.sheetsByTitle[query.sheet]
@ -573,7 +582,12 @@ class GoogleSheetsIntegration implements DatasourcePlus {
return { sheet, row }
}
async update(query: { sheet: string; rowIndex: number; row: any }) {
async update(query: {
sheet: string
rowIndex: number
row: any
table: Table
}) {
try {
await this.connect()
const { sheet, row } = await this.getRowByIndex(
@ -589,6 +603,15 @@ class GoogleSheetsIntegration implements DatasourcePlus {
if (row[key] === null) {
row[key] = ""
}
const { type, subtype, constraints } = query.table.schema[key]
const isDeprecatedSingleUser =
type === FieldType.BB_REFERENCE &&
subtype === BBReferenceFieldSubType.USER &&
constraints?.type !== "array"
if (isDeprecatedSingleUser && Array.isArray(row[key])) {
row[key] = row[key][0]
}
}
await row.save()
return [

View File

@ -129,10 +129,11 @@ describe("Google Sheets Integration", () => {
})
expect(sheet.loadHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledTimes(1)
expect(sheet.setHeaderRow).toHaveBeenCalledWith(["name"])
// No undefined are sent
expect((sheet.setHeaderRow as any).mock.calls[0][0]).toHaveLength(1)
expect(sheet.setHeaderRow).toHaveBeenCalledWith([
"name",
"description",
"location",
])
})
})

View File

@ -383,6 +383,7 @@ function copyExistingPropsOver(
case FieldType.ATTACHMENT_SINGLE:
case FieldType.JSON:
case FieldType.BB_REFERENCE:
case FieldType.BB_REFERENCE_SINGLE:
shouldKeepSchema = keepIfType(FieldType.JSON, FieldType.STRING)
break

View File

@ -79,7 +79,9 @@ export async function search(
}
const table = await sdk.tables.getTable(options.tableId)
options = searchInputMapping(table, options)
options = searchInputMapping(table, options, {
isSql: !!table.sql || !!env.SQS_SEARCH_ENABLE,
})
if (isExternalTable) {
return external.search(options, table)

View File

@ -19,7 +19,7 @@ const tableWithUserCol: Table = {
schema: {
user: {
name: "user",
type: FieldType.BB_REFERENCE,
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
},
},
@ -35,7 +35,7 @@ const tableWithUsersCol: Table = {
user: {
name: "user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
subtype: BBReferenceFieldSubType.USER,
},
},
}

View File

@ -11,7 +11,7 @@ import {
RowSearchParams,
} from "@budibase/types"
import { db as dbCore, context } from "@budibase/backend-core"
import { utils } from "@budibase/shared-core"
import { helpers, utils } from "@budibase/shared-core"
export async function paginatedSearch(
query: SearchFilters,
@ -49,13 +49,19 @@ function findColumnInQueries(
}
}
function userColumnMapping(column: string, options: RowSearchParams) {
function userColumnMapping(
column: string,
options: RowSearchParams,
isDeprecatedSingleUserColumn: boolean = false,
isSql: boolean = false
) {
findColumnInQueries(column, options, (filterValue: any): any => {
const isArray = Array.isArray(filterValue),
isString = typeof filterValue === "string"
if (!isString && !isArray) {
return filterValue
}
const processString = (input: string) => {
const rowPrefix = DocumentType.ROW + SEPARATOR
if (input.startsWith(rowPrefix)) {
@ -64,40 +70,60 @@ function userColumnMapping(column: string, options: RowSearchParams) {
return input
}
}
let wrapper = (s: string) => s
if (isDeprecatedSingleUserColumn && filterValue && isSql) {
// Decreated single users are stored as stringified arrays of a single value
wrapper = (s: string) => JSON.stringify([s])
}
if (isArray) {
return filterValue.map(el => {
if (typeof el === "string") {
return processString(el)
return wrapper(processString(el))
} else {
return el
}
})
} else {
return processString(filterValue)
return wrapper(processString(filterValue))
}
})
}
// maps through the search parameters to check if any of the inputs are invalid
// based on the table schema, converts them to something that is valid.
export function searchInputMapping(table: Table, options: RowSearchParams) {
export function searchInputMapping(
table: Table,
options: RowSearchParams,
datasourceOptions: { isSql?: boolean } = {}
) {
if (!table?.schema) {
return options
}
for (let [key, column] of Object.entries(table.schema)) {
switch (column.type) {
case FieldType.BB_REFERENCE: {
case FieldType.BB_REFERENCE_SINGLE: {
const subtype = column.subtype
switch (subtype) {
case BBReferenceFieldSubType.USER:
case BBReferenceFieldSubType.USERS:
userColumnMapping(key, options)
break
default:
utils.unreachable(subtype)
}
break
}
case FieldType.BB_REFERENCE: {
userColumnMapping(
key,
options,
helpers.schema.isDeprecatedSingleUserColumn(column),
datasourceOptions.isSql
)
break
}
}
}
return options

View File

@ -45,6 +45,7 @@ const FieldTypeMap: Record<FieldType, SQLiteType> = {
[FieldType.BIGINT]: SQLiteType.TEXT,
// TODO: consider the difference between multi-user and single user types (subtyping)
[FieldType.BB_REFERENCE]: SQLiteType.TEXT,
[FieldType.BB_REFERENCE_SINGLE]: SQLiteType.TEXT,
}
function buildRelationshipDefinitions(

View File

@ -4,7 +4,6 @@ import {
FieldSchema,
BBReferenceFieldSubType,
InternalTable,
isBBReferenceField,
isRelationshipField,
LinkDocument,
LinkInfo,
@ -12,6 +11,8 @@ import {
RelationshipType,
Row,
Table,
FieldType,
BBReferenceSingleFieldMetadata,
} from "@budibase/types"
import sdk from "../../../sdk"
import { isExternalTableID } from "../../../integrations/utils"
@ -24,25 +25,58 @@ export interface MigrationResult {
export async function migrate(
table: Table,
oldColumn: FieldSchema,
newColumn: FieldSchema
oldColumnName: string,
newColumnName: string
): Promise<MigrationResult> {
if (newColumn.name in table.schema) {
throw new BadRequestError(`Column "${newColumn.name}" already exists`)
if (newColumnName in table.schema) {
throw new BadRequestError(`Column "${newColumnName}" already exists`)
}
if (newColumn.name === "") {
if (newColumnName === "") {
throw new BadRequestError(`Column name cannot be empty`)
}
if (dbCore.isInternalColumnName(newColumn.name)) {
if (dbCore.isInternalColumnName(newColumnName)) {
throw new BadRequestError(`Column name cannot be a reserved column name`)
}
const oldColumn = table.schema[oldColumnName]
if (!oldColumn) {
throw new BadRequestError(
`Column "${oldColumnName}" does not exist on table "${table.name}"`
)
}
if (
oldColumn.type !== FieldType.LINK ||
oldColumn.tableId !== InternalTable.USER_METADATA
) {
throw new BadRequestError(
`Only user relationship migration columns is currently supported`
)
}
const type =
oldColumn.relationshipType === RelationshipType.ONE_TO_MANY
? FieldType.BB_REFERENCE_SINGLE
: FieldType.BB_REFERENCE
const newColumn: FieldSchema = {
name: newColumnName,
type,
subtype: BBReferenceFieldSubType.USER,
}
if (newColumn.type === FieldType.BB_REFERENCE) {
newColumn.constraints = {
type: "array",
}
}
table.schema[newColumn.name] = newColumn
table = await sdk.tables.saveTable(table)
let migrator = getColumnMigrator(table, oldColumn, newColumn)
const migrator = getColumnMigrator(table, oldColumn, newColumn)
try {
return await migrator.doMigration()
} catch (e) {
@ -75,11 +109,14 @@ function getColumnMigrator(
throw new BadRequestError(`Column "${oldColumn.name}" does not exist`)
}
if (!isBBReferenceField(newColumn)) {
if (
newColumn.type !== FieldType.BB_REFERENCE_SINGLE &&
newColumn.type !== FieldType.BB_REFERENCE
) {
throw new BadRequestError(`Column "${newColumn.name}" is not a user column`)
}
if (newColumn.subtype !== "user" && newColumn.subtype !== "users") {
if (newColumn.subtype !== BBReferenceFieldSubType.USER) {
throw new BadRequestError(`Column "${newColumn.name}" is not a user column`)
}
@ -96,7 +133,7 @@ function getColumnMigrator(
}
if (oldColumn.relationshipType === RelationshipType.ONE_TO_MANY) {
if (newColumn.subtype !== BBReferenceFieldSubType.USER) {
if (newColumn.type !== FieldType.BB_REFERENCE_SINGLE) {
throw new BadRequestError(
`Column "${oldColumn.name}" is a one-to-many column but "${newColumn.name}" is not a single user column`
)
@ -107,22 +144,23 @@ function getColumnMigrator(
oldColumn.relationshipType === RelationshipType.MANY_TO_MANY ||
oldColumn.relationshipType === RelationshipType.MANY_TO_ONE
) {
if (newColumn.subtype !== BBReferenceFieldSubType.USERS) {
if (newColumn.type !== FieldType.BB_REFERENCE) {
throw new BadRequestError(
`Column "${oldColumn.name}" is a ${oldColumn.relationshipType} column but "${newColumn.name}" is not a multi user column`
)
}
return new MultiUserColumnMigrator(table, oldColumn, newColumn)
}
throw new BadRequestError(`Unknown migration type`)
}
abstract class UserColumnMigrator implements ColumnMigrator {
abstract class UserColumnMigrator<T> implements ColumnMigrator {
constructor(
protected table: Table,
protected oldColumn: RelationshipFieldMetadata,
protected newColumn: BBReferenceFieldMetadata
protected newColumn: T
) {}
abstract updateRow(row: Row, linkInfo: LinkInfo): void
@ -192,7 +230,7 @@ abstract class UserColumnMigrator implements ColumnMigrator {
}
}
class SingleUserColumnMigrator extends UserColumnMigrator {
class SingleUserColumnMigrator extends UserColumnMigrator<BBReferenceSingleFieldMetadata> {
updateRow(row: Row, linkInfo: LinkInfo): void {
row[this.newColumn.name] = dbCore.getGlobalIDFromUserMetadataID(
linkInfo.rowId
@ -200,7 +238,7 @@ class SingleUserColumnMigrator extends UserColumnMigrator {
}
}
class MultiUserColumnMigrator extends UserColumnMigrator {
class MultiUserColumnMigrator extends UserColumnMigrator<BBReferenceFieldMetadata> {
updateRow(row: Row, linkInfo: LinkInfo): void {
if (!row[this.newColumn.name]) {
row[this.newColumn.name] = []

View File

@ -9,26 +9,57 @@ import { InvalidBBRefError } from "./errors"
const ROW_PREFIX = DocumentType.ROW + SEPARATOR
export async function processInputBBReferences(
value: string | string[] | { _id: string } | { _id: string }[],
subtype: BBReferenceFieldSubType.USER | BBReferenceFieldSubType.USERS
): Promise<string | string[] | null> {
let referenceIds: string[] = []
export async function processInputBBReference(
value: string | { _id: string },
subtype: BBReferenceFieldSubType.USER
): Promise<string | null> {
if (value && Array.isArray(value)) {
throw "BB_REFERENCE_SINGLE cannot be an array"
}
let id = typeof value === "string" ? value : value?._id
if (Array.isArray(value)) {
referenceIds.push(
...value.map(idOrDoc =>
typeof idOrDoc === "string" ? idOrDoc : idOrDoc._id
)
)
} else if (typeof value !== "string") {
referenceIds.push(value._id)
if (!id) {
return null
}
switch (subtype) {
case BBReferenceFieldSubType.USER: {
if (id.startsWith(ROW_PREFIX)) {
id = dbCore.getGlobalIDFromUserMetadataID(id)
}
try {
await cache.user.getUser(id)
return id
} catch (e: any) {
if (e.statusCode === 404) {
throw new InvalidBBRefError(id, BBReferenceFieldSubType.USER)
}
throw e
}
}
default:
throw utils.unreachable(subtype)
}
}
export async function processInputBBReferences(
value: string | string[] | { _id: string }[],
subtype: BBReferenceFieldSubType
): Promise<string[] | null> {
if (!value || !value[0]) {
return null
}
let referenceIds
if (typeof value === "string") {
referenceIds = value
.split(",")
.map(u => u.trim())
.filter(u => !!u)
} else {
referenceIds.push(
...value
.split(",")
.filter(x => x)
.map((id: string) => id.trim())
referenceIds = value.map(idOrDoc =>
typeof idOrDoc === "string" ? idOrDoc : idOrDoc._id
)
}
@ -44,6 +75,8 @@ export async function processInputBBReferences(
})
switch (subtype) {
case undefined:
throw "Subtype must be defined"
case BBReferenceFieldSubType.USER:
case BBReferenceFieldSubType.USERS: {
const { notFoundIds } = await cache.user.getUsers(referenceIds)
@ -55,11 +88,54 @@ export async function processInputBBReferences(
)
}
if (subtype === BBReferenceFieldSubType.USERS) {
return referenceIds
if (!referenceIds?.length) {
return null
}
return referenceIds.join(",") || null
return referenceIds
}
default:
throw utils.unreachable(subtype)
}
}
interface UserReferenceInfo {
_id: string
primaryDisplay: string
email: string
firstName?: string
lastName?: string
}
export async function processOutputBBReference(
value: string | null | undefined,
subtype: BBReferenceFieldSubType.USER
): Promise<UserReferenceInfo | undefined> {
if (!value) {
return undefined
}
switch (subtype) {
case BBReferenceFieldSubType.USER: {
let user
try {
user = await cache.user.getUser(value as string)
} catch (err: any) {
if (err.statusCode !== 404) {
throw err
}
}
if (!user) {
return undefined
}
return {
_id: user._id!,
primaryDisplay: user.email,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
}
}
default:
throw utils.unreachable(subtype)
@ -67,14 +143,12 @@ export async function processInputBBReferences(
}
export async function processOutputBBReferences(
value: string | string[],
subtype: BBReferenceFieldSubType.USER | BBReferenceFieldSubType.USERS
) {
if (value === null || value === undefined) {
// Already processed or nothing to process
return value || undefined
value: string | null | undefined,
subtype: BBReferenceFieldSubType
): Promise<UserReferenceInfo[] | undefined> {
if (!value) {
return undefined
}
const ids =
typeof value === "string" ? value.split(",").filter(id => !!id) : value
@ -87,7 +161,7 @@ export async function processOutputBBReferences(
}
return users.map(u => ({
_id: u._id,
_id: u._id!,
primaryDisplay: u.email,
email: u.email,
firstName: u.firstName,

View File

@ -12,7 +12,9 @@ import {
} from "@budibase/types"
import { cloneDeep } from "lodash/fp"
import {
processInputBBReference,
processInputBBReferences,
processOutputBBReference,
processOutputBBReferences,
} from "./bbReferenceProcessor"
import { isExternalTableID } from "../../integrations/utils"
@ -160,10 +162,10 @@ export async function inputProcessing(
if (attachment?.url) {
delete clonedRow[key].url
}
}
if (field.type === FieldType.BB_REFERENCE && value) {
} else if (field.type === FieldType.BB_REFERENCE && value) {
clonedRow[key] = await processInputBBReferences(value, field.subtype)
} else if (field.type === FieldType.BB_REFERENCE_SINGLE && value) {
clonedRow[key] = await processInputBBReference(value, field.subtype)
}
}
@ -252,6 +254,16 @@ export async function outputProcessing<T extends Row[] | Row>(
column.subtype
)
}
} else if (
!opts.skipBBReferences &&
column.type == FieldType.BB_REFERENCE_SINGLE
) {
for (let row of enriched) {
row[property] = await processOutputBBReference(
row[property],
column.subtype
)
}
}
}

View File

@ -2,7 +2,9 @@ import _ from "lodash"
import * as backendCore from "@budibase/backend-core"
import { BBReferenceFieldSubType, User } from "@budibase/types"
import {
processInputBBReference,
processInputBBReferences,
processOutputBBReference,
processOutputBBReferences,
} from "../bbReferenceProcessor"
import {
@ -22,6 +24,7 @@ jest.mock("@budibase/backend-core", (): typeof backendCore => {
...actual.cache,
user: {
...actual.cache.user,
getUser: jest.fn(actual.cache.user.getUser),
getUsers: jest.fn(actual.cache.user.getUsers),
},
},
@ -31,6 +34,9 @@ jest.mock("@budibase/backend-core", (): typeof backendCore => {
const config = new DBTestConfiguration()
describe("bbReferenceProcessor", () => {
const cacheGetUserSpy = backendCore.cache.user.getUser as jest.MockedFunction<
typeof backendCore.cache.user.getUser
>
const cacheGetUsersSpy = backendCore.cache.user
.getUsers as jest.MockedFunction<typeof backendCore.cache.user.getUsers>
@ -56,6 +62,64 @@ describe("bbReferenceProcessor", () => {
jest.clearAllMocks()
})
describe("processInputBBReference", () => {
describe("subtype user", () => {
it("validate valid string id", async () => {
const user = _.sample(users)
const userId = user!._id!
const result = await config.doInTenant(() =>
processInputBBReference(userId, BBReferenceFieldSubType.USER)
)
expect(result).toEqual(userId)
expect(cacheGetUserSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUserSpy).toHaveBeenCalledWith(userId)
})
it("throws an error given an invalid id", async () => {
const userId = generator.guid()
await expect(
config.doInTenant(() =>
processInputBBReference(userId, BBReferenceFieldSubType.USER)
)
).rejects.toThrow(
new InvalidBBRefError(userId, BBReferenceFieldSubType.USER)
)
})
it("validate valid user object", async () => {
const userId = _.sample(users)!._id!
const result = await config.doInTenant(() =>
processInputBBReference({ _id: userId }, BBReferenceFieldSubType.USER)
)
expect(result).toEqual(userId)
expect(cacheGetUserSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUserSpy).toHaveBeenCalledWith(userId)
})
it("empty strings will return null", async () => {
const result = await config.doInTenant(() =>
processInputBBReference("", BBReferenceFieldSubType.USER)
)
expect(result).toEqual(null)
})
it("should convert user medata IDs to global IDs", async () => {
const userId = _.sample(users)!._id!
const userMetadataId = backendCore.db.generateUserMetadataID(userId)
const result = await config.doInTenant(() =>
processInputBBReference(userMetadataId, BBReferenceFieldSubType.USER)
)
expect(result).toBe(userId)
})
})
})
describe("processInputBBReferences", () => {
describe("subtype user", () => {
it("validate valid string id", async () => {
@ -66,7 +130,7 @@ describe("bbReferenceProcessor", () => {
processInputBBReferences(userId, BBReferenceFieldSubType.USER)
)
expect(result).toEqual(userId)
expect(result).toEqual([userId])
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith([userId])
})
@ -93,7 +157,7 @@ describe("bbReferenceProcessor", () => {
processInputBBReferences(userIdCsv, BBReferenceFieldSubType.USER)
)
expect(result).toEqual(userIds.join(","))
expect(result).toEqual(userIds)
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith(userIds)
})
@ -117,36 +181,26 @@ describe("bbReferenceProcessor", () => {
)
})
it("validate valid user object", async () => {
const userId = _.sample(users)!._id!
const result = await config.doInTenant(() =>
processInputBBReferences(
{ _id: userId },
BBReferenceFieldSubType.USER
)
)
expect(result).toEqual(userId)
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith([userId])
})
it("validate valid user object array", async () => {
const userIds = _.sampleSize(users, 3).map(x => x._id!)
const inputUsers = _.sampleSize(users, 3).map(u => ({ _id: u._id! }))
const userIds = inputUsers.map(u => u._id)
const result = await config.doInTenant(() =>
processInputBBReferences(userIds, BBReferenceFieldSubType.USER)
processInputBBReferences(inputUsers, BBReferenceFieldSubType.USER)
)
expect(result).toEqual(userIds.join(","))
expect(result).toEqual(userIds)
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith(userIds)
})
it("empty strings will return null", async () => {
const result = await config.doInTenant(() =>
processInputBBReferences("", BBReferenceFieldSubType.USER)
processInputBBReferences(
"",
BBReferenceFieldSubType.USER
)
)
expect(result).toEqual(null)
@ -166,7 +220,42 @@ describe("bbReferenceProcessor", () => {
const result = await config.doInTenant(() =>
processInputBBReferences(userMetadataId, BBReferenceFieldSubType.USER)
)
expect(result).toBe(userId)
expect(result).toEqual([userId])
})
})
})
describe("processOutputBBReference", () => {
describe("subtype user", () => {
it("fetches user given a valid string id", async () => {
const user = _.sample(users)!
const userId = user._id!
const result = await config.doInTenant(() =>
processOutputBBReference(userId, BBReferenceFieldSubType.USER)
)
expect(result).toEqual({
_id: user._id,
primaryDisplay: user.email,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
})
expect(cacheGetUserSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUserSpy).toHaveBeenCalledWith(userId)
})
it("returns undefined given an unexisting user", async () => {
const userId = generator.guid()
const result = await config.doInTenant(() =>
processOutputBBReference(userId, BBReferenceFieldSubType.USER)
)
expect(result).toBeUndefined()
expect(cacheGetUserSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUserSpy).toHaveBeenCalledWith(userId)
})
})
})
@ -221,6 +310,46 @@ describe("bbReferenceProcessor", () => {
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith([userId1, userId2])
})
it("trims unexisting users user given a valid string id csv", async () => {
const [user1, user2] = _.sampleSize(users, 2)
const userId1 = user1._id!
const userId2 = user2._id!
const unexistingUserId1 = generator.guid()
const unexistingUserId2 = generator.guid()
const input = [
unexistingUserId1,
userId1,
unexistingUserId2,
userId2,
].join(",")
const result = await config.doInTenant(() =>
processOutputBBReferences(input, BBReferenceFieldSubType.USER)
)
expect(result).toHaveLength(2)
expect(result).toEqual(
expect.arrayContaining(
[user1, user2].map(u => ({
_id: u._id,
primaryDisplay: u.email,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
}))
)
)
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith([
unexistingUserId1,
userId1,
unexistingUserId2,
userId2,
])
})
})
})
})

View File

@ -10,7 +10,9 @@ import {
import * as bbReferenceProcessor from "../bbReferenceProcessor"
jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
processInputBBReference: jest.fn(),
processInputBBReferences: jest.fn(),
processOutputBBReference: jest.fn(),
processOutputBBReferences: jest.fn(),
}))
@ -19,7 +21,64 @@ describe("rowProcessor - inputProcessing", () => {
jest.resetAllMocks()
})
it("processes BB references if on the schema and it's populated", async () => {
const processInputBBReferenceMock =
bbReferenceProcessor.processInputBBReference as jest.Mock
const processInputBBReferencesMock =
bbReferenceProcessor.processInputBBReferences as jest.Mock
it("processes single BB references if on the schema and it's populated", async () => {
const userId = generator.guid()
const table: Table = {
_id: generator.guid(),
name: "TestTable",
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: {
name: {
type: FieldType.STRING,
name: "name",
constraints: {
presence: true,
type: "string",
},
},
user: {
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
name: "user",
constraints: {
presence: true,
type: "string",
},
},
},
}
const newRow = {
name: "Jack",
user: "123",
}
const user = structures.users.user()
processInputBBReferenceMock.mockResolvedValue(user)
const { row } = await inputProcessing(userId, table, newRow)
expect(bbReferenceProcessor.processInputBBReference).toHaveBeenCalledTimes(
1
)
expect(bbReferenceProcessor.processInputBBReference).toHaveBeenCalledWith(
"123",
"user"
)
expect(row).toEqual({ ...newRow, user })
})
it("processes multiple BB references if on the schema and it's populated", async () => {
const userId = generator.guid()
const table: Table = {
@ -56,9 +115,7 @@ describe("rowProcessor - inputProcessing", () => {
const user = structures.users.user()
;(
bbReferenceProcessor.processInputBBReferences as jest.Mock
).mockResolvedValue(user)
processInputBBReferencesMock.mockResolvedValue(user)
const { row } = await inputProcessing(userId, table, newRow)

View File

@ -11,7 +11,9 @@ import { generator, structures } from "@budibase/backend-core/tests"
import * as bbReferenceProcessor from "../bbReferenceProcessor"
jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
processInputBBReference: jest.fn(),
processInputBBReferences: jest.fn(),
processOutputBBReference: jest.fn(),
processOutputBBReferences: jest.fn(),
}))
@ -20,10 +22,12 @@ describe("rowProcessor - outputProcessing", () => {
jest.resetAllMocks()
})
const processOutputBBReferenceMock =
bbReferenceProcessor.processOutputBBReference as jest.Mock
const processOutputBBReferencesMock =
bbReferenceProcessor.processOutputBBReferences as jest.Mock
it("fetches bb user references given a populated field", async () => {
it("fetches single user references given a populated field", async () => {
const table: Table = {
_id: generator.guid(),
name: "TestTable",
@ -40,7 +44,7 @@ describe("rowProcessor - outputProcessing", () => {
},
},
user: {
type: FieldType.BB_REFERENCE,
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
name: "user",
constraints: {
@ -57,12 +61,61 @@ describe("rowProcessor - outputProcessing", () => {
}
const user = structures.users.user()
processOutputBBReferencesMock.mockResolvedValue(user)
processOutputBBReferenceMock.mockResolvedValue(user)
const result = await outputProcessing(table, row, { squash: false })
expect(result).toEqual({ name: "Jack", user })
expect(bbReferenceProcessor.processOutputBBReference).toHaveBeenCalledTimes(
1
)
expect(bbReferenceProcessor.processOutputBBReference).toHaveBeenCalledWith(
"123",
BBReferenceFieldSubType.USER
)
})
it("fetches users references given a populated field", async () => {
const table: Table = {
_id: generator.guid(),
name: "TestTable",
type: "table",
sourceId: INTERNAL_TABLE_SOURCE_ID,
sourceType: TableSourceType.INTERNAL,
schema: {
name: {
type: FieldType.STRING,
name: "name",
constraints: {
presence: true,
type: "string",
},
},
users: {
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
name: "users",
constraints: {
presence: false,
type: "string",
},
},
},
}
const row = {
name: "Jack",
users: "123",
}
const users = [structures.users.user()]
processOutputBBReferencesMock.mockResolvedValue(users)
const result = await outputProcessing(table, row, { squash: false })
expect(result).toEqual({ name: "Jack", users })
expect(
bbReferenceProcessor.processOutputBBReferences
).toHaveBeenCalledTimes(1)

View File

@ -54,6 +54,7 @@ export function validate(rows: Rows, schema: TableSchema): ValidationResults {
type: columnType,
subtype: columnSubtype,
autocolumn: isAutoColumn,
constraints,
} = schema[columnName] || {}
// If the column had an invalid value we don't want to override it
@ -61,6 +62,12 @@ export function validate(rows: Rows, schema: TableSchema): ValidationResults {
return
}
const isRequired =
!!constraints &&
((typeof constraints.presence !== "boolean" &&
!constraints.presence?.allowEmpty) ||
constraints.presence === true)
// If the columnType is not a string, then it's not present in the schema, and should be added to the invalid columns array
if (typeof columnType !== "string") {
results.invalidColumns.push(columnName)
@ -92,8 +99,9 @@ export function validate(rows: Rows, schema: TableSchema): ValidationResults {
) {
results.schemaValidation[columnName] = false
} else if (
columnType === FieldType.BB_REFERENCE &&
!isValidBBReference(columnData, columnSubtype)
(columnType === FieldType.BB_REFERENCE ||
columnType === FieldType.BB_REFERENCE_SINGLE) &&
!isValidBBReference(columnData, columnType, columnSubtype, isRequired)
) {
results.schemaValidation[columnName] = false
} else {
@ -121,7 +129,7 @@ export function parse(rows: Rows, schema: TableSchema): Rows {
return
}
const { type: columnType, subtype: columnSubtype } = schema[columnName]
const { type: columnType } = schema[columnName]
if (columnType === FieldType.NUMBER) {
// If provided must be a valid number
parsedRow[columnName] = columnData ? Number(columnData) : columnData
@ -131,22 +139,16 @@ export function parse(rows: Rows, schema: TableSchema): Rows {
? new Date(columnData).toISOString()
: columnData
} else if (columnType === FieldType.BB_REFERENCE) {
const parsedValues =
!!columnData && parseCsvExport<{ _id: string }[]>(columnData)
if (!parsedValues) {
parsedRow[columnName] = undefined
} else {
switch (columnSubtype) {
case BBReferenceFieldSubType.USER:
parsedRow[columnName] = parsedValues[0]?._id
break
case BBReferenceFieldSubType.USERS:
parsedRow[columnName] = parsedValues.map(u => u._id)
break
default:
utils.unreachable(columnSubtype)
}
let parsedValues: { _id: string }[] = columnData || []
if (columnData) {
parsedValues = parseCsvExport<{ _id: string }[]>(columnData)
}
parsedRow[columnName] = parsedValues?.map(u => u._id)
} else if (columnType === FieldType.BB_REFERENCE_SINGLE) {
const parsedValue =
columnData && parseCsvExport<{ _id: string }>(columnData)
parsedRow[columnName] = parsedValue?._id
} else if (
(columnType === FieldType.ATTACHMENTS ||
columnType === FieldType.ATTACHMENT_SINGLE) &&
@ -163,33 +165,37 @@ export function parse(rows: Rows, schema: TableSchema): Rows {
}
function isValidBBReference(
columnData: any,
columnSubtype: BBReferenceFieldSubType.USER | BBReferenceFieldSubType.USERS
data: any,
type: FieldType.BB_REFERENCE | FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType,
isRequired: boolean
): boolean {
switch (columnSubtype) {
if (typeof data !== "string") {
return false
}
if (type === FieldType.BB_REFERENCE_SINGLE) {
if (!data) {
return !isRequired
}
const user = parseCsvExport<{ _id: string }>(data)
return db.isGlobalUserID(user._id)
}
switch (subtype) {
case BBReferenceFieldSubType.USER:
case BBReferenceFieldSubType.USERS: {
if (typeof columnData !== "string") {
return false
}
const userArray = parseCsvExport<{ _id: string }[]>(columnData)
const userArray = parseCsvExport<{ _id: string }[]>(data)
if (!Array.isArray(userArray)) {
return false
}
if (
columnSubtype === BBReferenceFieldSubType.USER &&
userArray.length > 1
) {
return false
}
const constainsWrongId = userArray.find(
user => !db.isGlobalUserID(user._id)
)
return !constainsWrongId
}
default:
throw utils.unreachable(columnSubtype)
throw utils.unreachable(subtype)
}
}

View File

@ -9,10 +9,11 @@ import {
SearchFilterOperator,
SortDirection,
SortType,
FieldConstraints,
} from "@budibase/types"
import dayjs from "dayjs"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
import { deepGet } from "./helpers"
import { deepGet, schema } from "./helpers"
const HBS_REGEX = /{{([^{].*?)}}/g
@ -24,9 +25,10 @@ export const getValidOperatorsForType = (
type: FieldType
subtype?: BBReferenceFieldSubType
formulaType?: FormulaType
constraints?: FieldConstraints
},
field: string,
datasource: Datasource & { tableId: any }
field?: string,
datasource?: Datasource & { tableId: any }
) => {
const Op = OperatorOptions
const stringOps = [
@ -51,7 +53,7 @@ export const getValidOperatorsForType = (
value: string
label: string
}[] = []
const { type, subtype, formulaType } = fieldType
const { type, formulaType } = fieldType
if (type === FieldType.STRING) {
ops = stringOps
} else if (type === FieldType.NUMBER || type === FieldType.BIGINT) {
@ -69,14 +71,11 @@ export const getValidOperatorsForType = (
} else if (type === FieldType.FORMULA && formulaType === FormulaType.STATIC) {
ops = stringOps.concat([Op.MoreThan, Op.LessThan])
} else if (
type === FieldType.BB_REFERENCE &&
subtype == BBReferenceFieldSubType.USER
type === FieldType.BB_REFERENCE_SINGLE ||
schema.isDeprecatedSingleUserColumn(fieldType)
) {
ops = [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty, Op.In]
} else if (
type === FieldType.BB_REFERENCE &&
subtype == BBReferenceFieldSubType.USERS
) {
} else if (type === FieldType.BB_REFERENCE) {
ops = [Op.Contains, Op.NotContains, Op.ContainsAny, Op.Empty, Op.NotEmpty]
}

View File

@ -1,3 +1,4 @@
export * from "./helpers"
export * from "./integrations"
export * as cron from "./cron"
export * as schema from "./schema"

View File

@ -0,0 +1,15 @@
import {
BBReferenceFieldSubType,
FieldSchema,
FieldType,
} from "@budibase/types"
export function isDeprecatedSingleUserColumn(
schema: Pick<FieldSchema, "type" | "subtype" | "constraints">
) {
const result =
schema.type === FieldType.BB_REFERENCE &&
schema.subtype === BBReferenceFieldSubType.USER &&
schema.constraints?.type !== "array"
return result
}

View File

@ -18,6 +18,7 @@ const allowDisplayColumnByType: Record<FieldType, boolean> = {
[FieldType.LINK]: false,
[FieldType.JSON]: false,
[FieldType.BB_REFERENCE]: false,
[FieldType.BB_REFERENCE_SINGLE]: false,
}
const allowSortColumnByType: Record<FieldType, boolean> = {
@ -39,6 +40,7 @@ const allowSortColumnByType: Record<FieldType, boolean> = {
[FieldType.ARRAY]: false,
[FieldType.LINK]: false,
[FieldType.BB_REFERENCE]: false,
[FieldType.BB_REFERENCE_SINGLE]: false,
}
export function canBeDisplayColumn(type: FieldType): boolean {

View File

@ -1,5 +1,4 @@
import {
FieldSchema,
Row,
Table,
TableRequest,
@ -31,8 +30,8 @@ export interface BulkImportResponse {
}
export interface MigrateRequest {
oldColumn: FieldSchema
newColumn: FieldSchema
oldColumn: string
newColumn: string
}
export interface MigrateResponse {

View File

@ -100,13 +100,17 @@ export enum FieldType {
*/
BIGINT = "bigint",
/**
* a JSON type, called User within Budibase. This type is used to represent a link to an internal Budibase
* a JSON type, called Users within Budibase. It will hold an array of strings. This type is used to represent a link to an internal Budibase
* resource, like a user or group, today only users are supported. This type will be represented as an
* array of internal resource IDs (e.g. user IDs) within the row - this ID list will be enriched with
* the full resources when rows are returned from the API. The full resources can be input to the API, or
* an array of resource IDs, the API will squash these down and validate them before saving the row.
*/
BB_REFERENCE = "bb_reference",
/**
* a string type, called User within Budibase. Same logic as `bb_reference`, storing a single id as string instead of an array
*/
BB_REFERENCE_SINGLE = "bb_reference_single",
}
export interface RowAttachment {

View File

@ -1,3 +1,5 @@
import { FieldType } from "../row"
export enum RelationshipType {
ONE_TO_MANY = "one-to-many",
MANY_TO_ONE = "many-to-one",
@ -27,5 +29,21 @@ export enum FormulaType {
export enum BBReferenceFieldSubType {
USER = "user",
/** @deprecated this should not be used anymore, left here in order to support the existing usages */
USERS = "users",
}
export type SupportedSqlTypes =
| FieldType.STRING
| FieldType.BARCODEQR
| FieldType.LONGFORM
| FieldType.OPTIONS
| FieldType.DATETIME
| FieldType.NUMBER
| FieldType.BOOLEAN
| FieldType.FORMULA
| FieldType.BIGINT
| FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE
| FieldType.LINK
| FieldType.ARRAY

View File

@ -110,9 +110,14 @@ export interface FormulaFieldMetadata extends BaseFieldSchema {
export interface BBReferenceFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.BB_REFERENCE
subtype: BBReferenceFieldSubType.USER | BBReferenceFieldSubType.USERS
subtype: BBReferenceFieldSubType
relationshipType?: RelationshipType
}
export interface BBReferenceSingleFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.BB_REFERENCE_SINGLE
subtype: Exclude<BBReferenceFieldSubType, BBReferenceFieldSubType.USERS>
}
export interface AttachmentFieldMetadata extends BaseFieldSchema {
type: FieldType.ATTACHMENTS
@ -164,6 +169,7 @@ interface OtherFieldMetadata extends BaseFieldSchema {
| FieldType.NUMBER
| FieldType.LONGFORM
| FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE
| FieldType.ATTACHMENTS
>
}
@ -179,6 +185,7 @@ export type FieldSchema =
| BBReferenceFieldMetadata
| JsonFieldMetadata
| AttachmentFieldMetadata
| BBReferenceSingleFieldMetadata
export interface TableSchema {
[key: string]: FieldSchema
@ -207,15 +214,3 @@ export function isManyToOne(
): field is ManyToOneRelationshipFieldMetadata {
return field.relationshipType === RelationshipType.MANY_TO_ONE
}
export function isBBReferenceField(
field: FieldSchema
): field is BBReferenceFieldMetadata {
return field.type === FieldType.BB_REFERENCE
}
export function isAttachmentField(
field: FieldSchema
): field is AttachmentFieldMetadata {
return field.type === FieldType.ATTACHMENTS
}