Merge branch 'master' into s3-upload-fixes

This commit is contained in:
deanhannigan 2025-01-09 16:38:15 +00:00 committed by GitHub
commit bf3168314e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
127 changed files with 2421 additions and 1975 deletions

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.2.32",
"version": "3.2.37",
"npmClient": "yarn",
"concurrency": 20,
"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
): T | undefined {
): Record<string, boolean> | undefined {
const context = getCurrentContext()
if (!context) {
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()
if (!context) {
return

View File

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

View File

@ -2,9 +2,10 @@ import env from "../environment"
import * as crypto from "crypto"
import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag } from "@budibase/types"
import tracer from "dd-trace"
import { Duration } from "../utils"
import { cloneDeep } from "lodash"
import { FeatureFlagDefaults } from "@budibase/types"
let posthog: PostHog | undefined
export function init(opts?: PostHogOptions) {
@ -30,74 +31,6 @@ export function 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 {
tenantId: string
key: string
@ -120,7 +53,7 @@ export function parseEnvFlags(flags: string): EnvFlagEntry[] {
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.
// Because multiple sets could theoretically exist, we don't want the cache of
// 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()
}
defaults(): FlagValues<T> {
return Object.keys(this.flagSchema).reduce((acc, key) => {
const typedKey = key as keyof T
acc[typedKey] = this.flagSchema[key].defaultValue
return acc
}, {} as FlagValues<T>)
defaults(): T {
return cloneDeep(this.flagSchema)
}
isFlagName(name: string | number | symbol): name is keyof T {
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()
return flags[key]
}
async isEnabled<K extends KeysOfType<T, boolean>>(key: K): Promise<boolean> {
const flags = await this.fetch()
return flags[key]
}
async fetch(): Promise<FlagValues<T>> {
async fetch(): Promise<T> {
return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
const cachedFlags = context.getFeatureFlags(this.setId)
if (cachedFlags) {
span?.addTags({ fromCache: true })
return cachedFlags
return cachedFlags as T
}
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,
// 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"
}
@ -217,11 +141,11 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = { tenantId }
const posthogFlags = await posthog.getAllFlagsAndPayloads(userId, {
const posthogFlags = await posthog.getAllFlags(userId, {
personProperties,
})
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
for (const [name, value] of Object.entries(posthogFlags)) {
if (!this.isFlagName(name)) {
// We don't want an unexpected PostHog flag to break the app, so we
// just log it and continue.
@ -229,19 +153,20 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
continue
}
if (typeof value !== "boolean") {
console.warn(`Invalid value for posthog flag "${name}": ${value}`)
continue
}
if (flagValues[name] === true || specificallySetFalse.has(name)) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
const payload = posthogFlags.featureFlagPayloads?.[name]
const flag = this.flagSchema[name]
try {
// @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.
flagValues[name] = flag.parse(payload || value)
// @ts-expect-error - TS does not like you writing into a generic type.
flagValues[name] = value
tags[`flags.${name}.source`] = "posthog"
} catch (err) {
// 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
// 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)
export const flags = new FlagSet(FeatureFlagDefaults)
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
export type FeatureFlags = UnwrapPromise<ReturnType<typeof flags.fetch>>
export async function isEnabled(flag: keyof typeof FeatureFlagDefaults) {
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 { Flag, FlagSet, FlagValues, init, shutdown } from "../"
import { FlagSet, init, shutdown } from "../"
import * as context from "../../context"
import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch"
@ -7,10 +7,8 @@ import nock from "nock"
import * as crypto from "crypto"
const schema = {
TEST_BOOLEAN: Flag.boolean(false),
TEST_STRING: Flag.string("default value"),
TEST_NUMBER: Flag.number(0),
TEST_BOOLEAN_DEFAULT_TRUE: Flag.boolean(true),
TEST_BOOLEAN: false,
TEST_BOOLEAN_DEFAULT_TRUE: true,
}
const flags = new FlagSet(schema)
@ -19,7 +17,7 @@ interface TestCase {
identity?: Partial<IdentityContext>
environmentFlags?: string
posthogFlags?: PostHogFlags
expected?: Partial<FlagValues<typeof schema>>
expected?: Partial<typeof schema>
errorMessage?: string | RegExp
}
@ -83,22 +81,6 @@ describe("feature flags", () => {
},
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",
environmentFlags: "default:!TEST_BOOLEAN",
@ -177,7 +159,7 @@ describe("feature flags", () => {
expect(values).toMatchObject(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)
}
} else {

View File

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

3
packages/bbui/src/helpers.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
declare module "./helpers" {
export const cloneDeep: <T>(obj: T) => T
}

View File

@ -43,7 +43,6 @@
export let showDataProviders = true
const dispatch = createEventDispatcher()
const arrayTypes = ["attachment", "array"]
let anchorRight, dropdownRight
let drawer
@ -116,8 +115,11 @@
}
})
$: fields = bindings
.filter(x => arrayTypes.includes(x.fieldSchema?.type))
.filter(x => x.fieldSchema?.tableId != null)
.filter(
x =>
x.fieldSchema?.type === "attachment" ||
(x.fieldSchema?.type === "array" && x.tableId)
)
.map(binding => {
const { providerId, readableBinding, runtimeBinding } = binding
const { name, type, tableId } = binding.fieldSchema

View File

@ -236,13 +236,13 @@
}
if (!role) {
await groups.actions.removeApp(target._id, prodAppId)
await groups.removeApp(target._id, prodAppId)
} else {
await groups.actions.addApp(target._id, prodAppId, role)
await groups.addApp(target._id, prodAppId, role)
}
await usersFetch.refresh()
await groups.actions.init()
await groups.init()
}
const onUpdateGroup = async (group, role) => {
@ -268,7 +268,7 @@
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(appId)
return groups.getGroupAppIds(group).includes(appId)
})
}
@ -299,7 +299,7 @@
role: group?.builder?.apps.includes(prodAppId)
? Constants.Roles.CREATOR
: group.roles?.[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
groups.getGroupAppIds(group).find(x => x === prodAppId)
],
}
}
@ -485,12 +485,12 @@
}
const removeGroupAppBuilder = async groupId => {
await groups.actions.removeGroupAppBuilder(groupId, prodAppId)
await groups.removeGroupAppBuilder(groupId, prodAppId)
}
const initSidePanel = async sidePaneOpen => {
if (sidePaneOpen === true) {
await groups.actions.init()
await groups.init()
}
loaded = true
}

View File

@ -53,7 +53,7 @@
}
if (!Object.keys(user?.roles).length && user?.userGroups) {
return userGroups.find(group => {
return groups.actions
return groups
.getGroupAppIds(group)
.map(role => appsStore.extractAppId(role))
.includes(app.appId)
@ -86,7 +86,7 @@
try {
await organisation.init()
await appsStore.load()
await groups.actions.init()
await groups.init()
} catch (error) {
notifications.error("Error loading apps")
}

View File

@ -24,7 +24,7 @@
promises.push(templates.load())
}
promises.push(groups.actions.init())
promises.push(groups.init())
// Always load latest
await Promise.all(promises)

View File

@ -34,7 +34,7 @@
async function saveTemplate() {
try {
// Save your template config
await email.templates.save(selectedTemplate)
await email.saveTemplate(selectedTemplate)
notifications.success("Template saved")
} catch (error) {
notifications.error("Failed to update template settings")

View File

@ -5,7 +5,7 @@
onMount(async () => {
try {
await email.templates.fetch()
await email.fetchTemplates()
} catch (error) {
notifications.error("Error fetching email templates")
}

View File

@ -10,7 +10,8 @@
let deleteDialog
const save = async data => {
await environment.updateVariable(data)
const { name, ...rest } = data
await environment.updateVariable(name, rest)
editVariableModal.hide()
}
</script>

View File

@ -53,9 +53,7 @@
$: readonly = !isAdmin || isScimGroup
$: groupApps = $appsStore.apps
.filter(app =>
groups.actions
.getGroupAppIds(group)
.includes(appsStore.getProdAppID(app.devId))
groups.getGroupAppIds(group).includes(appsStore.getProdAppID(app.devId))
)
.map(app => ({
...app,
@ -72,7 +70,7 @@
async function deleteGroup() {
try {
await groups.actions.delete(group)
await groups.delete(group)
notifications.success("User group deleted successfully")
$goto("./")
} catch (error) {
@ -82,7 +80,7 @@
async function saveGroup(group) {
try {
await groups.actions.save(group)
await groups.save(group)
} catch (error) {
if (error.message) {
notifications.error(error.message)
@ -93,7 +91,7 @@
}
const removeApp = async app => {
await groups.actions.removeApp(groupId, appsStore.getProdAppID(app.devId))
await groups.removeApp(groupId, appsStore.getProdAppID(app.devId))
}
setContext("roles", {
updateRole: () => {},
@ -102,7 +100,7 @@
onMount(async () => {
try {
await Promise.all([groups.actions.init(), roles.fetch()])
await Promise.all([groups.init(), roles.fetch()])
loaded = true
} catch (error) {
notifications.error("Error fetching user group data")

View File

@ -23,7 +23,7 @@
return keepOpen
} else {
await groups.actions.addApp(group._id, prodAppId, selectedRoleId)
await groups.addApp(group._id, prodAppId, selectedRoleId)
}
}
</script>

View File

@ -50,11 +50,11 @@
selected={group.users?.map(user => user._id)}
list={$users.data}
on:select={async e => {
await groups.actions.addUser(groupId, e.detail)
await groups.addUser(groupId, e.detail)
onUsersUpdated()
}}
on:deselect={async e => {
await groups.actions.removeUser(groupId, e.detail)
await groups.removeUser(groupId, e.detail)
onUsersUpdated()
}}
/>

View File

@ -60,7 +60,7 @@
async function saveGroup(group) {
try {
group = await groups.actions.save(group)
group = await groups.save(group)
$goto(`./${group._id}`)
notifications.success(`User group created successfully`)
} catch (error) {
@ -83,7 +83,7 @@
try {
// always load latest
await licensing.init()
await groups.actions.init()
await groups.init()
} catch (error) {
notifications.error("Error getting user groups")
}

View File

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

View File

@ -247,7 +247,7 @@
try {
bulkSaveResponse = await users.create(await removingDuplicities(userData))
notifications.success("Successfully created user")
await groups.actions.init()
await groups.init()
passwordModal.show()
await fetch.refresh()
} catch (error) {
@ -317,7 +317,7 @@
onMount(async () => {
try {
await groups.actions.init()
await groups.init()
groupsLoaded = true
} catch (error) {
notifications.error("Error fetching user group data")

View File

@ -53,17 +53,24 @@ export class BudiStore<T> {
}
}
export class DerivedBudiStore<T, DerivedT extends T> extends BudiStore<T> {
// This deliberately does not extend a BudiStore as doing so imposes a requirement that
// DerivedT must extend T, which is not desirable, due to the type of the subscribe property.
export class DerivedBudiStore<T, DerivedT> {
store: BudiStore<T>
derivedStore: Readable<DerivedT>
subscribe: Readable<DerivedT>["subscribe"]
update: Writable<T>["update"]
set: Writable<T>["set"]
constructor(
init: T,
makeDerivedStore: (store: Writable<T>) => Readable<DerivedT>,
opts?: BudiStoreOpts
) {
super(init, opts)
this.store = new BudiStore(init, opts)
this.derivedStore = makeDerivedStore(this.store)
this.subscribe = this.derivedStore.subscribe
this.update = this.store.update
this.set = this.store.set
}
}

View File

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

View File

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

View File

@ -1,13 +1,16 @@
import { appStore } from "./app"
import { appsStore } from "@/stores/portal/apps"
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]) => {
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
}
)

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 { 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() {
super(initialState)
}
reset = () => {
this.store.set(initialState)
this.set(initialState)
}
refreshRowActions = async sourceId => {
refreshRowActions = async (sourceId: string) => {
if (!sourceId) {
return
}
@ -34,26 +45,30 @@ export class RowActionStore extends BudiStore {
// Fetch row actions for this table
const res = await API.rowActions.fetch(tableId)
const actions = Object.values(res || {})
const actions = Object.values(res || {}) as RowAction[]
this.update(state => ({
...state,
[tableId]: actions,
}))
}
createRowAction = async (tableId, viewId, name) => {
createRowAction = async (tableId: string, viewId?: string, name?: string) => {
if (!tableId) {
return
}
// Get a unique name for this action
if (!name) {
const existingRowActions = get(this.store)[tableId] || []
const existingRowActions = get(this)[tableId] || []
name = getSequentialName(existingRowActions, "New row action ", {
getName: x => x.name,
})
}
if (!name) {
throw new Error("Failed to generate a unique name for the row action")
}
// Create the action
const res = await API.rowActions.create(tableId, name)
@ -73,41 +88,35 @@ export class RowActionStore extends BudiStore {
return res
}
enableView = async (tableId, rowActionId, viewId) => {
enableView = async (tableId: string, rowActionId: string, viewId: string) => {
await API.rowActions.enableView(tableId, rowActionId, viewId)
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 this.refreshRowActions(tableId)
}
rename = async (tableId, rowActionId, name) => {
await API.rowActions.update({
tableId,
rowActionId,
name,
})
await this.refreshRowActions(tableId)
automationStore.actions.fetch()
}
delete = async (tableId, rowActionId) => {
delete = async (tableId: string, rowActionId: string) => {
await API.rowActions.delete(tableId, rowActionId)
await this.refreshRowActions(tableId)
// 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
}
trigger = async (sourceId, rowActionId, rowId) => {
trigger = async (sourceId: string, rowActionId: string, rowId: string) => {
await API.rowActions.trigger(sourceId, rowActionId, rowId)
}
}
const store = new RowActionStore()
const derivedStore = derived(store, $store => {
let map = {}
const derivedStore = derived<RowActionStore, RowActionState>(store, $store => {
const map: RowActionState = {}
// Generate an entry for every view as well
Object.keys($store || {}).forEach(tableId => {
@ -115,7 +124,7 @@ const derivedStore = derived(store, $store => {
map[tableId] = $store[tableId]
for (let action of $store[tableId]) {
const otherSources = (action.allowedSources || []).filter(
sourceId => sourceId !== tableId
(sourceId: string) => sourceId !== tableId
)
for (let source of otherSources) {
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

@ -4,14 +4,14 @@ import { admin } from "@/stores/portal"
import analytics from "@/analytics"
import { BudiStore } from "@/stores/BudiStore"
import {
GetGlobalSelfResponse,
isSSOUser,
SetInitInfoRequest,
UpdateSelfRequest,
User,
} from "@budibase/types"
interface PortalAuthStore {
user?: User
user?: GetGlobalSelfResponse
initInfo?: Record<string, any>
accountPortalAccess: boolean
loaded: boolean
@ -33,7 +33,7 @@ class AuthStore extends BudiStore<PortalAuthStore> {
})
}
setUser(user?: User) {
setUser(user?: GetGlobalSelfResponse) {
this.set({
loaded: true,
user: user,

View File

@ -1,40 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function createBackupsStore() {
const store = writable({})
function selectBackup(backupId) {
store.update(state => {
state.selectedBackup = backupId
return state
})
}
async function searchBackups(appId, opts) {
return API.searchBackups(appId, opts)
}
async function restoreBackup(appId, backupId, name) {
return API.restoreBackup(appId, backupId, name)
}
async function deleteBackup(appId, backupId) {
return API.deleteBackup(appId, backupId)
}
async function createManualBackup(appId) {
return API.createManualBackup(appId)
}
return {
createManualBackup,
searchBackups,
selectBackup,
deleteBackup,
restoreBackup,
subscribe: store.subscribe,
}
}
export const backups = createBackupsStore()

View File

@ -1,5 +1,5 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import { createBackupsStore } from "./backups"
import { BackupStore } from "./backups"
import { writable } from "svelte/store"
import { API } from "@/api"
@ -33,7 +33,7 @@ describe("backups store", () => {
ctx.writableReturn = { update: vi.fn(), subscribe: vi.fn() }
writable.mockReturnValue(ctx.writableReturn)
ctx.returnedStore = createBackupsStore()
ctx.returnedStore = new BackupStore()
})
it("inits the writable store with the default config", () => {

View File

@ -0,0 +1,38 @@
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import { SearchAppBackupsRequest } from "@budibase/types"
interface BackupState {
selectedBackup?: string
}
export class BackupStore extends BudiStore<BackupState> {
constructor() {
super({})
}
selectBackup(backupId: string) {
this.update(state => {
state.selectedBackup = backupId
return state
})
}
async searchBackups(appId: string, opts: SearchAppBackupsRequest) {
return API.searchBackups(appId, opts)
}
async restoreBackup(appId: string, backupId: string, name?: string) {
return API.restoreBackup(appId, backupId, name)
}
async deleteBackup(appId: string, backupId: string) {
return API.deleteBackup(appId, backupId)
}
async createManualBackup(appId: string) {
return API.createManualBackup(appId)
}
}
export const backups = new BackupStore()

View File

@ -1,36 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
export function createEmailStore() {
const store = writable({})
return {
subscribe: store.subscribe,
templates: {
fetch: async () => {
// Fetch the email template definitions and templates
const definitions = await API.getEmailTemplateDefinitions()
const templates = await API.getEmailTemplates()
store.set({
definitions,
templates,
})
},
save: async template => {
// Save your template config
const savedTemplate = await API.saveEmailTemplate(template)
template._rev = savedTemplate._rev
template._id = savedTemplate._id
store.update(state => {
const currentIdx = state.templates.findIndex(
template => template.purpose === savedTemplate.purpose
)
state.templates.splice(currentIdx, 1, template)
return state
})
},
},
}
}
export const email = createEmailStore()

View File

@ -0,0 +1,43 @@
import { API } from "@/api"
import { BudiStore } from "../BudiStore"
import {
FetchGlobalTemplateDefinitionResponse,
Template,
} from "@budibase/types"
interface EmailState {
definitions?: FetchGlobalTemplateDefinitionResponse
templates: Template[]
}
class EmailStore extends BudiStore<EmailState> {
constructor() {
super({
templates: [],
})
}
async fetchTemplates() {
const definitions = await API.getEmailTemplateDefinitions()
const templates = await API.getEmailTemplates()
this.set({
definitions,
templates,
})
}
async saveTemplate(template: Template) {
const savedTemplate = await API.saveEmailTemplate(template)
template._rev = savedTemplate._rev
template._id = savedTemplate._id
this.update(state => {
const currentIdx = state.templates.findIndex(
template => template.purpose === savedTemplate.purpose
)
state.templates.splice(currentIdx, 1, template)
return state
})
}
}
export const email = new EmailStore()

View File

@ -1,71 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { Constants } from "@budibase/frontend-core"
import { licensing } from "@/stores/portal"
export function createEnvironmentStore() {
const { subscribe, update } = writable({
variables: [],
status: {},
})
async function checkStatus() {
const status = await API.checkEnvironmentVariableStatus()
update(store => {
store.status = status
return store
})
}
async function loadVariables() {
if (get(licensing).environmentVariablesEnabled) {
const envVars = await API.fetchEnvironmentVariables()
const mappedVars = envVars.variables.map(name => ({ name }))
update(store => {
store.variables = mappedVars
return store
})
}
}
async function createVariable(data) {
await API.createEnvironmentVariable(data)
let mappedVar = { name: data.name }
update(store => {
store.variables = [mappedVar, ...store.variables]
return store
})
}
async function deleteVariable(varName) {
await API.deleteEnvironmentVariable(varName)
update(store => {
store.variables = store.variables.filter(
envVar => envVar.name !== varName
)
return store
})
}
async function updateVariable(data) {
await API.updateEnvironmentVariable(data)
}
async function upgradePanelOpened() {
await API.publishEvent(
Constants.EventPublishType.ENV_VAR_UPGRADE_PANEL_OPENED
)
}
return {
subscribe,
checkStatus,
loadVariables,
createVariable,
deleteVariable,
updateVariable,
upgradePanelOpened,
}
}
export const environment = createEnvironmentStore()

View File

@ -0,0 +1,79 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { licensing } from "@/stores/portal"
import { BudiStore } from "../BudiStore"
import {
CreateEnvironmentVariableRequest,
EventPublishType,
StatusEnvironmentVariableResponse,
UpdateEnvironmentVariableRequest,
} from "@budibase/types"
type EnvVar = {
name: string
}
interface EnvironmentState {
variables: EnvVar[]
status: StatusEnvironmentVariableResponse
}
class EnvironmentStore extends BudiStore<EnvironmentState> {
constructor() {
super({
variables: [],
status: {
encryptionKeyAvailable: false,
},
})
}
async checkStatus() {
const status = await API.checkEnvironmentVariableStatus()
this.update(store => {
store.status = status
return store
})
}
async loadVariables() {
if (get(licensing).environmentVariablesEnabled) {
const envVars: string[] = (await API.fetchEnvironmentVariables())
.variables
const mappedVars = envVars.map(name => ({ name }))
this.update(store => {
store.variables = mappedVars
return store
})
}
}
async createVariable(data: CreateEnvironmentVariableRequest) {
await API.createEnvironmentVariable(data)
let mappedVar = { name: data.name }
this.update(state => {
state.variables = [mappedVar, ...state.variables]
return state
})
}
async deleteVariable(name: string) {
await API.deleteEnvironmentVariable(name)
this.update(state => {
state.variables = state.variables.filter(envVar => envVar.name !== name)
return state
})
}
async updateVariable(name: string, data: UpdateEnvironmentVariableRequest) {
await API.updateEnvironmentVariable(name, data)
}
async upgradePanelOpened() {
await API.publishEvent(
EventPublishType.ENVIRONMENT_VARIABLE_UPGRADE_PANEL_OPENED
)
}
}
export const environment = new EnvironmentStore()

View File

@ -1,16 +0,0 @@
import { derived } from "svelte/store"
import { auth } from "@/stores/portal"
export const INITIAL_FEATUREFLAG_STATE = {
SQS: false,
DEFAULT_VALUES: false,
BUDIBASE_AI: false,
AI_CUSTOM_CONFIGS: false,
}
export const featureFlags = derived([auth], ([$auth]) => {
return {
...INITIAL_FEATUREFLAG_STATE,
...($auth?.user?.flags || {}),
}
})

View File

@ -0,0 +1,8 @@
import { derived, Readable } from "svelte/store"
import { auth } from "@/stores/portal"
import { FeatureFlags, FeatureFlagDefaults } from "@budibase/types"
export const featureFlags: Readable<FeatureFlags> = derived(auth, $auth => ({
...FeatureFlagDefaults,
...($auth?.user?.flags || {}),
}))

View File

@ -1,54 +0,0 @@
import { writable } from "svelte/store"
import { API } from "@/api"
import { licensing } from "./licensing"
import { ConfigType } from "@budibase/types"
export const createFeatureStore = () => {
const internalStore = writable({
scim: {
isFeatureFlagEnabled: false,
isConfigFlagEnabled: false,
},
})
const store = writable({
isScimEnabled: false,
})
internalStore.subscribe(s => {
store.update(state => ({
...state,
isScimEnabled: s.scim.isFeatureFlagEnabled && s.scim.isConfigFlagEnabled,
}))
})
licensing.subscribe(v => {
internalStore.update(state => ({
...state,
scim: {
...state.scim,
isFeatureFlagEnabled: v.scimEnabled,
},
}))
})
const actions = {
init: async () => {
const scimConfig = await API.getConfig(ConfigType.SCIM)
internalStore.update(state => ({
...state,
scim: {
...state.scim,
isConfigFlagEnabled: scimConfig?.config?.enabled,
},
}))
},
}
return {
subscribe: store.subscribe,
...actions,
}
}
export const features = createFeatureStore()

View File

@ -0,0 +1,35 @@
import { derived, Writable } from "svelte/store"
import { API } from "@/api"
import { licensing } from "./licensing"
import { ConfigType, isConfig, isSCIMConfig } from "@budibase/types"
import { DerivedBudiStore } from "../BudiStore"
interface FeatureState {
scimConfigEnabled: Boolean
}
interface DerivedFeatureState {
isScimEnabled: Boolean
}
class FeatureStore extends DerivedBudiStore<FeatureState, DerivedFeatureState> {
constructor() {
const makeDerivedStore = (store: Writable<FeatureState>) => {
return derived([store, licensing], ([$state, $licensing]) => ({
isScimEnabled: $state.scimConfigEnabled && $licensing.scimEnabled,
}))
}
super({ scimConfigEnabled: false }, makeDerivedStore)
}
async init() {
const config = await API.getConfig(ConfigType.SCIM)
this.update(state => ({
...state,
scimConfigEnabled:
isConfig(config) && isSCIMConfig(config) && config.config.enabled,
}))
}
}
export const features = new FeatureStore()

View File

@ -1,103 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { licensing } from "@/stores/portal"
export function createGroupsStore() {
const store = writable([])
const updateStore = group => {
store.update(state => {
const currentIdx = state.findIndex(gr => gr._id === group._id)
if (currentIdx >= 0) {
state.splice(currentIdx, 1, group)
} else {
state.push(group)
}
return state
})
}
const getGroup = async groupId => {
const group = await API.getGroup(groupId)
updateStore(group)
}
const actions = {
init: async () => {
// only init if there is a groups license, just to be sure but the feature will be blocked
// on the backend anyway
if (get(licensing).groupsEnabled) {
const groups = await API.getGroups()
store.set(groups.data)
}
},
get: getGroup,
save: async group => {
const { ...dataToSave } = group
delete dataToSave.scimInfo
delete dataToSave.userGroups
const response = await API.saveGroup(dataToSave)
group._id = response._id
group._rev = response._rev
updateStore(group)
return group
},
delete: async group => {
await API.deleteGroup(group._id, group._rev)
store.update(state => {
state = state.filter(state => state._id !== group._id)
return state
})
},
addUser: async (groupId, userId) => {
await API.addUsersToGroup(groupId, userId)
// refresh the group enrichment
await getGroup(groupId)
},
removeUser: async (groupId, userId) => {
await API.removeUsersFromGroup(groupId, userId)
// refresh the group enrichment
await getGroup(groupId)
},
addApp: async (groupId, appId, roleId) => {
await API.addAppsToGroup(groupId, [{ appId, roleId }])
// refresh the group roles
await getGroup(groupId)
},
removeApp: async (groupId, appId) => {
await API.removeAppsFromGroup(groupId, [{ appId }])
// refresh the group roles
await getGroup(groupId)
},
getGroupAppIds: group => {
let groupAppIds = Object.keys(group?.roles || {})
if (group?.builder?.apps) {
groupAppIds = groupAppIds.concat(group.builder.apps)
}
return groupAppIds
},
addGroupAppBuilder: async (groupId, appId) => {
return await API.addGroupAppBuilder(groupId, appId)
},
removeGroupAppBuilder: async (groupId, appId) => {
return await API.removeGroupAppBuilder(groupId, appId)
},
}
return {
subscribe: store.subscribe,
actions,
}
}
export const groups = createGroupsStore()

View File

@ -0,0 +1,96 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { licensing } from "@/stores/portal"
import { UserGroup } from "@budibase/types"
import { BudiStore } from "../BudiStore"
class GroupStore extends BudiStore<UserGroup[]> {
constructor() {
super([])
}
updateStore = (group: UserGroup) => {
this.update(state => {
const currentIdx = state.findIndex(gr => gr._id === group._id)
if (currentIdx >= 0) {
state.splice(currentIdx, 1, group)
} else {
state.push(group)
}
return state
})
}
async init() {
// Only init if there is a groups license, just to be sure but the feature will be blocked
// on the backend anyway
if (get(licensing).groupsEnabled) {
const groups = await API.getGroups()
this.set(groups)
}
}
private async refreshGroup(groupId: string) {
const group = await API.getGroup(groupId)
this.updateStore(group)
}
async save(group: UserGroup) {
const { ...dataToSave } = group
delete dataToSave.scimInfo
const response = await API.saveGroup(dataToSave)
group._id = response._id
group._rev = response._rev
this.updateStore(group)
return group
}
async delete(group: UserGroup) {
await API.deleteGroup(group._id!, group._rev!)
this.update(groups => {
const index = groups.findIndex(g => g._id === group._id)
if (index !== -1) {
groups.splice(index, 1)
}
return groups
})
}
async addUser(groupId: string, userId: string) {
await API.addUsersToGroup(groupId, [userId])
await this.refreshGroup(groupId)
}
async removeUser(groupId: string, userId: string) {
await API.removeUsersFromGroup(groupId, [userId])
await this.refreshGroup(groupId)
}
async addApp(groupId: string, appId: string, roleId: string) {
await API.addAppsToGroup(groupId, [{ appId, roleId }])
await this.refreshGroup(groupId)
}
async removeApp(groupId: string, appId: string) {
await API.removeAppsFromGroup(groupId, [{ appId }])
await this.refreshGroup(groupId)
}
getGroupAppIds(group: UserGroup) {
let groupAppIds = Object.keys(group?.roles || {})
if (group?.builder?.apps) {
groupAppIds = groupAppIds.concat(group.builder.apps)
}
return groupAppIds
}
async addGroupAppBuilder(groupId: string, appId: string) {
return await API.addGroupAppBuilder(groupId, appId)
}
async removeGroupAppBuilder(groupId: string, appId: string) {
return await API.removeGroupAppBuilder(groupId, appId)
}
}
export const groups = new GroupStore()

View File

@ -1,279 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth, admin } from "@/stores/portal"
import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "@/components/portal/licensing/constants"
import { PlanModel } from "@budibase/types"
const UNLIMITED = -1
export const createLicensingStore = () => {
const DEFAULT = {
// navigation
goToUpgradePage: () => {},
goToPricingPage: () => {},
// the top level license
license: undefined,
isFreePlan: true,
isEnterprisePlan: true,
isBusinessPlan: true,
// features
groupsEnabled: false,
backupsEnabled: false,
brandingEnabled: false,
scimEnabled: false,
environmentVariablesEnabled: false,
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
auditLogsEnabled: false,
// the currently used quotas from the db
quotaUsage: undefined,
// derived quota metrics for percentages used
usageMetrics: undefined,
// quota reset
quotaResetDaysRemaining: undefined,
quotaResetDate: undefined,
// failed payments
accountPastDue: undefined,
pastDueEndDate: undefined,
pastDueDaysRemaining: undefined,
accountDowngraded: undefined,
// user limits
userCount: undefined,
userLimit: undefined,
userLimitReached: false,
errUserLimit: false,
}
const oneDayInMilliseconds = 86400000
const store = writable(DEFAULT)
function usersLimitReached(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount >= userLimit
}
function usersLimitExceeded(userCount, userLimit) {
if (userLimit === UNLIMITED) {
return false
}
return userCount > userLimit
}
async function isCloud() {
let adminStore = get(admin)
if (!adminStore.loaded) {
await admin.init()
adminStore = get(admin)
}
return adminStore.cloud
}
const actions = {
init: async () => {
actions.setNavigation()
actions.setLicense()
await actions.setQuotaUsage()
},
setNavigation: () => {
const adminStore = get(admin)
const authStore = get(auth)
const upgradeUrl = authStore?.user?.accountPortalAccess
? `${adminStore.accountPortalUrl}/portal/upgrade`
: "/builder/portal/account/upgrade"
const goToUpgradePage = () => {
window.location.href = upgradeUrl
}
const goToPricingPage = () => {
window.open("https://budibase.com/pricing/", "_blank")
}
store.update(state => {
return {
...state,
goToUpgradePage,
goToPricingPage,
}
})
},
setLicense: () => {
const license = get(auth).user.license
const planType = license?.plan.type
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
const isFreePlan = planType === Constants.PlanType.FREE
const isBusinessPlan = planType === Constants.PlanType.BUSINESS
const isEnterpriseTrial =
planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL
const groupsEnabled = license.features.includes(
Constants.Features.USER_GROUPS
)
const backupsEnabled = license.features.includes(
Constants.Features.APP_BACKUPS
)
const scimEnabled = license.features.includes(Constants.Features.SCIM)
const environmentVariablesEnabled = license.features.includes(
Constants.Features.ENVIRONMENT_VARIABLES
)
const enforceableSSO = license.features.includes(
Constants.Features.ENFORCEABLE_SSO
)
const brandingEnabled = license.features.includes(
Constants.Features.BRANDING
)
const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS
)
const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
const triggerAutomationRunEnabled = license.features.includes(
Constants.Features.TRIGGER_AUTOMATION_RUN
)
const perAppBuildersEnabled = license.features.includes(
Constants.Features.APP_BUILDERS
)
const budibaseAIEnabled = license.features.includes(
Constants.Features.BUDIBASE_AI
)
const customAIConfigsEnabled = license.features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
store.update(state => {
return {
...state,
license,
isEnterprisePlan,
isFreePlan,
isBusinessPlan,
isEnterpriseTrial,
groupsEnabled,
backupsEnabled,
brandingEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled,
environmentVariablesEnabled,
auditLogsEnabled,
enforceableSSO,
syncAutomationsEnabled,
triggerAutomationRunEnabled,
perAppBuildersEnabled,
}
})
},
setQuotaUsage: async () => {
const quotaUsage = await API.getQuotaUsage()
store.update(state => {
return {
...state,
quotaUsage,
}
})
await actions.setUsageMetrics()
},
usersLimitReached: userCount => {
return usersLimitReached(userCount, get(store).userLimit)
},
usersLimitExceeded(userCount) {
return usersLimitExceeded(userCount, get(store).userLimit)
},
setUsageMetrics: async () => {
const usage = get(store).quotaUsage
const license = get(auth).user.license
const now = new Date()
const getMetrics = (keys, license, quota) => {
if (!license || !quota || !keys) {
return {}
}
return keys.reduce((acc, key) => {
const quotaLimit = license[key].value
const quotaUsed = (quota[key] / quotaLimit) * 100
acc[key] = quotaLimit > -1 ? Math.floor(quotaUsed) : -1
return acc
}, {})
}
const monthlyMetrics = getMetrics(
["queries", "automations"],
license.quotas.usage.monthly,
usage.monthly.current
)
const staticMetrics = getMetrics(
["apps", "rows"],
license.quotas.usage.static,
usage.usageQuota
)
const getDaysBetween = (dateStart, dateEnd) => {
return dateEnd > dateStart
? Math.round(
(dateEnd.getTime() - dateStart.getTime()) / oneDayInMilliseconds
)
: 0
}
const quotaResetDate = new Date(usage.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
license?.billing?.subscription?.downgradeAt &&
license?.billing?.subscription?.downgradeAt <= now.getTime() &&
license?.billing?.subscription?.status === StripeStatus.PAST_DUE &&
license?.plan.type === Constants.PlanType.FREE
const pastDueAtMilliseconds = license?.billing?.subscription?.pastDueAt
const downgradeAtMilliseconds =
license?.billing?.subscription?.downgradeAt
let pastDueDaysRemaining
let pastDueEndDate
if (pastDueAtMilliseconds && downgradeAtMilliseconds) {
pastDueEndDate = new Date(downgradeAtMilliseconds)
pastDueDaysRemaining = getDaysBetween(
new Date(pastDueAtMilliseconds),
pastDueEndDate
)
}
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota?.value
const userCount = usage.usageQuota.users
const userLimitReached = usersLimitReached(userCount, userLimit)
const userLimitExceeded = usersLimitExceeded(userCount, userLimit)
const isCloudAccount = await isCloud()
const errUserLimit =
isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
store.update(state => {
return {
...state,
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
quotaResetDaysRemaining,
quotaResetDate,
accountDowngraded,
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
// user limits
userCount,
userLimit,
userLimitReached,
errUserLimit,
}
})
},
}
return {
subscribe: store.subscribe,
...actions,
}
}
export const licensing = createLicensingStore()

View File

@ -0,0 +1,305 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { auth, admin } from "@/stores/portal"
import { Constants } from "@budibase/frontend-core"
import { StripeStatus } from "@/components/portal/licensing/constants"
import {
License,
MonthlyQuotaName,
PlanModel,
QuotaUsage,
StaticQuotaName,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
const UNLIMITED = -1
const ONE_DAY_MILLIS = 86400000
type MonthlyMetrics = { [key in MonthlyQuotaName]?: number }
type StaticMetrics = { [key in StaticQuotaName]?: number }
type UsageMetrics = MonthlyMetrics & StaticMetrics
interface LicensingState {
goToUpgradePage: () => void
goToPricingPage: () => void
// the top level license
license?: License
isFreePlan: boolean
isEnterprisePlan: boolean
isBusinessPlan: boolean
// features
groupsEnabled: boolean
backupsEnabled: boolean
brandingEnabled: boolean
scimEnabled: boolean
environmentVariablesEnabled: boolean
budibaseAIEnabled: boolean
customAIConfigsEnabled: boolean
auditLogsEnabled: boolean
// the currently used quotas from the db
quotaUsage?: QuotaUsage
// derived quota metrics for percentages used
usageMetrics?: UsageMetrics
// quota reset
quotaResetDaysRemaining?: number
quotaResetDate?: Date
// failed payments
accountPastDue: boolean
pastDueEndDate?: Date
pastDueDaysRemaining?: number
accountDowngraded: boolean
// user limits
userCount?: number
userLimit?: number
userLimitReached: boolean
errUserLimit: boolean
}
class LicensingStore extends BudiStore<LicensingState> {
constructor() {
super({
// navigation
goToUpgradePage: () => {},
goToPricingPage: () => {},
// the top level license
license: undefined,
isFreePlan: true,
isEnterprisePlan: true,
isBusinessPlan: true,
// features
groupsEnabled: false,
backupsEnabled: false,
brandingEnabled: false,
scimEnabled: false,
environmentVariablesEnabled: false,
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
auditLogsEnabled: false,
// the currently used quotas from the db
quotaUsage: undefined,
// derived quota metrics for percentages used
usageMetrics: undefined,
// quota reset
quotaResetDaysRemaining: undefined,
quotaResetDate: undefined,
// failed payments
accountPastDue: false,
pastDueEndDate: undefined,
pastDueDaysRemaining: undefined,
accountDowngraded: false,
// user limits
userCount: undefined,
userLimit: undefined,
userLimitReached: false,
errUserLimit: false,
})
}
usersLimitReached(userCount: number, userLimit = get(this.store).userLimit) {
if (userLimit === UNLIMITED || userLimit === undefined) {
return false
}
return userCount >= userLimit
}
usersLimitExceeded(userCount: number, userLimit = get(this.store).userLimit) {
if (userLimit === UNLIMITED || userLimit === undefined) {
return false
}
return userCount > userLimit
}
async isCloud() {
let adminStore = get(admin)
if (!adminStore.loaded) {
await admin.init()
adminStore = get(admin)
}
return adminStore.cloud
}
async init() {
this.setNavigation()
this.setLicense()
await this.setQuotaUsage()
}
setNavigation() {
const adminStore = get(admin)
const authStore = get(auth)
const upgradeUrl = authStore?.user?.accountPortalAccess
? `${adminStore.accountPortalUrl}/portal/upgrade`
: "/builder/portal/account/upgrade"
const goToUpgradePage = () => {
window.location.href = upgradeUrl
}
const goToPricingPage = () => {
window.open("https://budibase.com/pricing/", "_blank")
}
this.update(state => {
return {
...state,
goToUpgradePage,
goToPricingPage,
}
})
}
setLicense() {
const license = get(auth).user?.license
const planType = license?.plan.type
const features = license?.features || []
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
const isFreePlan = planType === Constants.PlanType.FREE
const isBusinessPlan = planType === Constants.PlanType.BUSINESS
const isEnterpriseTrial =
planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL
const groupsEnabled = features.includes(Constants.Features.USER_GROUPS)
const backupsEnabled = features.includes(Constants.Features.APP_BACKUPS)
const scimEnabled = features.includes(Constants.Features.SCIM)
const environmentVariablesEnabled = features.includes(
Constants.Features.ENVIRONMENT_VARIABLES
)
const enforceableSSO = features.includes(Constants.Features.ENFORCEABLE_SSO)
const brandingEnabled = features.includes(Constants.Features.BRANDING)
const auditLogsEnabled = features.includes(Constants.Features.AUDIT_LOGS)
const syncAutomationsEnabled = features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
const triggerAutomationRunEnabled = features.includes(
Constants.Features.TRIGGER_AUTOMATION_RUN
)
const perAppBuildersEnabled = features.includes(
Constants.Features.APP_BUILDERS
)
const budibaseAIEnabled = features.includes(Constants.Features.BUDIBASE_AI)
const customAIConfigsEnabled = features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
this.update(state => {
return {
...state,
license,
isEnterprisePlan,
isFreePlan,
isBusinessPlan,
isEnterpriseTrial,
groupsEnabled,
backupsEnabled,
brandingEnabled,
budibaseAIEnabled,
customAIConfigsEnabled,
scimEnabled,
environmentVariablesEnabled,
auditLogsEnabled,
enforceableSSO,
syncAutomationsEnabled,
triggerAutomationRunEnabled,
perAppBuildersEnabled,
}
})
}
async setQuotaUsage() {
const quotaUsage = await API.getQuotaUsage()
this.update(state => {
return {
...state,
quotaUsage,
}
})
await this.setUsageMetrics()
}
async setUsageMetrics() {
const usage = get(this.store).quotaUsage
const license = get(auth).user?.license
const now = new Date()
if (!license || !usage) {
return
}
// Process monthly metrics
const monthlyMetrics = [
MonthlyQuotaName.QUERIES,
MonthlyQuotaName.AUTOMATIONS,
].reduce((acc: MonthlyMetrics, key) => {
const limit = license.quotas.usage.monthly[key].value
const used = ((usage.monthly.current?.[key] || 0) / limit) * 100
acc[key] = limit > -1 ? Math.floor(used) : -1
return acc
}, {})
// Process static metrics
const staticMetrics = [StaticQuotaName.APPS, StaticQuotaName.ROWS].reduce(
(acc: StaticMetrics, key) => {
const limit = license.quotas.usage.static[key].value
const used = ((usage.usageQuota[key] || 0) / limit) * 100
acc[key] = limit > -1 ? Math.floor(used) : -1
return acc
},
{}
)
const getDaysBetween = (dateStart: Date, dateEnd: Date) => {
return dateEnd > dateStart
? Math.round((dateEnd.getTime() - dateStart.getTime()) / ONE_DAY_MILLIS)
: 0
}
const quotaResetDate = new Date(usage.quotaReset)
const quotaResetDaysRemaining = getDaysBetween(now, quotaResetDate)
const accountDowngraded =
!!license.billing?.subscription?.downgradeAt &&
license.billing?.subscription?.downgradeAt <= now.getTime() &&
license.billing?.subscription?.status === StripeStatus.PAST_DUE &&
license.plan.type === Constants.PlanType.FREE
const pastDueAtMilliseconds = license.billing?.subscription?.pastDueAt
const downgradeAtMilliseconds = license.billing?.subscription?.downgradeAt
let pastDueDaysRemaining: number
let pastDueEndDate: Date
if (pastDueAtMilliseconds && downgradeAtMilliseconds) {
pastDueEndDate = new Date(downgradeAtMilliseconds)
pastDueDaysRemaining = getDaysBetween(
new Date(pastDueAtMilliseconds),
pastDueEndDate
)
}
const userQuota = license.quotas.usage.static.users
const userLimit = userQuota.value
const userCount = usage.usageQuota.users
const userLimitReached = this.usersLimitReached(userCount, userLimit)
const userLimitExceeded = this.usersLimitExceeded(userCount, userLimit)
const isCloudAccount = await this.isCloud()
const errUserLimit =
isCloudAccount &&
license.plan.model === PlanModel.PER_USER &&
userLimitExceeded
this.update(state => {
return {
...state,
usageMetrics: { ...monthlyMetrics, ...staticMetrics },
quotaResetDaysRemaining,
quotaResetDate,
accountDowngraded,
accountPastDue: pastDueAtMilliseconds != null,
pastDueEndDate,
pastDueDaysRemaining,
// user limits
userCount,
userLimit,
userLimitReached,
errUserLimit,
}
})
}
}
export const licensing = new LicensingStore()

View File

@ -1,138 +0,0 @@
import { derived } from "svelte/store"
import { admin } from "./admin"
import { auth } from "./auth"
import { isEnabled } from "@/helpers/featureFlags"
import { sdk } from "@budibase/shared-core"
import { FeatureFlag } from "@budibase/types"
export const menu = derived([admin, auth], ([$admin, $auth]) => {
const user = $auth?.user
const isAdmin = sdk.users.isAdmin(user)
const cloud = $admin?.cloud
// Determine user sub pages
let userSubPages = [
{
title: "Users",
href: "/builder/portal/users/users",
},
]
userSubPages.push({
title: "Groups",
href: "/builder/portal/users/groups",
})
// Pages that all devs and admins can access
let menu = [
{
title: "Apps",
href: "/builder/portal/apps",
},
]
if (sdk.users.isGlobalBuilder(user)) {
menu.push({
title: "Users",
href: "/builder/portal/users",
subPages: userSubPages,
})
menu.push({
title: "Plugins",
href: "/builder/portal/plugins",
})
}
// Add settings page for admins
if (isAdmin) {
let settingsSubPages = [
{
title: "Auth",
href: "/builder/portal/settings/auth",
},
{
title: "Email",
href: "/builder/portal/settings/email",
},
{
title: "Organisation",
href: "/builder/portal/settings/organisation",
},
{
title: "Branding",
href: "/builder/portal/settings/branding",
},
{
title: "Environment",
href: "/builder/portal/settings/environment",
},
]
if (isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) {
settingsSubPages.push({
title: "AI",
href: "/builder/portal/settings/ai",
})
}
if (!cloud) {
settingsSubPages.push({
title: "Version",
href: "/builder/portal/settings/version",
})
settingsSubPages.push({
title: "Diagnostics",
href: "/builder/portal/settings/diagnostics",
})
}
menu.push({
title: "Settings",
href: "/builder/portal/settings",
subPages: [...settingsSubPages].sort((a, b) =>
a.title.localeCompare(b.title)
),
})
}
// Add account page
let accountSubPages = [
{
title: "Usage",
href: "/builder/portal/account/usage",
},
]
if (isAdmin) {
accountSubPages.push({
title: "Audit Logs",
href: "/builder/portal/account/auditLogs",
})
if (!cloud) {
accountSubPages.push({
title: "System Logs",
href: "/builder/portal/account/systemLogs",
})
}
}
if (cloud && user?.accountPortalAccess) {
accountSubPages.push({
title: "Upgrade",
href: $admin?.accountPortalUrl + "/portal/upgrade",
})
} else if (!cloud && isAdmin) {
accountSubPages.push({
title: "Upgrade",
href: "/builder/portal/account/upgrade",
})
}
// add license check here
if (user?.accountPortalAccess && user.account.stripeCustomerId) {
accountSubPages.push({
title: "Billing",
href: $admin?.accountPortalUrl + "/portal/billing",
})
}
menu.push({
title: "Account",
href: "/builder/portal/account",
subPages: accountSubPages,
})
return menu
})

View File

@ -0,0 +1,145 @@
import { derived, Readable } from "svelte/store"
import { admin } from "./admin"
import { auth } from "./auth"
import { sdk } from "@budibase/shared-core"
interface MenuItem {
title: string
href: string
subPages?: MenuItem[]
}
export const menu: Readable<MenuItem[]> = derived(
[admin, auth],
([$admin, $auth]) => {
const user = $auth?.user
const isAdmin = user != null && sdk.users.isAdmin(user)
const isGlobalBuilder = user != null && sdk.users.isGlobalBuilder(user)
const cloud = $admin?.cloud
// Determine user sub pages
let userSubPages: MenuItem[] = [
{
title: "Users",
href: "/builder/portal/users/users",
},
]
userSubPages.push({
title: "Groups",
href: "/builder/portal/users/groups",
})
// Pages that all devs and admins can access
let menu: MenuItem[] = [
{
title: "Apps",
href: "/builder/portal/apps",
},
]
if (isGlobalBuilder) {
menu.push({
title: "Users",
href: "/builder/portal/users",
subPages: userSubPages,
})
menu.push({
title: "Plugins",
href: "/builder/portal/plugins",
})
}
// Add settings page for admins
if (isAdmin) {
let settingsSubPages: MenuItem[] = [
{
title: "Auth",
href: "/builder/portal/settings/auth",
},
{
title: "Email",
href: "/builder/portal/settings/email",
},
{
title: "Organisation",
href: "/builder/portal/settings/organisation",
},
{
title: "Branding",
href: "/builder/portal/settings/branding",
},
{
title: "Environment",
href: "/builder/portal/settings/environment",
},
{
title: "AI",
href: "/builder/portal/settings/ai",
},
]
if (!cloud) {
settingsSubPages.push({
title: "Version",
href: "/builder/portal/settings/version",
})
settingsSubPages.push({
title: "Diagnostics",
href: "/builder/portal/settings/diagnostics",
})
}
menu.push({
title: "Settings",
href: "/builder/portal/settings",
subPages: [...settingsSubPages].sort((a, b) =>
a.title.localeCompare(b.title)
),
})
}
// Add account page
let accountSubPages: MenuItem[] = [
{
title: "Usage",
href: "/builder/portal/account/usage",
},
]
if (isAdmin) {
accountSubPages.push({
title: "Audit Logs",
href: "/builder/portal/account/auditLogs",
})
if (!cloud) {
accountSubPages.push({
title: "System Logs",
href: "/builder/portal/account/systemLogs",
})
}
}
if (cloud && user?.accountPortalAccess) {
accountSubPages.push({
title: "Upgrade",
href: $admin?.accountPortalUrl + "/portal/upgrade",
})
} else if (!cloud && isAdmin) {
accountSubPages.push({
title: "Upgrade",
href: "/builder/portal/account/upgrade",
})
}
// add license check here
if (user?.accountPortalAccess && user?.account?.stripeCustomerId) {
accountSubPages.push({
title: "Billing",
href: $admin?.accountPortalUrl + "/portal/billing",
})
}
menu.push({
title: "Account",
href: "/builder/portal/account",
subPages: accountSubPages,
})
return menu
}
)

View File

@ -1,31 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
const OIDC_CONFIG = {
logo: undefined,
name: undefined,
uuid: undefined,
}
export function createOidcStore() {
const store = writable(OIDC_CONFIG)
const { set, subscribe } = store
return {
subscribe,
set,
init: async () => {
const tenantId = get(auth).tenantId
const config = await API.getOIDCConfig(tenantId)
if (Object.keys(config || {}).length) {
// Just use the first config for now.
// We will be support multiple logins buttons later on.
set(...config)
} else {
set(OIDC_CONFIG)
}
},
}
}
export const oidc = createOidcStore()

View File

@ -0,0 +1,21 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import { BudiStore } from "../BudiStore"
import { PublicOIDCConfig } from "@budibase/types"
class OIDCStore extends BudiStore<PublicOIDCConfig> {
constructor() {
super({})
}
async init() {
const tenantId = get(auth).tenantId
const configs = await API.getOIDCConfigs(tenantId)
// Just use the first config for now.
// We will be support multiple logins buttons later on.
this.set(configs[0] || {})
}
}
export const oidc = new OIDCStore()

View File

@ -1,66 +0,0 @@
import { writable, get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import _ from "lodash"
const DEFAULT_CONFIG = {
platformUrl: "",
logoUrl: undefined,
faviconUrl: undefined,
emailBrandingEnabled: true,
testimonialsEnabled: true,
platformTitle: "Budibase",
loginHeading: undefined,
loginButton: undefined,
metaDescription: undefined,
metaImageUrl: undefined,
metaTitle: undefined,
docsUrl: undefined,
company: "Budibase",
oidc: undefined,
google: undefined,
googleDatasourceConfigured: undefined,
oidcCallbackUrl: "",
googleCallbackUrl: "",
isSSOEnforced: false,
loaded: false,
}
export function createOrganisationStore() {
const store = writable(DEFAULT_CONFIG)
const { subscribe, set } = store
async function init() {
const tenantId = get(auth).tenantId
const settingsConfigDoc = await API.getTenantConfig(tenantId)
set({ ...DEFAULT_CONFIG, ...settingsConfigDoc.config, loaded: true })
}
async function save(config) {
// Delete non-persisted fields
const storeConfig = _.cloneDeep(get(store))
delete storeConfig.oidc
delete storeConfig.google
delete storeConfig.googleDatasourceConfigured
delete storeConfig.oidcCallbackUrl
delete storeConfig.googleCallbackUrl
// delete internal store field
delete storeConfig.loaded
await API.saveConfig({
type: "settings",
config: { ...storeConfig, ...config },
})
await init()
}
return {
subscribe,
set,
save,
init,
}
}
export const organisation = createOrganisationStore()

View File

@ -0,0 +1,71 @@
import { get } from "svelte/store"
import { API } from "@/api"
import { auth } from "@/stores/portal"
import {
ConfigType,
PublicSettingsInnerConfig,
SettingsBrandingConfig,
SettingsInnerConfig,
} from "@budibase/types"
import { BudiStore } from "../BudiStore"
interface LocalOrganisationState {
loaded: boolean
}
type SavedOrganisationState = SettingsInnerConfig & SettingsBrandingConfig
type OrganisationState = SavedOrganisationState &
PublicSettingsInnerConfig &
LocalOrganisationState
const DEFAULT_STATE: OrganisationState = {
platformUrl: "",
emailBrandingEnabled: true,
testimonialsEnabled: true,
platformTitle: "Budibase",
company: "Budibase",
google: false,
googleDatasourceConfigured: false,
oidc: false,
oidcCallbackUrl: "",
googleCallbackUrl: "",
loaded: false,
}
class OrganisationStore extends BudiStore<OrganisationState> {
constructor() {
super(DEFAULT_STATE)
}
async init() {
const tenantId = get(auth).tenantId
const settingsConfigDoc = await API.getTenantConfig(tenantId)
this.set({ ...DEFAULT_STATE, ...settingsConfigDoc.config, loaded: true })
}
async save(changes: Partial<SavedOrganisationState>) {
// Strip non persisted fields
const {
oidc,
google,
googleDatasourceConfigured,
oidcCallbackUrl,
googleCallbackUrl,
loaded,
...config
} = get(this.store)
// Save new config
const newConfig: SavedOrganisationState = {
...config,
...changes,
}
await API.saveConfig({
type: ConfigType.SETTINGS,
config: newConfig,
})
await this.init()
}
}
export const organisation = new OrganisationStore()

View File

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

View File

@ -3,6 +3,8 @@ import { info, success } from "../utils"
import * as makeFiles from "./makeFiles"
import compose from "docker-compose"
import fs from "fs"
import { confirmation } from "../questions"
const open = require("open")
export async function start() {
await checkDockerConfigured()
@ -22,6 +24,9 @@ export async function start() {
// need to log as it makes it more clear
await compose.upAll({ cwd: "./", log: true })
})
if (await confirmation(`Do you wish to open http://localhost:${port} ?`)) {
await open(`http://localhost:${port}`)
}
console.log(
success(
`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
API.fetchRelationshipData = async params => {
const tableId = params?.tableId
const rows = await fetchRelationshipData(params)
return await enrichRows(rows, tableId)
API.fetchRelationshipData = async (sourceId, rowId, fieldName) => {
const rows = await fetchRelationshipData(sourceId, rowId, fieldName)
return await enrichRows(rows, sourceId)
}
const fetchTableData = API.fetchTableData
API.fetchTableData = async tableId => {
@ -85,9 +84,9 @@ export const patchAPI = API => {
}
}
const fetchViewData = API.fetchViewData
API.fetchViewData = async params => {
API.fetchViewData = async (viewName, params) => {
const tableId = params?.tableId
const rows = await fetchViewData(params)
const rows = await fetchViewData(viewName, params)
return await enrichRows(rows, tableId)
}

View File

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

View File

@ -1,12 +1,12 @@
import { API } from "api"
import TableFetch from "@budibase/frontend-core/src/fetch/TableFetch.js"
import ViewFetch from "@budibase/frontend-core/src/fetch/ViewFetch.js"
import QueryFetch from "@budibase/frontend-core/src/fetch/QueryFetch.js"
import RelationshipFetch from "@budibase/frontend-core/src/fetch/RelationshipFetch.js"
import NestedProviderFetch from "@budibase/frontend-core/src/fetch/NestedProviderFetch.js"
import FieldFetch from "@budibase/frontend-core/src/fetch/FieldFetch.js"
import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js"
import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch.js"
import TableFetch from "@budibase/frontend-core/src/fetch/TableFetch"
import ViewFetch from "@budibase/frontend-core/src/fetch/ViewFetch"
import QueryFetch from "@budibase/frontend-core/src/fetch/QueryFetch"
import RelationshipFetch from "@budibase/frontend-core/src/fetch/RelationshipFetch"
import NestedProviderFetch from "@budibase/frontend-core/src/fetch/NestedProviderFetch"
import FieldFetch from "@budibase/frontend-core/src/fetch/FieldFetch"
import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch"
import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch"
import QueryArrayFetch from "@budibase/frontend-core/src/fetch/QueryArrayFetch"
/**

View File

@ -16,7 +16,7 @@ import { BaseAPIClient } from "./types"
export interface ConfigEndpoints {
getConfig: (type: ConfigType) => Promise<FindConfigResponse>
getTenantConfig: (tentantId: string) => Promise<GetPublicSettingsResponse>
getOIDCConfig: (tenantId: string) => Promise<GetPublicOIDCConfigResponse>
getOIDCConfigs: (tenantId: string) => Promise<GetPublicOIDCConfigResponse>
getOIDCLogos: () => Promise<Config<OIDCLogosConfig>>
saveConfig: (config: SaveConfigRequest) => Promise<SaveConfigResponse>
deleteConfig: (id: string, rev: string) => Promise<DeleteConfigResponse>
@ -73,7 +73,7 @@ export const buildConfigEndpoints = (API: BaseAPIClient): ConfigEndpoints => ({
* Gets the OIDC config for a certain tenant.
* @param tenantId the tenant ID to get the config for
*/
getOIDCConfig: async tenantId => {
getOIDCConfigs: async tenantId => {
return await API.get({
url: `/api/global/configs/public/oidc?tenantId=${tenantId}`,
})

View File

@ -1,4 +1,8 @@
import { SearchUserGroupResponse, UserGroup } from "@budibase/types"
import {
SearchGroupResponse,
SearchUserGroupResponse,
UserGroup,
} from "@budibase/types"
import { BaseAPIClient } from "./types"
export interface GroupEndpoints {
@ -64,9 +68,10 @@ export const buildGroupsEndpoints = (API: BaseAPIClient): GroupEndpoints => {
* Gets all the user groups
*/
getGroups: async () => {
return await API.get({
const res = await API.get<SearchGroupResponse>({
url: "/api/global/groups",
})
return res.data
},
/**

View File

@ -3,7 +3,15 @@ import { BaseAPIClient } from "./types"
export interface ViewEndpoints {
// Missing request or response types
fetchViewData: (name: string, opts: any) => Promise<Row[]>
fetchViewData: (
name: string,
opts: {
calculation?: string
field?: string
groupBy?: string
tableId: string
}
) => Promise<Row[]>
exportView: (name: string, format: string) => Promise<any>
saveView: (view: any) => Promise<any>
deleteView: (name: string) => Promise<any>
@ -20,7 +28,9 @@ export const buildViewEndpoints = (API: BaseAPIClient): ViewEndpoints => ({
fetchViewData: async (name, { field, groupBy, calculation }) => {
const params = new URLSearchParams()
if (calculation) {
params.set("field", field)
if (field) {
params.set("field", field)
}
params.set("calculation", calculation)
}
if (groupBy) {

View File

@ -1,6 +1,7 @@
import {
CreateViewRequest,
CreateViewResponse,
PaginatedSearchRowResponse,
SearchRowResponse,
SearchViewRowRequest,
UpdateViewRequest,
@ -13,10 +14,14 @@ export interface ViewV2Endpoints {
fetchDefinition: (viewId: string) => Promise<ViewResponseEnriched>
create: (view: CreateViewRequest) => Promise<CreateViewResponse>
update: (view: UpdateViewRequest) => Promise<UpdateViewResponse>
fetch: (
fetch: <T extends SearchViewRowRequest>(
viewId: string,
opts: SearchViewRowRequest
) => Promise<SearchRowResponse>
opts: T
) => Promise<
T extends { paginate: true }
? PaginatedSearchRowResponse
: SearchRowResponse
>
delete: (viewId: string) => Promise<void>
}
@ -59,7 +64,7 @@ export const buildViewV2Endpoints = (API: BaseAPIClient): ViewV2Endpoints => ({
* @param viewId the id of the view
* @param opts the search options
*/
fetch: async (viewId, opts) => {
fetch: async (viewId, opts: SearchViewRowRequest) => {
return await API.post({
url: `/api/v2/views/${encodeURIComponent(viewId)}/search`,
body: opts,

View File

@ -69,7 +69,7 @@ export const deriveStores = (context: StoreContext): ConfigDerivedStore => {
}
// Disable features for non DS+
if (!["table", "viewV2"].includes(type)) {
if (type && !["table", "viewV2"].includes(type)) {
config.canAddRows = false
config.canEditRows = false
config.canDeleteRows = false

View File

@ -1,3 +1,5 @@
// TODO: datasource and defitions are unions of the different implementations. At this point, the datasource does not know what type is being used, and the assignations will cause TS exceptions. Casting it "as any" for now. This should be fixed improving the type usages.
import { derived, get, Readable, Writable } from "svelte/store"
import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import { enrichSchemaWithRelColumns, memo } from "../../../utils"
@ -71,10 +73,10 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
} = context
const schema = derived(definition, $definition => {
let schema: Record<string, UIFieldSchema> = getDatasourceSchema({
const schema: Record<string, any> | undefined = getDatasourceSchema({
API,
datasource: get(datasource),
definition: $definition,
datasource: get(datasource) as any, // TODO: see line 1
definition: $definition ?? undefined,
})
if (!schema) {
return null
@ -82,7 +84,7 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
// Ensure schema is configured as objects.
// Certain datasources like queries use primitives.
Object.keys(schema || {}).forEach(key => {
Object.keys(schema).forEach(key => {
if (typeof schema[key] !== "object") {
schema[key] = { name: key, type: schema[key] }
}
@ -130,13 +132,13 @@ export const deriveStores = (context: StoreContext): DerivedDatasourceStore => {
([$datasource, $definition]) => {
let type = $datasource?.type
if (type === "provider") {
type = ($datasource as any).value?.datasource?.type
type = ($datasource as any).value?.datasource?.type // TODO: see line 1
}
// Handle calculation views
if (type === "viewV2" && $definition?.type === ViewV2Type.CALCULATION) {
return false
}
return ["table", "viewV2", "link"].includes(type)
return !!type && ["table", "viewV2", "link"].includes(type)
}
)
@ -184,9 +186,9 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
const refreshDefinition = async () => {
const def = await getDatasourceDefinition({
API,
datasource: get(datasource),
datasource: get(datasource) as any, // TODO: see line 1
})
definition.set(def)
definition.set(def as any) // TODO: see line 1
}
// Saves the datasource definition
@ -231,7 +233,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
if ("default" in newDefinition.schema[column]) {
delete newDefinition.schema[column].default
}
return await saveDefinition(newDefinition as any)
return await saveDefinition(newDefinition as any) // TODO: see line 1
}
// Adds a schema mutation for a single field
@ -307,7 +309,7 @@ export const createActions = (context: StoreContext): ActionDatasourceStore => {
await saveDefinition({
...$definition,
schema: newSchema,
} as any)
} as any) // TODO: see line 1
resetSchemaMutations()
}

View File

@ -10,9 +10,10 @@ import {
import { tick } from "svelte"
import { Helpers } from "@budibase/bbui"
import { sleep } from "../../../utils/utils"
import { FieldType, Row, UIFetchAPI, UIRow } from "@budibase/types"
import { FieldType, Row, UIRow } from "@budibase/types"
import { getRelatedTableValues } from "../../../utils"
import { Store as StoreContext } from "."
import DataFetch from "../../../fetch/DataFetch"
interface IndexedUIRow extends UIRow {
__idx: number
@ -20,7 +21,7 @@ interface IndexedUIRow extends UIRow {
interface RowStore {
rows: Writable<UIRow[]>
fetch: Writable<UIFetchAPI | null>
fetch: Writable<DataFetch<any, any, any> | null> // TODO: type this properly, having a union of all the possible options
loaded: Writable<boolean>
refreshing: Writable<boolean>
loading: Writable<boolean>
@ -225,7 +226,7 @@ export const createActions = (context: StoreContext): RowActionStore => {
})
// Subscribe to changes of this fetch model
unsubscribe = newFetch.subscribe(async ($fetch: UIFetchAPI) => {
unsubscribe = newFetch.subscribe(async $fetch => {
if ($fetch.error) {
// Present a helpful error to the user
let message = "An unknown error occurred"
@ -253,7 +254,7 @@ export const createActions = (context: StoreContext): RowActionStore => {
// Reset state properties when dataset changes
if (!$instanceLoaded || resetRows) {
definition.set($fetch.definition)
definition.set($fetch.definition as any) // TODO: datasource and defitions are unions of the different implementations. At this point, the datasource does not know what type is being used, and the assignations will cause TS exceptions. Casting it "as any" for now. This should be fixed improving the type usages.
}
// Reset scroll state when data changes

View File

@ -32,8 +32,8 @@ export const Cookies = {
}
// Table names
export const TableNames = {
USERS: "ta_users",
export const enum TableNames {
USERS = "ta_users",
}
export const BudibaseRoles = {

View File

@ -1,8 +1,17 @@
import DataFetch from "./DataFetch.js"
import DataFetch from "./DataFetch"
export default class CustomFetch extends DataFetch {
interface CustomDatasource {
data: any
}
type CustomDefinition = Record<string, any>
export default class CustomFetch extends DataFetch<
CustomDatasource,
CustomDefinition
> {
// Gets the correct Budibase type for a JS value
getType(value) {
getType(value: any) {
if (value == null) {
return "string"
}
@ -22,7 +31,7 @@ export default class CustomFetch extends DataFetch {
}
// Parses the custom data into an array format
parseCustomData(data) {
parseCustomData(data: any) {
if (!data) {
return []
}
@ -55,7 +64,7 @@ export default class CustomFetch extends DataFetch {
}
// Enriches the custom data to ensure the structure and format is usable
enrichCustomData(data) {
enrichCustomData(data: (string | any)[]) {
if (!data?.length) {
return []
}
@ -72,7 +81,7 @@ export default class CustomFetch extends DataFetch {
// Try parsing strings
if (typeof value === "string") {
const split = value.split(",").map(x => x.trim())
let obj = {}
const obj: Record<string, string> = {}
for (let i = 0; i < split.length; i++) {
const suffix = i === 0 ? "" : ` ${i + 1}`
const key = `Value${suffix}`
@ -87,27 +96,29 @@ export default class CustomFetch extends DataFetch {
}
// Extracts and parses the custom data from the datasource definition
getCustomData(datasource) {
getCustomData(datasource: CustomDatasource) {
return this.enrichCustomData(this.parseCustomData(datasource?.data))
}
async getDefinition(datasource) {
async getDefinition() {
const { datasource } = this.options
// Try and work out the schema from the array provided
let schema = {}
const schema: CustomDefinition = {}
const data = this.getCustomData(datasource)
if (!data?.length) {
return { schema }
}
// Go through every object and extract all valid keys
for (let datum of data) {
for (let key of Object.keys(datum)) {
for (const datum of data) {
for (const key of Object.keys(datum)) {
if (key === "_id") {
continue
}
if (!schema[key]) {
let type = this.getType(datum[key])
let constraints = {}
const constraints: any = {}
// Determine whether we should render text columns as options instead
if (type === "string") {

View File

@ -1,25 +1,102 @@
import { writable, derived, get } from "svelte/store"
import { writable, derived, get, Writable, Readable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
import { QueryUtils } from "../utils"
import { convertJSONSchemaToTableSchema } from "../utils/json"
import { FieldType, SortOrder, SortType } from "@budibase/types"
import {
FieldType,
LegacyFilter,
Row,
SearchFilters,
SortOrder,
SortType,
TableSchema,
UISearchFilter,
} from "@budibase/types"
import { APIClient } from "../api/types"
const { buildQuery, limit: queryLimit, runQuery, sort } = QueryUtils
interface DataFetchStore<TDefinition, TQuery> {
rows: Row[]
info: any
schema: TableSchema | null
loading: boolean
loaded: boolean
query: TQuery
pageNumber: number
cursor: string | null
cursors: string[]
resetKey: string
error: {
message: string
status: number
} | null
definition?: TDefinition | null
}
interface DataFetchDerivedStore<TDefinition, TQuery>
extends DataFetchStore<TDefinition, TQuery> {
hasNextPage: boolean
hasPrevPage: boolean
supportsSearch: boolean
supportsSort: boolean
supportsPagination: boolean
}
export interface DataFetchParams<
TDatasource,
TQuery = SearchFilters | undefined
> {
API: APIClient
datasource: TDatasource
query: TQuery
options?: {}
}
/**
* Parent class which handles the implementation of fetching data from an
* internal table or datasource plus.
* For other types of datasource, this class is overridden and extended.
*/
export default class DataFetch {
export default abstract class DataFetch<
TDatasource extends {},
TDefinition extends {
schema?: Record<string, any> | null
primaryDisplay?: string
},
TQuery extends {} = SearchFilters
> {
API: APIClient
features: {
supportsSearch: boolean
supportsSort: boolean
supportsPagination: boolean
}
options: {
datasource: TDatasource
limit: number
// Search config
filter: UISearchFilter | LegacyFilter[] | null
query: TQuery
// Sorting config
sortColumn: string | null
sortOrder: SortOrder
sortType: SortType | null
// Pagination config
paginate: boolean
// Client side feature customisation
clientSideSearching: boolean
clientSideSorting: boolean
clientSideLimiting: boolean
}
store: Writable<DataFetchStore<TDefinition, TQuery>>
derivedStore: Readable<DataFetchDerivedStore<TDefinition, TQuery>>
/**
* Constructs a new DataFetch instance.
* @param opts the fetch options
*/
constructor(opts) {
// API client
this.API = null
constructor(opts: DataFetchParams<TDatasource, TQuery>) {
// Feature flags
this.features = {
supportsSearch: false,
@ -29,12 +106,12 @@ export default class DataFetch {
// Config
this.options = {
datasource: null,
datasource: opts.datasource,
limit: 10,
// Search config
filter: null,
query: null,
query: opts.query,
// Sorting config
sortColumn: null,
@ -57,11 +134,11 @@ export default class DataFetch {
schema: null,
loading: false,
loaded: false,
query: null,
query: opts.query,
pageNumber: 0,
cursor: null,
cursors: [],
resetKey: Math.random(),
resetKey: Math.random().toString(),
error: null,
})
@ -118,7 +195,10 @@ export default class DataFetch {
/**
* Gets the default sort column for this datasource
*/
getDefaultSortColumn(definition, schema) {
getDefaultSortColumn(
definition: { primaryDisplay?: string } | null,
schema: Record<string, any>
): string | null {
if (definition?.primaryDisplay && schema[definition.primaryDisplay]) {
return definition.primaryDisplay
} else {
@ -130,13 +210,13 @@ export default class DataFetch {
* Fetches a fresh set of data from the server, resetting pagination
*/
async getInitialData() {
const { datasource, filter, paginate } = this.options
const { filter, paginate } = this.options
// Fetch datasource definition and extract sort properties if configured
const definition = await this.getDefinition(datasource)
const definition = await this.getDefinition()
// Determine feature flags
const features = this.determineFeatureFlags(definition)
const features = await this.determineFeatureFlags()
this.features = {
supportsSearch: !!features?.supportsSearch,
supportsSort: !!features?.supportsSort,
@ -144,11 +224,11 @@ export default class DataFetch {
}
// Fetch and enrich schema
let schema = this.getSchema(datasource, definition)
schema = this.enrichSchema(schema)
let schema = this.getSchema(definition)
if (!schema) {
return
}
schema = this.enrichSchema(schema)
// If an invalid sort column is specified, delete it
if (this.options.sortColumn && !schema[this.options.sortColumn]) {
@ -172,20 +252,25 @@ export default class DataFetch {
if (
fieldSchema?.type === FieldType.NUMBER ||
fieldSchema?.type === FieldType.BIGINT ||
fieldSchema?.calculationType
("calculationType" in fieldSchema && fieldSchema?.calculationType)
) {
this.options.sortType = SortType.NUMBER
}
// If no sort order, default to ascending
if (!this.options.sortOrder) {
this.options.sortOrder = SortOrder.ASCENDING
} else {
// Ensure sortOrder matches the enum
this.options.sortOrder =
this.options.sortOrder.toLowerCase() as SortOrder
}
}
// Build the query
let query = this.options.query
if (!query) {
query = buildQuery(filter)
query = buildQuery(filter ?? undefined) as TQuery
}
// Update store
@ -210,7 +295,7 @@ export default class DataFetch {
info: page.info,
cursors: paginate && page.hasNextPage ? [null, page.cursor] : [null],
error: page.error,
resetKey: Math.random(),
resetKey: Math.random().toString(),
}))
}
@ -238,8 +323,8 @@ export default class DataFetch {
}
// If we don't support sorting, do a client-side sort
if (!this.features.supportsSort && clientSideSorting) {
rows = sort(rows, sortColumn, sortOrder, sortType)
if (!this.features.supportsSort && clientSideSorting && sortType) {
rows = sort(rows, sortColumn as any, sortOrder, sortType)
}
// If we don't support pagination, do a client-side limit
@ -256,49 +341,28 @@ export default class DataFetch {
}
}
/**
* Fetches a single page of data from the remote resource.
* Must be overridden by a datasource specific child class.
*/
async getData() {
return {
rows: [],
info: null,
hasNextPage: false,
cursor: null,
}
}
abstract getData(): Promise<{
rows: Row[]
info?: any
hasNextPage?: boolean
cursor?: any
error?: any
}>
/**
* Gets the definition for this datasource.
* Defaults to fetching a table definition.
* @param datasource
* @return {object} the definition
*/
async getDefinition(datasource) {
if (!datasource?.tableId) {
return null
}
try {
return await this.API.fetchTableDefinition(datasource.tableId)
} catch (error) {
this.store.update(state => ({
...state,
error,
}))
return null
}
}
abstract getDefinition(): Promise<TDefinition | null>
/**
* Gets the schema definition for a datasource.
* Defaults to getting the "schema" property of the definition.
* @param datasource the datasource
* @param definition the datasource definition
* @return {object} the schema
*/
getSchema(datasource, definition) {
return definition?.schema
getSchema(definition: TDefinition | null): Record<string, any> | undefined {
return definition?.schema ?? undefined
}
/**
@ -307,53 +371,56 @@ export default class DataFetch {
* @param schema the datasource schema
* @return {object} the enriched datasource schema
*/
enrichSchema(schema) {
if (schema == null) {
return null
}
private enrichSchema(schema: TableSchema): TableSchema {
// Check for any JSON fields so we can add any top level properties
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
let jsonAdditions: Record<string, { type: string; nestedJSON: true }> = {}
for (const fieldKey of Object.keys(schema)) {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === FieldType.JSON) {
if (fieldSchema.type === FieldType.JSON) {
const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
squashObjects: true,
})
Object.keys(jsonSchema).forEach(jsonKey => {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}) as Record<string, { type: string }> | null // TODO: remove when convertJSONSchemaToTableSchema is typed
if (jsonSchema) {
for (const jsonKey of Object.keys(jsonSchema)) {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}
}
})
}
}
})
schema = { ...schema, ...jsonAdditions }
}
// Ensure schema is in the correct structure
let enrichedSchema = {}
Object.entries(schema).forEach(([fieldName, fieldSchema]) => {
if (typeof fieldSchema === "string") {
enrichedSchema[fieldName] = {
type: fieldSchema,
name: fieldName,
}
} else {
enrichedSchema[fieldName] = {
...fieldSchema,
name: fieldName,
let enrichedSchema: TableSchema = {}
Object.entries({ ...schema, ...jsonAdditions }).forEach(
([fieldName, fieldSchema]) => {
if (typeof fieldSchema === "string") {
enrichedSchema[fieldName] = {
type: fieldSchema,
name: fieldName,
}
} else {
enrichedSchema[fieldName] = {
...fieldSchema,
type: fieldSchema.type as any, // TODO: check type union definition conflicts
name: fieldName,
}
}
}
})
)
return enrichedSchema
}
/**
* Determine the feature flag for this datasource definition
* @param definition
* Determine the feature flag for this datasource
*/
determineFeatureFlags(_definition) {
async determineFeatureFlags(): Promise<{
supportsPagination: boolean
supportsSearch?: boolean
supportsSort?: boolean
}> {
return {
supportsSearch: false,
supportsSort: false,
@ -365,12 +432,11 @@ export default class DataFetch {
* Resets the data set and updates options
* @param newOptions any new options
*/
async update(newOptions) {
async update(newOptions: any) {
// Check if any settings have actually changed
let refresh = false
const entries = Object.entries(newOptions || {})
for (let [key, value] of entries) {
const oldVal = this.options[key] == null ? null : this.options[key]
for (const [key, value] of Object.entries(newOptions || {})) {
const oldVal = this.options[key as keyof typeof this.options] ?? null
const newVal = value == null ? null : value
if (JSON.stringify(newVal) !== JSON.stringify(oldVal)) {
refresh = true
@ -437,7 +503,7 @@ export default class DataFetch {
* @param state the current store state
* @return {boolean} whether there is a next page of data or not
*/
hasNextPage(state) {
private hasNextPage(state: DataFetchStore<TDefinition, TQuery>): boolean {
return state.cursors[state.pageNumber + 1] != null
}
@ -447,7 +513,7 @@ export default class DataFetch {
* @param state the current store state
* @return {boolean} whether there is a previous page of data or not
*/
hasPrevPage(state) {
private hasPrevPage(state: { pageNumber: number }): boolean {
return state.pageNumber > 0
}

View File

@ -1,7 +1,27 @@
import DataFetch from "./DataFetch.js"
import { Row } from "@budibase/types"
import DataFetch from "./DataFetch"
export interface FieldDatasource {
tableId: string
fieldType: "attachment" | "array"
value: string[] | Row[]
}
export interface FieldDefinition {
schema?: Record<string, { type: string }> | null
}
function isArrayOfStrings(value: string[] | Row[]): value is string[] {
return Array.isArray(value) && !!value[0] && typeof value[0] !== "object"
}
export default class FieldFetch extends DataFetch<
FieldDatasource,
FieldDefinition
> {
async getDefinition(): Promise<FieldDefinition | null> {
const { datasource } = this.options
export default class FieldFetch extends DataFetch {
async getDefinition(datasource) {
// Field sources have their schema statically defined
let schema
if (datasource.fieldType === "attachment") {
@ -28,8 +48,8 @@ export default class FieldFetch extends DataFetch {
// These sources will be available directly from context
const data = datasource?.value || []
let rows
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
let rows: Row[]
if (isArrayOfStrings(data)) {
rows = data.map(value => ({ value }))
} else {
rows = data

View File

@ -1,9 +1,22 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import DataFetch, { DataFetchParams } from "./DataFetch"
import { TableNames } from "../constants"
export default class GroupUserFetch extends DataFetch {
constructor(opts) {
interface GroupUserQuery {
groupId: string
emailSearch: string
}
interface GroupUserDatasource {
tableId: TableNames.USERS
}
export default class GroupUserFetch extends DataFetch<
GroupUserDatasource,
{},
GroupUserQuery
> {
constructor(opts: DataFetchParams<GroupUserDatasource, GroupUserQuery>) {
super({
...opts,
datasource: {
@ -12,7 +25,7 @@ export default class GroupUserFetch extends DataFetch {
})
}
determineFeatureFlags() {
async determineFeatureFlags() {
return {
supportsSearch: true,
supportsSort: false,
@ -28,11 +41,12 @@ export default class GroupUserFetch extends DataFetch {
async getData() {
const { query, cursor } = get(this.store)
try {
const res = await this.API.getGroupUsers({
id: query.groupId,
emailSearch: query.emailSearch,
bookmark: cursor,
bookmark: cursor ?? undefined,
})
return {

View File

@ -1,8 +1,10 @@
import FieldFetch from "./FieldFetch.js"
import FieldFetch from "./FieldFetch"
import { getJSONArrayDatasourceSchema } from "../utils/json"
export default class JSONArrayFetch extends FieldFetch {
async getDefinition(datasource) {
async getDefinition() {
const { datasource } = this.options
// JSON arrays need their table definitions fetched.
// We can then extract their schema as a subset of the table schema.
try {

View File

@ -1,21 +0,0 @@
import DataFetch from "./DataFetch.js"
export default class NestedProviderFetch extends DataFetch {
async getDefinition(datasource) {
// Nested providers should already have exposed their own schema
return {
schema: datasource?.value?.schema,
primaryDisplay: datasource?.value?.primaryDisplay,
}
}
async getData() {
const { datasource } = this.options
// Pull the rows from the existing data provider
return {
rows: datasource?.value?.rows || [],
hasNextPage: false,
cursor: null,
}
}
}

View File

@ -0,0 +1,39 @@
import { Row, TableSchema } from "@budibase/types"
import DataFetch from "./DataFetch"
interface NestedProviderDatasource {
value?: {
schema: TableSchema
primaryDisplay: string
rows: Row[]
}
}
interface NestedProviderDefinition {
schema?: TableSchema
primaryDisplay?: string
}
export default class NestedProviderFetch extends DataFetch<
NestedProviderDatasource,
NestedProviderDefinition
> {
async getDefinition() {
const { datasource } = this.options
// Nested providers should already have exposed their own schema
return {
schema: datasource?.value?.schema,
primaryDisplay: datasource?.value?.primaryDisplay,
}
}
async getData() {
const { datasource } = this.options
// Pull the rows from the existing data provider
return {
rows: datasource?.value?.rows || [],
hasNextPage: false,
cursor: null,
}
}
}

View File

@ -1,11 +1,13 @@
import FieldFetch from "./FieldFetch.js"
import FieldFetch from "./FieldFetch"
import {
getJSONArrayDatasourceSchema,
generateQueryArraySchemas,
} from "../utils/json"
export default class QueryArrayFetch extends FieldFetch {
async getDefinition(datasource) {
async getDefinition() {
const { datasource } = this.options
if (!datasource?.tableId) {
return null
}
@ -14,10 +16,14 @@ export default class QueryArrayFetch extends FieldFetch {
try {
const table = await this.API.fetchQueryDefinition(datasource.tableId)
const schema = generateQueryArraySchemas(
table?.schema,
table?.nestedSchemaFields
table.schema,
table.nestedSchemaFields
)
return { schema: getJSONArrayDatasourceSchema(schema, datasource) }
const result = {
schema: getJSONArrayDatasourceSchema(schema, datasource),
}
return result
} catch (error) {
return null
}

View File

@ -1,9 +1,24 @@
import DataFetch from "./DataFetch.js"
import DataFetch from "./DataFetch"
import { Helpers } from "@budibase/bbui"
import { ExecuteQueryRequest, Query } from "@budibase/types"
import { get } from "svelte/store"
export default class QueryFetch extends DataFetch {
determineFeatureFlags(definition) {
interface QueryDatasource {
_id: string
fields: Record<string, any> & {
pagination?: {
type: string
location: string
pageParam: string
}
}
queryParams?: Record<string, string>
parameters: { name: string; default: string }[]
}
export default class QueryFetch extends DataFetch<QueryDatasource, Query> {
async determineFeatureFlags() {
const definition = await this.getDefinition()
const supportsPagination =
!!definition?.fields?.pagination?.type &&
!!definition?.fields?.pagination?.location &&
@ -11,7 +26,9 @@ export default class QueryFetch extends DataFetch {
return { supportsPagination }
}
async getDefinition(datasource) {
async getDefinition() {
const { datasource } = this.options
if (!datasource?._id) {
return null
}
@ -40,17 +57,17 @@ export default class QueryFetch extends DataFetch {
const type = definition?.fields?.pagination?.type
// Set the default query params
let parameters = Helpers.cloneDeep(datasource?.queryParams || {})
for (let param of datasource?.parameters || {}) {
const parameters = Helpers.cloneDeep(datasource.queryParams || {})
for (const param of datasource?.parameters || []) {
if (!parameters[param.name]) {
parameters[param.name] = param.default
}
}
// Add pagination to query if supported
let queryPayload = { parameters }
const queryPayload: ExecuteQueryRequest = { parameters }
if (paginate && supportsPagination) {
const requestCursor = type === "page" ? parseInt(cursor || 1) : cursor
const requestCursor = type === "page" ? parseInt(cursor || "1") : cursor
queryPayload.pagination = { page: requestCursor, limit }
}
@ -65,7 +82,7 @@ export default class QueryFetch extends DataFetch {
if (paginate && supportsPagination) {
if (type === "page") {
// For "page number" pagination, increment the existing page number
nextCursor = queryPayload.pagination.page + 1
nextCursor = queryPayload.pagination!.page! + 1
hasNextPage = data?.length === limit && limit > 0
} else {
// For "cursor" pagination, the cursor should be in the response

View File

@ -1,20 +0,0 @@
import DataFetch from "./DataFetch.js"
export default class RelationshipFetch extends DataFetch {
async getData() {
const { datasource } = this.options
if (!datasource?.rowId || !datasource?.rowTableId) {
return { rows: [] }
}
try {
const res = await this.API.fetchRelationshipData(
datasource.rowTableId,
datasource.rowId,
datasource.fieldName
)
return { rows: res }
} catch (error) {
return { rows: [] }
}
}
}

View File

@ -0,0 +1,48 @@
import { Table } from "@budibase/types"
import DataFetch from "./DataFetch"
interface RelationshipDatasource {
tableId: string
rowId: string
rowTableId: string
fieldName: string
}
export default class RelationshipFetch extends DataFetch<
RelationshipDatasource,
Table
> {
async getDefinition() {
const { datasource } = this.options
if (!datasource?.tableId) {
return null
}
try {
return await this.API.fetchTableDefinition(datasource.tableId)
} catch (error: any) {
this.store.update(state => ({
...state,
error,
}))
return null
}
}
async getData() {
const { datasource } = this.options
if (!datasource?.rowId || !datasource?.rowTableId) {
return { rows: [] }
}
try {
const res = await this.API.fetchRelationshipData(
datasource.rowTableId,
datasource.rowId,
datasource.fieldName
)
return { rows: res }
} catch (error) {
return { rows: [] }
}
}
}

View File

@ -1,9 +1,9 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import { SortOrder } from "@budibase/types"
import DataFetch from "./DataFetch"
import { SortOrder, Table, UITable } from "@budibase/types"
export default class TableFetch extends DataFetch {
determineFeatureFlags() {
export default class TableFetch extends DataFetch<UITable, Table> {
async determineFeatureFlags() {
return {
supportsSearch: true,
supportsSort: true,
@ -11,6 +11,23 @@ export default class TableFetch extends DataFetch {
}
}
async getDefinition() {
const { datasource } = this.options
if (!datasource?.tableId) {
return null
}
try {
return await this.API.fetchTableDefinition(datasource.tableId)
} catch (error: any) {
this.store.update(state => ({
...state,
error,
}))
return null
}
}
async getData() {
const { datasource, limit, sortColumn, sortOrder, sortType, paginate } =
this.options
@ -23,7 +40,7 @@ export default class TableFetch extends DataFetch {
query,
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? SortOrder.ASCENDING,
sortOrder: sortOrder ?? SortOrder.ASCENDING,
sortType,
paginate,
bookmark: cursor,

View File

@ -1,10 +1,28 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import DataFetch, { DataFetchParams } from "./DataFetch"
import { TableNames } from "../constants"
import { utils } from "@budibase/shared-core"
import {
BasicOperator,
SearchFilters,
SearchUsersRequest,
} from "@budibase/types"
export default class UserFetch extends DataFetch {
constructor(opts) {
interface UserFetchQuery {
appId: string
paginated: boolean
}
interface UserDatasource {
tableId: string
}
export default class UserFetch extends DataFetch<
UserDatasource,
{},
UserFetchQuery
> {
constructor(opts: DataFetchParams<UserDatasource, UserFetchQuery>) {
super({
...opts,
datasource: {
@ -13,7 +31,7 @@ export default class UserFetch extends DataFetch {
})
}
determineFeatureFlags() {
async determineFeatureFlags() {
return {
supportsSearch: true,
supportsSort: false,
@ -22,9 +40,7 @@ export default class UserFetch extends DataFetch {
}
async getDefinition() {
return {
schema: {},
}
return { schema: {} }
}
async getData() {
@ -32,15 +48,16 @@ export default class UserFetch extends DataFetch {
const { cursor, query } = get(this.store)
// Convert old format to new one - we now allow use of the lucene format
const { appId, paginated, ...rest } = query || {}
const finalQuery = utils.isSupportedUserSearch(rest)
? query
: { string: { email: null } }
const { appId, paginated, ...rest } = query
const finalQuery: SearchFilters = utils.isSupportedUserSearch(rest)
? rest
: { [BasicOperator.EMPTY]: { email: null } }
try {
const opts = {
bookmark: cursor,
query: finalQuery,
const opts: SearchUsersRequest = {
bookmark: cursor ?? undefined,
query: finalQuery ?? undefined,
appId: appId,
paginate: paginated || paginate,
limit,

View File

@ -1,17 +0,0 @@
import DataFetch from "./DataFetch.js"
export default class ViewFetch extends DataFetch {
getSchema(datasource, definition) {
return definition?.views?.[datasource.name]?.schema
}
async getData() {
const { datasource } = this.options
try {
const res = await this.API.fetchViewData(datasource.name)
return { rows: res || [] }
} catch (error) {
return { rows: [] }
}
}
}

View File

@ -0,0 +1,44 @@
import { Table, View } from "@budibase/types"
import DataFetch from "./DataFetch"
type ViewV1 = View & { name: string }
export default class ViewFetch extends DataFetch<ViewV1, Table> {
async getDefinition() {
const { datasource } = this.options
if (!datasource?.tableId) {
return null
}
try {
return await this.API.fetchTableDefinition(datasource.tableId)
} catch (error: any) {
this.store.update(state => ({
...state,
error,
}))
return null
}
}
getSchema(definition: Table) {
const { datasource } = this.options
return definition?.views?.[datasource.name]?.schema
}
async getData() {
const { datasource } = this.options
try {
const res = await this.API.fetchViewData(datasource.name, {
calculation: datasource.calculation,
field: datasource.field,
groupBy: datasource.groupBy,
tableId: datasource.tableId,
})
return { rows: res || [] }
} catch (error) {
console.error(error, { datasource })
return { rows: [] }
}
}
}

View File

@ -1,9 +1,10 @@
import { ViewV2Type } from "@budibase/types"
import DataFetch from "./DataFetch.js"
import { SortOrder, UIView, ViewV2, ViewV2Type } from "@budibase/types"
import DataFetch from "./DataFetch"
import { get } from "svelte/store"
import { helpers } from "@budibase/shared-core"
export default class ViewV2Fetch extends DataFetch {
determineFeatureFlags() {
export default class ViewV2Fetch extends DataFetch<UIView, ViewV2> {
async determineFeatureFlags() {
return {
supportsSearch: true,
supportsSort: true,
@ -11,18 +12,13 @@ export default class ViewV2Fetch extends DataFetch {
}
}
getSchema(datasource, definition) {
return definition?.schema
}
async getDefinition() {
const { datasource } = this.options
async getDefinition(datasource) {
if (!datasource?.id) {
return null
}
try {
const res = await this.API.viewV2.fetchDefinition(datasource.id)
return res?.data
} catch (error) {
} catch (error: any) {
this.store.update(state => ({
...state,
error,
@ -42,8 +38,10 @@ export default class ViewV2Fetch extends DataFetch {
// If this is a calculation view and we have no calculations, return nothing
if (
definition.type === ViewV2Type.CALCULATION &&
!Object.values(definition.schema || {}).some(x => x.calculationType)
definition?.type === ViewV2Type.CALCULATION &&
!Object.values(definition.schema || {}).some(
helpers.views.isCalculationField
)
) {
return {
rows: [],
@ -56,25 +54,41 @@ export default class ViewV2Fetch extends DataFetch {
// If sort/filter params are not defined, update options to store the
// params built in to this view. This ensures that we can accurately
// compare old and new params and skip a redundant API call.
if (!sortColumn && definition.sort?.field) {
if (!sortColumn && definition?.sort?.field) {
this.options.sortColumn = definition.sort.field
this.options.sortOrder = definition.sort.order
this.options.sortOrder = definition.sort.order || SortOrder.ASCENDING
}
try {
const res = await this.API.viewV2.fetch(datasource.id, {
...(query ? { query } : {}),
const request = {
query,
paginate,
limit,
bookmark: cursor,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase(),
sortOrder: sortOrder,
sortType,
})
return {
rows: res?.rows || [],
hasNextPage: res?.hasNextPage || false,
cursor: res?.bookmark || null,
}
if (paginate) {
const res = await this.API.viewV2.fetch(datasource.id, {
...request,
paginate,
})
return {
rows: res?.rows || [],
hasNextPage: res?.hasNextPage || false,
cursor: res?.bookmark || null,
}
} else {
const res = await this.API.viewV2.fetch(datasource.id, {
...request,
paginate,
})
return {
rows: res?.rows || [],
hasNextPage: false,
cursor: null,
}
}
} catch (error) {
return {

View File

@ -1,57 +0,0 @@
import TableFetch from "./TableFetch.js"
import ViewFetch from "./ViewFetch.js"
import ViewV2Fetch from "./ViewV2Fetch.js"
import QueryFetch from "./QueryFetch.js"
import RelationshipFetch from "./RelationshipFetch.js"
import NestedProviderFetch from "./NestedProviderFetch.js"
import FieldFetch from "./FieldFetch.js"
import JSONArrayFetch from "./JSONArrayFetch.js"
import UserFetch from "./UserFetch.js"
import GroupUserFetch from "./GroupUserFetch.js"
import CustomFetch from "./CustomFetch.js"
import QueryArrayFetch from "./QueryArrayFetch.js"
const DataFetchMap = {
table: TableFetch,
view: ViewFetch,
viewV2: ViewV2Fetch,
query: QueryFetch,
link: RelationshipFetch,
user: UserFetch,
groupUser: GroupUserFetch,
custom: CustomFetch,
// Client specific datasource types
provider: NestedProviderFetch,
field: FieldFetch,
jsonarray: JSONArrayFetch,
queryarray: QueryArrayFetch,
}
// Constructs a new fetch model for a certain datasource
export const fetchData = ({ API, datasource, options }) => {
const Fetch = DataFetchMap[datasource?.type] || TableFetch
return new Fetch({ API, datasource, ...options })
}
// Creates an empty fetch instance with no datasource configured, so no data
// will initially be loaded
const createEmptyFetchInstance = ({ API, datasource }) => {
const handler = DataFetchMap[datasource?.type]
if (!handler) {
return null
}
return new handler({ API })
}
// Fetches the definition of any type of datasource
export const getDatasourceDefinition = async ({ API, datasource }) => {
const instance = createEmptyFetchInstance({ API, datasource })
return await instance?.getDefinition(datasource)
}
// Fetches the schema of any type of datasource
export const getDatasourceSchema = ({ API, datasource, definition }) => {
const instance = createEmptyFetchInstance({ API, datasource })
return instance?.getSchema(datasource, definition)
}

View File

@ -0,0 +1,91 @@
import TableFetch from "./TableFetch.js"
import ViewFetch from "./ViewFetch.js"
import ViewV2Fetch from "./ViewV2Fetch.js"
import QueryFetch from "./QueryFetch"
import RelationshipFetch from "./RelationshipFetch"
import NestedProviderFetch from "./NestedProviderFetch"
import FieldFetch from "./FieldFetch"
import JSONArrayFetch from "./JSONArrayFetch"
import UserFetch from "./UserFetch.js"
import GroupUserFetch from "./GroupUserFetch"
import CustomFetch from "./CustomFetch"
import QueryArrayFetch from "./QueryArrayFetch.js"
import { APIClient } from "../api/types.js"
const DataFetchMap = {
table: TableFetch,
view: ViewFetch,
viewV2: ViewV2Fetch,
query: QueryFetch,
link: RelationshipFetch,
user: UserFetch,
groupUser: GroupUserFetch,
custom: CustomFetch,
// Client specific datasource types
provider: NestedProviderFetch,
field: FieldFetch,
jsonarray: JSONArrayFetch,
queryarray: QueryArrayFetch,
}
// Constructs a new fetch model for a certain datasource
export const fetchData = ({ API, datasource, options }: any) => {
const Fetch =
DataFetchMap[datasource?.type as keyof typeof DataFetchMap] || TableFetch
return new Fetch({ API, datasource, ...options })
}
// Creates an empty fetch instance with no datasource configured, so no data
// will initially be loaded
const createEmptyFetchInstance = <
TDatasource extends {
type: keyof typeof DataFetchMap
}
>({
API,
datasource,
}: {
API: APIClient
datasource: TDatasource
}) => {
const handler = DataFetchMap[datasource?.type as keyof typeof DataFetchMap]
if (!handler) {
return null
}
return new handler({ API, datasource: null as any, query: null as any })
}
// Fetches the definition of any type of datasource
export const getDatasourceDefinition = async <
TDatasource extends {
type: keyof typeof DataFetchMap
}
>({
API,
datasource,
}: {
API: APIClient
datasource: TDatasource
}) => {
const instance = createEmptyFetchInstance({ API, datasource })
return await instance?.getDefinition()
}
// Fetches the schema of any type of datasource
export const getDatasourceSchema = <
TDatasource extends {
type: keyof typeof DataFetchMap
}
>({
API,
datasource,
definition,
}: {
API: APIClient
datasource: TDatasource
definition?: any
}) => {
const instance = createEmptyFetchInstance({ API, datasource })
return instance?.getSchema(definition)
}

View File

@ -0,0 +1,23 @@
import { JsonFieldMetadata, QuerySchema } from "@budibase/types"
type Schema = Record<string, QuerySchema | string>
declare module "./json" {
export const getJSONArrayDatasourceSchema: (
tableSchema: Schema,
datasource: any
) => Record<string, { type: string; name: string; prefixKeys: string }>
export const generateQueryArraySchemas: (
schema: Schema,
nestedSchemaFields?: Record<string, Schema>
) => Schema
export const convertJSONSchemaToTableSchema: (
jsonSchema: JsonFieldMetadata,
options: {
squashObjects?: boolean
prefixKeys?: string
}
) => Record<string, { type: string; name: string; prefixKeys: string }>
}

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 { APISessionID } from "../api"
const DefaultOptions = {
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) {
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
// will only work with sticky sessions which we don't have
transports: ["websocket"],
})
}) as ExtendedSocket
// Set up a heartbeat that's half of the session TTL
let interval
let interval: NodeJS.Timeout | undefined
if (heartbeat) {
interval = setInterval(() => {
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
// changes made by us
socket.onOther = (event, callback) => {
socket.onOther = (event: string, callback: (data: any) => void) => {
socket.on(event, data => {
if (data?.apiSessionId !== APISessionID) {
callback(data)

@ -1 +1 @@
Subproject commit ae786121d923449b0ad5fcbd123d0a9fec28f65e
Subproject commit 193476cdfade6d3c613e6972f16ee0c527e01ff6

View File

@ -355,7 +355,7 @@ async function execute(
ExecuteQueryRequest,
ExecuteV2QueryResponse | ExecuteV1QueryResponse
>,
opts: any = { rowsOnly: false, isAutomation: false }
opts = { rowsOnly: false, isAutomation: false }
) {
const db = context.getAppDB()
@ -416,7 +416,7 @@ export async function executeV1(
export async function executeV2(
ctx: UserCtx<ExecuteQueryRequest, ExecuteV2QueryResponse>
) {
return execute(ctx, { rowsOnly: false })
return execute(ctx, { rowsOnly: false, isAutomation: false })
}
export async function executeV2AsAutomation(

View File

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

View File

@ -1,16 +1,16 @@
import {
UserCtx,
ViewV2,
SearchRowResponse,
SearchViewRowRequest,
RequiredKeys,
RowSearchParams,
PaginatedSearchRowResponse,
} from "@budibase/types"
import sdk from "../../../sdk"
import { context } from "@budibase/backend-core"
export async function searchView(
ctx: UserCtx<SearchViewRowRequest, SearchRowResponse>
ctx: UserCtx<SearchViewRowRequest, PaginatedSearchRowResponse>
) {
const { viewId } = ctx.params
@ -49,7 +49,13 @@ export async function searchView(
user: sdk.users.getUserContextBindings(ctx.user),
})
result.rows.forEach(r => (r._viewId = view.id))
ctx.body = result
ctx.body = {
rows: result.rows,
bookmark: result.bookmark,
hasNextPage: result.hasNextPage,
totalRows: result.totalRows,
}
}
function getSortOptions(request: SearchViewRowRequest, view: ViewV2) {

View File

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

View File

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

View File

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

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