Merge branch 'master' into feat/ai-license-key-fix

This commit is contained in:
Peter Clement 2025-04-29 15:29:50 +01:00 committed by GitHub
commit eda4e2022f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 620 additions and 293 deletions

View File

@ -9,6 +9,7 @@ export enum Cookie {
ACCOUNT_RETURN_URL = "budibase:account:returnurl", ACCOUNT_RETURN_URL = "budibase:account:returnurl",
DatasourceAuth = "budibase:datasourceauth", DatasourceAuth = "budibase:datasourceauth",
OIDC_CONFIG = "budibase:oidc:config", OIDC_CONFIG = "budibase:oidc:config",
FeatureFlags = "budibase:featureflags",
} }
export { Header } from "@budibase/shared-core" export { Header } from "@budibase/shared-core"

View File

@ -460,6 +460,17 @@ export function setFeatureFlags(key: string, value: Record<string, boolean>) {
context.featureFlagCache[key] = value context.featureFlagCache[key] = value
} }
export function getFeatureFlagOverrides(): Record<string, boolean> {
return getCurrentContext()?.featureFlagOverrides || {}
}
export async function doInFeatureFlagOverrideContext<T>(
value: Record<string, boolean>,
callback: () => Promise<T>
) {
return await newContext({ featureFlagOverrides: value }, callback)
}
export function getTableForView(viewId: string): Table | undefined { export function getTableForView(viewId: string): Table | undefined {
const context = getCurrentContext() const context = getCurrentContext()
if (!context) { if (!context) {

View File

@ -24,5 +24,6 @@ export type ContextMap = {
featureFlagCache?: { featureFlagCache?: {
[key: string]: Record<string, boolean> [key: string]: Record<string, boolean>
} }
featureFlagOverrides?: Record<string, boolean>
viewToTableCache?: Record<string, Table> viewToTableCache?: Record<string, Table>
} }

View File

@ -1,5 +1,7 @@
// BASE // BASE
import { ErrorCode } from "@budibase/types"
export abstract class BudibaseError extends Error { export abstract class BudibaseError extends Error {
code: string code: string
@ -13,13 +15,6 @@ export abstract class BudibaseError extends Error {
// ERROR HANDLING // ERROR HANDLING
export enum ErrorCode {
USAGE_LIMIT_EXCEEDED = "usage_limit_exceeded",
FEATURE_DISABLED = "feature_disabled",
INVALID_API_KEY = "invalid_api_key",
HTTP = "http",
}
/** /**
* For the given error, build the public representation that is safe * For the given error, build the public representation that is safe
* to be exposed over an api. * to be exposed over an api.

View File

@ -175,6 +175,21 @@ export class FlagSet<T extends { [name: string]: boolean }> {
} }
} }
const overrides = context.getFeatureFlagOverrides()
for (const [key, value] of Object.entries(overrides)) {
if (!this.isFlagName(key)) {
continue
}
if (typeof value !== "boolean") {
continue
}
// @ts-expect-error - TS does not like you writing into a generic type.
flagValues[key] = value
tags[`flags.${key}.source`] = "override"
}
context.setFeatureFlags(this.setId, flagValues) context.setFeatureFlags(this.setId, flagValues)
for (const [key, value] of Object.entries(flagValues)) { for (const [key, value] of Object.entries(flagValues)) {
tags[`flags.${key}.value`] = value tags[`flags.${key}.value`] = value

View File

@ -16,11 +16,12 @@ import env from "../environment"
import { import {
Ctx, Ctx,
EndpointMatcher, EndpointMatcher,
ErrorCode,
LoginMethod, LoginMethod,
SessionCookie, SessionCookie,
User, User,
} from "@budibase/types" } from "@budibase/types"
import { ErrorCode, InvalidAPIKeyError } from "../errors" import { InvalidAPIKeyError } from "../errors"
import tracer from "dd-trace" import tracer from "dd-trace"
import type { Middleware, Next } from "koa" import type { Middleware, Next } from "koa"

View File

@ -0,0 +1,13 @@
import { Ctx, FeatureFlagCookie } from "@budibase/types"
import { Middleware, Next } from "koa"
import { getCookie } from "../utils"
import { Cookie } from "../constants"
import { doInFeatureFlagOverrideContext } from "../context"
export default (async (ctx: Ctx, next: Next) => {
const cookie = getCookie<FeatureFlagCookie>(ctx, Cookie.FeatureFlags)
const flags = cookie?.flags || {}
await doInFeatureFlagOverrideContext(flags, async () => {
await next()
})
}) as Middleware

View File

@ -20,5 +20,6 @@ export { default as correlation } from "../logging/correlation/middleware"
export { default as errorHandling } from "./errorHandling" export { default as errorHandling } from "./errorHandling"
export { default as querystringToBody } from "./querystringToBody" export { default as querystringToBody } from "./querystringToBody"
export { default as csp } from "./contentSecurityPolicy" export { default as csp } from "./contentSecurityPolicy"
export { default as featureFlagCookie } from "./featureFlagCookie"
export * as joiValidator from "./joi-validator" export * as joiValidator from "./joi-validator"
export { default as ip } from "./ip" export { default as ip } from "./ip"

View File

@ -8,7 +8,7 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { API } from "@/api" import { API } from "@/api"
import { goto } from "@roxi/routify" import { goto, params, isActive } from "@roxi/routify"
import { import {
automationStore, automationStore,
previewStore, previewStore,
@ -19,54 +19,36 @@
queries, queries,
tables, tables,
views, views,
viewsV2,
} from "@/stores/builder" } from "@/stores/builder"
import { themeStore } from "@/stores/portal" import { themeStore, featureFlags } from "@/stores/portal"
import { getContext } from "svelte" import { getContext } from "svelte"
import { ThemeOptions } from "@budibase/shared-core" import { ThemeOptions } from "@budibase/shared-core"
import { FeatureFlag } from "@budibase/types"
const modalContext = getContext(Context.Modal) const modalContext = getContext(Context.Modal)
const commands = [
let search
let selected = null
$: inApp = $isActive("/builder/app/:application")
$: commands = [
{ {
type: "Access", type: "Access",
name: "Invite users and manage app access", name: "Invite users and manage app access",
description: "", description: "",
icon: "User", icon: "User",
action: () => builderStore.showBuilderSidePanel(), action: () => builderStore.showBuilderSidePanel(),
requiresApp: true,
}, },
{ ...navigationCommands(),
type: "Navigate",
name: "Portal",
description: "",
icon: "Compass",
action: () => $goto("../../portal"),
},
{
type: "Navigate",
name: "Data",
description: "",
icon: "Compass",
action: () => $goto("./data"),
},
{
type: "Navigate",
name: "Design",
description: "",
icon: "Compass",
action: () => $goto("./design"),
},
{
type: "Navigate",
name: "Automations",
description: "",
icon: "Compass",
action: () => $goto("./automation"),
},
{ {
type: "Publish", type: "Publish",
name: "App", name: "App",
description: "Deploy your application", description: "Deploy your application",
icon: "Box", icon: "Box",
action: deployApp, action: deployApp,
requiresApp: true,
}, },
{ {
type: "Preview", type: "Preview",
@ -74,12 +56,14 @@
description: "", description: "",
icon: "Play", icon: "Play",
action: () => previewStore.showPreview(true), action: () => previewStore.showPreview(true),
requiresApp: true,
}, },
{ {
type: "Preview", type: "Preview",
name: "Published App", name: "Published App",
icon: "Play", icon: "Play",
action: () => window.open(`/app${$appStore.url}`), action: () => window.open(`/app${$appStore.url}`),
requiresApp: true,
}, },
{ {
type: "Support", type: "Support",
@ -87,6 +71,7 @@
icon: "Help", icon: "Help",
action: () => action: () =>
window.open(`https://github.com/Budibase/budibase/discussions/new`), window.open(`https://github.com/Budibase/budibase/discussions/new`),
requiresApp: true,
}, },
{ {
type: "Support", type: "Support",
@ -96,52 +81,166 @@
window.open( window.open(
`https://github.com/Budibase/budibase/issues/new?assignees=&labels=bug&template=bug_report.md&title=` `https://github.com/Budibase/budibase/issues/new?assignees=&labels=bug&template=bug_report.md&title=`
), ),
requiresApp: true,
}, },
...($datasources?.list?.map(datasource => ({ ...datasourceCommands($datasources?.list || []),
...tableCommands($tables?.list || []),
...viewCommands($views?.list || []),
...viewV2Commands($viewsV2?.list || []),
...queryCommands($queries?.list || []),
...screenCommands($sortedScreens),
...automationCommands($automationStore?.automations || []),
...themeCommands(),
...featureFlagCommands($featureFlags),
]
$: enrichedCommands = commands.map(cmd => ({
...cmd,
searchValue: `${cmd.type} ${cmd.name}`.toLowerCase().replace(/_/g, " "),
}))
$: results = filterResults(enrichedCommands, search, inApp)
$: categories = groupResults(results)
const navigationCommands = () => {
const routes = [
{
name: "Portal",
url: "/builder/portal",
},
{
name: "Data",
url: "/builder/app/:application/data",
},
{
name: "Design",
url: "/builder/app/:application/design",
},
{
name: "Automations",
url: "/builder/app/:application/automation",
},
{
name: "Settings",
url: "/builder/app/:application/settings",
},
]
return routes.map(route => ({
type: "Navigate",
name: route.name,
icon: "Compass",
action: () => {
const gotoParams = route.url.includes(":application")
? { application: $params.application }
: {}
$goto(route.url, gotoParams)
},
requiresApp: true,
}))
}
const datasourceCommands = datasources => {
return datasources.map(datasource => ({
type: "Datasource", type: "Datasource",
name: `${datasource.name}`, name: datasource.name,
icon: "Data", icon: "Data",
action: () => $goto(`./data/datasource/${datasource._id}`), action: () =>
})) ?? []), $goto(`/builder/app/:application/data/datasource/:id`, {
...($tables?.list?.map(table => ({ application: $params.application,
id: datasource._id,
}),
requiresApp: true,
}))
}
const tableCommands = tables => {
return tables.map(table => ({
type: "Table", type: "Table",
name: table.name, name: table.name,
icon: "Table", icon: "Table",
action: () => $goto(`./data/table/${table._id}`), action: () =>
})) ?? []), $goto(`/builder/app/:application/data/table/:id`, {
...($views?.list?.map(view => ({ application: $params.application,
id: table._id,
}),
requiresApp: true,
}))
}
const viewCommands = views => {
return views.map(view => ({
type: "View", type: "View",
name: view.name, name: view.name,
icon: "Remove", icon: "Remove",
action: () => { action: () => {
if (view.version === 2) { $goto(`/builder/app/:application/data/view/:name`, {
$goto(`./data/view/v2/${view.id}`) application: $params.application,
} else { name: view.name,
$goto(`./data/view/${view.name}`) })
}
}, },
})) ?? []), requiresApp: true,
...($queries?.list?.map(query => ({ }))
}
const viewV2Commands = views => {
return views.map(view => ({
type: "View",
name: view.name,
icon: "Remove",
action: () => {
$goto(`/builder/app/:application/data/table/:tableId/:viewId`, {
application: $params.application,
x: view.tableId,
viewId: view.id,
})
},
requiresApp: true,
}))
}
const queryCommands = queries => {
return queries.map(query => ({
type: "Query", type: "Query",
name: query.name, name: query.name,
icon: "SQLQuery", icon: "SQLQuery",
action: () => $goto(`./data/query/${query._id}`), action: () =>
})) ?? []), $goto(`/builder/app/:application/data/query/:id`, {
...$sortedScreens.map(screen => ({ application: $params.application,
id: query._id,
}),
requiresApp: true,
}))
}
const screenCommands = screens => {
return screens.map(screen => ({
type: "Screen", type: "Screen",
name: screen.routing.route, name: screen.routing.route,
icon: "WebPage", icon: "WebPage",
action: () => { action: () =>
$goto(`./design/${screen._id}/${screen._id}-screen`) $goto(`/builder/app/:application/design/:screenId/:componentId`, {
}, application: $params.application,
})), screenId: screen._id,
...($automationStore?.automations?.map(automation => ({ componentId: `${screen._id}-screen`,
}),
requiresApp: true,
}))
}
const automationCommands = automations => {
return automations.map(automation => ({
type: "Automation", type: "Automation",
name: automation.name, name: automation.name,
icon: "ShareAndroid", icon: "ShareAndroid",
action: () => $goto(`./automation/${automation._id}`), action: () =>
})) ?? []), $goto(`/builder/app/:application/automation/:id`, {
...ThemeOptions.map(themeMeta => ({ application: $params.application,
id: automation._id,
}),
requiresApp: true,
}))
}
const themeCommands = () => {
return ThemeOptions.map(themeMeta => ({
type: "Change Builder Theme", type: "Change Builder Theme",
name: themeMeta.name, name: themeMeta.name,
icon: "ColorPalette", icon: "ColorPalette",
@ -150,28 +249,41 @@
state.theme = themeMeta.id state.theme = themeMeta.id
return state return state
}), }),
})), }))
] }
let search const featureFlagCommands = flags => {
let selected = null if (!flags.DEBUG_UI) {
return []
$: enrichedCommands = commands.map(cmd => ({ }
...cmd, return Object.entries(flags)
searchValue: `${cmd.type} ${cmd.name}`.toLowerCase(), .filter(([flag]) => flag !== FeatureFlag.DEBUG_UI)
})) .map(([flag, value]) => ({
$: results = filterResults(enrichedCommands, search) type: "Feature Flag",
$: categories = groupResults(results) name: `${value ? "Disable" : "Enable"} <code>${flag}</code>`,
icon: "Flag",
const filterResults = (commands, search) => { action: () => {
if (!search) { featureFlags.setFlag(flag, !value)
selected = null },
return commands }))
}
const filterResults = (commands, search, inApp) => {
if (search) {
selected = 0
search = search.toLowerCase().replace(/_/g, " ")
} else {
selected = null
} }
selected = 0
search = search.toLowerCase()
return commands return commands
.filter(cmd => cmd.searchValue.includes(search)) .filter(cmd => {
// Handle searching
if (search && !cmd.searchValue.includes(search)) {
return false
}
// Handle commands that require an app
return inApp || !cmd.requiresApp
})
.map((cmd, idx) => ({ .map((cmd, idx) => ({
...cmd, ...cmd,
idx, idx,
@ -264,7 +376,8 @@
<Icon size="M" name={command.icon} /> <Icon size="M" name={command.icon} />
<strong>{command.type}:&nbsp;</strong> <strong>{command.type}:&nbsp;</strong>
<div class="name"> <div class="name">
{command.name} <!--eslint-disable-next-line svelte/no-at-html-tags-->
{@html command.name}
</div> </div>
</div> </div>
{/each} {/each}
@ -339,4 +452,10 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.name :global(code) {
font-size: 12px;
background: var(--background-alt);
padding: 4px;
border-radius: 4px;
}
</style> </style>

View File

@ -3,7 +3,7 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { API } from "@/api" import { API } from "@/api"
import type { EnrichedBinding } from "@budibase/types" import { ErrorCode, type EnrichedBinding } from "@budibase/types"
import analytics, { Events } from "@/analytics" import analytics, { Events } from "@/analytics"
import AiInput from "../ai/AIInput.svelte" import AiInput from "../ai/AIInput.svelte"
@ -43,17 +43,26 @@
const resp = await API.generateJs({ prompt, bindings }) const resp = await API.generateJs({ prompt, bindings })
const code = resp.code const code = resp.code
if (code === "") { if (code === "") {
throw new Error("We didn't understand your prompt. Please rephrase it.") throw new Error(
"We didn't understand your prompt. This can happen if the prompt isn't specific, or if it's a request for something other than code. Try expressing your request in a different way."
)
} }
suggestedCode = code suggestedCode = code
dispatch("update", { code }) dispatch("update", { code })
} catch (e) { } catch (e) {
console.error(e) console.error(e)
notifications.error( if (!(e instanceof Error)) {
e instanceof Error notifications.error("Unable to generate code. Please try again later.")
? `Unable to generate code: ${e.message}` return
: "Unable to generate code. Please try again later." }
)
if ("code" in e && e.code === ErrorCode.USAGE_LIMIT_EXCEEDED) {
notifications.error(
"Monthly usage limit reached. We're exploring options to expand this soon. Questions? Contact support@budibase.com"
)
} else {
notifications.error(`Unable to generate code: ${e.message}`)
}
} }
} }
@ -78,6 +87,8 @@
function reset() { function reset() {
suggestedCode = null suggestedCode = null
previousContents = null previousContents = null
promptText = ""
expanded = false
} }
function calculateExpandedWidth() { function calculateExpandedWidth() {
@ -103,7 +114,6 @@
placeholder="Generate with AI" placeholder="Generate with AI"
onSubmit={generateJs} onSubmit={generateJs}
bind:expanded bind:expanded
on:collapse={rejectSuggestion}
readonly={!!suggestedCode} readonly={!!suggestedCode}
{expandedOnly} {expandedOnly}
/> />
@ -121,21 +131,11 @@
overflow: visible; overflow: visible;
} }
@keyframes border-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.floating-actions { .floating-actions {
position: absolute; position: absolute;
display: flex; display: flex;
gap: var(--spacing-s); gap: var(--spacing-s);
bottom: calc(100% + 5px); bottom: calc(100% + 5px);
left: 5px;
z-index: 2; z-index: 2;
animation: fade-in 0.2s ease-out forwards; animation: fade-in 0.2s ease-out forwards;
} }

View File

@ -9,7 +9,6 @@
import { Label } from "@budibase/bbui" import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher, onDestroy } from "svelte" import { onMount, createEventDispatcher, onDestroy } from "svelte"
import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates" import { FIND_ANY_HBS_REGEX } from "@budibase/string-templates"
import { import {
autocompletion, autocompletion,
closeBrackets, closeBrackets,
@ -52,17 +51,12 @@
import type { Extension } from "@codemirror/state" import type { Extension } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./" import { EditorModes } from "./"
import { themeStore } from "@/stores/portal" import { featureFlags, themeStore } from "@/stores/portal"
import { import { type EnrichedBinding, type EditorMode } from "@budibase/types"
type EnrichedBinding,
FeatureFlag,
type EditorMode,
} from "@budibase/types"
import { tooltips } from "@codemirror/view" import { tooltips } from "@codemirror/view"
import type { BindingCompletion, CodeValidator } from "@/types" import type { BindingCompletion, CodeValidator } from "@/types"
import { validateHbsTemplate } from "./validator/hbs" import { validateHbsTemplate } from "./validator/hbs"
import { validateJsTemplate } from "./validator/js" import { validateJsTemplate } from "./validator/js"
import { featureFlag } from "@/helpers"
import AIGen from "./AIGen.svelte" import AIGen from "./AIGen.svelte"
export let label: string | undefined = undefined export let label: string | undefined = undefined
@ -88,6 +82,7 @@
let isEditorInitialised = false let isEditorInitialised = false
let queuedRefresh = false let queuedRefresh = false
let editorWidth: number | null = null let editorWidth: number | null = null
let isAIGeneratedContent = false
// Theming! // Theming!
let currentTheme = $themeStore?.theme let currentTheme = $themeStore?.theme
@ -101,9 +96,7 @@
} }
$: aiGenEnabled = $: aiGenEnabled =
featureFlag.isEnabled(FeatureFlag.AI_JS_GENERATION) && $featureFlags.AI_JS_GENERATION && mode.name === "javascript" && !readonly
mode.name === "javascript" &&
!readonly
$: { $: {
if (autofocus && isEditorInitialised) { if (autofocus && isEditorInitialised) {
@ -429,6 +422,7 @@
editor.dispatch({ editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code }, changes: { from: 0, to: editor.state.doc.length, insert: code },
}) })
isAIGeneratedContent = true
} }
onMount(() => { onMount(() => {
@ -462,7 +456,12 @@
</div> </div>
{/if} {/if}
<div class={`code-editor ${mode?.name || ""}`} bind:this={editorEle}> <div
class={`code-editor ${mode?.name || ""} ${
isAIGeneratedContent ? "ai-generated" : ""
}`}
bind:this={editorEle}
>
<div tabindex="-1" bind:this={textarea} /> <div tabindex="-1" bind:this={textarea} />
</div> </div>
@ -475,6 +474,7 @@
on:accept={() => { on:accept={() => {
dispatch("change", editor.state.doc.toString()) dispatch("change", editor.state.doc.toString())
dispatch("blur", editor.state.doc.toString()) dispatch("blur", editor.state.doc.toString())
isAIGeneratedContent = false
}} }}
on:reject={event => { on:reject={event => {
const { code } = event.detail const { code } = event.detail
@ -482,6 +482,7 @@
editor.dispatch({ editor.dispatch({
changes: { from: 0, to: editor.state.doc.length, insert: code || "" }, changes: { from: 0, to: editor.state.doc.length, insert: code || "" },
}) })
isAIGeneratedContent = false
}} }}
/> />
{/if} {/if}
@ -691,4 +692,19 @@
text-overflow: ellipsis !important; text-overflow: ellipsis !important;
white-space: nowrap !important; white-space: nowrap !important;
} }
.code-editor.ai-generated :global(.cm-editor) {
background: var(--spectrum-global-color-blue-50) !important;
}
.code-editor.ai-generated :global(.cm-content) {
background: transparent !important;
}
.code-editor.ai-generated :global(.cm-line) {
background: #765ffe1a !important;
display: inline-block;
min-width: fit-content;
padding-right: 2px !important;
}
</style> </style>

View File

@ -14,7 +14,6 @@
export const submit = onPromptSubmit export const submit = onPromptSubmit
$: expanded = expandedOnly || expanded $: expanded = expandedOnly || expanded
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let promptInput: HTMLInputElement let promptInput: HTMLInputElement
@ -28,7 +27,7 @@
$: aiEnabled = $auth?.user?.llm $: aiEnabled = $auth?.user?.llm
$: creditsExceeded = $licensing.aiCreditsExceeded $: creditsExceeded = $licensing.aiCreditsExceeded
$: disabled = !aiEnabled || creditsExceeded || readonly || promptLoading $: disabled = !aiEnabled || creditsExceeded || readonly
$: animateBorder = !disabled && expanded $: animateBorder = !disabled && expanded
$: canSubmit = !readonly && !!value $: canSubmit = !readonly && !!value
@ -91,6 +90,7 @@
src={BBAI} src={BBAI}
alt="AI" alt="AI"
class="ai-icon" class="ai-icon"
class:loading={promptLoading}
class:disabled={expanded && disabled} class:disabled={expanded && disabled}
on:click={e => { on:click={e => {
e.stopPropagation() e.stopPropagation()
@ -105,7 +105,7 @@
class="prompt-input" class="prompt-input"
{placeholder} {placeholder}
on:keydown={handleKeyPress} on:keydown={handleKeyPress}
{disabled} disabled={disabled || promptLoading}
/> />
{:else} {:else}
<span class="spectrum-ActionButton-label ai-gen-text"> <span class="spectrum-ActionButton-label ai-gen-text">
@ -153,11 +153,11 @@
</Modal> </Modal>
{:else} {:else}
<Icon <Icon
color={promptLoading color={promptInput
? "#6E56FF" ? "#6E56FF"
: "var(--spectrum-global-color-gray-600)"} : "var(--spectrum-global-color-gray-600)"}
size="S" size="S"
disabled={!canSubmit} disabled={!canSubmit || promptLoading}
hoverable={!readonly} hoverable={!readonly}
hoverColor="#6E56FF" hoverColor="#6E56FF"
name={promptLoading ? "StopCircle" : "PlayCircle"} name={promptLoading ? "StopCircle" : "PlayCircle"}
@ -170,14 +170,12 @@
<style> <style>
.spectrum-ActionButton { .spectrum-ActionButton {
--offset: 1px;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
box-sizing: border-box; box-sizing: border-box;
padding: var(--spacing-s); padding: var(--spacing-s);
border: 1px solid var(--spectrum-alias-border-color);
border-radius: 30px; border-radius: 30px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1); transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
width: 100%; width: 100%;
@ -185,16 +183,30 @@
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
background-color: var(--spectrum-global-color-gray-75); background-color: var(--spectrum-global-color-gray-75);
border: none;
} }
.spectrum-ActionButton::before { .spectrum-ActionButton::before {
content: ""; content: "";
position: absolute; position: absolute;
top: -1px; inset: 0;
left: -1px; border-radius: 30px;
width: calc(100% + 2px); padding: 1px;
height: calc(100% + 2px); background: var(--spectrum-alias-border-color);
border-radius: inherit; -webkit-mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.animate-border::after {
content: "";
position: absolute;
inset: 0;
border-radius: 30px;
padding: 1px;
background: linear-gradient( background: linear-gradient(
125deg, 125deg,
transparent -10%, transparent -10%,
@ -204,40 +216,42 @@
transparent 35%, transparent 35%,
transparent 110% transparent 110%
); );
-webkit-mask: linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none; pointer-events: none;
z-index: 0; animation: border-flow 1.5s cubic-bezier(0.17, 0.67, 0.83, 0.67) forwards;
} }
.spectrum-ActionButton:not(.animate-border)::before { @keyframes border-flow {
0% {
clip-path: polygon(0% 0%, 10% 0%, 8% 100%, 0% 100%);
}
30% {
clip-path: polygon(0% 0%, 35% 0%, 26% 100%, 0% 100%);
}
50% {
clip-path: polygon(0% 0%, 55% 0%, 41% 100%, 0% 100%);
}
70% {
clip-path: polygon(0% 0%, 70% 0%, 53% 100%, 0% 100%);
}
85% {
clip-path: polygon(0% 0%, 80% 0%, 60% 100%, 0% 100%);
}
95% {
clip-path: polygon(0% 0%, 86% 0%, 65% 100%, 0% 100%);
}
100% {
clip-path: polygon(0% 0%, 90% 0%, 68% 100%, 0% 100%);
}
}
.spectrum-ActionButton:not(.animate-border)::after {
content: none; content: none;
} }
.animate-border::before {
animation: border-fade-in 1s cubic-bezier(0.17, 0.67, 0.83, 0.67);
animation-fill-mode: forwards;
}
@keyframes border-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.spectrum-ActionButton::after {
content: "";
background: inherit;
position: absolute;
top: 50%;
left: 50%;
inset: var(--offset);
height: calc(100% - 2 * var(--offset));
width: calc(100% - 2 * var(--offset));
border-radius: inherit;
}
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;
@ -268,10 +282,12 @@
.ai-icon { .ai-icon {
width: 18px; width: 18px;
height: 18px; height: 18px;
margin-left: 4px; margin-left: var(--spacing-xs);
margin-right: 8px; margin-right: var(--spacing-s);
flex-shrink: 0; flex-shrink: 0;
cursor: var(--ai-icon-cursor, pointer); cursor: var(--ai-icon-cursor, pointer);
position: relative;
z-index: 2;
} }
.ai-gen-text { .ai-gen-text {
@ -280,10 +296,12 @@
text-overflow: ellipsis; text-overflow: ellipsis;
transition: opacity 0.2s ease-out; transition: opacity 0.2s ease-out;
margin-right: var(--spacing-xs); margin-right: var(--spacing-xs);
position: relative;
z-index: 2;
} }
.prompt-input { .prompt-input {
font-size: 14px; font-size: inherit;
flex: 1; flex: 1;
border: none; border: none;
background: transparent; background: transparent;
@ -293,6 +311,8 @@
min-width: 0; min-width: 0;
resize: none; resize: none;
overflow: hidden; overflow: hidden;
position: relative;
z-index: 2;
} }
.prompt-input::placeholder { .prompt-input::placeholder {
@ -303,14 +323,15 @@
.action-buttons { .action-buttons {
display: flex; display: flex;
gap: var(--spacing-s); gap: var(--spacing-s);
z-index: 4; z-index: 5;
flex-shrink: 0; flex-shrink: 0;
margin-right: var(--spacing-s); margin-right: var(--spacing-s);
position: relative;
} }
.button-content-wrapper { .button-content-wrapper {
position: relative; position: relative;
z-index: 1; z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
overflow: hidden; overflow: hidden;
@ -328,4 +349,17 @@
filter: grayscale(1) brightness(1.5); filter: grayscale(1) brightness(1.5);
opacity: 0.5; opacity: 0.5;
} }
.ai-icon.loading {
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style> </style>

View File

@ -6,8 +6,11 @@
import { API } from "@/api" import { API } from "@/api"
import Branding from "./Branding.svelte" import Branding from "./Branding.svelte"
import ContextMenu from "@/components/ContextMenu.svelte" import ContextMenu from "@/components/ContextMenu.svelte"
import CommandPalette from "@/components/commandPalette/CommandPalette.svelte"
import { Modal } from "@budibase/bbui"
let loaded = false let loaded = false
let commandPaletteModal
$: multiTenancyEnabled = $admin.multiTenancy $: multiTenancyEnabled = $admin.multiTenancy
$: hasAdminUser = $admin?.checklist?.adminUser?.checked $: hasAdminUser = $admin?.checklist?.adminUser?.checked
@ -157,12 +160,25 @@
} }
} }
} }
// Event handler for the command palette
const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
commandPaletteModal.toggle()
}
}
</script> </script>
<!--Portal branding overrides --> <!--Portal branding overrides -->
<Branding /> <Branding />
<ContextMenu /> <ContextMenu />
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette />
</Modal>
{#if loaded} {#if loaded}
<slot /> <slot />
{/if} {/if}

View File

@ -14,7 +14,6 @@
Tabs, Tabs,
Tab, Tab,
Heading, Heading,
Modal,
notifications, notifications,
TooltipPosition, TooltipPosition,
} from "@budibase/bbui" } from "@budibase/bbui"
@ -24,7 +23,6 @@
import { capitalise } from "@/helpers" import { capitalise } from "@/helpers"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte" import VerificationPromptBanner from "@/components/common/VerificationPromptBanner.svelte"
import CommandPalette from "@/components/commandPalette/CommandPalette.svelte"
import TourWrap from "@/components/portal/onboarding/TourWrap.svelte" import TourWrap from "@/components/portal/onboarding/TourWrap.svelte"
import TourPopover from "@/components/portal/onboarding/TourPopover.svelte" import TourPopover from "@/components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte" import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
@ -38,7 +36,6 @@
let promise = getPackage() let promise = getPackage()
let hasSynced = false let hasSynced = false
let commandPaletteModal
let loaded = false let loaded = false
$: loaded && initTour() $: loaded && initTour()
@ -79,14 +76,6 @@
$goto($builderStore.previousTopNavPath[path] || path) $goto($builderStore.previousTopNavPath[path] || path)
} }
// Event handler for the command palette
const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
commandPaletteModal.toggle()
}
}
const initTour = async () => { const initTour = async () => {
// Check if onboarding is enabled. // Check if onboarding is enabled.
if (!$auth.user?.onboardedAt) { if (!$auth.user?.onboardedAt) {
@ -184,11 +173,6 @@
<PreviewOverlay /> <PreviewOverlay />
{/if} {/if}
<svelte:window on:keydown={handleKeyDown} />
<Modal bind:this={commandPaletteModal} zIndex={999999}>
<CommandPalette />
</Modal>
<EnterpriseBasicTrialModal /> <EnterpriseBasicTrialModal />
<style> <style>

View File

@ -16,8 +16,7 @@
import CreationPage from "@/components/common/CreationPage.svelte" import CreationPage from "@/components/common/CreationPage.svelte"
import ICONS from "@/components/backend/DatasourceNavigator/icons/index.js" import ICONS from "@/components/backend/DatasourceNavigator/icons/index.js"
import AiTableGeneration from "./_components/AITableGeneration.svelte" import AiTableGeneration from "./_components/AITableGeneration.svelte"
import { featureFlag } from "@/helpers" import { featureFlags } from "@/stores/portal"
import { FeatureFlag } from "@budibase/types"
let internalTableModal: CreateInternalTableModal let internalTableModal: CreateInternalTableModal
let externalDatasourceModal: CreateExternalDatasourceModal let externalDatasourceModal: CreateExternalDatasourceModal
@ -26,10 +25,7 @@
let externalDatasourceLoading = false let externalDatasourceLoading = false
$: disabled = sampleDataLoading || externalDatasourceLoading $: disabled = sampleDataLoading || externalDatasourceLoading
$: aiTableGenerationEnabled = $featureFlags.AI_TABLE_GENERATION
$: aiTableGenerationEnabled = featureFlag.isEnabled(
FeatureFlag.AI_TABLE_GENERATION
)
const createSampleData = async () => { const createSampleData = async () => {
sampleDataLoading = true sampleDataLoading = true

View File

@ -17,16 +17,9 @@ import { bindings } from "@/helpers"
import { getBindableProperties } from "@/dataBinding" import { getBindableProperties } from "@/dataBinding"
import { componentStore } from "./components" import { componentStore } from "./components"
import { getSettingsDefinition } from "@budibase/frontend-core" import { getSettingsDefinition } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
function reduceBy<TItem extends {}, TKey extends keyof TItem>( const reduceBy = utils.toMap
key: TKey,
list: TItem[]
): Record<string, TItem> {
return list.reduce<Record<string, TItem>>((result, item) => {
result[item[key] as string] = item
return result
}, {})
}
const friendlyNameByType: Partial<Record<UIDatasourceType, string>> = { const friendlyNameByType: Partial<Record<UIDatasourceType, string>> = {
viewV2: "view", viewV2: "view",

View File

@ -1,8 +1,38 @@
import { derived, Readable } from "svelte/store"
import { auth } from "@/stores/portal" import { auth } from "@/stores/portal"
import { FeatureFlags, FeatureFlagDefaults } from "@budibase/types" import { FeatureFlag, FeatureFlags, FeatureFlagDefaults } from "@budibase/types"
import { BudiStore } from "@/stores/BudiStore"
import { API } from "@/api"
import { notifications } from "@budibase/bbui"
export const featureFlags: Readable<FeatureFlags> = derived(auth, $auth => ({ class FeatureFlagStore extends BudiStore<FeatureFlags> {
...FeatureFlagDefaults, constructor() {
...($auth?.user?.flags || {}), super(FeatureFlagDefaults)
})) auth.subscribe($auth => {
this.set({
...FeatureFlagDefaults,
...($auth?.user?.flags || {}),
})
})
}
async setFlag(flag: FeatureFlag, value: boolean) {
try {
await API.overrideFeatureFlags({
[flag]: value,
})
// The feature flag store is derived from the auth store, so we need to
// refresh the auth store to update the feature flags.
await auth.getSelf()
notifications.success(
`Feature flag ${flag} ${value ? "enabled" : "disabled"}`
)
} catch (e) {
console.error(e)
notifications.error(
`Failed to set feature flag ${flag} to ${value}, see console for details`
)
}
}
}
export const featureFlags = new FeatureFlagStore()

View File

@ -0,0 +1,19 @@
import { OverrideFeatureFlagRequest } from "@budibase/types"
import { BaseAPIClient } from "./types"
export interface FeatureFlagEndpoints {
overrideFeatureFlags: (flags: Record<string, boolean>) => Promise<void>
}
export const buildFeatureFlagEndpoints = (
API: BaseAPIClient
): FeatureFlagEndpoints => ({
overrideFeatureFlags: async flags => {
const body: OverrideFeatureFlagRequest = { flags }
return await API.patch({
url: "/api/features",
body,
parseResponse: () => {},
})
},
})

View File

@ -46,6 +46,7 @@ import { buildLogsEndpoints } from "./logs"
import { buildMigrationEndpoints } from "./migrations" import { buildMigrationEndpoints } from "./migrations"
import { buildRowActionEndpoints } from "./rowActions" import { buildRowActionEndpoints } from "./rowActions"
import { buildOAuth2Endpoints } from "./oauth2" import { buildOAuth2Endpoints } from "./oauth2"
import { buildFeatureFlagEndpoints } from "./features"
export type { APIClient } from "./types" export type { APIClient } from "./types"
@ -289,6 +290,7 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
...buildAuditLogEndpoints(API), ...buildAuditLogEndpoints(API),
...buildLogsEndpoints(API), ...buildLogsEndpoints(API),
...buildMigrationEndpoints(API), ...buildMigrationEndpoints(API),
...buildFeatureFlagEndpoints(API),
viewV2: buildViewV2Endpoints(API), viewV2: buildViewV2Endpoints(API),
rowActions: buildRowActionEndpoints(API), rowActions: buildRowActionEndpoints(API),
oauth2: buildOAuth2Endpoints(API), oauth2: buildOAuth2Endpoints(API),

View File

@ -10,6 +10,7 @@ import { ConfigEndpoints } from "./configs"
import { DatasourceEndpoints } from "./datasources" import { DatasourceEndpoints } from "./datasources"
import { EnvironmentVariableEndpoints } from "./environmentVariables" import { EnvironmentVariableEndpoints } from "./environmentVariables"
import { EventEndpoints } from "./events" import { EventEndpoints } from "./events"
import { FeatureFlagEndpoints } from "./features"
import { FlagEndpoints } from "./flags" import { FlagEndpoints } from "./flags"
import { GroupEndpoints } from "./groups" import { GroupEndpoints } from "./groups"
import { LayoutEndpoints } from "./layouts" import { LayoutEndpoints } from "./layouts"
@ -133,6 +134,7 @@ export type APIClient = BaseAPIClient &
TableEndpoints & TableEndpoints &
TemplateEndpoints & TemplateEndpoints &
UserEndpoints & UserEndpoints &
FeatureFlagEndpoints &
ViewEndpoints & { ViewEndpoints & {
rowActions: RowActionEndpoints rowActions: RowActionEndpoints
viewV2: ViewV2Endpoints viewV2: ViewV2Endpoints

@ -1 +1 @@
Subproject commit 350e18c4aaf482d050143abd01076778a771b69a Subproject commit cf45e608e393a7fc33d69e7a37b918aecac51100

View File

@ -13,11 +13,13 @@ export async function generateTables(
const tableGenerator = await ai.TableGeneration.init({ const tableGenerator = await ai.TableGeneration.init({
generateTablesDelegate: sdk.ai.helpers.generateTables, generateTablesDelegate: sdk.ai.helpers.generateTables,
getTablesDelegate: sdk.tables.getTables,
generateDataDelegate: sdk.ai.helpers.generateRows, generateDataDelegate: sdk.ai.helpers.generateRows,
}) })
tableGenerator.withData(ctx.user._id || "")
const createdTables = await tableGenerator.generate(prompt) const createdTables = await tableGenerator.generate(
prompt,
ctx.user._id || ""
)
ctx.body = { ctx.body = {
createdTables, createdTables,

View File

@ -26,7 +26,6 @@ import {
db as dbCore, db as dbCore,
docIds, docIds,
env as envCore, env as envCore,
ErrorCode,
events, events,
objectStore, objectStore,
roles, roles,
@ -70,6 +69,7 @@ import {
AddAppSampleDataResponse, AddAppSampleDataResponse,
UnpublishAppResponse, UnpublishAppResponse,
SetRevertableAppVersionResponse, SetRevertableAppVersionResponse,
ErrorCode,
} from "@budibase/types" } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"

View File

@ -0,0 +1,26 @@
import {
UserCtx,
OverrideFeatureFlagRequest,
FeatureFlagCookie,
} from "@budibase/types"
import { Cookie, utils } from "@budibase/backend-core"
export async function override(ctx: UserCtx<OverrideFeatureFlagRequest, void>) {
const { flags } = ctx.request.body
let cookie = utils.getCookie<FeatureFlagCookie>(ctx, Cookie.FeatureFlags)
if (!cookie) {
cookie = {
flags: {},
}
}
cookie.flags = {
...cookie.flags,
...flags,
}
utils.setCookie(ctx, cookie, Cookie.FeatureFlags)
ctx.status = 200
}

View File

@ -91,6 +91,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
const result = await finaliseRow(source, row, { const result = await finaliseRow(source, row, {
updateFormula: true, updateFormula: true,
updateAIColumns: true,
}) })
return { ...result, oldRow } return { ...result, oldRow }

View File

@ -81,6 +81,7 @@ export async function updateRelatedFormula(
relatedRows[tableId].map(related => relatedRows[tableId].map(related =>
finaliseRow(relatedTable, related, { finaliseRow(relatedTable, related, {
updateFormula: false, updateFormula: false,
updateAIColumns: false,
}) })
) )
) )
@ -136,10 +137,10 @@ export async function updateAllFormulasInTable(table: Table) {
export async function finaliseRow( export async function finaliseRow(
source: Table | ViewV2, source: Table | ViewV2,
row: Row, row: Row,
opts?: { updateFormula: boolean } opts?: { updateFormula: boolean; updateAIColumns: boolean }
) { ) {
const db = context.getAppDB() const db = context.getAppDB()
const { updateFormula = true } = opts || {} const { updateFormula = true, updateAIColumns = true } = opts || {}
const table = sdk.views.isView(source) const table = sdk.views.isView(source)
? await sdk.views.getTable(source.id) ? await sdk.views.getTable(source.id)
: source : source
@ -155,9 +156,11 @@ export async function finaliseRow(
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
row = await processAIColumns(table, row, { if (updateAIColumns) {
contextRows: [enrichedRow], row = await processAIColumns(table, row, {
}) contextRows: [enrichedRow],
})
}
await db.put(row) await db.put(row)
const retrieved = await db.tryGet<Row>(row._id) const retrieved = await db.tryGet<Row>(row._id)

View File

@ -31,7 +31,7 @@ router.get("/health", async ctx => {
}) })
router.get("/version", ctx => (ctx.body = envCore.VERSION)) router.get("/version", ctx => (ctx.body = envCore.VERSION))
router.use(middleware.errorHandling) router.use(middleware.errorHandling).use(middleware.featureFlagCookie)
// only add the routes if they are enabled // only add the routes if they are enabled
if (apiEnabled()) { if (apiEnabled()) {

View File

@ -0,0 +1,14 @@
import Router from "@koa/router"
import * as controller from "../controllers/features"
import { validateBody } from "../../middleware/zod-validator"
import { z } from "zod"
const router: Router = new Router()
const validator = z.object({
flags: z.record(z.boolean()),
})
router.patch("/api/features", validateBody(validator), controller.override)
export default router

View File

@ -30,6 +30,7 @@ import Router from "@koa/router"
import { api as pro } from "@budibase/pro" import { api as pro } from "@budibase/pro"
import rowActionRoutes from "./rowAction" import rowActionRoutes from "./rowAction"
import oauth2Routes from "./oauth2" import oauth2Routes from "./oauth2"
import featuresRoutes from "./features"
import aiRoutes from "./ai" import aiRoutes from "./ai"
export { default as staticRoutes } from "./static" export { default as staticRoutes } from "./static"
@ -72,6 +73,7 @@ export const mainRoutes: Router[] = [
rowActionRoutes, rowActionRoutes,
proAiRoutes, proAiRoutes,
oauth2Routes, oauth2Routes,
featuresRoutes,
// these need to be handled last as they still use /api/:tableId // these need to be handled last as they still use /api/:tableId
// this could be breaking as koa may recognise other routes as this // this could be breaking as koa may recognise other routes as this
tableRoutes, tableRoutes,

View File

@ -470,7 +470,9 @@ describe("BudibaseAI", () => {
) => ) =>
mockChatGPTResponse(JSON.stringify(aiColumnGeneration), { mockChatGPTResponse(JSON.stringify(aiColumnGeneration), {
format: zodResponseFormat( format: zodResponseFormat(
ai.aiColumnSchemas(generationStructure), ai.aiColumnSchemas(
ai.aiTableResponseToTableSchema(generationStructure)
),
"key" "key"
), ),
}) })
@ -684,7 +686,7 @@ describe("BudibaseAI", () => {
}, },
}, },
], ],
"Employees 2": [ Employees: [
{ {
"First Name": "Joshua", "First Name": "Joshua",
"Last Name": "Lee", "Last Name": "Lee",

View File

@ -1,11 +1,11 @@
import { FieldSchema, FieldType, Table } from "@budibase/types" import { FieldSchema, FieldType, TableSchema } from "@budibase/types"
import sdk from "../../.." import sdk from "../../.."
import { uploadFile, uploadUrl } from "../../../../utilities" import { uploadFile, uploadUrl } from "../../../../utilities"
export async function generateRows( export async function generateRows(
data: Record<string, Record<string, any>[]>, data: Record<string, Record<string, any>[]>,
userId: string, userId: string,
tables: Record<string, Table> tables: Record<string, { _id: string; schema: TableSchema }>
) { ) {
const createdData: Record<string, Record<string, string>> = {} const createdData: Record<string, Record<string, string>> = {}
const toUpdateLinks: { const toUpdateLinks: {
@ -13,6 +13,9 @@ export async function generateRows(
rowId: string rowId: string
data: Record<string, { rowId: string[]; tableId: string }> data: Record<string, { rowId: string[]; tableId: string }>
}[] = [] }[] = []
const rowPromises = []
for (const tableName of Object.keys(data)) { for (const tableName of Object.keys(data)) {
const table = tables[tableName] const table = tables[tableName]
const linksOverride: Record<string, null> = {} const linksOverride: Record<string, null> = {}
@ -26,70 +29,77 @@ export async function generateRows(
[FieldType.ATTACHMENTS, FieldType.ATTACHMENT_SINGLE].includes(f.type) [FieldType.ATTACHMENTS, FieldType.ATTACHMENT_SINGLE].includes(f.type)
) )
for (const entry of data[tableName]) { rowPromises.push(
await processAttachments(entry, attachmentColumns) ...data[tableName].map(async entry => {
await processAttachments(entry, attachmentColumns)
const tableId = table._id! const tableId = table._id!
const createdRow = await sdk.rows.save( const createdRow = await sdk.rows.save(
tableId, tableId,
{ {
...entry, ...entry,
...linksOverride, ...linksOverride,
_id: undefined, _id: undefined,
},
userId
)
createdData[tableId] ??= {}
createdData[tableId][entry._id] = createdRow.row._id!
const overridenLinks = Object.keys(linksOverride).reduce<
Record<string, { rowId: string[]; tableId: string }>
>((acc, l) => {
if (entry[l]) {
acc[l] = {
tableId: (table.schema[l] as any).tableId,
rowId: entry[l],
}
}
return acc
}, {})
if (Object.keys(overridenLinks).length) {
toUpdateLinks.push({
tableId: createdRow.table._id!,
rowId: createdRow.row._id!,
data: overridenLinks,
})
}
})
)
}
await Promise.all(rowPromises)
await Promise.all(
toUpdateLinks.map(async data => {
const persistedRow = await sdk.rows.find(data.tableId, data.rowId)
const updatedLinks = Object.keys(data.data).reduce<Record<string, any>>(
(acc, d) => {
acc[d] = [
...(persistedRow[d] || []),
...data.data[d].rowId.map(
rid => createdData[data.data[d].tableId][rid]
),
]
return acc
}, },
userId {}
) )
createdData[tableId] ??= {} await sdk.rows.save(
createdData[tableId][entry._id] = createdRow.row._id! data.tableId,
{
const overridenLinks = Object.keys(linksOverride).reduce< ...persistedRow,
Record<string, { rowId: string[]; tableId: string }> ...updatedLinks,
>((acc, l) => { },
if (entry[l]) { userId,
acc[l] = { { updateAIColumns: false }
tableId: (table.schema[l] as any).tableId, )
rowId: entry[l], })
} )
}
return acc
}, {})
if (Object.keys(overridenLinks).length) {
toUpdateLinks.push({
tableId: createdRow.table._id!,
rowId: createdRow.row._id!,
data: overridenLinks,
})
}
}
}
for (const data of toUpdateLinks) {
const persistedRow = await sdk.rows.find(data.tableId, data.rowId)
const updatedLinks = Object.keys(data.data).reduce<Record<string, any>>(
(acc, d) => {
acc[d] = [
...(persistedRow[d] || []),
...data.data[d].rowId.map(
rid => createdData[data.data[d].tableId][rid]
),
]
return acc
},
{}
)
await sdk.rows.save(
data.tableId,
{
...persistedRow,
...updatedLinks,
},
userId
)
}
} }
async function processAttachments( async function processAttachments(
entry: Record<string, any>, entry: Record<string, any>,

View File

@ -15,7 +15,8 @@ import { helpers } from "@budibase/shared-core"
export async function save( export async function save(
tableOrViewId: string, tableOrViewId: string,
inputs: Row, inputs: Row,
userId: string | undefined userId: string | undefined,
opts?: { updateAIColumns: boolean }
) { ) {
const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId) const { tableId, viewId } = tryExtractingTableAndViewId(tableOrViewId)
inputs.tableId = tableId inputs.tableId = tableId
@ -57,7 +58,10 @@ export async function save(
table, table,
})) as Row })) as Row
return finaliseRow(source, row, { updateFormula: true }) return finaliseRow(source, row, {
updateFormula: true,
updateAIColumns: opts?.updateAIColumns || true,
})
} }
export async function find(sourceId: string, rowId: string): Promise<Row> { export async function find(sourceId: string, rowId: string): Promise<Row> {

View File

@ -35,9 +35,10 @@ function pickApi(tableOrViewId: string) {
export async function save( export async function save(
sourceId: string, sourceId: string,
row: Row, row: Row,
userId: string | undefined userId: string | undefined,
opts?: { updateAIColumns: boolean }
) { ) {
return pickApi(sourceId).save(sourceId, row, userId) return pickApi(sourceId).save(sourceId, row, userId, opts)
} }
export async function find(sourceId: string, rowId: string) { export async function find(sourceId: string, rowId: string) {

View File

@ -174,3 +174,13 @@ export function processSearchFilters(
], ],
} }
} }
export function toMap<TKey extends keyof TItem, TItem extends {}>(
key: TKey,
list: TItem[]
): Record<string, TItem> {
return list.reduce<Record<string, TItem>>((result, item) => {
result[item[key] as string] = item
return result
}, {})
}

View File

@ -0,0 +1,3 @@
export interface OverrideFeatureFlagRequest {
flags: Record<string, boolean>
}

View File

@ -5,6 +5,7 @@ export * from "./backup"
export * from "./component" export * from "./component"
export * from "./datasource" export * from "./datasource"
export * from "./deployment" export * from "./deployment"
export * from "./features"
export * from "./integration" export * from "./integration"
export * from "./layout" export * from "./layout"
export * from "./metadata" export * from "./metadata"

View File

@ -7,3 +7,7 @@ export interface SessionCookie {
sessionId: string sessionId: string
userId: string userId: string
} }
export interface FeatureFlagCookie {
flags: Record<string, boolean>
}

View File

@ -4,3 +4,10 @@ export interface APIError {
error?: any error?: any
validationErrors?: any validationErrors?: any
} }
export enum ErrorCode {
USAGE_LIMIT_EXCEEDED = "usage_limit_exceeded",
FEATURE_DISABLED = "feature_disabled",
INVALID_API_KEY = "invalid_api_key",
HTTP = "http",
}

View File

@ -1,4 +1,5 @@
export enum FeatureFlag { export enum FeatureFlag {
DEBUG_UI = "DEBUG_UI",
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR", USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
AI_JS_GENERATION = "AI_JS_GENERATION", AI_JS_GENERATION = "AI_JS_GENERATION",
AI_TABLE_GENERATION = "AI_TABLE_GENERATION", AI_TABLE_GENERATION = "AI_TABLE_GENERATION",
@ -14,6 +15,8 @@ export const FeatureFlagDefaults: Record<FeatureFlag, boolean> = {
// Account-portal // Account-portal
[FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false, [FeatureFlag.DIRECT_LOGIN_TO_ACCOUNT_PORTAL]: false,
[FeatureFlag.DEBUG_UI]: false,
} }
export type FeatureFlags = typeof FeatureFlagDefaults export type FeatureFlags = typeof FeatureFlagDefaults

View File

@ -32,14 +32,12 @@ import {
OIDCConfigs, OIDCConfigs,
OIDCLogosConfig, OIDCLogosConfig,
PASSWORD_REPLACEMENT, PASSWORD_REPLACEMENT,
QuotaUsageType,
SaveConfigRequest, SaveConfigRequest,
SaveConfigResponse, SaveConfigResponse,
SettingsBrandingConfig, SettingsBrandingConfig,
SettingsInnerConfig, SettingsInnerConfig,
SSOConfig, SSOConfig,
SSOConfigType, SSOConfigType,
StaticQuotaName,
UploadConfigFileResponse, UploadConfigFileResponse,
UserCtx, UserCtx,
} from "@budibase/types" } from "@budibase/types"
@ -53,7 +51,6 @@ const getEventFns = async (config: Config, existing?: Config) => {
fns.push(events.email.SMTPCreated) fns.push(events.email.SMTPCreated)
} else if (isAIConfig(config)) { } else if (isAIConfig(config)) {
fns.push(() => events.ai.AIConfigCreated) fns.push(() => events.ai.AIConfigCreated)
fns.push(() => pro.quotas.addCustomAIConfig())
} else if (isGoogleConfig(config)) { } else if (isGoogleConfig(config)) {
fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE)) fns.push(() => events.auth.SSOCreated(ConfigType.GOOGLE))
if (config.config.activated) { if (config.config.activated) {
@ -92,12 +89,6 @@ const getEventFns = async (config: Config, existing?: Config) => {
fns.push(events.email.SMTPUpdated) fns.push(events.email.SMTPUpdated)
} else if (isAIConfig(config)) { } else if (isAIConfig(config)) {
fns.push(() => events.ai.AIConfigUpdated) fns.push(() => events.ai.AIConfigUpdated)
if (
Object.keys(existing.config).length > Object.keys(config.config).length
) {
fns.push(() => pro.quotas.removeCustomAIConfig())
}
fns.push(() => pro.quotas.addCustomAIConfig())
} else if (isGoogleConfig(config)) { } else if (isGoogleConfig(config)) {
fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE)) fns.push(() => events.auth.SSOUpdated(ConfigType.GOOGLE))
if (!existing.config.activated && config.config.activated) { if (!existing.config.activated && config.config.activated) {
@ -576,13 +567,6 @@ export async function destroy(ctx: UserCtx<void, DeleteConfigResponse>) {
try { try {
await db.remove(id, rev) await db.remove(id, rev)
await cache.destroy(cache.CacheKey.CHECKLIST) await cache.destroy(cache.CacheKey.CHECKLIST)
if (id === configs.generateConfigID(ConfigType.AI)) {
await pro.quotas.set(
StaticQuotaName.AI_CUSTOM_CONFIGS,
QuotaUsageType.STATIC,
0
)
}
ctx.body = { message: "Config deleted successfully" } ctx.body = { message: "Config deleted successfully" }
} catch (err: any) { } catch (err: any) {
ctx.throw(err.status, err) ctx.throw(err.status, err)

View File

@ -16,6 +16,7 @@ import {
DeleteInviteUsersRequest, DeleteInviteUsersRequest,
DeleteInviteUsersResponse, DeleteInviteUsersResponse,
DeleteUserResponse, DeleteUserResponse,
ErrorCode,
FetchUsersResponse, FetchUsersResponse,
FindUserResponse, FindUserResponse,
GetUserInvitesResponse, GetUserInvitesResponse,
@ -42,7 +43,6 @@ import {
import { import {
users, users,
cache, cache,
ErrorCode,
events, events,
platform, platform,
tenancy, tenancy,

View File

@ -130,6 +130,7 @@ const router: Router = new Router()
router router
.use(middleware.errorHandling) .use(middleware.errorHandling)
.use(middleware.featureFlagCookie)
.use( .use(
compress({ compress({
threshold: 2048, threshold: 2048,