Merge branch 'pdf-screen-template' of github.com:Budibase/budibase into pdf-specific-components

This commit is contained in:
Andrew Kingston 2025-04-01 20:23:35 +01:00
commit e53efa1f48
No known key found for this signature in database
35 changed files with 717 additions and 85 deletions

View File

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

View File

@ -96,6 +96,24 @@ async function get<T extends Document>(db: Database, id: string): Promise<T> {
return cacheItem.doc return cacheItem.doc
} }
async function tryGet<T extends Document>(
db: Database,
id: string
): Promise<T | null> {
const cache = await getCache()
const cacheKey = makeCacheKey(db, id)
let cacheItem: CacheItem<T> | null = await cache.get(cacheKey)
if (!cacheItem) {
const doc = await db.tryGet<T>(id)
if (!doc) {
return null
}
cacheItem = makeCacheItem(doc)
await cache.store(cacheKey, cacheItem)
}
return cacheItem.doc
}
async function remove(db: Database, docOrId: any, rev?: any): Promise<void> { async function remove(db: Database, docOrId: any, rev?: any): Promise<void> {
const cache = await getCache() const cache = await getCache()
if (!docOrId) { if (!docOrId) {
@ -123,10 +141,17 @@ export class Writethrough {
return put(this.db, doc, writeRateMs) return put(this.db, doc, writeRateMs)
} }
/**
* @deprecated use `tryGet` instead
*/
async get<T extends Document>(id: string) { async get<T extends Document>(id: string) {
return get<T>(this.db, id) return get<T>(this.db, id)
} }
async tryGet<T extends Document>(id: string) {
return tryGet<T>(this.db, id)
}
async remove(docOrId: any, rev?: any) { async remove(docOrId: any, rev?: any) {
return remove(this.db, docOrId, rev) return remove(this.db, docOrId, rev)
} }

View File

@ -47,6 +47,9 @@ export async function getConfig<T extends Config>(
export async function save( export async function save(
config: Config config: Config
): Promise<{ id: string; rev: string }> { ): Promise<{ id: string; rev: string }> {
if (!config._id) {
config._id = generateConfigID(config.type)
}
const db = context.getGlobalDB() const db = context.getGlobalDB()
return db.put(config) return db.put(config)
} }

View File

@ -12,7 +12,6 @@ describe("configs", () => {
const setDbPlatformUrl = async (dbUrl: string) => { const setDbPlatformUrl = async (dbUrl: string) => {
const settingsConfig = { const settingsConfig = {
_id: configs.generateConfigID(ConfigType.SETTINGS),
type: ConfigType.SETTINGS, type: ConfigType.SETTINGS,
config: { config: {
platformUrl: dbUrl, platformUrl: dbUrl,

View File

@ -60,6 +60,11 @@ export const StaticDatabases = {
SCIM_LOGS: { SCIM_LOGS: {
name: "scim-logs", name: "scim-logs",
}, },
// Used by self-host users making use of Budicloud resources. Introduced when
// we started letting self-host users use Budibase AI in the cloud.
SELF_HOST_CLOUD: {
name: "self-host-cloud",
},
} }
export const APP_PREFIX = prefixed(DocumentType.APP) export const APP_PREFIX = prefixed(DocumentType.APP)

View File

@ -157,6 +157,33 @@ export async function doInTenant<T>(
return newContext(updates, task) return newContext(updates, task)
} }
// We allow self-host licensed users to make use of some Budicloud services
// (e.g. Budibase AI). When they do this, they use their license key as an API
// key. We use that license key to identify the tenant ID, and we set the
// context to be self-host using cloud. This affects things like where their
// quota documents get stored (because we want to avoid creating a new global
// DB for each self-host tenant).
export async function doInSelfHostTenantUsingCloud<T>(
tenantId: string,
task: () => T
): Promise<T> {
const updates = { tenantId, isSelfHostUsingCloud: true }
return newContext(updates, task)
}
export function isSelfHostUsingCloud() {
const context = Context.get()
return !!context?.isSelfHostUsingCloud
}
export function getSelfHostCloudDB() {
const context = Context.get()
if (!context || !context.isSelfHostUsingCloud) {
throw new Error("Self-host cloud DB not found")
}
return getDB(StaticDatabases.SELF_HOST_CLOUD.name)
}
export async function doInAppContext<T>( export async function doInAppContext<T>(
appId: string, appId: string,
task: () => T task: () => T
@ -325,6 +352,11 @@ export function getGlobalDB(): Database {
if (!context || (env.MULTI_TENANCY && !context.tenantId)) { if (!context || (env.MULTI_TENANCY && !context.tenantId)) {
throw new Error("Global DB not found") throw new Error("Global DB not found")
} }
if (context.isSelfHostUsingCloud) {
throw new Error(
"Global DB not found - self-host users using cloud don't have a global DB"
)
}
return getDB(baseGlobalDBName(context?.tenantId)) return getDB(baseGlobalDBName(context?.tenantId))
} }
@ -344,6 +376,11 @@ export function getAppDB(opts?: any): Database {
if (!appId) { if (!appId) {
throw new Error("Unable to retrieve app DB - no app ID.") throw new Error("Unable to retrieve app DB - no app ID.")
} }
if (isSelfHostUsingCloud()) {
throw new Error(
"App DB not found - self-host users using cloud don't have app DBs"
)
}
return getDB(appId, opts) return getDB(appId, opts)
} }

View File

@ -5,6 +5,7 @@ import { GoogleSpreadsheet } from "google-spreadsheet"
// keep this out of Budibase types, don't want to expose context info // keep this out of Budibase types, don't want to expose context info
export type ContextMap = { export type ContextMap = {
tenantId?: string tenantId?: string
isSelfHostUsingCloud?: boolean
appId?: string appId?: string
identity?: IdentityContext identity?: IdentityContext
environmentVariables?: Record<string, string> environmentVariables?: Record<string, string>

View File

@ -6,7 +6,13 @@
Multiselect, Multiselect,
Button, Button,
} from "@budibase/bbui" } from "@budibase/bbui"
import { CalculationType, canGroupBy, isNumeric } from "@budibase/types" import {
CalculationType,
canGroupBy,
FieldType,
isNumeric,
isNumericStaticFormula,
} from "@budibase/types"
import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte" import InfoDisplay from "@/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
import DetailPopover from "@/components/common/DetailPopover.svelte" import DetailPopover from "@/components/common/DetailPopover.svelte"
@ -94,10 +100,15 @@
if (fieldSchema.calculationType) { if (fieldSchema.calculationType) {
return false return false
} }
// static numeric formulas will work
if (isNumericStaticFormula(fieldSchema)) {
return true
}
// Only allow numeric columns for most calculation types // Only allow numeric columns for most calculation types
if ( if (
self.type !== CalculationType.COUNT && self.type !== CalculationType.COUNT &&
!isNumeric(fieldSchema.type) !isNumeric(fieldSchema.type) &&
fieldSchema.responseType !== FieldType.NUMBER
) { ) {
return false return false
} }

View File

@ -45,6 +45,16 @@
break break
default: default:
screenComponentSettings = [ screenComponentSettings = [
{
key: "width",
label: "Width",
control: Select,
props: {
options: ["Extra small", "Small", "Medium", "Large", "Max"],
placeholder: "Default",
disabled: !!screen.layoutId,
},
},
{ {
key: "props.layout", key: "props.layout",
label: "Layout", label: "Layout",
@ -109,16 +119,6 @@
label: "On screen load", label: "On screen load",
control: ButtonActionEditor, control: ButtonActionEditor,
}, },
{
key: "width",
label: "Width",
control: Select,
props: {
options: ["Extra small", "Small", "Medium", "Large", "Max"],
placeholder: "Default",
disabled: !!screen.layoutId,
},
},
...screenComponentSettings, ...screenComponentSettings,
{ {
key: "urlTest", key: "urlTest",

View File

@ -26,7 +26,9 @@
<div class="info"> <div class="info">
<Icon name="InfoOutline" size="S" /> <Icon name="InfoOutline" size="S" />
<Body size="S">These settings apply to all screens</Body> <Body size="S">
These settings apply to all screens. PDFs are always light theme.
</Body>
</div> </div>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">

View File

@ -58,7 +58,7 @@
// Get initial set of allowed components // Get initial set of allowed components
let allowedComponents = [] let allowedComponents = []
const definition = componentStore.getDefinition(component?._component) const definition = componentStore.getDefinition(component?._component)
if (definition.legalDirectChildren?.length) { if (definition?.legalDirectChildren?.length) {
allowedComponents = definition.legalDirectChildren.map(x => { allowedComponents = definition.legalDirectChildren.map(x => {
return `@budibase/standard-components/${x}` return `@budibase/standard-components/${x}`
}) })
@ -67,7 +67,7 @@
} }
// Build up list of illegal children from ancestors // Build up list of illegal children from ancestors
let illegalChildren = definition.illegalChildren || [] let illegalChildren = definition?.illegalChildren || []
path.forEach(ancestor => { path.forEach(ancestor => {
// Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level. // Sidepanels and modals can be nested anywhere in the component tree, but really they are always rendered at the top level.
// Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here. // Because of this, it doesn't make sense to carry over any parent illegal children to them, so the array is reset here.

View File

@ -63,7 +63,7 @@
<img alt="A form containing data" src={pdf} width="185" /> <img alt="A form containing data" src={pdf} width="185" />
</div> </div>
<div class="text"> <div class="text">
<Body size="M">PDF Editor</Body> <Body size="M">PDF</Body>
<Body size="XS">Create, edit and export your PDF</Body> <Body size="XS">Create, edit and export your PDF</Body>
</div> </div>
</div> </div>

View File

@ -99,8 +99,8 @@ export class PDFScreen extends Screen {
selected: {}, selected: {},
}, },
_children: [], _children: [],
_instanceName: "", _instanceName: "PDF",
title: "PDF Editor", title: "PDF",
} }
} }
} }

View File

@ -6,11 +6,7 @@ import { Roles } from "@/constants/backend"
const pdf = ({ route, screens }) => { const pdf = ({ route, screens }) => {
const validRoute = getValidRoute(screens, route, Roles.BASIC) const validRoute = getValidRoute(screens, route, Roles.BASIC)
const template = new PDFScreen() const template = new PDFScreen().role(Roles.BASIC).route(validRoute).json()
.instanceName("PDF Editor")
.role(Roles.BASIC)
.route(validRoute)
.json()
return [ return [
{ {

View File

@ -1,12 +1,18 @@
<script> <script>
import { themeStore } from "@/stores" import { themeStore } from "@/stores"
import { setContext } from "svelte" import { setContext } from "svelte"
import { Context } from "@budibase/bbui" import { Context, Helpers } from "@budibase/bbui"
setContext(Context.PopoverRoot, "#theme-root") export let popoverRoot = true
const id = Helpers.uuid()
if (popoverRoot) {
setContext(Context.PopoverRoot, `#id`)
}
</script> </script>
<div style={$themeStore.customThemeCss} id="theme-root"> <div style={$themeStore.customThemeCss} {id}>
<slot /> <slot />
</div> </div>

View File

@ -3,6 +3,7 @@
import { Heading, Button } from "@budibase/bbui" import { Heading, Button } from "@budibase/bbui"
import { htmlToPdf, pxToPt, A4HeightPx, type PDFOptions } from "./pdf" import { htmlToPdf, pxToPt, A4HeightPx, type PDFOptions } from "./pdf"
import { GridRowHeight } from "@/constants" import { GridRowHeight } from "@/constants"
import CustomThemeWrapper from "@/components/CustomThemeWrapper.svelte"
const component = getContext("component") const component = getContext("component")
const { styleable, Block, BlockComponent } = getContext("sdk") const { styleable, Block, BlockComponent } = getContext("sdk")
@ -30,6 +31,7 @@
const generatePDF = async () => { const generatePDF = async () => {
rendering = true rendering = true
await tick() await tick()
preprocessCSS()
try { try {
const opts: PDFOptions = { const opts: PDFOptions = {
fileName: safeName, fileName: safeName,
@ -43,6 +45,19 @@
rendering = false rendering = false
} }
const preprocessCSS = () => {
const els = document.getElementsByClassName("grid-child")
for (let el of els) {
if (!(el instanceof HTMLElement)) {
return
}
// Get the computed values and assign them back to the style, simplifying
// the CSS that gets handled by HTML2PDF
const styles = window.getComputedStyle(el)
el.style.setProperty("grid-column-end", styles.gridColumnEnd, "important")
}
}
const getDividerStyle = (idx: number) => { const getDividerStyle = (idx: number) => {
const top = (idx + 1) * innerPageHeightPx + doubleMarginPx / 2 const top = (idx + 1) * innerPageHeightPx + doubleMarginPx / 2
return `--idx:"${idx + 1}"; --top:${top}px;` return `--idx:"${idx + 1}"; --top:${top}px;`
@ -91,22 +106,23 @@
{/each} {/each}
{/if} {/if}
<div <div
dir="ltr" class="spectrum spectrum--medium spectrum--light pageContent"
class="spectrum spectrum--lightest spectrum--medium pageContent"
bind:this={ref} bind:this={ref}
> >
<BlockComponent <CustomThemeWrapper popoverRoot={false}>
type="container" <BlockComponent
props={{ layout: "grid" }} type="container"
styles={{ props={{ layout: "grid" }}
normal: { styles={{
height: `${gridMinHeight}px`, normal: {
}, height: `${gridMinHeight}px`,
}} },
context="grid" }}
> context="grid"
<slot /> >
</BlockComponent> <slot />
</BlockComponent>
</CustomThemeWrapper>
</div> </div>
</div> </div>
</div> </div>
@ -157,6 +173,7 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
background: white;
} }
.divider { .divider {
width: 100%; width: 100%;
@ -171,12 +188,4 @@
top: calc(var(--top) + var(--margin)); top: calc(var(--top) + var(--margin));
background: transparent; background: transparent;
} }
/*.divider::after {*/
/* position: absolute;*/
/* top: -32px;*/
/* right: 24px;*/
/* content: var(--idx);*/
/* color: var(--spectrum-global-color-static-gray-400);*/
/* text-align: right;*/
/*}*/
</style> </style>

View File

@ -116,6 +116,9 @@ export const gridLayout = (node: HTMLDivElement, metadata: GridMetadata) => {
return return
} }
// Add a unique class to elements we mutate so we can easily find them later
node.classList.add("grid-child")
// Callback to select the component when clicking on the wrapper // Callback to select the component when clicking on the wrapper
selectComponent = (e: Event) => { selectComponent = (e: Event) => {
e.stopPropagation() e.stopPropagation()

@ -1 +1 @@
Subproject commit 40c36f86584568d31abd6dd5b6b00dd3a458093f Subproject commit 8eb981cf01151261697a8f26c08c4c28f66b8e15

View File

@ -49,6 +49,7 @@
"author": "Budibase", "author": "Budibase",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.27.3",
"@apidevtools/swagger-parser": "10.0.3", "@apidevtools/swagger-parser": "10.0.3",
"@aws-sdk/client-dynamodb": "3.709.0", "@aws-sdk/client-dynamodb": "3.709.0",
"@aws-sdk/client-s3": "3.709.0", "@aws-sdk/client-s3": "3.709.0",

View File

@ -47,6 +47,7 @@ async function init() {
VERSION: "0.0.0+local", VERSION: "0.0.0+local",
PASSWORD_MIN_LENGTH: "1", PASSWORD_MIN_LENGTH: "1",
OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd", OPENAI_API_KEY: "sk-abcdefghijklmnopqrstuvwxyz1234567890abcd",
BUDICLOUD_URL: "https://budibaseqa.app",
} }
config = { ...config, ...existingConfig } config = { ...config, ...existingConfig }

View File

@ -0,0 +1,344 @@
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import nock from "nock"
import { configs, env, features, setEnv } from "@budibase/backend-core"
import {
AIInnerConfig,
ConfigType,
License,
PlanModel,
PlanType,
ProviderConfig,
} from "@budibase/types"
import { context } from "@budibase/backend-core"
import { mocks } from "@budibase/backend-core/tests"
import { MockLLMResponseFn } from "../../../tests/utilities/mocks/ai"
import { mockAnthropicResponse } from "../../../tests/utilities/mocks/ai/anthropic"
function dedent(str: string) {
return str
.split("\n")
.map(line => line.trim())
.join("\n")
}
type SetupFn = (
config: TestConfiguration
) => Promise<() => Promise<void> | void>
interface TestSetup {
name: string
setup: SetupFn
mockLLMResponse: MockLLMResponseFn
}
function budibaseAI(): SetupFn {
return async () => {
const cleanup = setEnv({
OPENAI_API_KEY: "test-key",
})
mocks.licenses.useBudibaseAI()
return async () => {
mocks.licenses.useCloudFree()
cleanup()
}
}
}
function customAIConfig(providerConfig: Partial<ProviderConfig>): SetupFn {
return async (config: TestConfiguration) => {
mocks.licenses.useAICustomConfigs()
const innerConfig: AIInnerConfig = {
myaiconfig: {
provider: "OpenAI",
name: "OpenAI",
apiKey: "test-key",
defaultModel: "gpt-4o-mini",
active: true,
isDefault: true,
...providerConfig,
},
}
const { id, rev } = await config.doInTenant(
async () =>
await configs.save({
type: ConfigType.AI,
config: innerConfig,
})
)
return async () => {
mocks.licenses.useCloudFree()
await config.doInTenant(async () => {
const db = context.getGlobalDB()
await db.remove(id, rev)
})
}
}
}
const allProviders: TestSetup[] = [
{
name: "OpenAI API key",
setup: async () => {
return setEnv({
OPENAI_API_KEY: "test-key",
})
},
mockLLMResponse: mockChatGPTResponse,
},
{
name: "OpenAI API key with custom config",
setup: customAIConfig({ provider: "OpenAI", defaultModel: "gpt-4o-mini" }),
mockLLMResponse: mockChatGPTResponse,
},
{
name: "Anthropic API key with custom config",
setup: customAIConfig({
provider: "Anthropic",
defaultModel: "claude-3-5-sonnet-20240620",
}),
mockLLMResponse: mockAnthropicResponse,
},
{
name: "BudibaseAI",
setup: budibaseAI(),
mockLLMResponse: mockChatGPTResponse,
},
]
describe("AI", () => {
const config = new TestConfiguration()
beforeAll(async () => {
await config.init()
})
afterAll(() => {
config.end()
})
beforeEach(() => {
nock.cleanAll()
})
describe.each(allProviders)(
"provider: $name",
({ setup, mockLLMResponse }: TestSetup) => {
let cleanup: () => Promise<void> | void
beforeAll(async () => {
cleanup = await setup(config)
})
afterAll(async () => {
const maybePromise = cleanup()
if (maybePromise) {
await maybePromise
}
})
describe("POST /api/ai/js", () => {
let cleanup: () => void
beforeAll(() => {
cleanup = features.testutils.setFeatureFlags("*", {
AI_JS_GENERATION: true,
})
})
afterAll(() => {
cleanup()
})
it("handles correct plain code response", async () => {
mockLLMResponse(`return 42`)
const { code } = await config.api.ai.generateJs({ prompt: "test" })
expect(code).toBe("return 42")
})
it("handles correct markdown code response", async () => {
mockLLMResponse(
dedent(`
\`\`\`js
return 42
\`\`\`
`)
)
const { code } = await config.api.ai.generateJs({ prompt: "test" })
expect(code).toBe("return 42")
})
it("handles multiple markdown code blocks returned", async () => {
mockLLMResponse(
dedent(`
This:
\`\`\`js
return 42
\`\`\`
Or this:
\`\`\`js
return 10
\`\`\`
`)
)
const { code } = await config.api.ai.generateJs({ prompt: "test" })
expect(code).toBe("return 42")
})
// TODO: handle when this happens
it.skip("handles no code response", async () => {
mockLLMResponse("I'm sorry, you're quite right, etc.")
const { code } = await config.api.ai.generateJs({ prompt: "test" })
expect(code).toBe("")
})
it("handles LLM errors", async () => {
mockLLMResponse(() => {
throw new Error("LLM error")
})
await config.api.ai.generateJs({ prompt: "test" }, { status: 500 })
})
})
describe("POST /api/ai/cron", () => {
it("handles correct cron response", async () => {
mockLLMResponse("0 0 * * *")
const { message } = await config.api.ai.generateCron({
prompt: "test",
})
expect(message).toBe("0 0 * * *")
})
it("handles expected LLM error", async () => {
mockLLMResponse("Error generating cron: skill issue")
await config.api.ai.generateCron(
{
prompt: "test",
},
{ status: 400 }
)
})
it("handles unexpected LLM error", async () => {
mockLLMResponse(() => {
throw new Error("LLM error")
})
await config.api.ai.generateCron(
{
prompt: "test",
},
{ status: 500 }
)
})
})
}
)
})
describe("BudibaseAI", () => {
const config = new TestConfiguration()
let cleanup: () => void | Promise<void>
beforeAll(async () => {
await config.init()
cleanup = await budibaseAI()(config)
})
afterAll(async () => {
if ("then" in cleanup) {
await cleanup()
} else {
cleanup()
}
config.end()
})
describe("POST /api/ai/chat", () => {
let envCleanup: () => void
let featureCleanup: () => void
beforeAll(() => {
envCleanup = setEnv({ SELF_HOSTED: false })
featureCleanup = features.testutils.setFeatureFlags("*", {
AI_JS_GENERATION: true,
})
})
afterAll(() => {
featureCleanup()
envCleanup()
})
beforeEach(() => {
nock.cleanAll()
const license: License = {
plan: {
type: PlanType.FREE,
model: PlanModel.PER_USER,
usesInvoicing: false,
},
features: [],
quotas: {} as any,
tenantId: config.tenantId,
}
nock(env.ACCOUNT_PORTAL_URL).get("/api/license").reply(200, license)
})
it("handles correct chat response", async () => {
mockChatGPTResponse("Hi there!")
const { message } = await config.api.ai.chat({
messages: [{ role: "user", content: "Hello!" }],
licenseKey: "test-key",
})
expect(message).toBe("Hi there!")
})
it("handles chat response error", async () => {
mockChatGPTResponse(() => {
throw new Error("LLM error")
})
await config.api.ai.chat(
{
messages: [{ role: "user", content: "Hello!" }],
licenseKey: "test-key",
},
{ status: 500 }
)
})
it("handles no license", async () => {
nock.cleanAll()
nock(env.ACCOUNT_PORTAL_URL).get("/api/license").reply(404)
await config.api.ai.chat(
{
messages: [{ role: "user", content: "Hello!" }],
licenseKey: "test-key",
},
{
status: 403,
}
)
})
it("handles no license key", async () => {
await config.api.ai.chat(
{
messages: [{ role: "user", content: "Hello!" }],
// @ts-expect-error - intentionally wrong
licenseKey: undefined,
},
{
status: 403,
}
)
})
})
})

View File

@ -46,7 +46,7 @@ import { withEnv } from "../../../environment"
import { JsTimeoutError } from "@budibase/string-templates" import { JsTimeoutError } from "@budibase/string-templates"
import { isDate } from "../../../utilities" import { isDate } from "../../../utilities"
import nock from "nock" import nock from "nock"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai" import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
tk.freeze(timestamp) tk.freeze(timestamp)

View File

@ -44,7 +44,7 @@ import { generator, structures, mocks } from "@budibase/backend-core/tests"
import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default" import { DEFAULT_EMPLOYEE_TABLE_SCHEMA } from "../../../db/defaultData/datasource_bb_default"
import { generateRowIdField } from "../../../integrations/utils" import { generateRowIdField } from "../../../integrations/utils"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai" import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
const descriptions = datasourceDescribe({ plus: true }) const descriptions = datasourceDescribe({ plus: true })

View File

@ -35,13 +35,14 @@ import {
ViewV2, ViewV2,
ViewV2Schema, ViewV2Schema,
ViewV2Type, ViewV2Type,
FormulaType,
} from "@budibase/types" } from "@budibase/types"
import { generator, mocks } from "@budibase/backend-core/tests" import { generator, mocks } from "@budibase/backend-core/tests"
import { datasourceDescribe } from "../../../integrations/tests/utils" import { datasourceDescribe } from "../../../integrations/tests/utils"
import merge from "lodash/merge" import merge from "lodash/merge"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { context, db, events, roles, setEnv } from "@budibase/backend-core" import { context, db, events, roles, setEnv } from "@budibase/backend-core"
import { mockChatGPTResponse } from "../../../tests/utilities/mocks/openai" import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
import nock from "nock" import nock from "nock"
const descriptions = datasourceDescribe({ plus: true }) const descriptions = datasourceDescribe({ plus: true })
@ -3865,6 +3866,48 @@ if (descriptions.length) {
expect(rows[0].count).toEqual(2) expect(rows[0].count).toEqual(2)
}) })
isInternal &&
it("should be able to max a static formula field", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
string: {
type: FieldType.STRING,
name: "string",
},
formula: {
type: FieldType.FORMULA,
name: "formula",
formulaType: FormulaType.STATIC,
responseType: FieldType.NUMBER,
formula: "{{ string }}",
},
},
})
)
await config.api.row.save(table._id!, {
string: "1",
})
await config.api.row.save(table._id!, {
string: "2",
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
type: ViewV2Type.CALCULATION,
schema: {
maxFormula: {
visible: true,
calculationType: CalculationType.MAX,
field: "formula",
},
},
})
const { rows } = await config.api.row.search(view.id)
expect(rows.length).toEqual(1)
expect(rows[0].maxFormula).toEqual(2)
})
it("should not be able to COUNT(DISTINCT ...) against a non-existent field", async () => { it("should not be able to COUNT(DISTINCT ...) against a non-existent field", async () => {
await config.api.viewV2.create( await config.api.viewV2.create(
{ {

View File

@ -1,11 +1,8 @@
import { createAutomationBuilder } from "../utilities/AutomationTestBuilder" import { createAutomationBuilder } from "../utilities/AutomationTestBuilder"
import { setEnv as setCoreEnv } from "@budibase/backend-core" import { setEnv as setCoreEnv, withEnv } from "@budibase/backend-core"
import { Model, MonthlyQuotaName, QuotaUsageType } from "@budibase/types" import { Model, MonthlyQuotaName, QuotaUsageType } from "@budibase/types"
import TestConfiguration from "../../..//tests/utilities/TestConfiguration" import TestConfiguration from "../../..//tests/utilities/TestConfiguration"
import { import { mockChatGPTResponse } from "../../../tests/utilities/mocks/ai/openai"
mockChatGPTError,
mockChatGPTResponse,
} from "../../../tests/utilities/mocks/openai"
import nock from "nock" import nock from "nock"
import { mocks } from "@budibase/backend-core/tests" import { mocks } from "@budibase/backend-core/tests"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
@ -83,7 +80,9 @@ describe("test the openai action", () => {
}) })
it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => { it("should present the correct error message when an error is thrown from the createChatCompletion call", async () => {
mockChatGPTError() mockChatGPTResponse(() => {
throw new Error("oh no")
})
const result = await expectAIUsage(0, () => const result = await expectAIUsage(0, () =>
createAutomationBuilder(config) createAutomationBuilder(config)
@ -108,11 +107,13 @@ describe("test the openai action", () => {
// path, because we've enabled Budibase AI. The exact value depends on a // path, because we've enabled Budibase AI. The exact value depends on a
// calculation we use to approximate cost. This uses Budibase's OpenAI API // calculation we use to approximate cost. This uses Budibase's OpenAI API
// key, so we charge users for it. // key, so we charge users for it.
const result = await expectAIUsage(14, () => const result = await withEnv({ SELF_HOSTED: false }, () =>
createAutomationBuilder(config) expectAIUsage(14, () =>
.onAppAction() createAutomationBuilder(config)
.openai({ model: Model.GPT_4O_MINI, prompt: "Hello, world" }) .onAppAction()
.test({ fields: {} }) .openai({ model: Model.GPT_4O_MINI, prompt: "Hello, world" })
.test({ fields: {} })
)
) )
expect(result.steps[0].outputs.response).toEqual("This is a test") expect(result.steps[0].outputs.response).toEqual("This is a test")

View File

@ -365,7 +365,11 @@ export function createSampleDataTableScreen(): Screen {
_component: "@budibase/standard-components/textv2", _component: "@budibase/standard-components/textv2",
_styles: { _styles: {
normal: { normal: {
"--grid-desktop-col-start": 1,
"--grid-desktop-col-end": 3, "--grid-desktop-col-end": 3,
"--grid-desktop-row-start": 1,
"--grid-desktop-row-end": 3,
"--grid-mobile-col-end": 7,
}, },
hover: {}, hover: {},
active: {}, active: {},
@ -384,6 +388,7 @@ export function createSampleDataTableScreen(): Screen {
"--grid-desktop-row-start": 1, "--grid-desktop-row-start": 1,
"--grid-desktop-row-end": 3, "--grid-desktop-row-end": 3,
"--grid-desktop-h-align": "end", "--grid-desktop-h-align": "end",
"--grid-mobile-col-start": 7,
}, },
hover: {}, hover: {},
active: {}, active: {},

View File

@ -4,6 +4,7 @@ import {
canGroupBy, canGroupBy,
FieldType, FieldType,
isNumeric, isNumeric,
isNumericStaticFormula,
PermissionLevel, PermissionLevel,
RelationSchemaField, RelationSchemaField,
RenameColumn, RenameColumn,
@ -176,7 +177,11 @@ async function guardCalculationViewSchema(
} }
const isCount = schema.calculationType === CalculationType.COUNT const isCount = schema.calculationType === CalculationType.COUNT
if (!isCount && !isNumeric(targetSchema.type)) { if (
!isCount &&
!isNumeric(targetSchema.type) &&
!isNumericStaticFormula(targetSchema)
) {
throw new HTTPError( throw new HTTPError(
`Calculation field "${name}" references field "${schema.field}" which is not a numeric field`, `Calculation field "${name}" references field "${schema.field}" which is not a numeric field`,
400 400

View File

@ -0,0 +1,47 @@
import {
ChatCompletionRequest,
ChatCompletionResponse,
GenerateCronRequest,
GenerateCronResponse,
GenerateJsRequest,
GenerateJsResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
import { constants } from "@budibase/backend-core"
export class AIAPI extends TestAPI {
generateJs = async (
req: GenerateJsRequest,
expectations?: Expectations
): Promise<GenerateJsResponse> => {
return await this._post<GenerateJsResponse>(`/api/ai/js`, {
body: req,
expectations,
})
}
generateCron = async (
req: GenerateCronRequest,
expectations?: Expectations
): Promise<GenerateCronResponse> => {
return await this._post<GenerateCronResponse>(`/api/ai/cron`, {
body: req,
expectations,
})
}
chat = async (
req: ChatCompletionRequest & { licenseKey: string },
expectations?: Expectations
): Promise<ChatCompletionResponse> => {
const headers: Record<string, string> = {}
if (req.licenseKey) {
headers[constants.Header.LICENSE_KEY] = req.licenseKey
}
return await this._post<ChatCompletionResponse>(`/api/ai/chat`, {
body: req,
headers,
expectations,
})
}
}

View File

@ -22,8 +22,10 @@ import { UserPublicAPI } from "./public/user"
import { MiscAPI } from "./misc" import { MiscAPI } from "./misc"
import { OAuth2API } from "./oauth2" import { OAuth2API } from "./oauth2"
import { AssetsAPI } from "./assets" import { AssetsAPI } from "./assets"
import { AIAPI } from "./ai"
export default class API { export default class API {
ai: AIAPI
application: ApplicationAPI application: ApplicationAPI
attachment: AttachmentAPI attachment: AttachmentAPI
automation: AutomationAPI automation: AutomationAPI
@ -52,6 +54,7 @@ export default class API {
} }
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
this.ai = new AIAPI(config)
this.application = new ApplicationAPI(config) this.application = new ApplicationAPI(config)
this.attachment = new AttachmentAPI(config) this.attachment = new AttachmentAPI(config)
this.automation = new AutomationAPI(config) this.automation = new AutomationAPI(config)

View File

@ -0,0 +1,48 @@
import AnthropicClient from "@anthropic-ai/sdk"
import nock from "nock"
import { MockLLMResponseFn, MockLLMResponseOpts } from "."
let chatID = 1
const SPACE_REGEX = /\s+/g
export const mockAnthropicResponse: MockLLMResponseFn = (
answer: string | ((prompt: string) => string),
opts?: MockLLMResponseOpts
) => {
return nock(opts?.host || "https://api.anthropic.com")
.post("/v1/messages")
.reply((uri: string, body: nock.Body) => {
const req = body as AnthropicClient.MessageCreateParamsNonStreaming
const prompt = req.messages[0].content
if (typeof prompt !== "string") {
throw new Error("Anthropic mock only supports string prompts")
}
let content
if (typeof answer === "function") {
try {
content = answer(prompt)
} catch (e) {
return [500, "Internal Server Error"]
}
} else {
content = answer
}
const resp: AnthropicClient.Messages.Message = {
id: `${chatID++}`,
type: "message",
role: "assistant",
model: req.model,
stop_reason: "end_turn",
usage: {
input_tokens: prompt.split(SPACE_REGEX).length,
output_tokens: content.split(SPACE_REGEX).length,
},
stop_sequence: null,
content: [{ type: "text", text: content }],
}
return [200, resp]
})
.persist()
}

View File

@ -0,0 +1,10 @@
import { Scope } from "nock"
export interface MockLLMResponseOpts {
host?: string
}
export type MockLLMResponseFn = (
answer: string | ((prompt: string) => string),
opts?: MockLLMResponseOpts
) => Scope

View File

@ -1,12 +1,9 @@
import nock from "nock" import nock from "nock"
import { MockLLMResponseFn, MockLLMResponseOpts } from "."
let chatID = 1 let chatID = 1
const SPACE_REGEX = /\s+/g const SPACE_REGEX = /\s+/g
interface MockChatGPTResponseOpts {
host?: string
}
interface Message { interface Message {
role: string role: string
content: string content: string
@ -47,19 +44,24 @@ interface ChatCompletionResponse {
usage: Usage usage: Usage
} }
export function mockChatGPTResponse( export const mockChatGPTResponse: MockLLMResponseFn = (
answer: string | ((prompt: string) => string), answer: string | ((prompt: string) => string),
opts?: MockChatGPTResponseOpts opts?: MockLLMResponseOpts
) { ) => {
return nock(opts?.host || "https://api.openai.com") return nock(opts?.host || "https://api.openai.com")
.post("/v1/chat/completions") .post("/v1/chat/completions")
.reply(200, (uri: string, requestBody: ChatCompletionRequest) => { .reply((uri: string, body: nock.Body) => {
const messages = requestBody.messages const req = body as ChatCompletionRequest
const messages = req.messages
const prompt = messages[0].content const prompt = messages[0].content
let content let content
if (typeof answer === "function") { if (typeof answer === "function") {
content = answer(prompt) try {
content = answer(prompt)
} catch (e) {
return [500, "Internal Server Error"]
}
} else { } else {
content = answer content = answer
} }
@ -76,7 +78,7 @@ export function mockChatGPTResponse(
id: `chatcmpl-${chatID}`, id: `chatcmpl-${chatID}`,
object: "chat.completion", object: "chat.completion",
created: Math.floor(Date.now() / 1000), created: Math.floor(Date.now() / 1000),
model: requestBody.model, model: req.model,
system_fingerprint: `fp_${chatID}`, system_fingerprint: `fp_${chatID}`,
choices: [ choices: [
{ {
@ -97,14 +99,7 @@ export function mockChatGPTResponse(
}, },
}, },
} }
return response return [200, response]
}) })
.persist() .persist()
} }
export function mockChatGPTError() {
return nock("https://api.openai.com")
.post("/v1/chat/completions")
.reply(500, "Internal Server Error")
.persist()
}

View File

@ -1,5 +1,18 @@
import { EnrichedBinding } from "../../ui" import { EnrichedBinding } from "../../ui"
export interface Message {
role: "system" | "user"
content: string
}
export interface ChatCompletionRequest {
messages: Message[]
}
export interface ChatCompletionResponse {
message?: string
}
export interface GenerateJsRequest { export interface GenerateJsRequest {
prompt: string prompt: string
bindings?: EnrichedBinding[] bindings?: EnrichedBinding[]
@ -8,3 +21,11 @@ export interface GenerateJsRequest {
export interface GenerateJsResponse { export interface GenerateJsResponse {
code: string code: string
} }
export interface GenerateCronRequest {
prompt: string
}
export interface GenerateCronResponse {
message?: string
}

View File

@ -1,4 +1,5 @@
import { Document } from "../document" import { Document } from "../document"
import { FieldSchema, FormulaType } from "./table"
export enum FieldType { export enum FieldType {
/** /**
@ -147,6 +148,15 @@ export function isNumeric(type: FieldType) {
return NumericTypes.includes(type) return NumericTypes.includes(type)
} }
export function isNumericStaticFormula(schema: FieldSchema) {
return (
schema.type === FieldType.FORMULA &&
schema.formulaType === FormulaType.STATIC &&
schema.responseType &&
isNumeric(schema.responseType)
)
}
export const GroupByTypes = [ export const GroupByTypes = [
FieldType.STRING, FieldType.STRING,
FieldType.LONGFORM, FieldType.LONGFORM,

View File

@ -117,6 +117,7 @@ export type AIProvider =
| "AzureOpenAI" | "AzureOpenAI"
| "TogetherAI" | "TogetherAI"
| "Custom" | "Custom"
| "BudibaseAI"
export interface ProviderConfig { export interface ProviderConfig {
provider: AIProvider provider: AIProvider