Merge branch 'master' into type-portal-user-store-2

This commit is contained in:
Andrew Kingston 2025-01-13 09:13:15 +00:00 committed by GitHub
commit 8ec852873f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 559 additions and 374 deletions

View File

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

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

@ -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,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

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"

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,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

@ -68,13 +68,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 +93,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 +226,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 +239,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

@ -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

@ -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,6 @@
export { createAPIClient } from "./api"
export { fetchData } from "./fetch"
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 193476cdfade6d3c613e6972f16ee0c527e01ff6

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

@ -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(
@ -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

@ -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

@ -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

@ -432,6 +432,21 @@ export async function enrichSchema(
...tableSchema[key],
...ui,
order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key]?.order,
// When this was written, the only column types in FieldSchema to have columns
// field were the relationship columns. We blank this out here to make sure it's
// not set on non-relationship columns, then below we populate it by calling
// populateRelSchema.
//
// For Budibase 3.0 we introduced the FieldType.AI fields. Some of these fields
// have `columns: string[]` and it flew under the radar here because the
// AIFieldMetadata type isn't a union on its subtypes, it has a collection of
// optional fields. So columns is `columns?: string[]` which allows undefined,
// and doesn't fail this type check.
//
// What this means in practice is when FieldType.AI fields get enriched, we
// delete their `columns`. At the time of writing, I don't believe anything in
// the frontend depends on this, but it is odd and will probably bite us at
// some point.
columns: undefined,
}

View File

@ -0,0 +1,46 @@
import nock from "nock"
let chatID = 1
export function mockChatGPTResponse(
response: string | ((prompt: string) => string)
) {
return nock("https://api.openai.com")
.post("/v1/chat/completions")
.reply(200, (uri, requestBody) => {
let content = response
if (typeof response === "function") {
const messages = (requestBody as any).messages
content = response(messages[0].content)
}
chatID++
return {
id: `chatcmpl-${chatID}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: "gpt-4o-mini",
system_fingerprint: `fp_${chatID}`,
choices: [
{
index: 0,
message: { role: "assistant", content },
logprobs: null,
finish_reason: "stop",
},
],
usage: {
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
completion_tokens_details: {
reasoning_tokens: 0,
accepted_prediction_tokens: 0,
rejected_prediction_tokens: 0,
},
},
}
})
.persist()
}

View File

@ -160,7 +160,7 @@ export async function processAIColumns<T extends Row | Row[]>(
return tracer.trace("processAIColumn", {}, async span => {
span?.addTags({ table_id: table._id, column })
const llmResponse = await llmWrapper.run(prompt!)
const llmResponse = await llmWrapper.run(prompt)
return {
...row,
[column]: llmResponse,

View File

@ -15,5 +15,5 @@ export interface GetGlobalSelfResponse extends User {
license: License
budibaseAccess: boolean
accountPortalAccess: boolean
csrfToken: boolean
csrfToken: string
}

View File

@ -154,6 +154,7 @@ export const GroupByTypes = [
FieldType.BOOLEAN,
FieldType.DATETIME,
FieldType.BIGINT,
FieldType.AI,
]
export function canGroupBy(type: FieldType) {

View File

@ -33,11 +33,6 @@ export interface ScreenRoutesViewOutput extends Document {
export type ScreenRoutingJson = Record<
string,
{
subpaths: Record<
string,
{
screens: Record<string, string>
}
>
subpaths: Record<string, any>
}
>

View File

@ -123,7 +123,7 @@ export interface AIFieldMetadata extends BaseFieldSchema {
operation: AIOperationEnum
columns?: string[]
column?: string
categories?: string[]
categories?: string
prompt?: string
language?: string
}

View File

@ -1,17 +1,15 @@
export enum FeatureFlag {
AUTOMATION_BRANCHING = "AUTOMATION_BRANCHING",
AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS",
DEFAULT_VALUES = "DEFAULT_VALUES",
BUDIBASE_AI = "BUDIBASE_AI",
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
// Account-portal
DIRECT_LOGIN_TO_ACCOUNT_PORTAL = "DIRECT_LOGIN_TO_ACCOUNT_PORTAL",
}
export const FeatureFlagDefaults = {
[FeatureFlag.DEFAULT_VALUES]: true,
[FeatureFlag.AUTOMATION_BRANCHING]: true,
[FeatureFlag.AI_CUSTOM_CONFIGS]: true,
[FeatureFlag.BUDIBASE_AI]: true,
[FeatureFlag.USE_ZOD_VALIDATOR]: false,
// Account-portal
[FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false,
}
export type FeatureFlags = typeof FeatureFlagDefaults

View File

@ -14,6 +14,7 @@ export type UIColumn = FieldSchema & {
type: FieldType
readonly: boolean
autocolumn: boolean
cellRenderType?: FieldType | "role"
}
calculationType: CalculationType
__idx: number