diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 2e7851b338..c8bdfe9655 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -165,6 +165,7 @@ jobs: oracle, sqs, elasticsearch, + dynamodb, none, ] steps: @@ -205,6 +206,8 @@ jobs: docker pull postgres:9.5.25 elif [ "${{ matrix.datasource }}" == "elasticsearch" ]; then docker pull elasticsearch@${{ steps.dotenv.outputs.ELASTICSEARCH_SHA }} + elif [ "${{ matrix.datasource }}" == "dynamodb" ]; then + docker pull amazon/dynamodb-local@${{ steps.dotenv.outputs.DYNAMODB_SHA }} fi docker pull minio/minio & docker pull redis & diff --git a/globalSetup.ts b/globalSetup.ts index 0b0e276b49..7396540936 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -88,6 +88,16 @@ export default async function setup() { content: ` [log] level = warn + + [httpd] + socket_options = [{nodelay, true}] + + [couchdb] + single_node = true + + [cluster] + n = 1 + q = 1 `, target: "/opt/couchdb/etc/local.d/test-couchdb.ini", }, diff --git a/lerna.json b/lerna.json index d20097b6e5..a0de97dc7a 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", - "version": "3.4.22", + "version": "3.5.0", "npmClient": "yarn", "concurrency": 20, "command": { diff --git a/package.json b/package.json index 1475abadf9..d083dbad90 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "eslint-plugin-jest": "28.9.0", "eslint-plugin-local-rules": "3.0.2", "eslint-plugin-svelte": "2.46.1", - "svelte-preprocess": "^6.0.3", "husky": "^8.0.3", "kill-port": "^1.6.1", "lerna": "7.4.2", @@ -29,7 +28,9 @@ "prettier-plugin-svelte": "^2.3.0", "proper-lockfile": "^4.1.2", "svelte": "4.2.19", + "svelte-check": "^4.1.5", "svelte-eslint-parser": "0.43.0", + "svelte-preprocess": "^6.0.3", "typescript": "5.7.2", "typescript-eslint": "8.17.0", "yargs": "^17.7.2" diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index 98e24e0996..69f0fd64ea 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -222,9 +222,12 @@ export class DatabaseImpl implements Database { } async getMultiple( - ids: string[], + ids?: string[], opts?: { allowMissing?: boolean; excludeDocs?: boolean } ): Promise { + if (!ids || ids.length === 0) { + return [] + } // get unique ids = [...new Set(ids)] const includeDocs = !opts?.excludeDocs @@ -249,7 +252,7 @@ export class DatabaseImpl implements Database { if (!opts?.allowMissing && someMissing) { const missing = response.rows.filter(row => rowUnavailable(row)) const missingIds = missing.map(row => row.key).join(", ") - throw new Error(`Unable to get documents: ${missingIds}`) + throw new Error(`Unable to get bulk documents: ${missingIds}`) } return rows.map(row => (includeDocs ? row.doc! : row.value)) } diff --git a/packages/backend-core/src/db/instrumentation.ts b/packages/backend-core/src/db/instrumentation.ts index 0c0056d6ed..68c3694672 100644 --- a/packages/backend-core/src/db/instrumentation.ts +++ b/packages/backend-core/src/db/instrumentation.ts @@ -52,13 +52,13 @@ export class DDInstrumentedDatabase implements Database { } getMultiple( - ids: string[], + ids?: string[], opts?: { allowMissing?: boolean | undefined } | undefined ): Promise { return tracer.trace("db.getMultiple", async span => { span.addTags({ db_name: this.name, - num_docs: ids.length, + num_docs: ids?.length || 0, allow_missing: opts?.allowMissing, }) const docs = await this.db.getMultiple(ids, opts) diff --git a/packages/backend-core/src/queue/inMemoryQueue.ts b/packages/backend-core/src/queue/inMemoryQueue.ts index 842d3243bc..dc6890e655 100644 --- a/packages/backend-core/src/queue/inMemoryQueue.ts +++ b/packages/backend-core/src/queue/inMemoryQueue.ts @@ -3,7 +3,6 @@ import { newid } from "../utils" import { Queue, QueueOptions, JobOptions } from "./queue" import { helpers } from "@budibase/shared-core" import { Job, JobId, JobInformation } from "bull" -import { cloneDeep } from "lodash" function jobToJobInformation(job: Job): JobInformation { let cron = "" @@ -88,9 +87,7 @@ export class InMemoryQueue implements Partial> { */ async process(concurrencyOrFunc: number | any, func?: any) { func = typeof concurrencyOrFunc === "number" ? func : concurrencyOrFunc - this._emitter.on("message", async msg => { - const message = cloneDeep(msg) - + this._emitter.on("message", async message => { // For the purpose of testing, don't trigger cron jobs immediately. // Require the test to trigger them manually with timestamps. if (!message.manualTrigger && message.opts?.repeat != null) { @@ -165,6 +162,9 @@ export class InMemoryQueue implements Partial> { opts, } this._messages.push(message) + if (this._messages.length > 1000) { + this._messages.shift() + } this._addCount++ this._emitter.emit("message", message) } diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index 2b15338925..677feed678 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -26,8 +26,9 @@ import { import { getAccountHolderFromUsers, isAdmin, - isCreator, + creatorsInList, validateUniqueUser, + isCreatorAsync, } from "./utils" import { getFirstPlatformUser, @@ -261,8 +262,16 @@ export class UserDB { } const change = dbUser ? 0 : 1 // no change if there is existing user - const creatorsChange = - (await isCreator(dbUser)) !== (await isCreator(user)) ? 1 : 0 + + let creatorsChange = 0 + if (dbUser) { + const [isDbUserCreator, isUserCreator] = await creatorsInList([ + dbUser, + user, + ]) + creatorsChange = isDbUserCreator !== isUserCreator ? 1 : 0 + } + return UserDB.quotas.addUsers(change, creatorsChange, async () => { if (!opts.isAccountHolder) { await validateUniqueUser(email, tenantId) @@ -353,7 +362,7 @@ export class UserDB { } newUser.userGroups = groups || [] newUsers.push(newUser) - if (await isCreator(newUser)) { + if (await isCreatorAsync(newUser)) { newCreators.push(newUser) } } @@ -453,10 +462,8 @@ export class UserDB { })) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) - const creatorsEval = await Promise.all(usersToDelete.map(isCreator)) - const creatorsToDeleteCount = creatorsEval.filter( - creator => !!creator - ).length + const creatorsEval = await creatorsInList(usersToDelete) + const creatorsToDeleteCount = creatorsEval.filter(creator => creator).length const ssoUsersToDelete: AnyDocument[] = [] for (let user of usersToDelete) { @@ -533,7 +540,7 @@ export class UserDB { await db.remove(userId, dbUser._rev!) - const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0 + const creatorsToDelete = (await isCreatorAsync(dbUser)) ? 1 : 0 await UserDB.quotas.removeUsers(1, creatorsToDelete) await eventHelpers.handleDeleteEvents(dbUser) await cache.user.invalidateUser(userId) diff --git a/packages/backend-core/src/users/test/utils.spec.ts b/packages/backend-core/src/users/test/utils.spec.ts index cb98b8972b..b52397c979 100644 --- a/packages/backend-core/src/users/test/utils.spec.ts +++ b/packages/backend-core/src/users/test/utils.spec.ts @@ -2,39 +2,39 @@ import { User, UserGroup } from "@budibase/types" import { generator, structures } from "../../../tests" import { DBTestConfiguration } from "../../../tests/extra" import { getGlobalDB } from "../../context" -import { isCreator } from "../utils" +import { isCreatorSync, creatorsInList } from "../utils" const config = new DBTestConfiguration() describe("Users", () => { - it("User is a creator if it is configured as a global builder", async () => { + it("User is a creator if it is configured as a global builder", () => { const user: User = structures.users.user({ builder: { global: true } }) - expect(await isCreator(user)).toBe(true) + expect(isCreatorSync(user, [])).toBe(true) }) - it("User is a creator if it is configured as a global admin", async () => { + it("User is a creator if it is configured as a global admin", () => { const user: User = structures.users.user({ admin: { global: true } }) - expect(await isCreator(user)).toBe(true) + expect(isCreatorSync(user, [])).toBe(true) }) - it("User is a creator if it is configured with creator permission", async () => { + it("User is a creator if it is configured with creator permission", () => { const user: User = structures.users.user({ builder: { creator: true } }) - expect(await isCreator(user)).toBe(true) + expect(isCreatorSync(user, [])).toBe(true) }) - it("User is a creator if it is a builder in some application", async () => { + it("User is a creator if it is a builder in some application", () => { const user: User = structures.users.user({ builder: { apps: ["app1"] } }) - expect(await isCreator(user)).toBe(true) + expect(isCreatorSync(user, [])).toBe(true) }) - it("User is a creator if it has CREATOR permission in some application", async () => { + it("User is a creator if it has CREATOR permission in some application", () => { const user: User = structures.users.user({ roles: { app1: "CREATOR" } }) - expect(await isCreator(user)).toBe(true) + expect(isCreatorSync(user, [])).toBe(true) }) - it("User is a creator if it has ADMIN permission in some application", async () => { + it("User is a creator if it has ADMIN permission in some application", () => { const user: User = structures.users.user({ roles: { app1: "ADMIN" } }) - expect(await isCreator(user)).toBe(true) + expect(isCreatorSync(user, [])).toBe(true) }) it("User is a creator if it remains to a group with ADMIN permissions", async () => { @@ -59,7 +59,7 @@ describe("Users", () => { await db.put(group) for (let user of users) { await db.put(user) - const creator = await isCreator(user) + const creator = (await creatorsInList([user]))[0] expect(creator).toBe(true) } }) diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts index 0bff428fa9..36abfcfb2d 100644 --- a/packages/backend-core/src/users/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -22,7 +22,7 @@ import { } from "@budibase/types" import * as context from "../context" import { getGlobalDB } from "../context" -import { isCreator } from "./utils" +import { creatorsInList } from "./utils" import { UserDB } from "./db" import { dataFilters } from "@budibase/shared-core" @@ -305,8 +305,8 @@ export async function getCreatorCount() { let creators = 0 async function iterate(startPage?: string) { const page = await paginatedUsers({ bookmark: startPage }) - const creatorsEval = await Promise.all(page.data.map(isCreator)) - creators += creatorsEval.filter(creator => !!creator).length + const creatorsEval = await creatorsInList(page.data) + creators += creatorsEval.filter(creator => creator).length if (page.hasNextPage) { await iterate(page.nextPage) } diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts index 91b667ce17..039f9228f9 100644 --- a/packages/backend-core/src/users/utils.ts +++ b/packages/backend-core/src/users/utils.ts @@ -16,30 +16,47 @@ export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasBuilderPermissions = sdk.users.hasBuilderPermissions export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions -export async function isCreator(user?: User | ContextUser) { +export async function creatorsInList( + users: (User | ContextUser)[], + groups?: UserGroup[] +) { + const groupIds = [ + ...new Set( + users.filter(user => user.userGroups).flatMap(user => user.userGroups!) + ), + ] + const db = context.getGlobalDB() + groups = await db.getMultiple(groupIds, { allowMissing: true }) + return users.map(user => isCreatorSync(user, groups)) +} + +// fetches groups if no provided, but is async and shouldn't be looped with +export async function isCreatorAsync(user: User | ContextUser) { + let groups: UserGroup[] = [] + if (user.userGroups) { + const db = context.getGlobalDB() + groups = await db.getMultiple(user.userGroups) + } + return isCreatorSync(user, groups) +} + +export function isCreatorSync(user: User | ContextUser, groups?: UserGroup[]) { const isCreatorByUserDefinition = sdk.users.isCreator(user) if (!isCreatorByUserDefinition && user) { - return await isCreatorByGroupMembership(user) + return isCreatorByGroupMembership(user, groups) } return isCreatorByUserDefinition } -async function isCreatorByGroupMembership(user?: User | ContextUser) { - const userGroups = user?.userGroups || [] - if (userGroups.length > 0) { - const db = context.getGlobalDB() - const groups: UserGroup[] = [] - for (let groupId of userGroups) { - try { - const group = await db.get(groupId) - groups.push(group) - } catch (e: any) { - if (e.error !== "not_found") { - throw e - } - } - } - return groups.some(group => +function isCreatorByGroupMembership( + user: User | ContextUser, + groups?: UserGroup[] +) { + const userGroups = groups?.filter( + group => user.userGroups?.indexOf(group._id!) !== -1 + ) + if (userGroups && userGroups.length > 0) { + return userGroups.some(group => Object.values(group.roles || {}).includes(BUILTIN_ROLE_IDS.ADMIN) ) } diff --git a/packages/backend-core/tests/core/users/users.spec.ts b/packages/backend-core/tests/core/users/users.spec.ts deleted file mode 100644 index b14f553266..0000000000 --- a/packages/backend-core/tests/core/users/users.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { range } from "lodash/fp" -import { structures } from "../.." - -jest.mock("../../../src/context") -jest.mock("../../../src/db") - -import * as context from "../../../src/context" -import * as db from "../../../src/db" - -import { getCreatorCount } from "../../../src/users/users" - -describe("Users", () => { - let getGlobalDBMock: jest.SpyInstance - let paginationMock: jest.SpyInstance - - beforeEach(() => { - jest.resetAllMocks() - - getGlobalDBMock = jest.spyOn(context, "getGlobalDB") - paginationMock = jest.spyOn(db, "pagination") - - jest.spyOn(db, "getGlobalUserParams") - }) - - it("retrieves the number of creators", async () => { - const getUsers = (offset: number, limit: number, creators = false) => { - const opts = creators ? { builder: { global: true } } : undefined - return range(offset, limit).map(() => structures.users.user(opts)) - } - const page1Data = getUsers(0, 8) - const page2Data = getUsers(8, 12, true) - getGlobalDBMock.mockImplementation(() => ({ - name: "fake-db", - allDocs: () => ({ - rows: [...page1Data, ...page2Data], - }), - })) - paginationMock.mockImplementationOnce(() => ({ - data: page1Data, - hasNextPage: true, - nextPage: "1", - })) - paginationMock.mockImplementation(() => ({ - data: page2Data, - hasNextPage: false, - nextPage: undefined, - })) - const creatorsCount = await getCreatorCount() - expect(creatorsCount).toBe(4) - expect(paginationMock).toHaveBeenCalledTimes(2) - }) -}) diff --git a/packages/bbui/src/Form/Core/Multiselect.svelte b/packages/bbui/src/Form/Core/Multiselect.svelte index 26951c526f..7c4c857056 100644 --- a/packages/bbui/src/Form/Core/Multiselect.svelte +++ b/packages/bbui/src/Form/Core/Multiselect.svelte @@ -1,22 +1,26 @@ - + +
@@ -40,4 +42,9 @@ margin-bottom: 0; margin-top: 0; } + + .is-emphasized { + border-color: var(--spectrum-global-color-blue-700); + color: var(--spectrum-global-color-blue-700); + } diff --git a/packages/builder/package.json b/packages/builder/package.json index a70d19209e..ee121cdf14 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -100,7 +100,6 @@ "jest": "29.7.0", "jsdom": "^21.1.1", "resize-observer-polyfill": "^1.5.1", - "svelte-check": "^4.1.0", "svelte-jester": "^1.3.2", "vite": "^4.5.0", "vite-plugin-static-copy": "^0.17.0", diff --git a/packages/builder/src/analytics/PosthogClient.js b/packages/builder/src/analytics/PosthogClient.ts similarity index 63% rename from packages/builder/src/analytics/PosthogClient.js rename to packages/builder/src/analytics/PosthogClient.ts index f541b69b13..fe41989a66 100644 --- a/packages/builder/src/analytics/PosthogClient.js +++ b/packages/builder/src/analytics/PosthogClient.ts @@ -1,9 +1,12 @@ import posthog from "posthog-js" -import { Events } from "./constants" export default class PosthogClient { - constructor(token) { + token: string + initialised: boolean + + constructor(token: string) { this.token = token + this.initialised = false } init() { @@ -12,6 +15,8 @@ export default class PosthogClient { posthog.init(this.token, { autocapture: false, capture_pageview: false, + // disable by default + disable_session_recording: true, }) posthog.set_config({ persistence: "cookie" }) @@ -22,7 +27,7 @@ export default class PosthogClient { * Set the posthog context to the current user * @param {String} id - unique user id */ - identify(id) { + identify(id: string) { if (!this.initialised) return posthog.identify(id) @@ -32,7 +37,7 @@ export default class PosthogClient { * Update user metadata associated with current user in posthog * @param {Object} meta - user fields */ - updateUser(meta) { + updateUser(meta: Record) { if (!this.initialised) return posthog.people.set(meta) @@ -43,28 +48,22 @@ export default class PosthogClient { * @param {String} event - event identifier * @param {Object} props - properties for the event */ - captureEvent(eventName, props) { - if (!this.initialised) return - - props.sourceApp = "builder" - posthog.capture(eventName, props) - } - - /** - * Submit NPS feedback to posthog. - * @param {Object} values - NPS Values - */ - npsFeedback(values) { - if (!this.initialised) return - - localStorage.setItem(Events.NPS.SUBMITTED, Date.now()) - - const prefixedFeedback = {} - for (let key in values) { - prefixedFeedback[`feedback_${key}`] = values[key] + captureEvent(event: string, props: Record) { + if (!this.initialised) { + return } - posthog.capture(Events.NPS.SUBMITTED, prefixedFeedback) + props.sourceApp = "builder" + posthog.capture(event, props) + } + + enableSessionRecording() { + if (!this.initialised) { + return + } + posthog.set_config({ + disable_session_recording: false, + }) } /** diff --git a/packages/builder/src/analytics/index.js b/packages/builder/src/analytics/index.js index aa83f3c7ab..12bd548e9b 100644 --- a/packages/builder/src/analytics/index.js +++ b/packages/builder/src/analytics/index.js @@ -31,6 +31,10 @@ class AnalyticsHub { posthog.captureEvent(eventName, props) } + enableSessionRecording() { + posthog.enableSessionRecording() + } + async logout() { posthog.logout() } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index 706c196fff..d9f5ad1ef6 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -23,9 +23,8 @@ let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK] let selectedAction let actions = Object.entries($automationStore.blockDefinitions.ACTION).filter( - entry => { - const [key] = entry - return key !== AutomationActionStepId.BRANCH + ([key, action]) => { + return key !== AutomationActionStepId.BRANCH && action.deprecated !== true } ) let lockedFeatures = [ @@ -186,6 +185,10 @@
{:else if isDisabled} + {:else if action.new} + + New + {/if} @@ -227,6 +230,10 @@ grid-gap: var(--spectrum-alias-grid-baseline); } + .item :global(.spectrum-Tags-itemLabel) { + cursor: pointer; + } + .item { cursor: pointer; grid-gap: var(--spectrum-alias-grid-margin-xsmall); @@ -237,6 +244,8 @@ border-radius: 5px; box-sizing: border-box; border-width: 2px; + min-height: 3.5rem; + display: flex; } .item:not(.disabled):hover, .selected { diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte index 779eaf415a..cdf0f82225 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/BranchNode.svelte @@ -18,8 +18,12 @@ import AutomationBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte" import FlowItemHeader from "./FlowItemHeader.svelte" import FlowItemActions from "./FlowItemActions.svelte" - import { automationStore, selectedAutomation } from "@/stores/builder" - import { QueryUtils, Utils } from "@budibase/frontend-core" + import { + automationStore, + selectedAutomation, + evaluationContext, + } from "@/stores/builder" + import { QueryUtils, Utils, memo } from "@budibase/frontend-core" import { cloneDeep } from "lodash/fp" import { createEventDispatcher, getContext } from "svelte" import DragZone from "./DragZone.svelte" @@ -34,11 +38,14 @@ export let automation const view = getContext("draggableView") + const memoContext = memo({}) let drawer let open = true let confirmDeleteModal + $: memoContext.set($evaluationContext) + $: branch = step.inputs?.branches?.[branchIdx] $: editableConditionUI = branch.conditionUI || {} @@ -100,6 +107,7 @@ allowOnEmpty={false} builderType={"condition"} docsURL={null} + evaluationContext={$memoContext} /> diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte index e81d6d0f5c..f3dea4885f 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItemHeader.svelte @@ -1,6 +1,13 @@ @@ -69,7 +93,7 @@
- {#each fieldsArray as field} + {#each fieldsArray as field, idx (field.id)}
removeField(field.name)} + on:click={() => { + removeField(idx) + }} />
{/each} @@ -115,4 +141,12 @@ align-items: center; gap: var(--spacing-m); } + + .remove-field { + cursor: pointer; + } + + .remove-field:hover { + color: var(--spectrum-global-color-gray-900); + } diff --git a/packages/builder/src/components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte b/packages/builder/src/components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte deleted file mode 100644 index 37610061ac..0000000000 --- a/packages/builder/src/components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte +++ /dev/null @@ -1,132 +0,0 @@ - - - importQueries()} - {onCancel} - confirmText={"Import"} - cancelText="Back" - size="L" -> - - Import - Import your rest collection using one of the options below - - - - - { - $data.file = e.detail?.[0] - lastTouched = "file" - }} - fileTags={[ - "OpenAPI 3.0", - "OpenAPI 2.0", - "Swagger 2.0", - "cURL", - "YAML", - "JSON", - ]} - maximum={1} - /> - - -