Merge branch 'master' into builder-store-conversions-pc

This commit is contained in:
Peter Clement 2025-01-15 09:02:49 +00:00 committed by GitHub
commit cc053ec190
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
116 changed files with 2806 additions and 818 deletions

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.2.37",
"version": "3.2.42",
"npmClient": "yarn",
"concurrency": 20,
"command": {

View File

@ -21,7 +21,7 @@
"scripts": {
"prebuild": "rimraf dist/",
"prepack": "cp package.json dist",
"build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null",
"build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null && tsc -p tsconfig.test.json --paths null",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"build:oss": "node ./scripts/build.js",
"check:types": "tsc -p tsconfig.json --noEmit --paths null",

View File

@ -32,8 +32,12 @@ export async function errorHandling(ctx: any, next: any) {
}
if (environment.isTest() && ctx.headers["x-budibase-include-stacktrace"]) {
let rootErr = err
while (rootErr.cause) {
rootErr = rootErr.cause
}
// @ts-ignore
error.stack = err.stack
error.stack = rootErr.stack
}
ctx.body = error

View File

@ -272,17 +272,6 @@ class InternalBuilder {
return parts.join(".")
}
private isFullSelectStatementRequired(): boolean {
for (let column of Object.values(this.table.schema)) {
if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(column)) {
return true
} else if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(column)) {
return true
}
}
return false
}
private generateSelectStatement(): (string | Knex.Raw)[] | "*" {
const { table, resource } = this.query
@ -292,11 +281,9 @@ class InternalBuilder {
const alias = this.getTableName(table)
const schema = this.table.schema
if (!this.isFullSelectStatementRequired()) {
return [this.knex.raw("??", [`${alias}.*`])]
}
// get just the fields for this table
return resource.fields
const tableFields = resource.fields
.map(field => {
const parts = field.split(/\./g)
let table: string | undefined = undefined
@ -311,34 +298,33 @@ class InternalBuilder {
return { table, column, field }
})
.filter(({ table }) => !table || table === alias)
.map(({ table, column, field }) => {
const columnSchema = schema[column]
if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) {
return this.knex.raw(`??::money::numeric as ??`, [
this.rawQuotedIdentifier([table, column].join(".")),
this.knex.raw(this.quote(field)),
])
}
return tableFields.map(({ table, column, field }) => {
const columnSchema = schema[column]
if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) {
// Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format
if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) {
return this.knex.raw(`??::money::numeric as ??`, [
this.rawQuotedIdentifier([table, column].join(".")),
this.knex.raw(this.quote(field)),
])
}
// TODO: figure out how to express this safely without string
// interpolation.
return this.knex.raw(`CONVERT(varchar, ??, 108) as ??`, [
this.rawQuotedIdentifier(field),
this.knex.raw(this.quote(field)),
])
}
if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) {
// Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format
if (table) {
return this.rawQuotedIdentifier(`${table}.${column}`)
} else {
return this.rawQuotedIdentifier(field)
}
})
return this.knex.raw(`CONVERT(varchar, ??, 108) as ??`, [
this.rawQuotedIdentifier(field),
this.knex.raw(this.quote(field)),
])
}
if (table) {
return this.rawQuotedIdentifier(`${table}.${column}`)
} else {
return this.rawQuotedIdentifier(field)
}
})
}
// OracleDB can't use character-large-objects (CLOBs) in WHERE clauses,
@ -816,14 +802,29 @@ class InternalBuilder {
filters.oneOf,
ArrayOperator.ONE_OF,
(q, key: string, array) => {
const schema = this.getFieldSchema(key)
const values = Array.isArray(array) ? array : [array]
if (shouldOr) {
q = q.or
}
if (this.client === SqlClient.ORACLE) {
// @ts-ignore
key = this.convertClobs(key)
} else if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
for (const value of values) {
if (value != null) {
q = q.or.whereLike(key, `${value.toISOString().slice(0, 10)}%`)
} else {
q = q.or.whereNull(key)
}
}
return q
}
return q.whereIn(key, Array.isArray(array) ? array : [array])
return q.whereIn(key, values)
},
(q, key: string[], array) => {
if (shouldOr) {
@ -882,6 +883,19 @@ class InternalBuilder {
let high = value.high
let low = value.low
if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
if (high != null) {
high = `${high.toISOString().slice(0, 10)}T23:59:59.999Z`
}
if (low != null) {
low = low.toISOString().slice(0, 10)
}
}
if (this.client === SqlClient.ORACLE) {
rawKey = this.convertClobs(key)
} else if (
@ -914,6 +928,7 @@ class InternalBuilder {
}
if (filters.equal) {
iterate(filters.equal, BasicOperator.EQUAL, (q, key, value) => {
const schema = this.getFieldSchema(key)
if (shouldOr) {
q = q.or
}
@ -928,6 +943,16 @@ class InternalBuilder {
// @ts-expect-error knex types are wrong, raw is fine here
subq.whereNotNull(identifier).andWhere(identifier, value)
)
} else if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
if (value != null) {
return q.whereLike(key, `${value.toISOString().slice(0, 10)}%`)
} else {
return q.whereNull(key)
}
} else {
return q.whereRaw(`COALESCE(?? = ?, FALSE)`, [
this.rawQuotedIdentifier(key),
@ -938,6 +963,7 @@ class InternalBuilder {
}
if (filters.notEqual) {
iterate(filters.notEqual, BasicOperator.NOT_EQUAL, (q, key, value) => {
const schema = this.getFieldSchema(key)
if (shouldOr) {
q = q.or
}
@ -959,6 +985,18 @@ class InternalBuilder {
// @ts-expect-error knex types are wrong, raw is fine here
.or.whereNull(identifier)
)
} else if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.DATETIME &&
schema.dateOnly
) {
if (value != null) {
return q.not
.whereLike(key, `${value.toISOString().slice(0, 10)}%`)
.or.whereNull(key)
} else {
return q.not.whereNull(key)
}
} else {
return q.whereRaw(`COALESCE(?? != ?, TRUE)`, [
this.rawQuotedIdentifier(key),
@ -1239,6 +1277,7 @@ class InternalBuilder {
if (!toTable || !fromTable) {
continue
}
const relatedTable = tables[toTable]
if (!relatedTable) {
throw new Error(`related table "${toTable}" not found in datasource`)
@ -1267,6 +1306,10 @@ class InternalBuilder {
const fieldList = relationshipFields.map(field =>
this.buildJsonField(relatedTable, field)
)
if (!fieldList.length) {
continue
}
const fieldListFormatted = fieldList
.map(f => {
const separator = this.client === SqlClient.ORACLE ? " VALUE " : ","
@ -1307,7 +1350,9 @@ class InternalBuilder {
)
const standardWrap = (select: Knex.Raw): Knex.QueryBuilder => {
subQuery = subQuery.select(`${toAlias}.*`).limit(getRelationshipLimit())
subQuery = subQuery
.select(relationshipFields)
.limit(getRelationshipLimit())
// @ts-ignore - the from alias syntax isn't in Knex typing
return knex.select(select).from({
[toAlias]: subQuery,
@ -1537,11 +1582,12 @@ class InternalBuilder {
limits?: { base: number; query: number }
} = {}
): Knex.QueryBuilder {
let { operation, filters, paginate, relationships, table } = this.query
const { operation, filters, paginate, relationships, table } = this.query
const { limits } = opts
// start building the query
let query = this.qualifiedKnex()
// handle pagination
let foundOffset: number | null = null
let foundLimit = limits?.query || limits?.base
@ -1590,7 +1636,7 @@ class InternalBuilder {
const mainTable = this.query.tableAliases?.[table.name] || table.name
const cte = this.addSorting(
this.knex
.with("paginated", query)
.with("paginated", query.clone().clearSelect().select("*"))
.select(this.generateSelectStatement())
.from({
[mainTable]: "paginated",

View File

@ -14,7 +14,7 @@ import environment from "../environment"
const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}`
const ROW_ID_REGEX = /^\[.*]$/g
const ENCODED_SPACE = encodeURIComponent(" ")
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}.\d{3}Z)?$/
const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/
export function isExternalTableID(tableId: string) {
@ -149,15 +149,7 @@ export function isInvalidISODateString(str: string) {
}
export function isValidISODateString(str: string) {
const trimmedValue = str.trim()
if (!ISO_DATE_REGEX.test(trimmedValue)) {
return false
}
let d = new Date(trimmedValue)
if (isNaN(d.getTime())) {
return false
}
return d.toISOString() === trimmedValue
return ISO_DATE_REGEX.test(str.trim())
}
export function isValidFilter(value: any) {

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.build.json",
"compilerOptions": {
"outDir": "dist",
"sourceMap": true
},
"include": ["tests/**/*.js", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@ -49,7 +49,6 @@
import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core"
import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte"
import { isEnabled } from "@/helpers/featureFlags"
import { getUserBindings } from "@/dataBinding"
export let field
@ -168,7 +167,6 @@
// used to select what different options can be displayed for column type
$: canBeDisplay =
canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn
$: defaultValuesEnabled = isEnabled("DEFAULT_VALUES")
$: canHaveDefault = !required && canHaveDefaultColumn(editableColumn.type)
$: canBeRequired =
editableColumn?.type !== FieldType.LINK &&
@ -300,7 +298,7 @@
}
// Ensure we don't have a default value if we can't have one
if (!canHaveDefault || !defaultValuesEnabled) {
if (!canHaveDefault) {
delete saveColumn.default
}
@ -848,51 +846,49 @@
</div>
{/if}
{#if defaultValuesEnabled}
{#if editableColumn.type === FieldType.OPTIONS}
<Select
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
placeholder="None"
/>
{:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else if editableColumn.subtype === BBReferenceFieldSubType.USER}
{@const defaultValue =
editableColumn.type === FieldType.BB_REFERENCE_SINGLE
? SingleUserDefault
: MultiUserDefault}
<Toggle
disabled={!canHaveDefault}
text="Default to current user"
value={editableColumn.default === defaultValue}
on:change={e =>
(editableColumn.default = e.detail ? defaultValue : undefined)}
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
{/if}
{#if editableColumn.type === FieldType.OPTIONS}
<Select
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
placeholder="None"
/>
{:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else if editableColumn.subtype === BBReferenceFieldSubType.USER}
{@const defaultValue =
editableColumn.type === FieldType.BB_REFERENCE_SINGLE
? SingleUserDefault
: MultiUserDefault}
<Toggle
disabled={!canHaveDefault}
text="Default to current user"
value={editableColumn.default === defaultValue}
on:change={e =>
(editableColumn.default = e.detail ? defaultValue : undefined)}
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
{/if}
</Layout>

View File

@ -28,7 +28,9 @@
let loading = false
let deleteConfirmationDialog
$: defaultName = getSequentialName($snippets, "MySnippet", x => x.name)
$: defaultName = getSequentialName($snippets, "MySnippet", {
getName: x => x.name,
})
$: key = snippet?.name
$: name = snippet?.name || defaultName
$: code = snippet?.code ? encodeJSBinding(snippet.code) : ""

View File

@ -16,7 +16,10 @@ export {
export const AUTO_COLUMN_SUB_TYPES = AutoFieldSubType
export const AUTO_COLUMN_DISPLAY_NAMES = {
export const AUTO_COLUMN_DISPLAY_NAMES: Record<
keyof typeof AUTO_COLUMN_SUB_TYPES,
string
> = {
AUTO_ID: "Auto ID",
CREATED_BY: "Created By",
CREATED_AT: "Created At",
@ -209,13 +212,6 @@ export const Roles = {
BUILDER: "BUILDER",
}
export function isAutoColumnUserRelationship(subtype) {
return (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY ||
subtype === AUTO_COLUMN_SUB_TYPES.UPDATED_BY
)
}
export const PrettyRelationshipDefinitions = {
MANY: "Many rows",
ONE: "One row",

View File

@ -10,13 +10,13 @@
*
* Repl
*/
export const duplicateName = (name, allNames) => {
export const duplicateName = (name: string, allNames: string[]) => {
const duplicatePattern = new RegExp(`\\s(\\d+)$`)
const baseName = name.split(duplicatePattern)[0]
const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`)
// get the sequence from matched names
const sequence = []
const sequence: number[] = []
allNames.filter(n => {
if (n === baseName) {
return true
@ -70,12 +70,18 @@ export const duplicateName = (name, allNames) => {
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = (
items,
prefix,
{ getName = x => x, numberFirstItem = false } = {}
export const getSequentialName = <T extends any>(
items: T[] | null,
prefix: string | null,
{
getName,
numberFirstItem,
}: {
getName?: (item: T) => string
numberFirstItem?: boolean
} = {}
) => {
if (!prefix?.length || !getName) {
if (!prefix?.length) {
return null
}
const trimmedPrefix = prefix.trim()
@ -85,7 +91,7 @@ export const getSequentialName = (
}
let max = 0
items.forEach(item => {
const name = getName(item)
const name = getName?.(item) ?? item
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}

View File

@ -1,7 +1,8 @@
import { FeatureFlag } from "@budibase/types"
import { auth } from "../stores/portal"
import { get } from "svelte/store"
export const isEnabled = featureFlag => {
export const isEnabled = (featureFlag: FeatureFlag | `${FeatureFlag}`) => {
const user = get(auth).user
return !!user?.flags?.[featureFlag]
}

View File

@ -1,13 +1,21 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export default function (url) {
const store = writable({ status: "LOADING", data: {}, error: {} })
export default function (url: string) {
const store = writable<{
status: "LOADING" | "SUCCESS" | "ERROR"
data: object
error?: unknown
}>({
status: "LOADING",
data: {},
error: {},
})
async function get() {
store.update(u => ({ ...u, status: "LOADING" }))
try {
const data = await API.get({ url })
const data = await API.get<object>({ url })
store.set({ data, status: "SUCCESS" })
} catch (e) {
store.set({ data: {}, error: e, status: "ERROR" })

View File

@ -1,46 +0,0 @@
import { last, flow } from "lodash/fp"
export const buildStyle = styles => {
let str = ""
for (let s in styles) {
if (styles[s]) {
let key = convertCamel(s)
str += `${key}: ${styles[s]}; `
}
}
return str
}
export const convertCamel = str => {
return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
}
export const pipe = (arg, funcs) => flow(funcs)(arg)
export const capitalise = s => {
if (!s) {
return s
}
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = s =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise])
export const isBuilderInputFocused = e => {
const activeTag = document.activeElement?.tagName.toLowerCase()
const inCodeEditor = document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) &&
e.key !== "Escape"
) {
return true
}
return false
}

View File

@ -0,0 +1,50 @@
import type { Many } from "lodash"
import { last, flow } from "lodash/fp"
export const buildStyle = (styles: Record<string, any>) => {
let str = ""
for (let s in styles) {
if (styles[s]) {
let key = convertCamel(s)
str += `${key}: ${styles[s]}; `
}
}
return str
}
export const convertCamel = (str: string) => {
return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`)
}
export const pipe = (arg: string, funcs: Many<(...args: any[]) => any>) =>
flow(funcs)(arg)
export const capitalise = (s: string) => {
if (!s) {
return s
}
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
export const lowercase = (s: string) =>
s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = (s: string) =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = (s: string) => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = (name: string) =>
pipe(name, [get_name, capitalise])
export const isBuilderInputFocused = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName.toLowerCase()
const inCodeEditor = document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag!) !== -1) &&
e.key !== "Escape"
) {
return true
}
return false
}

View File

@ -1,7 +0,0 @@
function handleEnter(fnc) {
return e => e.key === "Enter" && fnc()
}
export const keyUtils = {
handleEnter,
}

View File

@ -0,0 +1,7 @@
function handleEnter(fnc: () => void) {
return (e: KeyboardEvent) => e.key === "Enter" && fnc()
}
export const keyUtils = {
handleEnter,
}

View File

@ -1,6 +1,16 @@
import { writable } from "svelte/store"
function defaultValue() {
interface PaginationStore {
nextPage: string | null | undefined
page: string | null | undefined
hasPrevPage: boolean
hasNextPage: boolean
loading: boolean
pageNumber: number
pages: string[]
}
function defaultValue(): PaginationStore {
return {
nextPage: null,
page: undefined,
@ -29,13 +39,13 @@ export function createPaginationStore() {
update(state => {
state.pageNumber++
state.page = state.nextPage
state.pages.push(state.page)
state.pages.push(state.page!)
state.hasPrevPage = state.pageNumber > 1
return state
})
}
function fetched(hasNextPage, nextPage) {
function fetched(hasNextPage: boolean, nextPage: string) {
update(state => {
state.hasNextPage = hasNextPage
state.nextPage = nextPage

View File

@ -1,6 +1,6 @@
import { PlanType } from "@budibase/types"
export function getFormattedPlanName(userPlanType) {
export function getFormattedPlanName(userPlanType: PlanType) {
let planName
switch (userPlanType) {
case PlanType.PRO:
@ -29,6 +29,6 @@ export function getFormattedPlanName(userPlanType) {
return `${planName} Plan`
}
export function isPremiumOrAbove(userPlanType) {
export function isPremiumOrAbove(userPlanType: PlanType) {
return ![PlanType.PRO, PlanType.TEAM, PlanType.FREE].includes(userPlanType)
}

View File

@ -1,4 +1,4 @@
export default function (url) {
export default function (url: string) {
return url
.split("/")
.map(part => {

View File

@ -1,75 +0,0 @@
import { FieldType } from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { TableNames } from "@/constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
AUTO_COLUMN_SUB_TYPES,
FIELDS,
isAutoColumnUserRelationship,
} from "@/constants/backend"
import { isEnabled } from "@/helpers/featureFlags"
export function getAutoColumnInformation(enabled = true) {
let info = {}
for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) {
// Because it's possible to replicate the functionality of CREATED_AT and
// CREATED_BY columns, we disable their creation when the DEFAULT_VALUES
// feature flag is enabled.
if (isEnabled("DEFAULT_VALUES")) {
if (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT ||
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY
) {
continue
}
}
info[subtype] = { enabled, name: AUTO_COLUMN_DISPLAY_NAMES[key] }
}
return info
}
export function buildAutoColumn(tableName, name, subtype) {
let type, constraints
switch (subtype) {
case AUTO_COLUMN_SUB_TYPES.UPDATED_BY:
case AUTO_COLUMN_SUB_TYPES.CREATED_BY:
type = FieldType.LINK
constraints = FIELDS.LINK.constraints
break
case AUTO_COLUMN_SUB_TYPES.AUTO_ID:
type = FieldType.NUMBER
constraints = FIELDS.NUMBER.constraints
break
case AUTO_COLUMN_SUB_TYPES.UPDATED_AT:
case AUTO_COLUMN_SUB_TYPES.CREATED_AT:
type = FieldType.DATETIME
constraints = FIELDS.DATETIME.constraints
break
default:
type = FieldType.STRING
constraints = FIELDS.STRING.constraints
break
}
if (Object.values(AUTO_COLUMN_SUB_TYPES).indexOf(subtype) === -1) {
throw "Cannot build auto column with supplied subtype"
}
const base = {
name,
type,
subtype,
icon: "ri-magic-line",
autocolumn: true,
constraints,
}
if (isAutoColumnUserRelationship(subtype)) {
base.tableId = TableNames.USERS
base.fieldName = `${tableName}-${name}`
}
return base
}
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -0,0 +1,96 @@
import {
AutoFieldSubType,
Automation,
DateFieldMetadata,
FieldType,
NumberFieldMetadata,
RelationshipFieldMetadata,
RelationshipType,
} from "@budibase/types"
import { ActionStepID } from "@/constants/backend/automations"
import { TableNames } from "@/constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
AUTO_COLUMN_SUB_TYPES,
FIELDS,
} from "@/constants/backend"
import { utils } from "@budibase/shared-core"
type AutoColumnInformation = Partial<
Record<AutoFieldSubType, { enabled: boolean; name: string }>
>
export function getAutoColumnInformation(
enabled = true
): AutoColumnInformation {
const info: AutoColumnInformation = {}
for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) {
// Because it's possible to replicate the functionality of CREATED_AT and
// CREATED_BY columns with user column default values, we disable their creation
if (
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT ||
subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY
) {
continue
}
const typedKey = key as keyof typeof AUTO_COLUMN_SUB_TYPES
info[subtype] = {
enabled,
name: AUTO_COLUMN_DISPLAY_NAMES[typedKey],
}
}
return info
}
export function buildAutoColumn(
tableName: string,
name: string,
subtype: AutoFieldSubType
): RelationshipFieldMetadata | NumberFieldMetadata | DateFieldMetadata {
const base = {
name,
icon: "ri-magic-line",
autocolumn: true,
}
switch (subtype) {
case AUTO_COLUMN_SUB_TYPES.UPDATED_BY:
case AUTO_COLUMN_SUB_TYPES.CREATED_BY:
return {
...base,
type: FieldType.LINK,
subtype,
constraints: FIELDS.LINK.constraints,
tableId: TableNames.USERS,
fieldName: `${tableName}-${name}`,
relationshipType: RelationshipType.MANY_TO_ONE,
}
case AUTO_COLUMN_SUB_TYPES.AUTO_ID:
return {
...base,
type: FieldType.NUMBER,
subtype,
constraints: FIELDS.NUMBER.constraints,
}
case AUTO_COLUMN_SUB_TYPES.UPDATED_AT:
case AUTO_COLUMN_SUB_TYPES.CREATED_AT:
return {
...base,
type: FieldType.DATETIME,
subtype,
constraints: FIELDS.DATETIME.constraints,
}
default:
throw utils.unreachable(subtype, {
message: "Cannot build auto column with supplied subtype",
})
}
}
export function checkForCollectStep(automation: Automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -1,4 +1,4 @@
export const suppressWarnings = warnings => {
export const suppressWarnings = (warnings: string[]) => {
if (!warnings?.length) {
return
}

View File

@ -442,13 +442,11 @@
const onUpdateUserInvite = async (invite, role) => {
let updateBody = {
code: invite.code,
apps: {
...invite.apps,
[prodAppId]: role,
},
}
if (role === Constants.Roles.CREATOR) {
updateBody.builder = updateBody.builder || {}
updateBody.builder.apps = [...(updateBody.builder.apps ?? []), prodAppId]
@ -456,7 +454,7 @@
} else if (role !== Constants.Roles.CREATOR && invite?.builder?.apps) {
invite.builder.apps = []
}
await users.updateInvite(updateBody)
await users.updateInvite(invite.code, updateBody)
await filterInvites(query)
}
@ -470,8 +468,7 @@
let updated = { ...invite }
delete updated.info.apps[prodAppId]
return await users.updateInvite({
code: updated.code,
return await users.updateInvite(updated.code, {
apps: updated.apps,
})
}

View File

@ -191,8 +191,14 @@
? "View errors"
: "View error"}
on:dismiss={async () => {
await automationStore.actions.clearLogErrors({ appId })
await appsStore.load()
const automationId = Object.keys(automationErrors[appId] || {})[0]
if (automationId) {
await automationStore.actions.clearLogErrors({
appId,
automationId,
})
await appsStore.load()
}
}}
message={automationErrorMessage(appId)}
/>

View File

@ -52,7 +52,7 @@
]
const removeUser = async id => {
await groups.actions.removeUser(groupId, id)
await groups.removeUser(groupId, id)
fetchGroupUsers.refresh()
}

View File

@ -251,6 +251,7 @@
passwordModal.show()
await fetch.refresh()
} catch (error) {
console.error(error)
notifications.error("Error creating user")
}
}

View File

@ -1,130 +0,0 @@
import { writable, get, derived } from "svelte/store"
import { datasources } from "./datasources"
import { integrations } from "./integrations"
import { API } from "@/api"
import { duplicateName } from "@/helpers/duplicate"
const sortQueries = queryList => {
queryList.sort((q1, q2) => {
return q1.name.localeCompare(q2.name)
})
}
export function createQueriesStore() {
const store = writable({
list: [],
selectedQueryId: null,
})
const derivedStore = derived(store, $store => ({
...$store,
selected: $store.list?.find(q => q._id === $store.selectedQueryId),
}))
const fetch = async () => {
const queries = await API.getQueries()
sortQueries(queries)
store.update(state => ({
...state,
list: queries,
}))
}
const save = async (datasourceId, query) => {
const _integrations = get(integrations)
const dataSource = get(datasources).list.filter(
ds => ds._id === datasourceId
)
// Check if readable attribute is found
if (dataSource.length !== 0) {
const integration = _integrations[dataSource[0].source]
const readable = integration.query[query.queryVerb].readable
if (readable) {
query.readable = readable
}
}
query.datasourceId = datasourceId
const savedQuery = await API.saveQuery(query)
store.update(state => {
const idx = state.list.findIndex(query => query._id === savedQuery._id)
const queries = state.list
if (idx >= 0) {
queries.splice(idx, 1, savedQuery)
} else {
queries.push(savedQuery)
}
sortQueries(queries)
return {
list: queries,
selectedQueryId: savedQuery._id,
}
})
return savedQuery
}
const importQueries = async ({ data, datasourceId }) => {
return await API.importQueries(datasourceId, data)
}
const select = id => {
store.update(state => ({
...state,
selectedQueryId: id,
}))
}
const preview = async query => {
const result = await API.previewQuery(query)
// Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server
const schema = {}
for (let [field, metadata] of Object.entries(result.schema)) {
schema[field] = metadata || { type: "string" }
}
return { ...result, schema, rows: result.rows || [] }
}
const deleteQuery = async query => {
await API.deleteQuery(query._id, query._rev)
store.update(state => {
state.list = state.list.filter(existing => existing._id !== query._id)
return state
})
}
const duplicate = async query => {
let list = get(store).list
const newQuery = { ...query }
const datasourceId = query.datasourceId
delete newQuery._id
delete newQuery._rev
newQuery.name = duplicateName(
query.name,
list.map(q => q.name)
)
return await save(datasourceId, newQuery)
}
const removeDatasourceQueries = datasourceId => {
store.update(state => ({
...state,
list: state.list.filter(table => table.datasourceId !== datasourceId),
}))
}
return {
subscribe: derivedStore.subscribe,
fetch,
init: fetch,
select,
save,
import: importQueries,
delete: deleteQuery,
preview,
duplicate,
removeDatasourceQueries,
}
}
export const queries = createQueriesStore()

View File

@ -0,0 +1,156 @@
import { derived, get, Writable } from "svelte/store"
import { datasources } from "./datasources"
import { integrations } from "./integrations"
import { API } from "@/api"
import { duplicateName } from "@/helpers/duplicate"
import { DerivedBudiStore } from "@/stores/BudiStore"
import {
Query,
QueryPreview,
PreviewQueryResponse,
SaveQueryRequest,
ImportRestQueryRequest,
QuerySchema,
} from "@budibase/types"
const sortQueries = (queryList: Query[]) => {
queryList.sort((q1, q2) => {
return q1.name.localeCompare(q2.name)
})
}
interface BuilderQueryStore {
list: Query[]
selectedQueryId: string | null
}
interface DerivedQueryStore extends BuilderQueryStore {
selected?: Query
}
export class QueryStore extends DerivedBudiStore<
BuilderQueryStore,
DerivedQueryStore
> {
constructor() {
const makeDerivedStore = (store: Writable<BuilderQueryStore>) => {
return derived(store, ($store): DerivedQueryStore => {
return {
list: $store.list,
selectedQueryId: $store.selectedQueryId,
selected: $store.list?.find(q => q._id === $store.selectedQueryId),
}
})
}
super(
{
list: [],
selectedQueryId: null,
},
makeDerivedStore
)
this.select = this.select.bind(this)
}
async fetch() {
const queries = await API.getQueries()
sortQueries(queries)
this.store.update(state => ({
...state,
list: queries,
}))
}
async save(datasourceId: string, query: SaveQueryRequest) {
const _integrations = get(integrations)
const dataSource = get(datasources).list.filter(
ds => ds._id === datasourceId
)
// Check if readable attribute is found
if (dataSource.length !== 0) {
const integration = _integrations[dataSource[0].source]
const readable = integration.query[query.queryVerb].readable
if (readable) {
query.readable = readable
}
}
query.datasourceId = datasourceId
const savedQuery = await API.saveQuery(query)
this.store.update(state => {
const idx = state.list.findIndex(query => query._id === savedQuery._id)
const queries = state.list
if (idx >= 0) {
queries.splice(idx, 1, savedQuery)
} else {
queries.push(savedQuery)
}
sortQueries(queries)
return {
list: queries,
selectedQueryId: savedQuery._id || null,
}
})
return savedQuery
}
async importQueries(data: ImportRestQueryRequest) {
return await API.importQueries(data)
}
select(id: string | null) {
this.store.update(state => ({
...state,
selectedQueryId: id,
}))
}
async preview(query: QueryPreview): Promise<PreviewQueryResponse> {
const result = await API.previewQuery(query)
// Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server
const schema: Record<string, QuerySchema> = {}
for (let [field, metadata] of Object.entries(result.schema)) {
schema[field] = (metadata as QuerySchema) || { type: "string" }
}
return { ...result, schema, rows: result.rows || [] }
}
async delete(query: Query) {
if (!query._id || !query._rev) {
throw new Error("Query ID or Revision is missing")
}
await API.deleteQuery(query._id, query._rev)
this.store.update(state => ({
...state,
list: state.list.filter(existing => existing._id !== query._id),
}))
}
async duplicate(query: Query) {
let list = get(this.store).list
const newQuery = { ...query }
const datasourceId = query.datasourceId
delete newQuery._id
delete newQuery._rev
newQuery.name = duplicateName(
query.name,
list.map(q => q.name)
)
return await this.save(datasourceId, newQuery)
}
removeDatasourceQueries(datasourceId: string) {
this.store.update(state => ({
...state,
list: state.list.filter(table => table.datasourceId !== datasourceId),
}))
}
init = this.fetch
}
export const queries = new QueryStore()

View File

@ -62,7 +62,7 @@ export class RowActionStore extends BudiStore<RowActionState> {
const existingRowActions = get(this)[tableId] || []
name = getSequentialName(existingRowActions, "New row action ", {
getName: x => x.name,
})
})!
}
if (!name) {

View File

@ -1,9 +1,7 @@
import { derived, Readable } from "svelte/store"
import { admin } from "./admin"
import { auth } from "./auth"
import { isEnabled } from "@/helpers/featureFlags"
import { sdk } from "@budibase/shared-core"
import { FeatureFlag } from "@budibase/types"
interface MenuItem {
title: string
@ -73,13 +71,11 @@ export const menu: Readable<MenuItem[]> = derived(
title: "Environment",
href: "/builder/portal/settings/environment",
},
]
if (isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) {
settingsSubPages.push({
{
title: "AI",
href: "/builder/portal/settings/ai",
})
}
},
]
if (!cloud) {
settingsSubPages.push({

View File

@ -1,41 +1,71 @@
import { writable } from "svelte/store"
import { API } from "@/api"
import { update } from "lodash"
import { licensing } from "."
import { sdk } from "@budibase/shared-core"
import { Constants } from "@budibase/frontend-core"
import {
DeleteInviteUsersRequest,
InviteUsersRequest,
SearchUsersRequest,
SearchUsersResponse,
UpdateInviteRequest,
User,
UserIdentifier,
UnsavedUser,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
export function createUsersStore() {
const { subscribe, set } = writable({})
interface UserInfo {
email: string
password: string
forceResetPassword?: boolean
role: keyof typeof Constants.BudibaseRoles
}
// opts can contain page and search params
async function search(opts = {}) {
type UserState = SearchUsersResponse & SearchUsersRequest
class UserStore extends BudiStore<UserState> {
constructor() {
super({
data: [],
})
}
async search(opts: SearchUsersRequest = {}) {
const paged = await API.searchUsers(opts)
set({
this.set({
...paged,
...opts,
})
return paged
}
async function get(userId) {
async get(userId: string) {
try {
return await API.getUser(userId)
} catch (err) {
return null
}
}
const fetch = async () => {
async fetch() {
return await API.getUsers()
}
// One or more users.
async function onboard(payload) {
async onboard(payload: InviteUsersRequest) {
return await API.onboardUsers(payload)
}
async function invite(payload) {
const users = payload.map(user => {
async invite(
payload: {
admin?: boolean
builder?: boolean
creator?: boolean
email: string
apps?: any[]
groups?: any[]
}[]
) {
const users: InviteUsersRequest = payload.map(user => {
let builder = undefined
if (user.admin || user.builder) {
builder = { global: true }
@ -55,11 +85,16 @@ export function createUsersStore() {
return API.inviteUsers(users)
}
async function removeInvites(payload) {
async removeInvites(payload: DeleteInviteUsersRequest) {
return API.removeUserInvites(payload)
}
async function acceptInvite(inviteCode, password, firstName, lastName) {
async acceptInvite(
inviteCode: string,
password: string,
firstName: string,
lastName?: string
) {
return API.acceptInvite({
inviteCode,
password,
@ -68,21 +103,25 @@ export function createUsersStore() {
})
}
async function fetchInvite(inviteCode) {
async fetchInvite(inviteCode: string) {
return API.getUserInvite(inviteCode)
}
async function getInvites() {
async getInvites() {
return API.getUserInvites()
}
async function updateInvite(invite) {
return API.updateUserInvite(invite.code, invite)
async updateInvite(code: string, invite: UpdateInviteRequest) {
return API.updateUserInvite(code, invite)
}
async function create(data) {
let mappedUsers = data.users.map(user => {
const body = {
async getUserCountByApp(appId: string) {
return await API.getUserCountByApp(appId)
}
async create(data: { users: UserInfo[]; groups: any[] }) {
let mappedUsers: UnsavedUser[] = data.users.map((user: any) => {
const body: UnsavedUser = {
email: user.email,
password: user.password,
roles: {},
@ -92,17 +131,17 @@ export function createUsersStore() {
}
switch (user.role) {
case "appUser":
case Constants.BudibaseRoles.AppUser:
body.builder = { global: false }
body.admin = { global: false }
break
case "developer":
case Constants.BudibaseRoles.Developer:
body.builder = { global: true }
break
case "creator":
case Constants.BudibaseRoles.Creator:
body.builder = { creator: true, global: false }
break
case "admin":
case Constants.BudibaseRoles.Admin:
body.admin = { global: true }
body.builder = { global: true }
break
@ -111,43 +150,47 @@ export function createUsersStore() {
return body
})
const response = await API.createUsers(mappedUsers, data.groups)
licensing.setQuotaUsage()
// re-search from first page
await search()
await this.search()
return response
}
async function del(id) {
async delete(id: string) {
await API.deleteUser(id)
update(users => users.filter(user => user._id !== id))
licensing.setQuotaUsage()
}
async function getUserCountByApp(appId) {
return await API.getUserCountByApp(appId)
async bulkDelete(users: UserIdentifier[]) {
const res = API.deleteUsers(users)
licensing.setQuotaUsage()
return res
}
async function bulkDelete(users) {
return API.deleteUsers(users)
async save(user: User) {
const res = await API.saveUser(user)
licensing.setQuotaUsage()
return res
}
async function save(user) {
return await API.saveUser(user)
}
async function addAppBuilder(userId, appId) {
async addAppBuilder(userId: string, appId: string) {
return await API.addAppBuilder(userId, appId)
}
async function removeAppBuilder(userId, appId) {
async removeAppBuilder(userId: string, appId: string) {
return await API.removeAppBuilder(userId, appId)
}
async function getAccountHolder() {
async getAccountHolder() {
return await API.getAccountHolder()
}
const getUserRole = user => {
if (user && user.email === user.tenantOwnerEmail) {
getUserRole(user?: User & { tenantOwnerEmail?: string }) {
if (!user) {
return Constants.BudibaseRoles.AppUser
}
if (user.email === user.tenantOwnerEmail) {
return Constants.BudibaseRoles.Owner
} else if (sdk.users.isAdmin(user)) {
return Constants.BudibaseRoles.Admin
@ -159,38 +202,6 @@ export function createUsersStore() {
return Constants.BudibaseRoles.AppUser
}
}
const refreshUsage =
fn =>
async (...args) => {
const response = await fn(...args)
await licensing.setQuotaUsage()
return response
}
return {
subscribe,
search,
get,
getUserRole,
fetch,
invite,
onboard,
fetchInvite,
getInvites,
removeInvites,
updateInvite,
getUserCountByApp,
addAppBuilder,
removeAppBuilder,
// any operation that adds or deletes users
acceptInvite,
create: refreshUsage(create),
save: refreshUsage(save),
bulkDelete: refreshUsage(bulkDelete),
delete: refreshUsage(del),
getAccountHolder,
}
}
export const users = createUsersStore()
export const users = new UserStore()

View File

@ -5139,7 +5139,8 @@
{
"type": "text",
"label": "File name",
"key": "key"
"key": "key",
"nested": true
},
{
"type": "event",

View File

@ -1,6 +1,10 @@
import { createAPIClient } from "@budibase/frontend-core"
import { authStore } from "../stores/auth.js"
import { notificationStore, devToolsEnabled, devToolsStore } from "../stores/"
import { authStore } from "../stores/auth"
import {
notificationStore,
devToolsEnabled,
devToolsStore,
} from "../stores/index"
import { get } from "svelte/store"
export const API = createAPIClient({

View File

@ -1,5 +1,5 @@
import { API } from "./api.js"
import { patchAPI } from "./patches.js"
import { API } from "./api"
import { patchAPI } from "./patches"
// Certain endpoints which return rows need patched so that they transform
// and enrich the row docs, so that they can be correctly handled by the

View File

@ -1,19 +1,20 @@
import { Constants } from "@budibase/frontend-core"
import { Constants, APIClient } from "@budibase/frontend-core"
import { FieldTypes } from "../constants"
import { Row, Table } from "@budibase/types"
export const patchAPI = API => {
export const patchAPI = (API: APIClient) => {
/**
* Enriches rows which contain certain field types so that they can
* be properly displayed.
* The ability to create these bindings has been removed, but they will still
* exist in client apps to support backwards compatibility.
*/
const enrichRows = async (rows, tableId) => {
const enrichRows = async (rows: Row[], tableId: string) => {
if (!Array.isArray(rows)) {
return []
}
if (rows.length) {
const tables = {}
const tables: Record<string, Table> = {}
for (let row of rows) {
// Fall back to passed in tableId if row doesn't have it specified
let rowTableId = row.tableId || tableId
@ -54,7 +55,7 @@ export const patchAPI = API => {
const fetchSelf = API.fetchSelf
API.fetchSelf = async () => {
const user = await fetchSelf()
if (user && user._id) {
if (user && "_id" in user && user._id) {
if (user.roleId === "PUBLIC") {
// Don't try to enrich a public user as it will 403
return user
@ -90,13 +91,14 @@ export const patchAPI = API => {
return await enrichRows(rows, tableId)
}
// Wipe any HBS formulae from table definitions, as these interfere with
// Wipe any HBS formulas from table definitions, as these interfere with
// handlebars enrichment
const fetchTableDefinition = API.fetchTableDefinition
API.fetchTableDefinition = async tableId => {
const definition = await fetchTableDefinition(tableId)
Object.keys(definition?.schema || {}).forEach(field => {
if (definition.schema[field]?.type === "formula") {
// @ts-expect-error TODO check what use case removing that would break
delete definition.schema[field].formula
}
})

View File

@ -1,7 +1,7 @@
<script>
import { getContext, onDestroy, onMount, setContext } from "svelte"
import { builderStore } from "stores/builder.js"
import { blockStore } from "stores/blocks.js"
import { blockStore } from "stores/blocks"
const component = getContext("component")
const { styleable } = getContext("sdk")

View File

@ -39,7 +39,7 @@
getActionContextKey,
getActionDependentContextKeys,
} from "../utils/buttonActions.js"
import { gridLayout } from "utils/grid.js"
import { gridLayout } from "utils/grid"
export let instance = {}
export let parent = null

View File

@ -3,7 +3,7 @@
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
import { enrichSearchColumns, enrichFilter } from "utils/blocks"
import { get } from "svelte/store"
export let title

View File

@ -5,7 +5,7 @@
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
import { enrichSearchColumns, enrichFilter } from "utils/blocks.js"
import { enrichSearchColumns, enrichFilter } from "utils/blocks"
import { Utils } from "@budibase/frontend-core"
export let title

View File

@ -2,6 +2,8 @@
import Field from "./Field.svelte"
import { CoreDropzone, ProgressCircle, Helpers } from "@budibase/bbui"
import { getContext, onMount, onDestroy } from "svelte"
import { builderStore } from "stores/builder.js"
import { processStringSync } from "@budibase/string-templates"
export let datasourceId
export let bucket
@ -12,6 +14,8 @@
export let validation
export let onChange
const context = getContext("context")
let fieldState
let fieldApi
let localFiles = []
@ -42,6 +46,9 @@
// Process the file input and return a serializable structure expected by
// the dropzone component to display the file
const processFiles = async fileList => {
if ($builderStore.inBuilder) {
return []
}
return await new Promise(resolve => {
if (!fileList?.length) {
return []
@ -78,9 +85,15 @@
}
const upload = async () => {
const processedFileKey = processStringSync(key, $context)
loading = true
try {
const res = await API.externalUpload(datasourceId, bucket, key, data)
const res = await API.externalUpload(
datasourceId,
bucket,
processedFileKey,
data
)
notificationStore.actions.success("File uploaded successfully")
loading = false
return res
@ -126,7 +139,7 @@
bind:fieldApi
defaultValue={[]}
>
<div class="content">
<div class="content" class:builder={$builderStore.inBuilder}>
{#if fieldState}
<CoreDropzone
value={localFiles}
@ -149,6 +162,9 @@
</Field>
<style>
.content.builder :global(.spectrum-Dropzone) {
pointer-events: none;
}
.content {
position: relative;
}

5
packages/client/src/index.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
interface Window {
"##BUDIBASE_APP_ID##": string
"##BUDIBASE_IN_BUILDER##": string
MIGRATING_APP: boolean
}

View File

@ -29,7 +29,7 @@ import { ActionTypes } from "./constants"
import {
fetchDatasourceSchema,
fetchDatasourceDefinition,
} from "./utils/schema.js"
} from "./utils/schema"
import { getAPIKey } from "./utils/api.js"
import { enrichButtonActions } from "./utils/buttonActions.js"
import { processStringSync, makePropSafe } from "@budibase/string-templates"
@ -74,6 +74,7 @@ export default {
fetchData,
QueryUtils,
ContextScopes: Constants.ContextScopes,
// This is not used internally but exposed to users to be used in plugins
getAPIKey,
enrichButtonActions,
processStringSync,

View File

@ -2,7 +2,9 @@ import { API } from "api"
import { writable } from "svelte/store"
const createAuthStore = () => {
const store = writable(null)
const store = writable<{
csrfToken?: string
} | null>(null)
// Fetches the user object if someone is logged in and has reloaded the page
const fetchUser = async () => {

View File

@ -1,7 +1,7 @@
import { derived } from "svelte/store"
import { Constants } from "@budibase/frontend-core"
import { devToolsStore } from "../devTools.js"
import { authStore } from "../auth.js"
import { authStore } from "../auth"
import { devToolsEnabled } from "./devToolsEnabled.js"
// Derive the current role of the logged-in user

View File

@ -6,7 +6,7 @@ const DEFAULT_NOTIFICATION_TIMEOUT = 3000
const createNotificationStore = () => {
let block = false
const store = writable([])
const store = writable<{ id: string; message: string; count: number }[]>([])
const blockNotifications = (timeout = 1000) => {
block = true
@ -14,11 +14,11 @@ const createNotificationStore = () => {
}
const send = (
message,
message: string,
type = "info",
icon,
icon: string,
autoDismiss = true,
duration,
duration?: number,
count = 1
) => {
if (block) {
@ -66,7 +66,7 @@ const createNotificationStore = () => {
}
}
const dismiss = id => {
const dismiss = (id: string) => {
store.update(state => {
return state.filter(n => n.id !== id)
})
@ -76,13 +76,13 @@ const createNotificationStore = () => {
subscribe: store.subscribe,
actions: {
send,
info: (msg, autoDismiss, duration) =>
info: (msg: string, autoDismiss?: boolean, duration?: number) =>
send(msg, "info", "Info", autoDismiss ?? true, duration),
success: (msg, autoDismiss, duration) =>
success: (msg: string, autoDismiss?: boolean, duration?: number) =>
send(msg, "success", "CheckmarkCircle", autoDismiss ?? true, duration),
warning: (msg, autoDismiss, duration) =>
warning: (msg: string, autoDismiss?: boolean, duration?: number) =>
send(msg, "warning", "Alert", autoDismiss ?? true, duration),
error: (msg, autoDismiss, duration) =>
error: (msg: string, autoDismiss?: boolean, duration?: number) =>
send(msg, "error", "Alert", autoDismiss ?? false, duration),
blockNotifications,
dismiss,

View File

@ -4,8 +4,24 @@ import { API } from "api"
import { peekStore } from "./peek"
import { builderStore } from "./builder"
interface Route {
path: string
screenId: string
}
interface StoreType {
routes: Route[]
routeParams: {}
activeRoute?: Route | null
routeSessionId: number
routerLoaded: boolean
queryParams?: {
peek?: boolean
}
}
const createRouteStore = () => {
const initialState = {
const initialState: StoreType = {
routes: [],
routeParams: {},
activeRoute: null,
@ -22,7 +38,7 @@ const createRouteStore = () => {
} catch (error) {
routeConfig = null
}
let routes = []
const routes: Route[] = []
Object.values(routeConfig?.routes || {}).forEach(route => {
Object.entries(route.subpaths || {}).forEach(([path, config]) => {
routes.push({
@ -43,13 +59,13 @@ const createRouteStore = () => {
return state
})
}
const setRouteParams = routeParams => {
const setRouteParams = (routeParams: StoreType["routeParams"]) => {
store.update(state => {
state.routeParams = routeParams
return state
})
}
const setQueryParams = queryParams => {
const setQueryParams = (queryParams: { peek?: boolean }) => {
store.update(state => {
state.queryParams = {
...queryParams,
@ -60,13 +76,13 @@ const createRouteStore = () => {
return state
})
}
const setActiveRoute = route => {
const setActiveRoute = (route: string) => {
store.update(state => {
state.activeRoute = state.routes.find(x => x.path === route)
return state
})
}
const navigate = (url, peek, externalNewTab) => {
const navigate = (url: string, peek: boolean, externalNewTab: boolean) => {
if (get(builderStore).inBuilder) {
return
}
@ -93,7 +109,7 @@ const createRouteStore = () => {
const setRouterLoaded = () => {
store.update(state => ({ ...state, routerLoaded: true }))
}
const createFullURL = relativeURL => {
const createFullURL = (relativeURL: string) => {
if (!relativeURL?.startsWith("/")) {
return relativeURL
}

View File

@ -1,10 +1,16 @@
import { makePropSafe as safe } from "@budibase/string-templates"
import { API } from "../api/index.js"
import { UILogicalOperator } from "@budibase/types"
import { API } from "../api"
import {
BasicOperator,
LegacyFilter,
UIColumn,
UILogicalOperator,
UISearchFilter,
} from "@budibase/types"
import { Constants } from "@budibase/frontend-core"
// Map of data types to component types for search fields inside blocks
const schemaComponentMap = {
const schemaComponentMap: Record<string, string> = {
string: "stringfield",
options: "optionsfield",
number: "numberfield",
@ -19,7 +25,16 @@ const schemaComponentMap = {
* @param searchColumns the search columns to use
* @param schema the datasource schema
*/
export const enrichSearchColumns = async (searchColumns, schema) => {
export const enrichSearchColumns = async (
searchColumns: string[],
schema: Record<
string,
{
tableId: string
type: string
}
>
) => {
if (!searchColumns?.length || !schema) {
return []
}
@ -61,12 +76,16 @@ export const enrichSearchColumns = async (searchColumns, schema) => {
* @param columns the enriched search column structure
* @param formId the ID of the form containing the search fields
*/
export const enrichFilter = (filter, columns, formId) => {
export const enrichFilter = (
filter: UISearchFilter,
columns: UIColumn[],
formId: string
) => {
if (!columns?.length) {
return filter
}
let newFilters = []
const newFilters: LegacyFilter[] = []
columns?.forEach(column => {
const safePath = column.name.split(".").map(safe).join(".")
const stringType = column.type === "string" || column.type === "formula"
@ -99,7 +118,7 @@ export const enrichFilter = (filter, columns, formId) => {
newFilters.push({
field: column.name,
type: column.type,
operator: stringType ? "string" : "equal",
operator: stringType ? BasicOperator.STRING : BasicOperator.EQUAL,
valueType: "Binding",
value: `{{ ${binding} }}`,
})

View File

@ -1,7 +1,27 @@
import { GridSpacing, GridRowHeight } from "constants"
import { GridSpacing, GridRowHeight } from "@/constants"
import { builderStore } from "stores"
import { buildStyleString } from "utils/styleable.js"
interface GridMetadata {
id: string
styles: Record<string, string | number> & {
"--default-width"?: number
"--default-height"?: number
}
interactive: boolean
errored: boolean
definition?: {
size?: {
width: number
height: number
}
grid?: { hAlign: string; vAlign: string }
}
draggable: boolean
insideGrid: boolean
ignoresLayout: boolean
}
/**
* We use CSS variables on components to control positioning and layout of
* components inside grids.
@ -44,14 +64,17 @@ export const GridDragModes = {
}
// Builds a CSS variable name for a certain piece of grid metadata
export const getGridVar = (device, param) => `--grid-${device}-${param}`
export const getGridVar = (device: string, param: string) =>
`--grid-${device}-${param}`
// Determines whether a JS event originated from immediately within a grid
export const isGridEvent = e => {
export const isGridEvent = (e: Event & { target: HTMLElement }): boolean => {
return (
e.target.dataset?.indicator === "true" ||
// @ts-expect-error: api is not properly typed
e.target
.closest?.(".component")
// @ts-expect-error
?.parentNode.closest(".component")
?.childNodes[0]?.classList?.contains("grid")
)
@ -59,11 +82,11 @@ export const isGridEvent = e => {
// Svelte action to apply required class names and styles to our component
// wrappers
export const gridLayout = (node, metadata) => {
let selectComponent
export const gridLayout = (node: HTMLDivElement, metadata: GridMetadata) => {
let selectComponent: ((e: Event) => void) | null
// Applies the required listeners, CSS and classes to a component DOM node
const applyMetadata = metadata => {
const applyMetadata = (metadata: GridMetadata) => {
const {
id,
styles,
@ -86,7 +109,7 @@ export const gridLayout = (node, metadata) => {
}
// Callback to select the component when clicking on the wrapper
selectComponent = e => {
selectComponent = (e: Event) => {
e.stopPropagation()
builderStore.actions.selectComponent(id)
}
@ -100,7 +123,7 @@ export const gridLayout = (node, metadata) => {
}
width += 2 * GridSpacing
height += 2 * GridSpacing
let vars = {
const vars: Record<string, string | number> = {
"--default-width": width,
"--default-height": height,
}
@ -135,7 +158,7 @@ export const gridLayout = (node, metadata) => {
}
// Apply some metadata to data attributes to speed up lookups
const addDataTag = (tagName, device, param) => {
const addDataTag = (tagName: string, device: string, param: string) => {
const val = `${vars[getGridVar(device, param)]}`
if (node.dataset[tagName] !== val) {
node.dataset[tagName] = val
@ -147,11 +170,12 @@ export const gridLayout = (node, metadata) => {
addDataTag("gridMobileHAlign", Devices.Mobile, GridParams.HAlign)
addDataTag("gridDesktopVAlign", Devices.Desktop, GridParams.VAlign)
addDataTag("gridMobileVAlign", Devices.Mobile, GridParams.VAlign)
if (node.dataset.insideGrid !== true) {
node.dataset.insideGrid = true
if (node.dataset.insideGrid !== "true") {
node.dataset.insideGrid = "true"
}
// Apply all CSS variables to the wrapper
// @ts-expect-error TODO
node.style = buildStyleString(vars)
// Add a listener to select this node on click
@ -160,7 +184,7 @@ export const gridLayout = (node, metadata) => {
}
// Add draggable attribute
node.setAttribute("draggable", !!draggable)
node.setAttribute("draggable", (!!draggable).toString())
}
// Removes the previously set up listeners
@ -176,7 +200,7 @@ export const gridLayout = (node, metadata) => {
applyMetadata(metadata)
return {
update(newMetadata) {
update(newMetadata: GridMetadata) {
removeListeners()
applyMetadata(newMetadata)
},

View File

@ -1,8 +1,8 @@
import { get } from "svelte/store"
import { link } from "svelte-spa-router"
import { link, LinkActionOpts } from "svelte-spa-router"
import { builderStore } from "stores"
export const linkable = (node, href) => {
export const linkable = (node: HTMLElement, href?: LinkActionOpts) => {
if (get(builderStore).inBuilder) {
node.onclick = e => {
e.preventDefault()

View File

@ -1,13 +1,5 @@
import { API } from "api"
import TableFetch from "@budibase/frontend-core/src/fetch/TableFetch"
import ViewFetch from "@budibase/frontend-core/src/fetch/ViewFetch"
import QueryFetch from "@budibase/frontend-core/src/fetch/QueryFetch"
import RelationshipFetch from "@budibase/frontend-core/src/fetch/RelationshipFetch"
import NestedProviderFetch from "@budibase/frontend-core/src/fetch/NestedProviderFetch"
import FieldFetch from "@budibase/frontend-core/src/fetch/FieldFetch"
import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch"
import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch"
import QueryArrayFetch from "@budibase/frontend-core/src/fetch/QueryArrayFetch"
import { DataFetchMap, DataFetchType } from "@budibase/frontend-core"
/**
* Constructs a fetch instance for a given datasource.
@ -16,22 +8,20 @@ import QueryArrayFetch from "@budibase/frontend-core/src/fetch/QueryArrayFetch"
* @param datasource the datasource
* @returns
*/
const getDatasourceFetchInstance = datasource => {
const handler = {
table: TableFetch,
view: ViewFetch,
viewV2: ViewV2Fetch,
query: QueryFetch,
link: RelationshipFetch,
provider: NestedProviderFetch,
field: FieldFetch,
jsonarray: JSONArrayFetch,
queryarray: QueryArrayFetch,
}[datasource?.type]
const getDatasourceFetchInstance = <
TDatasource extends { type: DataFetchType }
>(
datasource: TDatasource
) => {
const handler = DataFetchMap[datasource?.type]
if (!handler) {
return null
}
return new handler({ API })
return new handler({
API,
datasource: datasource as never,
query: null as any,
})
}
/**
@ -39,21 +29,23 @@ const getDatasourceFetchInstance = datasource => {
* @param datasource the datasource to fetch the schema for
* @param options options for enriching the schema
*/
export const fetchDatasourceSchema = async (
datasource,
export const fetchDatasourceSchema = async <
TDatasource extends { type: DataFetchType }
>(
datasource: TDatasource,
options = { enrichRelationships: false, formSchema: false }
) => {
const instance = getDatasourceFetchInstance(datasource)
const definition = await instance?.getDefinition(datasource)
if (!definition) {
const definition = await instance?.getDefinition()
if (!instance || !definition) {
return null
}
// Get the normal schema as long as we aren't wanting a form schema
let schema
let schema: any
if (datasource?.type !== "query" || !options?.formSchema) {
schema = instance.getSchema(datasource, definition)
} else if (definition.parameters?.length) {
schema = instance.getSchema(definition as any)
} else if ("parameters" in definition && definition.parameters?.length) {
schema = {}
definition.parameters.forEach(param => {
schema[param.name] = { ...param, type: "string" }
@ -73,7 +65,12 @@ export const fetchDatasourceSchema = async (
}
// Enrich schema with relationships if required
if (definition?.sql && options?.enrichRelationships) {
if (
definition &&
"sql" in definition &&
definition.sql &&
options?.enrichRelationships
) {
const relationshipAdditions = await getRelationshipSchemaAdditions(schema)
schema = {
...schema,
@ -89,20 +86,26 @@ export const fetchDatasourceSchema = async (
* Fetches the definition of any kind of datasource.
* @param datasource the datasource to fetch the schema for
*/
export const fetchDatasourceDefinition = async datasource => {
export const fetchDatasourceDefinition = async <
TDatasource extends { type: DataFetchType }
>(
datasource: TDatasource
) => {
const instance = getDatasourceFetchInstance(datasource)
return await instance?.getDefinition(datasource)
return await instance?.getDefinition()
}
/**
* Fetches the schema of relationship fields for a SQL table schema
* @param schema the schema to enrich
*/
export const getRelationshipSchemaAdditions = async schema => {
export const getRelationshipSchemaAdditions = async (
schema: Record<string, any>
) => {
if (!schema) {
return null
}
let relationshipAdditions = {}
let relationshipAdditions: Record<string, any> = {}
for (let fieldKey of Object.keys(schema)) {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "link") {
@ -110,7 +113,10 @@ export const getRelationshipSchemaAdditions = async schema => {
type: "table",
tableId: fieldSchema?.tableId,
})
Object.keys(linkSchema || {}).forEach(linkKey => {
if (!linkSchema) {
continue
}
Object.keys(linkSchema).forEach(linkKey => {
relationshipAdditions[`${fieldKey}.${linkKey}`] = {
type: linkSchema[linkKey].type,
externalType: linkSchema[linkKey].externalType,

View File

@ -14,6 +14,7 @@
"../*",
"../../node_modules/@budibase/*"
],
"@/*": ["./src/*"],
"*": ["./src/*"]
}
}

View File

@ -67,6 +67,10 @@ export default defineConfig(({ mode }) => {
find: "constants",
replacement: path.resolve("./src/constants"),
},
{
find: "@/constants",
replacement: path.resolve("./src/constants"),
},
{
find: "sdk",
replacement: path.resolve("./src/sdk"),

View File

@ -100,6 +100,7 @@ export const buildAttachmentEndpoints = (
body: data,
json: false,
external: true,
parseResponse: response => response as any,
})
return { publicUrl }
},

View File

@ -46,6 +46,8 @@ import { buildLogsEndpoints } from "./logs"
import { buildMigrationEndpoints } from "./migrations"
import { buildRowActionEndpoints } from "./rowActions"
export type { APIClient } from "./types"
/**
* Random identifier to uniquely identify a session in a tab. This is
* used to determine the originator of calls to the API, which is in
@ -68,13 +70,13 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
): Promise<APIError> => {
// Try to read a message from the error
let message = response.statusText
let json: any = null
let json = null
try {
json = await response.json()
if (json?.message) {
message = json.message
} else if (json?.error) {
message = json.error
message = JSON.stringify(json.error)
}
} catch (error) {
// Do nothing
@ -93,7 +95,7 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
// Generates an error object from a string
const makeError = (
message: string,
url?: string,
url: string,
method?: HTTPMethod
): APIError => {
return {
@ -226,7 +228,7 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
return await handler(callConfig)
} catch (error) {
if (config?.onError) {
config.onError(error)
config.onError(error as APIError)
}
throw error
}
@ -239,13 +241,9 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
patch: requestApiCall(HTTPMethod.PATCH),
delete: requestApiCall(HTTPMethod.DELETE),
put: requestApiCall(HTTPMethod.PUT),
error: (message: string) => {
throw makeError(message)
},
invalidateCache: () => {
cache = {}
},
// Generic utility to extract the current app ID. Assumes that any client
// that exists in an app context will be attaching our app ID header.
getAppID: (): string => {

View File

@ -13,7 +13,7 @@ export interface SelfEndpoints {
generateAPIKey: () => Promise<string | undefined>
fetchDeveloperInfo: () => Promise<FetchAPIKeyResponse>
fetchBuilderSelf: () => Promise<GetGlobalSelfResponse>
fetchSelf: () => Promise<AppSelfResponse>
fetchSelf: () => Promise<AppSelfResponse | null>
}
export const buildSelfEndpoints = (API: BaseAPIClient): SelfEndpoints => ({

View File

@ -46,7 +46,7 @@ export type Headers = Record<string, string>
export type APIClientConfig = {
enableCaching?: boolean
attachHeaders?: (headers: Headers) => void
onError?: (error: any) => void
onError?: (error: APIError) => void
onMigrationDetected?: (migration: string) => void
}
@ -86,14 +86,13 @@ export type BaseAPIClient = {
patch: <RequestT = null, ResponseT = void>(
params: APICallParams<RequestT, ResponseT>
) => Promise<ResponseT>
error: (message: string) => void
invalidateCache: () => void
getAppID: () => string
}
export type APIError = {
message?: string
url?: string
url: string
method?: HTTPMethod
json: any
status: number

View File

@ -21,11 +21,12 @@ import {
SaveUserResponse,
SearchUsersRequest,
SearchUsersResponse,
UnsavedUser,
UpdateInviteRequest,
UpdateInviteResponse,
UpdateSelfMetadataRequest,
UpdateSelfMetadataResponse,
User,
UserIdentifier,
} from "@budibase/types"
import { BaseAPIClient } from "./types"
@ -38,14 +39,9 @@ export interface UserEndpoints {
createAdminUser: (
user: CreateAdminUserRequest
) => Promise<CreateAdminUserResponse>
saveUser: (user: User) => Promise<SaveUserResponse>
saveUser: (user: UnsavedUser) => Promise<SaveUserResponse>
deleteUser: (userId: string) => Promise<DeleteUserResponse>
deleteUsers: (
users: Array<{
userId: string
email: string
}>
) => Promise<BulkUserDeleted | undefined>
deleteUsers: (users: UserIdentifier[]) => Promise<BulkUserDeleted | undefined>
onboardUsers: (data: InviteUsersRequest) => Promise<InviteUsersResponse>
getUserInvite: (code: string) => Promise<CheckInviteResponse>
getUserInvites: () => Promise<GetUserInvitesResponse>
@ -60,7 +56,7 @@ export interface UserEndpoints {
getAccountHolder: () => Promise<LookupAccountHolderResponse>
searchUsers: (data: SearchUsersRequest) => Promise<SearchUsersResponse>
createUsers: (
users: User[],
users: UnsavedUser[],
groups: any[]
) => Promise<BulkUserCreated | undefined>
updateUserInvite: (

View File

@ -1,4 +1,4 @@
import { FieldType } from "@budibase/types"
import { FieldType, UIColumn } from "@budibase/types"
import OptionsCell from "../cells/OptionsCell.svelte"
import DateCell from "../cells/DateCell.svelte"
@ -40,13 +40,23 @@ const TypeComponentMap = {
// Custom types for UI only
role: RoleCell,
}
export const getCellRenderer = column => {
function getCellRendererByType(type: FieldType | "role" | undefined) {
if (!type) {
return
}
return TypeComponentMap[type as keyof typeof TypeComponentMap]
}
export const getCellRenderer = (column: UIColumn) => {
if (column.calculationType) {
return NumberCell
}
return (
TypeComponentMap[column?.schema?.cellRenderType] ||
TypeComponentMap[column?.schema?.type] ||
getCellRendererByType(column.schema?.cellRenderType) ||
getCellRendererByType(column.schema?.type) ||
TextCell
)
}

View File

@ -1,32 +0,0 @@
// TODO: remove when all stores are typed
import { GeneratedIDPrefix, CellIDSeparator } from "./constants"
import { Helpers } from "@budibase/bbui"
export const parseCellID = cellId => {
if (!cellId) {
return { rowId: undefined, field: undefined }
}
const parts = cellId.split(CellIDSeparator)
const field = parts.pop()
return { rowId: parts.join(CellIDSeparator), field }
}
export const getCellID = (rowId, fieldName) => {
return `${rowId}${CellIDSeparator}${fieldName}`
}
export const parseEventLocation = e => {
return {
x: e.clientX ?? e.touches?.[0]?.clientX,
y: e.clientY ?? e.touches?.[0]?.clientY,
}
}
export const generateRowID = () => {
return `${GeneratedIDPrefix}${Helpers.uuid()}`
}
export const isGeneratedRowID = id => {
return id?.startsWith(GeneratedIDPrefix)
}

View File

@ -1,12 +1,14 @@
import { get } from "svelte/store"
import { createWebsocket } from "../../../utils"
import { SocketEvent, GridSocketEvent } from "@budibase/shared-core"
import { Store } from "../stores"
import { UIDatasource, UIUser } from "@budibase/types"
export const createGridWebsocket = context => {
export const createGridWebsocket = (context: Store) => {
const { rows, datasource, users, focusedCellId, definition, API } = context
const socket = createWebsocket("/socket/grid")
const connectToDatasource = datasource => {
const connectToDatasource = (datasource: UIDatasource) => {
if (!socket.connected) {
return
}
@ -18,7 +20,7 @@ export const createGridWebsocket = context => {
datasource,
appId,
},
({ users: gridUsers }) => {
({ users: gridUsers }: { users: UIUser[] }) => {
users.set(gridUsers)
}
)
@ -65,7 +67,7 @@ export const createGridWebsocket = context => {
GridSocketEvent.DatasourceChange,
({ datasource: newDatasource }) => {
// Listen builder renames, as these aren't handled otherwise
if (newDatasource?.name !== get(definition).name) {
if (newDatasource?.name !== get(definition)?.name) {
definition.set(newDatasource)
}
}

View File

@ -1,6 +1,7 @@
import DataFetch from "./DataFetch"
interface CustomDatasource {
type: "custom"
data: any
}

View File

@ -13,6 +13,7 @@ import {
UISearchFilter,
} from "@budibase/types"
import { APIClient } from "../api/types"
import { DataFetchType } from "."
const { buildQuery, limit: queryLimit, runQuery, sort } = QueryUtils
@ -59,7 +60,7 @@ export interface DataFetchParams<
* For other types of datasource, this class is overridden and extended.
*/
export default abstract class DataFetch<
TDatasource extends {},
TDatasource extends { type: DataFetchType },
TDefinition extends {
schema?: Record<string, any> | null
primaryDisplay?: string
@ -179,9 +180,6 @@ export default abstract class DataFetch<
this.store.update($store => ({ ...$store, loaded: true }))
return
}
// Initially fetch data but don't bother waiting for the result
this.getInitialData()
}
/**
@ -371,7 +369,7 @@ export default abstract class DataFetch<
* @param schema the datasource schema
* @return {object} the enriched datasource schema
*/
private enrichSchema(schema: TableSchema): TableSchema {
enrichSchema(schema: TableSchema): TableSchema {
// Check for any JSON fields so we can add any top level properties
let jsonAdditions: Record<string, { type: string; nestedJSON: true }> = {}
for (const fieldKey of Object.keys(schema)) {

View File

@ -1,7 +1,10 @@
import { Row } from "@budibase/types"
import DataFetch from "./DataFetch"
export interface FieldDatasource {
type Types = "field" | "queryarray" | "jsonarray"
export interface FieldDatasource<TType extends Types> {
type: TType
tableId: string
fieldType: "attachment" | "array"
value: string[] | Row[]
@ -15,8 +18,8 @@ function isArrayOfStrings(value: string[] | Row[]): value is string[] {
return Array.isArray(value) && !!value[0] && typeof value[0] !== "object"
}
export default class FieldFetch extends DataFetch<
FieldDatasource,
export default class FieldFetch<TType extends Types> extends DataFetch<
FieldDatasource<TType>,
FieldDefinition
> {
async getDefinition(): Promise<FieldDefinition | null> {

View File

@ -8,6 +8,7 @@ interface GroupUserQuery {
}
interface GroupUserDatasource {
type: "groupUser"
tableId: TableNames.USERS
}
@ -20,6 +21,7 @@ export default class GroupUserFetch extends DataFetch<
super({
...opts,
datasource: {
type: "groupUser",
tableId: TableNames.USERS,
},
})

View File

@ -1,7 +1,7 @@
import FieldFetch from "./FieldFetch"
import { getJSONArrayDatasourceSchema } from "../utils/json"
export default class JSONArrayFetch extends FieldFetch {
export default class JSONArrayFetch extends FieldFetch<"jsonarray"> {
async getDefinition() {
const { datasource } = this.options

View File

@ -2,6 +2,7 @@ import { Row, TableSchema } from "@budibase/types"
import DataFetch from "./DataFetch"
interface NestedProviderDatasource {
type: "provider"
value?: {
schema: TableSchema
primaryDisplay: string

View File

@ -4,7 +4,7 @@ import {
generateQueryArraySchemas,
} from "../utils/json"
export default class QueryArrayFetch extends FieldFetch {
export default class QueryArrayFetch extends FieldFetch<"queryarray"> {
async getDefinition() {
const { datasource } = this.options

View File

@ -4,6 +4,7 @@ import { ExecuteQueryRequest, Query } from "@budibase/types"
import { get } from "svelte/store"
interface QueryDatasource {
type: "query"
_id: string
fields: Record<string, any> & {
pagination?: {

View File

@ -2,6 +2,7 @@ import { Table } from "@budibase/types"
import DataFetch from "./DataFetch"
interface RelationshipDatasource {
type: "link"
tableId: string
rowId: string
rowTableId: string

View File

@ -1,8 +1,13 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch"
import { SortOrder, Table, UITable } from "@budibase/types"
import { SortOrder, Table } from "@budibase/types"
export default class TableFetch extends DataFetch<UITable, Table> {
interface TableDatasource {
type: "table"
tableId: string
}
export default class TableFetch extends DataFetch<TableDatasource, Table> {
async determineFeatureFlags() {
return {
supportsSearch: true,

View File

@ -2,11 +2,7 @@ import { get } from "svelte/store"
import DataFetch, { DataFetchParams } from "./DataFetch"
import { TableNames } from "../constants"
import { utils } from "@budibase/shared-core"
import {
BasicOperator,
SearchFilters,
SearchUsersRequest,
} from "@budibase/types"
import { SearchFilters, SearchUsersRequest } from "@budibase/types"
interface UserFetchQuery {
appId: string
@ -14,18 +10,22 @@ interface UserFetchQuery {
}
interface UserDatasource {
tableId: string
type: "user"
tableId: TableNames.USERS
}
interface UserDefinition {}
export default class UserFetch extends DataFetch<
UserDatasource,
{},
UserDefinition,
UserFetchQuery
> {
constructor(opts: DataFetchParams<UserDatasource, UserFetchQuery>) {
super({
...opts,
datasource: {
type: "user",
tableId: TableNames.USERS,
},
})
@ -52,7 +52,7 @@ export default class UserFetch extends DataFetch<
const finalQuery: SearchFilters = utils.isSupportedUserSearch(rest)
? rest
: { [BasicOperator.EMPTY]: { email: null } }
: {}
try {
const opts: SearchUsersRequest = {

View File

@ -1,9 +1,16 @@
import { Table, View } from "@budibase/types"
import { Table } from "@budibase/types"
import DataFetch from "./DataFetch"
type ViewV1 = View & { name: string }
type ViewV1Datasource = {
type: "view"
name: string
tableId: string
calculation: string
field: string
groupBy: string
}
export default class ViewFetch extends DataFetch<ViewV1, Table> {
export default class ViewFetch extends DataFetch<ViewV1Datasource, Table> {
async getDefinition() {
const { datasource } = this.options

View File

@ -1,9 +1,17 @@
import { SortOrder, UIView, ViewV2, ViewV2Type } from "@budibase/types"
import { SortOrder, ViewV2Enriched, ViewV2Type } from "@budibase/types"
import DataFetch from "./DataFetch"
import { get } from "svelte/store"
import { helpers } from "@budibase/shared-core"
export default class ViewV2Fetch extends DataFetch<UIView, ViewV2> {
interface ViewDatasource {
type: "viewV2"
id: string
}
export default class ViewV2Fetch extends DataFetch<
ViewDatasource,
ViewV2Enriched
> {
async determineFeatureFlags() {
return {
supportsSearch: true,

View File

@ -1,18 +1,20 @@
import TableFetch from "./TableFetch.js"
import ViewFetch from "./ViewFetch.js"
import ViewV2Fetch from "./ViewV2Fetch.js"
import TableFetch from "./TableFetch"
import ViewFetch from "./ViewFetch"
import ViewV2Fetch from "./ViewV2Fetch"
import QueryFetch from "./QueryFetch"
import RelationshipFetch from "./RelationshipFetch"
import NestedProviderFetch from "./NestedProviderFetch"
import FieldFetch from "./FieldFetch"
import JSONArrayFetch from "./JSONArrayFetch"
import UserFetch from "./UserFetch.js"
import UserFetch from "./UserFetch"
import GroupUserFetch from "./GroupUserFetch"
import CustomFetch from "./CustomFetch"
import QueryArrayFetch from "./QueryArrayFetch.js"
import { APIClient } from "../api/types.js"
import QueryArrayFetch from "./QueryArrayFetch"
import { APIClient } from "../api/types"
const DataFetchMap = {
export type DataFetchType = keyof typeof DataFetchMap
export const DataFetchMap = {
table: TableFetch,
view: ViewFetch,
viewV2: ViewV2Fetch,
@ -24,43 +26,45 @@ const DataFetchMap = {
// Client specific datasource types
provider: NestedProviderFetch,
field: FieldFetch,
field: FieldFetch<"field">,
jsonarray: JSONArrayFetch,
queryarray: QueryArrayFetch,
}
// Constructs a new fetch model for a certain datasource
export const fetchData = ({ API, datasource, options }: any) => {
const Fetch =
DataFetchMap[datasource?.type as keyof typeof DataFetchMap] || TableFetch
return new Fetch({ API, datasource, ...options })
const Fetch = DataFetchMap[datasource?.type as DataFetchType] || TableFetch
const fetch = new Fetch({ API, datasource, ...options })
// Initially fetch data but don't bother waiting for the result
fetch.getInitialData()
return fetch
}
// Creates an empty fetch instance with no datasource configured, so no data
// will initially be loaded
const createEmptyFetchInstance = <
TDatasource extends {
type: keyof typeof DataFetchMap
}
>({
const createEmptyFetchInstance = <TDatasource extends { type: DataFetchType }>({
API,
datasource,
}: {
API: APIClient
datasource: TDatasource
}) => {
const handler = DataFetchMap[datasource?.type as keyof typeof DataFetchMap]
const handler = DataFetchMap[datasource?.type as DataFetchType]
if (!handler) {
return null
}
return new handler({ API, datasource: null as any, query: null as any })
return new handler({
API,
datasource: null as never,
query: null as any,
})
}
// Fetches the definition of any type of datasource
export const getDatasourceDefinition = async <
TDatasource extends {
type: keyof typeof DataFetchMap
}
TDatasource extends { type: DataFetchType }
>({
API,
datasource,
@ -74,9 +78,7 @@ export const getDatasourceDefinition = async <
// Fetches the schema of any type of datasource
export const getDatasourceSchema = <
TDatasource extends {
type: keyof typeof DataFetchMap
}
TDatasource extends { type: DataFetchType }
>({
API,
datasource,

View File

@ -1,5 +1,7 @@
export { createAPIClient } from "./api"
export { fetchData } from "./fetch"
export type { APIClient } from "./api"
export { fetchData, DataFetchMap } from "./fetch"
export type { DataFetchType } from "./fetch"
export * as Constants from "./constants"
export * from "./stores"
export * from "./utils"

View File

@ -209,6 +209,9 @@ export const buildFormBlockButtonConfig = props => {
{
"##eventHandlerType": "Close Side Panel",
},
{
"##eventHandlerType": "Close Modal",
},
...(actionUrl
? [

@ -1 +1 @@
Subproject commit 32d84f109d4edc526145472a7446327312151442
Subproject commit a4f63b22675e16dcdcaa4d9e83b298eee6466a07

View File

@ -45,6 +45,9 @@ export async function handleRequest<T extends Operation>(
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const source = await utils.getSource(ctx)
const { viewId, tableId } = utils.getSourceId(ctx)
const sourceId = viewId || tableId
if (sdk.views.isView(source) && helpers.views.isCalculationView(source)) {
ctx.throw(400, "Cannot update rows through a calculation view")
}
@ -86,7 +89,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
// The id might have been changed, so the refetching would fail. Recalculating the id just in case
const updatedId =
generateIdForRow({ ...beforeRow, ...dataToUpdate }, table) || _id
const row = await sdk.rows.external.getRow(table._id!, updatedId, {
const row = await sdk.rows.external.getRow(sourceId, updatedId, {
relationships: true,
})

View File

@ -4,15 +4,8 @@ import {
processAIColumns,
processFormulas,
} from "../../../utilities/rowProcessor"
import { context, features } from "@budibase/backend-core"
import {
Table,
Row,
FeatureFlag,
FormulaType,
FieldType,
ViewV2,
} from "@budibase/types"
import { context } from "@budibase/backend-core"
import { Table, Row, FormulaType, FieldType, ViewV2 } from "@budibase/types"
import * as linkRows from "../../../db/linkedRows"
import isEqual from "lodash/isEqual"
import { cloneDeep, merge } from "lodash/fp"
@ -162,11 +155,10 @@ export async function finaliseRow(
dynamic: false,
contextRows: [enrichedRow],
})
const aiEnabled =
((await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled())) ||
((await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled()))
(await pro.features.isBudibaseAIEnabled()) ||
(await pro.features.isAICustomConfigsEnabled())
if (aiEnabled) {
row = await processAIColumns(table, row, {
contextRows: [enrichedRow],
@ -184,11 +176,6 @@ export async function finaliseRow(
enrichedRow = await processFormulas(table, enrichedRow, {
dynamic: false,
})
if (aiEnabled) {
enrichedRow = await processAIColumns(table, enrichedRow, {
contextRows: [enrichedRow],
})
}
// this updates the related formulas in other rows based on the relations to this row
if (updateFormula) {

View File

@ -14,7 +14,8 @@ import {
import { breakExternalTableId } from "../../../../integrations/utils"
import { generateJunctionTableID } from "../../../../db/utils"
import sdk from "../../../../sdk"
import { helpers } from "@budibase/shared-core"
import { helpers, PROTECTED_INTERNAL_COLUMNS } from "@budibase/shared-core"
import { sql } from "@budibase/backend-core"
type TableMap = Record<string, Table>
@ -118,45 +119,131 @@ export async function buildSqlFieldList(
opts?: { relationships: boolean }
) {
const { relationships } = opts || {}
const nonMappedColumns = [FieldType.LINK, FieldType.FORMULA, FieldType.AI]
function extractRealFields(table: Table, existing: string[] = []) {
return Object.entries(table.schema)
.filter(
([columnName, column]) =>
column.type !== FieldType.LINK &&
column.type !== FieldType.FORMULA &&
column.type !== FieldType.AI &&
!existing.find(
(field: string) => field === `${table.name}.${columnName}`
)
!nonMappedColumns.includes(column.type) &&
!existing.find((field: string) => field === columnName)
)
.map(([columnName]) => `${table.name}.${columnName}`)
.map(([columnName]) => columnName)
}
function getRequiredFields(table: Table, existing: string[] = []) {
const requiredFields: string[] = []
if (table.primary) {
requiredFields.push(...table.primary)
}
if (table.primaryDisplay) {
requiredFields.push(table.primaryDisplay)
}
if (!sql.utils.isExternalTable(table)) {
requiredFields.push(...PROTECTED_INTERNAL_COLUMNS)
}
return requiredFields.filter(
column =>
!existing.find((field: string) => field === column) &&
table.schema[column] &&
!nonMappedColumns.includes(table.schema[column].type)
)
}
let fields: string[] = []
if (sdk.views.isView(source)) {
fields = Object.keys(helpers.views.basicFields(source))
} else {
fields = extractRealFields(source)
}
const isView = sdk.views.isView(source)
let table: Table
if (sdk.views.isView(source)) {
if (isView) {
table = await sdk.views.getTable(source.id)
fields = Object.keys(helpers.views.basicFields(source)).filter(
f => table.schema[f].type !== FieldType.LINK
)
} else {
table = source
fields = extractRealFields(source).filter(
f => table.schema[f].visible !== false
)
}
for (let field of Object.values(table.schema)) {
const containsFormula = (isView ? fields : Object.keys(table.schema)).some(
f => table.schema[f]?.type === FieldType.FORMULA
)
// If are requesting for a formula field, we need to retrieve all fields
if (containsFormula) {
fields = extractRealFields(table)
}
if (!isView || !helpers.views.isCalculationView(source)) {
fields.push(
...getRequiredFields(
{
...table,
primaryDisplay: source.primaryDisplay || table.primaryDisplay,
},
fields
)
)
}
fields = fields.map(c => `${table.name}.${c}`)
for (const field of Object.values(table.schema)) {
if (field.type !== FieldType.LINK || !relationships || !field.tableId) {
continue
}
const { tableName } = breakExternalTableId(field.tableId)
if (tables[tableName]) {
fields = fields.concat(extractRealFields(tables[tableName], fields))
if (
isView &&
(!source.schema?.[field.name] ||
!helpers.views.isVisible(source.schema[field.name])) &&
!containsFormula
) {
continue
}
const { tableName } = breakExternalTableId(field.tableId)
const relatedTable = tables[tableName]
if (!relatedTable) {
continue
}
const viewFields = new Set<string>()
if (containsFormula) {
extractRealFields(relatedTable).forEach(f => viewFields.add(f))
} else {
relatedTable.primary?.forEach(f => viewFields.add(f))
if (relatedTable.primaryDisplay) {
viewFields.add(relatedTable.primaryDisplay)
}
if (isView) {
Object.entries(source.schema?.[field.name]?.columns || {})
.filter(
([columnName, columnConfig]) =>
relatedTable.schema[columnName] &&
helpers.views.isVisible(columnConfig) &&
![FieldType.LINK, FieldType.FORMULA].includes(
relatedTable.schema[columnName].type
)
)
.forEach(([field]) => viewFields.add(field))
}
}
const fieldsToAdd = Array.from(viewFields)
.filter(f => !nonMappedColumns.includes(relatedTable.schema[f].type))
.map(f => `${relatedTable.name}.${f}`)
.filter(f => !fields.includes(f))
fields.push(...fieldsToAdd)
}
return fields
return [...new Set(fields)]
}
export function isKnexEmptyReadResponse(resp: DatasourcePlusQueryResponse) {

View File

@ -0,0 +1,511 @@
import {
AIOperationEnum,
CalculationType,
FieldType,
RelationshipType,
SourceName,
Table,
ViewV2,
ViewV2Type,
} from "@budibase/types"
import { buildSqlFieldList } from "../sqlUtils"
import { structures } from "../../../../routes/tests/utilities"
import { sql } from "@budibase/backend-core"
import { generator } from "@budibase/backend-core/tests"
import { generateViewID } from "../../../../../db/utils"
import sdk from "../../../../../sdk"
import { cloneDeep } from "lodash"
import { utils } from "@budibase/shared-core"
jest.mock("../../../../../sdk/app/views", () => ({
...jest.requireActual("../../../../../sdk/app/views"),
getTable: jest.fn(),
}))
const getTableMock = sdk.views.getTable as jest.MockedFunction<
typeof sdk.views.getTable
>
describe("buildSqlFieldList", () => {
let allTables: Record<string, Table>
class TableConfig {
private _table: Table & { _id: string }
constructor(name: string) {
this._table = {
...structures.tableForDatasource({
type: "datasource",
source: SourceName.POSTGRES,
}),
name,
_id: sql.utils.buildExternalTableId("ds_id", name),
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
description: {
name: "description",
type: FieldType.STRING,
},
amount: {
name: "amount",
type: FieldType.NUMBER,
},
},
}
allTables[name] = this._table
}
withHiddenField(field: string) {
this._table.schema[field].visible = false
return this
}
withField(
name: string,
type:
| FieldType.STRING
| FieldType.NUMBER
| FieldType.FORMULA
| FieldType.AI,
options?: { visible: boolean }
) {
switch (type) {
case FieldType.NUMBER:
case FieldType.STRING:
this._table.schema[name] = {
name,
type,
...options,
}
break
case FieldType.FORMULA:
this._table.schema[name] = {
name,
type,
formula: "any",
...options,
}
break
case FieldType.AI:
this._table.schema[name] = {
name,
type,
operation: AIOperationEnum.PROMPT,
...options,
}
break
default:
utils.unreachable(type)
}
return this
}
withRelation(name: string, toTableId: string) {
this._table.schema[name] = {
name,
type: FieldType.LINK,
relationshipType: RelationshipType.ONE_TO_MANY,
fieldName: "link",
tableId: toTableId,
}
return this
}
withPrimary(field: string) {
this._table.primary = [field]
return this
}
withDisplay(field: string) {
this._table.primaryDisplay = field
return this
}
create() {
return cloneDeep(this._table)
}
}
class ViewConfig {
private _table: Table
private _view: ViewV2
constructor(table: Table) {
this._table = table
this._view = {
version: 2,
id: generateViewID(table._id!),
name: generator.word(),
tableId: table._id!,
}
}
withVisible(field: string) {
this._view.schema ??= {}
this._view.schema[field] ??= {}
this._view.schema[field].visible = true
return this
}
withHidden(field: string) {
this._view.schema ??= {}
this._view.schema[field] ??= {}
this._view.schema[field].visible = false
return this
}
withRelationshipColumns(
field: string,
columns: Record<string, { visible: boolean }>
) {
this._view.schema ??= {}
this._view.schema[field] ??= {}
this._view.schema[field].columns = columns
return this
}
withCalculation(
name: string,
field: string,
calculationType: CalculationType
) {
this._view.type = ViewV2Type.CALCULATION
this._view.schema ??= {}
this._view.schema[name] = {
field,
calculationType,
visible: true,
}
return this
}
create() {
getTableMock.mockResolvedValueOnce(this._table)
return cloneDeep(this._view)
}
}
beforeEach(() => {
jest.clearAllMocks()
allTables = {}
})
describe("table", () => {
it("extracts fields from table schema", async () => {
const table = new TableConfig("table").create()
const result = await buildSqlFieldList(table, {})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
])
})
it("excludes hidden fields", async () => {
const table = new TableConfig("table")
.withHiddenField("description")
.create()
const result = await buildSqlFieldList(table, {})
expect(result).toEqual(["table.name", "table.amount"])
})
it("excludes non-sql fields fields", async () => {
const table = new TableConfig("table")
.withField("formula", FieldType.FORMULA)
.withField("ai", FieldType.AI)
.withRelation("link", "otherTableId")
.create()
const result = await buildSqlFieldList(table, {})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
])
})
it("includes hidden fields if there is a formula column", async () => {
const table = new TableConfig("table")
.withHiddenField("description")
.withField("formula", FieldType.FORMULA)
.create()
const result = await buildSqlFieldList(table, {})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
])
})
it("includes relationships fields when flagged", async () => {
const otherTable = new TableConfig("linkedTable")
.withField("id", FieldType.NUMBER)
.withPrimary("id")
.withDisplay("name")
.create()
const table = new TableConfig("table")
.withRelation("link", otherTable._id)
.create()
const result = await buildSqlFieldList(table, allTables, {
relationships: true,
})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
"linkedTable.id",
"linkedTable.name",
])
})
it("includes all relationship fields if there is a formula column", async () => {
const otherTable = new TableConfig("linkedTable")
.withField("hidden", FieldType.STRING, { visible: false })
.create()
const table = new TableConfig("table")
.withRelation("link", otherTable._id)
.withField("formula", FieldType.FORMULA)
.create()
const result = await buildSqlFieldList(table, allTables, {
relationships: true,
})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
"linkedTable.name",
"linkedTable.description",
"linkedTable.amount",
"linkedTable.hidden",
])
})
it("never includes non-sql columns from relationships", async () => {
const otherTable = new TableConfig("linkedTable")
.withField("id", FieldType.NUMBER)
.withField("hidden", FieldType.STRING, { visible: false })
.withField("formula", FieldType.FORMULA)
.withField("ai", FieldType.AI)
.withRelation("link", "otherTableId")
.create()
const table = new TableConfig("table")
.withRelation("link", otherTable._id)
.withField("formula", FieldType.FORMULA)
.create()
const result = await buildSqlFieldList(table, allTables, {
relationships: true,
})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
"linkedTable.name",
"linkedTable.description",
"linkedTable.amount",
"linkedTable.id",
"linkedTable.hidden",
])
})
})
describe("view", () => {
it("extracts fields from table schema", async () => {
const view = new ViewConfig(new TableConfig("table").create())
.withVisible("amount")
.withHidden("name")
.create()
const result = await buildSqlFieldList(view, {})
expect(result).toEqual(["table.amount"])
})
it("includes all fields if there is a formula column", async () => {
const table = new TableConfig("table")
.withField("formula", FieldType.FORMULA)
.create()
const view = new ViewConfig(table)
.withHidden("name")
.withVisible("amount")
.withVisible("formula")
.create()
const result = await buildSqlFieldList(view, {})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
])
})
it("does not includes all fields if the formula column is not included", async () => {
const table = new TableConfig("table")
.withField("formula", FieldType.FORMULA)
.create()
const view = new ViewConfig(table)
.withHidden("name")
.withVisible("amount")
.withHidden("formula")
.create()
const result = await buildSqlFieldList(view, {})
expect(result).toEqual(["table.amount"])
})
it("includes relationships columns", async () => {
const otherTable = new TableConfig("linkedTable")
.withField("id", FieldType.NUMBER)
.withField("formula", FieldType.FORMULA)
.withPrimary("id")
.create()
const table = new TableConfig("table")
.withRelation("link", otherTable._id)
.create()
const view = new ViewConfig(table)
.withVisible("name")
.withVisible("link")
.withRelationshipColumns("link", {
name: { visible: false },
amount: { visible: true },
formula: { visible: false },
})
.create()
const result = await buildSqlFieldList(view, allTables, {
relationships: true,
})
expect(result).toEqual([
"table.name",
"linkedTable.id",
"linkedTable.amount",
])
})
it("excludes relationships fields when view is not included in the view", async () => {
const otherTable = new TableConfig("linkedTable")
.withField("id", FieldType.NUMBER)
.withPrimary("id")
.withDisplay("name")
.create()
const table = new TableConfig("table")
.withRelation("link", otherTable._id)
.withField("formula", FieldType.FORMULA)
.create()
const view = new ViewConfig(table)
.withVisible("name")
.withHidden("amount")
.create()
const result = await buildSqlFieldList(view, allTables, {
relationships: true,
})
expect(result).toEqual(["table.name"])
})
it("does not include relationships columns for hidden links", async () => {
const otherTable = new TableConfig("linkedTable")
.withField("id", FieldType.NUMBER)
.withField("formula", FieldType.FORMULA)
.withPrimary("id")
.create()
const table = new TableConfig("table")
.withRelation("link", otherTable._id)
.create()
const view = new ViewConfig(table)
.withVisible("name")
.withHidden("link")
.withRelationshipColumns("link", {
name: { visible: false },
amount: { visible: true },
formula: { visible: false },
})
.create()
const result = await buildSqlFieldList(view, allTables, {
relationships: true,
})
expect(result).toEqual(["table.name"])
})
it("includes all relationship fields if there is a formula column", async () => {
const otherTable = new TableConfig("linkedTable")
.withField("id", FieldType.NUMBER)
.withField("hidden", FieldType.STRING, { visible: false })
.withField("formula", FieldType.FORMULA)
.withField("ai", FieldType.AI)
.withRelation("link", "otherTableId")
.withPrimary("id")
.create()
const table = new TableConfig("table")
.withRelation("link", otherTable._id)
.withField("formula", FieldType.FORMULA)
.create()
const view = new ViewConfig(table)
.withVisible("name")
.withVisible("formula")
.withHidden("link")
.withRelationshipColumns("link", {
name: { visible: false },
amount: { visible: true },
formula: { visible: false },
})
.create()
const result = await buildSqlFieldList(view, allTables, {
relationships: true,
})
expect(result).toEqual([
"table.name",
"table.description",
"table.amount",
"linkedTable.name",
"linkedTable.description",
"linkedTable.amount",
"linkedTable.id",
"linkedTable.hidden",
])
})
})
describe("calculation view", () => {
it("does not include calculation fields", async () => {
const view = new ViewConfig(new TableConfig("table").create())
.withCalculation("average", "amount", CalculationType.AVG)
.create()
const result = await buildSqlFieldList(view, {})
expect(result).toEqual([])
})
it("includes visible fields calculation fields", async () => {
const view = new ViewConfig(new TableConfig("table").create())
.withCalculation("average", "amount", CalculationType.AVG)
.withHidden("name")
.withVisible("amount")
.create()
const result = await buildSqlFieldList(view, {})
expect(result).toEqual(["table.amount"])
})
})
})

View File

@ -8,7 +8,13 @@ import {
import tk from "timekeeper"
import emitter from "../../../../src/events"
import { outputProcessing } from "../../../utilities/rowProcessor"
import { context, InternalTable, tenancy, utils } from "@budibase/backend-core"
import {
context,
setEnv,
InternalTable,
tenancy,
utils,
} from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
import {
AIOperationEnum,
@ -42,19 +48,8 @@ import { InternalTables } from "../../../db/utils"
import { withEnv } from "../../../environment"
import { JsTimeoutError } from "@budibase/string-templates"
import { isDate } from "../../../utilities"
jest.mock("@budibase/pro", () => ({
...jest.requireActual("@budibase/pro"),
ai: {
LargeLanguageModel: {
forCurrentTenant: async () => ({
llm: {},
run: jest.fn(() => `Mock LLM Response`),
buildPromptFromAIOperation: jest.fn(),
}),
},
},
}))
import nock from "nock"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai"
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
tk.freeze(timestamp)
@ -99,6 +94,8 @@ if (descriptions.length) {
const ds = await dsProvider()
datasource = ds.datasource
client = ds.client
mocks.licenses.useCloudFree()
})
afterAll(async () => {
@ -172,10 +169,6 @@ if (descriptions.length) {
)
}
beforeEach(async () => {
mocks.licenses.useCloudFree()
})
const getRowUsage = async () => {
const { total } = await config.doInContext(undefined, () =>
quotas.getCurrentUsageValues(
@ -2348,7 +2341,7 @@ if (descriptions.length) {
[FieldType.ARRAY]: ["options 2", "options 4"],
[FieldType.NUMBER]: generator.natural(),
[FieldType.BOOLEAN]: generator.bool(),
[FieldType.DATETIME]: generator.date().toISOString(),
[FieldType.DATETIME]: generator.date().toISOString().slice(0, 10),
[FieldType.ATTACHMENTS]: [setup.structures.basicAttachment()],
[FieldType.ATTACHMENT_SINGLE]: setup.structures.basicAttachment(),
[FieldType.FORMULA]: undefined, // generated field
@ -3224,10 +3217,17 @@ if (descriptions.length) {
isInternal &&
describe("AI fields", () => {
let table: Table
let envCleanup: () => void
beforeAll(async () => {
mocks.licenses.useBudibaseAI()
mocks.licenses.useAICustomConfigs()
envCleanup = setEnv({
OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd",
})
mockChatGPTResponse("Mock LLM Response")
table = await config.api.table.save(
saveTableRequest({
schema: {
@ -3251,7 +3251,9 @@ if (descriptions.length) {
})
afterAll(() => {
jest.unmock("@budibase/pro")
nock.cleanAll()
envCleanup()
mocks.licenses.useCloudFree()
})
it("should be able to save a row with an AI column", async () => {

View File

@ -1683,6 +1683,151 @@ if (descriptions.length) {
})
})
describe("datetime - date only", () => {
describe.each([true, false])(
"saved with timestamp: %s",
saveWithTimestamp => {
describe.each([true, false])(
"search with timestamp: %s",
searchWithTimestamp => {
const SAVE_SUFFIX = saveWithTimestamp
? "T00:00:00.000Z"
: ""
const SEARCH_SUFFIX = searchWithTimestamp
? "T00:00:00.000Z"
: ""
const JAN_1ST = `2020-01-01`
const JAN_10TH = `2020-01-10`
const JAN_30TH = `2020-01-30`
const UNEXISTING_DATE = `2020-01-03`
const NULL_DATE__ID = `null_date__id`
beforeAll(async () => {
tableOrViewId = await createTableOrView({
dateid: { name: "dateid", type: FieldType.STRING },
date: {
name: "date",
type: FieldType.DATETIME,
dateOnly: true,
},
})
await createRows([
{ dateid: NULL_DATE__ID, date: null },
{ date: `${JAN_1ST}${SAVE_SUFFIX}` },
{ date: `${JAN_10TH}${SAVE_SUFFIX}` },
])
})
describe("equal", () => {
it("successfully finds a row", async () => {
await expectQuery({
equal: { date: `${JAN_1ST}${SEARCH_SUFFIX}` },
}).toContainExactly([{ date: JAN_1ST }])
})
it("successfully finds an ISO8601 row", async () => {
await expectQuery({
equal: { date: `${JAN_10TH}${SEARCH_SUFFIX}` },
}).toContainExactly([{ date: JAN_10TH }])
})
it("finds a row with ISO8601 timestamp", async () => {
await expectQuery({
equal: { date: `${JAN_1ST}${SEARCH_SUFFIX}` },
}).toContainExactly([{ date: JAN_1ST }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
equal: {
date: `${UNEXISTING_DATE}${SEARCH_SUFFIX}`,
},
}).toFindNothing()
})
})
describe("notEqual", () => {
it("successfully finds a row", async () => {
await expectQuery({
notEqual: { date: `${JAN_1ST}${SEARCH_SUFFIX}` },
}).toContainExactly([
{ date: JAN_10TH },
{ dateid: NULL_DATE__ID },
])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
notEqual: { date: `${JAN_30TH}${SEARCH_SUFFIX}` },
}).toContainExactly([
{ date: JAN_1ST },
{ date: JAN_10TH },
{ dateid: NULL_DATE__ID },
])
})
})
describe("oneOf", () => {
it("successfully finds a row", async () => {
await expectQuery({
oneOf: { date: [`${JAN_1ST}${SEARCH_SUFFIX}`] },
}).toContainExactly([{ date: JAN_1ST }])
})
it("fails to find nonexistent row", async () => {
await expectQuery({
oneOf: {
date: [`${UNEXISTING_DATE}${SEARCH_SUFFIX}`],
},
}).toFindNothing()
})
})
describe("range", () => {
it("successfully finds a row", async () => {
await expectQuery({
range: {
date: {
low: `${JAN_1ST}${SEARCH_SUFFIX}`,
high: `${JAN_1ST}${SEARCH_SUFFIX}`,
},
},
}).toContainExactly([{ date: JAN_1ST }])
})
it("successfully finds multiple rows", async () => {
await expectQuery({
range: {
date: {
low: `${JAN_1ST}${SEARCH_SUFFIX}`,
high: `${JAN_10TH}${SEARCH_SUFFIX}`,
},
},
}).toContainExactly([
{ date: JAN_1ST },
{ date: JAN_10TH },
])
})
it("successfully finds no rows", async () => {
await expectQuery({
range: {
date: {
low: `${JAN_30TH}${SEARCH_SUFFIX}`,
high: `${JAN_30TH}${SEARCH_SUFFIX}`,
},
},
}).toFindNothing()
})
})
}
)
}
)
})
isInternal &&
!isInMemory &&
describe("AI Column", () => {

View File

@ -1,4 +1,5 @@
import {
AIOperationEnum,
ArrayOperator,
BasicOperator,
BBReferenceFieldSubType,
@ -42,7 +43,9 @@ import {
} from "../../../integrations/tests/utils"
import merge from "lodash/merge"
import { quotas } from "@budibase/pro"
import { context, db, events, roles } from "@budibase/backend-core"
import { context, db, events, roles, setEnv } from "@budibase/backend-core"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai"
import nock from "nock"
const descriptions = datasourceDescribe({ exclude: [DatabaseName.MONGODB] })
@ -100,6 +103,7 @@ if (descriptions.length) {
beforeAll(async () => {
await config.init()
mocks.licenses.useCloudFree()
const ds = await dsProvider()
rawDatasource = ds.rawDatasource
@ -109,7 +113,6 @@ if (descriptions.length) {
beforeEach(() => {
jest.clearAllMocks()
mocks.licenses.useCloudFree()
})
describe("view crud", () => {
@ -507,7 +510,6 @@ if (descriptions.length) {
})
it("readonly fields can be used on free license", async () => {
mocks.licenses.useCloudFree()
const table = await config.api.table.save(
saveTableRequest({
schema: {
@ -933,6 +935,95 @@ if (descriptions.length) {
}
)
})
isInternal &&
describe("AI fields", () => {
let envCleanup: () => void
beforeAll(() => {
mocks.licenses.useBudibaseAI()
mocks.licenses.useAICustomConfigs()
envCleanup = setEnv({
OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd",
})
mockChatGPTResponse(prompt => {
if (prompt.includes("elephant")) {
return "big"
}
if (prompt.includes("mouse")) {
return "small"
}
if (prompt.includes("whale")) {
return "big"
}
return "unknown"
})
})
afterAll(() => {
nock.cleanAll()
envCleanup()
mocks.licenses.useCloudFree()
})
it("can use AI fields in view calculations", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
animal: {
name: "animal",
type: FieldType.STRING,
},
bigOrSmall: {
name: "bigOrSmall",
type: FieldType.AI,
operation: AIOperationEnum.CATEGORISE_TEXT,
categories: "big,small",
columns: ["animal"],
},
},
})
)
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
bigOrSmall: {
visible: true,
},
count: {
visible: true,
calculationType: CalculationType.COUNT,
field: "animal",
},
},
})
await config.api.row.save(table._id!, {
animal: "elephant",
})
await config.api.row.save(table._id!, {
animal: "mouse",
})
await config.api.row.save(table._id!, {
animal: "whale",
})
const { rows } = await config.api.row.search(view.id, {
sort: "bigOrSmall",
sortOrder: SortOrder.ASCENDING,
})
expect(rows).toHaveLength(2)
expect(rows[0].bigOrSmall).toEqual("big")
expect(rows[1].bigOrSmall).toEqual("small")
expect(rows[0].count).toEqual(2)
expect(rows[1].count).toEqual(1)
})
})
})
describe("update", () => {
@ -1836,7 +1927,6 @@ if (descriptions.length) {
},
})
mocks.licenses.useCloudFree()
const view = await getDelegate(res)
expect(view.schema?.one).toEqual(
expect.objectContaining({ visible: true, readonly: true })

View File

@ -27,11 +27,9 @@ import {
Hosting,
ActionImplementation,
AutomationStepDefinition,
FeatureFlag,
} from "@budibase/types"
import sdk from "../sdk"
import { getAutomationPlugin } from "../utilities/fileSystem"
import { features } from "@budibase/backend-core"
type ActionImplType = ActionImplementations<
typeof env.SELF_HOSTED extends "true" ? Hosting.SELF : Hosting.CLOUD
@ -78,6 +76,7 @@ export const BUILTIN_ACTION_DEFINITIONS: Record<
LOOP: loop.definition,
COLLECT: collect.definition,
TRIGGER_AUTOMATION_RUN: triggerAutomationRun.definition,
BRANCH: branch.definition,
// these used to be lowercase step IDs, maintain for backwards compat
discord: discord.definition,
slack: slack.definition,
@ -105,14 +104,7 @@ if (env.SELF_HOSTED) {
export async function getActionDefinitions(): Promise<
Record<keyof typeof AutomationActionStepId, AutomationStepDefinition>
> {
if (await features.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
}
if (
env.SELF_HOSTED ||
(await features.isEnabled(FeatureFlag.BUDIBASE_AI)) ||
(await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS))
) {
if (env.SELF_HOSTED) {
BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition
}

View File

@ -97,7 +97,7 @@ export async function run({
const ctx: any = buildCtx(appId, emitter, {
body: inputs.row,
params: {
tableId: inputs.row.tableId,
tableId: decodeURIComponent(inputs.row.tableId),
},
})
try {

View File

@ -85,7 +85,7 @@ export async function run({
_rev: inputs.revision,
},
params: {
tableId: inputs.tableId,
tableId: decodeURIComponent(inputs.tableId),
},
})

View File

@ -7,9 +7,8 @@ import {
AutomationIOType,
OpenAIStepInputs,
OpenAIStepOutputs,
FeatureFlag,
} from "@budibase/types"
import { env, features } from "@budibase/backend-core"
import { env } from "@budibase/backend-core"
import * as automationUtils from "../automationUtils"
import * as pro from "@budibase/pro"
@ -99,12 +98,8 @@ export async function run({
try {
let response
const customConfigsEnabled =
(await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled())
const budibaseAIEnabled =
(await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled())
const customConfigsEnabled = await pro.features.isAICustomConfigsEnabled()
const budibaseAIEnabled = await pro.features.isBudibaseAIEnabled()
let llmWrapper
if (budibaseAIEnabled || customConfigsEnabled) {

View File

@ -122,9 +122,10 @@ export async function run({
sortType =
fieldType === FieldType.NUMBER ? FieldType.NUMBER : FieldType.STRING
}
// when passing the tableId in the Ctx it needs to be decoded
const ctx = buildCtx(appId, null, {
params: {
tableId,
tableId: decodeURIComponent(tableId),
},
body: {
sortType,

View File

@ -90,6 +90,8 @@ export async function run({
}
}
const tableId = inputs.row.tableId
? decodeURIComponent(inputs.row.tableId)
: inputs.row.tableId
// Base update
let rowUpdate: Record<string, any>
@ -157,7 +159,7 @@ export async function run({
},
params: {
rowId: inputs.rowId,
tableId,
tableId: tableId,
},
})
await rowController.patch(ctx)

View File

@ -2,6 +2,7 @@ import { EmptyFilterOption, SortOrder, Table } from "@budibase/types"
import * as setup from "./utilities"
import { createAutomationBuilder } from "./utilities/AutomationTestBuilder"
import * as automation from "../index"
import { basicTable } from "../../tests/utilities/structures"
const NAME = "Test"
@ -13,6 +14,7 @@ describe("Test a query step automation", () => {
await automation.init()
await config.init()
table = await config.createTable()
const row = {
name: NAME,
description: "original description",
@ -153,4 +155,32 @@ describe("Test a query step automation", () => {
expect(result.steps[0].outputs.rows).toBeDefined()
expect(result.steps[0].outputs.rows.length).toBe(2)
})
it("return rows when querying a table with a space in the name", async () => {
const tableWithSpaces = await config.createTable({
...basicTable(),
name: "table with spaces",
})
await config.createRow({
name: NAME,
tableId: tableWithSpaces._id,
})
const result = await createAutomationBuilder({
name: "Return All Test",
config,
})
.appAction({ fields: {} })
.queryRows(
{
tableId: tableWithSpaces._id!,
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
filters: {},
},
{ stepName: "Query table with spaces" }
)
.run()
expect(result.steps[0].outputs.success).toBe(true)
expect(result.steps[0].outputs.rows).toBeDefined()
expect(result.steps[0].outputs.rows.length).toBe(1)
})
})

View File

@ -13,7 +13,7 @@ const mainDescriptions = datasourceDescribe({
if (mainDescriptions.length) {
describe.each(mainDescriptions)(
"/postgres integrations",
"/postgres integrations ($dbName)",
({ config, dsProvider }) => {
let datasource: Datasource
let client: Knex

View File

@ -73,6 +73,27 @@ describe("Captures of real examples", () => {
})
describe("read", () => {
it("should retrieve all fields if non are specified", () => {
const queryJson = getJson("basicFetch.json")
delete queryJson.resource
let query = new Sql(SqlClient.POSTGRES)._query(queryJson)
expect(query).toEqual({
bindings: [primaryLimit],
sql: `select * from "persons" as "a" order by "a"."firstname" asc nulls first, "a"."personid" asc limit $1`,
})
})
it("should retrieve only requested fields", () => {
const queryJson = getJson("basicFetch.json")
let query = new Sql(SqlClient.POSTGRES)._query(queryJson)
expect(query).toEqual({
bindings: [primaryLimit],
sql: `select "a"."year", "a"."firstname", "a"."personid", "a"."age", "a"."type", "a"."lastname" from "persons" as "a" order by "a"."firstname" asc nulls first, "a"."personid" asc limit $1`,
})
})
it("should handle basic retrieval with relationships", () => {
const queryJson = getJson("basicFetchWithRelationships.json")
let query = new Sql(SqlClient.POSTGRES, relationshipLimit)._query(
@ -112,9 +133,9 @@ describe("Captures of real examples", () => {
bindings: [primaryLimit, relationshipLimit],
sql: expect.stringContaining(
multiline(
`with "paginated" as (select "a".* from "products" as "a" order by "a"."productname" asc nulls first, "a"."productid" asc limit $1)
select "a".*, (select json_agg(json_build_object('executorid',"b"."executorid",'qaid',"b"."qaid",'taskid',"b"."taskid",'completed',"b"."completed",'taskname',"b"."taskname"))
from (select "b".* from "tasks" as "b" inner join "products_tasks" as "c" on "b"."taskid" = "c"."taskid" where "c"."productid" = "a"."productid" order by "b"."taskid" asc limit $2) as "b") as "tasks"
`with "paginated" as (select * from "products" as "a" order by "a"."productname" asc nulls first, "a"."productid" asc limit $1)
select "a"."productname", "a"."productid", (select json_agg(json_build_object('executorid',"b"."executorid",'qaid',"b"."qaid",'taskid',"b"."taskid",'completed',"b"."completed",'taskname',"b"."taskname"))
from (select "b"."executorid", "b"."qaid", "b"."taskid", "b"."completed", "b"."taskname" from "tasks" as "b" inner join "products_tasks" as "c" on "b"."taskid" = "c"."taskid" where "c"."productid" = "a"."productid" order by "b"."taskid" asc limit $2) as "b") as "tasks"
from "paginated" as "a" order by "a"."productname" asc nulls first, "a"."productid" asc`
)
),
@ -130,9 +151,9 @@ describe("Captures of real examples", () => {
expect(query).toEqual({
bindings: [...filters, relationshipLimit, relationshipLimit],
sql: multiline(
`with "paginated" as (select "a".* from "tasks" as "a" where "a"."taskid" in ($1, $2) order by "a"."taskid" asc limit $3)
select "a".*, (select json_agg(json_build_object('productid',"b"."productid",'productname',"b"."productname"))
from (select "b".* from "products" as "b" inner join "products_tasks" as "c" on "b"."productid" = "c"."productid"
`with "paginated" as (select * from "tasks" as "a" where "a"."taskid" in ($1, $2) order by "a"."taskid" asc limit $3)
select "a"."executorid", "a"."taskname", "a"."taskid", "a"."completed", "a"."qaid", (select json_agg(json_build_object('productid',"b"."productid",'productname',"b"."productname"))
from (select "b"."productid", "b"."productname" from "products" as "b" inner join "products_tasks" as "c" on "b"."productid" = "c"."productid"
where "c"."taskid" = "a"."taskid" order by "b"."productid" asc limit $4) as "b") as "products" from "paginated" as "a" order by "a"."taskid" asc`
),
})
@ -209,7 +230,7 @@ describe("Captures of real examples", () => {
bindings: ["ddd", ""],
sql: multiline(`delete from "compositetable" as "a"
where COALESCE("a"."keypartone" = $1, FALSE) and COALESCE("a"."keyparttwo" = $2, FALSE)
returning "a".*`),
returning "a"."keyparttwo", "a"."keypartone", "a"."name"`),
})
})
})

Some files were not shown because too many files have changed in this diff Show More