diff --git a/.github/workflows/readme-openapi.yml b/.github/workflows/readme-openapi.yml index b52787934f..9f42f6141b 100644 --- a/.github/workflows/readme-openapi.yml +++ b/.github/workflows/readme-openapi.yml @@ -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 diff --git a/examples/nextjs-api-sales/package.json b/examples/nextjs-api-sales/package.json index 1050c6b75e..d50e7d4eb5 100644 --- a/examples/nextjs-api-sales/package.json +++ b/examples/nextjs-api-sales/package.json @@ -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", diff --git a/examples/nextjs-api-sales/yarn.lock b/examples/nextjs-api-sales/yarn.lock index b595a148bf..a10fccb2e9 100644 --- a/examples/nextjs-api-sales/yarn.lock +++ b/examples/nextjs-api-sales/yarn.lock @@ -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" diff --git a/lerna.json b/lerna.json index dde9cf03a0..647c9f202d 100644 --- a/lerna.json +++ b/lerna.json @@ -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": { diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index 64ba240fa5..e5f20882d3 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -385,17 +385,17 @@ export function getCurrentContext(): ContextMap | undefined { } } -export function getFeatureFlags>( +export function getFeatureFlags( key: string -): T | undefined { +): Record | 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) { +export function setFeatureFlags(key: string, value: Record) { const context = getCurrentContext() if (!context) { return diff --git a/packages/backend-core/src/context/types.ts b/packages/backend-core/src/context/types.ts index 5549a47ff7..23598b951e 100644 --- a/packages/backend-core/src/context/types.ts +++ b/packages/backend-core/src/context/types.ts @@ -20,7 +20,7 @@ export type ContextMap = { clients: Record } featureFlagCache?: { - [key: string]: Record + [key: string]: Record } viewToTableCache?: Record } diff --git a/packages/backend-core/src/features/features.ts b/packages/backend-core/src/features/features.ts index 650254fcb2..772bcf5860 100644 --- a/packages/backend-core/src/features/features.ts +++ b/packages/backend-core/src/features/features.ts @@ -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 { - static boolean(defaultValue: boolean): Flag { - return new BooleanFlag(defaultValue) - } - - static string(defaultValue: string): Flag { - return new StringFlag(defaultValue) - } - - static number(defaultValue: number): Flag { - return new NumberFlag(defaultValue) - } - - protected constructor(public defaultValue: T) {} - - abstract parse(value: any): T -} - -type UnwrapFlag = F extends Flag ? U : never - -export type FlagValues = { - [K in keyof T]: UnwrapFlag -} - -type KeysOfType = { - [K in keyof T]: T[K] extends Flag ? K : never -}[keyof T] - -class BooleanFlag extends Flag { - 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 { - parse(value: any) { - if (typeof value === "string") { - return value - } - throw new Error(`could not parse value "${value}" as string`) - } -} - -class NumberFlag extends Flag { - 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, T extends { [key: string]: V }> { +export class FlagSet { // 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, T extends { [key: string]: V }> { this.setId = crypto.randomUUID() } - defaults(): FlagValues { - return Object.keys(this.flagSchema).reduce((acc, key) => { - const typedKey = key as keyof T - acc[typedKey] = this.flagSchema[key].defaultValue - return acc - }, {} as FlagValues) + 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(key: K): Promise[K]> { + async isEnabled(key: K): Promise { const flags = await this.fetch() return flags[key] } - async isEnabled>(key: K): Promise { - const flags = await this.fetch() - return flags[key] - } - - async fetch(): Promise> { + async fetch(): Promise { return await tracer.trace("features.fetch", async span => { - const cachedFlags = context.getFeatureFlags>(this.setId) + const cachedFlags = context.getFeatureFlags(this.setId) if (cachedFlags) { span?.addTags({ fromCache: true }) - return cachedFlags + return cachedFlags as T } const tags: Record = {} @@ -189,7 +113,7 @@ export class FlagSet, 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, T extends { [key: string]: V }> { tags[`readFromPostHog`] = true const personProperties: Record = { 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, 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, 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.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 extends Promise ? U : T -export type FeatureFlags = UnwrapPromise> +export async function isEnabled(flag: keyof typeof FeatureFlagDefaults) { + return await flags.isEnabled(flag) +} + +export async function all() { + return await flags.fetch() +} diff --git a/packages/backend-core/src/features/tests/features.spec.ts b/packages/backend-core/src/features/tests/features.spec.ts index ced874f4af..f918347eea 100644 --- a/packages/backend-core/src/features/tests/features.spec.ts +++ b/packages/backend-core/src/features/tests/features.spec.ts @@ -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 environmentFlags?: string posthogFlags?: PostHogFlags - expected?: Partial> + expected?: Partial 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 { diff --git a/packages/backend-core/src/features/tests/utils.ts b/packages/backend-core/src/features/tests/utils.ts index cc633c083d..b9281b7f19 100644 --- a/packages/backend-core/src/features/tests/utils.ts +++ b/packages/backend-core/src/features/tests/utils.ts @@ -1,5 +1,6 @@ -import { FeatureFlags, parseEnvFlags } from ".." +import { FeatureFlags } from "@budibase/types" import { setEnv } from "../../environment" +import { parseEnvFlags } from "../features" function getCurrentFlags(): Record> { const result: Record> = {} diff --git a/packages/bbui/src/helpers.d.ts b/packages/bbui/src/helpers.d.ts new file mode 100644 index 0000000000..79e08657b7 --- /dev/null +++ b/packages/bbui/src/helpers.d.ts @@ -0,0 +1,3 @@ +declare module "./helpers" { + export const cloneDeep: (obj: T) => T +} diff --git a/packages/builder/src/components/design/settings/controls/DataSourceSelect/DataSourceSelect.svelte b/packages/builder/src/components/design/settings/controls/DataSourceSelect/DataSourceSelect.svelte index e7a30e68dd..b23ef5348d 100644 --- a/packages/builder/src/components/design/settings/controls/DataSourceSelect/DataSourceSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/DataSourceSelect/DataSourceSelect.svelte @@ -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 diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index 88e034a96b..37abd7f1eb 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -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 } diff --git a/packages/builder/src/pages/builder/apps/index.svelte b/packages/builder/src/pages/builder/apps/index.svelte index 8bf96d0240..e106d0dd68 100644 --- a/packages/builder/src/pages/builder/apps/index.svelte +++ b/packages/builder/src/pages/builder/apps/index.svelte @@ -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") } diff --git a/packages/builder/src/pages/builder/portal/apps/_layout.svelte b/packages/builder/src/pages/builder/portal/apps/_layout.svelte index 560c1394fb..0a3b02f30f 100644 --- a/packages/builder/src/pages/builder/portal/apps/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/apps/_layout.svelte @@ -24,7 +24,7 @@ promises.push(templates.load()) } - promises.push(groups.actions.init()) + promises.push(groups.init()) // Always load latest await Promise.all(promises) diff --git a/packages/builder/src/pages/builder/portal/settings/email/[template].svelte b/packages/builder/src/pages/builder/portal/settings/email/[template].svelte index a9c0275367..7fbf6d3361 100644 --- a/packages/builder/src/pages/builder/portal/settings/email/[template].svelte +++ b/packages/builder/src/pages/builder/portal/settings/email/[template].svelte @@ -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") diff --git a/packages/builder/src/pages/builder/portal/settings/email/_layout.svelte b/packages/builder/src/pages/builder/portal/settings/email/_layout.svelte index c0d4d4463e..14e7d2c3ca 100644 --- a/packages/builder/src/pages/builder/portal/settings/email/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/settings/email/_layout.svelte @@ -5,7 +5,7 @@ onMount(async () => { try { - await email.templates.fetch() + await email.fetchTemplates() } catch (error) { notifications.error("Error fetching email templates") } diff --git a/packages/builder/src/pages/builder/portal/settings/environment/_components/EditVariableColumn.svelte b/packages/builder/src/pages/builder/portal/settings/environment/_components/EditVariableColumn.svelte index 8f60b3f3ca..00ff1804d9 100644 --- a/packages/builder/src/pages/builder/portal/settings/environment/_components/EditVariableColumn.svelte +++ b/packages/builder/src/pages/builder/portal/settings/environment/_components/EditVariableColumn.svelte @@ -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() } diff --git a/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte b/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte index 312d87f873..58fd1d93cb 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/[groupId].svelte @@ -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") diff --git a/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte index 75600c6fc0..88b8b4657b 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/_components/AppAddModal.svelte @@ -23,7 +23,7 @@ return keepOpen } else { - await groups.actions.addApp(group._id, prodAppId, selectedRoleId) + await groups.addApp(group._id, prodAppId, selectedRoleId) } } diff --git a/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte index 1e7e15d1b4..d360de3850 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/_components/EditUserPicker.svelte @@ -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() }} /> diff --git a/packages/builder/src/pages/builder/portal/users/groups/index.svelte b/packages/builder/src/pages/builder/portal/users/groups/index.svelte index 77b0dc5734..9982f85352 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/index.svelte @@ -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") } diff --git a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte index 94fe3081c3..6c480d9ef8 100644 --- a/packages/builder/src/pages/builder/portal/users/users/[userId].svelte +++ b/packages/builder/src/pages/builder/portal/users/users/[userId].svelte @@ -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 @@
@@ -304,7 +301,7 @@
@@ -325,6 +322,13 @@ {/if} +
+ +
{#if $licensing.groupsEnabled} diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte index 80772ccbee..97120c55d4 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -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") diff --git a/packages/builder/src/stores/BudiStore.ts b/packages/builder/src/stores/BudiStore.ts index d5bbc99328..c638bbf5bc 100644 --- a/packages/builder/src/stores/BudiStore.ts +++ b/packages/builder/src/stores/BudiStore.ts @@ -53,17 +53,24 @@ export class BudiStore { } } -export class DerivedBudiStore extends BudiStore { +// 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 { + store: BudiStore derivedStore: Readable subscribe: Readable["subscribe"] + update: Writable["update"] + set: Writable["set"] constructor( init: T, makeDerivedStore: (store: Writable) => Readable, 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 } } diff --git a/packages/builder/src/stores/builder/hover.js b/packages/builder/src/stores/builder/hover.ts similarity index 73% rename from packages/builder/src/stores/builder/hover.js rename to packages/builder/src/stores/builder/hover.ts index e28c74eaf2..8f9b77075c 100644 --- a/packages/builder/src/stores/builder/hover.js +++ b/packages/builder/src/stores/builder/hover.ts @@ -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 { + 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 } diff --git a/packages/builder/src/stores/builder/navigation.js b/packages/builder/src/stores/builder/navigation.ts similarity index 73% rename from packages/builder/src/stores/builder/navigation.js rename to packages/builder/src/stores/builder/navigation.ts index e9a5e6f9dd..1574efee2a 100644 --- a/packages/builder/src/stores/builder/navigation.js +++ b/packages/builder/src/stores/builder/navigation.ts @@ -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 { 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) } diff --git a/packages/builder/src/stores/builder/published.js b/packages/builder/src/stores/builder/published.ts similarity index 53% rename from packages/builder/src/stores/builder/published.js rename to packages/builder/src/stores/builder/published.ts index a59352fb22..c38f3bb718 100644 --- a/packages/builder/src/stores/builder/published.js +++ b/packages/builder/src/stores/builder/published.ts @@ -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 = 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 } ) diff --git a/packages/builder/src/stores/builder/roles.js b/packages/builder/src/stores/builder/roles.js deleted file mode 100644 index e718545f14..0000000000 --- a/packages/builder/src/stores/builder/roles.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/builder/roles.ts b/packages/builder/src/stores/builder/roles.ts new file mode 100644 index 0000000000..732f50d6be --- /dev/null +++ b/packages/builder/src/stores/builder/roles.ts @@ -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 { + constructor() { + const makeDerivedStore = (store: Writable) => + 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() diff --git a/packages/builder/src/stores/builder/rowActions.js b/packages/builder/src/stores/builder/rowActions.ts similarity index 70% rename from packages/builder/src/stores/builder/rowActions.js rename to packages/builder/src/stores/builder/rowActions.ts index 9cc4063b91..9576eccd1b 100644 --- a/packages/builder/src/stores/builder/rowActions.js +++ b/packages/builder/src/stores/builder/rowActions.ts @@ -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 { 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(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] ??= [] diff --git a/packages/builder/src/stores/builder/snippets.js b/packages/builder/src/stores/builder/snippets.js deleted file mode 100644 index 4e98ef1bdc..0000000000 --- a/packages/builder/src/stores/builder/snippets.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/builder/snippets.ts b/packages/builder/src/stores/builder/snippets.ts new file mode 100644 index 0000000000..a6a63f7c89 --- /dev/null +++ b/packages/builder/src/stores/builder/snippets.ts @@ -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 { + 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() diff --git a/packages/builder/src/stores/builder/theme.js b/packages/builder/src/stores/builder/theme.js deleted file mode 100644 index ed46e9095a..0000000000 --- a/packages/builder/src/stores/builder/theme.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/builder/theme.ts b/packages/builder/src/stores/builder/theme.ts new file mode 100644 index 0000000000..604add9410 --- /dev/null +++ b/packages/builder/src/stores/builder/theme.ts @@ -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 { + 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, 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() diff --git a/packages/builder/src/stores/builder/users.js b/packages/builder/src/stores/builder/users.js deleted file mode 100644 index 0794c63289..0000000000 --- a/packages/builder/src/stores/builder/users.js +++ /dev/null @@ -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 -}) diff --git a/packages/builder/src/stores/builder/users.ts b/packages/builder/src/stores/builder/users.ts new file mode 100644 index 0000000000..5350b2af90 --- /dev/null +++ b/packages/builder/src/stores/builder/users.ts @@ -0,0 +1,59 @@ +import { get, derived } from "svelte/store" +import { BudiStore } from "../BudiStore" +import { UIUser } from "@budibase/types" + +export class UserStore extends BudiStore { + 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 => { + let map: Record = {} + $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 +}) diff --git a/packages/builder/src/stores/builder/websocket.js b/packages/builder/src/stores/builder/websocket.js deleted file mode 100644 index 3944c8cba5..0000000000 --- a/packages/builder/src/stores/builder/websocket.js +++ /dev/null @@ -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 -} diff --git a/packages/builder/src/stores/builder/websocket.ts b/packages/builder/src/stores/builder/websocket.ts new file mode 100644 index 0000000000..bd9e2c8d4d --- /dev/null +++ b/packages/builder/src/stores/builder/websocket.ts @@ -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 +} diff --git a/packages/builder/src/stores/portal/auth.ts b/packages/builder/src/stores/portal/auth.ts index afbb75646c..171b2b43ae 100644 --- a/packages/builder/src/stores/portal/auth.ts +++ b/packages/builder/src/stores/portal/auth.ts @@ -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 accountPortalAccess: boolean loaded: boolean @@ -33,7 +33,7 @@ class AuthStore extends BudiStore { }) } - setUser(user?: User) { + setUser(user?: GetGlobalSelfResponse) { this.set({ loaded: true, user: user, diff --git a/packages/builder/src/stores/portal/backups.js b/packages/builder/src/stores/portal/backups.js deleted file mode 100644 index 7bd98c78ea..0000000000 --- a/packages/builder/src/stores/portal/backups.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/backups.test.js b/packages/builder/src/stores/portal/backups.test.js index 51cd13f91a..93b2c8a2e0 100644 --- a/packages/builder/src/stores/portal/backups.test.js +++ b/packages/builder/src/stores/portal/backups.test.js @@ -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", () => { diff --git a/packages/builder/src/stores/portal/backups.ts b/packages/builder/src/stores/portal/backups.ts new file mode 100644 index 0000000000..050d509555 --- /dev/null +++ b/packages/builder/src/stores/portal/backups.ts @@ -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 { + 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() diff --git a/packages/builder/src/stores/portal/email.js b/packages/builder/src/stores/portal/email.js deleted file mode 100644 index 7afd543002..0000000000 --- a/packages/builder/src/stores/portal/email.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/email.ts b/packages/builder/src/stores/portal/email.ts new file mode 100644 index 0000000000..290f443c78 --- /dev/null +++ b/packages/builder/src/stores/portal/email.ts @@ -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 { + 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() diff --git a/packages/builder/src/stores/portal/environment.js b/packages/builder/src/stores/portal/environment.js deleted file mode 100644 index 232f314cad..0000000000 --- a/packages/builder/src/stores/portal/environment.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/environment.ts b/packages/builder/src/stores/portal/environment.ts new file mode 100644 index 0000000000..2269ba48ab --- /dev/null +++ b/packages/builder/src/stores/portal/environment.ts @@ -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 { + 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() diff --git a/packages/builder/src/stores/portal/featureFlags.js b/packages/builder/src/stores/portal/featureFlags.js deleted file mode 100644 index ef1100310a..0000000000 --- a/packages/builder/src/stores/portal/featureFlags.js +++ /dev/null @@ -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 || {}), - } -}) diff --git a/packages/builder/src/stores/portal/featureFlags.ts b/packages/builder/src/stores/portal/featureFlags.ts new file mode 100644 index 0000000000..0e40d0c7b4 --- /dev/null +++ b/packages/builder/src/stores/portal/featureFlags.ts @@ -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 = derived(auth, $auth => ({ + ...FeatureFlagDefaults, + ...($auth?.user?.flags || {}), +})) diff --git a/packages/builder/src/stores/portal/features.js b/packages/builder/src/stores/portal/features.js deleted file mode 100644 index 747052aa6c..0000000000 --- a/packages/builder/src/stores/portal/features.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/features.ts b/packages/builder/src/stores/portal/features.ts new file mode 100644 index 0000000000..049c7485a6 --- /dev/null +++ b/packages/builder/src/stores/portal/features.ts @@ -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 { + constructor() { + const makeDerivedStore = (store: Writable) => { + 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() diff --git a/packages/builder/src/stores/portal/groups.js b/packages/builder/src/stores/portal/groups.js deleted file mode 100644 index 408fb4189a..0000000000 --- a/packages/builder/src/stores/portal/groups.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/groups.ts b/packages/builder/src/stores/portal/groups.ts new file mode 100644 index 0000000000..028f300d2c --- /dev/null +++ b/packages/builder/src/stores/portal/groups.ts @@ -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 { + 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() diff --git a/packages/builder/src/stores/portal/licensing.js b/packages/builder/src/stores/portal/licensing.js deleted file mode 100644 index afc3ea1628..0000000000 --- a/packages/builder/src/stores/portal/licensing.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/licensing.ts b/packages/builder/src/stores/portal/licensing.ts new file mode 100644 index 0000000000..99970313e2 --- /dev/null +++ b/packages/builder/src/stores/portal/licensing.ts @@ -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 { + 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() diff --git a/packages/builder/src/stores/portal/menu.js b/packages/builder/src/stores/portal/menu.js deleted file mode 100644 index 75a9b363be..0000000000 --- a/packages/builder/src/stores/portal/menu.js +++ /dev/null @@ -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 -}) diff --git a/packages/builder/src/stores/portal/menu.ts b/packages/builder/src/stores/portal/menu.ts new file mode 100644 index 0000000000..5cd619d4a9 --- /dev/null +++ b/packages/builder/src/stores/portal/menu.ts @@ -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 = 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 + } +) diff --git a/packages/builder/src/stores/portal/oidc.js b/packages/builder/src/stores/portal/oidc.js deleted file mode 100644 index 65d8eac04c..0000000000 --- a/packages/builder/src/stores/portal/oidc.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/oidc.ts b/packages/builder/src/stores/portal/oidc.ts new file mode 100644 index 0000000000..6c3609f9d5 --- /dev/null +++ b/packages/builder/src/stores/portal/oidc.ts @@ -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 { + 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() diff --git a/packages/builder/src/stores/portal/organisation.js b/packages/builder/src/stores/portal/organisation.js deleted file mode 100644 index 6d41620c9f..0000000000 --- a/packages/builder/src/stores/portal/organisation.js +++ /dev/null @@ -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() diff --git a/packages/builder/src/stores/portal/organisation.ts b/packages/builder/src/stores/portal/organisation.ts new file mode 100644 index 0000000000..219245807a --- /dev/null +++ b/packages/builder/src/stores/portal/organisation.ts @@ -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 { + 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) { + // 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() diff --git a/packages/cli/package.json b/packages/cli/package.json index 927824a522..662434957e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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" diff --git a/packages/cli/src/hosting/start.ts b/packages/cli/src/hosting/start.ts index 75e3df451f..5b08268c94 100644 --- a/packages/cli/src/hosting/start.ts +++ b/packages/cli/src/hosting/start.ts @@ -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.` diff --git a/packages/client/src/api/patches.js b/packages/client/src/api/patches.js index 5413379b72..722167e16d 100644 --- a/packages/client/src/api/patches.js +++ b/packages/client/src/api/patches.js @@ -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) } diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index 9d0bddcc92..16dc3c97e7 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -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) { diff --git a/packages/client/src/utils/schema.js b/packages/client/src/utils/schema.js index ec1cef53ce..ffab142cf3 100644 --- a/packages/client/src/utils/schema.js +++ b/packages/client/src/utils/schema.js @@ -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" /** diff --git a/packages/frontend-core/src/api/configs.ts b/packages/frontend-core/src/api/configs.ts index 82f08e58a7..408180a859 100644 --- a/packages/frontend-core/src/api/configs.ts +++ b/packages/frontend-core/src/api/configs.ts @@ -16,7 +16,7 @@ import { BaseAPIClient } from "./types" export interface ConfigEndpoints { getConfig: (type: ConfigType) => Promise getTenantConfig: (tentantId: string) => Promise - getOIDCConfig: (tenantId: string) => Promise + getOIDCConfigs: (tenantId: string) => Promise getOIDCLogos: () => Promise> saveConfig: (config: SaveConfigRequest) => Promise deleteConfig: (id: string, rev: string) => Promise @@ -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}`, }) diff --git a/packages/frontend-core/src/api/groups.ts b/packages/frontend-core/src/api/groups.ts index c09c5284ec..e6374f257c 100644 --- a/packages/frontend-core/src/api/groups.ts +++ b/packages/frontend-core/src/api/groups.ts @@ -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({ url: "/api/global/groups", }) + return res.data }, /** diff --git a/packages/frontend-core/src/api/views.ts b/packages/frontend-core/src/api/views.ts index 3f8ac8aa41..aa0f797f58 100644 --- a/packages/frontend-core/src/api/views.ts +++ b/packages/frontend-core/src/api/views.ts @@ -3,7 +3,15 @@ import { BaseAPIClient } from "./types" export interface ViewEndpoints { // Missing request or response types - fetchViewData: (name: string, opts: any) => Promise + fetchViewData: ( + name: string, + opts: { + calculation?: string + field?: string + groupBy?: string + tableId: string + } + ) => Promise exportView: (name: string, format: string) => Promise saveView: (view: any) => Promise deleteView: (name: string) => Promise @@ -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) { diff --git a/packages/frontend-core/src/api/viewsV2.ts b/packages/frontend-core/src/api/viewsV2.ts index 5018448e8c..4a867e8f6a 100644 --- a/packages/frontend-core/src/api/viewsV2.ts +++ b/packages/frontend-core/src/api/viewsV2.ts @@ -1,6 +1,7 @@ import { CreateViewRequest, CreateViewResponse, + PaginatedSearchRowResponse, SearchRowResponse, SearchViewRowRequest, UpdateViewRequest, @@ -13,10 +14,14 @@ export interface ViewV2Endpoints { fetchDefinition: (viewId: string) => Promise create: (view: CreateViewRequest) => Promise update: (view: UpdateViewRequest) => Promise - fetch: ( + fetch: ( viewId: string, - opts: SearchViewRowRequest - ) => Promise + opts: T + ) => Promise< + T extends { paginate: true } + ? PaginatedSearchRowResponse + : SearchRowResponse + > delete: (viewId: string) => Promise } @@ -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, diff --git a/packages/frontend-core/src/components/grid/stores/config.ts b/packages/frontend-core/src/components/grid/stores/config.ts index e334b58495..2ddaf1b65c 100644 --- a/packages/frontend-core/src/components/grid/stores/config.ts +++ b/packages/frontend-core/src/components/grid/stores/config.ts @@ -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 diff --git a/packages/frontend-core/src/components/grid/stores/datasource.ts b/packages/frontend-core/src/components/grid/stores/datasource.ts index 74101701ed..805ace5a8f 100644 --- a/packages/frontend-core/src/components/grid/stores/datasource.ts +++ b/packages/frontend-core/src/components/grid/stores/datasource.ts @@ -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 = getDatasourceSchema({ + const schema: Record | 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() } diff --git a/packages/frontend-core/src/components/grid/stores/rows.ts b/packages/frontend-core/src/components/grid/stores/rows.ts index d6b80df885..07fbf02134 100644 --- a/packages/frontend-core/src/components/grid/stores/rows.ts +++ b/packages/frontend-core/src/components/grid/stores/rows.ts @@ -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 - fetch: Writable + fetch: Writable | null> // TODO: type this properly, having a union of all the possible options loaded: Writable refreshing: Writable loading: Writable @@ -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 diff --git a/packages/frontend-core/src/constants.ts b/packages/frontend-core/src/constants.ts index 8a39e8c106..907d91825f 100644 --- a/packages/frontend-core/src/constants.ts +++ b/packages/frontend-core/src/constants.ts @@ -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 = { diff --git a/packages/frontend-core/src/fetch/CustomFetch.js b/packages/frontend-core/src/fetch/CustomFetch.ts similarity index 84% rename from packages/frontend-core/src/fetch/CustomFetch.js rename to packages/frontend-core/src/fetch/CustomFetch.ts index fc62d790e2..afd3d18ba9 100644 --- a/packages/frontend-core/src/fetch/CustomFetch.js +++ b/packages/frontend-core/src/fetch/CustomFetch.ts @@ -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 + +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 = {} 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") { diff --git a/packages/frontend-core/src/fetch/DataFetch.js b/packages/frontend-core/src/fetch/DataFetch.ts similarity index 69% rename from packages/frontend-core/src/fetch/DataFetch.js rename to packages/frontend-core/src/fetch/DataFetch.ts index 175365a442..9312c57637 100644 --- a/packages/frontend-core/src/fetch/DataFetch.js +++ b/packages/frontend-core/src/fetch/DataFetch.ts @@ -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 { + 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 + extends DataFetchStore { + 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 | 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> + derivedStore: Readable> + /** * Constructs a new DataFetch instance. * @param opts the fetch options */ - constructor(opts) { - // API client - this.API = null - + constructor(opts: DataFetchParams) { // 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 | 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 /** * 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 | 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 = {} + 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 | 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): 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 } diff --git a/packages/frontend-core/src/fetch/FieldFetch.js b/packages/frontend-core/src/fetch/FieldFetch.ts similarity index 52% rename from packages/frontend-core/src/fetch/FieldFetch.js rename to packages/frontend-core/src/fetch/FieldFetch.ts index 9402a45a83..ac1e683c51 100644 --- a/packages/frontend-core/src/fetch/FieldFetch.js +++ b/packages/frontend-core/src/fetch/FieldFetch.ts @@ -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 | 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 { + 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 diff --git a/packages/frontend-core/src/fetch/GroupUserFetch.js b/packages/frontend-core/src/fetch/GroupUserFetch.ts similarity index 64% rename from packages/frontend-core/src/fetch/GroupUserFetch.js rename to packages/frontend-core/src/fetch/GroupUserFetch.ts index bd2cf264c5..a14623bfb0 100644 --- a/packages/frontend-core/src/fetch/GroupUserFetch.js +++ b/packages/frontend-core/src/fetch/GroupUserFetch.ts @@ -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) { 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 { diff --git a/packages/frontend-core/src/fetch/JSONArrayFetch.js b/packages/frontend-core/src/fetch/JSONArrayFetch.ts similarity index 82% rename from packages/frontend-core/src/fetch/JSONArrayFetch.js rename to packages/frontend-core/src/fetch/JSONArrayFetch.ts index ab2af3e2c7..cae9a1e521 100644 --- a/packages/frontend-core/src/fetch/JSONArrayFetch.js +++ b/packages/frontend-core/src/fetch/JSONArrayFetch.ts @@ -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 { diff --git a/packages/frontend-core/src/fetch/NestedProviderFetch.js b/packages/frontend-core/src/fetch/NestedProviderFetch.js deleted file mode 100644 index 0a08b00cb4..0000000000 --- a/packages/frontend-core/src/fetch/NestedProviderFetch.js +++ /dev/null @@ -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, - } - } -} diff --git a/packages/frontend-core/src/fetch/NestedProviderFetch.ts b/packages/frontend-core/src/fetch/NestedProviderFetch.ts new file mode 100644 index 0000000000..666340610f --- /dev/null +++ b/packages/frontend-core/src/fetch/NestedProviderFetch.ts @@ -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, + } + } +} diff --git a/packages/frontend-core/src/fetch/QueryArrayFetch.js b/packages/frontend-core/src/fetch/QueryArrayFetch.ts similarity index 65% rename from packages/frontend-core/src/fetch/QueryArrayFetch.js rename to packages/frontend-core/src/fetch/QueryArrayFetch.ts index 0b36b640a6..9142000fe6 100644 --- a/packages/frontend-core/src/fetch/QueryArrayFetch.js +++ b/packages/frontend-core/src/fetch/QueryArrayFetch.ts @@ -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 } diff --git a/packages/frontend-core/src/fetch/QueryFetch.js b/packages/frontend-core/src/fetch/QueryFetch.ts similarity index 73% rename from packages/frontend-core/src/fetch/QueryFetch.js rename to packages/frontend-core/src/fetch/QueryFetch.ts index 9fac9704d3..0754edd267 100644 --- a/packages/frontend-core/src/fetch/QueryFetch.js +++ b/packages/frontend-core/src/fetch/QueryFetch.ts @@ -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 & { + pagination?: { + type: string + location: string + pageParam: string + } + } + queryParams?: Record + parameters: { name: string; default: string }[] +} + +export default class QueryFetch extends DataFetch { + 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 diff --git a/packages/frontend-core/src/fetch/RelationshipFetch.js b/packages/frontend-core/src/fetch/RelationshipFetch.js deleted file mode 100644 index 0dec535724..0000000000 --- a/packages/frontend-core/src/fetch/RelationshipFetch.js +++ /dev/null @@ -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: [] } - } - } -} diff --git a/packages/frontend-core/src/fetch/RelationshipFetch.ts b/packages/frontend-core/src/fetch/RelationshipFetch.ts new file mode 100644 index 0000000000..f853a753cd --- /dev/null +++ b/packages/frontend-core/src/fetch/RelationshipFetch.ts @@ -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: [] } + } + } +} diff --git a/packages/frontend-core/src/fetch/TableFetch.js b/packages/frontend-core/src/fetch/TableFetch.ts similarity index 58% rename from packages/frontend-core/src/fetch/TableFetch.js rename to packages/frontend-core/src/fetch/TableFetch.ts index 777d16aa45..f5927262cb 100644 --- a/packages/frontend-core/src/fetch/TableFetch.js +++ b/packages/frontend-core/src/fetch/TableFetch.ts @@ -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 { + 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, diff --git a/packages/frontend-core/src/fetch/UserFetch.js b/packages/frontend-core/src/fetch/UserFetch.ts similarity index 54% rename from packages/frontend-core/src/fetch/UserFetch.js rename to packages/frontend-core/src/fetch/UserFetch.ts index 36f61542b5..656cd840fe 100644 --- a/packages/frontend-core/src/fetch/UserFetch.js +++ b/packages/frontend-core/src/fetch/UserFetch.ts @@ -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) { 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, diff --git a/packages/frontend-core/src/fetch/ViewFetch.js b/packages/frontend-core/src/fetch/ViewFetch.js deleted file mode 100644 index 272c222dd4..0000000000 --- a/packages/frontend-core/src/fetch/ViewFetch.js +++ /dev/null @@ -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: [] } - } - } -} diff --git a/packages/frontend-core/src/fetch/ViewFetch.ts b/packages/frontend-core/src/fetch/ViewFetch.ts new file mode 100644 index 0000000000..b6830e7118 --- /dev/null +++ b/packages/frontend-core/src/fetch/ViewFetch.ts @@ -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 { + 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: [] } + } + } +} diff --git a/packages/frontend-core/src/fetch/ViewV2Fetch.js b/packages/frontend-core/src/fetch/ViewV2Fetch.ts similarity index 51% rename from packages/frontend-core/src/fetch/ViewV2Fetch.js rename to packages/frontend-core/src/fetch/ViewV2Fetch.ts index 8436646077..cdd3bab6ed 100644 --- a/packages/frontend-core/src/fetch/ViewV2Fetch.js +++ b/packages/frontend-core/src/fetch/ViewV2Fetch.ts @@ -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 { + 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 { diff --git a/packages/frontend-core/src/fetch/index.js b/packages/frontend-core/src/fetch/index.js deleted file mode 100644 index 903810ac25..0000000000 --- a/packages/frontend-core/src/fetch/index.js +++ /dev/null @@ -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) -} diff --git a/packages/frontend-core/src/fetch/index.ts b/packages/frontend-core/src/fetch/index.ts new file mode 100644 index 0000000000..4accb0b5ec --- /dev/null +++ b/packages/frontend-core/src/fetch/index.ts @@ -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) +} diff --git a/packages/frontend-core/src/utils/json.d.ts b/packages/frontend-core/src/utils/json.d.ts new file mode 100644 index 0000000000..e9b6ac5703 --- /dev/null +++ b/packages/frontend-core/src/utils/json.d.ts @@ -0,0 +1,23 @@ +import { JsonFieldMetadata, QuerySchema } from "@budibase/types" + +type Schema = Record + +declare module "./json" { + export const getJSONArrayDatasourceSchema: ( + tableSchema: Schema, + datasource: any + ) => Record + + export const generateQueryArraySchemas: ( + schema: Schema, + nestedSchemaFields?: Record + ) => Schema + + export const convertJSONSchemaToTableSchema: ( + jsonSchema: JsonFieldMetadata, + options: { + squashObjects?: boolean + prefixKeys?: string + } + ) => Record +} diff --git a/packages/frontend-core/src/utils/websocket.js b/packages/frontend-core/src/utils/websocket.ts similarity index 78% rename from packages/frontend-core/src/utils/websocket.js rename to packages/frontend-core/src/utils/websocket.ts index dee679eaef..475b14176f 100644 --- a/packages/frontend-core/src/utils/websocket.js +++ b/packages/frontend-core/src/utils/websocket.ts @@ -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) diff --git a/packages/pro b/packages/pro index ae786121d9..193476cdfa 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit ae786121d923449b0ad5fcbd123d0a9fec28f65e +Subproject commit 193476cdfade6d3c613e6972f16ee0c527e01ff6 diff --git a/packages/server/src/api/controllers/query/index.ts b/packages/server/src/api/controllers/query/index.ts index 7084d065fa..b522008cad 100644 --- a/packages/server/src/api/controllers/query/index.ts +++ b/packages/server/src/api/controllers/query/index.ts @@ -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 ) { - return execute(ctx, { rowsOnly: false }) + return execute(ctx, { rowsOnly: false, isAutomation: false }) } export async function executeV2AsAutomation( diff --git a/packages/server/src/api/controllers/row/staticFormula.ts b/packages/server/src/api/controllers/row/staticFormula.ts index 4464b7f44a..1feecb4429 100644 --- a/packages/server/src/api/controllers/row/staticFormula.ts +++ b/packages/server/src/api/controllers/row/staticFormula.ts @@ -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) { diff --git a/packages/server/src/api/controllers/row/views.ts b/packages/server/src/api/controllers/row/views.ts index 0655a3b38f..418aa462c4 100644 --- a/packages/server/src/api/controllers/row/views.ts +++ b/packages/server/src/api/controllers/row/views.ts @@ -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 + ctx: UserCtx ) { 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) { diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index a3012c3760..968ce9c798 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -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 () => { diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 6ace7e256b..9531737d30 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -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 }) diff --git a/packages/server/src/automations/actions.ts b/packages/server/src/automations/actions.ts index e5a1c63b7d..537b6befc3 100644 --- a/packages/server/src/automations/actions.ts +++ b/packages/server/src/automations/actions.ts @@ -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 > { - 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 } diff --git a/packages/server/src/automations/steps/openai.ts b/packages/server/src/automations/steps/openai.ts index 48eaa93057..53e41ceb09 100644 --- a/packages/server/src/automations/steps/openai.ts +++ b/packages/server/src/automations/steps/openai.ts @@ -7,9 +7,8 @@ import { AutomationIOType, OpenAIStepInputs, OpenAIStepOutputs, - FeatureFlag, } from "@budibase/types" -import { env, features } from "@budibase/backend-core" +import { env } from "@budibase/backend-core" import * as automationUtils from "../automationUtils" import * as pro from "@budibase/pro" @@ -99,12 +98,8 @@ export async function run({ try { let response - const customConfigsEnabled = - (await features.flags.isEnabled(FeatureFlag.AI_CUSTOM_CONFIGS)) && - (await pro.features.isAICustomConfigsEnabled()) - const budibaseAIEnabled = - (await features.flags.isEnabled(FeatureFlag.BUDIBASE_AI)) && - (await pro.features.isBudibaseAIEnabled()) + const customConfigsEnabled = await pro.features.isAICustomConfigsEnabled() + const budibaseAIEnabled = await pro.features.isBudibaseAIEnabled() let llmWrapper if (budibaseAIEnabled || customConfigsEnabled) { diff --git a/packages/server/src/middleware/zod-validator.ts b/packages/server/src/middleware/zod-validator.ts index e8cc2c470a..d57e1c48ff 100644 --- a/packages/server/src/middleware/zod-validator.ts +++ b/packages/server/src/middleware/zod-validator.ts @@ -7,7 +7,7 @@ import { fromZodError } from "zod-validation-error" function validate(schema: AnyZodObject, property: "body" | "params") { // Return a Koa middleware function return async (ctx: Ctx, next: any) => { - if (!(await features.flags.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) { + if (!(await features.isEnabled(FeatureFlag.USE_ZOD_VALIDATOR))) { return next() } diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index 7fc78b9085..4f978253d6 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -432,6 +432,21 @@ export async function enrichSchema( ...tableSchema[key], ...ui, order: anyViewOrder ? ui?.order ?? undefined : tableSchema[key]?.order, + // When this was written, the only column types in FieldSchema to have columns + // field were the relationship columns. We blank this out here to make sure it's + // not set on non-relationship columns, then below we populate it by calling + // populateRelSchema. + // + // For Budibase 3.0 we introduced the FieldType.AI fields. Some of these fields + // have `columns: string[]` and it flew under the radar here because the + // AIFieldMetadata type isn't a union on its subtypes, it has a collection of + // optional fields. So columns is `columns?: string[]` which allows undefined, + // and doesn't fail this type check. + // + // What this means in practice is when FieldType.AI fields get enriched, we + // delete their `columns`. At the time of writing, I don't believe anything in + // the frontend depends on this, but it is odd and will probably bite us at + // some point. columns: undefined, } diff --git a/packages/server/src/tests/utilities/mocks/openai.ts b/packages/server/src/tests/utilities/mocks/openai.ts new file mode 100644 index 0000000000..b17491808c --- /dev/null +++ b/packages/server/src/tests/utilities/mocks/openai.ts @@ -0,0 +1,46 @@ +import nock from "nock" + +let chatID = 1 + +export function mockChatGPTResponse( + response: string | ((prompt: string) => string) +) { + return nock("https://api.openai.com") + .post("/v1/chat/completions") + .reply(200, (uri, requestBody) => { + let content = response + if (typeof response === "function") { + const messages = (requestBody as any).messages + content = response(messages[0].content) + } + + chatID++ + + return { + id: `chatcmpl-${chatID}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: "gpt-4o-mini", + system_fingerprint: `fp_${chatID}`, + choices: [ + { + index: 0, + message: { role: "assistant", content }, + logprobs: null, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + completion_tokens_details: { + reasoning_tokens: 0, + accepted_prediction_tokens: 0, + rejected_prediction_tokens: 0, + }, + }, + } + }) + .persist() +} diff --git a/packages/server/src/threads/definitions.ts b/packages/server/src/threads/definitions.ts index 85e546280d..44a76a60a3 100644 --- a/packages/server/src/threads/definitions.ts +++ b/packages/server/src/threads/definitions.ts @@ -3,7 +3,10 @@ import { Datasource, Row, Query } from "@budibase/types" export type WorkerCallback = (error: any, response?: any) => void export interface QueryEvent - extends Omit { + extends Omit< + Query, + "datasourceId" | "name" | "parameters" | "readable" | "nestedSchemaFields" + > { appId?: string datasource: Datasource pagination?: any diff --git a/packages/server/src/threads/query.ts b/packages/server/src/threads/query.ts index facdd20642..3ba4995b2c 100644 --- a/packages/server/src/threads/query.ts +++ b/packages/server/src/threads/query.ts @@ -174,7 +174,9 @@ class QueryRunner { } // needs to an array for next step - if (!Array.isArray(rows)) { + if (rows === null) { + rows = [] + } else if (!Array.isArray(rows)) { rows = [rows] } diff --git a/packages/server/src/utilities/rowProcessor/utils.ts b/packages/server/src/utilities/rowProcessor/utils.ts index 09d3324ded..7d2f8b49f4 100644 --- a/packages/server/src/utilities/rowProcessor/utils.ts +++ b/packages/server/src/utilities/rowProcessor/utils.ts @@ -160,7 +160,7 @@ export async function processAIColumns( return tracer.trace("processAIColumn", {}, async span => { span?.addTags({ table_id: table._id, column }) - const llmResponse = await llmWrapper.run(prompt!) + const llmResponse = await llmWrapper.run(prompt) return { ...row, [column]: llmResponse, diff --git a/packages/shared-core/src/filters.ts b/packages/shared-core/src/filters.ts index a023015b7e..b711d4cb61 100644 --- a/packages/shared-core/src/filters.ts +++ b/packages/shared-core/src/filters.ts @@ -911,8 +911,8 @@ export function sort>( * @param docs the data * @param limit the number of docs to limit to */ -export function limit(docs: T[], limit: string): T[] { - const numLimit = parseFloat(limit) +export function limit(docs: T[], limit: string | number): T[] { + const numLimit = typeof limit === "number" ? limit : parseFloat(limit) if (isNaN(numLimit)) { return docs } diff --git a/packages/shared-core/src/utils.ts b/packages/shared-core/src/utils.ts index e2c40a8849..fac8fa61ee 100644 --- a/packages/shared-core/src/utils.ts +++ b/packages/shared-core/src/utils.ts @@ -109,7 +109,9 @@ export function trimOtherProps(object: any, allowedProps: string[]) { return result } -export function isSupportedUserSearch(query: SearchFilters) { +export function isSupportedUserSearch( + query: SearchFilters +): query is SearchFilters { const allowed = [ { op: BasicOperator.STRING, key: "email" }, { op: BasicOperator.EQUAL, key: "_id" }, diff --git a/packages/types/src/api/web/app/query.ts b/packages/types/src/api/web/app/query.ts index 302f0d03e5..75cc37e1a9 100644 --- a/packages/types/src/api/web/app/query.ts +++ b/packages/types/src/api/web/app/query.ts @@ -40,6 +40,10 @@ export interface ExecuteQueryRequest { export type ExecuteV1QueryResponse = Record[] export interface ExecuteV2QueryResponse { data: Record[] + pagination?: { + page: number + cursor: string + } } export interface DeleteQueryResponse { diff --git a/packages/types/src/api/web/global/self.ts b/packages/types/src/api/web/global/self.ts index 5f21a8ddc5..517559d1ca 100644 --- a/packages/types/src/api/web/global/self.ts +++ b/packages/types/src/api/web/global/self.ts @@ -1,4 +1,6 @@ -import { DevInfo, User } from "../../../documents" +import { License } from "../../../sdk" +import { Account, DevInfo, User } from "../../../documents" +import { FeatureFlags } from "@budibase/types" export interface GenerateAPIKeyRequest { userId?: string @@ -8,5 +10,10 @@ export interface GenerateAPIKeyResponse extends DevInfo {} export interface FetchAPIKeyResponse extends DevInfo {} export interface GetGlobalSelfResponse extends User { - flags?: Record + flags?: FeatureFlags + account?: Account + license: License + budibaseAccess: boolean + accountPortalAccess: boolean + csrfToken: boolean } diff --git a/packages/types/src/api/web/pagination.ts b/packages/types/src/api/web/pagination.ts index 48588bf6a1..f87bc97824 100644 --- a/packages/types/src/api/web/pagination.ts +++ b/packages/types/src/api/web/pagination.ts @@ -24,4 +24,5 @@ export interface PaginationRequest extends BasicPaginationRequest { export interface PaginationResponse { bookmark: string | number | undefined hasNextPage?: boolean + totalRows?: number } diff --git a/packages/types/src/documents/app/app.ts b/packages/types/src/documents/app/app.ts index e31dd1e9ac..46799cb61a 100644 --- a/packages/types/src/documents/app/app.ts +++ b/packages/types/src/documents/app/app.ts @@ -1,4 +1,4 @@ -import { User, Document, Plugin, Snippet } from "../" +import { User, Document, Plugin, Snippet, Theme } from "../" import { SocketSession } from "../../sdk" export type AppMetadataErrors = { [key: string]: string[] } @@ -14,7 +14,7 @@ export interface App extends Document { instance: AppInstance tenantId: string status: string - theme?: string + theme?: Theme customTheme?: AppCustomTheme revertableVersion?: string lockedBy?: User @@ -37,8 +37,8 @@ export interface AppInstance { export interface AppNavigation { navigation: string - title: string - navWidth: string + title?: string + navWidth?: string sticky?: boolean hideLogo?: boolean logoUrl?: string @@ -46,6 +46,7 @@ export interface AppNavigation { navBackground?: string navTextColor?: string links?: AppNavigationLink[] + textAlign?: string } export interface AppNavigationLink { @@ -53,6 +54,8 @@ export interface AppNavigationLink { url: string id?: string roleId?: string + type?: string + subLinks?: AppNavigationLink[] } export interface AppCustomTheme { diff --git a/packages/types/src/documents/app/query.ts b/packages/types/src/documents/app/query.ts index a545ca144e..d287a5b2c3 100644 --- a/packages/types/src/documents/app/query.ts +++ b/packages/types/src/documents/app/query.ts @@ -1,4 +1,5 @@ import { Document } from "../document" +import { Row } from "./row" export interface QuerySchema { name?: string @@ -13,6 +14,7 @@ export interface Query extends Document { fields: RestQueryFields | any transformer: string | null schema: Record + nestedSchemaFields?: Record> readable: boolean queryVerb: string // flag to state whether the default bindings are empty strings (old behaviour) or null @@ -29,7 +31,7 @@ export interface QueryParameter { } export interface QueryResponse { - rows: any[] + rows: Row[] keys: string[] info: any extra: any diff --git a/packages/types/src/documents/app/row.ts b/packages/types/src/documents/app/row.ts index 6b6b38a5cf..bb58933b65 100644 --- a/packages/types/src/documents/app/row.ts +++ b/packages/types/src/documents/app/row.ts @@ -154,6 +154,7 @@ export const GroupByTypes = [ FieldType.BOOLEAN, FieldType.DATETIME, FieldType.BIGINT, + FieldType.AI, ] export function canGroupBy(type: FieldType) { diff --git a/packages/types/src/documents/app/table/schema.ts b/packages/types/src/documents/app/table/schema.ts index 771192e2f5..551b1f16a8 100644 --- a/packages/types/src/documents/app/table/schema.ts +++ b/packages/types/src/documents/app/table/schema.ts @@ -123,7 +123,7 @@ export interface AIFieldMetadata extends BaseFieldSchema { operation: AIOperationEnum columns?: string[] column?: string - categories?: string[] + categories?: string prompt?: string language?: string } @@ -227,6 +227,7 @@ interface OtherFieldMetadata extends BaseFieldSchema { | FieldType.OPTIONS | FieldType.BOOLEAN | FieldType.BIGINT + | FieldType.JSON > } diff --git a/packages/types/src/documents/global/config.ts b/packages/types/src/documents/global/config.ts index 33f7e10584..1ad20e291f 100644 --- a/packages/types/src/documents/global/config.ts +++ b/packages/types/src/documents/global/config.ts @@ -26,13 +26,11 @@ export interface SMTPConfig extends Config {} export interface SettingsBrandingConfig { faviconUrl?: string faviconUrlEtag?: string - emailBrandingEnabled?: boolean testimonialsEnabled?: boolean platformTitle?: string loginHeading?: string loginButton?: string - metaDescription?: string metaImageUrl?: string metaTitle?: string @@ -42,6 +40,7 @@ export interface SettingsInnerConfig { platformUrl?: string company?: string logoUrl?: string // Populated on read + docsUrl?: string logoUrlEtag?: string uniqueTenantId?: string analyticsEnabled?: boolean @@ -127,6 +126,9 @@ export interface AIInnerConfig { export interface AIConfig extends Config {} +export const isConfig = (config: Object): config is Config => + "type" in config && "config" in config + export const isSettingsConfig = (config: Config): config is SettingsConfig => config.type === ConfigType.SETTINGS diff --git a/packages/types/src/sdk/featureFlag.ts b/packages/types/src/sdk/featureFlag.ts index 98e744324c..7b61b70772 100644 --- a/packages/types/src/sdk/featureFlag.ts +++ b/packages/types/src/sdk/featureFlag.ts @@ -1,11 +1,9 @@ export enum FeatureFlag { - AUTOMATION_BRANCHING = "AUTOMATION_BRANCHING", - AI_CUSTOM_CONFIGS = "AI_CUSTOM_CONFIGS", - DEFAULT_VALUES = "DEFAULT_VALUES", - BUDIBASE_AI = "BUDIBASE_AI", USE_ZOD_VALIDATOR = "USE_ZOD_VALIDATOR", } -export interface TenantFeatureFlags { - [key: string]: FeatureFlag[] +export const FeatureFlagDefaults = { + [FeatureFlag.USE_ZOD_VALIDATOR]: false, } + +export type FeatureFlags = typeof FeatureFlagDefaults diff --git a/packages/types/src/ui/stores/grid/datasource.ts b/packages/types/src/ui/stores/grid/datasource.ts index 9533bbb8f0..9927518133 100644 --- a/packages/types/src/ui/stores/grid/datasource.ts +++ b/packages/types/src/ui/stores/grid/datasource.ts @@ -1,8 +1,6 @@ import { UITable, UIView } from "@budibase/types" -export type UIDatasource = (UITable | UIView) & { - type: string -} +export type UIDatasource = UITable | UIView export interface UIFieldMutation { visible?: boolean diff --git a/packages/types/src/ui/stores/grid/fetch.ts b/packages/types/src/ui/stores/grid/fetch.ts deleted file mode 100644 index 8901acc08b..0000000000 --- a/packages/types/src/ui/stores/grid/fetch.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - Row, - SortOrder, - UIDatasource, - UILegacyFilter, - UISearchFilter, -} from "@budibase/types" - -export interface UIFetchAPI { - definition: UIDatasource - - getInitialData: () => Promise - loading: any - loaded: boolean - - resetKey: string | null - error: any - - hasNextPage: boolean - nextPage: () => Promise - - rows: Row[] - - options?: { - datasource?: { - tableId: string - id: string - } - } - update: ({ - sortOrder, - sortColumn, - }: { - sortOrder?: SortOrder - sortColumn?: string - filter?: UILegacyFilter[] | UISearchFilter - }) => any -} diff --git a/packages/types/src/ui/stores/grid/index.ts b/packages/types/src/ui/stores/grid/index.ts index f419134452..7c3b6d4cb4 100644 --- a/packages/types/src/ui/stores/grid/index.ts +++ b/packages/types/src/ui/stores/grid/index.ts @@ -6,4 +6,3 @@ export * from "./view" export * from "./user" export * from "./filters" export * from "./rows" -export * from "./fetch" diff --git a/packages/types/src/ui/stores/grid/user.ts b/packages/types/src/ui/stores/grid/user.ts index b6eb529805..9625b0bb80 100644 --- a/packages/types/src/ui/stores/grid/user.ts +++ b/packages/types/src/ui/stores/grid/user.ts @@ -3,4 +3,5 @@ import { User } from "@budibase/types" export interface UIUser extends User { sessionId: string gridMetadata?: { focusedCellId?: string } + builderMetadata?: { selectedResourceId?: string } } diff --git a/packages/types/src/ui/stores/index.ts b/packages/types/src/ui/stores/index.ts index 1b82a06388..daf73bba75 100644 --- a/packages/types/src/ui/stores/index.ts +++ b/packages/types/src/ui/stores/index.ts @@ -1,4 +1,5 @@ export * from "./integration" +export * from "./misc" export * from "./automations" export * from "./grid" export * from "./preview" diff --git a/packages/types/src/ui/stores/misc.ts b/packages/types/src/ui/stores/misc.ts new file mode 100644 index 0000000000..275b388e9f --- /dev/null +++ b/packages/types/src/ui/stores/misc.ts @@ -0,0 +1,2 @@ +// type purely to capture structures that the type is unknown, but maybe known later +export type UIObject = Record diff --git a/packages/worker/src/api/controllers/global/self.ts b/packages/worker/src/api/controllers/global/self.ts index f8488f526b..3464bff88f 100644 --- a/packages/worker/src/api/controllers/global/self.ts +++ b/packages/worker/src/api/controllers/global/self.ts @@ -84,15 +84,15 @@ export async function fetchAPIKey(ctx: UserCtx) { } /** - * Add the attributes that are session based to the current user. + * */ -const addSessionAttributesToUser = (ctx: any) => { - ctx.body.account = ctx.user.account - ctx.body.license = ctx.user.license - ctx.body.budibaseAccess = !!ctx.user.budibaseAccess - ctx.body.accountPortalAccess = !!ctx.user.accountPortalAccess - ctx.body.csrfToken = ctx.user.csrfToken -} +const getUserSessionAttributes = (ctx: any) => ({ + account: ctx.user.account, + license: ctx.user.license, + budibaseAccess: !!ctx.user.budibaseAccess, + accountPortalAccess: !!ctx.user.accountPortalAccess, + csrfToken: ctx.user.csrfToken, +}) export async function getSelf(ctx: UserCtx) { if (!ctx.user) { @@ -108,13 +108,19 @@ export async function getSelf(ctx: UserCtx) { // get the main body of the user const user = await userSdk.db.getUser(userId) - ctx.body = await groups.enrichUserRolesFromGroups(user) + const enrichedUser = await groups.enrichUserRolesFromGroups(user) + + // add the attributes that are session based to the current user + const sessionAttributes = getUserSessionAttributes(ctx) // add the feature flags for this tenant const flags = await features.flags.fetch() - ctx.body.flags = flags - addSessionAttributesToUser(ctx) + ctx.body = { + ...enrichedUser, + ...sessionAttributes, + flags, + } } export const syncAppFavourites = async (processedAppIds: string[]) => { diff --git a/packages/worker/src/api/controllers/global/users.ts b/packages/worker/src/api/controllers/global/users.ts index a028f4fd33..83f2f41b0e 100644 --- a/packages/worker/src/api/controllers/global/users.ts +++ b/packages/worker/src/api/controllers/global/users.ts @@ -72,12 +72,14 @@ export const save = async (ctx: UserCtx) => { const requestUser = ctx.request.body // Do not allow the account holder role to be changed - const accountMetadata = await users.getExistingAccounts([requestUser.email]) - if (accountMetadata?.length > 0) { - if ( - requestUser.admin?.global !== true || - requestUser.builder?.global !== true - ) { + if ( + requestUser.admin?.global !== true || + requestUser.builder?.global !== true + ) { + const accountMetadata = await users.getExistingAccounts([ + requestUser.email, + ]) + if (accountMetadata?.length > 0) { throw Error("Cannot set role of account holder") } } @@ -441,7 +443,6 @@ export const checkInvite = async (ctx: UserCtx) => { } catch (e) { console.warn("Error getting invite from code", e) ctx.throw(400, "There was a problem with the invite") - return } ctx.body = { email: invite.email, @@ -472,7 +473,6 @@ export const updateInvite = async ( invite = await cache.invite.getCode(code) } catch (e) { ctx.throw(400, "There was a problem with the invite") - return } let updated = { diff --git a/yarn.lock b/yarn.lock index c6181f7866..c54385478e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2131,9 +2131,9 @@ through2 "^2.0.0" "@budibase/pro@npm:@budibase/pro@latest": - version "3.2.28" - resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.28.tgz#59b5b37225715bb8fbf5b1c5c989140b10b58710" - integrity sha512-eDPeZpYFRZYQhCulcQAUwFoPk68c8+K9mIsB6QD3oMHmHTDA1P2ZcXvLNqDTIqHB94DqnWinqDf4hTuGYApgPA== + version "3.2.32" + resolved "https://registry.yarnpkg.com/@budibase/pro/-/pro-3.2.32.tgz#f6abcd5a5524e7f33d958acb6e610e29995427bb" + integrity sha512-bF0pd17IjYugjll2yKYmb0RM+tfKZcCmRBc4XG2NZ4f/I47QaOovm9RqSw6tfqCFuzRewxR3SWmtmSseUc/e0w== dependencies: "@anthropic-ai/sdk" "^0.27.3" "@budibase/backend-core" "*" @@ -15600,15 +15600,7 @@ only@~0.0.2: resolved "https://registry.yarnpkg.com/only/-/only-0.0.2.tgz#2afde84d03e50b9a8edc444e30610a70295edfb4" integrity sha512-Fvw+Jemq5fjjyWz6CpKx6w9s7xxqo3+JCyM0WXWeCSOboZ8ABkyvP8ID4CZuChA/wxSx+XSJmdOm8rGVyJ1hdQ== -open@^7.3.1: - version "7.4.2" - resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" - integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== - dependencies: - is-docker "^2.0.0" - is-wsl "^2.1.1" - -open@^8.0.0, open@^8.4.0, open@~8.4.0: +open@8.4.2, open@^8.0.0, open@^8.4.0, open@~8.4.0: version "8.4.2" resolved "https://registry.yarnpkg.com/open/-/open-8.4.2.tgz#5b5ffe2a8f793dcd2aad73e550cb87b59cb084f9" integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== @@ -15617,6 +15609,14 @@ open@^8.0.0, open@^8.4.0, open@~8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +open@^7.3.1: + version "7.4.2" + resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + openai@4.59.0: version "4.59.0" resolved "https://registry.yarnpkg.com/openai/-/openai-4.59.0.tgz#3961d11a9afb5920e1bd475948a87969e244fc08"