diff --git a/.github/workflows/stale_bot.yml b/.github/workflows/stale_bot.yml index 411a70a463..df222a8483 100644 --- a/.github/workflows/stale_bot.yml +++ b/.github/workflows/stale_bot.yml @@ -8,41 +8,15 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 - with: - days-before-stale: 330 - operations-per-run: 1 - # stale rules for PRs - days-before-pr-stale: 7 - stale-issue-label: stale - exempt-pr-labels: pinned,security,roadmap - days-before-pr-close: 7 - days-before-issue-close: 30 - - - uses: actions/stale@v8 - with: - operations-per-run: 3 - # stale rules for high priority bugs - days-before-stale: 30 - only-issue-labels: bug,High priority - stale-issue-label: warn - days-before-close: 30 - - - uses: actions/stale@v8 - with: - operations-per-run: 3 - # stale rules for medium priority bugs - days-before-stale: 90 - only-issue-labels: bug,Medium priority - stale-issue-label: warn - days-before-close: 30 - - - uses: actions/stale@v8 - with: - operations-per-run: 3 - # stale rules for all bugs - days-before-stale: 180 - stale-issue-label: stale - only-issue-labels: bug - stale-issue-message: "This issue has been automatically marked as stale because it has not had any activity for six months." - days-before-close: 30 + - uses: actions/stale@v8 + with: + # Issues + days-before-stale: 180 + stale-issue-label: stale + days-before-close: 30 + stale-issue-message: "This issue has been automatically marked as stale as there has been no activity for 6 months." + # Pull requests + days-before-pr-stale: 7 + days-before-pr-close: 14 + exempt-pr-labels: pinned,security,roadmap + operations-per-run: 100 diff --git a/lerna.json b/lerna.json index dde9cf03a0..d033c24518 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.46", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 3e1b5f324b..1ab05cd14b 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -21,7 +21,7 @@ "scripts": { "prebuild": "rimraf dist/", "prepack": "cp package.json dist", - "build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null", + "build": "node ./scripts/build.js && tsc -p tsconfig.build.json --emitDeclarationOnly --paths null && tsc -p tsconfig.test.json --paths null", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "build:oss": "node ./scripts/build.js", "check:types": "tsc -p tsconfig.json --noEmit --paths null", diff --git a/packages/backend-core/src/db/couch/connections.ts b/packages/backend-core/src/db/couch/connections.ts index 5c7d7ec81d..9692b095a8 100644 --- a/packages/backend-core/src/db/couch/connections.ts +++ b/packages/backend-core/src/db/couch/connections.ts @@ -1,6 +1,6 @@ import env from "../../environment" -export const getCouchInfo = (connection?: string) => { +export const getCouchInfo = (connection?: string | null) => { // clean out any auth credentials const urlInfo = getUrlInfo(connection) let username @@ -45,7 +45,7 @@ export const getCouchInfo = (connection?: string) => { } } -export const getUrlInfo = (url = env.COUCH_DB_URL) => { +export const getUrlInfo = (url: string | null = env.COUCH_DB_URL) => { let cleanUrl, username, password, host if (url) { // Ensure the URL starts with a protocol diff --git a/packages/backend-core/src/db/tests/pouch.spec.js b/packages/backend-core/src/db/tests/pouch.spec.ts similarity index 98% rename from packages/backend-core/src/db/tests/pouch.spec.js rename to packages/backend-core/src/db/tests/pouch.spec.ts index f0abc82240..21632cff88 100644 --- a/packages/backend-core/src/db/tests/pouch.spec.js +++ b/packages/backend-core/src/db/tests/pouch.spec.ts @@ -1,5 +1,6 @@ require("../../../tests") -const getUrlInfo = require("../couch").getUrlInfo + +import { getUrlInfo } from "../couch" describe("pouch", () => { describe("Couch DB URL parsing", () => { diff --git a/packages/backend-core/src/middleware/errorHandling.ts b/packages/backend-core/src/middleware/errorHandling.ts index 6ceda9cd3a..5a5a25b461 100644 --- a/packages/backend-core/src/middleware/errorHandling.ts +++ b/packages/backend-core/src/middleware/errorHandling.ts @@ -32,8 +32,12 @@ export async function errorHandling(ctx: any, next: any) { } if (environment.isTest() && ctx.headers["x-budibase-include-stacktrace"]) { + let rootErr = err + while (rootErr.cause) { + rootErr = rootErr.cause + } // @ts-ignore - error.stack = err.stack + error.stack = rootErr.stack } ctx.body = error diff --git a/packages/backend-core/src/sql/sql.ts b/packages/backend-core/src/sql/sql.ts index 5f462ee144..334f1efdd4 100644 --- a/packages/backend-core/src/sql/sql.ts +++ b/packages/backend-core/src/sql/sql.ts @@ -272,17 +272,6 @@ class InternalBuilder { return parts.join(".") } - private isFullSelectStatementRequired(): boolean { - for (let column of Object.values(this.table.schema)) { - if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(column)) { - return true - } else if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(column)) { - return true - } - } - return false - } - private generateSelectStatement(): (string | Knex.Raw)[] | "*" { const { table, resource } = this.query @@ -292,11 +281,9 @@ class InternalBuilder { const alias = this.getTableName(table) const schema = this.table.schema - if (!this.isFullSelectStatementRequired()) { - return [this.knex.raw("??", [`${alias}.*`])] - } + // get just the fields for this table - return resource.fields + const tableFields = resource.fields .map(field => { const parts = field.split(/\./g) let table: string | undefined = undefined @@ -311,34 +298,33 @@ class InternalBuilder { return { table, column, field } }) .filter(({ table }) => !table || table === alias) - .map(({ table, column, field }) => { - const columnSchema = schema[column] - if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) { - return this.knex.raw(`??::money::numeric as ??`, [ - this.rawQuotedIdentifier([table, column].join(".")), - this.knex.raw(this.quote(field)), - ]) - } + return tableFields.map(({ table, column, field }) => { + const columnSchema = schema[column] - if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) { - // Time gets returned as timestamp from mssql, not matching the expected - // HH:mm format + if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) { + return this.knex.raw(`??::money::numeric as ??`, [ + this.rawQuotedIdentifier([table, column].join(".")), + this.knex.raw(this.quote(field)), + ]) + } - // TODO: figure out how to express this safely without string - // interpolation. - return this.knex.raw(`CONVERT(varchar, ??, 108) as ??`, [ - this.rawQuotedIdentifier(field), - this.knex.raw(this.quote(field)), - ]) - } + if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) { + // Time gets returned as timestamp from mssql, not matching the expected + // HH:mm format - if (table) { - return this.rawQuotedIdentifier(`${table}.${column}`) - } else { - return this.rawQuotedIdentifier(field) - } - }) + return this.knex.raw(`CONVERT(varchar, ??, 108) as ??`, [ + this.rawQuotedIdentifier(field), + this.knex.raw(this.quote(field)), + ]) + } + + if (table) { + return this.rawQuotedIdentifier(`${table}.${column}`) + } else { + return this.rawQuotedIdentifier(field) + } + }) } // OracleDB can't use character-large-objects (CLOBs) in WHERE clauses, @@ -816,14 +802,29 @@ class InternalBuilder { filters.oneOf, ArrayOperator.ONE_OF, (q, key: string, array) => { + const schema = this.getFieldSchema(key) + const values = Array.isArray(array) ? array : [array] if (shouldOr) { q = q.or } if (this.client === SqlClient.ORACLE) { // @ts-ignore key = this.convertClobs(key) + } else if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + for (const value of values) { + if (value != null) { + q = q.or.whereLike(key, `${value.toISOString().slice(0, 10)}%`) + } else { + q = q.or.whereNull(key) + } + } + return q } - return q.whereIn(key, Array.isArray(array) ? array : [array]) + return q.whereIn(key, values) }, (q, key: string[], array) => { if (shouldOr) { @@ -882,6 +883,19 @@ class InternalBuilder { let high = value.high let low = value.low + if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + if (high != null) { + high = `${high.toISOString().slice(0, 10)}T23:59:59.999Z` + } + if (low != null) { + low = low.toISOString().slice(0, 10) + } + } + if (this.client === SqlClient.ORACLE) { rawKey = this.convertClobs(key) } else if ( @@ -914,6 +928,7 @@ class InternalBuilder { } if (filters.equal) { iterate(filters.equal, BasicOperator.EQUAL, (q, key, value) => { + const schema = this.getFieldSchema(key) if (shouldOr) { q = q.or } @@ -928,6 +943,16 @@ class InternalBuilder { // @ts-expect-error knex types are wrong, raw is fine here subq.whereNotNull(identifier).andWhere(identifier, value) ) + } else if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + if (value != null) { + return q.whereLike(key, `${value.toISOString().slice(0, 10)}%`) + } else { + return q.whereNull(key) + } } else { return q.whereRaw(`COALESCE(?? = ?, FALSE)`, [ this.rawQuotedIdentifier(key), @@ -938,6 +963,7 @@ class InternalBuilder { } if (filters.notEqual) { iterate(filters.notEqual, BasicOperator.NOT_EQUAL, (q, key, value) => { + const schema = this.getFieldSchema(key) if (shouldOr) { q = q.or } @@ -959,6 +985,18 @@ class InternalBuilder { // @ts-expect-error knex types are wrong, raw is fine here .or.whereNull(identifier) ) + } else if ( + this.client === SqlClient.SQL_LITE && + schema?.type === FieldType.DATETIME && + schema.dateOnly + ) { + if (value != null) { + return q.not + .whereLike(key, `${value.toISOString().slice(0, 10)}%`) + .or.whereNull(key) + } else { + return q.not.whereNull(key) + } } else { return q.whereRaw(`COALESCE(?? != ?, TRUE)`, [ this.rawQuotedIdentifier(key), @@ -1134,20 +1172,22 @@ class InternalBuilder { nulls = value.direction === SortOrder.ASCENDING ? "first" : "last" } + const composite = `${aliased}.${key}` + let identifier + if (this.isAggregateField(key)) { - query = query.orderBy(key, direction, nulls) + identifier = this.rawQuotedIdentifier(key) + } else if (this.client === SqlClient.ORACLE) { + identifier = this.convertClobs(composite) } else { - let composite = `${aliased}.${key}` - if (this.client === SqlClient.ORACLE) { - query = query.orderByRaw(`?? ?? nulls ??`, [ - this.convertClobs(composite), - this.knex.raw(direction), - this.knex.raw(nulls as string), - ]) - } else { - query = query.orderBy(composite, direction, nulls) - } + identifier = this.rawQuotedIdentifier(composite) } + + query = query.orderByRaw(`?? ?? ${nulls ? "nulls ??" : ""}`, [ + identifier, + this.knex.raw(direction), + ...(nulls ? [this.knex.raw(nulls as string)] : []), + ]) } } @@ -1239,6 +1279,7 @@ class InternalBuilder { if (!toTable || !fromTable) { continue } + const relatedTable = tables[toTable] if (!relatedTable) { throw new Error(`related table "${toTable}" not found in datasource`) @@ -1267,6 +1308,10 @@ class InternalBuilder { const fieldList = relationshipFields.map(field => this.buildJsonField(relatedTable, field) ) + if (!fieldList.length) { + continue + } + const fieldListFormatted = fieldList .map(f => { const separator = this.client === SqlClient.ORACLE ? " VALUE " : "," @@ -1301,13 +1346,17 @@ class InternalBuilder { // add the correlation to the overall query subQuery = subQuery.where( - correlatedTo, + this.rawQuotedIdentifier(correlatedTo), "=", this.rawQuotedIdentifier(correlatedFrom) ) const standardWrap = (select: Knex.Raw): Knex.QueryBuilder => { - subQuery = subQuery.select(`${toAlias}.*`).limit(getRelationshipLimit()) + subQuery = subQuery + .select( + relationshipFields.map(field => this.rawQuotedIdentifier(field)) + ) + .limit(getRelationshipLimit()) // @ts-ignore - the from alias syntax isn't in Knex typing return knex.select(select).from({ [toAlias]: subQuery, @@ -1537,11 +1586,12 @@ class InternalBuilder { limits?: { base: number; query: number } } = {} ): Knex.QueryBuilder { - let { operation, filters, paginate, relationships, table } = this.query + const { operation, filters, paginate, relationships, table } = this.query const { limits } = opts // start building the query let query = this.qualifiedKnex() + // handle pagination let foundOffset: number | null = null let foundLimit = limits?.query || limits?.base @@ -1590,7 +1640,7 @@ class InternalBuilder { const mainTable = this.query.tableAliases?.[table.name] || table.name const cte = this.addSorting( this.knex - .with("paginated", query) + .with("paginated", query.clone().clearSelect().select("*")) .select(this.generateSelectStatement()) .from({ [mainTable]: "paginated", diff --git a/packages/backend-core/src/sql/utils.ts b/packages/backend-core/src/sql/utils.ts index 16b352995b..b07854b2a0 100644 --- a/packages/backend-core/src/sql/utils.ts +++ b/packages/backend-core/src/sql/utils.ts @@ -14,7 +14,7 @@ import environment from "../environment" const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` const ROW_ID_REGEX = /^\[.*]$/g const ENCODED_SPACE = encodeURIComponent(" ") -const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/ +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}.\d{3}Z)?$/ const TIME_REGEX = /^(?:\d{2}:)?(?:\d{2}:)(?:\d{2})$/ export function isExternalTableID(tableId: string) { @@ -149,15 +149,7 @@ export function isInvalidISODateString(str: string) { } export function isValidISODateString(str: string) { - const trimmedValue = str.trim() - if (!ISO_DATE_REGEX.test(trimmedValue)) { - return false - } - let d = new Date(trimmedValue) - if (isNaN(d.getTime())) { - return false - } - return d.toISOString() === trimmedValue + return ISO_DATE_REGEX.test(str.trim()) } export function isValidFilter(value: any) { diff --git a/packages/backend-core/tests/core/users/users.spec.js b/packages/backend-core/tests/core/users/users.spec.ts similarity index 67% rename from packages/backend-core/tests/core/users/users.spec.js rename to packages/backend-core/tests/core/users/users.spec.ts index dde0d87fb7..b14f553266 100644 --- a/packages/backend-core/tests/core/users/users.spec.js +++ b/packages/backend-core/tests/core/users/users.spec.ts @@ -1,17 +1,17 @@ -const _ = require("lodash/fp") -const { structures } = require("../../../tests") +import { range } from "lodash/fp" +import { structures } from "../.." jest.mock("../../../src/context") jest.mock("../../../src/db") -const context = require("../../../src/context") -const db = require("../../../src/db") +import * as context from "../../../src/context" +import * as db from "../../../src/db" -const { getCreatorCount } = require("../../../src/users/users") +import { getCreatorCount } from "../../../src/users/users" describe("Users", () => { - let getGlobalDBMock - let paginationMock + let getGlobalDBMock: jest.SpyInstance + let paginationMock: jest.SpyInstance beforeEach(() => { jest.resetAllMocks() @@ -22,11 +22,10 @@ describe("Users", () => { jest.spyOn(db, "getGlobalUserParams") }) - it("Retrieves the number of creators", async () => { - const getUsers = (offset, limit, creators = false) => { - const range = _.range(offset, limit) + it("retrieves the number of creators", async () => { + const getUsers = (offset: number, limit: number, creators = false) => { const opts = creators ? { builder: { global: true } } : undefined - return range.map(() => structures.users.user(opts)) + return range(offset, limit).map(() => structures.users.user(opts)) } const page1Data = getUsers(0, 8) const page2Data = getUsers(8, 12, true) diff --git a/packages/backend-core/tsconfig.test.json b/packages/backend-core/tsconfig.test.json new file mode 100644 index 0000000000..0e3e7a79b2 --- /dev/null +++ b/packages/backend-core/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "dist", + "sourceMap": true + }, + "include": ["tests/**/*.js", "tests/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 89f72bc46d..2caad20bf6 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -3,7 +3,7 @@ "description": "A UI solution used in the different Budibase projects.", "version": "0.0.0", "license": "MPL-2.0", - "svelte": "src/index.js", + "svelte": "src/index.ts", "module": "dist/bbui.mjs", "exports": { ".": { @@ -14,7 +14,8 @@ "./spectrum-icons-vite.js": "./src/spectrum-icons-vite.js" }, "scripts": { - "build": "vite build" + "build": "vite build", + "dev": "vite build --watch --mode=dev" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "1.4.0", diff --git a/packages/bbui/src/Icon/Icon.svelte b/packages/bbui/src/Icon/Icon.svelte index 73ad8edd10..c22bb3f918 100644 --- a/packages/bbui/src/Icon/Icon.svelte +++ b/packages/bbui/src/Icon/Icon.svelte @@ -10,12 +10,12 @@ export let size = "M" export let hoverable = false export let disabled = false - export let color - export let hoverColor - export let tooltip + export let color = undefined + export let hoverColor = undefined + export let tooltip = undefined export let tooltipPosition = TooltipPosition.Bottom export let tooltipType = TooltipType.Default - export let tooltipColor + export let tooltipColor = undefined export let tooltipWrap = true export let newStyles = false diff --git a/packages/bbui/src/Label/Label.svelte b/packages/bbui/src/Label/Label.svelte index 71b0967d99..41e1ccf794 100644 --- a/packages/bbui/src/Label/Label.svelte +++ b/packages/bbui/src/Label/Label.svelte @@ -4,7 +4,7 @@ export let size = "M" export let tooltip = "" - export let muted + export let muted = undefined diff --git a/packages/bbui/src/Typography/Heading.svelte b/packages/bbui/src/Typography/Heading.svelte index 90d53fb208..f48d5d958e 100644 --- a/packages/bbui/src/Typography/Heading.svelte +++ b/packages/bbui/src/Typography/Heading.svelte @@ -2,10 +2,10 @@ import "@spectrum-css/typography/dist/index-vars.css" // Sizes - export let size = "M" - export let textAlign = undefined - export let noPadding = false - export let weight = "default" // light, heavy, default + export let size: "XS" | "S" | "M" | "L" = "M" + export let textAlign: string | undefined = undefined + export let noPadding: boolean = false + export let weight: "light" | "heavy" | "default" = "default"

