diff --git a/.eslintignore b/.eslintignore index 8d4c64d960..f2c53c2fdc 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,6 +6,7 @@ packages/server/coverage packages/worker/coverage packages/backend-core/coverage packages/server/client +packages/server/coverage packages/builder/.routify packages/sdk/sdk packages/account-portal/packages/server/build diff --git a/.eslintrc.json b/.eslintrc.json index ae9512152f..9b4eb7eebb 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -34,18 +34,40 @@ }, { "files": ["**/*.ts"], + "excludedFiles": ["qa-core/**"], "parser": "@typescript-eslint/parser", "extends": ["eslint:recommended"], + "globals": { + "NodeJS": true + }, + "rules": { + "no-unused-vars": "off", + "local-rules/no-budibase-imports": "error" + } + }, + { + "files": ["**/*.spec.ts"], + "excludedFiles": ["qa-core/**"], + "parser": "@typescript-eslint/parser", + "plugins": ["jest"], + "extends": ["eslint:recommended", "plugin:jest/recommended"], + "env": { + "jest/globals": true + }, + "globals": { + "NodeJS": true + }, "rules": { "no-unused-vars": "off", - "no-inner-declarations": "off", - "no-case-declarations": "off", - "no-useless-escape": "off", - "no-undef": "off", - "no-prototype-builtins": "off", - "local-rules/no-budibase-imports": "error", "local-rules/no-test-com": "error", - "local-rules/email-domain-example-com": "error" + "local-rules/email-domain-example-com": "error", + "no-console": "warn", + // We have a lot of tests that don't have assertions, they use our test + // API client that does the assertions for them + "jest/expect-expect": "off", + // We do this in some tests where the behaviour of internal tables + // differs to external, but the API is broadly the same + "jest/no-conditional-expect": "off" } }, { diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 3060660d47..5c474aa826 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -107,9 +107,9 @@ jobs: - name: Test run: | if ${{ env.USE_NX_AFFECTED }}; then - yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro --since=${{ env.NX_BASE_BRANCH }} + yarn test --ignore=@budibase/worker --ignore=@budibase/server --since=${{ env.NX_BASE_BRANCH }} else - yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro + yarn test --ignore=@budibase/worker --ignore=@budibase/server fi test-worker: @@ -160,31 +160,6 @@ jobs: yarn test --scope=@budibase/server fi - test-pro: - runs-on: ubuntu-latest - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' - steps: - - name: Checkout repo and submodules - uses: actions/checkout@v4 - with: - submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - fetch-depth: 0 - - - name: Use Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: yarn - - run: yarn --frozen-lockfile - - name: Test - run: | - if ${{ env.USE_NX_AFFECTED }}; then - yarn test --scope=@budibase/pro --since=${{ env.NX_BASE_BRANCH }} - else - yarn test --scope=@budibase/pro - fi - integration-test: runs-on: ubuntu-latest steps: diff --git a/charts/budibase/README.md b/charts/budibase/README.md index 342011bdb1..dea7d1dbae 100644 --- a/charts/budibase/README.md +++ b/charts/budibase/README.md @@ -140,7 +140,7 @@ $ helm install --create-namespace --namespace budibase budibase . -f values.yaml | ingress.className | string | `""` | What ingress class to use. | | ingress.enabled | bool | `true` | Whether to create an Ingress resource pointing to the Budibase proxy. | | ingress.hosts | list | `[]` | Standard hosts block for the Ingress resource. Defaults to pointing to the Budibase proxy. | -| nameOverride | string | `""` | Override the name of the deploymen. Defaults to {{ .Chart.Name }}. | +| nameOverride | string | `""` | Override the name of the deployment. Defaults to {{ .Chart.Name }}. | | service.port | int | `10000` | Port to expose on the service. | | service.type | string | `"ClusterIP"` | Service type for the service that points to the main Budibase proxy pod. | | serviceAccount.annotations | object | `{}` | Annotations to add to the service account | diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 09262df463..19b6c22d6c 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -1,6 +1,6 @@ # -- Passed to all pods created by this chart. Should not ordinarily need to be changed. imagePullSecrets: [] -# -- Override the name of the deploymen. Defaults to {{ .Chart.Name }}. +# -- Override the name of the deployment. Defaults to {{ .Chart.Name }}. nameOverride: "" serviceAccount: diff --git a/eslint-local-rules/index.js b/eslint-local-rules/index.js index 177b0a129c..a4866bc1f8 100644 --- a/eslint-local-rules/index.js +++ b/eslint-local-rules/index.js @@ -7,11 +7,12 @@ module.exports = { if ( /^@budibase\/[^/]+\/.*$/.test(importPath) && - importPath !== "@budibase/backend-core/tests" + importPath !== "@budibase/backend-core/tests" && + importPath !== "@budibase/string-templates/test/utils" ) { context.report({ node, - message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests.`, + message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests and @budibase/string-templates/test/utils.`, }) } }, @@ -24,11 +25,9 @@ module.exports = { docs: { description: "disallow the use of 'test.com' in strings and replace it with 'example.com'", - category: "Possible Errors", - recommended: false, }, - schema: [], // no options - fixable: "code", // Indicates that this rule supports automatic fixing + schema: [], + fixable: "code", }, create: function (context) { return { @@ -57,8 +56,6 @@ module.exports = { docs: { description: "enforce using the example.com domain for generator.email calls", - category: "Possible Errors", - recommended: false, }, fixable: "code", schema: [], diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index ee98b0729d..be01056b53 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -12,8 +12,6 @@ COPY .yarnrc . COPY packages/server/package.json packages/server/package.json COPY packages/worker/package.json packages/worker/package.json -# string-templates does not get bundled during the esbuild process, so we want to use the local version -COPY packages/string-templates/package.json packages/string-templates/package.json COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh @@ -26,7 +24,7 @@ RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json RUN echo '' > scripts/syncProPackage.js RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile # copy the actual code COPY packages/server/dist packages/server/dist @@ -35,7 +33,6 @@ COPY packages/server/client packages/server/client COPY packages/server/builder packages/server/builder COPY packages/worker/dist packages/worker/dist COPY packages/worker/pm2.config.js packages/worker/pm2.config.js -COPY packages/string-templates packages/string-templates FROM budibase/couchdb:v3.3.3 as runner @@ -52,11 +49,11 @@ RUN apt-get update && \ # Install postgres client for pg_dump utils RUN apt install -y software-properties-common apt-transport-https ca-certificates gnupg \ - && curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \ - && echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \ - && apt update -y \ - && apt install postgresql-client-15 -y \ - && apt remove software-properties-common apt-transport-https gpg -y + && curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \ + && echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \ + && apt update -y \ + && apt install postgresql-client-15 -y \ + && apt remove software-properties-common apt-transport-https gpg -y # We use pm2 in order to run multiple node processes in a single container RUN npm install --global pm2 @@ -100,9 +97,6 @@ COPY --from=build /app/node_modules /node_modules COPY --from=build /app/package.json /package.json COPY --from=build /app/packages/server /app COPY --from=build /app/packages/worker /worker -COPY --from=build /app/packages/string-templates /string-templates - -RUN cd /string-templates && yarn link && cd ../app && yarn link @budibase/string-templates && cd ../worker && yarn link @budibase/string-templates EXPOSE 80 diff --git a/lerna.json b/lerna.json index b845465de5..341efc0cad 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.21.8", + "version": "2.22.5", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/package.json b/package.json index 0a20f01d52..af7aac0025 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "esbuild-node-externals": "^1.8.0", "eslint": "^8.52.0", "eslint-plugin-import": "^2.29.0", + "eslint-plugin-jest": "^27.9.0", "eslint-plugin-local-rules": "^2.0.0", "eslint-plugin-svelte": "^2.34.0", "husky": "^8.0.3", diff --git a/packages/account-portal b/packages/account-portal index 0c050591c2..6465dc9c2a 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 0c050591c21d3b67dc0c9225d60cc9e2324c8dac +Subproject commit 6465dc9c2a38e1380b32204cad4ae0c1f33e065a diff --git a/packages/backend-core/scripts/test.sh b/packages/backend-core/scripts/test.sh index 7d19ec96cc..b9937e3a4a 100644 --- a/packages/backend-core/scripts/test.sh +++ b/packages/backend-core/scripts/test.sh @@ -4,10 +4,10 @@ set -e if [[ -n $CI ]] then # --runInBand performs better in ci where resources are limited - echo "jest --coverage --runInBand --forceExit" - jest --coverage --runInBand --forceExit + echo "jest --coverage --runInBand --forceExit $@" + jest --coverage --runInBand --forceExit $@ else # --maxWorkers performs better in development - echo "jest --coverage --detectOpenHandles" - jest --coverage --detectOpenHandles + echo "jest --coverage --forceExit --detectOpenHandles $@" + jest --coverage --forceExit --detectOpenHandles $@ fi \ No newline at end of file diff --git a/packages/backend-core/src/auth/auth.ts b/packages/backend-core/src/auth/auth.ts index 1951c7986c..87ac46cf1c 100644 --- a/packages/backend-core/src/auth/auth.ts +++ b/packages/backend-core/src/auth/auth.ts @@ -133,7 +133,7 @@ export async function refreshOAuthToken( configId?: string ): Promise { switch (providerType) { - case SSOProviderType.OIDC: + case SSOProviderType.OIDC: { if (!configId) { return { err: { data: "OIDC config id not provided" } } } @@ -142,12 +142,14 @@ export async function refreshOAuthToken( return { err: { data: "OIDC configuration not found" } } } return refreshOIDCAccessToken(oidcConfig, refreshToken) - case SSOProviderType.GOOGLE: + } + case SSOProviderType.GOOGLE: { let googleConfig = await configs.getGoogleConfig() if (!googleConfig) { return { err: { data: "Google configuration not found" } } } return refreshGoogleAccessToken(googleConfig, refreshToken) + } } } diff --git a/packages/backend-core/src/auth/tests/auth.spec.ts b/packages/backend-core/src/auth/tests/auth.spec.ts index 3ae691be58..a80e1ea739 100644 --- a/packages/backend-core/src/auth/tests/auth.spec.ts +++ b/packages/backend-core/src/auth/tests/auth.spec.ts @@ -8,7 +8,7 @@ describe("platformLogout", () => { await testEnv.withTenant(async () => { const ctx = structures.koa.newContext() await auth.platformLogout({ ctx, userId: "test" }) - expect(events.auth.logout).toBeCalledTimes(1) + expect(events.auth.logout).toHaveBeenCalledTimes(1) }) }) }) diff --git a/packages/backend-core/src/cache/docWritethrough.ts b/packages/backend-core/src/cache/docWritethrough.ts index 1b129bb26a..05f13a0d91 100644 --- a/packages/backend-core/src/cache/docWritethrough.ts +++ b/packages/backend-core/src/cache/docWritethrough.ts @@ -1,6 +1,6 @@ import { AnyDocument, Database } from "@budibase/types" -import { JobQueue, createQueue } from "../queue" +import { JobQueue, Queue, createQueue } from "../queue" import * as dbUtils from "../db" interface ProcessDocMessage { @@ -12,18 +12,26 @@ interface ProcessDocMessage { const PERSIST_MAX_ATTEMPTS = 100 let processor: DocWritethroughProcessor | undefined -export const docWritethroughProcessorQueue = createQueue( - JobQueue.DOC_WRITETHROUGH_QUEUE, - { - jobOptions: { - attempts: PERSIST_MAX_ATTEMPTS, - }, - } -) +export class DocWritethroughProcessor { + private static _queue: Queue + + public static get queue() { + if (!DocWritethroughProcessor._queue) { + DocWritethroughProcessor._queue = createQueue( + JobQueue.DOC_WRITETHROUGH_QUEUE, + { + jobOptions: { + attempts: PERSIST_MAX_ATTEMPTS, + }, + } + ) + } + + return DocWritethroughProcessor._queue + } -class DocWritethroughProcessor { init() { - docWritethroughProcessorQueue.process(async message => { + DocWritethroughProcessor.queue.process(async message => { try { await this.persistToDb(message.data) } catch (err: any) { @@ -76,7 +84,7 @@ export class DocWritethrough { } async patch(data: Record) { - await docWritethroughProcessorQueue.add({ + await DocWritethroughProcessor.queue.add({ dbName: this.db.name, docId: this.docId, data, diff --git a/packages/backend-core/src/cache/tests/docWritethrough.spec.ts b/packages/backend-core/src/cache/tests/docWritethrough.spec.ts index 5fe09b95ff..47b3f0672f 100644 --- a/packages/backend-core/src/cache/tests/docWritethrough.spec.ts +++ b/packages/backend-core/src/cache/tests/docWritethrough.spec.ts @@ -6,7 +6,7 @@ import { getDB } from "../../db" import { DocWritethrough, - docWritethroughProcessorQueue, + DocWritethroughProcessor, init, } from "../docWritethrough" @@ -15,7 +15,7 @@ import InMemoryQueue from "../../queue/inMemoryQueue" const initialTime = Date.now() async function waitForQueueCompletion() { - const queue: InMemoryQueue = docWritethroughProcessorQueue as never + const queue: InMemoryQueue = DocWritethroughProcessor.queue as never await queue.waitForCompletion() } @@ -32,7 +32,7 @@ describe("docWritethrough", () => { describe("patch", () => { function generatePatchObject(fieldCount: number) { - const keys = generator.unique(() => generator.word(), fieldCount) + const keys = generator.unique(() => generator.guid(), fieldCount) return keys.reduce((acc, c) => { acc[c] = generator.word() return acc @@ -235,11 +235,11 @@ describe("docWritethrough", () => { return acc }, {}) } - const queueMessageSpy = jest.spyOn(docWritethroughProcessorQueue, "add") + const queueMessageSpy = jest.spyOn(DocWritethroughProcessor.queue, "add") await config.doInTenant(async () => { let patches = await parallelPatch(5) - expect(queueMessageSpy).toBeCalledTimes(5) + expect(queueMessageSpy).toHaveBeenCalledTimes(5) await waitForQueueCompletion() expect(await db.get(documentId)).toEqual( @@ -247,7 +247,7 @@ describe("docWritethrough", () => { ) patches = { ...patches, ...(await parallelPatch(40)) } - expect(queueMessageSpy).toBeCalledTimes(45) + expect(queueMessageSpy).toHaveBeenCalledTimes(45) await waitForQueueCompletion() expect(await db.get(documentId)).toEqual( @@ -255,7 +255,7 @@ describe("docWritethrough", () => { ) patches = { ...patches, ...(await parallelPatch(10)) } - expect(queueMessageSpy).toBeCalledTimes(55) + expect(queueMessageSpy).toHaveBeenCalledTimes(55) await waitForQueueCompletion() expect(await db.get(documentId)).toEqual( @@ -265,6 +265,7 @@ describe("docWritethrough", () => { }) // This is not yet supported + // eslint-disable-next-line jest/no-disabled-tests it.skip("patches will execute in order", async () => { let incrementalValue = 0 const keyToOverride = generator.word() diff --git a/packages/backend-core/src/cache/tests/user.spec.ts b/packages/backend-core/src/cache/tests/user.spec.ts index 80e5bc3063..49a8d51c16 100644 --- a/packages/backend-core/src/cache/tests/user.spec.ts +++ b/packages/backend-core/src/cache/tests/user.spec.ts @@ -55,8 +55,8 @@ describe("user cache", () => { })), }) - expect(UserDB.bulkGet).toBeCalledTimes(1) - expect(UserDB.bulkGet).toBeCalledWith(userIdsToRequest) + expect(UserDB.bulkGet).toHaveBeenCalledTimes(1) + expect(UserDB.bulkGet).toHaveBeenCalledWith(userIdsToRequest) }) it("on a second all, all of them are retrieved from cache", async () => { @@ -82,7 +82,7 @@ describe("user cache", () => { ), }) - expect(UserDB.bulkGet).toBeCalledTimes(1) + expect(UserDB.bulkGet).toHaveBeenCalledTimes(1) }) it("when some users are cached, only the missing ones are retrieved from db", async () => { @@ -110,8 +110,8 @@ describe("user cache", () => { ), }) - expect(UserDB.bulkGet).toBeCalledTimes(1) - expect(UserDB.bulkGet).toBeCalledWith([ + expect(UserDB.bulkGet).toHaveBeenCalledTimes(1) + expect(UserDB.bulkGet).toHaveBeenCalledWith([ userIdsToRequest[1], userIdsToRequest[2], userIdsToRequest[4], diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index ae86695168..6cea7efeba 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -10,7 +10,7 @@ import { StaticDatabases, DEFAULT_TENANT_ID, } from "../constants" -import { Database, IdentityContext } from "@budibase/types" +import { Database, IdentityContext, Snippet, App } from "@budibase/types" import { ContextMap } from "./types" let TEST_APP_ID: string | null = null @@ -122,10 +122,10 @@ export async function doInAutomationContext(params: { automationId: string task: () => T }): Promise { - const tenantId = getTenantIDFromAppID(params.appId) + await ensureSnippetContext() return newContext( { - tenantId, + tenantId: getTenantIDFromAppID(params.appId), appId: params.appId, automationId: params.automationId, }, @@ -281,6 +281,27 @@ export function doInScimContext(task: any) { return newContext(updates, task) } +export async function ensureSnippetContext() { + const ctx = getCurrentContext() + + // If we've already added snippets to context, continue + if (!ctx || ctx.snippets) { + return + } + + // Otherwise get snippets for this app and update context + let snippets: Snippet[] | undefined + const db = getAppDB() + if (db && !env.isTest()) { + const app = await db.get(DocumentType.APP_METADATA) + snippets = app.snippets + } + + // Always set snippets to a non-null value so that we can tell we've attempted + // to load snippets + ctx.snippets = snippets || [] +} + export function getEnvironmentVariables() { const context = Context.get() if (!context.environmentVariables) { diff --git a/packages/backend-core/src/context/tests/index.spec.ts b/packages/backend-core/src/context/tests/index.spec.ts index cfc820e169..2d89131549 100644 --- a/packages/backend-core/src/context/tests/index.spec.ts +++ b/packages/backend-core/src/context/tests/index.spec.ts @@ -246,7 +246,7 @@ describe("context", () => { context.doInAppMigrationContext(db.generateAppID(), async () => { await otherContextCall() }) - ).rejects.toThrowError( + ).rejects.toThrow( "The context cannot be changed, a migration is currently running" ) } diff --git a/packages/backend-core/src/context/types.ts b/packages/backend-core/src/context/types.ts index 6fb9f44fad..f297d3089f 100644 --- a/packages/backend-core/src/context/types.ts +++ b/packages/backend-core/src/context/types.ts @@ -1,5 +1,4 @@ -import { IdentityContext, VM } from "@budibase/types" -import { ExecutionTimeTracker } from "../timers" +import { IdentityContext, Snippet, VM } from "@budibase/types" // keep this out of Budibase types, don't want to expose context info export type ContextMap = { @@ -10,6 +9,7 @@ export type ContextMap = { isScim?: boolean automationId?: string isMigrating?: boolean - jsExecutionTracker?: ExecutionTimeTracker vm?: VM + cleanup?: (() => void | Promise)[] + snippets?: Snippet[] } diff --git a/packages/backend-core/src/db/lucene.ts b/packages/backend-core/src/db/lucene.ts index f982ee67d0..7055ec8031 100644 --- a/packages/backend-core/src/db/lucene.ts +++ b/packages/backend-core/src/db/lucene.ts @@ -247,7 +247,7 @@ export class QueryBuilder { } // Escape characters if (!this.#noEscaping && escape && originalType === "string") { - value = `${value}`.replace(/[ \/#+\-&|!(){}\]^"~*?:\\]/g, "\\$&") + value = `${value}`.replace(/[ /#+\-&|!(){}\]^"~*?:\\]/g, "\\$&") } // Wrap in quotes diff --git a/packages/backend-core/src/db/searchIndexes/searchIndexes.ts b/packages/backend-core/src/db/searchIndexes/searchIndexes.ts index b953e3516e..8742d405f2 100644 --- a/packages/backend-core/src/db/searchIndexes/searchIndexes.ts +++ b/packages/backend-core/src/db/searchIndexes/searchIndexes.ts @@ -34,12 +34,12 @@ export async function createUserIndex() { } let idxKey = prev != null ? `${prev}.${key}` : key if (typeof input[key] === "string") { + // @ts-expect-error index is available in a CouchDB map function // eslint-disable-next-line no-undef - // @ts-ignore index(idxKey, input[key].toLowerCase(), { facet: true }) } else if (typeof input[key] !== "object") { + // @ts-expect-error index is available in a CouchDB map function // eslint-disable-next-line no-undef - // @ts-ignore index(idxKey, input[key], { facet: true }) } else { idx(input[key], idxKey) diff --git a/packages/backend-core/src/events/publishers/app.ts b/packages/backend-core/src/events/publishers/app.ts index d08d59b5f1..af26b09e72 100644 --- a/packages/backend-core/src/events/publishers/app.ts +++ b/packages/backend-core/src/events/publishers/app.ts @@ -13,6 +13,7 @@ import { AppVersionRevertedEvent, AppRevertedEvent, AppExportedEvent, + AppDuplicatedEvent, } from "@budibase/types" const created = async (app: App, timestamp?: string | number) => { @@ -77,6 +78,17 @@ async function fileImported(app: App) { await publishEvent(Event.APP_FILE_IMPORTED, properties) } +async function duplicated(app: App, duplicateAppId: string) { + const properties: AppDuplicatedEvent = { + duplicateAppId, + appId: app.appId, + audited: { + name: app.name, + }, + } + await publishEvent(Event.APP_DUPLICATED, properties) +} + async function templateImported(app: App, templateKey: string) { const properties: AppTemplateImportedEvent = { appId: app.appId, @@ -147,6 +159,7 @@ export default { published, unpublished, fileImported, + duplicated, templateImported, versionUpdated, versionReverted, diff --git a/packages/backend-core/src/logging/pino/logger.ts b/packages/backend-core/src/logging/pino/logger.ts index 7a051e7f12..0a8470a453 100644 --- a/packages/backend-core/src/logging/pino/logger.ts +++ b/packages/backend-core/src/logging/pino/logger.ts @@ -10,6 +10,18 @@ import { formats } from "dd-trace/ext" import { localFileDestination } from "../system" +function isPlainObject(obj: any) { + return typeof obj === "object" && obj !== null && !(obj instanceof Error) +} + +function isError(obj: any) { + return obj instanceof Error +} + +function isMessage(obj: any) { + return typeof obj === "string" +} + // LOGGER let pinoInstance: pino.Logger | undefined @@ -71,23 +83,11 @@ if (!env.DISABLE_PINO_LOGGER) { err?: Error } - function isPlainObject(obj: any) { - return typeof obj === "object" && obj !== null && !(obj instanceof Error) - } - - function isError(obj: any) { - return obj instanceof Error - } - - function isMessage(obj: any) { - return typeof obj === "string" - } - /** * Backwards compatibility between console logging statements * and pino logging requirements. */ - function getLogParams(args: any[]): [MergingObject, string] { + const getLogParams = (args: any[]): [MergingObject, string] => { let error = undefined let objects: any[] = [] let message = "" diff --git a/packages/backend-core/src/middleware/matchers.ts b/packages/backend-core/src/middleware/matchers.ts index efbdec2dbe..8bede1cc6a 100644 --- a/packages/backend-core/src/middleware/matchers.ts +++ b/packages/backend-core/src/middleware/matchers.ts @@ -11,7 +11,6 @@ export const buildMatcherRegex = ( return patterns.map(pattern => { let route = pattern.route const method = pattern.method - const strict = pattern.strict ? pattern.strict : false // if there is a param in the route // use a wildcard pattern @@ -24,24 +23,17 @@ export const buildMatcherRegex = ( } } - return { regex: new RegExp(route), method, strict, route } + return { regex: new RegExp(route), method, route } }) } export const matches = (ctx: BBContext, options: RegexMatcher[]) => { - return options.find(({ regex, method, strict, route }) => { - let urlMatch - if (strict) { - urlMatch = ctx.request.url === route - } else { - urlMatch = regex.test(ctx.request.url) - } - + return options.find(({ regex, method, route }) => { + const urlMatch = regex.test(ctx.request.url) const methodMatch = method === "ALL" ? true : ctx.request.method.toLowerCase() === method.toLowerCase() - return urlMatch && methodMatch }) } diff --git a/packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts b/packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts index d3486a5b14..ea9584c284 100644 --- a/packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts +++ b/packages/backend-core/src/middleware/passport/sso/tests/sso.spec.ts @@ -114,11 +114,11 @@ describe("sso", () => { // tenant id added ssoUser.tenantId = context.getTenantId() - expect(mockSaveUser).toBeCalledWith(ssoUser, { + expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, { hashPassword: false, requirePassword: false, }) - expect(mockDone).toBeCalledWith(null, ssoUser) + expect(mockDone).toHaveBeenCalledWith(null, ssoUser) }) }) }) @@ -159,11 +159,11 @@ describe("sso", () => { // existing id preserved ssoUser._id = existingUser._id - expect(mockSaveUser).toBeCalledWith(ssoUser, { + expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, { hashPassword: false, requirePassword: false, }) - expect(mockDone).toBeCalledWith(null, ssoUser) + expect(mockDone).toHaveBeenCalledWith(null, ssoUser) }) }) @@ -187,11 +187,11 @@ describe("sso", () => { // existing id preserved ssoUser._id = existingUser._id - expect(mockSaveUser).toBeCalledWith(ssoUser, { + expect(mockSaveUser).toHaveBeenCalledWith(ssoUser, { hashPassword: false, requirePassword: false, }) - expect(mockDone).toBeCalledWith(null, ssoUser) + expect(mockDone).toHaveBeenCalledWith(null, ssoUser) }) }) }) diff --git a/packages/backend-core/src/middleware/tests/builder.spec.ts b/packages/backend-core/src/middleware/tests/builder.spec.ts index 0514dc13f0..0f35b0b833 100644 --- a/packages/backend-core/src/middleware/tests/builder.spec.ts +++ b/packages/backend-core/src/middleware/tests/builder.spec.ts @@ -24,13 +24,13 @@ function buildUserCtx(user: ContextUser) { } function passed(throwFn: jest.Func, nextFn: jest.Func) { - expect(throwFn).not.toBeCalled() - expect(nextFn).toBeCalled() + expect(throwFn).not.toHaveBeenCalled() + expect(nextFn).toHaveBeenCalled() } function threw(throwFn: jest.Func) { // cant check next, the throw function doesn't actually throw - so it still continues - expect(throwFn).toBeCalled() + expect(throwFn).toHaveBeenCalled() } describe("adminOnly middleware", () => { diff --git a/packages/backend-core/src/middleware/tests/matchers.spec.ts b/packages/backend-core/src/middleware/tests/matchers.spec.ts index c39bbb6dd3..1b79db2e68 100644 --- a/packages/backend-core/src/middleware/tests/matchers.spec.ts +++ b/packages/backend-core/src/middleware/tests/matchers.spec.ts @@ -34,23 +34,6 @@ describe("matchers", () => { expect(!!matchers.matches(ctx, built)).toBe(true) }) - it("doesn't wildcard path with strict", () => { - const pattern = [ - { - route: "/api/tests", - method: "POST", - strict: true, - }, - ] - const ctx = structures.koa.newContext() - ctx.request.url = "/api/tests/id/something/else" - ctx.request.method = "POST" - - const built = matchers.buildMatcherRegex(pattern) - - expect(!!matchers.matches(ctx, built)).toBe(false) - }) - it("matches with param", () => { const pattern = [ { @@ -67,23 +50,6 @@ describe("matchers", () => { expect(!!matchers.matches(ctx, built)).toBe(true) }) - // TODO: Support the below behaviour - // Strict does not work when a param is present - // it("matches with param with strict", () => { - // const pattern = [{ - // route: "/api/tests/:testId", - // method: "GET", - // strict: true - // }] - // const ctx = structures.koa.newContext() - // ctx.request.url = "/api/tests/id" - // ctx.request.method = "GET" - // - // const built = matchers.buildMatcherRegex(pattern) - // - // expect(!!matchers.matches(ctx, built)).toBe(true) - // }) - it("doesn't match by path", () => { const pattern = [ { diff --git a/packages/backend-core/src/queue/inMemoryQueue.ts b/packages/backend-core/src/queue/inMemoryQueue.ts index afb5592562..f10194b8cf 100644 --- a/packages/backend-core/src/queue/inMemoryQueue.ts +++ b/packages/backend-core/src/queue/inMemoryQueue.ts @@ -39,7 +39,7 @@ class InMemoryQueue implements Partial { _opts?: QueueOptions _messages: JobMessage[] _queuedJobIds: Set - _emitter: EventEmitter + _emitter: NodeJS.EventEmitter _runCount: number _addCount: number diff --git a/packages/backend-core/src/redis/tests/redis.spec.ts b/packages/backend-core/src/redis/tests/redis.spec.ts index c2c9e4a14e..e2076ad698 100644 --- a/packages/backend-core/src/redis/tests/redis.spec.ts +++ b/packages/backend-core/src/redis/tests/redis.spec.ts @@ -147,17 +147,6 @@ describe("redis", () => { expect(results).toEqual([1, 2, 3, 4, 5]) }) - it("can increment on a new key", async () => { - const key1 = structures.uuid() - const key2 = structures.uuid() - - const result1 = await redis.increment(key1) - expect(result1).toBe(1) - - const result2 = await redis.increment(key2) - expect(result2).toBe(1) - }) - it("can increment multiple times in parallel", async () => { const key = structures.uuid() const results = await Promise.all( @@ -184,7 +173,7 @@ describe("redis", () => { const key = structures.uuid() await redis.store(key, value) - await expect(redis.increment(key)).rejects.toThrowError( + await expect(redis.increment(key)).rejects.toThrow( "ERR value is not an integer or out of range" ) }) diff --git a/packages/backend-core/src/redis/tests/redlockImpl.spec.ts b/packages/backend-core/src/redis/tests/redlockImpl.spec.ts index a1e83d8e6c..e647b63bf5 100644 --- a/packages/backend-core/src/redis/tests/redlockImpl.spec.ts +++ b/packages/backend-core/src/redis/tests/redlockImpl.spec.ts @@ -96,8 +96,8 @@ describe("redlockImpl", () => { task: mockTask, executionTimeMs: lockTtl * 2, }) - ).rejects.toThrowError( - `Unable to fully release the lock on resource \"lock:${config.tenantId}_persist_writethrough\".` + ).rejects.toThrow( + `Unable to fully release the lock on resource "lock:${config.tenantId}_persist_writethrough".` ) } ) diff --git a/packages/backend-core/src/tenancy/tests/tenancy.spec.ts b/packages/backend-core/src/tenancy/tests/tenancy.spec.ts index 95dd76a6dd..34e9f87064 100644 --- a/packages/backend-core/src/tenancy/tests/tenancy.spec.ts +++ b/packages/backend-core/src/tenancy/tests/tenancy.spec.ts @@ -158,8 +158,8 @@ describe("getTenantIDFromCtx", () => { ], } expect(getTenantIDFromCtx(ctx, mockOpts)).toBeUndefined() - expect(ctx.throw).toBeCalledTimes(1) - expect(ctx.throw).toBeCalledWith(403, "Tenant id not set") + expect(ctx.throw).toHaveBeenCalledTimes(1) + expect(ctx.throw).toHaveBeenCalledWith(403, "Tenant id not set") }) it("returns undefined if allowNoTenant is true", () => { diff --git a/packages/backend-core/src/timers/timers.ts b/packages/backend-core/src/timers/timers.ts index 9121c576cd..000be74821 100644 --- a/packages/backend-core/src/timers/timers.ts +++ b/packages/backend-core/src/timers/timers.ts @@ -20,41 +20,3 @@ export function cleanup() { } intervals = [] } - -export class ExecutionTimeoutError extends Error { - public readonly name = "ExecutionTimeoutError" -} - -export class ExecutionTimeTracker { - static withLimit(limitMs: number) { - return new ExecutionTimeTracker(limitMs) - } - - constructor(readonly limitMs: number) {} - - private totalTimeMs = 0 - - track(f: () => T): T { - this.checkLimit() - const start = process.hrtime.bigint() - try { - return f() - } finally { - const end = process.hrtime.bigint() - this.totalTimeMs += Number(end - start) / 1e6 - this.checkLimit() - } - } - - get elapsedMS() { - return this.totalTimeMs - } - - checkLimit() { - if (this.totalTimeMs > this.limitMs) { - throw new ExecutionTimeoutError( - `Execution time limit of ${this.limitMs}ms exceeded: ${this.totalTimeMs}ms` - ) - } - } -} diff --git a/packages/backend-core/tests/core/utilities/mocks/events.ts b/packages/backend-core/tests/core/utilities/mocks/events.ts index fef730768a..96f351de10 100644 --- a/packages/backend-core/tests/core/utilities/mocks/events.ts +++ b/packages/backend-core/tests/core/utilities/mocks/events.ts @@ -15,6 +15,7 @@ beforeAll(async () => { jest.spyOn(events.app, "created") jest.spyOn(events.app, "updated") + jest.spyOn(events.app, "duplicated") jest.spyOn(events.app, "deleted") jest.spyOn(events.app, "published") jest.spyOn(events.app, "unpublished") diff --git a/packages/backend-core/tests/core/utilities/structures/userGroups.ts b/packages/backend-core/tests/core/utilities/structures/userGroups.ts index 4dc870a00a..4af3f72e51 100644 --- a/packages/backend-core/tests/core/utilities/structures/userGroups.ts +++ b/packages/backend-core/tests/core/utilities/structures/userGroups.ts @@ -3,7 +3,7 @@ import { generator } from "./generator" export function userGroup(): UserGroup { return { - name: generator.word(), + name: generator.guid(), icon: generator.word(), color: generator.word(), } diff --git a/packages/bbui/src/ActionMenu/ActionMenu.svelte b/packages/bbui/src/ActionMenu/ActionMenu.svelte index 642ec4932a..c55d1cb43d 100644 --- a/packages/bbui/src/ActionMenu/ActionMenu.svelte +++ b/packages/bbui/src/ActionMenu/ActionMenu.svelte @@ -38,7 +38,7 @@
- + diff --git a/packages/bbui/src/Actions/click_outside.js b/packages/bbui/src/Actions/click_outside.js index 1961dca47c..12c4c4d002 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -32,6 +32,13 @@ const handleClick = event => { return } + // Ignore clicks for drawers, unless the handler is registered from a drawer + const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null + const clickInDrawer = event.target.closest(".drawer-wrapper") != null + if (clickInDrawer && !sourceInDrawer) { + return + } + handler.callback?.(event) }) } diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index d259b9197a..770d1bd507 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -15,6 +15,7 @@ export default function positionDropdown(element, opts) { align, maxHeight, maxWidth, + minWidth, useAnchorWidth, offset = 5, customUpdate, @@ -28,7 +29,7 @@ export default function positionDropdown(element, opts) { const elementBounds = element.getBoundingClientRect() let styles = { maxHeight: null, - minWidth: null, + minWidth, maxWidth, left: null, top: null, @@ -41,8 +42,13 @@ export default function positionDropdown(element, opts) { }) } else { // Determine vertical styles - if (align === "right-outside") { - styles.top = anchorBounds.top + if (align === "right-outside" || align === "left-outside") { + styles.top = + anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2 + styles.maxHeight = maxHeight + if (styles.top + elementBounds.height > window.innerHeight) { + styles.top = window.innerHeight - elementBounds.height + } } else if ( window.innerHeight - anchorBounds.bottom < (maxHeight || 100) diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 8976bfb81e..89ee92726d 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -1,28 +1,111 @@ + + {#if visible} - -
- {#if !headless} + + +
+
+
0} + class:modal={$modal} + transition:drawerSlide|local + {style} + >
-
- {title} - - - -
+ {#if $$slots.title} + + {:else} +
{title || "Bindings"}
+ {/if}
+ {#if $resizable} + modal.set(!$modal)} + > + + + {/if}
- {/if} - -
+ +
+
+
{/if} diff --git a/packages/bbui/src/Drawer/DrawerContent.svelte b/packages/bbui/src/Drawer/DrawerContent.svelte index 944a3f4313..490dfecc31 100644 --- a/packages/bbui/src/Drawer/DrawerContent.svelte +++ b/packages/bbui/src/Drawer/DrawerContent.svelte @@ -1,4 +1,8 @@ -
+ + +
{#if $$slots.sidebar}
- +
diff --git a/packages/builder/src/components/common/NavItem.svelte b/packages/builder/src/components/common/NavItem.svelte index 0819bcb86b..66b21e95a1 100644 --- a/packages/builder/src/components/common/NavItem.svelte +++ b/packages/builder/src/components/common/NavItem.svelte @@ -8,6 +8,7 @@ export let iconTooltip export let withArrow = false export let withActions = true + export let showActions = false export let indentLevel = 0 export let text export let border = true @@ -68,6 +69,8 @@ class:border class:selected class:withActions + class:showActions + class:actionsOpen={highlighted && withActions} class:scrollable class:highlighted class:selectedBy @@ -168,8 +171,10 @@ --avatars-background: var(--spectrum-global-color-gray-300); } .nav-item:hover .actions, - .hovering .actions { - visibility: visible; + .hovering .actions, + .nav-item.withActions.actionsOpen .actions, + .nav-item.withActions.showActions .actions { + opacity: 1; } .nav-item-content { flex: 1 1 auto; @@ -272,7 +277,6 @@ position: relative; display: grid; place-items: center; - visibility: hidden; order: 3; opacity: 0; width: 20px; diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 7b567e052b..21d389357f 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -1,74 +1,194 @@ - - + +
- { - if (selectedMode == mode) { - return true - } - - //Get the current mode value - const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue - - if (editorValue) { - targetMode = selectedMode - return false - } - return true - }} - > - -
-
-
- {#if targetMode} -
-
- - {`Switch to ${targetMode}?`} - - This will discard anything in your binding -
- - -
-
-
- {/if} - -
- -
- - {#if sidebar} -
- -
- {/if} + {#if showTabBar} +
+
+ {#each editorModeOptions as editorMode} + changeMode(editorMode)} + > + {capitalise(editorMode)} + + {/each} +
+
+ {#each sidePanelOptions as panel} + changeSidePanel(panel)} + > + + + {/each}
- - {#if allowJS} - -
-
-
- {#if targetMode} -
-
- - {`Switch to ${targetMode}?`} - - This will discard anything in your binding -
- - -
-
-
- {/if} - - -
- -
- - {#if sidebar} -
- -
- {/if} -
-
- {/if} -
- {#if typeof drawerActions?.hide === "function" && drawerActions?.headless} - - {/if} - {#if typeof bindingDrawerActions?.save === "function" && drawerActions?.headless} - - {/if}
- + {/if} +
+ {#if mode === Modes.Text} + {#key hbsCompletions} + + {/key} + {:else if mode === Modes.JavaScript} + {#key jsCompletions} + + {/key} + {/if} + {#if targetMode} +
+
+ + Switch to {targetMode}? + + This will discard anything in your binding +
+ + +
+
+
+ {/if} +
- - +
+ {#if sidePanel === SidePanels.Bindings} + + {:else if sidePanel === SidePanels.Evaluation} + + {:else if sidePanel === SidePanels.Snippets} + bindingHelpers.onSelectSnippet(snippet)} + {snippets} + /> + {/if} +
+
+ diff --git a/packages/builder/src/components/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingPicker.svelte deleted file mode 100644 index 42057b382b..0000000000 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ /dev/null @@ -1,399 +0,0 @@ - - - - - -
- {#if hoverTarget.title} -
{hoverTarget.title}
- {/if} - {#if hoverTarget.description} -
- - {@html hoverTarget.description} -
- {/if} - {#if hoverTarget.example} -
{hoverTarget.example}
- {/if} -
-
-
-
- - - - - - {#if selectedCategory} -
- { - selectedCategory = null - }} - > - Back - -
- {/if} - - {#if !selectedCategory} - - {/if} - - {#if !selectedCategory && !search} -
    - {#each categoryNames as categoryName} -
  • { - selectedCategory = categoryName - }} - > - - {categoryName} - -
  • - {/each} -
- {/if} - - {#if selectedCategory || search} - {#each filteredCategories as category} - {#if category.bindings?.length} -
-
- {category.name} -
-
    - {#each category.bindings as binding} -
  • { - popoverAnchor = e.target - if (!binding.description) { - return - } - hoverTarget = { - title: binding.display?.name || binding.fieldSchema?.name, - description: binding.description, - } - popover.show() - e.stopPropagation() - }} - on:mouseleave={() => { - popover.hide() - popoverAnchor = null - hoverTarget = null - }} - on:focus={() => {}} - on:blur={() => {}} - on:click={() => addBinding(binding)} - > - - {#if binding.display?.name} - {binding.display.name} - {:else if binding.fieldSchema?.name} - {binding.fieldSchema?.name} - {:else} - {binding.readableBinding} - {/if} - - - {#if binding.display?.type || binding.fieldSchema?.type} - - - {binding.display?.type || binding.fieldSchema?.type} - - - {/if} -
  • - {/each} -
-
- {/if} - {/each} - - {#if selectedCategory === "Helpers" || search} - {#if filteredHelpers?.length} -
-
Helpers
-
    - {#each filteredHelpers as helper} -
  • addHelper(helper, mode.name == "javascript")} - on:mouseenter={e => { - popoverAnchor = e.target - if (!helper.displayText && helper.description) { - return - } - hoverTarget = { - title: helper.displayText, - description: helper.description, - example: getHelperExample( - helper, - mode.name == "javascript" - ), - } - popover.show() - e.stopPropagation() - }} - on:mouseleave={() => { - popover.hide() - popoverAnchor = null - hoverTarget = null - }} - on:focus={() => {}} - on:blur={() => {}} - > - {helper.displayText} - - function - -
  • - {/each} -
-
- {/if} - {/if} - {/if} -
- - diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte new file mode 100644 index 0000000000..6ef2d35a6c --- /dev/null +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -0,0 +1,446 @@ + + + +
+ {#if hoverTarget.description} +
+ + {@html hoverTarget.description} +
+ {/if} + {#if hoverTarget.code} + +
{@html hoverTarget.code}
+ {/if} +
+
+ + + +
+ + {#if selectedCategory} +
+ (selectedCategory = null)} + /> + {selectedCategory} +
+ {/if} + + {#if !selectedCategory} +
+ {#if searching} +
+ +
+ + {:else} +
Bindings
+ + {/if} +
+ {/if} + {#if !selectedCategory && !search} +
    + {#each categoryNames as categoryName} +
  • { + selectedCategory = categoryName + }} + > + + {categoryName} + +
  • + {/each} +
+ {/if} + + {#if selectedCategory || search} + {#each filteredCategories as category} + {#if category.bindings?.length} +
+ {#if filteredCategories.length > 1} +
+ {category.name} +
+ {/if} +
    + {#each category.bindings as binding} +
  • showBindingPopover(binding, e.target)} + on:mouseleave={hidePopover} + on:click={() => addBinding(binding)} + > + + {#if binding.display?.name} + {binding.display.name} + {:else if binding.fieldSchema?.name} + {binding.fieldSchema?.name} + {:else} + {binding.readableBinding} + {/if} + + {#if binding.display?.type || binding.fieldSchema?.type} + + + {binding.display?.type || binding.fieldSchema?.type} + + + {/if} +
  • + {/each} +
+
+ {/if} + {/each} + + {#if selectedCategory === "Helpers" || search} + {#if filteredHelpers?.length} +
+
    + {#each filteredHelpers as helper} +
  • showHelperPopover(helper, e.target)} + on:mouseleave={hidePopover} + on:click={() => addHelper(helper, mode.name === "javascript")} + > + {helper.displayText} + + function + +
  • + {/each} +
+
+ {/if} + {/if} + {/if} +
+
+ + diff --git a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte index 7888f2e486..cb65d2bbe4 100644 --- a/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ClientBindingPanel.svelte @@ -1,8 +1,9 @@ - - - Add the objects on the left to enrich your text. - - - + + (tempValue = event.detail)} {bindings} {allowJS} diff --git a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte index 80996a484d..d11ebcf87a 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte @@ -13,21 +13,21 @@ export let panel = ClientBindingPanel export let value = "" export let bindings = [] - export let title = "Bindings" + export let title export let placeholder export let label export let disabled = false - export let fillWidth export let allowJS = true export let allowHelpers = true export let updateOnChange = true - export let drawerLeft export let key export let disableBindings = false + export let forceModal = false + export let context = null const dispatch = createEventDispatcher() + let bindingDrawer - let valid = true let currentVal = value $: readableValue = runtimeToReadableBinding(bindings, value) @@ -88,27 +88,20 @@ - - Add the objects on the left to enrich your text. - - + (tempValue = event.detail)} {bindings} {allowJS} {allowHelpers} + {context} /> diff --git a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte index 4cac24660f..8ce9dda209 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte @@ -16,7 +16,6 @@ export let placeholder export let label export let disabled = false - export let fillWidth export let allowJS = true export let allowHelpers = true export let updateOnChange = true @@ -26,7 +25,6 @@ const dispatch = createEventDispatcher() let bindingDrawer - let valid = true let currentVal = value $: readableValue = runtimeToReadableBinding(bindings, value) @@ -173,22 +171,14 @@ - - Add the objects on the left to enrich your text. - - + (tempValue = event.detail)} {bindings} diff --git a/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte b/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte new file mode 100644 index 0000000000..2c4e6a0991 --- /dev/null +++ b/packages/builder/src/components/common/bindings/EvaluationSidePanel.svelte @@ -0,0 +1,133 @@ + + +
+
+
+ {#if error} + +
Error
+ {#if evaluating} +
+ +
+ {/if} + + + {:else} +
Preview
+ {#if evaluating} +
+ +
+ {/if} + + {#if !empty} + + {/if} + {/if} +
+
+
+ {#if empty} + Your expression will be evaluated here + {:else} + + {@html highlightedResult} + {/if} +
+
+ + diff --git a/packages/builder/src/components/common/bindings/ModalBindableInput.svelte b/packages/builder/src/components/common/bindings/ModalBindableInput.svelte index 41245ceaec..3261dc6b74 100644 --- a/packages/builder/src/components/common/bindings/ModalBindableInput.svelte +++ b/packages/builder/src/components/common/bindings/ModalBindableInput.svelte @@ -1,115 +1,12 @@ - - -
- onChange(event.detail)} - {placeholder} - {updateOnChange} - /> -
- -
-
- - - - Add the objects on the left to enrich your text. - -
- (tempValue = e.detail)} - {bindings} - {allowJS} - /> -
-
-
- - + diff --git a/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte b/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte index 213e5bbf1d..df0da768e0 100644 --- a/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/ServerBindingPanel.svelte @@ -1,10 +1,11 @@ diff --git a/packages/builder/src/components/common/bindings/SnippetDrawer.svelte b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte new file mode 100644 index 0000000000..d6b6f92b17 --- /dev/null +++ b/packages/builder/src/components/common/bindings/SnippetDrawer.svelte @@ -0,0 +1,160 @@ + + + + + {#if snippet} + {snippet.name} + {:else} +
+ Name + + {#if nameError} + + + + {/if} +
+ {/if} +
+ + {#if snippet} + + {/if} + + + + {#key key} + (code = e.detail)} + > +
+ +
+
+ {/key} +
+
+ + + + diff --git a/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte new file mode 100644 index 0000000000..c68699fc0f --- /dev/null +++ b/packages/builder/src/components/common/bindings/SnippetSidePanel.svelte @@ -0,0 +1,278 @@ + + + + +
+ +
+ {#if enableSnippets} + {#if searching} +
+ +
+ + {:else} +
Snippets
+ + + {/if} + {:else} +
+ Snippets + + Premium + +
+ {/if} +
+
+ {#if enableSnippets && filteredSnippets?.length} + {#each filteredSnippets as snippet} +
showSnippet(snippet, e.target)} + on:mouseleave={hidePopover} + on:click={() => addSnippet(snippet)} + > + {snippet.name} + editSnippet(e, snippet)} + /> +
+ {/each} + {:else} +
+ + Snippets let you create reusable JS functions and values that can + all be managed in one place + + {#if enableSnippets} + + {:else} + + {/if} +
+ {/if} +
+
+
+ + +
+ {#key hoveredSnippet} + + {/key} +
+
+ + + + diff --git a/packages/builder/src/components/common/bindings/utils.js b/packages/builder/src/components/common/bindings/utils.js index a086cd0394..c60374f0f7 100644 --- a/packages/builder/src/components/common/bindings/utils.js +++ b/packages/builder/src/components/common/bindings/utils.js @@ -38,4 +38,11 @@ export class BindingHelpers { this.insertAtPos({ start, end, value: insertVal }) } } + + // Adds a snippet to the expression + onSelectSnippet(snippet) { + const pos = this.getCaretPosition() + const { start, end } = pos + this.insertAtPos({ start, end, value: `snippets.${snippet.name}` }) + } } diff --git a/packages/builder/src/components/deploy/AppActions.svelte b/packages/builder/src/components/deploy/AppActions.svelte index 72622d0a86..1fa4fc7cd6 100644 --- a/packages/builder/src/components/deploy/AppActions.svelte +++ b/packages/builder/src/components/deploy/AppActions.svelte @@ -19,7 +19,7 @@ import ConfirmDialog from "components/common/ConfirmDialog.svelte" import analytics, { Events, EventSource } from "analytics" import { API } from "api" - import { apps } from "stores/portal" + import { appsStore } from "stores/portal" import { previewStore, builderStore, @@ -46,7 +46,7 @@ let publishing = false let lastOpened - $: filteredApps = $apps.filter(app => app.devId === application) + $: filteredApps = $appsStore.apps.filter(app => app.devId === application) $: selectedApp = filteredApps?.length ? filteredApps[0] : null $: latestDeployments = $deploymentStore .filter(deployment => deployment.status === "SUCCESS") @@ -130,7 +130,7 @@ } try { await API.unpublishApp(selectedApp.prodId) - await apps.load() + await appsStore.load() notifications.send("App unpublished", { type: "success", icon: "GlobeStrike", @@ -142,7 +142,7 @@ const completePublish = async () => { try { - await apps.load() + await appsStore.load() await deploymentStore.load() } catch (err) { notifications.error("Error refreshing app") diff --git a/packages/builder/src/components/deploy/DeleteModal.svelte b/packages/builder/src/components/deploy/DeleteModal.svelte index 855f6a0757..75204b55ce 100644 --- a/packages/builder/src/components/deploy/DeleteModal.svelte +++ b/packages/builder/src/components/deploy/DeleteModal.svelte @@ -2,10 +2,17 @@ import { Input, notifications } from "@budibase/bbui" import { goto } from "@roxi/routify" import ConfirmDialog from "components/common/ConfirmDialog.svelte" - import { apps } from "stores/portal" - import { appStore } from "stores/builder" + import { appsStore } from "stores/portal" import { API } from "api" + export let appId + export let appName + export let onDeleteSuccess = () => { + $goto("/builder") + } + + let deleting = false + export const show = () => { deletionModal.show() } @@ -17,32 +24,52 @@ let deletionModal let deletionConfirmationAppName + const copyName = () => { + deletionConfirmationAppName = appName + } + const deleteApp = async () => { + if (!appId) { + console.error("No app id provided") + return + } + deleting = true try { - await API.deleteApp($appStore.appId) - apps.load() + await API.deleteApp(appId) + appsStore.load() notifications.success("App deleted successfully") - $goto("/builder") + onDeleteSuccess() } catch (err) { notifications.error("Error deleting app") + deleting = false } } + (deletionConfirmationAppName = null)} - disabled={deletionConfirmationAppName !== $appStore.name} + disabled={deletionConfirmationAppName !== appName || deleting} > - Are you sure you want to delete {$appStore.name}? + Are you sure you want to delete + + {appName} + ? +
Please enter the app name below to confirm.

- +
+ + diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte index d445c98a1a..f009c975cf 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte @@ -12,6 +12,7 @@ export let bindings export let nested export let componentInstance + export let title = "Actions" let drawer let tmpValue @@ -37,7 +38,7 @@ {actionText}
- + Define what actions to run. diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte index 431368d28f..5b7844ce53 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/DeleteRow.svelte @@ -31,7 +31,7 @@ (parameters.rowId = value.detail)} /> diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/FetchRow.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/FetchRow.svelte index 4478f1bb16..61b7494ab2 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/FetchRow.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/FetchRow.svelte @@ -29,7 +29,7 @@ (parameters.rowId = value.detail)} /> diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveFields.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveFields.svelte index 52bd84c453..e03af75207 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveFields.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/SaveFields.svelte @@ -62,7 +62,7 @@ {/if} updateFieldValue(idx, event.detail)} diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ShowNotification.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ShowNotification.svelte index 55b00d215d..d95e13cb5f 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ShowNotification.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ShowNotification.svelte @@ -40,6 +40,7 @@ diff --git a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte index 0f0276823a..4db9a03d80 100644 --- a/packages/builder/src/components/design/settings/controls/PropertyControl.svelte +++ b/packages/builder/src/components/design/settings/controls/PropertyControl.svelte @@ -105,6 +105,7 @@ onChange={handleChange} bindings={allBindings} name={key} + title={label} {nested} {key} {type} diff --git a/packages/builder/src/components/integration/KeyValueBuilder.svelte b/packages/builder/src/components/integration/KeyValueBuilder.svelte index e71090f613..74636fc50c 100644 --- a/packages/builder/src/components/integration/KeyValueBuilder.svelte +++ b/packages/builder/src/components/integration/KeyValueBuilder.svelte @@ -143,7 +143,6 @@ value={field.value} allowJS={false} {allowHelpers} - fillWidth={true} drawerLeft={bindingDrawerLeft} /> {:else} diff --git a/packages/builder/src/components/portal/licensing/AppLimitModal.svelte b/packages/builder/src/components/portal/licensing/AppLimitModal.svelte index 39f553517e..bdecbcab3d 100644 --- a/packages/builder/src/components/portal/licensing/AppLimitModal.svelte +++ b/packages/builder/src/components/portal/licensing/AppLimitModal.svelte @@ -31,17 +31,11 @@ : null} > - You are currently on our Free plan. Upgrade - to our Pro plan to get unlimited apps and additional features. + You have exceeded the app limit for your current plan. Upgrade to get + unlimited apps and additional features! {#if !$auth.user.accountPortalAccess} Please contact the account holder to upgrade. {/if} - - diff --git a/packages/builder/src/components/start/AppRow.svelte b/packages/builder/src/components/start/AppRow.svelte index 32bddd58a6..6dd4030f3a 100644 --- a/packages/builder/src/components/start/AppRow.svelte +++ b/packages/builder/src/components/start/AppRow.svelte @@ -5,10 +5,14 @@ import { goto } from "@roxi/routify" import { UserAvatars } from "@budibase/frontend-core" import { sdk } from "@budibase/shared-core" + import AppRowContext from "./AppRowContext.svelte" + import FavouriteAppButton from "pages/builder/portal/apps/FavouriteAppButton.svelte" export let app export let lockedAction + let actionsOpen = false + $: editing = app.sessions?.length $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: unclickable = !isBuilder && !app.deployed @@ -42,8 +46,10 @@
@@ -74,21 +80,35 @@ {app.deployed ? "Published" : "Unpublished"}
- {#if isBuilder} +
- - + {#if isBuilder} +
+ +
+
+ { + actionsOpen = true + }} + on:close={() => { + actionsOpen = false + }} + /> +
+ {:else} + + + {/if}
- {:else if app.deployed} - -
- + +
+
- {/if} +
diff --git a/packages/builder/src/components/start/ExportAppModal.svelte b/packages/builder/src/components/start/ExportAppModal.svelte index 734e4448a1..ec0cf42fe0 100644 --- a/packages/builder/src/components/start/ExportAppModal.svelte +++ b/packages/builder/src/components/start/ExportAppModal.svelte @@ -121,6 +121,7 @@ { - const applications = svelteGet(apps) + const applications = svelteGet(appsStore).apps appValidation.name(validation, { apps: applications, currentApp: { @@ -62,7 +62,7 @@ async function updateApp() { try { - await apps.update(app.appId, { + await appsStore.save(app.appId, { name: $values.name?.trim(), url: $values.url?.trim(), icon: { diff --git a/packages/builder/src/global.css b/packages/builder/src/global.css index bc1f55d9d3..adf4a47070 100644 --- a/packages/builder/src/global.css +++ b/packages/builder/src/global.css @@ -22,6 +22,7 @@ body { --grey-7: var(--spectrum-global-color-gray-700); --grey-8: var(--spectrum-global-color-gray-800); --grey-9: var(--spectrum-global-color-gray-900); + --spectrum-global-color-yellow-1000: #d8b500; color: var(--ink); background-color: var(--background-alt); diff --git a/packages/builder/src/helpers/components.js b/packages/builder/src/helpers/components.js index 4f4f3ed380..a03ebfdfa7 100644 --- a/packages/builder/src/helpers/components.js +++ b/packages/builder/src/helpers/components.js @@ -279,3 +279,11 @@ export const buildContextTreeLookupMap = rootComponent => { }) return map } + +// Get a flat list of ids for all descendants of a component +export const getChildIdsForComponent = component => { + return [ + component._id, + ...(component?._children ?? []).map(getChildIdsForComponent).flat(1), + ] +} diff --git a/packages/builder/src/helpers/duplicate.js b/packages/builder/src/helpers/duplicate.js index 1547fcd4d1..c2a924f97b 100644 --- a/packages/builder/src/helpers/duplicate.js +++ b/packages/builder/src/helpers/duplicate.js @@ -48,3 +48,53 @@ export const duplicateName = (name, allNames) => { return `${baseName} ${number}` } + +/** + * More flexible alternative to the above function, which handles getting the + * next sequential name from an array of existing items while accounting for + * any type of prefix, and being able to deeply retrieve that name from the + * existing item array. + * + * Examples with a prefix of "foo": + * [] => "foo" + * ["foo"] => "foo2" + * ["foo", "foo6"] => "foo7" + * + * Examples with a prefix of "foo " (space at the end): + * [] => "foo" + * ["foo"] => "foo 2" + * ["foo", "foo 6"] => "foo 7" + * + * @param items the array of existing items + * @param prefix the string prefix of each name, including any spaces desired + * @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) => { + if (!prefix?.length || !getName) { + return null + } + const trimmedPrefix = prefix.trim() + if (!items?.length) { + return trimmedPrefix + } + let max = 0 + items.forEach(item => { + const name = getName(item) + if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) { + return + } + const split = name.split(trimmedPrefix) + if (split.length !== 2) { + return + } + if (split[1].trim() === "") { + split[1] = "1" + } + const num = parseInt(split[1]) + if (num > max) { + max = num + } + }) + return max === 0 ? trimmedPrefix : `${prefix}${max + 1}` +} diff --git a/packages/builder/src/helpers/tests/duplicate.test.js b/packages/builder/src/helpers/tests/duplicate.test.js index 400abed0aa..7e51c5ff2a 100644 --- a/packages/builder/src/helpers/tests/duplicate.test.js +++ b/packages/builder/src/helpers/tests/duplicate.test.js @@ -1,5 +1,5 @@ import { expect, describe, it } from "vitest" -import { duplicateName } from "../duplicate" +import { duplicateName, getSequentialName } from "../duplicate" describe("duplicate", () => { describe("duplicates a name ", () => { @@ -40,3 +40,64 @@ describe("duplicate", () => { }) }) }) + +describe("getSequentialName", () => { + it("handles nullish items", async () => { + const name = getSequentialName(null, "foo", () => {}) + expect(name).toBe("foo") + }) + + it("handles nullish prefix", async () => { + const name = getSequentialName([], null, () => {}) + expect(name).toBe(null) + }) + + it("handles nullish getName function", async () => { + const name = getSequentialName([], "foo", null) + expect(name).toBe(null) + }) + + it("handles just the prefix", async () => { + const name = getSequentialName(["foo"], "foo", x => x) + expect(name).toBe("foo2") + }) + + it("handles continuous ranges", async () => { + const name = getSequentialName(["foo", "foo2", "foo3"], "foo", x => x) + expect(name).toBe("foo4") + }) + + it("handles discontinuous ranges", async () => { + const name = getSequentialName(["foo", "foo3"], "foo", x => x) + expect(name).toBe("foo4") + }) + + it("handles a space inside the prefix", async () => { + const name = getSequentialName(["foo", "foo 2", "foo 3"], "foo ", x => x) + expect(name).toBe("foo 4") + }) + + it("handles a space inside the prefix with just the prefix", async () => { + const name = getSequentialName(["foo"], "foo ", x => x) + expect(name).toBe("foo 2") + }) + + it("handles no matches", async () => { + const name = getSequentialName(["aaa", "bbb"], "foo", x => x) + expect(name).toBe("foo") + }) + + it("handles similar names", async () => { + const name = getSequentialName( + ["fooo1", "2foo", "a3foo4", "5foo5"], + "foo", + x => x + ) + expect(name).toBe("foo") + }) + + it("handles non-string names", async () => { + const name = getSequentialName([null, 4123, [], {}], "foo", x => x) + expect(name).toBe("foo") + }) +}) diff --git a/packages/builder/src/pages/builder/_layout.svelte b/packages/builder/src/pages/builder/_layout.svelte index 62d3951fb5..95ca05b87b 100644 --- a/packages/builder/src/pages/builder/_layout.svelte +++ b/packages/builder/src/pages/builder/_layout.svelte @@ -71,6 +71,10 @@ await auth.getSelf() await admin.init() + if ($admin.maintenance.length > 0) { + $redirect("./maintenance") + } + if ($auth.user) { await licensing.init() } 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 7c1cc583e1..d714bafc70 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -15,7 +15,14 @@ FancySelect, } from "@budibase/bbui" import { builderStore, appStore, roles } from "stores/builder" - import { groups, licensing, apps, users, auth, admin } from "stores/portal" + import { + groups, + licensing, + appsStore, + users, + auth, + admin, + } from "stores/portal" import { fetchData, Constants, @@ -54,7 +61,7 @@ let inviteFailureResponse = "" $: validEmail = emailValidator(email) === true - $: prodAppId = apps.getProdAppID($appStore.appId) + $: prodAppId = appsStore.getProdAppID($appStore.appId) $: promptInvite = showInvite( filteredInvites, filteredUsers, diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index 16a1adadee..fd6a97560d 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -8,7 +8,7 @@ userStore, deploymentStore, } from "stores/builder" - import { auth, apps } from "stores/portal" + import { auth, appsStore } from "stores/portal" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" import { Icon, @@ -52,7 +52,7 @@ const pkg = await API.fetchAppPackage(application) await initialise(pkg) - await apps.load() + await appsStore.load() await deploymentStore.load() loaded = true @@ -188,7 +188,7 @@ {/if} - + diff --git a/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte index c4ee060149..57180625b1 100644 --- a/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/automation/_layout.svelte @@ -40,7 +40,7 @@
-
+
{#if $automationStore.automations?.length} {:else} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte index 50e1ad0cf8..4db218f60b 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte @@ -24,6 +24,13 @@ navigationStore, } from "stores/builder" import { DefaultAppTheme } from "constants" + import BarButtonList from "/src/components/design/settings/controls/BarButtonList.svelte" + + $: alignmentOptions = [ + { value: "Left", barIcon: "TextAlignLeft" }, + { value: "Center", barIcon: "TextAlignCenter" }, + { value: "Right", barIcon: "TextAlignRight" }, + ] $: screenRouteOptions = $screenStore.screens .map(screen => screen.routing?.route) @@ -46,6 +53,10 @@ notifications.error("Error updating navigation settings") } } + + const updateTextAlign = textAlignValue => { + navigationStore.syncAppNavigation({ textAlign: textAlignValue }) + } update("title", e.detail)} updateOnChange={false} /> + +
+ +
+ {/if}
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte index 5867021386..4617814485 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte @@ -6,6 +6,7 @@
+
@@ -32,7 +33,17 @@ flex-direction: column; justify-content: flex-start; align-items: stretch; - padding: 9px var(--spacing-m); + padding: 9px 10px 12px 10px; + position: relative; + transition: width 360ms ease-out; + } + .drawer-container { + position: absolute; + height: 100%; + width: 100%; + overflow: hidden; + top: 0; + left: 0; } .header { display: flex; diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte index bf66c28a09..e74a05ff99 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte @@ -10,6 +10,8 @@ navigationStore, selectedScreen, hoverStore, + componentTreeNodesStore, + snippets, } from "stores/builder" import ConfirmDialog from "components/common/ConfirmDialog.svelte" import { @@ -68,6 +70,7 @@ hostname: window.location.hostname, port: window.location.port, }, + snippets: $snippets, } // Refresh the preview when required @@ -130,6 +133,7 @@ error = event.error || "An unknown error occurred" } else if (type === "select-component" && data.id) { componentStore.select(data.id) + componentTreeNodesStore.makeNodeVisible(data.id) } else if (type === "hover-component") { hoverStore.hover(data.id, false) } else if (type === "update-prop") { @@ -196,6 +200,16 @@ } else if (type === "add-parent-component") { const { componentId, parentType } = data await componentStore.addParent(componentId, parentType) + } else if (type === "provide-context") { + let context = data?.context + if (context) { + try { + context = JSON.parse(context) + } catch (error) { + context = null + } + } + previewStore.setSelectedComponentContext(context) } else { console.warn(`Client sent unknown event type: ${type}`) } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte index f6bbac39a5..7e9c113a77 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte @@ -4,12 +4,12 @@ selectedScreen, componentStore, selectedComponent, + componentTreeNodesStore, } from "stores/builder" - import { findComponent } from "helpers/components" + import { findComponent, getChildIdsForComponent } from "helpers/components" import { goto, isActive } from "@roxi/routify" import { notifications } from "@budibase/bbui" import ConfirmDialog from "components/common/ConfirmDialog.svelte" - import componentTreeNodesStore from "stores/portal/componentTreeNodesStore" let confirmDeleteDialog let confirmEjectDialog @@ -63,38 +63,25 @@ componentStore.selectNext() }, ["ArrowRight"]: component => { - componentTreeNodesStore.expandNode(component._id) + componentTreeNodesStore.expandNodes([component._id]) }, ["ArrowLeft"]: component => { - componentTreeNodesStore.collapseNode(component._id) + // Select the collapsing root component to ensure the currently selected component is not + // hidden in a collapsed node + componentStore.select(component._id) + componentTreeNodesStore.collapseNodes([component._id]) }, ["Ctrl+ArrowRight"]: component => { - componentTreeNodesStore.expandNode(component._id) - - const expandChildren = component => { - const children = component._children ?? [] - - children.forEach(child => { - componentTreeNodesStore.expandNode(child._id) - expandChildren(child) - }) - } - - expandChildren(component) + const childIds = getChildIdsForComponent(component) + componentTreeNodesStore.expandNodes(childIds) }, ["Ctrl+ArrowLeft"]: component => { - componentTreeNodesStore.collapseNode(component._id) + // Select the collapsing root component to ensure the currently selected component is not + // hidden in a collapsed node + componentStore.select(component._id) - const collapseChildren = component => { - const children = component._children ?? [] - - children.forEach(child => { - componentTreeNodesStore.collapseNode(child._id) - collapseChildren(child) - }) - } - - collapseChildren(component) + const childIds = getChildIdsForComponent(component) + componentTreeNodesStore.collapseNodes(childIds) }, ["Escape"]: () => { if ($isActive(`./:componentId/new`)) { diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentTree.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentTree.svelte index f24235ad07..0219dc304d 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentTree.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentTree.svelte @@ -7,8 +7,8 @@ componentStore, userSelectedResourceMap, selectedComponent, - selectedComponentPath, hoverStore, + componentTreeNodesStore, } from "stores/builder" import { findComponentPath, @@ -17,7 +17,6 @@ } from "helpers/components" import { get } from "svelte/store" import { dndStore } from "./dndStore" - import componentTreeNodesStore from "stores/portal/componentTreeNodesStore" export let components = [] export let level = 0 @@ -64,14 +63,11 @@ } } - const isOpen = (component, selectedComponentPath, openNodes) => { + const isOpen = component => { if (!component?._children?.length) { return false } - if (selectedComponentPath.slice(0, -1).includes(component._id)) { - return true - } - return openNodes[`nodeOpen-${component._id}`] + return componentTreeNodesStore.isNodeExpanded(component._id) } const isChildOfSelectedComponent = component => { @@ -83,6 +79,11 @@ return findComponentPath($selectedComponent, component._id)?.length > 0 } + const handleIconClick = componentId => { + componentStore.select(componentId) + componentTreeNodesStore.toggleNode(componentId) + } + const hover = hoverStore.hover @@ -90,7 +91,7 @@
    {#each filteredComponents || [] as component, index (component._id)} - {@const opened = isOpen(component, $selectedComponentPath, openNodes)} + {@const opened = isOpen(component, openNodes)}
  • { componentStore.select(component._id) @@ -104,7 +105,7 @@ on:dragend={dndStore.actions.reset} on:dragstart={() => dndStore.actions.dragstart(component)} on:dragover={dragover(component, index)} - on:iconClick={() => componentTreeNodesStore.toggleNode(component._id)} + on:iconClick={() => handleIconClick(component._id)} on:drop={onDrop} hovering={$hoverStore.componentId === component._id} on:mouseenter={() => hover(component._id)} diff --git a/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte index da4f743f04..67befddcb9 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/_layout.svelte @@ -3,7 +3,7 @@ import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui" import { url, isActive } from "@roxi/routify" import DeleteModal from "components/deploy/DeleteModal.svelte" - import { isOnlyUser } from "stores/builder" + import { isOnlyUser, appStore } from "stores/builder" let deleteModal @@ -67,7 +67,11 @@
- + diff --git a/packages/builder/src/pages/builder/portal/_components/BudibaseLogo.svelte b/packages/builder/src/pages/builder/portal/_components/BudibaseLogo.svelte new file mode 100644 index 0000000000..b0dc4cda50 --- /dev/null +++ b/packages/builder/src/pages/builder/portal/_components/BudibaseLogo.svelte @@ -0,0 +1,15 @@ + + + + +Budibase Logo $goto("./apps")} /> + + diff --git a/packages/builder/src/pages/builder/portal/_layout.svelte b/packages/builder/src/pages/builder/portal/_layout.svelte index aad33f852a..18378c37e9 100644 --- a/packages/builder/src/pages/builder/portal/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/_layout.svelte @@ -1,7 +1,7 @@ + + { + e.stopPropagation() + const userAppFavourites = new Set([...($auth.user.appFavourites || [])]) + let processedAppIds = [] + + if ($auth.user.appFavourites && app?.appId) { + if (userAppFavourites.has(app.appId)) { + userAppFavourites.delete(app.appId) + } else { + userAppFavourites.add(app.appId) + } + processedAppIds = [...userAppFavourites] + } else { + processedAppIds = [app.appId] + } + + await auth.updateSelf({ + appFavourites: processedAppIds, + }) + }} + disabled={!app} +/> diff --git a/packages/builder/src/pages/builder/portal/apps/[appId]/_layout.svelte b/packages/builder/src/pages/builder/portal/apps/[appId]/_layout.svelte index f1ed686a3a..871326eb91 100644 --- a/packages/builder/src/pages/builder/portal/apps/[appId]/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/apps/[appId]/_layout.svelte @@ -1,8 +1,8 @@ + +
{#if $sideBarCollapsed} - sideBarCollapsed.set(false)} - > - Menu - +
sideBarCollapsed.set(false)}> + +
{:else} - sideBarCollapsed.set(true)} - > - Collapse - +
sideBarCollapsed.set(true)}> + +
{/if} {#if isBuilder} - $goto(`/builder/app/${app.devId}`)} > Edit - + {/if} - window.open(iframeUrl, "_blank")} - > - Fullscreen - +
+ +
+
window.open(iframeUrl, "_blank")}> + +
+
{#if noScreens}
@@ -83,6 +113,15 @@
diff --git a/packages/builder/src/pages/builder/portal/apps/_layout.svelte b/packages/builder/src/pages/builder/portal/apps/_layout.svelte index 8810edca9c..00719dc6d5 100644 --- a/packages/builder/src/pages/builder/portal/apps/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/apps/_layout.svelte @@ -2,7 +2,7 @@ import { notifications } from "@budibase/bbui" import { admin, - apps, + appsStore, templates, licensing, groups, @@ -14,7 +14,7 @@ import PortalSideBar from "./_components/PortalSideBar.svelte" // Don't block loading if we've already hydrated state - let loaded = !!$apps?.length + let loaded = !!$appsStore.apps?.length onMount(async () => { try { @@ -34,7 +34,10 @@ } // Go to new app page if no apps exists - if (!$apps.length && sdk.users.hasBuilderPermissions($auth.user)) { + if ( + !$appsStore.apps.length && + sdk.users.hasBuilderPermissions($auth.user) + ) { $redirect("./onboarding") } } catch (error) { @@ -46,7 +49,7 @@ {#if loaded}
- {#if $apps.length > 0} + {#if $appsStore.apps.length > 0} {/if} diff --git a/packages/builder/src/pages/builder/portal/apps/create.svelte b/packages/builder/src/pages/builder/portal/apps/create.svelte index 1f2c579071..1248c41cf8 100644 --- a/packages/builder/src/pages/builder/portal/apps/create.svelte +++ b/packages/builder/src/pages/builder/portal/apps/create.svelte @@ -5,7 +5,7 @@ import CreateAppModal from "components/start/CreateAppModal.svelte" import TemplateDisplay from "components/common/TemplateDisplay.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" - import { apps, templates, licensing } from "stores/portal" + import { appsStore, templates, licensing } from "stores/portal" import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page" let template @@ -35,7 +35,7 @@ } -{#if !$apps.length} +{#if !$appsStore.apps.length} {:else} diff --git a/packages/builder/src/pages/builder/portal/apps/index.svelte b/packages/builder/src/pages/builder/portal/apps/index.svelte index a1aa242a36..9a073d041f 100644 --- a/packages/builder/src/pages/builder/portal/apps/index.svelte +++ b/packages/builder/src/pages/builder/portal/apps/index.svelte @@ -19,13 +19,18 @@ import { automationStore, initialise } from "stores/builder" import { API } from "api" import { onMount } from "svelte" - import { apps, auth, admin, licensing, environment } from "stores/portal" + import { + appsStore, + auth, + admin, + licensing, + environment, + enrichedApps, + } from "stores/portal" import { goto } from "@roxi/routify" import AppRow from "components/start/AppRow.svelte" - import { AppStatus } from "constants" import Logo from "assets/bb-space-man.svg" - let sortBy = "name" let template let creationModal let appLimitModal @@ -33,56 +38,27 @@ let searchTerm = "" let creatingFromTemplate = false let automationErrors - let accessFilterList = null $: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}` - $: enrichedApps = enrichApps($apps, $auth.user, sortBy) - $: filteredApps = enrichedApps.filter( - app => - (searchTerm - ? app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) - : true) && - (accessFilterList !== null - ? accessFilterList?.includes( - `${app?.type}_${app?.tenantId}_${app?.appId}` - ) - : true) - ) - $: automationErrors = getAutomationErrors(enrichedApps) + $: filteredApps = filterApps($enrichedApps, searchTerm) + $: automationErrors = getAutomationErrors(filteredApps || []) $: isOwner = $auth.accountPortalAccess && $admin.cloud + const filterApps = (apps, searchTerm) => { + return apps?.filter(app => { + const query = searchTerm?.trim()?.replace(/\s/g, "") + if (query) { + return app?.name?.toLowerCase().includes(query.toLowerCase()) + } else { + return true + } + }) + } + const usersLimitLockAction = $licensing?.errUserLimit ? () => accountLockedModal.show() : null - const enrichApps = (apps, user, sortBy) => { - const enrichedApps = apps.map(app => ({ - ...app, - deployed: app.status === AppStatus.DEPLOYED, - lockedYou: app.lockedBy && app.lockedBy.email === user?.email, - lockedOther: app.lockedBy && app.lockedBy.email !== user?.email, - })) - - if (sortBy === "status") { - return enrichedApps.sort((a, b) => { - if (a.status === b.status) { - return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1 - } - return a.status === AppStatus.DEPLOYED ? -1 : 1 - }) - } else if (sortBy === "updated") { - return enrichedApps.sort((a, b) => { - const aUpdated = a.updatedAt || "9999" - const bUpdated = b.updatedAt || "9999" - return aUpdated < bUpdated ? 1 : -1 - }) - } else { - return enrichedApps.sort((a, b) => { - return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1 - }) - } - } - const getAutomationErrors = apps => { const automationErrors = {} for (let app of apps) { @@ -109,7 +85,7 @@ } const automationErrorMessage = appId => { - const app = enrichedApps.find(app => app.devId === appId) + const app = $enrichedApps.find(app => app.devId === appId) const errors = automationErrors[appId] return `${app.name} - Automation error (${errorCount(errors)})` } @@ -117,7 +93,7 @@ const initiateAppCreation = async () => { if ($licensing?.usageMetrics?.apps >= 100) { appLimitModal.show() - } else if ($apps?.length) { + } else if ($appsStore.apps?.length) { $goto("/builder/portal/apps/create") } else { template = null @@ -136,7 +112,7 @@ const templateKey = template.key.split("/")[1] let appName = templateKey.replace(/-/g, " ") - const appsWithSameName = $apps.filter(app => + const appsWithSameName = $appsStore.apps.filter(app => app.name?.startsWith(appName) ) appName = `${appName} ${appsWithSameName.length + 1}` @@ -217,7 +193,7 @@ : "View error"} on:dismiss={async () => { await automationStore.actions.clearLogErrors({ appId }) - await apps.load() + await appsStore.load() }} message={automationErrorMessage(appId)} /> @@ -233,7 +209,7 @@
- {#if enrichedApps.length} + {#if $appsStore.apps.length}
{#if $auth.user && sdk.users.canCreateApps($auth.user)} @@ -245,7 +221,7 @@ > Create new app - {#if $apps?.length > 0 && !$admin.offlineMode} + {#if $appsStore.apps?.length > 0 && !$admin.offlineMode}