Merge branch 'type-portal-oidc-store' of github.com:Budibase/budibase into type-portal-oidc-store

This commit is contained in:
Andrew Kingston 2025-01-09 08:54:48 +00:00
commit 833cb84f24
No known key found for this signature in database
55 changed files with 802 additions and 794 deletions

View File

@ -19,5 +19,8 @@ jobs:
cache: yarn cache: yarn
- run: yarn --frozen-lockfile - run: yarn --frozen-lockfile
- name: Install OpenAPI pkg
run: yarn global add openapi
- name: update specs - name: update specs
run: cd packages/server && yarn specs && openapi specs/openapi.yaml --key=${{ secrets.README_API_KEY }} --id=6728a74f5918b50036c61841 run: cd packages/server && yarn specs && openapi specs/openapi.yaml --key=${{ secrets.README_API_KEY }} --id=6728a74f5918b50036c61841

View File

@ -10,7 +10,7 @@
}, },
"dependencies": { "dependencies": {
"bulma": "^0.9.3", "bulma": "^0.9.3",
"next": "14.2.15", "next": "14.2.21",
"node-fetch": "^3.2.10", "node-fetch": "^3.2.10",
"sass": "^1.52.3", "sass": "^1.52.3",
"react": "17.0.2", "react": "17.0.2",

View File

@ -46,10 +46,10 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
"@next/env@14.2.15": "@next/env@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.15.tgz#06d984e37e670d93ddd6790af1844aeb935f332f" resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.21.tgz#09ff0813d29c596397e141205d4f5fd5c236bdd0"
integrity sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ== integrity sha512-lXcwcJd5oR01tggjWJ6SrNNYFGuOOMB9c251wUNkjCpkoXOPkDeF/15c3mnVlBqrW4JJXb2kVxDFhC4GduJt2A==
"@next/eslint-plugin-next@12.1.0": "@next/eslint-plugin-next@12.1.0":
version "12.1.0" version "12.1.0"
@ -58,50 +58,50 @@
dependencies: dependencies:
glob "7.1.7" glob "7.1.7"
"@next/swc-darwin-arm64@14.2.15": "@next/swc-darwin-arm64@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz#6386d585f39a1c490c60b72b1f76612ba4434347" resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.21.tgz#32a31992aace1440981df9cf7cb3af7845d94fec"
integrity sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA== integrity sha512-HwEjcKsXtvszXz5q5Z7wCtrHeTTDSTgAbocz45PHMUjU3fBYInfvhR+ZhavDRUYLonm53aHZbB09QtJVJj8T7g==
"@next/swc-darwin-x64@14.2.15": "@next/swc-darwin-x64@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz#b7baeedc6a28f7545ad2bc55adbab25f7b45cb89" resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.21.tgz#5ab4b3f6685b6b52f810d0f5cf6e471480ddffdb"
integrity sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg== integrity sha512-TSAA2ROgNzm4FhKbTbyJOBrsREOMVdDIltZ6aZiKvCi/v0UwFmwigBGeqXDA97TFMpR3LNNpw52CbVelkoQBxA==
"@next/swc-linux-arm64-gnu@14.2.15": "@next/swc-linux-arm64-gnu@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz#fa13c59d3222f70fb4cb3544ac750db2c6e34d02" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.21.tgz#8a0e1fa887aef19ca218af2af515d0a5ee67ba3f"
integrity sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw== integrity sha512-0Dqjn0pEUz3JG+AImpnMMW/m8hRtl1GQCNbO66V1yp6RswSTiKmnHf3pTX6xMdJYSemf3O4Q9ykiL0jymu0TuA==
"@next/swc-linux-arm64-musl@14.2.15": "@next/swc-linux-arm64-musl@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz#30e45b71831d9a6d6d18d7ac7d611a8d646a17f9" resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.21.tgz#ddad844406b42fa8965fe11250abc85c1fe0fd05"
integrity sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ== integrity sha512-Ggfw5qnMXldscVntwnjfaQs5GbBbjioV4B4loP+bjqNEb42fzZlAaK+ldL0jm2CTJga9LynBMhekNfV8W4+HBw==
"@next/swc-linux-x64-gnu@14.2.15": "@next/swc-linux-x64-gnu@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz#5065db17fc86f935ad117483f21f812dc1b39254" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.21.tgz#db55fd666f9ba27718f65caa54b622a912cdd16b"
integrity sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA== integrity sha512-uokj0lubN1WoSa5KKdThVPRffGyiWlm/vCc/cMkWOQHw69Qt0X1o3b2PyLLx8ANqlefILZh1EdfLRz9gVpG6tg==
"@next/swc-linux-x64-musl@14.2.15": "@next/swc-linux-x64-musl@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz#3c4a4568d8be7373a820f7576cf33388b5dab47e" resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.21.tgz#dddb850353624efcd58c4c4e30ad8a1aab379642"
integrity sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ== integrity sha512-iAEBPzWNbciah4+0yI4s7Pce6BIoxTQ0AGCkxn/UBuzJFkYyJt71MadYQkjPqCQCJAFQ26sYh7MOKdU+VQFgPg==
"@next/swc-win32-arm64-msvc@14.2.15": "@next/swc-win32-arm64-msvc@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz#fb812cc4ca0042868e32a6a021da91943bb08b98" resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.21.tgz#290012ee57b196d3d2d04853e6bf0179cae9fbaf"
integrity sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g== integrity sha512-plykgB3vL2hB4Z32W3ktsfqyuyGAPxqwiyrAi2Mr8LlEUhNn9VgkiAl5hODSBpzIfWweX3er1f5uNpGDygfQVQ==
"@next/swc-win32-ia32-msvc@14.2.15": "@next/swc-win32-ia32-msvc@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz#ec26e6169354f8ced240c1427be7fd485c5df898" resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.21.tgz#c959135a78cab18cca588d11d1e33bcf199590d4"
integrity sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ== integrity sha512-w5bacz4Vxqrh06BjWgua3Yf7EMDb8iMcVhNrNx8KnJXt8t+Uu0Zg4JHLDL/T7DkTCEEfKXO/Er1fcfWxn2xfPA==
"@next/swc-win32-x64-msvc@14.2.15": "@next/swc-win32-x64-msvc@14.2.21":
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz#18d68697002b282006771f8d92d79ade9efd35c4" resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.21.tgz#21ff892286555b90538a7d1b505ea21a005d6ead"
integrity sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g== integrity sha512-sT6+llIkzpsexGYZq8cjjthRyRGe5cJVhqh12FmlbxHqna6zsDDK8UNaV7g41T6atFHCJUPeLb3uyAwrBwy0NA==
"@nodelib/fs.scandir@2.1.5": "@nodelib/fs.scandir@2.1.5":
version "2.1.5" version "2.1.5"
@ -1253,12 +1253,12 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
next@14.2.15: next@14.2.21:
version "14.2.15" version "14.2.21"
resolved "https://registry.yarnpkg.com/next/-/next-14.2.15.tgz#348e5603e22649775d19c785c09a89c9acb5189a" resolved "https://registry.yarnpkg.com/next/-/next-14.2.21.tgz#f6da9e2abba1a0e4ca7a5273825daf06632554ba"
integrity sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw== integrity sha512-rZmLwucLHr3/zfDMYbJXbw0ZeoBpirxkXuvsJbk7UPorvPYZhP7vq7aHbKnU7dQNCYIimRrbB2pp3xmf+wsYUg==
dependencies: dependencies:
"@next/env" "14.2.15" "@next/env" "14.2.21"
"@swc/helpers" "0.5.5" "@swc/helpers" "0.5.5"
busboy "1.6.0" busboy "1.6.0"
caniuse-lite "^1.0.30001579" caniuse-lite "^1.0.30001579"
@ -1266,15 +1266,15 @@ next@14.2.15:
postcss "8.4.31" postcss "8.4.31"
styled-jsx "5.1.1" styled-jsx "5.1.1"
optionalDependencies: optionalDependencies:
"@next/swc-darwin-arm64" "14.2.15" "@next/swc-darwin-arm64" "14.2.21"
"@next/swc-darwin-x64" "14.2.15" "@next/swc-darwin-x64" "14.2.21"
"@next/swc-linux-arm64-gnu" "14.2.15" "@next/swc-linux-arm64-gnu" "14.2.21"
"@next/swc-linux-arm64-musl" "14.2.15" "@next/swc-linux-arm64-musl" "14.2.21"
"@next/swc-linux-x64-gnu" "14.2.15" "@next/swc-linux-x64-gnu" "14.2.21"
"@next/swc-linux-x64-musl" "14.2.15" "@next/swc-linux-x64-musl" "14.2.21"
"@next/swc-win32-arm64-msvc" "14.2.15" "@next/swc-win32-arm64-msvc" "14.2.21"
"@next/swc-win32-ia32-msvc" "14.2.15" "@next/swc-win32-ia32-msvc" "14.2.21"
"@next/swc-win32-x64-msvc" "14.2.15" "@next/swc-win32-x64-msvc" "14.2.21"
node-domexception@^1.0.0: node-domexception@^1.0.0:
version "1.0.0" version "1.0.0"

View File

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

View File

@ -385,17 +385,17 @@ export function getCurrentContext(): ContextMap | undefined {
} }
} }
export function getFeatureFlags<T extends Record<string, any>>( export function getFeatureFlags(
key: string key: string
): T | undefined { ): Record<string, boolean> | undefined {
const context = getCurrentContext() const context = getCurrentContext()
if (!context) { if (!context) {
return undefined return undefined
} }
return context.featureFlagCache?.[key] as T return context.featureFlagCache?.[key]
} }
export function setFeatureFlags(key: string, value: Record<string, any>) { export function setFeatureFlags(key: string, value: Record<string, boolean>) {
const context = getCurrentContext() const context = getCurrentContext()
if (!context) { if (!context) {
return return

View File

@ -20,7 +20,7 @@ export type ContextMap = {
clients: Record<string, GoogleSpreadsheet> clients: Record<string, GoogleSpreadsheet>
} }
featureFlagCache?: { featureFlagCache?: {
[key: string]: Record<string, any> [key: string]: Record<string, boolean>
} }
viewToTableCache?: Record<string, Table> viewToTableCache?: Record<string, Table>
} }

View File

@ -2,9 +2,10 @@ import env from "../environment"
import * as crypto from "crypto" import * as crypto from "crypto"
import * as context from "../context" import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node" import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag } from "@budibase/types"
import tracer from "dd-trace" import tracer from "dd-trace"
import { Duration } from "../utils" import { Duration } from "../utils"
import { cloneDeep } from "lodash"
import { FeatureFlagDefaults } from "@budibase/types"
let posthog: PostHog | undefined let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) { export function init(opts?: PostHogOptions) {
@ -30,74 +31,6 @@ export function shutdown() {
posthog?.shutdown() posthog?.shutdown()
} }
export abstract class Flag<T> {
static boolean(defaultValue: boolean): Flag<boolean> {
return new BooleanFlag(defaultValue)
}
static string(defaultValue: string): Flag<string> {
return new StringFlag(defaultValue)
}
static number(defaultValue: number): Flag<number> {
return new NumberFlag(defaultValue)
}
protected constructor(public defaultValue: T) {}
abstract parse(value: any): T
}
type UnwrapFlag<F> = F extends Flag<infer U> ? U : never
export type FlagValues<T> = {
[K in keyof T]: UnwrapFlag<T[K]>
}
type KeysOfType<T, U> = {
[K in keyof T]: T[K] extends Flag<U> ? K : never
}[keyof T]
class BooleanFlag extends Flag<boolean> {
parse(value: any) {
if (typeof value === "string") {
return ["true", "t", "1"].includes(value.toLowerCase())
}
if (typeof value === "boolean") {
return value
}
throw new Error(`could not parse value "${value}" as boolean`)
}
}
class StringFlag extends Flag<string> {
parse(value: any) {
if (typeof value === "string") {
return value
}
throw new Error(`could not parse value "${value}" as string`)
}
}
class NumberFlag extends Flag<number> {
parse(value: any) {
if (typeof value === "number") {
return value
}
if (typeof value === "string") {
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
return parsed
}
}
throw new Error(`could not parse value "${value}" as number`)
}
}
export interface EnvFlagEntry { export interface EnvFlagEntry {
tenantId: string tenantId: string
key: string key: string
@ -120,7 +53,7 @@ export function parseEnvFlags(flags: string): EnvFlagEntry[] {
return result return result
} }
export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> { export class FlagSet<T extends { [name: string]: boolean }> {
// This is used to safely cache flags sets in the current request context. // This is used to safely cache flags sets in the current request context.
// Because multiple sets could theoretically exist, we don't want the cache of // Because multiple sets could theoretically exist, we don't want the cache of
// one to leak into another. // one to leak into another.
@ -130,34 +63,25 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
this.setId = crypto.randomUUID() this.setId = crypto.randomUUID()
} }
defaults(): FlagValues<T> { defaults(): T {
return Object.keys(this.flagSchema).reduce((acc, key) => { return cloneDeep(this.flagSchema)
const typedKey = key as keyof T
acc[typedKey] = this.flagSchema[key].defaultValue
return acc
}, {} as FlagValues<T>)
} }
isFlagName(name: string | number | symbol): name is keyof T { isFlagName(name: string | number | symbol): name is keyof T {
return this.flagSchema[name as keyof T] !== undefined return this.flagSchema[name as keyof T] !== undefined
} }
async get<K extends keyof T>(key: K): Promise<FlagValues<T>[K]> { async isEnabled<K extends keyof T>(key: K): Promise<T[K]> {
const flags = await this.fetch() const flags = await this.fetch()
return flags[key] return flags[key]
} }
async isEnabled<K extends KeysOfType<T, boolean>>(key: K): Promise<boolean> { async fetch(): Promise<T> {
const flags = await this.fetch()
return flags[key]
}
async fetch(): Promise<FlagValues<T>> {
return await tracer.trace("features.fetch", async span => { return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId) const cachedFlags = context.getFeatureFlags(this.setId)
if (cachedFlags) { if (cachedFlags) {
span?.addTags({ fromCache: true }) span?.addTags({ fromCache: true })
return cachedFlags return cachedFlags as T
} }
const tags: Record<string, any> = {} const tags: Record<string, any> = {}
@ -189,7 +113,7 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
// @ts-expect-error - TS does not like you writing into a generic type, // @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object. // but we know that it's okay in this case because it's just an object.
flagValues[key as keyof FlagValues] = value flagValues[key as keyof T] = value
tags[`flags.${key}.source`] = "environment" tags[`flags.${key}.source`] = "environment"
} }
@ -217,11 +141,11 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
tags[`readFromPostHog`] = true tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = { tenantId } const personProperties: Record<string, string> = { tenantId }
const posthogFlags = await posthog.getAllFlagsAndPayloads(userId, { const posthogFlags = await posthog.getAllFlags(userId, {
personProperties, personProperties,
}) })
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) { for (const [name, value] of Object.entries(posthogFlags)) {
if (!this.isFlagName(name)) { if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we // We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue. // just log it and continue.
@ -229,19 +153,20 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
continue continue
} }
if (typeof value !== "boolean") {
console.warn(`Invalid value for posthog flag "${name}": ${value}`)
continue
}
if (flagValues[name] === true || specificallySetFalse.has(name)) { if (flagValues[name] === true || specificallySetFalse.has(name)) {
// If the flag is already set to through environment variables, we // If the flag is already set to through environment variables, we
// don't want to override it back to false here. // don't want to override it back to false here.
continue continue
} }
const payload = posthogFlags.featureFlagPayloads?.[name]
const flag = this.flagSchema[name]
try { try {
// @ts-expect-error - TS does not like you writing into a generic // @ts-expect-error - TS does not like you writing into a generic type.
// type, but we know that it's okay in this case because it's just flagValues[name] = value
// an object.
flagValues[name] = flag.parse(payload || value)
tags[`flags.${name}.source`] = "posthog" tags[`flags.${name}.source`] = "posthog"
} catch (err) { } catch (err) {
// We don't want an invalid PostHog flag to break the app, so we just // We don't want an invalid PostHog flag to break the app, so we just
@ -262,18 +187,12 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
} }
} }
// This is the primary source of truth for feature flags. If you want to add a export const flags = new FlagSet(FeatureFlagDefaults)
// new flag, add it here and use the `fetch` and `get` functions to access it.
// All of the machinery in this file is to make sure that flags have their
// default values set correctly and their types flow through the system.
const flagsConfig: Record<FeatureFlag, Flag<any>> = {
[FeatureFlag.DEFAULT_VALUES]: Flag.boolean(true),
[FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(true),
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(true),
[FeatureFlag.BUDIBASE_AI]: Flag.boolean(true),
[FeatureFlag.USE_ZOD_VALIDATOR]: Flag.boolean(env.isDev()),
}
export const flags = new FlagSet(flagsConfig)
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T export async function isEnabled(flag: keyof typeof FeatureFlagDefaults) {
export type FeatureFlags = UnwrapPromise<ReturnType<typeof flags.fetch>> return await flags.isEnabled(flag)
}
export async function all() {
return await flags.fetch()
}

View File

@ -1,5 +1,5 @@
import { IdentityContext, IdentityType } from "@budibase/types" import { IdentityContext, IdentityType } from "@budibase/types"
import { Flag, FlagSet, FlagValues, init, shutdown } from "../" import { FlagSet, init, shutdown } from "../"
import * as context from "../../context" import * as context from "../../context"
import environment, { withEnv } from "../../environment" import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch" import nodeFetch from "node-fetch"
@ -7,10 +7,8 @@ import nock from "nock"
import * as crypto from "crypto" import * as crypto from "crypto"
const schema = { const schema = {
TEST_BOOLEAN: Flag.boolean(false), TEST_BOOLEAN: false,
TEST_STRING: Flag.string("default value"), TEST_BOOLEAN_DEFAULT_TRUE: true,
TEST_NUMBER: Flag.number(0),
TEST_BOOLEAN_DEFAULT_TRUE: Flag.boolean(true),
} }
const flags = new FlagSet(schema) const flags = new FlagSet(schema)
@ -19,7 +17,7 @@ interface TestCase {
identity?: Partial<IdentityContext> identity?: Partial<IdentityContext>
environmentFlags?: string environmentFlags?: string
posthogFlags?: PostHogFlags posthogFlags?: PostHogFlags
expected?: Partial<FlagValues<typeof schema>> expected?: Partial<typeof schema>
errorMessage?: string | RegExp errorMessage?: string | RegExp
} }
@ -83,22 +81,6 @@ describe("feature flags", () => {
}, },
expected: { TEST_BOOLEAN: true }, expected: { TEST_BOOLEAN: true },
}, },
{
it: "should be able to read string flags from PostHog",
posthogFlags: {
featureFlags: { TEST_STRING: true },
featureFlagPayloads: { TEST_STRING: "test" },
},
expected: { TEST_STRING: "test" },
},
{
it: "should be able to read numeric flags from PostHog",
posthogFlags: {
featureFlags: { TEST_NUMBER: true },
featureFlagPayloads: { TEST_NUMBER: "123" },
},
expected: { TEST_NUMBER: 123 },
},
{ {
it: "should not be able to override a negative environment flag from PostHog", it: "should not be able to override a negative environment flag from PostHog",
environmentFlags: "default:!TEST_BOOLEAN", environmentFlags: "default:!TEST_BOOLEAN",
@ -177,7 +159,7 @@ describe("feature flags", () => {
expect(values).toMatchObject(expected) expect(values).toMatchObject(expected)
for (const [key, expectedValue] of Object.entries(expected)) { for (const [key, expectedValue] of Object.entries(expected)) {
const value = await flags.get(key as keyof typeof schema) const value = await flags.isEnabled(key as keyof typeof schema)
expect(value).toBe(expectedValue) expect(value).toBe(expectedValue)
} }
} else { } else {

View File

@ -1,5 +1,6 @@
import { FeatureFlags, parseEnvFlags } from ".." import { FeatureFlags } from "@budibase/types"
import { setEnv } from "../../environment" import { setEnv } from "../../environment"
import { parseEnvFlags } from "../features"
function getCurrentFlags(): Record<string, Record<string, boolean>> { function getCurrentFlags(): Record<string, Record<string, boolean>> {
const result: Record<string, Record<string, boolean>> = {} const result: Record<string, Record<string, boolean>> = {}

View File

@ -87,6 +87,7 @@
let popover let popover
let user, tenantOwner let user, tenantOwner
let loaded = false let loaded = false
let userFieldsToUpdate = {}
$: internalGroups = $groups?.filter(g => !g?.scimInfo?.isSync) $: internalGroups = $groups?.filter(g => !g?.scimInfo?.isSync)
@ -164,40 +165,45 @@
return label return label
} }
async function updateUserFirstName(evt) { async function saveUser() {
try { try {
await users.save({ ...user, firstName: evt.target.value }) await users.save({ ...user, ...userFieldsToUpdate })
userFieldsToUpdate = {}
await fetchUser() await fetchUser()
} catch (error) { } catch (error) {
notifications.error("Error updating user") notifications.error("Error updating user")
} }
} }
async function updateUserFirstName(evt) {
userFieldsToUpdate.firstName = evt.target.value
}
async function updateUserLastName(evt) { async function updateUserLastName(evt) {
try { userFieldsToUpdate.lastName = evt.target.value
await users.save({ ...user, lastName: evt.target.value })
await fetchUser()
} catch (error) {
notifications.error("Error updating user")
}
} }
async function updateUserRole({ detail }) { async function updateUserRole({ detail }) {
let flags = {}
if (detail === Constants.BudibaseRoles.Developer) { if (detail === Constants.BudibaseRoles.Developer) {
toggleFlags({ admin: { global: false }, builder: { global: true } }) flags = { admin: { global: false }, builder: { global: true } }
} else if (detail === Constants.BudibaseRoles.Admin) { } else if (detail === Constants.BudibaseRoles.Admin) {
toggleFlags({ admin: { global: true }, builder: { global: true } }) flags = { admin: { global: true }, builder: { global: true } }
} else if (detail === Constants.BudibaseRoles.AppUser) { } else if (detail === Constants.BudibaseRoles.AppUser) {
toggleFlags({ admin: { global: false }, builder: { global: false } }) flags = { admin: { global: false }, builder: { global: false } }
} else if (detail === Constants.BudibaseRoles.Creator) { } else if (detail === Constants.BudibaseRoles.Creator) {
toggleFlags({ flags = {
admin: { global: false }, admin: { global: false },
builder: { builder: {
global: false, global: false,
creator: true, creator: true,
apps: user?.builder?.apps || [], apps: user?.builder?.apps || [],
}, },
}) }
}
userFieldsToUpdate = {
...userFieldsToUpdate,
...flags,
} }
} }
@ -209,15 +215,6 @@
tenantOwner = await users.getAccountHolder() tenantOwner = await users.getAccountHolder()
} }
async function toggleFlags(detail) {
try {
await users.save({ ...user, ...detail })
await fetchUser()
} catch (error) {
notifications.error("Error updating user")
}
}
const addGroup = async groupId => { const addGroup = async groupId => {
await groups.addUser(groupId, userId) await groups.addUser(groupId, userId)
await fetchUser() await fetchUser()
@ -296,7 +293,7 @@
<Input <Input
disabled={readonly} disabled={readonly}
value={user?.firstName} value={user?.firstName}
on:blur={updateUserFirstName} on:input={updateUserFirstName}
/> />
</div> </div>
<div class="field"> <div class="field">
@ -304,7 +301,7 @@
<Input <Input
disabled={readonly} disabled={readonly}
value={user?.lastName} value={user?.lastName}
on:blur={updateUserLastName} on:input={updateUserLastName}
/> />
</div> </div>
<!-- don't let a user remove the privileges that let them be here --> <!-- don't let a user remove the privileges that let them be here -->
@ -325,6 +322,13 @@
{/if} {/if}
</div> </div>
</Layout> </Layout>
<div>
<Button
cta
disabled={Object.keys(userFieldsToUpdate).length === 0}
on:click={saveUser}>Save</Button
>
</div>
{#if $licensing.groupsEnabled} {#if $licensing.groupsEnabled}
<!-- User groups --> <!-- User groups -->

View File

@ -291,8 +291,8 @@ const automationActions = (store: AutomationStore) => ({
let result: (AutomationStep | AutomationTrigger)[] = [] let result: (AutomationStep | AutomationTrigger)[] = []
pathWay.forEach(path => { pathWay.forEach(path => {
const { stepIdx, branchIdx } = path const { stepIdx, branchIdx } = path
let last = result ? result[result.length - 1] : [] let last = result.length ? result[result.length - 1] : []
if (!result) { if (!result.length) {
// Preceeding steps. // Preceeding steps.
result = steps.slice(0, stepIdx + 1) result = steps.slice(0, stepIdx + 1)
return return

View File

@ -1,27 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function createFlagsStore() {
const { subscribe, set } = writable({})
const actions = {
fetch: async () => {
const flags = await API.getFlags()
set(flags)
},
updateFlag: async (flag, value) => {
await API.updateFlag(flag, value)
await actions.fetch()
},
toggleUiFeature: async feature => {
await API.toggleUiFeature(feature)
},
}
return {
subscribe,
...actions,
}
}
export const flags = createFlagsStore()

View File

@ -0,0 +1,40 @@
import { API } from "@/api"
import { GetUserFlagsResponse } from "@budibase/types"
import { BudiStore } from "../BudiStore"
interface FlagsState {
flags: GetUserFlagsResponse
}
const INITIAL_FLAGS_STATE: FlagsState = {
flags: {},
}
export class FlagsStore extends BudiStore<FlagsState> {
constructor() {
super(INITIAL_FLAGS_STATE)
this.fetch = this.fetch.bind(this)
this.updateFlag = this.updateFlag.bind(this)
this.toggleUiFeature = this.toggleUiFeature.bind(this)
}
async fetch() {
const flags = await API.getFlags()
this.update(state => ({
...state,
flags,
}))
}
async updateFlag(flag: string, value: any) {
await API.updateFlag(flag, value)
await this.fetch()
}
async toggleUiFeature(feature: string) {
await API.toggleUiFeature(feature)
}
}
export const flags = new FlagsStore()

View File

@ -2,19 +2,24 @@ import { get } from "svelte/store"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { previewStore } from "@/stores/builder" import { previewStore } from "@/stores/builder"
interface BuilderHoverStore {
hoverTimeout?: NodeJS.Timeout
componentId: string | null
}
export const INITIAL_HOVER_STATE = { export const INITIAL_HOVER_STATE = {
componentId: null, componentId: null,
} }
export class HoverStore extends BudiStore { export class HoverStore extends BudiStore<BuilderHoverStore> {
hoverTimeout hoverTimeout?: NodeJS.Timeout
constructor() { constructor() {
super({ ...INITIAL_HOVER_STATE }) super({ ...INITIAL_HOVER_STATE })
this.hover = this.hover.bind(this) this.hover = this.hover.bind(this)
} }
hover(componentId, notifyClient = true) { hover(componentId: string, notifyClient = true) {
clearTimeout(this.hoverTimeout) clearTimeout(this.hoverTimeout)
if (componentId) { if (componentId) {
this.processHover(componentId, notifyClient) this.processHover(componentId, notifyClient)
@ -25,7 +30,7 @@ export class HoverStore extends BudiStore {
} }
} }
processHover(componentId, notifyClient) { processHover(componentId: string, notifyClient?: boolean) {
if (componentId === get(this.store).componentId) { if (componentId === get(this.store).componentId) {
return return
} }

View File

@ -2,13 +2,19 @@ import { derived, get } from "svelte/store"
import { componentStore } from "@/stores/builder" import { componentStore } from "@/stores/builder"
import { API } from "@/api" import { API } from "@/api"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { Layout } from "@budibase/types"
export const INITIAL_LAYOUT_STATE = { interface LayoutState {
layouts: Layout[]
selectedLayoutId: string | null
}
export const INITIAL_LAYOUT_STATE: LayoutState = {
layouts: [], layouts: [],
selectedLayoutId: null, selectedLayoutId: null,
} }
export class LayoutStore extends BudiStore { export class LayoutStore extends BudiStore<LayoutState> {
constructor() { constructor() {
super(INITIAL_LAYOUT_STATE) super(INITIAL_LAYOUT_STATE)
@ -22,14 +28,14 @@ export class LayoutStore extends BudiStore {
this.store.set({ ...INITIAL_LAYOUT_STATE }) this.store.set({ ...INITIAL_LAYOUT_STATE })
} }
syncAppLayouts(pkg) { syncAppLayouts(pkg: { layouts: Layout[] }) {
this.update(state => ({ this.update(state => ({
...state, ...state,
layouts: [...pkg.layouts], layouts: [...pkg.layouts],
})) }))
} }
select(layoutId) { select(layoutId: string) {
// Check this layout exists // Check this layout exists
const state = get(this.store) const state = get(this.store)
const componentState = get(componentStore) const componentState = get(componentStore)
@ -48,15 +54,15 @@ export class LayoutStore extends BudiStore {
// Select new layout // Select new layout
this.update(state => { this.update(state => {
state.selectedLayoutId = layout._id state.selectedLayoutId = layout._id!
return state return state
}) })
componentStore.select(layout.props?._id) componentStore.select(layout.props?._id)
} }
async deleteLayout(layout) { async deleteLayout(layout: Layout) {
if (!layout?._id) { if (!layout?._id || !layout?._rev) {
return return
} }
await API.deleteLayout(layout._id, layout._rev) await API.deleteLayout(layout._id, layout._rev)

View File

@ -2,27 +2,22 @@ import { get } from "svelte/store"
import { API } from "@/api" import { API } from "@/api"
import { appStore } from "@/stores/builder" import { appStore } from "@/stores/builder"
import { BudiStore } from "../BudiStore" import { BudiStore } from "../BudiStore"
import { AppNavigation, AppNavigationLink, UIObject } from "@budibase/types"
interface BuilderNavigationStore extends AppNavigation {}
export const INITIAL_NAVIGATION_STATE = { export const INITIAL_NAVIGATION_STATE = {
navigation: "Top", navigation: "Top",
links: [], links: [],
title: null,
sticky: null,
hideLogo: null,
logoUrl: null,
hideTitle: null,
textAlign: "Left", textAlign: "Left",
navBackground: null,
navWidth: null,
navTextColor: null,
} }
export class NavigationStore extends BudiStore { export class NavigationStore extends BudiStore<BuilderNavigationStore> {
constructor() { constructor() {
super(INITIAL_NAVIGATION_STATE) super(INITIAL_NAVIGATION_STATE)
} }
syncAppNavigation(nav) { syncAppNavigation(nav: AppNavigation) {
this.update(state => ({ this.update(state => ({
...state, ...state,
...nav, ...nav,
@ -33,15 +28,17 @@ export class NavigationStore extends BudiStore {
this.store.set({ ...INITIAL_NAVIGATION_STATE }) this.store.set({ ...INITIAL_NAVIGATION_STATE })
} }
async save(navigation) { async save(navigation: AppNavigation) {
const appId = get(appStore).appId const appId = get(appStore).appId
const app = await API.saveAppMetadata(appId, { navigation }) const app = await API.saveAppMetadata(appId, { navigation })
if (app.navigation) {
this.syncAppNavigation(app.navigation) this.syncAppNavigation(app.navigation)
} }
}
async saveLink(url, title, roleId) { async saveLink(url: string, title: string, roleId: string) {
const navigation = get(this.store) const navigation = get(this.store)
let links = [...(navigation?.links ?? [])] let links: AppNavigationLink[] = [...(navigation?.links ?? [])]
// Skip if we have an identical link // Skip if we have an identical link
if (links.find(link => link.url === url && link.text === title)) { if (links.find(link => link.url === url && link.text === title)) {
@ -60,7 +57,7 @@ export class NavigationStore extends BudiStore {
}) })
} }
async deleteLink(urls) { async deleteLink(urls: string[] | string) {
const navigation = get(this.store) const navigation = get(this.store)
let links = navigation?.links let links = navigation?.links
if (!links?.length) { if (!links?.length) {
@ -86,7 +83,7 @@ export class NavigationStore extends BudiStore {
}) })
} }
syncMetadata(metadata) { syncMetadata(metadata: UIObject) {
const { navigation } = metadata const { navigation } = metadata
this.syncAppNavigation(navigation) this.syncAppNavigation(navigation)
} }

View File

@ -1,80 +0,0 @@
import { writable, get } from "svelte/store"
const INITIAL_PREVIEW_STATE = {
previewDevice: "desktop",
previewEventHandler: null,
showPreview: false,
selectedComponentContext: null,
}
export const createPreviewStore = () => {
const store = writable({
...INITIAL_PREVIEW_STATE,
})
const setDevice = device => {
store.update(state => {
state.previewDevice = device
return state
})
}
// Potential evt names "eject-block", "dragging-new-component"
const sendEvent = (name, payload) => {
const { previewEventHandler } = get(store)
previewEventHandler?.(name, payload)
}
const registerEventHandler = handler => {
store.update(state => {
state.previewEventHandler = handler
return state
})
}
const startDrag = component => {
sendEvent("dragging-new-component", {
dragging: true,
component,
})
}
const stopDrag = () => {
sendEvent("dragging-new-component", {
dragging: false,
})
}
//load preview?
const showPreview = isVisible => {
store.update(state => {
state.showPreview = isVisible
return state
})
}
const setSelectedComponentContext = context => {
store.update(state => {
state.selectedComponentContext = context
return state
})
}
const requestComponentContext = () => {
sendEvent("request-context")
}
return {
subscribe: store.subscribe,
setDevice,
sendEvent, //may not be required
registerEventHandler,
startDrag,
stopDrag,
showPreview,
setSelectedComponentContext,
requestComponentContext,
}
}
export const previewStore = createPreviewStore()

View File

@ -0,0 +1,90 @@
import { get } from "svelte/store"
import { BudiStore } from "../BudiStore"
type PreviewDevice = "desktop" | "tablet" | "mobile"
type PreviewEventHandler = (name: string, payload?: any) => void
type ComponentContext = Record<string, any>
interface PreviewState {
previewDevice: PreviewDevice
previewEventHandler: PreviewEventHandler | null
showPreview: boolean
selectedComponentContext: ComponentContext | null
}
const INITIAL_PREVIEW_STATE: PreviewState = {
previewDevice: "desktop",
previewEventHandler: null,
showPreview: false,
selectedComponentContext: null,
}
export class PreviewStore extends BudiStore<PreviewState> {
constructor() {
super(INITIAL_PREVIEW_STATE)
this.setDevice = this.setDevice.bind(this)
this.sendEvent = this.sendEvent.bind(this)
this.registerEventHandler = this.registerEventHandler.bind(this)
this.startDrag = this.startDrag.bind(this)
this.stopDrag = this.stopDrag.bind(this)
this.showPreview = this.showPreview.bind(this)
this.setSelectedComponentContext =
this.setSelectedComponentContext.bind(this)
this.requestComponentContext = this.requestComponentContext.bind(this)
}
setDevice(device: PreviewDevice) {
this.update(state => ({
...state,
previewDevice: device,
}))
}
// Potential evt names "eject-block", "dragging-new-component"
sendEvent(name: string, payload?: any) {
const { previewEventHandler } = get(this.store)
previewEventHandler?.(name, payload)
}
registerEventHandler(handler: PreviewEventHandler) {
this.update(state => ({
...state,
previewEventHandler: handler,
}))
}
startDrag(component: any) {
this.sendEvent("dragging-new-component", {
dragging: true,
component,
})
}
stopDrag() {
this.sendEvent("dragging-new-component", {
dragging: false,
})
}
//load preview?
showPreview(isVisible: boolean) {
this.update(state => ({
...state,
showPreview: isVisible,
}))
}
setSelectedComponentContext(context: ComponentContext) {
this.update(state => ({
...state,
selectedComponentContext: context,
}))
}
requestComponentContext() {
this.sendEvent("request-context")
}
}
export const previewStore = new PreviewStore()

View File

@ -1,13 +1,16 @@
import { appStore } from "./app" import { appStore } from "./app"
import { appsStore } from "@/stores/portal/apps" import { appsStore } from "@/stores/portal/apps"
import { deploymentStore } from "./deployments" import { deploymentStore } from "./deployments"
import { derived } from "svelte/store" import { derived, type Readable } from "svelte/store"
import { DeploymentProgressResponse, DeploymentStatus } from "@budibase/types"
export const appPublished = derived( export const appPublished: Readable<boolean> = derived(
[appStore, appsStore, deploymentStore], [appStore, appsStore, deploymentStore],
([$appStore, $appsStore, $deploymentStore]) => { ([$appStore, $appsStore, $deploymentStore]) => {
const app = $appsStore.apps.find(app => app.devId === $appStore.appId) const app = $appsStore.apps.find(app => app.devId === $appStore.appId)
const deployments = $deploymentStore.filter(x => x.status === "SUCCESS") const deployments = $deploymentStore.filter(
(x: DeploymentProgressResponse) => x.status === DeploymentStatus.SUCCESS
)
return app?.status === "published" && deployments.length > 0 return app?.status === "published" && deployments.length > 0
} }
) )

View File

@ -1,88 +0,0 @@
import { derived, writable, get } from "svelte/store"
import { API } from "@/api"
import { RoleUtils } from "@budibase/frontend-core"
export function createRolesStore() {
const store = writable([])
const enriched = derived(store, $store => {
return $store.map(role => ({
...role,
// Ensure we have new metadata for all roles
uiMetadata: {
displayName: role.uiMetadata?.displayName || role.name,
color:
role.uiMetadata?.color || "var(--spectrum-global-color-magenta-400)",
description: role.uiMetadata?.description || "Custom role",
},
}))
})
function setRoles(roles) {
store.set(
roles.sort((a, b) => {
const priorityA = RoleUtils.getRolePriority(a._id)
const priorityB = RoleUtils.getRolePriority(b._id)
if (priorityA !== priorityB) {
return priorityA > priorityB ? -1 : 1
}
const nameA = a.uiMetadata?.displayName || a.name
const nameB = b.uiMetadata?.displayName || b.name
return nameA < nameB ? -1 : 1
})
)
}
const actions = {
fetch: async () => {
const roles = await API.getRoles()
setRoles(roles)
},
fetchByAppId: async appId => {
const { roles } = await API.getRolesForApp(appId)
setRoles(roles)
},
delete: async role => {
await API.deleteRole(role._id, role._rev)
await actions.fetch()
},
save: async role => {
const savedRole = await API.saveRole(role)
await actions.fetch()
return savedRole
},
replace: (roleId, role) => {
// Handles external updates of roles
if (!roleId) {
return
}
// Handle deletion
if (!role) {
store.update(state => state.filter(x => x._id !== roleId))
return
}
// Add new role
const index = get(store).findIndex(x => x._id === role._id)
if (index === -1) {
store.update(state => [...state, role])
}
// Update existing role
else if (role) {
store.update(state => {
state[index] = role
return [...state]
})
}
},
}
return {
subscribe: enriched.subscribe,
...actions,
}
}
export const roles = createRolesStore()

View File

@ -0,0 +1,94 @@
import { derived, get, type Writable } from "svelte/store"
import { API } from "@/api"
import { RoleUtils } from "@budibase/frontend-core"
import { DerivedBudiStore } from "../BudiStore"
import { Role } from "@budibase/types"
export class RoleStore extends DerivedBudiStore<Role[], Role[]> {
constructor() {
const makeDerivedStore = (store: Writable<Role[]>) =>
derived(store, $store => {
return $store.map((role: Role) => ({
...role,
// Ensure we have new metadata for all roles
uiMetadata: {
displayName: role.uiMetadata?.displayName || role.name,
color:
role.uiMetadata?.color ||
"var(--spectrum-global-color-magenta-400)",
description: role.uiMetadata?.description || "Custom role",
},
}))
})
super([], makeDerivedStore)
}
setRoles = (roles: Role[]) => {
this.set(
roles.sort((a, b) => {
// Ensure we have valid IDs for priority comparison
const priorityA = RoleUtils.getRolePriority(a._id)
const priorityB = RoleUtils.getRolePriority(b._id)
if (priorityA !== priorityB) {
return priorityA > priorityB ? -1 : 1
}
const nameA = a.uiMetadata?.displayName || a.name
const nameB = b.uiMetadata?.displayName || b.name
return nameA < nameB ? -1 : 1
})
)
}
fetch = async () => {
const roles = await API.getRoles()
this.setRoles(roles)
}
fetchByAppId = async (appId: string) => {
const { roles } = await API.getRolesForApp(appId)
this.setRoles(roles)
}
delete = async (role: Role) => {
if (!role._id || !role._rev) {
return
}
await API.deleteRole(role._id, role._rev)
await this.fetch()
}
save = async (role: Role) => {
const savedRole = await API.saveRole(role)
await this.fetch()
return savedRole
}
replace = (roleId: string, role?: Role) => {
// Handles external updates of roles
if (!roleId) {
return
}
// Handle deletion
if (!role) {
this.update(state => state.filter(x => x._id !== roleId))
return
}
// Add new role
const index = get(this).findIndex(x => x._id === role._id)
if (index === -1) {
this.update(state => [...state, role])
}
// Update existing role
else if (role) {
this.update(state => {
state[index] = role
return [...state]
})
}
}
}
export const roles = new RoleStore()

View File

@ -6,18 +6,29 @@ import { automationStore } from "./automations"
import { API } from "@/api" import { API } from "@/api"
import { getSequentialName } from "@/helpers/duplicate" import { getSequentialName } from "@/helpers/duplicate"
const initialState = {} interface RowAction {
id: string
name: string
tableId: string
allowedSources?: string[]
}
export class RowActionStore extends BudiStore { interface RowActionState {
[tableId: string]: RowAction[]
}
const initialState: RowActionState = {}
export class RowActionStore extends BudiStore<RowActionState> {
constructor() { constructor() {
super(initialState) super(initialState)
} }
reset = () => { reset = () => {
this.store.set(initialState) this.set(initialState)
} }
refreshRowActions = async sourceId => { refreshRowActions = async (sourceId: string) => {
if (!sourceId) { if (!sourceId) {
return return
} }
@ -34,26 +45,30 @@ export class RowActionStore extends BudiStore {
// Fetch row actions for this table // Fetch row actions for this table
const res = await API.rowActions.fetch(tableId) const res = await API.rowActions.fetch(tableId)
const actions = Object.values(res || {}) const actions = Object.values(res || {}) as RowAction[]
this.update(state => ({ this.update(state => ({
...state, ...state,
[tableId]: actions, [tableId]: actions,
})) }))
} }
createRowAction = async (tableId, viewId, name) => { createRowAction = async (tableId: string, viewId?: string, name?: string) => {
if (!tableId) { if (!tableId) {
return return
} }
// Get a unique name for this action // Get a unique name for this action
if (!name) { if (!name) {
const existingRowActions = get(this.store)[tableId] || [] const existingRowActions = get(this)[tableId] || []
name = getSequentialName(existingRowActions, "New row action ", { name = getSequentialName(existingRowActions, "New row action ", {
getName: x => x.name, getName: x => x.name,
}) })
} }
if (!name) {
throw new Error("Failed to generate a unique name for the row action")
}
// Create the action // Create the action
const res = await API.rowActions.create(tableId, name) const res = await API.rowActions.create(tableId, name)
@ -73,41 +88,35 @@ export class RowActionStore extends BudiStore {
return res return res
} }
enableView = async (tableId, rowActionId, viewId) => { enableView = async (tableId: string, rowActionId: string, viewId: string) => {
await API.rowActions.enableView(tableId, rowActionId, viewId) await API.rowActions.enableView(tableId, rowActionId, viewId)
await this.refreshRowActions(tableId) await this.refreshRowActions(tableId)
} }
disableView = async (tableId, rowActionId, viewId) => { disableView = async (
tableId: string,
rowActionId: string,
viewId: string
) => {
await API.rowActions.disableView(tableId, rowActionId, viewId) await API.rowActions.disableView(tableId, rowActionId, viewId)
await this.refreshRowActions(tableId) await this.refreshRowActions(tableId)
} }
rename = async (tableId, rowActionId, name) => { delete = async (tableId: string, rowActionId: string) => {
await API.rowActions.update({
tableId,
rowActionId,
name,
})
await this.refreshRowActions(tableId)
automationStore.actions.fetch()
}
delete = async (tableId, rowActionId) => {
await API.rowActions.delete(tableId, rowActionId) await API.rowActions.delete(tableId, rowActionId)
await this.refreshRowActions(tableId) await this.refreshRowActions(tableId)
// We don't need to refresh automations as we can only delete row actions // We don't need to refresh automations as we can only delete row actions
// from the automations store, so we already handle the state update there // from the automations store, so we already handle the state update there
} }
trigger = async (sourceId, rowActionId, rowId) => { trigger = async (sourceId: string, rowActionId: string, rowId: string) => {
await API.rowActions.trigger(sourceId, rowActionId, rowId) await API.rowActions.trigger(sourceId, rowActionId, rowId)
} }
} }
const store = new RowActionStore() const store = new RowActionStore()
const derivedStore = derived(store, $store => { const derivedStore = derived<RowActionStore, RowActionState>(store, $store => {
let map = {} const map: RowActionState = {}
// Generate an entry for every view as well // Generate an entry for every view as well
Object.keys($store || {}).forEach(tableId => { Object.keys($store || {}).forEach(tableId => {
@ -115,7 +124,7 @@ const derivedStore = derived(store, $store => {
map[tableId] = $store[tableId] map[tableId] = $store[tableId]
for (let action of $store[tableId]) { for (let action of $store[tableId]) {
const otherSources = (action.allowedSources || []).filter( const otherSources = (action.allowedSources || []).filter(
sourceId => sourceId !== tableId (sourceId: string) => sourceId !== tableId
) )
for (let source of otherSources) { for (let source of otherSources) {
map[source] ??= [] map[source] ??= []

View File

@ -1,35 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { appStore } from "./app"
const createsnippets = () => {
const store = writable([])
const syncMetadata = metadata => {
store.set(metadata?.snippets || [])
}
const saveSnippet = async updatedSnippet => {
const snippets = [
...get(store).filter(snippet => snippet.name !== updatedSnippet.name),
updatedSnippet,
]
const app = await API.saveAppMetadata(get(appStore).appId, { snippets })
syncMetadata(app)
}
const deleteSnippet = async snippetName => {
const snippets = get(store).filter(snippet => snippet.name !== snippetName)
const app = await API.saveAppMetadata(get(appStore).appId, { snippets })
syncMetadata(app)
}
return {
...store,
syncMetadata,
saveSnippet,
deleteSnippet,
}
}
export const snippets = createsnippets()

View File

@ -0,0 +1,32 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { appStore } from "./app"
import { BudiStore } from "../BudiStore"
import { Snippet, UpdateAppResponse } from "@budibase/types"
export class SnippetStore extends BudiStore<Snippet[]> {
constructor() {
super([])
}
syncMetadata = (metadata: UpdateAppResponse) => {
this.set(metadata?.snippets || [])
}
saveSnippet = async (updatedSnippet: Snippet) => {
const snippets = [
...get(this).filter(snippet => snippet.name !== updatedSnippet.name),
updatedSnippet,
]
const app = await API.saveAppMetadata(get(appStore).appId, { snippets })
this.syncMetadata(app)
}
deleteSnippet = async (snippetName: string) => {
const snippets = get(this).filter(snippet => snippet.name !== snippetName)
const app = await API.saveAppMetadata(get(appStore).appId, { snippets })
this.syncMetadata(app)
}
}
export const snippets = new SnippetStore()

View File

@ -1,58 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { ensureValidTheme, DefaultAppTheme } from "@budibase/shared-core"
export const createThemeStore = () => {
const store = writable({
theme: DefaultAppTheme,
customTheme: {},
})
const syncAppTheme = app => {
store.update(state => {
const theme = ensureValidTheme(app.theme, DefaultAppTheme)
return {
...state,
theme,
customTheme: app.customTheme,
}
})
}
const save = async (theme, appId) => {
const app = await API.saveAppMetadata(appId, { theme })
store.update(state => {
state.theme = app.theme
return state
})
}
const saveCustom = async (theme, appId) => {
const updated = { ...get(store).customTheme, ...theme }
const app = await API.saveAppMetadata(appId, { customTheme: updated })
store.update(state => {
state.customTheme = app.customTheme
return state
})
}
const syncMetadata = metadata => {
const { theme, customTheme } = metadata
store.update(state => ({
...state,
theme: ensureValidTheme(theme, DefaultAppTheme),
customTheme,
}))
}
return {
subscribe: store.subscribe,
update: store.update,
syncMetadata,
syncAppTheme,
save,
saveCustom,
}
}
export const themeStore = createThemeStore()

View File

@ -0,0 +1,58 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import { ensureValidTheme, DefaultAppTheme } from "@budibase/shared-core"
import { App, UpdateAppResponse, Theme, AppCustomTheme } from "@budibase/types"
interface ThemeState {
theme: Theme
customTheme: AppCustomTheme
}
export class ThemeStore extends BudiStore<ThemeState> {
constructor() {
super({
theme: DefaultAppTheme,
customTheme: {},
})
}
syncAppTheme = (app: App) => {
this.update(state => {
const theme = ensureValidTheme(app.theme, DefaultAppTheme)
return {
...state,
theme,
customTheme: app.customTheme || {},
}
})
}
save = async (theme: Theme, appId: string) => {
const app = await API.saveAppMetadata(appId, { theme })
this.update(state => ({
...state,
theme: ensureValidTheme(app.theme, DefaultAppTheme),
}))
}
saveCustom = async (theme: Partial<AppCustomTheme>, appId: string) => {
const updated = { ...get(this).customTheme, ...theme }
const app = await API.saveAppMetadata(appId, { customTheme: updated })
this.update(state => ({
...state,
customTheme: app.customTheme || {},
}))
}
syncMetadata = (metadata: UpdateAppResponse) => {
const { theme, customTheme } = metadata
this.update(state => ({
...state,
theme: ensureValidTheme(theme, DefaultAppTheme),
customTheme: customTheme || {},
}))
}
}
export const themeStore = new ThemeStore()

View File

@ -1,62 +0,0 @@
import { writable, get, derived } from "svelte/store"
export const createUserStore = () => {
const store = writable([])
const init = users => {
store.set(users)
}
const updateUser = user => {
const $users = get(store)
if (!$users.some(x => x.sessionId === user.sessionId)) {
store.set([...$users, user])
} else {
store.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
}
}
const removeUser = sessionId => {
store.update(state => {
return state.filter(x => x.sessionId !== sessionId)
})
}
const reset = () => {
store.set([])
}
return {
...store,
actions: {
init,
updateUser,
removeUser,
reset,
},
}
}
export const userStore = createUserStore()
export const userSelectedResourceMap = derived(userStore, $userStore => {
let map = {}
$userStore.forEach(user => {
const resource = user.builderMetadata?.selectedResourceId
if (resource) {
if (!map[resource]) {
map[resource] = []
}
map[resource].push(user)
}
})
return map
})
export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2
})

View File

@ -0,0 +1,59 @@
import { get, derived } from "svelte/store"
import { BudiStore } from "../BudiStore"
import { UIUser } from "@budibase/types"
export class UserStore extends BudiStore<UIUser[]> {
constructor() {
super([])
}
init(users: UIUser[]) {
this.set(users)
}
updateUser(user: UIUser) {
const $users = get(this)
if (!$users.some(x => x.sessionId === user.sessionId)) {
this.set([...$users, user])
} else {
this.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
}
}
removeUser(sessionId: string) {
this.update(state => {
return state.filter(x => x.sessionId !== sessionId)
})
}
reset() {
this.set([])
}
}
export const userStore = new UserStore()
export const userSelectedResourceMap = derived(
userStore,
($userStore): Record<string, UIUser[]> => {
let map: Record<string, UIUser[]> = {}
$userStore.forEach(user => {
const resource = user.builderMetadata?.selectedResourceId
if (resource) {
if (!map[resource]) {
map[resource] = []
}
map[resource].push(user)
}
})
return map
}
)
export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length < 2
})

View File

@ -1,95 +0,0 @@
import { createWebsocket } from "@budibase/frontend-core"
import {
automationStore,
userStore,
appStore,
themeStore,
navigationStore,
deploymentStore,
snippets,
datasources,
tables,
roles,
} from "@/stores/builder"
import { get } from "svelte/store"
import { auth, appsStore } from "@/stores/portal"
import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui"
export const createBuilderWebsocket = appId => {
const socket = createWebsocket("/socket/builder")
// Built-in events
socket.on("connect", () => {
socket.emit(BuilderSocketEvent.SelectApp, { appId }, ({ users }) => {
userStore.actions.init(users)
})
})
socket.on("connect_error", err => {
console.error("Failed to connect to builder websocket:", err.message)
})
socket.on("disconnect", () => {
userStore.actions.reset()
})
// User events
socket.onOther(SocketEvent.UserUpdate, ({ user }) => {
userStore.actions.updateUser(user)
})
socket.onOther(SocketEvent.UserDisconnect, ({ sessionId }) => {
userStore.actions.removeUser(sessionId)
})
socket.onOther(BuilderSocketEvent.LockTransfer, ({ userId }) => {
if (userId === get(auth)?.user?._id) {
appStore.update(state => ({
...state,
hasLock: true,
}))
}
})
// Data section events
socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => {
tables.replaceTable(id, table)
})
socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource)
})
// Role events
socket.onOther(BuilderSocketEvent.RoleChange, ({ id, role }) => {
roles.replace(id, role)
})
// Design section events
socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => {
screenStore.replace(id, screen)
})
// App events
socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => {
appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
})
socket.onOther(
BuilderSocketEvent.AppPublishChange,
async ({ user, published }) => {
await appsStore.load()
if (published) {
await deploymentStore.load()
}
const verb = published ? "published" : "unpublished"
notifications.success(`${helpers.getUserLabel(user)} ${verb} this app`)
}
)
// Automation events
socket.onOther(BuilderSocketEvent.AutomationChange, ({ id, automation }) => {
automationStore.actions.replace(id, automation)
})
return socket
}

View File

@ -0,0 +1,124 @@
import { createWebsocket } from "@budibase/frontend-core"
import {
automationStore,
userStore,
appStore,
themeStore,
navigationStore,
deploymentStore,
snippets,
datasources,
tables,
roles,
} from "@/stores/builder"
import { get } from "svelte/store"
import { auth, appsStore } from "@/stores/portal"
import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui"
import { Automation, Datasource, Role, Table, UIUser } from "@budibase/types"
export const createBuilderWebsocket = (appId: string) => {
const socket = createWebsocket("/socket/builder")
// Built-in events
socket.on("connect", () => {
socket.emit(
BuilderSocketEvent.SelectApp,
{ appId },
({ users }: { users: UIUser[] }) => {
userStore.init(users)
}
)
})
socket.on("connect_error", err => {
console.error("Failed to connect to builder websocket:", err.message)
})
socket.on("disconnect", () => {
userStore.reset()
})
// User events
socket.onOther(SocketEvent.UserUpdate, ({ user }: { user: UIUser }) => {
userStore.updateUser(user)
})
socket.onOther(
SocketEvent.UserDisconnect,
({ sessionId }: { sessionId: string }) => {
userStore.removeUser(sessionId)
}
)
socket.onOther(
BuilderSocketEvent.LockTransfer,
({ userId }: { userId: string }) => {
if (userId === get(auth)?.user?._id) {
appStore.update(state => ({
...state,
hasLock: true,
}))
}
}
)
// Data section events
socket.onOther(
BuilderSocketEvent.TableChange,
({ id, table }: { id: string; table: Table }) => {
tables.replaceTable(id, table)
}
)
socket.onOther(
BuilderSocketEvent.DatasourceChange,
({ id, datasource }: { id: string; datasource: Datasource }) => {
datasources.replaceDatasource(id, datasource)
}
)
// Role events
socket.onOther(
BuilderSocketEvent.RoleChange,
({ id, role }: { id: string; role: Role }) => {
roles.replace(id, role)
}
)
// Design section events
socket.onOther(
BuilderSocketEvent.ScreenChange,
({ id, screen }: { id: string; screen: Screen }) => {
screenStore.replace(id, screen)
}
)
// App events
socket.onOther(
BuilderSocketEvent.AppMetadataChange,
({ metadata }: { metadata: any }) => {
appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
}
)
socket.onOther(
BuilderSocketEvent.AppPublishChange,
async ({ user, published }: { user: UIUser; published: boolean }) => {
await appsStore.load()
if (published) {
await deploymentStore.load()
}
const verb = published ? "published" : "unpublished"
notifications.success(`${helpers.getUserLabel(user)} ${verb} this app`)
}
)
// Automation events
socket.onOther(
BuilderSocketEvent.AutomationChange,
({ id, automation }: { id: string; automation: Automation }) => {
automationStore.actions.replace(id, automation)
}
)
return socket
}

View File

@ -1,19 +1,8 @@
import { derived, Readable } from "svelte/store" import { derived, Readable } from "svelte/store"
import { auth } from "@/stores/portal" import { auth } from "@/stores/portal"
import { FeatureFlags, FeatureFlagDefaults } from "@budibase/types"
export const INITIAL_FEATUREFLAG_STATE = { export const featureFlags: Readable<FeatureFlags> = derived(auth, $auth => ({
SQS: false, ...FeatureFlagDefaults,
DEFAULT_VALUES: false,
BUDIBASE_AI: false,
AI_CUSTOM_CONFIGS: false,
}
export const featureFlags: Readable<Record<string, any>> = derived(
[auth],
([$auth]) => {
return {
...INITIAL_FEATUREFLAG_STATE,
...($auth?.user?.flags || {}), ...($auth?.user?.flags || {}),
} }))
}
)

View File

@ -47,9 +47,12 @@ class GroupStore extends BudiStore<UserGroup[]> {
async delete(group: UserGroup) { async delete(group: UserGroup) {
await API.deleteGroup(group._id!, group._rev!) await API.deleteGroup(group._id!, group._rev!)
this.update(state => { this.update(groups => {
state = state.filter(state => state._id !== group._id) const index = groups.findIndex(g => g._id === group._id)
return state if (index !== -1) {
groups.splice(index, 1)
}
return groups
}) })
} }

View File

@ -227,7 +227,7 @@ class LicensingStore extends BudiStore<LicensingState> {
MonthlyQuotaName.AUTOMATIONS, MonthlyQuotaName.AUTOMATIONS,
].reduce((acc: MonthlyMetrics, key) => { ].reduce((acc: MonthlyMetrics, key) => {
const limit = license.quotas.usage.monthly[key].value const limit = license.quotas.usage.monthly[key].value
const used = (usage.monthly.current?.[key] || 0 / limit) * 100 const used = ((usage.monthly.current?.[key] || 0) / limit) * 100
acc[key] = limit > -1 ? Math.floor(used) : -1 acc[key] = limit > -1 ? Math.floor(used) : -1
return acc return acc
}, {}) }, {})
@ -236,7 +236,7 @@ class LicensingStore extends BudiStore<LicensingState> {
const staticMetrics = [StaticQuotaName.APPS, StaticQuotaName.ROWS].reduce( const staticMetrics = [StaticQuotaName.APPS, StaticQuotaName.ROWS].reduce(
(acc: StaticMetrics, key) => { (acc: StaticMetrics, key) => {
const limit = license.quotas.usage.static[key].value const limit = license.quotas.usage.static[key].value
const used = (usage.usageQuota[key] || 0 / limit) * 100 const used = ((usage.usageQuota[key] || 0) / limit) * 100
acc[key] = limit > -1 ? Math.floor(used) : -1 acc[key] = limit > -1 ? Math.floor(used) : -1
return acc return acc
}, },

View File

@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "*", "@budibase/backend-core": "*",
"@budibase/pouchdb-replication-stream": "1.2.11",
"@budibase/string-templates": "*", "@budibase/string-templates": "*",
"@budibase/types": "*", "@budibase/types": "*",
"chalk": "4.1.0", "chalk": "4.1.0",
@ -28,9 +29,9 @@
"inquirer": "8.0.0", "inquirer": "8.0.0",
"lookpath": "1.1.0", "lookpath": "1.1.0",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"open": "8.4.2",
"posthog-node": "4.0.1", "posthog-node": "4.0.1",
"pouchdb": "7.3.0", "pouchdb": "7.3.0",
"@budibase/pouchdb-replication-stream": "1.2.11",
"randomstring": "1.1.5", "randomstring": "1.1.5",
"tar": "6.2.1", "tar": "6.2.1",
"yaml": "^2.1.1" "yaml": "^2.1.1"

View File

@ -3,6 +3,8 @@ import { info, success } from "../utils"
import * as makeFiles from "./makeFiles" import * as makeFiles from "./makeFiles"
import compose from "docker-compose" import compose from "docker-compose"
import fs from "fs" import fs from "fs"
import { confirmation } from "../questions"
const open = require("open")
export async function start() { export async function start() {
await checkDockerConfigured() await checkDockerConfigured()
@ -22,6 +24,9 @@ export async function start() {
// need to log as it makes it more clear // need to log as it makes it more clear
await compose.upAll({ cwd: "./", log: true }) await compose.upAll({ cwd: "./", log: true })
}) })
if (await confirmation(`Do you wish to open http://localhost:${port} ?`)) {
await open(`http://localhost:${port}`)
}
console.log( console.log(
success( success(
`Services started, please go to http://localhost:${port} for next steps.` `Services started, please go to http://localhost:${port} for next steps.`

View File

@ -66,10 +66,9 @@ export const patchAPI = API => {
} }
} }
const fetchRelationshipData = API.fetchRelationshipData const fetchRelationshipData = API.fetchRelationshipData
API.fetchRelationshipData = async params => { API.fetchRelationshipData = async (sourceId, rowId, fieldName) => {
const tableId = params?.tableId const rows = await fetchRelationshipData(sourceId, rowId, fieldName)
const rows = await fetchRelationshipData(params) return await enrichRows(rows, sourceId)
return await enrichRows(rows, tableId)
} }
const fetchTableData = API.fetchTableData const fetchTableData = API.fetchTableData
API.fetchTableData = async tableId => { API.fetchTableData = async tableId => {
@ -85,9 +84,9 @@ export const patchAPI = API => {
} }
} }
const fetchViewData = API.fetchViewData const fetchViewData = API.fetchViewData
API.fetchViewData = async params => { API.fetchViewData = async (viewName, params) => {
const tableId = params?.tableId const tableId = params?.tableId
const rows = await fetchViewData(params) const rows = await fetchViewData(viewName, params)
return await enrichRows(rows, tableId) return await enrichRows(rows, tableId)
} }

View File

@ -216,11 +216,11 @@ const deleteRowHandler = async action => {
const triggerAutomationHandler = async action => { const triggerAutomationHandler = async action => {
const { fields, notificationOverride, timeout } = action.parameters const { fields, notificationOverride, timeout } = action.parameters
try { try {
const result = await API.triggerAutomation({ const result = await API.triggerAutomation(
automationId: action.parameters.automationId, action.parameters.automationId,
fields, fields,
timeout, timeout
}) )
// Value will exist if automation is synchronous, so return it. // Value will exist if automation is synchronous, so return it.
if (result.value) { if (result.value) {

View File

@ -26,7 +26,7 @@
: RelationshipType.MANY_TO_MANY, : RelationshipType.MANY_TO_MANY,
} }
async function searchFunction(searchParams) { async function searchFunction(_tableId, searchParams) {
if ( if (
subtype !== BBReferenceFieldSubType.USER && subtype !== BBReferenceFieldSubType.USER &&
subtype !== BBReferenceFieldSubType.USERS subtype !== BBReferenceFieldSubType.USERS

View File

@ -8,9 +8,15 @@ export default class ViewFetch extends DataFetch {
async getData() { async getData() {
const { datasource } = this.options const { datasource } = this.options
try { try {
const res = await this.API.fetchViewData(datasource.name) const res = await this.API.fetchViewData(datasource.name, {
calculation: datasource.calculation,
field: datasource.field,
groupBy: datasource.groupBy,
tableId: datasource.tableId,
})
return { rows: res || [] } return { rows: res || [] }
} catch (error) { } catch (error) {
console.error(error)
return { rows: [] } return { rows: [] }
} }
} }

View File

@ -1,12 +1,18 @@
import { io } from "socket.io-client" import { io, Socket } from "socket.io-client"
import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core" import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core"
import { APISessionID } from "../api" import { APISessionID } from "../api"
const DefaultOptions = { const DefaultOptions = {
heartbeat: true, heartbeat: true,
} }
export interface ExtendedSocket extends Socket {
onOther: (event: string, callback: (data: any) => void) => void
}
export const createWebsocket = (path, options = DefaultOptions) => { export const createWebsocket = (
path: string,
options = DefaultOptions
): ExtendedSocket => {
if (!path) { if (!path) {
throw "A websocket path must be provided" throw "A websocket path must be provided"
} }
@ -32,10 +38,10 @@ export const createWebsocket = (path, options = DefaultOptions) => {
// Disable polling and rely on websocket only, as HTTP transport // Disable polling and rely on websocket only, as HTTP transport
// will only work with sticky sessions which we don't have // will only work with sticky sessions which we don't have
transports: ["websocket"], transports: ["websocket"],
}) }) as ExtendedSocket
// Set up a heartbeat that's half of the session TTL // Set up a heartbeat that's half of the session TTL
let interval let interval: NodeJS.Timeout | undefined
if (heartbeat) { if (heartbeat) {
interval = setInterval(() => { interval = setInterval(() => {
socket.emit(SocketEvent.Heartbeat) socket.emit(SocketEvent.Heartbeat)
@ -48,7 +54,7 @@ export const createWebsocket = (path, options = DefaultOptions) => {
// Helper utility to ignore events that were triggered due to API // Helper utility to ignore events that were triggered due to API
// changes made by us // changes made by us
socket.onOther = (event, callback) => { socket.onOther = (event: string, callback: (data: any) => void) => {
socket.on(event, data => { socket.on(event, data => {
if (data?.apiSessionId !== APISessionID) { if (data?.apiSessionId !== APISessionID) {
callback(data) callback(data)

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

View File

@ -163,9 +163,9 @@ export async function finaliseRow(
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
const aiEnabled = const aiEnabled =
((await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) && ((await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled())) || (await pro.features.isBudibaseAIEnabled())) ||
((await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) && ((await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled())) (await pro.features.isAICustomConfigsEnabled()))
if (aiEnabled) { if (aiEnabled) {
row = await processAIColumns(table, row, { row = await processAIColumns(table, row, {

View File

@ -105,13 +105,13 @@ if (env.SELF_HOSTED) {
export async function getActionDefinitions(): Promise< export async function getActionDefinitions(): Promise<
Record<keyof typeof AutomationActionStepId, AutomationStepDefinition> Record<keyof typeof AutomationActionStepId, AutomationStepDefinition>
> { > {
if (await features.flags.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) { if (await features.isEnabled(FeatureFlag.AUTOMATION_BRANCHING)) {
BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition BUILTIN_ACTION_DEFINITIONS["BRANCH"] = branch.definition
} }
if ( if (
env.SELF_HOSTED || env.SELF_HOSTED ||
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) || (await features.isEnabled(FeatureFlag.BUDIBASE_AI)) ||
(await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) (await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS))
) { ) {
BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition BUILTIN_ACTION_DEFINITIONS["OPENAI"] = openai.definition
} }

View File

@ -100,10 +100,10 @@ export async function run({
try { try {
let response let response
const customConfigsEnabled = const customConfigsEnabled =
(await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) && (await features.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) &&
(await pro.features.isAICustomConfigsEnabled()) (await pro.features.isAICustomConfigsEnabled())
const budibaseAIEnabled = const budibaseAIEnabled =
(await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) && (await features.isEnabled(FeatureFlag.BUDIBASE_AI)) &&
(await pro.features.isBudibaseAIEnabled()) (await pro.features.isBudibaseAIEnabled())
let llmWrapper let llmWrapper

View File

@ -7,7 +7,7 @@ import { fromZodError } from "zod-validation-error"
function validate(schema: AnyZodObject, property: "body" | "params") { function validate(schema: AnyZodObject, property: "body" | "params") {
// Return a Koa middleware function // Return a Koa middleware function
return async (ctx: Ctx, next: any) => { return async (ctx: Ctx, next: any) => {
if (!(await features.flags.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) { if (!(await features.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) {
return next() return next()
} }

View File

@ -174,7 +174,9 @@ class QueryRunner {
} }
// needs to an array for next step // needs to an array for next step
if (!Array.isArray(rows)) { if (rows === null) {
rows = []
} else if (!Array.isArray(rows)) {
rows = [rows] rows = [rows]
} }

View File

@ -1,5 +1,6 @@
import { License } from "../../../sdk" import { License } from "../../../sdk"
import { Account, DevInfo, User } from "../../../documents" import { Account, DevInfo, User } from "../../../documents"
import { FeatureFlags } from "@budibase/types"
export interface GenerateAPIKeyRequest { export interface GenerateAPIKeyRequest {
userId?: string userId?: string
@ -9,7 +10,7 @@ export interface GenerateAPIKeyResponse extends DevInfo {}
export interface FetchAPIKeyResponse extends DevInfo {} export interface FetchAPIKeyResponse extends DevInfo {}
export interface GetGlobalSelfResponse extends User { export interface GetGlobalSelfResponse extends User {
flags?: Record<string, any> flags?: FeatureFlags
account?: Account account?: Account
license: License license: License
budibaseAccess: boolean budibaseAccess: boolean

View File

@ -1,4 +1,4 @@
import { User, Document, Plugin, Snippet } from "../" import { User, Document, Plugin, Snippet, Theme } from "../"
import { SocketSession } from "../../sdk" import { SocketSession } from "../../sdk"
export type AppMetadataErrors = { [key: string]: string[] } export type AppMetadataErrors = { [key: string]: string[] }
@ -14,7 +14,7 @@ export interface App extends Document {
instance: AppInstance instance: AppInstance
tenantId: string tenantId: string
status: string status: string
theme?: string theme?: Theme
customTheme?: AppCustomTheme customTheme?: AppCustomTheme
revertableVersion?: string revertableVersion?: string
lockedBy?: User lockedBy?: User
@ -37,8 +37,8 @@ export interface AppInstance {
export interface AppNavigation { export interface AppNavigation {
navigation: string navigation: string
title: string title?: string
navWidth: string navWidth?: string
sticky?: boolean sticky?: boolean
hideLogo?: boolean hideLogo?: boolean
logoUrl?: string logoUrl?: string
@ -46,6 +46,7 @@ export interface AppNavigation {
navBackground?: string navBackground?: string
navTextColor?: string navTextColor?: string
links?: AppNavigationLink[] links?: AppNavigationLink[]
textAlign?: string
} }
export interface AppNavigationLink { export interface AppNavigationLink {
@ -53,6 +54,8 @@ export interface AppNavigationLink {
url: string url: string
id?: string id?: string
roleId?: string roleId?: string
type?: string
subLinks?: AppNavigationLink[]
} }
export interface AppCustomTheme { export interface AppCustomTheme {

View File

@ -6,6 +6,12 @@ export enum FeatureFlag {
USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR", USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR",
} }
export interface TenantFeatureFlags { export const FeatureFlagDefaults = {
[key: string]: FeatureFlag[] [FeatureFlag.DEFAULT_VALUES]: true,
[FeatureFlag.AUTOMATION_BRANCHING]: true,
[FeatureFlag.AI_CUSTOM_CONFIGS]: true,
[FeatureFlag.BUDIBASE_AI]: true,
[FeatureFlag.USE_ZOD_VALIDATOR]: false,
} }
export type FeatureFlags = typeof FeatureFlagDefaults

View File

@ -3,4 +3,5 @@ import { User } from "@budibase/types"
export interface UIUser extends User { export interface UIUser extends User {
sessionId: string sessionId: string
gridMetadata?: { focusedCellId?: string } gridMetadata?: { focusedCellId?: string }
builderMetadata?: { selectedResourceId?: string }
} }

View File

@ -1,3 +1,5 @@
export * from "./integration" export * from "./integration"
export * from "./misc"
export * from "./automations" export * from "./automations"
export * from "./grid" export * from "./grid"
export * from "./preview"

View File

@ -0,0 +1,2 @@
// type purely to capture structures that the type is unknown, but maybe known later
export type UIObject = Record<string, any>

View File

@ -0,0 +1 @@
export type PreviewDevice = "desktop" | "tablet" | "mobile"

View File

@ -72,12 +72,14 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
const requestUser = ctx.request.body const requestUser = ctx.request.body
// Do not allow the account holder role to be changed // Do not allow the account holder role to be changed
const accountMetadata = await users.getExistingAccounts([requestUser.email])
if (accountMetadata?.length > 0) {
if ( if (
requestUser.admin?.global !== true || requestUser.admin?.global !== true ||
requestUser.builder?.global !== true requestUser.builder?.global !== true
) { ) {
const accountMetadata = await users.getExistingAccounts([
requestUser.email,
])
if (accountMetadata?.length > 0) {
throw Error("Cannot set role of account holder") throw Error("Cannot set role of account holder")
} }
} }
@ -441,7 +443,6 @@ export const checkInvite = async (ctx: UserCtx<void, CheckInviteResponse>) => {
} catch (e) { } catch (e) {
console.warn("Error getting invite from code", e) console.warn("Error getting invite from code", e)
ctx.throw(400, "There was a problem with the invite") ctx.throw(400, "There was a problem with the invite")
return
} }
ctx.body = { ctx.body = {
email: invite.email, email: invite.email,
@ -472,7 +473,6 @@ export const updateInvite = async (
invite = await cache.invite.getCode(code) invite = await cache.invite.getCode(code)
} catch (e) { } catch (e) {
ctx.throw(400, "There was a problem with the invite") ctx.throw(400, "There was a problem with the invite")
return
} }
let updated = { let updated = {

View File

@ -2131,9 +2131,9 @@
through2 "^2.0.0" through2 "^2.0.0"
"@budibase/pro@npm:@budibase/pro@latest": "@budibase/pro@npm:@budibase/pro@latest":
version "3.2.28" version "3.2.32"
resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.28.tgz#59b5b37225715bb8fbf5b1c5c989140b10b58710" resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.32.tgz#f6abcd5a5524e7f33d958acb6e610e29995427bb"
integrity sha512-eDPeZpYFRZYQhCulcQAUwFoPk68c8+K9mIsB6QD3oMHmHTDA1P2ZcXvLNqDTIqHB94DqnWinqDf4hTuGYApgPA== integrity sha512-bF0pd17IjYugjll2yKYmb0RM+tfKZcCmRBc4XG2NZ4f/I47QaOovm9RqSw6tfqCFuzRewxR3SWmtmSseUc/e0w==
dependencies: dependencies:
"@anthropic-ai/sdk" "^0.27.3" "@anthropic-ai/sdk" "^0.27.3"
"@budibase/backend-core" "*" "@budibase/backend-core" "*"
@ -15600,15 +15600,7 @@ only@~0.0.2:
resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4"
integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ==
open@^7.3.1: open@8.4.2, open@^8.0.0, open@^8.4.0, open@~8.4.0:
version "7.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
dependencies:
is-docker "^2.0.0"
is-wsl "^2.1.1"
open@^8.0.0, open@^8.4.0, open@~8.4.0:
version "8.4.2" version "8.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9"
integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==
@ -15617,6 +15609,14 @@ open@^8.0.0, open@^8.4.0, open@~8.4.0:
is-docker "^2.1.1" is-docker "^2.1.1"
is-wsl "^2.2.0" is-wsl "^2.2.0"
open@^7.3.1:
version "7.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
dependencies:
is-docker "^2.0.0"
is-wsl "^2.1.1"
openai@4.59.0: openai@4.59.0:
version "4.59.0" version "4.59.0"
resolved "https://registry.yarnpkg.com/openai/-/openai-4.59.0.tgz#3961d11a9afb5920e1bd475948a87969e244fc08" resolved "https://registry.yarnpkg.com/openai/-/openai-4.59.0.tgz#3961d11a9afb5920e1bd475948a87969e244fc08"