1
0
Fork 0
mirror of synced 2024-09-10 06:26:02 +12:00

Merge remote-tracking branch 'origin/master' into feature/signature-field-and-component

This commit is contained in:
Dean 2024-05-13 16:22:19 +01:00
commit b0a65b4699
78 changed files with 1344 additions and 1655 deletions

View file

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

View file

@ -1,5 +1,5 @@
{ {
"version": "2.25.0", "version": "2.26.1",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View file

@ -73,7 +73,6 @@
"chance": "1.1.8", "chance": "1.1.8",
"ioredis-mock": "8.9.0", "ioredis-mock": "8.9.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-node": "29.7.0",
"jest-serial-runner": "1.2.1", "jest-serial-runner": "1.2.1",
"pino-pretty": "10.0.0", "pino-pretty": "10.0.0",
"pouchdb-adapter-memory": "7.2.2", "pouchdb-adapter-memory": "7.2.2",

View file

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

View file

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

View file

@ -93,7 +93,6 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "29.7.0", "jest": "29.7.0",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",
"ncp": "^2.0.0",
"svelte-jester": "^1.3.2", "svelte-jester": "^1.3.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-static-copy": "^0.17.0", "vite-plugin-static-copy": "^0.17.0",

View file

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

View file

@ -56,7 +56,7 @@ export function getBindings({
) )
} }
const field = Object.values(FIELDS).find( 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}` const label = path == null ? column : `${path}.0.${column}`

View file

@ -12,8 +12,13 @@
OptionSelectDnD, OptionSelectDnD,
Layout, Layout,
AbsTooltip, AbsTooltip,
ProgressCircle,
} from "@budibase/bbui" } 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 { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/builder" import { tables, datasources } from "stores/builder"
@ -30,8 +35,8 @@
import { getBindings } from "components/backend/DataTable/formula" import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte" import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { import {
FieldType,
BBReferenceFieldSubType, BBReferenceFieldSubType,
FieldType,
SourceName, SourceName,
} from "@budibase/types" } from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte" import RelationshipSelector from "components/common/RelationshipSelector.svelte"
@ -67,7 +72,6 @@
let savingColumn let savingColumn
let deleteColName let deleteColName
let jsonSchemaModal let jsonSchemaModal
let allowedTypes = []
let editableColumn = { let editableColumn = {
type: FIELDS.STRING.type, type: FIELDS.STRING.type,
constraints: FIELDS.STRING.constraints, constraints: FIELDS.STRING.constraints,
@ -175,6 +179,11 @@
SWITCHABLE_TYPES[field.type] && SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn) !editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
const fieldDefinitions = Object.values(FIELDS).reduce( const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id // Storing the fields by complex field id
(acc, field) => ({ (acc, field) => ({
@ -188,7 +197,10 @@
// don't make field IDs for auto types // don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) { if (type === AUTO_TYPE || autocolumn) {
return type.toUpperCase() return type.toUpperCase()
} else if (type === FieldType.BB_REFERENCE) { } else if (
type === FieldType.BB_REFERENCE ||
type === FieldType.BB_REFERENCE_SINGLE
) {
return `${type}${subtype || ""}`.toUpperCase() return `${type}${subtype || ""}`.toUpperCase()
} else { } else {
return type.toUpperCase() return type.toUpperCase()
@ -226,11 +238,6 @@
editableColumn.subtype, editableColumn.subtype,
editableColumn.autocolumn editableColumn.autocolumn
) )
allowedTypes = getAllowedTypes().map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
} }
} }
@ -245,11 +252,11 @@
} }
async function saveColumn() { async function saveColumn() {
savingColumn = true
if (errors?.length) { if (errors?.length) {
return return
} }
savingColumn = true
let saveColumn = cloneDeep(editableColumn) let saveColumn = cloneDeep(editableColumn)
delete saveColumn.fieldId delete saveColumn.fieldId
@ -264,13 +271,6 @@
if (saveColumn.type !== LINK_TYPE) { if (saveColumn.type !== LINK_TYPE) {
delete saveColumn.fieldName 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 { try {
await tables.saveField({ await tables.saveField({
@ -289,6 +289,8 @@
} }
} catch (err) { } catch (err) {
notifications.error(`Error saving column: ${err.message}`) notifications.error(`Error saving column: ${err.message}`)
} finally {
savingColumn = false
} }
} }
@ -363,20 +365,36 @@
deleteColName = "" deleteColName = ""
} }
function getAllowedTypes() { function getAllowedTypes(datasource) {
if (originalName) { if (originalName) {
const possibleTypes = SWITCHABLE_TYPES[field.type] || [ let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.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) return Object.entries(FIELDS)
.filter(([_, field]) => possibleTypes.includes(field.type)) .filter(([_, field]) => possibleTypes.includes(field.type))
.map(([_, fieldDefinition]) => fieldDefinition) .map(([_, fieldDefinition]) => fieldDefinition)
} }
const isUsers =
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === BBReferenceFieldSubType.USERS
if (!externalTable) { if (!externalTable) {
return [ return [
FIELDS.STRING, FIELDS.STRING,
@ -394,7 +412,8 @@
FIELDS.LINK, FIELDS.LINK,
FIELDS.FORMULA, FIELDS.FORMULA,
FIELDS.JSON, FIELDS.JSON,
isUsers ? FIELDS.USERS : FIELDS.USER, FIELDS.USER,
FIELDS.USERS,
FIELDS.AUTO, FIELDS.AUTO,
] ]
} else { } else {
@ -408,8 +427,12 @@
FIELDS.BOOLEAN, FIELDS.BOOLEAN,
FIELDS.FORMULA, FIELDS.FORMULA,
FIELDS.BIGINT, 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 // no-sql or a spreadsheet
if (!externalTable || table.sql) { if (!externalTable || table.sql) {
fields = [...fields, FIELDS.LINK, FIELDS.ARRAY] fields = [...fields, FIELDS.LINK, FIELDS.ARRAY]
@ -483,15 +506,6 @@
return newError return newError
} }
function isUsersColumn(column) {
return (
column.type === FieldType.BB_REFERENCE &&
[BBReferenceFieldSubType.USER, BBReferenceFieldSubType.USERS].includes(
column.subtype
)
)
}
onMount(() => { onMount(() => {
mounted = true mounted = true
}) })
@ -690,22 +704,6 @@
<Button primary text on:click={openJsonSchemaEditor} <Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button >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}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn} {#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select <Select
@ -740,7 +738,20 @@
<Button quiet warning text on:click={confirmDelete}>Delete</Button> <Button quiet warning text on:click={confirmDelete}>Delete</Button>
{/if} {/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button> <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> </div>
<Modal bind:this={jsonSchemaModal}> <Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal <JSONSchemaModal
@ -805,4 +816,9 @@
cursor: pointer; cursor: pointer;
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
} }
.save-loading {
display: flex;
justify-content: center;
}
</style> </style>

View file

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

View file

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

View file

@ -23,6 +23,7 @@
faQuestionCircle, faQuestionCircle,
faCircleCheck, faCircleCheck,
faGear, faGear,
faRectangleList,
} from "@fortawesome/free-solid-svg-icons" } from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons" import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
@ -37,6 +38,7 @@
faFileArrowUp, faFileArrowUp,
faChevronLeft, faChevronLeft,
faCircleInfo, faCircleInfo,
faRectangleList,
// -- Required for easyMDE use in the builder. // -- Required for easyMDE use in the builder.
faBold, faBold,

View file

@ -4,6 +4,7 @@
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { licensing } from "stores/portal" import { licensing } from "stores/portal"
import { isPremiumOrAbove } from "helpers/planTitle" import { isPremiumOrAbove } from "helpers/planTitle"
import { ChangelogURL } from "constants"
$: premiumOrAboveLicense = isPremiumOrAbove($licensing?.license?.plan?.type) $: premiumOrAboveLicense = isPremiumOrAbove($licensing?.license?.plan?.type)
@ -30,6 +31,13 @@
<Body size="S">Help docs</Body> <Body size="S">Help docs</Body>
</a> </a>
<div class="divider" /> <div class="divider" />
<a target="_blank" href={ChangelogURL}>
<div class="icon">
<FontAwesomeIcon name="fa-solid fa-rectangle-list" />
</div>
<Body size="S">Changelog</Body>
</a>
<div class="divider" />
<a <a
target="_blank" target="_blank"
href="https://github.com/Budibase/budibase/discussions" href="https://github.com/Budibase/budibase/discussions"

View file

@ -7,10 +7,12 @@
Body, Body,
Button, Button,
StatusLight, StatusLight,
Link,
} from "@budibase/bbui" } from "@budibase/bbui"
import { appStore, initialise } from "stores/builder" import { appStore, initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import RevertModalVersionSelect from "./RevertModalVersionSelect.svelte" import RevertModalVersionSelect from "./RevertModalVersionSelect.svelte"
import { ChangelogURL } from "constants"
export function show() { export function show() {
updateModal.show() updateModal.show()
@ -106,6 +108,10 @@
latest version available. latest version available.
</Body> </Body>
{/if} {/if}
<Body size="S">
Find the changelog for the latest release
<Link href={ChangelogURL} target="_blank">here</Link>
</Body>
{#if revertAvailable} {#if revertAvailable}
<Body size="S"> <Body size="S">
You can revert this app to version You can revert this app to version

View file

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

View file

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

View file

@ -70,3 +70,5 @@ export const PlanModel = {
PER_USER: "perUser", PER_USER: "perUser",
DAY_PASS: "dayPass", DAY_PASS: "dayPass",
} }
export const ChangelogURL = "https://docs.budibase.com/changelog"

View file

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

View file

@ -72,6 +72,7 @@
"s3upload", "s3upload",
"codescanner", "codescanner",
"signaturefield", "signaturefield",
"bbreferencesinglefield",
"bbreferencefield" "bbreferencefield"
] ]
}, },

View file

@ -166,10 +166,16 @@ const automationActions = store => ({
await store.actions.save(newAutomation) await store.actions.save(newAutomation)
}, },
test: async (automation, testData) => { test: async (automation, testData) => {
const result = await API.testAutomation({ let result
automationId: automation?._id, try {
testData, result = await API.testAutomation({
}) automationId: automation?._id,
testData,
})
} catch (err) {
const message = err.message || err.status || JSON.stringify(err)
throw `Automation test failed - ${message}`
}
if (!result?.trigger && !result?.steps?.length) { if (!result?.trigger && !result?.steps?.length) {
if (result?.err?.code === "usage_limit_exceeded") { if (result?.err?.code === "usage_limit_exceeded") {
throw "You have exceeded your automation quota" throw "You have exceeded your automation quota"

View file

@ -440,6 +440,8 @@ export class ComponentStore extends BudiStore {
return state return state
}) })
componentTreeNodesStore.makeNodeVisible(componentInstance._id)
// Log event // Log event
analytics.captureEvent(Events.COMPONENT_CREATED, { analytics.captureEvent(Events.COMPONENT_CREATED, {
name: componentInstance._component, name: componentInstance._component,

View file

@ -7074,8 +7074,8 @@
}, },
"bbreferencefield": { "bbreferencefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels", "devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field", "name": "User List Field",
"icon": "User", "icon": "UserGroup",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"editable": true, "editable": true,
@ -7179,5 +7179,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

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

View file

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

View file

@ -18,3 +18,4 @@ export { default as s3upload } from "./S3Upload.svelte"
export { default as codescanner } from "./CodeScannerField.svelte" export { default as codescanner } from "./CodeScannerField.svelte"
export { default as signaturefield } from "./SignatureField.svelte" export { default as signaturefield } from "./SignatureField.svelte"
export { default as bbreferencefield } from "./BBReferenceField.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.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype filter.subtype = fieldSchema?.subtype
filter.formulaType = fieldSchema?.formulaType filter.formulaType = fieldSchema?.formulaType
filter.constraints = fieldSchema?.constraints
// Update external type based on field // Update external type based on field
filter.externalType = getSchema(filter)?.externalType filter.externalType = getSchema(filter)?.externalType
@ -281,7 +282,7 @@
timeOnly={getSchema(filter)?.timeOnly} timeOnly={getSchema(filter)?.timeOnly}
bind:value={filter.value} bind:value={filter.value}
/> />
{:else if filter.type === FieldType.BB_REFERENCE} {:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)}
<FilterUsers <FilterUsers
bind:value={filter.value} bind:value={filter.value}
multiselect={[ multiselect={[

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,23 @@
import { helpers } from "@budibase/shared-core"
import { TypeIconMap } from "../../../constants" 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) => { export const getColor = (idx, opacity = 0.3) => {
if (idx == null || idx === -1) { if (idx == null || idx === -1) {
idx = 0 idx = 0
@ -11,6 +29,11 @@ export const getColumnIcon = column => {
if (column.schema.autocolumn) { if (column.schema.autocolumn) {
return "MagicWand" return "MagicWand"
} }
if (helpers.schema.isDeprecatedSingleUserColumn(column.schema)) {
return "User"
}
const { type, subtype } = column.schema const { type, subtype } = column.schema
const result = const result =
typeof TypeIconMap[type] === "object" && subtype typeof TypeIconMap[type] === "object" && subtype

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -1 +1 @@
Subproject commit ff397e5454ad3361b25efdf14746c36dcbd3f409 Subproject commit d3c3077011a8e20ed3c48dcd6301caca4120b6ac

View file

@ -101,7 +101,6 @@
"mysql2": "3.9.7", "mysql2": "3.9.7",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"object-sizeof": "2.6.1", "object-sizeof": "2.6.1",
"open": "8.4.0",
"openai": "^3.2.1", "openai": "^3.2.1",
"openapi-types": "9.3.1", "openapi-types": "9.3.1",
"pg": "8.10.0", "pg": "8.10.0",
@ -113,12 +112,8 @@
"server-destroy": "1.0.1", "server-destroy": "1.0.1",
"snowflake-promise": "^4.5.0", "snowflake-promise": "^4.5.0",
"socket.io": "4.6.1", "socket.io": "4.6.1",
"sqlite3": "5.1.6",
"swagger-parser": "10.0.3",
"tar": "6.1.15", "tar": "6.1.15",
"to-json-schema": "0.2.5", "to-json-schema": "0.2.5",
"undici": "^6.0.1",
"undici-types": "^6.0.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"validate.js": "0.13.1", "validate.js": "0.13.1",
"worker-farm": "1.7.0", "worker-farm": "1.7.0",
@ -144,16 +139,13 @@
"@types/supertest": "2.0.14", "@types/supertest": "2.0.14",
"@types/tar": "6.1.5", "@types/tar": "6.1.5",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"apidoc": "0.50.4",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"docker-compose": "0.23.17", "docker-compose": "0.23.17",
"jest": "29.7.0", "jest": "29.7.0",
"jest-openapi": "0.14.2", "jest-openapi": "0.14.2",
"jest-runner": "29.7.0",
"nock": "13.5.4", "nock": "13.5.4",
"nodemon": "2.0.15", "nodemon": "2.0.15",
"openapi-typescript": "5.2.0", "openapi-typescript": "5.2.0",
"path-to-regexp": "6.2.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"supertest": "6.3.3", "supertest": "6.3.3",
"swagger-jsdoc": "6.1.0", "swagger-jsdoc": "6.1.0",

View file

@ -279,8 +279,7 @@ export async function trigger(ctx: UserCtx) {
{ {
fields: ctx.request.body.fields, fields: ctx.request.body.fields,
timeout: timeout:
ctx.request.body.timeout * 1000 || ctx.request.body.timeout * 1000 || env.AUTOMATION_THREAD_TIMEOUT,
env.getDefaults().AUTOMATION_SYNC_TIMEOUT,
}, },
{ getResponses: true } { getResponses: true }
) )

View file

@ -1,5 +1,6 @@
// need to handle table name + field or just field, depending on if relationships used // need to handle table name + field or just field, depending on if relationships used
import { FieldType, Row, Table } from "@budibase/types" import { FieldType, Row, Table } from "@budibase/types"
import { helpers } from "@budibase/shared-core"
import { generateRowIdField } from "../../../../integrations/utils" import { generateRowIdField } from "../../../../integrations/utils"
import { CONSTANT_INTERNAL_ROW_COLS } from "../../../../db/utils" import { CONSTANT_INTERNAL_ROW_COLS } from "../../../../db/utils"
@ -107,12 +108,17 @@ export function basicProcessing({
export function fixArrayTypes(row: Row, table: Table) { export function fixArrayTypes(row: Row, table: Table) {
for (let [fieldName, schema] of Object.entries(table.schema)) { 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 { try {
row[fieldName] = JSON.parse(row[fieldName]) row[fieldName] = JSON.parse(row[fieldName])
} catch (err) { } catch (err) {
// couldn't convert back to array, ignore if (!helpers.schema.isDeprecatedSingleUserColumn(schema)) {
delete row[fieldName] // 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.status = 200
ctx.body = { message: `Column ${oldColumn.name} migrated.` } ctx.body = { message: `Column ${oldColumn} migrated.` }
} }

View file

@ -16,6 +16,7 @@ import {
SourceName, SourceName,
Table, Table,
TableSchema, TableSchema,
SupportedSqlTypes,
} from "@budibase/types" } from "@budibase/types"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import { tableForDatasource } from "../../../tests/utilities/structures" 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: { const fullSchema: {
[type in SupportedSqlTypes]: FieldSchema & { type: type } [type in SupportedSqlTypes]: FieldSchema & { type: type }
} = { } = {
@ -337,7 +324,12 @@ describe("/datasources", () => {
[FieldType.BB_REFERENCE]: { [FieldType.BB_REFERENCE]: {
name: "bb_reference", name: "bb_reference",
type: FieldType.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

@ -262,11 +262,19 @@ describe.each([
{ name: "serverDate", appointment: serverTime.toISOString() }, { name: "serverDate", appointment: serverTime.toISOString() },
{ {
name: "single user, session user", name: "single user, session user",
single_user: JSON.stringify([currentUser]), single_user: JSON.stringify(currentUser),
}, },
{ {
name: "single user", 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", name: "multi user",
@ -276,6 +284,14 @@ describe.each([
name: "multi user with session user", name: "multi user with session user",
multi_user: JSON.stringify([...globalUsers, currentUser]), 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 }, appointment: { name: "appointment", type: FieldType.DATETIME },
single_user: { single_user: {
name: "single_user", name: "single_user",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
},
deprecated_single_user: {
name: "deprecated_single_user",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER, subtype: BBReferenceFieldSubType.USER,
}, },
multi_user: { multi_user: {
name: "multi_user", name: "multi_user",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: {
type: "array",
},
},
deprecated_multi_user: {
name: "deprecated_multi_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS, subtype: BBReferenceFieldSubType.USERS,
constraints: {
type: "array",
},
}, },
}) })
await createRows(rows(config.getUser())) await createRows(rows(config.getUser()))
@ -398,7 +430,18 @@ describe.each([
}).toContainExactly([ }).toContainExactly([
{ {
name: "single user, session user", 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 // TODO(samwho): fix for SQS
!isSqs && !isSqs &&
it("should not match the session user id in a multi user field", async () => { 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 () => { 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({ await expectQuery({
oneOf: { oneOf: {
@ -447,11 +523,31 @@ describe.each([
}).toContainExactly([ }).toContainExactly([
{ {
name: "single user, session user", name: "single user, session user",
single_user: [{ _id: config.getUser()._id }], single_user: { _id: config.getUser()._id },
}, },
{ {
name: "single user", 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([ }).toContainExactly([
{ {
name: "single user", 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!, { await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"], oldColumn: "user relationship",
newColumn: { newColumn: "user column",
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
}) })
const migratedTable = await config.api.table.get(table._id!) 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() expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const migratedRows = await config.api.row.fetch(table._id!) const migratedRows = await config.api.row.fetch(table._id!)
@ -567,7 +567,7 @@ describe.each([
expect(migratedRow["user column"]).toBeDefined() expect(migratedRow["user column"]).toBeDefined()
expect(migratedRow["user relationship"]).not.toBeDefined() expect(migratedRow["user relationship"]).not.toBeDefined()
expect(row["user relationship"][0]._id).toEqual( 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!, { await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"], oldColumn: "user relationship",
newColumn: { newColumn: "user column",
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}) })
const migratedTable = await config.api.table.get(table._id!) 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() expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const migratedRow = await config.api.row.get(table._id!, testRow._id!) const migratedRow = await config.api.row.get(table._id!, testRow._id!)
@ -662,16 +665,19 @@ describe.each([
}) })
await config.api.table.migrate(table._id!, { await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"], oldColumn: "user relationship",
newColumn: { newColumn: "user column",
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}) })
const migratedTable = await config.api.table.get(table._id!) 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() expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const row1Migrated = await config.api.row.get(table._id!, row1._id!) const row1Migrated = await config.api.row.get(table._id!, row1._id!)
@ -717,16 +723,19 @@ describe.each([
}) })
await config.api.table.migrate(table._id!, { await config.api.table.migrate(table._id!, {
oldColumn: table.schema["user relationship"], oldColumn: "user relationship",
newColumn: { newColumn: "user column",
name: "user column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}) })
const migratedTable = await config.api.table.get(table._id!) 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() expect(migratedTable.schema["user relationship"]).not.toBeDefined()
const row1Migrated = await config.api.row.get(table._id!, row1._id!) const row1Migrated = await config.api.row.get(table._id!, row1._id!)
@ -776,12 +785,8 @@ describe.each([
await config.api.table.migrate( await config.api.table.migrate(
table._id!, table._id!,
{ {
oldColumn: table.schema["user relationship"], oldColumn: "user relationship",
newColumn: { newColumn: "",
name: "",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}, },
{ status: 400 } { status: 400 }
) )
@ -791,12 +796,8 @@ describe.each([
await config.api.table.migrate( await config.api.table.migrate(
table._id!, table._id!,
{ {
oldColumn: table.schema["user relationship"], oldColumn: "user relationship",
newColumn: { newColumn: "_id",
name: "_id",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}, },
{ status: 400 } { status: 400 }
) )
@ -806,12 +807,8 @@ describe.each([
await config.api.table.migrate( await config.api.table.migrate(
table._id!, table._id!,
{ {
oldColumn: table.schema["user relationship"], oldColumn: "user relationship",
newColumn: { newColumn: "num",
name: "num",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}, },
{ status: 400 } { status: 400 }
) )
@ -821,16 +818,8 @@ describe.each([
await config.api.table.migrate( await config.api.table.migrate(
table._id!, table._id!,
{ {
oldColumn: { oldColumn: "not a column",
name: "not a column", newColumn: "new column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
newColumn: {
name: "new column",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
},
}, },
{ status: 400 } { status: 400 }
) )

View file

@ -20,7 +20,7 @@ function parseIntSafe(number?: string) {
const DEFAULTS = { const DEFAULTS = {
QUERY_THREAD_TIMEOUT: 15000, QUERY_THREAD_TIMEOUT: 15000,
AUTOMATION_THREAD_TIMEOUT: 12000, AUTOMATION_THREAD_TIMEOUT: 15000,
AUTOMATION_SYNC_TIMEOUT: 120000, AUTOMATION_SYNC_TIMEOUT: 120000,
AUTOMATION_MAX_ITERATIONS: 200, AUTOMATION_MAX_ITERATIONS: 200,
JS_PER_EXECUTION_TIME_LIMIT_MS: 1500, JS_PER_EXECUTION_TIME_LIMIT_MS: 1500,
@ -34,6 +34,10 @@ const DEFAULTS = {
const QUERY_THREAD_TIMEOUT = const QUERY_THREAD_TIMEOUT =
parseIntSafe(process.env.QUERY_THREAD_TIMEOUT) || parseIntSafe(process.env.QUERY_THREAD_TIMEOUT) ||
DEFAULTS.QUERY_THREAD_TIMEOUT DEFAULTS.QUERY_THREAD_TIMEOUT
const DEFAULT_AUTOMATION_TIMEOUT =
QUERY_THREAD_TIMEOUT > DEFAULTS.AUTOMATION_THREAD_TIMEOUT
? QUERY_THREAD_TIMEOUT
: DEFAULTS.AUTOMATION_THREAD_TIMEOUT
const environment = { const environment = {
// features // features
APP_FEATURES: process.env.APP_FEATURES, APP_FEATURES: process.env.APP_FEATURES,
@ -75,9 +79,7 @@ const environment = {
QUERY_THREAD_TIMEOUT: QUERY_THREAD_TIMEOUT, QUERY_THREAD_TIMEOUT: QUERY_THREAD_TIMEOUT,
AUTOMATION_THREAD_TIMEOUT: AUTOMATION_THREAD_TIMEOUT:
parseIntSafe(process.env.AUTOMATION_THREAD_TIMEOUT) || parseIntSafe(process.env.AUTOMATION_THREAD_TIMEOUT) ||
DEFAULTS.AUTOMATION_THREAD_TIMEOUT > QUERY_THREAD_TIMEOUT DEFAULT_AUTOMATION_TIMEOUT,
? DEFAULTS.AUTOMATION_THREAD_TIMEOUT
: QUERY_THREAD_TIMEOUT,
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL, BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD, BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
PLUGINS_DIR: process.env.PLUGINS_DIR || DEFAULTS.PLUGINS_DIR, PLUGINS_DIR: process.env.PLUGINS_DIR || DEFAULTS.PLUGINS_DIR,

View file

@ -12,7 +12,6 @@ import SqlTableQueryBuilder from "./sqlTable"
import { import {
BBReferenceFieldMetadata, BBReferenceFieldMetadata,
FieldSchema, FieldSchema,
BBReferenceFieldSubType,
FieldType, FieldType,
JsonFieldMetadata, JsonFieldMetadata,
Operation, Operation,
@ -27,6 +26,7 @@ import {
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
} from "@budibase/types" } from "@budibase/types"
import environment from "../../environment" import environment from "../../environment"
import { helpers } from "@budibase/shared-core"
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
@ -787,7 +787,7 @@ class SqlQueryBuilder extends SqlTableQueryBuilder {
return ( return (
field.type === FieldType.JSON || field.type === FieldType.JSON ||
(field.type === FieldType.BB_REFERENCE && (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 { Knex, knex } from "knex"
import { import {
BBReferenceFieldSubType,
FieldType, FieldType,
NumberFieldMetadata, NumberFieldMetadata,
Operation, Operation,
@ -12,7 +11,7 @@ import {
TableSourceType, TableSourceType,
} from "@budibase/types" } from "@budibase/types"
import { breakExternalTableId, getNativeSql, SqlClient } from "../utils" import { breakExternalTableId, getNativeSql, SqlClient } from "../utils"
import { utils } from "@budibase/shared-core" import { helpers, utils } from "@budibase/shared-core"
import SchemaBuilder = Knex.SchemaBuilder import SchemaBuilder = Knex.SchemaBuilder
import CreateTableBuilder = Knex.CreateTableBuilder import CreateTableBuilder = Knex.CreateTableBuilder
@ -54,27 +53,15 @@ function generateSchema(
) { ) {
continue continue
} }
switch (column.type) { const columnType = column.type
switch (columnType) {
case FieldType.STRING: case FieldType.STRING:
case FieldType.OPTIONS: case FieldType.OPTIONS:
case FieldType.LONGFORM: case FieldType.LONGFORM:
case FieldType.BARCODEQR: case FieldType.BARCODEQR:
case FieldType.BB_REFERENCE_SINGLE:
schema.text(key) schema.text(key)
break 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: case FieldType.NUMBER:
// if meta is specified then this is a junction table entry // if meta is specified then this is a junction table entry
if (column.meta && column.meta.toKey && column.meta.toTable) { if (column.meta && column.meta.toKey && column.meta.toTable) {
@ -97,7 +84,13 @@ function generateSchema(
}) })
break break
case FieldType.ARRAY: 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 break
case FieldType.LINK: case FieldType.LINK:
// this side of the relationship doesn't need any SQL work // this side of the relationship doesn't need any SQL work
@ -127,6 +120,18 @@ function generateSchema(
.references(`${tableName}.${relatedPrimary}`) .references(`${tableName}.${relatedPrimary}`)
} }
break 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, TableRequest,
TableSourceType, TableSourceType,
DatasourcePlusQueryResponse, DatasourcePlusQueryResponse,
BBReferenceFieldSubType,
} from "@budibase/types" } from "@budibase/types"
import { OAuth2Client } from "google-auth-library" import { OAuth2Client } from "google-auth-library"
import { import {
@ -52,17 +53,30 @@ interface AuthTokenResponse {
access_token: string access_token: string
} }
const ALLOWED_TYPES = [ const isTypeAllowed: Record<FieldType, boolean> = {
FieldType.STRING, [FieldType.STRING]: true,
FieldType.FORMULA, [FieldType.FORMULA]: true,
FieldType.NUMBER, [FieldType.NUMBER]: true,
FieldType.LONGFORM, [FieldType.LONGFORM]: true,
FieldType.DATETIME, [FieldType.DATETIME]: true,
FieldType.OPTIONS, [FieldType.OPTIONS]: true,
FieldType.BOOLEAN, [FieldType.BOOLEAN]: true,
FieldType.BARCODEQR, [FieldType.BARCODEQR]: true,
FieldType.BB_REFERENCE, [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 = { const SCHEMA: Integration = {
plus: true, plus: true,
@ -350,6 +364,7 @@ class GoogleSheetsIntegration implements DatasourcePlus {
rowIndex: json.extra?.idFilter?.equal?.rowNumber, rowIndex: json.extra?.idFilter?.equal?.rowNumber,
sheet, sheet,
row: json.body, row: json.body,
table: json.meta.table,
}) })
case Operation.DELETE: case Operation.DELETE:
return this.delete({ return this.delete({
@ -371,9 +386,11 @@ class GoogleSheetsIntegration implements DatasourcePlus {
} }
buildRowObject(headers: string[], values: string[], rowNumber: number) { 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++) { for (let i = 0; i < headers.length; i++) {
rowObject._id = rowNumber
rowObject[headers[i]] = values[i] rowObject[headers[i]] = values[i]
} }
return rowObject 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 { try {
await sheet.setHeaderRow(updatedHeaderValues) await sheet.setHeaderRow(updatedHeaderValues)
} catch (err) { } 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 { try {
await this.connect() await this.connect()
const sheet = this.client.sheetsByTitle[query.sheet] 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 { try {
await this.connect() await this.connect()
const sheet = this.client.sheetsByTitle[query.sheet] const sheet = this.client.sheetsByTitle[query.sheet]
@ -573,7 +582,12 @@ class GoogleSheetsIntegration implements DatasourcePlus {
return { sheet, row } return { sheet, row }
} }
async update(query: { sheet: string; rowIndex: number; row: any }) { async update(query: {
sheet: string
rowIndex: number
row: any
table: Table
}) {
try { try {
await this.connect() await this.connect()
const { sheet, row } = await this.getRowByIndex( const { sheet, row } = await this.getRowByIndex(
@ -589,6 +603,15 @@ class GoogleSheetsIntegration implements DatasourcePlus {
if (row[key] === null) { if (row[key] === null) {
row[key] = "" 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() await row.save()
return [ return [

View file

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

View file

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

View file

@ -79,7 +79,9 @@ export async function search(
} }
const table = await sdk.tables.getTable(options.tableId) 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) { if (isExternalTable) {
return external.search(options, table) return external.search(options, table)

View file

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

View file

@ -11,7 +11,7 @@ import {
RowSearchParams, RowSearchParams,
} from "@budibase/types" } from "@budibase/types"
import { db as dbCore, context } from "@budibase/backend-core" 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( export async function paginatedSearch(
query: SearchFilters, 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 => { findColumnInQueries(column, options, (filterValue: any): any => {
const isArray = Array.isArray(filterValue), const isArray = Array.isArray(filterValue),
isString = typeof filterValue === "string" isString = typeof filterValue === "string"
if (!isString && !isArray) { if (!isString && !isArray) {
return filterValue return filterValue
} }
const processString = (input: string) => { const processString = (input: string) => {
const rowPrefix = DocumentType.ROW + SEPARATOR const rowPrefix = DocumentType.ROW + SEPARATOR
if (input.startsWith(rowPrefix)) { if (input.startsWith(rowPrefix)) {
@ -64,40 +70,60 @@ function userColumnMapping(column: string, options: RowSearchParams) {
return input 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) { if (isArray) {
return filterValue.map(el => { return filterValue.map(el => {
if (typeof el === "string") { if (typeof el === "string") {
return processString(el) return wrapper(processString(el))
} else { } else {
return el return el
} }
}) })
} else { } else {
return processString(filterValue) return wrapper(processString(filterValue))
} }
}) })
} }
// maps through the search parameters to check if any of the inputs are invalid // 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. // 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) { if (!table?.schema) {
return options return options
} }
for (let [key, column] of Object.entries(table.schema)) { for (let [key, column] of Object.entries(table.schema)) {
switch (column.type) { switch (column.type) {
case FieldType.BB_REFERENCE: { case FieldType.BB_REFERENCE_SINGLE: {
const subtype = column.subtype const subtype = column.subtype
switch (subtype) { switch (subtype) {
case BBReferenceFieldSubType.USER: case BBReferenceFieldSubType.USER:
case BBReferenceFieldSubType.USERS:
userColumnMapping(key, options) userColumnMapping(key, options)
break break
default: default:
utils.unreachable(subtype) utils.unreachable(subtype)
} }
break break
} }
case FieldType.BB_REFERENCE: {
userColumnMapping(
key,
options,
helpers.schema.isDeprecatedSingleUserColumn(column),
datasourceOptions.isSql
)
break
}
} }
} }
return options return options

View file

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

View file

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

View file

@ -9,26 +9,57 @@ import { InvalidBBRefError } from "./errors"
const ROW_PREFIX = DocumentType.ROW + SEPARATOR const ROW_PREFIX = DocumentType.ROW + SEPARATOR
export async function processInputBBReferences( export async function processInputBBReference(
value: string | string[] | { _id: string } | { _id: string }[], value: string | { _id: string },
subtype: BBReferenceFieldSubType.USER | BBReferenceFieldSubType.USERS subtype: BBReferenceFieldSubType.USER
): Promise<string | string[] | null> { ): Promise<string | null> {
let referenceIds: string[] = [] 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)) { if (!id) {
referenceIds.push( return null
...value.map(idOrDoc => }
typeof idOrDoc === "string" ? idOrDoc : idOrDoc._id
) switch (subtype) {
) case BBReferenceFieldSubType.USER: {
} else if (typeof value !== "string") { if (id.startsWith(ROW_PREFIX)) {
referenceIds.push(value._id) 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 { } else {
referenceIds.push( referenceIds = value.map(idOrDoc =>
...value typeof idOrDoc === "string" ? idOrDoc : idOrDoc._id
.split(",")
.filter(x => x)
.map((id: string) => id.trim())
) )
} }
@ -44,6 +75,8 @@ export async function processInputBBReferences(
}) })
switch (subtype) { switch (subtype) {
case undefined:
throw "Subtype must be defined"
case BBReferenceFieldSubType.USER: case BBReferenceFieldSubType.USER:
case BBReferenceFieldSubType.USERS: { case BBReferenceFieldSubType.USERS: {
const { notFoundIds } = await cache.user.getUsers(referenceIds) const { notFoundIds } = await cache.user.getUsers(referenceIds)
@ -55,11 +88,54 @@ export async function processInputBBReferences(
) )
} }
if (subtype === BBReferenceFieldSubType.USERS) { if (!referenceIds?.length) {
return referenceIds 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: default:
throw utils.unreachable(subtype) throw utils.unreachable(subtype)
@ -67,14 +143,12 @@ export async function processInputBBReferences(
} }
export async function processOutputBBReferences( export async function processOutputBBReferences(
value: string | string[], value: string | null | undefined,
subtype: BBReferenceFieldSubType.USER | BBReferenceFieldSubType.USERS subtype: BBReferenceFieldSubType
) { ): Promise<UserReferenceInfo[] | undefined> {
if (value === null || value === undefined) { if (!value) {
// Already processed or nothing to process return undefined
return value || undefined
} }
const ids = const ids =
typeof value === "string" ? value.split(",").filter(id => !!id) : value typeof value === "string" ? value.split(",").filter(id => !!id) : value
@ -87,7 +161,7 @@ export async function processOutputBBReferences(
} }
return users.map(u => ({ return users.map(u => ({
_id: u._id, _id: u._id!,
primaryDisplay: u.email, primaryDisplay: u.email,
email: u.email, email: u.email,
firstName: u.firstName, firstName: u.firstName,

View file

@ -12,7 +12,9 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { import {
processInputBBReference,
processInputBBReferences, processInputBBReferences,
processOutputBBReference,
processOutputBBReferences, processOutputBBReferences,
} from "./bbReferenceProcessor" } from "./bbReferenceProcessor"
import { isExternalTableID } from "../../integrations/utils" import { isExternalTableID } from "../../integrations/utils"
@ -163,10 +165,10 @@ export async function inputProcessing(
if (attachment?.url) { if (attachment?.url) {
delete clonedRow[key].url delete clonedRow[key].url
} }
} } else if (field.type === FieldType.BB_REFERENCE && value) {
if (field.type === FieldType.BB_REFERENCE && value) {
clonedRow[key] = await processInputBBReferences(value, field.subtype) clonedRow[key] = await processInputBBReferences(value, field.subtype)
} else if (field.type === FieldType.BB_REFERENCE_SINGLE && value) {
clonedRow[key] = await processInputBBReference(value, field.subtype)
} }
} }
@ -258,6 +260,16 @@ export async function outputProcessing<T extends Row[] | Row>(
column.subtype 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 * as backendCore from "@budibase/backend-core"
import { BBReferenceFieldSubType, User } from "@budibase/types" import { BBReferenceFieldSubType, User } from "@budibase/types"
import { import {
processInputBBReference,
processInputBBReferences, processInputBBReferences,
processOutputBBReference,
processOutputBBReferences, processOutputBBReferences,
} from "../bbReferenceProcessor" } from "../bbReferenceProcessor"
import { import {
@ -22,6 +24,7 @@ jest.mock("@budibase/backend-core", (): typeof backendCore => {
...actual.cache, ...actual.cache,
user: { user: {
...actual.cache.user, ...actual.cache.user,
getUser: jest.fn(actual.cache.user.getUser),
getUsers: jest.fn(actual.cache.user.getUsers), getUsers: jest.fn(actual.cache.user.getUsers),
}, },
}, },
@ -31,6 +34,9 @@ jest.mock("@budibase/backend-core", (): typeof backendCore => {
const config = new DBTestConfiguration() const config = new DBTestConfiguration()
describe("bbReferenceProcessor", () => { describe("bbReferenceProcessor", () => {
const cacheGetUserSpy = backendCore.cache.user.getUser as jest.MockedFunction<
typeof backendCore.cache.user.getUser
>
const cacheGetUsersSpy = backendCore.cache.user const cacheGetUsersSpy = backendCore.cache.user
.getUsers as jest.MockedFunction<typeof backendCore.cache.user.getUsers> .getUsers as jest.MockedFunction<typeof backendCore.cache.user.getUsers>
@ -56,6 +62,64 @@ describe("bbReferenceProcessor", () => {
jest.clearAllMocks() 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("processInputBBReferences", () => {
describe("subtype user", () => { describe("subtype user", () => {
it("validate valid string id", async () => { it("validate valid string id", async () => {
@ -66,7 +130,7 @@ describe("bbReferenceProcessor", () => {
processInputBBReferences(userId, BBReferenceFieldSubType.USER) processInputBBReferences(userId, BBReferenceFieldSubType.USER)
) )
expect(result).toEqual(userId) expect(result).toEqual([userId])
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1) expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith([userId]) expect(cacheGetUsersSpy).toHaveBeenCalledWith([userId])
}) })
@ -93,7 +157,7 @@ describe("bbReferenceProcessor", () => {
processInputBBReferences(userIdCsv, BBReferenceFieldSubType.USER) processInputBBReferences(userIdCsv, BBReferenceFieldSubType.USER)
) )
expect(result).toEqual(userIds.join(",")) expect(result).toEqual(userIds)
expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1) expect(cacheGetUsersSpy).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith(userIds) 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 () => { 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(() => 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).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith(userIds) expect(cacheGetUsersSpy).toHaveBeenCalledWith(userIds)
}) })
it("empty strings will return null", async () => { it("empty strings will return null", async () => {
const result = await config.doInTenant(() => const result = await config.doInTenant(() =>
processInputBBReferences("", BBReferenceFieldSubType.USER) processInputBBReferences(
"",
BBReferenceFieldSubType.USER
)
) )
expect(result).toEqual(null) expect(result).toEqual(null)
@ -166,7 +220,42 @@ describe("bbReferenceProcessor", () => {
const result = await config.doInTenant(() => const result = await config.doInTenant(() =>
processInputBBReferences(userMetadataId, BBReferenceFieldSubType.USER) 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).toHaveBeenCalledTimes(1)
expect(cacheGetUsersSpy).toHaveBeenCalledWith([userId1, userId2]) 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" import * as bbReferenceProcessor from "../bbReferenceProcessor"
jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({ jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
processInputBBReference: jest.fn(),
processInputBBReferences: jest.fn(), processInputBBReferences: jest.fn(),
processOutputBBReference: jest.fn(),
processOutputBBReferences: jest.fn(), processOutputBBReferences: jest.fn(),
})) }))
@ -19,7 +21,64 @@ describe("rowProcessor - inputProcessing", () => {
jest.resetAllMocks() 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 userId = generator.guid()
const table: Table = { const table: Table = {
@ -56,9 +115,7 @@ describe("rowProcessor - inputProcessing", () => {
const user = structures.users.user() const user = structures.users.user()
;( processInputBBReferencesMock.mockResolvedValue(user)
bbReferenceProcessor.processInputBBReferences as jest.Mock
).mockResolvedValue(user)
const { row } = await inputProcessing(userId, table, newRow) 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" import * as bbReferenceProcessor from "../bbReferenceProcessor"
jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({ jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
processInputBBReference: jest.fn(),
processInputBBReferences: jest.fn(), processInputBBReferences: jest.fn(),
processOutputBBReference: jest.fn(),
processOutputBBReferences: jest.fn(), processOutputBBReferences: jest.fn(),
})) }))
@ -20,10 +22,12 @@ describe("rowProcessor - outputProcessing", () => {
jest.resetAllMocks() jest.resetAllMocks()
}) })
const processOutputBBReferenceMock =
bbReferenceProcessor.processOutputBBReference as jest.Mock
const processOutputBBReferencesMock = const processOutputBBReferencesMock =
bbReferenceProcessor.processOutputBBReferences as jest.Mock 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 = { const table: Table = {
_id: generator.guid(), _id: generator.guid(),
name: "TestTable", name: "TestTable",
@ -40,7 +44,7 @@ describe("rowProcessor - outputProcessing", () => {
}, },
}, },
user: { user: {
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER, subtype: BBReferenceFieldSubType.USER,
name: "user", name: "user",
constraints: { constraints: {
@ -57,12 +61,61 @@ describe("rowProcessor - outputProcessing", () => {
} }
const user = structures.users.user() const user = structures.users.user()
processOutputBBReferencesMock.mockResolvedValue(user) processOutputBBReferenceMock.mockResolvedValue(user)
const result = await outputProcessing(table, row, { squash: false }) const result = await outputProcessing(table, row, { squash: false })
expect(result).toEqual({ name: "Jack", user }) 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( expect(
bbReferenceProcessor.processOutputBBReferences bbReferenceProcessor.processOutputBBReferences
).toHaveBeenCalledTimes(1) ).toHaveBeenCalledTimes(1)

View file

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

View file

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

View file

@ -1,3 +1,4 @@
export * from "./helpers" export * from "./helpers"
export * from "./integrations" export * from "./integrations"
export * as cron from "./cron" 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

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

View file

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

View file

@ -105,13 +105,17 @@ export enum FieldType {
*/ */
BIGINT = "bigint", 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 * 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 * 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 * 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. * an array of resource IDs, the API will squash these down and validate them before saving the row.
*/ */
BB_REFERENCE = "bb_reference", 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 { export interface RowAttachment {

View file

@ -1,3 +1,5 @@
import { FieldType } from "../row"
export enum RelationshipType { export enum RelationshipType {
ONE_TO_MANY = "one-to-many", ONE_TO_MANY = "one-to-many",
MANY_TO_ONE = "many-to-one", MANY_TO_ONE = "many-to-one",
@ -27,5 +29,21 @@ export enum FormulaType {
export enum BBReferenceFieldSubType { export enum BBReferenceFieldSubType {
USER = "user", USER = "user",
/** @deprecated this should not be used anymore, left here in order to support the existing usages */
USERS = "users", 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 export interface BBReferenceFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> { extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.BB_REFERENCE type: FieldType.BB_REFERENCE
subtype: BBReferenceFieldSubType.USER | BBReferenceFieldSubType.USERS subtype: BBReferenceFieldSubType
relationshipType?: RelationshipType relationshipType?: RelationshipType
} }
export interface BBReferenceSingleFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.BB_REFERENCE_SINGLE
subtype: Exclude<BBReferenceFieldSubType, BBReferenceFieldSubType.USERS>
}
export interface AttachmentFieldMetadata extends BaseFieldSchema { export interface AttachmentFieldMetadata extends BaseFieldSchema {
type: FieldType.ATTACHMENTS type: FieldType.ATTACHMENTS
@ -164,6 +169,7 @@ interface OtherFieldMetadata extends BaseFieldSchema {
| FieldType.NUMBER | FieldType.NUMBER
| FieldType.LONGFORM | FieldType.LONGFORM
| FieldType.BB_REFERENCE | FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE
| FieldType.ATTACHMENTS | FieldType.ATTACHMENTS
> >
} }
@ -179,6 +185,7 @@ export type FieldSchema =
| BBReferenceFieldMetadata | BBReferenceFieldMetadata
| JsonFieldMetadata | JsonFieldMetadata
| AttachmentFieldMetadata | AttachmentFieldMetadata
| BBReferenceSingleFieldMetadata
export interface TableSchema { export interface TableSchema {
[key: string]: FieldSchema [key: string]: FieldSchema
@ -207,15 +214,3 @@ export function isManyToOne(
): field is ManyToOneRelationshipFieldMetadata { ): field is ManyToOneRelationshipFieldMetadata {
return field.relationshipType === RelationshipType.MANY_TO_ONE 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
}

1248
yarn.lock

File diff suppressed because it is too large Load diff