{ const r = (Math.random() * 16) | 0 const v = c === "x" ? r : (r & 0x3) | 0x8 @@ -18,22 +17,18 @@ export function uuid() { /** * Capitalises a string - * @param string the string to capitalise - * @return {string} the capitalised string */ -export const capitalise = string => { +export const capitalise = (string?: string | null): string => { if (!string) { - return string + return "" } return string.substring(0, 1).toUpperCase() + string.substring(1) } /** * Computes a short hash of a string - * @param string the string to compute a hash of - * @return {string} the hash string */ -export const hashString = string => { +export const hashString = (string?: string | null): string => { if (!string) { return "0" } @@ -54,11 +49,12 @@ export const hashString = string => { * will override the value "foo" rather than "bar". * If a deep path is specified and the parent keys don't exist then these will * be created. - * @param obj the object - * @param key the key - * @param value the value */ -export const deepSet = (obj, key, value) => { +export const deepSet = ( + obj: Record | null, + key: string | null, + value: any +): void => { if (!obj || !key) { return } @@ -82,9 +78,8 @@ export const deepSet = (obj, key, value) => { /** * Deeply clones an object. Functions are not supported. - * @param obj the object to clone */ -export const cloneDeep = obj => { +export const cloneDeep = (obj: T): T => { if (!obj) { return obj } @@ -93,9 +88,8 @@ export const cloneDeep = obj => { /** * Copies a value to the clipboard - * @param value the value to copy */ -export const copyToClipboard = value => { +export const copyToClipboard = (value: any): Promise => { return new Promise(res => { if (navigator.clipboard && window.isSecureContext) { // Try using the clipboard API first @@ -117,9 +111,12 @@ export const copyToClipboard = value => { }) } -// Parsed a date value. This is usually an ISO string, but can be a +// Parse a date value. This is usually an ISO string, but can be a // bunch of different formats and shapes depending on schema flags. -export const parseDate = (value, { enableTime = true }) => { +export const parseDate = ( + value: string | dayjs.Dayjs | null, + { enableTime = true } +): dayjs.Dayjs | null => { // If empty then invalid if (!value) { return null @@ -128,7 +125,7 @@ export const parseDate = (value, { enableTime = true }) => { // Certain string values need transformed if (typeof value === "string") { // Check for time only values - if (!isNaN(new Date(`0-${value}`))) { + if (!isNaN(new Date(`0-${value}`).valueOf())) { value = `0-${value}` } @@ -153,9 +150,9 @@ export const parseDate = (value, { enableTime = true }) => { // Stringifies a dayjs object to create an ISO string that respects the various // schema flags export const stringifyDate = ( - value, + value: null | dayjs.Dayjs, { enableTime = true, timeOnly = false, ignoreTimezones = false } = {} -) => { +): string | null => { if (!value) { return null } @@ -192,7 +189,7 @@ export const stringifyDate = ( } // Determine the dayjs-compatible format of the browser's default locale -const getPatternForPart = part => { +const getPatternForPart = (part: Intl.DateTimeFormatPart): string => { switch (part.type) { case "day": return "D".repeat(part.value.length) @@ -214,9 +211,9 @@ const localeDateFormat = new Intl.DateTimeFormat() // Formats a dayjs date according to schema flags export const getDateDisplayValue = ( - value, + value: dayjs.Dayjs | null, { enableTime = true, timeOnly = false } = {} -) => { +): string => { if (!value?.isValid()) { return "" } @@ -229,7 +226,7 @@ export const getDateDisplayValue = ( } } -export const hexToRGBA = (color, opacity) => { +export const hexToRGBA = (color: string, opacity: number): string => { if (color.includes("#")) { color = color.replace("#", "") } diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.ts similarity index 100% rename from packages/bbui/src/index.js rename to packages/bbui/src/index.ts diff --git a/packages/bbui/svelte.config.js b/packages/bbui/svelte.config.js new file mode 100644 index 0000000000..7d908c15d5 --- /dev/null +++ b/packages/bbui/svelte.config.js @@ -0,0 +1,7 @@ +const { vitePreprocess } = require("@sveltejs/vite-plugin-svelte") + +const config = { + preprocess: vitePreprocess(), +} + +module.exports = config diff --git a/packages/bbui/tsconfig.json b/packages/bbui/tsconfig.json new file mode 100644 index 0000000000..2fe17da42e --- /dev/null +++ b/packages/bbui/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "allowJs": true, + "outDir": "./dist", + "lib": ["ESNext"], + "baseUrl": ".", + "paths": { + "@budibase/*": [ + "../*/src/index.ts", + "../*/src/index.js", + "../*", + "../../node_modules/@budibase/*" + ] + } + }, + "include": ["./src/**/*"], + "exclude": ["node_modules", "**/*.json", "**/*.spec.ts", "**/*.spec.js"] +} \ No newline at end of file diff --git a/packages/bbui/vite.config.js b/packages/bbui/vite.config.js index bf0f9fc26d..bccca20e43 100644 --- a/packages/bbui/vite.config.js +++ b/packages/bbui/vite.config.js @@ -9,7 +9,7 @@ export default defineConfig(({ mode }) => { build: { sourcemap: !isProduction, lib: { - entry: "src/index.js", + entry: "src/index.ts", formats: ["es"], }, }, diff --git a/packages/builder/package.json b/packages/builder/package.json index 71d1c32008..6fcf72c5fb 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -74,7 +74,6 @@ "dayjs": "^1.10.8", "downloadjs": "1.4.7", "fast-json-patch": "^3.1.1", - "json-format-highlight": "^1.0.4", "lodash": "4.17.21", "posthog-js": "^1.118.0", "remixicon": "2.5.0", @@ -94,6 +93,7 @@ "@sveltejs/vite-plugin-svelte": "1.4.0", "@testing-library/jest-dom": "6.4.2", "@testing-library/svelte": "^4.1.0", + "@types/sanitize-html": "^2.13.0", "@types/shortid": "^2.2.0", "babel-jest": "^29.6.2", "identity-obj-proxy": "^3.0.0", diff --git a/packages/builder/src/components/automation/SetupPanel/CronBuilder.svelte b/packages/builder/src/components/automation/SetupPanel/CronBuilder.svelte index f04c5454ea..1490baa602 100644 --- a/packages/builder/src/components/automation/SetupPanel/CronBuilder.svelte +++ b/packages/builder/src/components/automation/SetupPanel/CronBuilder.svelte @@ -9,7 +9,7 @@ } from "@budibase/bbui" import { onMount, createEventDispatcher } from "svelte" import { flags } from "@/stores/builder" - import { featureFlags, licensing } from "@/stores/portal" + import { licensing } from "@/stores/portal" import { API } from "@/api" import MagicWand from "../../../../assets/MagicWand.svelte" @@ -27,8 +27,7 @@ let loadingAICronExpression = false $: aiEnabled = - ($featureFlags.AI_CUSTOM_CONFIGS && $licensing.customAIConfigsEnabled) || - ($featureFlags.BUDIBASE_AI && $licensing.budibaseAIEnabled) + $licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled $: { if (cronExpression) { try { diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index db5b8a7d49..4af1dcc0ee 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -26,7 +26,7 @@ import { createEventDispatcher, getContext, onMount } from "svelte" import { cloneDeep } from "lodash/fp" import { tables, datasources } from "@/stores/builder" - import { featureFlags } from "@/stores/portal" + import { licensing } from "@/stores/portal" import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants" import { FIELDS, @@ -49,7 +49,6 @@ import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core" import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte" import OptionsEditor from "./OptionsEditor.svelte" - import { isEnabled } from "@/helpers/featureFlags" import { getUserBindings } from "@/dataBinding" export let field @@ -101,7 +100,8 @@ let optionsValid = true $: rowGoldenSample = RowUtils.generateGoldenSample($rows) - $: aiEnabled = $featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS + $: aiEnabled = + $licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled $: if (primaryDisplay) { editableColumn.constraints.presence = { allowEmpty: false } } @@ -168,7 +168,6 @@ // used to select what different options can be displayed for column type $: canBeDisplay = canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn - $: defaultValuesEnabled = isEnabled("DEFAULT_VALUES") $: canHaveDefault = !required && canHaveDefaultColumn(editableColumn.type) $: canBeRequired = editableColumn?.type !== FieldType.LINK && @@ -300,7 +299,7 @@ } // Ensure we don't have a default value if we can't have one - if (!canHaveDefault || !defaultValuesEnabled) { + if (!canHaveDefault) { delete saveColumn.default } @@ -848,51 +847,49 @@ {/if} - {#if defaultValuesEnabled} - {#if editableColumn.type === FieldType.OPTIONS} - (editableColumn.default = e.detail)} + placeholder="None" + /> + {:else if editableColumn.type === FieldType.ARRAY} + + (editableColumn.default = e.detail?.length ? e.detail : undefined)} + placeholder="None" + /> + {:else if editableColumn.subtype === BBReferenceFieldSubType.USER} + {@const defaultValue = + editableColumn.type === FieldType.BB_REFERENCE_SINGLE + ? SingleUserDefault + : MultiUserDefault} + + (editableColumn.default = e.detail ? defaultValue : undefined)} + /> + {:else} + (editableColumn.default = e.detail)} + bindings={defaultValueBindings} + allowJS + /> {/if} diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index f94d26603d..bc88f0f981 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -1,4 +1,4 @@ - diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte index 90ca6ffd9f..f10c44f81f 100644 --- a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -28,7 +28,9 @@ let loading = false let deleteConfirmationDialog - $: defaultName = getSequentialName($snippets, "MySnippet", x => x.name) + $: defaultName = getSequentialName($snippets, "MySnippet", { + getName: x => x.name, + }) $: key = snippet?.name $: name = snippet?.name || defaultName $: code = snippet?.code ? encodeJSBinding(snippet.code) : "" 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/constants/backend/automations.js b/packages/builder/src/constants/backend/automations.ts similarity index 100% rename from packages/builder/src/constants/backend/automations.js rename to packages/builder/src/constants/backend/automations.ts diff --git a/packages/builder/src/constants/backend/backups.js b/packages/builder/src/constants/backend/backups.ts similarity index 100% rename from packages/builder/src/constants/backend/backups.js rename to packages/builder/src/constants/backend/backups.ts diff --git a/packages/builder/src/constants/backend/index.js b/packages/builder/src/constants/backend/index.ts similarity index 97% rename from packages/builder/src/constants/backend/index.js rename to packages/builder/src/constants/backend/index.ts index 6ddf4c2138..b7d3f584be 100644 --- a/packages/builder/src/constants/backend/index.js +++ b/packages/builder/src/constants/backend/index.ts @@ -16,7 +16,10 @@ export { export const AUTO_COLUMN_SUB_TYPES = AutoFieldSubType -export const AUTO_COLUMN_DISPLAY_NAMES = { +export const AUTO_COLUMN_DISPLAY_NAMES: Record< + keyof typeof AUTO_COLUMN_SUB_TYPES, + string +> = { AUTO_ID: "Auto ID", CREATED_BY: "Created By", CREATED_AT: "Created At", @@ -209,13 +212,6 @@ export const Roles = { BUILDER: "BUILDER", } -export function isAutoColumnUserRelationship(subtype) { - return ( - subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY || - subtype === AUTO_COLUMN_SUB_TYPES.UPDATED_BY - ) -} - export const PrettyRelationshipDefinitions = { MANY: "Many rows", ONE: "One row", diff --git a/packages/builder/src/constants/index.js b/packages/builder/src/constants/index.ts similarity index 100% rename from packages/builder/src/constants/index.js rename to packages/builder/src/constants/index.ts diff --git a/packages/builder/src/helpers/duplicate.js b/packages/builder/src/helpers/duplicate.ts similarity index 86% rename from packages/builder/src/helpers/duplicate.js rename to packages/builder/src/helpers/duplicate.ts index 361e1faa25..b4740b3e52 100644 --- a/packages/builder/src/helpers/duplicate.js +++ b/packages/builder/src/helpers/duplicate.ts @@ -10,13 +10,13 @@ * * Repl */ -export const duplicateName = (name, allNames) => { +export const duplicateName = (name: string, allNames: string[]) => { const duplicatePattern = new RegExp(`\\s(\\d+)$`) const baseName = name.split(duplicatePattern)[0] const isDuplicate = new RegExp(`${baseName}\\s(\\d+)$`) // get the sequence from matched names - const sequence = [] + const sequence: number[] = [] allNames.filter(n => { if (n === baseName) { return true @@ -70,12 +70,18 @@ export const duplicateName = (name, allNames) => { * @param getName optional function to extract the name for an item, if not a * flat array of strings */ -export const getSequentialName = ( - items, - prefix, - { getName = x => x, numberFirstItem = false } = {} +export const getSequentialName = ( + items: T[] | null, + prefix: string | null, + { + getName, + numberFirstItem, + }: { + getName?: (item: T) => string + numberFirstItem?: boolean + } = {} ) => { - if (!prefix?.length || !getName) { + if (!prefix?.length) { return null } const trimmedPrefix = prefix.trim() @@ -85,7 +91,7 @@ export const getSequentialName = ( } let max = 0 items.forEach(item => { - const name = getName(item) + const name = getName?.(item) ?? item if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) { return } diff --git a/packages/builder/src/helpers/featureFlags.js b/packages/builder/src/helpers/featureFlags.ts similarity index 54% rename from packages/builder/src/helpers/featureFlags.js rename to packages/builder/src/helpers/featureFlags.ts index e9054e8a9c..faa57892e8 100644 --- a/packages/builder/src/helpers/featureFlags.js +++ b/packages/builder/src/helpers/featureFlags.ts @@ -1,7 +1,8 @@ +import { FeatureFlag } from "@budibase/types" import { auth } from "../stores/portal" import { get } from "svelte/store" -export const isEnabled = featureFlag => { +export const isEnabled = (featureFlag: FeatureFlag | `${FeatureFlag}`) => { const user = get(auth).user return !!user?.flags?.[featureFlag] } diff --git a/packages/builder/src/helpers/fetchData.js b/packages/builder/src/helpers/fetchData.ts similarity index 57% rename from packages/builder/src/helpers/fetchData.js rename to packages/builder/src/helpers/fetchData.ts index 085d0ae5b4..cffb8f4d7d 100644 --- a/packages/builder/src/helpers/fetchData.js +++ b/packages/builder/src/helpers/fetchData.ts @@ -1,13 +1,21 @@ import { writable } from "svelte/store" import { API } from "@/api" -export default function (url) { - const store = writable({ status: "LOADING", data: {}, error: {} }) +export default function (url: string) { + const store = writable<{ + status: "LOADING" | "SUCCESS" | "ERROR" + data: object + error?: unknown + }>({ + status: "LOADING", + data: {}, + error: {}, + }) async function get() { store.update(u => ({ ...u, status: "LOADING" })) try { - const data = await API.get({ url }) + const data = await API.get({ url }) store.set({ data, status: "SUCCESS" }) } catch (e) { store.set({ data: {}, error: e, status: "ERROR" }) diff --git a/packages/builder/src/helpers/helpers.js b/packages/builder/src/helpers/helpers.js deleted file mode 100644 index 99483d40e2..0000000000 --- a/packages/builder/src/helpers/helpers.js +++ /dev/null @@ -1,46 +0,0 @@ -import { last, flow } from "lodash/fp" - -export const buildStyle = styles => { - let str = "" - for (let s in styles) { - if (styles[s]) { - let key = convertCamel(s) - str += `${key}: ${styles[s]}; ` - } - } - return str -} - -export const convertCamel = str => { - return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`) -} - -export const pipe = (arg, funcs) => flow(funcs)(arg) - -export const capitalise = s => { - if (!s) { - return s - } - return s.substring(0, 1).toUpperCase() + s.substring(1) -} - -export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1) - -export const lowercaseExceptFirst = s => - s.charAt(0) + s.substring(1).toLowerCase() - -export const get_name = s => (!s ? "" : last(s.split("/"))) - -export const get_capitalised_name = name => pipe(name, [get_name, capitalise]) - -export const isBuilderInputFocused = e => { - const activeTag = document.activeElement?.tagName.toLowerCase() - const inCodeEditor = document.activeElement?.classList?.contains("cm-content") - if ( - (inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) && - e.key !== "Escape" - ) { - return true - } - return false -} diff --git a/packages/builder/src/helpers/helpers.ts b/packages/builder/src/helpers/helpers.ts new file mode 100644 index 0000000000..f767e6f59f --- /dev/null +++ b/packages/builder/src/helpers/helpers.ts @@ -0,0 +1,50 @@ +import type { Many } from "lodash" +import { last, flow } from "lodash/fp" + +export const buildStyle = (styles: Record) => { + let str = "" + for (let s in styles) { + if (styles[s]) { + let key = convertCamel(s) + str += `${key}: ${styles[s]}; ` + } + } + return str +} + +export const convertCamel = (str: string) => { + return str.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`) +} + +export const pipe = (arg: string, funcs: Many<(...args: any[]) => any>) => + flow(funcs)(arg) + +export const capitalise = (s: string) => { + if (!s) { + return s + } + return s.substring(0, 1).toUpperCase() + s.substring(1) +} + +export const lowercase = (s: string) => + s.substring(0, 1).toLowerCase() + s.substring(1) + +export const lowercaseExceptFirst = (s: string) => + s.charAt(0) + s.substring(1).toLowerCase() + +export const get_name = (s: string) => (!s ? "" : last(s.split("/"))) + +export const get_capitalised_name = (name: string) => + pipe(name, [get_name, capitalise]) + +export const isBuilderInputFocused = (e: KeyboardEvent) => { + const activeTag = document.activeElement?.tagName.toLowerCase() + const inCodeEditor = document.activeElement?.classList?.contains("cm-content") + if ( + (inCodeEditor || ["input", "textarea"].indexOf(activeTag!) !== -1) && + e.key !== "Escape" + ) { + return true + } + return false +} diff --git a/packages/builder/src/helpers/index.js b/packages/builder/src/helpers/index.ts similarity index 100% rename from packages/builder/src/helpers/index.js rename to packages/builder/src/helpers/index.ts diff --git a/packages/builder/src/helpers/keyUtils.js b/packages/builder/src/helpers/keyUtils.js deleted file mode 100644 index 8d6dfb06dc..0000000000 --- a/packages/builder/src/helpers/keyUtils.js +++ /dev/null @@ -1,7 +0,0 @@ -function handleEnter(fnc) { - return e => e.key === "Enter" && fnc() -} - -export const keyUtils = { - handleEnter, -} diff --git a/packages/builder/src/helpers/keyUtils.ts b/packages/builder/src/helpers/keyUtils.ts new file mode 100644 index 0000000000..f78b05ec2b --- /dev/null +++ b/packages/builder/src/helpers/keyUtils.ts @@ -0,0 +1,7 @@ +function handleEnter(fnc: () => void) { + return (e: KeyboardEvent) => e.key === "Enter" && fnc() +} + +export const keyUtils = { + handleEnter, +} diff --git a/packages/builder/src/helpers/pagination.js b/packages/builder/src/helpers/pagination.ts similarity index 76% rename from packages/builder/src/helpers/pagination.js rename to packages/builder/src/helpers/pagination.ts index 122973f1a1..0280cd1052 100644 --- a/packages/builder/src/helpers/pagination.js +++ b/packages/builder/src/helpers/pagination.ts @@ -1,6 +1,16 @@ import { writable } from "svelte/store" -function defaultValue() { +interface PaginationStore { + nextPage: string | null | undefined + page: string | null | undefined + hasPrevPage: boolean + hasNextPage: boolean + loading: boolean + pageNumber: number + pages: string[] +} + +function defaultValue(): PaginationStore { return { nextPage: null, page: undefined, @@ -29,13 +39,13 @@ export function createPaginationStore() { update(state => { state.pageNumber++ state.page = state.nextPage - state.pages.push(state.page) + state.pages.push(state.page!) state.hasPrevPage = state.pageNumber > 1 return state }) } - function fetched(hasNextPage, nextPage) { + function fetched(hasNextPage: boolean, nextPage: string) { update(state => { state.hasNextPage = hasNextPage state.nextPage = nextPage diff --git a/packages/builder/src/helpers/planTitle.js b/packages/builder/src/helpers/planTitle.ts similarity index 86% rename from packages/builder/src/helpers/planTitle.js rename to packages/builder/src/helpers/planTitle.ts index c08b8bf3fe..ab342b4d93 100644 --- a/packages/builder/src/helpers/planTitle.js +++ b/packages/builder/src/helpers/planTitle.ts @@ -1,6 +1,6 @@ import { PlanType } from "@budibase/types" -export function getFormattedPlanName(userPlanType) { +export function getFormattedPlanName(userPlanType: PlanType) { let planName switch (userPlanType) { case PlanType.PRO: @@ -29,6 +29,6 @@ export function getFormattedPlanName(userPlanType) { return `${planName} Plan` } -export function isPremiumOrAbove(userPlanType) { +export function isPremiumOrAbove(userPlanType: PlanType) { return ![PlanType.PRO, PlanType.TEAM, PlanType.FREE].includes(userPlanType) } diff --git a/packages/builder/src/helpers/sanitizeUrl.js b/packages/builder/src/helpers/sanitizeUrl.ts similarity index 88% rename from packages/builder/src/helpers/sanitizeUrl.js rename to packages/builder/src/helpers/sanitizeUrl.ts index 4d00c503fb..5b76930037 100644 --- a/packages/builder/src/helpers/sanitizeUrl.js +++ b/packages/builder/src/helpers/sanitizeUrl.ts @@ -1,4 +1,4 @@ -export default function (url) { +export default function (url: string) { return url .split("/") .map(part => { diff --git a/packages/builder/src/helpers/tests/duplicate.test.js b/packages/builder/src/helpers/tests/duplicate.test.ts similarity index 100% rename from packages/builder/src/helpers/tests/duplicate.test.js rename to packages/builder/src/helpers/tests/duplicate.test.ts diff --git a/packages/builder/src/helpers/tests/nameHelpers.spec.js b/packages/builder/src/helpers/tests/nameHelpers.spec.ts similarity index 100% rename from packages/builder/src/helpers/tests/nameHelpers.spec.js rename to packages/builder/src/helpers/tests/nameHelpers.spec.ts diff --git a/packages/builder/src/helpers/utils.js b/packages/builder/src/helpers/utils.js deleted file mode 100644 index 27f459d6f2..0000000000 --- a/packages/builder/src/helpers/utils.js +++ /dev/null @@ -1,75 +0,0 @@ -import { FieldType } from "@budibase/types" -import { ActionStepID } from "@/constants/backend/automations" -import { TableNames } from "@/constants" -import { - AUTO_COLUMN_DISPLAY_NAMES, - AUTO_COLUMN_SUB_TYPES, - FIELDS, - isAutoColumnUserRelationship, -} from "@/constants/backend" -import { isEnabled } from "@/helpers/featureFlags" - -export function getAutoColumnInformation(enabled = true) { - let info = {} - for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) { - // Because it's possible to replicate the functionality of CREATED_AT and - // CREATED_BY columns, we disable their creation when the DEFAULT_VALUES - // feature flag is enabled. - if (isEnabled("DEFAULT_VALUES")) { - if ( - subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT || - subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY - ) { - continue - } - } - info[subtype] = { enabled, name: AUTO_COLUMN_DISPLAY_NAMES[key] } - } - return info -} - -export function buildAutoColumn(tableName, name, subtype) { - let type, constraints - switch (subtype) { - case AUTO_COLUMN_SUB_TYPES.UPDATED_BY: - case AUTO_COLUMN_SUB_TYPES.CREATED_BY: - type = FieldType.LINK - constraints = FIELDS.LINK.constraints - break - case AUTO_COLUMN_SUB_TYPES.AUTO_ID: - type = FieldType.NUMBER - constraints = FIELDS.NUMBER.constraints - break - case AUTO_COLUMN_SUB_TYPES.UPDATED_AT: - case AUTO_COLUMN_SUB_TYPES.CREATED_AT: - type = FieldType.DATETIME - constraints = FIELDS.DATETIME.constraints - break - default: - type = FieldType.STRING - constraints = FIELDS.STRING.constraints - break - } - if (Object.values(AUTO_COLUMN_SUB_TYPES).indexOf(subtype) === -1) { - throw "Cannot build auto column with supplied subtype" - } - const base = { - name, - type, - subtype, - icon: "ri-magic-line", - autocolumn: true, - constraints, - } - if (isAutoColumnUserRelationship(subtype)) { - base.tableId = TableNames.USERS - base.fieldName = `${tableName}-${name}` - } - return base -} - -export function checkForCollectStep(automation) { - return automation.definition.steps.some( - step => step.stepId === ActionStepID.COLLECT - ) -} diff --git a/packages/builder/src/helpers/utils.ts b/packages/builder/src/helpers/utils.ts new file mode 100644 index 0000000000..140daaef3b --- /dev/null +++ b/packages/builder/src/helpers/utils.ts @@ -0,0 +1,96 @@ +import { + AutoFieldSubType, + Automation, + DateFieldMetadata, + FieldType, + NumberFieldMetadata, + RelationshipFieldMetadata, + RelationshipType, +} from "@budibase/types" +import { ActionStepID } from "@/constants/backend/automations" +import { TableNames } from "@/constants" +import { + AUTO_COLUMN_DISPLAY_NAMES, + AUTO_COLUMN_SUB_TYPES, + FIELDS, +} from "@/constants/backend" +import { utils } from "@budibase/shared-core" + +type AutoColumnInformation = Partial< + Record +> + +export function getAutoColumnInformation( + enabled = true +): AutoColumnInformation { + const info: AutoColumnInformation = {} + for (const [key, subtype] of Object.entries(AUTO_COLUMN_SUB_TYPES)) { + // Because it's possible to replicate the functionality of CREATED_AT and + // CREATED_BY columns with user column default values, we disable their creation + if ( + subtype === AUTO_COLUMN_SUB_TYPES.CREATED_AT || + subtype === AUTO_COLUMN_SUB_TYPES.CREATED_BY + ) { + continue + } + const typedKey = key as keyof typeof AUTO_COLUMN_SUB_TYPES + info[subtype] = { + enabled, + name: AUTO_COLUMN_DISPLAY_NAMES[typedKey], + } + } + return info +} + +export function buildAutoColumn( + tableName: string, + name: string, + subtype: AutoFieldSubType +): RelationshipFieldMetadata | NumberFieldMetadata | DateFieldMetadata { + const base = { + name, + icon: "ri-magic-line", + autocolumn: true, + } + + switch (subtype) { + case AUTO_COLUMN_SUB_TYPES.UPDATED_BY: + case AUTO_COLUMN_SUB_TYPES.CREATED_BY: + return { + ...base, + type: FieldType.LINK, + subtype, + constraints: FIELDS.LINK.constraints, + tableId: TableNames.USERS, + fieldName: `${tableName}-${name}`, + relationshipType: RelationshipType.MANY_TO_ONE, + } + + case AUTO_COLUMN_SUB_TYPES.AUTO_ID: + return { + ...base, + type: FieldType.NUMBER, + subtype, + constraints: FIELDS.NUMBER.constraints, + } + case AUTO_COLUMN_SUB_TYPES.UPDATED_AT: + case AUTO_COLUMN_SUB_TYPES.CREATED_AT: + return { + ...base, + type: FieldType.DATETIME, + subtype, + constraints: FIELDS.DATETIME.constraints, + } + + default: + throw utils.unreachable(subtype, { + message: "Cannot build auto column with supplied subtype", + }) + } +} + +export function checkForCollectStep(automation: Automation) { + return automation.definition.steps.some( + step => step.stepId === ActionStepID.COLLECT + ) +} diff --git a/packages/builder/src/helpers/warnings.js b/packages/builder/src/helpers/warnings.ts similarity index 85% rename from packages/builder/src/helpers/warnings.js rename to packages/builder/src/helpers/warnings.ts index ad943a8578..ae6c65666c 100644 --- a/packages/builder/src/helpers/warnings.js +++ b/packages/builder/src/helpers/warnings.ts @@ -1,4 +1,4 @@ -export const suppressWarnings = warnings => { +export const suppressWarnings = (warnings: string[]) => { if (!warnings?.length) { return } 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..2260892913 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) ], } } @@ -442,13 +442,11 @@ const onUpdateUserInvite = async (invite, role) => { let updateBody = { - code: invite.code, apps: { ...invite.apps, [prodAppId]: role, }, } - if (role === Constants.Roles.CREATOR) { updateBody.builder = updateBody.builder || {} updateBody.builder.apps = [...(updateBody.builder.apps ?? []), prodAppId] @@ -456,7 +454,7 @@ } else if (role !== Constants.Roles.CREATOR && invite?.builder?.apps) { invite.builder.apps = [] } - await users.updateInvite(updateBody) + await users.updateInvite(invite.code, updateBody) await filterInvites(query) } @@ -470,8 +468,7 @@ let updated = { ...invite } delete updated.info.apps[prodAppId] - return await users.updateInvite({ - code: updated.code, + return await users.updateInvite(updated.code, { apps: updated.apps, }) } @@ -485,12 +482,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/app/[application]/data/table/[tableId]/[viewId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte index 9ac7ccd715..4d42c0d5d6 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/[viewId]/index.svelte @@ -1,6 +1,6 @@ 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/_components/GroupUsers.svelte b/packages/builder/src/pages/builder/portal/users/groups/_components/GroupUsers.svelte index 8d99d406fd..71fd4c0be3 100644 --- a/packages/builder/src/pages/builder/portal/users/groups/_components/GroupUsers.svelte +++ b/packages/builder/src/pages/builder/portal/users/groups/_components/GroupUsers.svelte @@ -52,7 +52,7 @@ ] const removeUser = async id => { - await groups.actions.removeUser(groupId, id) + await groups.removeUser(groupId, id) fetchGroupUsers.refresh() } 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..c77e40c964 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -247,10 +247,11 @@ 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) { + console.error(error) notifications.error("Error creating user") } } @@ -317,7 +318,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/builder/permissions.js b/packages/builder/src/stores/builder/permissions.js deleted file mode 100644 index a303cd713b..0000000000 --- a/packages/builder/src/stores/builder/permissions.js +++ /dev/null @@ -1,27 +0,0 @@ -import { writable } from "svelte/store" -import { API } from "@/api" - -export function createPermissionStore() { - const { subscribe } = writable([]) - - return { - subscribe, - save: async ({ level, role, resource }) => { - return await API.updatePermissionForResource(resource, role, level) - }, - remove: async ({ level, role, resource }) => { - return await API.removePermissionFromResource(resource, role, level) - }, - forResource: async resourceId => { - return (await API.getPermissionForResource(resourceId)).permissions - }, - forResourceDetailed: async resourceId => { - return await API.getPermissionForResource(resourceId) - }, - getDependantsInfo: async resourceId => { - return await API.getDependants(resourceId) - }, - } -} - -export const permissions = createPermissionStore() diff --git a/packages/builder/src/stores/builder/permissions.ts b/packages/builder/src/stores/builder/permissions.ts new file mode 100644 index 0000000000..6056449150 --- /dev/null +++ b/packages/builder/src/stores/builder/permissions.ts @@ -0,0 +1,50 @@ +import { BudiStore } from "../BudiStore" +import { API } from "@/api" +import { + PermissionLevel, + GetResourcePermsResponse, + GetDependantResourcesResponse, + ResourcePermissionInfo, +} from "@budibase/types" + +interface Permission { + level: PermissionLevel + role: string + resource: string +} + +export class PermissionStore extends BudiStore { + constructor() { + super([]) + } + + save = async (permission: Permission) => { + const { level, role, resource } = permission + return await API.updatePermissionForResource(resource, role, level) + } + + remove = async (permission: Permission) => { + const { level, role, resource } = permission + return await API.removePermissionFromResource(resource, role, level) + } + + forResource = async ( + resourceId: string + ): Promise> => { + return (await API.getPermissionForResource(resourceId)).permissions + } + + forResourceDetailed = async ( + resourceId: string + ): Promise => { + return await API.getPermissionForResource(resourceId) + } + + getDependantsInfo = async ( + resourceId: string + ): Promise => { + return await API.getDependants(resourceId) + } +} + +export const permissions = new PermissionStore() 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/queries.js b/packages/builder/src/stores/builder/queries.js deleted file mode 100644 index 7aeb9ff8fd..0000000000 --- a/packages/builder/src/stores/builder/queries.js +++ /dev/null @@ -1,130 +0,0 @@ -import { writable, get, derived } from "svelte/store" -import { datasources } from "./datasources" -import { integrations } from "./integrations" -import { API } from "@/api" -import { duplicateName } from "@/helpers/duplicate" - -const sortQueries = queryList => { - queryList.sort((q1, q2) => { - return q1.name.localeCompare(q2.name) - }) -} - -export function createQueriesStore() { - const store = writable({ - list: [], - selectedQueryId: null, - }) - const derivedStore = derived(store, $store => ({ - ...$store, - selected: $store.list?.find(q => q._id === $store.selectedQueryId), - })) - - const fetch = async () => { - const queries = await API.getQueries() - sortQueries(queries) - store.update(state => ({ - ...state, - list: queries, - })) - } - - const save = async (datasourceId, query) => { - const _integrations = get(integrations) - const dataSource = get(datasources).list.filter( - ds => ds._id === datasourceId - ) - // Check if readable attribute is found - if (dataSource.length !== 0) { - const integration = _integrations[dataSource[0].source] - const readable = integration.query[query.queryVerb].readable - if (readable) { - query.readable = readable - } - } - query.datasourceId = datasourceId - const savedQuery = await API.saveQuery(query) - store.update(state => { - const idx = state.list.findIndex(query => query._id === savedQuery._id) - const queries = state.list - if (idx >= 0) { - queries.splice(idx, 1, savedQuery) - } else { - queries.push(savedQuery) - } - sortQueries(queries) - return { - list: queries, - selectedQueryId: savedQuery._id, - } - }) - return savedQuery - } - - const importQueries = async ({ data, datasourceId }) => { - return await API.importQueries(datasourceId, data) - } - - const select = id => { - store.update(state => ({ - ...state, - selectedQueryId: id, - })) - } - - const preview = async query => { - const result = await API.previewQuery(query) - // Assume all the fields are strings and create a basic schema from the - // unique fields returned by the server - const schema = {} - for (let [field, metadata] of Object.entries(result.schema)) { - schema[field] = metadata || { type: "string" } - } - return { ...result, schema, rows: result.rows || [] } - } - - const deleteQuery = async query => { - await API.deleteQuery(query._id, query._rev) - store.update(state => { - state.list = state.list.filter(existing => existing._id !== query._id) - return state - }) - } - - const duplicate = async query => { - let list = get(store).list - const newQuery = { ...query } - const datasourceId = query.datasourceId - - delete newQuery._id - delete newQuery._rev - newQuery.name = duplicateName( - query.name, - list.map(q => q.name) - ) - - return await save(datasourceId, newQuery) - } - - const removeDatasourceQueries = datasourceId => { - store.update(state => ({ - ...state, - list: state.list.filter(table => table.datasourceId !== datasourceId), - })) - } - - return { - subscribe: derivedStore.subscribe, - fetch, - init: fetch, - select, - save, - import: importQueries, - delete: deleteQuery, - preview, - duplicate, - removeDatasourceQueries, - } -} - -export const queries = createQueriesStore() diff --git a/packages/builder/src/stores/builder/queries.ts b/packages/builder/src/stores/builder/queries.ts new file mode 100644 index 0000000000..c6511dc346 --- /dev/null +++ b/packages/builder/src/stores/builder/queries.ts @@ -0,0 +1,156 @@ +import { derived, get, Writable } from "svelte/store" +import { datasources } from "./datasources" +import { integrations } from "./integrations" +import { API } from "@/api" +import { duplicateName } from "@/helpers/duplicate" +import { DerivedBudiStore } from "@/stores/BudiStore" +import { + Query, + QueryPreview, + PreviewQueryResponse, + SaveQueryRequest, + ImportRestQueryRequest, + QuerySchema, +} from "@budibase/types" + +const sortQueries = (queryList: Query[]) => { + queryList.sort((q1, q2) => { + return q1.name.localeCompare(q2.name) + }) +} + +interface BuilderQueryStore { + list: Query[] + selectedQueryId: string | null +} + +interface DerivedQueryStore extends BuilderQueryStore { + selected?: Query +} + +export class QueryStore extends DerivedBudiStore< + BuilderQueryStore, + DerivedQueryStore +> { + constructor() { + const makeDerivedStore = (store: Writable) => { + return derived(store, ($store): DerivedQueryStore => { + return { + list: $store.list, + selectedQueryId: $store.selectedQueryId, + selected: $store.list?.find(q => q._id === $store.selectedQueryId), + } + }) + } + + super( + { + list: [], + selectedQueryId: null, + }, + makeDerivedStore + ) + + this.select = this.select.bind(this) + } + + async fetch() { + const queries = await API.getQueries() + sortQueries(queries) + this.store.update(state => ({ + ...state, + list: queries, + })) + } + + async save(datasourceId: string, query: SaveQueryRequest) { + const _integrations = get(integrations) + const dataSource = get(datasources).list.filter( + ds => ds._id === datasourceId + ) + // Check if readable attribute is found + if (dataSource.length !== 0) { + const integration = _integrations[dataSource[0].source] + const readable = integration.query[query.queryVerb].readable + if (readable) { + query.readable = readable + } + } + query.datasourceId = datasourceId + const savedQuery = await API.saveQuery(query) + this.store.update(state => { + const idx = state.list.findIndex(query => query._id === savedQuery._id) + const queries = state.list + if (idx >= 0) { + queries.splice(idx, 1, savedQuery) + } else { + queries.push(savedQuery) + } + sortQueries(queries) + return { + list: queries, + selectedQueryId: savedQuery._id || null, + } + }) + return savedQuery + } + + async importQueries(data: ImportRestQueryRequest) { + return await API.importQueries(data) + } + + select(id: string | null) { + this.store.update(state => ({ + ...state, + selectedQueryId: id, + })) + } + + async preview(query: QueryPreview): Promise { + const result = await API.previewQuery(query) + // Assume all the fields are strings and create a basic schema from the + // unique fields returned by the server + const schema: Record = {} + for (let [field, metadata] of Object.entries(result.schema)) { + schema[field] = (metadata as QuerySchema) || { type: "string" } + } + return { ...result, schema, rows: result.rows || [] } + } + + async delete(query: Query) { + if (!query._id || !query._rev) { + throw new Error("Query ID or Revision is missing") + } + await API.deleteQuery(query._id, query._rev) + this.store.update(state => ({ + ...state, + list: state.list.filter(existing => existing._id !== query._id), + })) + } + + async duplicate(query: Query) { + let list = get(this.store).list + const newQuery = { ...query } + const datasourceId = query.datasourceId + + delete newQuery._id + delete newQuery._rev + newQuery.name = duplicateName( + query.name, + list.map(q => q.name) + ) + + return await this.save(datasourceId, newQuery) + } + + removeDatasourceQueries(datasourceId: string) { + this.store.update(state => ({ + ...state, + list: state.list.filter(table => table.datasourceId !== datasourceId), + })) + } + + init = this.fetch +} + +export const queries = new QueryStore() 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.ts b/packages/builder/src/stores/builder/rowActions.ts index 9576eccd1b..2b3077926e 100644 --- a/packages/builder/src/stores/builder/rowActions.ts +++ b/packages/builder/src/stores/builder/rowActions.ts @@ -62,7 +62,7 @@ export class RowActionStore extends BudiStore { const existingRowActions = get(this)[tableId] || [] name = getSequentialName(existingRowActions, "New row action ", { getName: x => x.name, - }) + })! } if (!name) { diff --git a/packages/builder/src/stores/builder/views.js b/packages/builder/src/stores/builder/views.js deleted file mode 100644 index 07c356f56d..0000000000 --- a/packages/builder/src/stores/builder/views.js +++ /dev/null @@ -1,67 +0,0 @@ -import { writable, derived } from "svelte/store" -import { tables } from "./tables" -import { API } from "@/api" - -export function createViewsStore() { - const store = writable({ - selectedViewName: null, - }) - const derivedStore = derived([store, tables], ([$store, $tables]) => { - let list = [] - $tables.list?.forEach(table => { - const views = Object.values(table?.views || {}).filter(view => { - return view.version !== 2 - }) - list = list.concat(views) - }) - return { - ...$store, - list, - selected: list.find(view => view.name === $store.selectedViewName), - } - }) - - const select = name => { - store.update(state => ({ - ...state, - selectedViewName: name, - })) - } - - const deleteView = async view => { - await API.deleteView(view.name) - - // Update tables - tables.update(state => { - const table = state.list.find(table => table._id === view.tableId) - delete table.views[view.name] - return { ...state } - }) - } - - const save = async view => { - const savedView = await API.saveView(view) - select(view.name) - - // Update tables - tables.update(state => { - const table = state.list.find(table => table._id === view.tableId) - if (table) { - if (view.originalName) { - delete table.views[view.originalName] - } - table.views[view.name] = savedView - } - return { ...state } - }) - } - - return { - subscribe: derivedStore.subscribe, - select, - delete: deleteView, - save, - } -} - -export const views = createViewsStore() diff --git a/packages/builder/src/stores/builder/views.ts b/packages/builder/src/stores/builder/views.ts new file mode 100644 index 0000000000..81085fcb42 --- /dev/null +++ b/packages/builder/src/stores/builder/views.ts @@ -0,0 +1,94 @@ +import { DerivedBudiStore } from "../BudiStore" +import { tables } from "./tables" +import { API } from "@/api" +import { View } from "@budibase/types" +import { helpers } from "@budibase/shared-core" +import { derived, Writable } from "svelte/store" + +interface BuilderViewStore { + selectedViewName: string | null +} + +interface DerivedViewStore extends BuilderViewStore { + list: View[] + selected?: View +} + +export class ViewsStore extends DerivedBudiStore< + BuilderViewStore, + DerivedViewStore +> { + constructor() { + const makeDerivedStore = (store: Writable) => { + return derived([store, tables], ([$store, $tables]): DerivedViewStore => { + let list: View[] = [] + $tables.list?.forEach(table => { + const views = Object.values(table?.views || {}).filter( + (view): view is View => !helpers.views.isV2(view) + ) + list = list.concat(views) + }) + return { + selectedViewName: $store.selectedViewName, + list, + selected: list.find(view => view.name === $store.selectedViewName), + } + }) + } + + super( + { + selectedViewName: null, + }, + makeDerivedStore + ) + + this.select = this.select.bind(this) + } + + select = (name: string) => { + this.store.update(state => ({ + ...state, + selectedViewName: name, + })) + } + + delete = async (view: View) => { + if (!view.name) { + throw new Error("View name is required") + } + await API.deleteView(view.name) + + // Update tables + tables.update(state => { + const table = state.list.find(table => table._id === view.tableId) + if (table?.views && view.name) { + delete table.views[view.name] + } + return { ...state } + }) + } + + save = async (view: View & { originalName?: string }) => { + if (!view.name) { + throw new Error("View name is required") + } + + const savedView = await API.saveView(view) + this.select(view.name) + + // Update tables + tables.update(state => { + const table = state.list.find(table => table._id === view.tableId) + if (table?.views && view.name) { + if (view.originalName) { + delete table.views[view.originalName] + } + table.views[view.name] = savedView + } + return { ...state } + }) + } +} + +export const views = new ViewsStore() 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/builder/src/stores/portal/users.js b/packages/builder/src/stores/portal/users.ts similarity index 52% rename from packages/builder/src/stores/portal/users.js rename to packages/builder/src/stores/portal/users.ts index 99ead22317..605f8612aa 100644 --- a/packages/builder/src/stores/portal/users.js +++ b/packages/builder/src/stores/portal/users.ts @@ -1,41 +1,71 @@ -import { writable } from "svelte/store" import { API } from "@/api" -import { update } from "lodash" import { licensing } from "." import { sdk } from "@budibase/shared-core" import { Constants } from "@budibase/frontend-core" +import { + DeleteInviteUsersRequest, + InviteUsersRequest, + SearchUsersRequest, + SearchUsersResponse, + UpdateInviteRequest, + User, + UserIdentifier, + UnsavedUser, +} from "@budibase/types" +import { BudiStore } from "../BudiStore" -export function createUsersStore() { - const { subscribe, set } = writable({}) +interface UserInfo { + email: string + password: string + forceResetPassword?: boolean + role: keyof typeof Constants.BudibaseRoles +} - // opts can contain page and search params - async function search(opts = {}) { +type UserState = SearchUsersResponse & SearchUsersRequest + +class UserStore extends BudiStore { + constructor() { + super({ + data: [], + }) + } + + async search(opts: SearchUsersRequest = {}) { const paged = await API.searchUsers(opts) - set({ + this.set({ ...paged, ...opts, }) return paged } - async function get(userId) { + async get(userId: string) { try { return await API.getUser(userId) } catch (err) { return null } } - const fetch = async () => { + + async fetch() { return await API.getUsers() } - // One or more users. - async function onboard(payload) { + async onboard(payload: InviteUsersRequest) { return await API.onboardUsers(payload) } - async function invite(payload) { - const users = payload.map(user => { + async invite( + payload: { + admin?: boolean + builder?: boolean + creator?: boolean + email: string + apps?: any[] + groups?: any[] + }[] + ) { + const users: InviteUsersRequest = payload.map(user => { let builder = undefined if (user.admin || user.builder) { builder = { global: true } @@ -55,11 +85,16 @@ export function createUsersStore() { return API.inviteUsers(users) } - async function removeInvites(payload) { + async removeInvites(payload: DeleteInviteUsersRequest) { return API.removeUserInvites(payload) } - async function acceptInvite(inviteCode, password, firstName, lastName) { + async acceptInvite( + inviteCode: string, + password: string, + firstName: string, + lastName?: string + ) { return API.acceptInvite({ inviteCode, password, @@ -68,21 +103,25 @@ export function createUsersStore() { }) } - async function fetchInvite(inviteCode) { + async fetchInvite(inviteCode: string) { return API.getUserInvite(inviteCode) } - async function getInvites() { + async getInvites() { return API.getUserInvites() } - async function updateInvite(invite) { - return API.updateUserInvite(invite.code, invite) + async updateInvite(code: string, invite: UpdateInviteRequest) { + return API.updateUserInvite(code, invite) } - async function create(data) { - let mappedUsers = data.users.map(user => { - const body = { + async getUserCountByApp(appId: string) { + return await API.getUserCountByApp(appId) + } + + async create(data: { users: UserInfo[]; groups: any[] }) { + let mappedUsers: UnsavedUser[] = data.users.map((user: any) => { + const body: UnsavedUser = { email: user.email, password: user.password, roles: {}, @@ -92,17 +131,17 @@ export function createUsersStore() { } switch (user.role) { - case "appUser": + case Constants.BudibaseRoles.AppUser: body.builder = { global: false } body.admin = { global: false } break - case "developer": + case Constants.BudibaseRoles.Developer: body.builder = { global: true } break - case "creator": + case Constants.BudibaseRoles.Creator: body.builder = { creator: true, global: false } break - case "admin": + case Constants.BudibaseRoles.Admin: body.admin = { global: true } body.builder = { global: true } break @@ -111,43 +150,47 @@ export function createUsersStore() { return body }) const response = await API.createUsers(mappedUsers, data.groups) + licensing.setQuotaUsage() // re-search from first page - await search() + await this.search() return response } - async function del(id) { + async delete(id: string) { await API.deleteUser(id) - update(users => users.filter(user => user._id !== id)) + licensing.setQuotaUsage() } - async function getUserCountByApp(appId) { - return await API.getUserCountByApp(appId) + async bulkDelete(users: UserIdentifier[]) { + const res = API.deleteUsers(users) + licensing.setQuotaUsage() + return res } - async function bulkDelete(users) { - return API.deleteUsers(users) + async save(user: User) { + const res = await API.saveUser(user) + licensing.setQuotaUsage() + return res } - async function save(user) { - return await API.saveUser(user) - } - - async function addAppBuilder(userId, appId) { + async addAppBuilder(userId: string, appId: string) { return await API.addAppBuilder(userId, appId) } - async function removeAppBuilder(userId, appId) { + async removeAppBuilder(userId: string, appId: string) { return await API.removeAppBuilder(userId, appId) } - async function getAccountHolder() { + async getAccountHolder() { return await API.getAccountHolder() } - const getUserRole = user => { - if (user && user.email === user.tenantOwnerEmail) { + getUserRole(user?: User & { tenantOwnerEmail?: string }) { + if (!user) { + return Constants.BudibaseRoles.AppUser + } + if (user.email === user.tenantOwnerEmail) { return Constants.BudibaseRoles.Owner } else if (sdk.users.isAdmin(user)) { return Constants.BudibaseRoles.Admin @@ -159,38 +202,6 @@ export function createUsersStore() { return Constants.BudibaseRoles.AppUser } } - - const refreshUsage = - fn => - async (...args) => { - const response = await fn(...args) - await licensing.setQuotaUsage() - return response - } - - return { - subscribe, - search, - get, - getUserRole, - fetch, - invite, - onboard, - fetchInvite, - getInvites, - removeInvites, - updateInvite, - getUserCountByApp, - addAppBuilder, - removeAppBuilder, - // any operation that adds or deletes users - acceptInvite, - create: refreshUsage(create), - save: refreshUsage(save), - bulkDelete: refreshUsage(bulkDelete), - delete: refreshUsage(del), - getAccountHolder, - } } -export const users = createUsersStore() +export const users = new UserStore() diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 6dd1b71b75..c236dd1ad9 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -5139,7 +5139,8 @@ { "type": "text", "label": "File name", - "key": "key" + "key": "key", + "nested": true }, { "type": "event", diff --git a/packages/client/src/api/api.js b/packages/client/src/api/api.ts similarity index 94% rename from packages/client/src/api/api.js rename to packages/client/src/api/api.ts index d4c8faa4d2..b944f7bd7c 100644 --- a/packages/client/src/api/api.js +++ b/packages/client/src/api/api.ts @@ -1,6 +1,10 @@ import { createAPIClient } from "@budibase/frontend-core" -import { authStore } from "../stores/auth.js" -import { notificationStore, devToolsEnabled, devToolsStore } from "../stores/" +import { authStore } from "../stores/auth" +import { + notificationStore, + devToolsEnabled, + devToolsStore, +} from "../stores/index" import { get } from "svelte/store" export const API = createAPIClient({ diff --git a/packages/client/src/api/index.js b/packages/client/src/api/index.ts similarity index 73% rename from packages/client/src/api/index.js rename to packages/client/src/api/index.ts index 5eb6b2b6f4..3c53045cfd 100644 --- a/packages/client/src/api/index.js +++ b/packages/client/src/api/index.ts @@ -1,5 +1,5 @@ -import { API } from "./api.js" -import { patchAPI } from "./patches.js" +import { API } from "./api" +import { patchAPI } from "./patches" // Certain endpoints which return rows need patched so that they transform // and enrich the row docs, so that they can be correctly handled by the diff --git a/packages/client/src/api/patches.js b/packages/client/src/api/patches.ts similarity index 80% rename from packages/client/src/api/patches.js rename to packages/client/src/api/patches.ts index 5413379b72..9482644fa7 100644 --- a/packages/client/src/api/patches.js +++ b/packages/client/src/api/patches.ts @@ -1,19 +1,20 @@ -import { Constants } from "@budibase/frontend-core" +import { Constants, APIClient } from "@budibase/frontend-core" import { FieldTypes } from "../constants" +import { Row, Table } from "@budibase/types" -export const patchAPI = API => { +export const patchAPI = (API: APIClient) => { /** * Enriches rows which contain certain field types so that they can * be properly displayed. * The ability to create these bindings has been removed, but they will still * exist in client apps to support backwards compatibility. */ - const enrichRows = async (rows, tableId) => { + const enrichRows = async (rows: Row[], tableId: string) => { if (!Array.isArray(rows)) { return [] } if (rows.length) { - const tables = {} + const tables: Record = {} for (let row of rows) { // Fall back to passed in tableId if row doesn't have it specified let rowTableId = row.tableId || tableId @@ -54,7 +55,7 @@ export const patchAPI = API => { const fetchSelf = API.fetchSelf API.fetchSelf = async () => { const user = await fetchSelf() - if (user && user._id) { + if (user && "_id" in user && user._id) { if (user.roleId === "PUBLIC") { // Don't try to enrich a public user as it will 403 return user @@ -66,10 +67,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,19 +85,20 @@ 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) } - // Wipe any HBS formulae from table definitions, as these interfere with + // Wipe any HBS formulas from table definitions, as these interfere with // handlebars enrichment const fetchTableDefinition = API.fetchTableDefinition API.fetchTableDefinition = async tableId => { const definition = await fetchTableDefinition(tableId) Object.keys(definition?.schema || {}).forEach(field => { if (definition.schema[field]?.type === "formula") { + // @ts-expect-error TODO check what use case removing that would break delete definition.schema[field].formula } }) diff --git a/packages/client/src/components/Block.svelte b/packages/client/src/components/Block.svelte index a739065015..48c11f152a 100644 --- a/packages/client/src/components/Block.svelte +++ b/packages/client/src/components/Block.svelte @@ -1,7 +1,7 @@