diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index a15e3d9110..6c875f2dfe 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -37,14 +37,17 @@ jobs: - uses: actions/checkout@v3 with: submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 14.x uses: actions/setup-node@v3 with: node-version: 14.x cache: "yarn" - run: yarn - - run: yarn nx run-many -t=build --configuration=production + # Run build all the projects + - run: yarn build + # Check the types of the projects built via esbuild + - run: yarn check:types test-libraries: runs-on: ubuntu-latest @@ -52,7 +55,7 @@ jobs: - uses: actions/checkout@v3 with: submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 14.x uses: actions/setup-node@v3 with: @@ -72,7 +75,7 @@ jobs: - uses: actions/checkout@v3 with: submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 14.x uses: actions/setup-node@v3 with: @@ -82,7 +85,7 @@ jobs: - run: yarn test --scope=@budibase/worker --scope=@budibase/server - uses: codecov/codecov-action@v3 with: - token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos + token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos name: codecov-umbrella verbose: true @@ -92,7 +95,7 @@ jobs: - uses: actions/checkout@v3 with: submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 14.x uses: actions/setup-node@v3 with: @@ -107,7 +110,7 @@ jobs: - uses: actions/checkout@v3 with: submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 14.x uses: actions/setup-node@v3 with: @@ -131,7 +134,7 @@ jobs: uses: actions/checkout@v3 with: submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Check submodule run: | diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index c724b717e2..48c51e8457 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -1,5 +1,7 @@ name: Budibase Prerelease -concurrency: release-prerelease +concurrency: + group: release-prerelease + cancel-in-progress: false on: push: diff --git a/.github/workflows/release-master.yml b/.github/workflows/release-master.yml index 4959194064..f05d369a34 100644 --- a/.github/workflows/release-master.yml +++ b/.github/workflows/release-master.yml @@ -1,5 +1,7 @@ name: Budibase Release -concurrency: release +concurrency: + group: release + cancel-in-progress: false on: push: diff --git a/.github/workflows/tag-prerelease.yml b/.github/workflows/tag-prerelease.yml index e04ef3b17d..83660e409d 100644 --- a/.github/workflows/tag-prerelease.yml +++ b/.github/workflows/tag-prerelease.yml @@ -1,5 +1,7 @@ name: Tag prerelease -concurrency: release-prerelease +concurrency: + group: tag-prerelease + cancel-in-progress: false on: push: diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 55549a7e9d..1dcb16ac56 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -1,5 +1,7 @@ name: Tag release -concurrency: release-prerelease +concurrency: + group: tag-release + cancel-in-progress: false on: push: diff --git a/lerna.json b/lerna.json index ebaf23cfb6..5d82675584 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.6.19-alpha.11", + "version": "2.6.19-alpha.52", "npmClient": "yarn", "packages": [ "packages/backend-core", diff --git a/package.json b/package.json index edbb82b892..3849c65274 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "devDependencies": { "@esbuild-plugins/node-resolve": "^0.2.2", "@esbuild-plugins/tsconfig-paths": "^0.1.2", - "@nx/esbuild": "16.2.1", "@nx/js": "16.2.1", "@rollup/plugin-json": "^4.0.2", "@typescript-eslint/parser": "5.45.0", @@ -34,6 +33,7 @@ "bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'", "build": "yarn nx run-many -t=build", "build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", + "check:types": "lerna run check:types --skip-nx-cache", "backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap", "backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'", "build:sdk": "lerna run --stream build:sdk", @@ -52,7 +52,7 @@ "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker", "dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built", - "dev:docker": "yarn build && docker-compose -f hosting/docker-compose.dev.yaml -f hosting/docker-compose.build.yaml up --build --scale proxy-service=0 ", + "dev:docker": "yarn build && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0", "test": "lerna run --stream test --stream", "lint:eslint": "eslint packages && eslint qa-core", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"", @@ -110,5 +110,11 @@ "packages/pro/packages/pro" ] }, + "resolutions": { + "@budibase/backend-core": "0.0.0", + "@budibase/shared-core": "0.0.0", + "@budibase/string-templates": "0.0.0", + "@budibase/types": "0.0.0" + }, "dependencies": {} } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 9337e689e4..f85687b007 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "0.0.1", + "version": "0.0.0", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -22,7 +22,7 @@ "dependencies": { "@budibase/nano": "10.1.2", "@budibase/pouchdb-replication-stream": "1.2.10", - "@budibase/types": "0.0.1", + "@budibase/types": "0.0.0", "@shopify/jest-koa-mocks": "5.0.1", "@techpass/passport-openidconnect": "0.3.2", "aws-cloudfront-sign": "2.2.0", @@ -33,7 +33,7 @@ "correlation-id": "4.0.0", "dotenv": "16.0.1", "emitter-listener": "1.1.2", - "ioredis": "4.28.0", + "ioredis": "5.3.2", "joi": "17.6.0", "jsonwebtoken": "9.0.0", "koa-passport": "4.1.4", @@ -62,7 +62,6 @@ "@swc/jest": "^0.2.24", "@trendyol/jest-testcontainers": "^2.1.1", "@types/chance": "1.1.3", - "@types/ioredis": "4.28.0", "@types/jest": "29.5.0", "@types/koa": "2.13.4", "@types/lodash": "4.14.180", @@ -74,7 +73,7 @@ "@types/tar-fs": "2.0.1", "@types/uuid": "8.3.4", "chance": "1.1.8", - "ioredis-mock": "5.8.0", + "ioredis-mock": "8.7.0", "jest": "29.5.0", "jest-environment-node": "29.5.0", "jest-serial-runner": "^1.2.1", diff --git a/packages/backend-core/src/cache/tests/writethrough.spec.ts b/packages/backend-core/src/cache/tests/writethrough.spec.ts index e4c7cc6e64..92b073ed64 100644 --- a/packages/backend-core/src/cache/tests/writethrough.spec.ts +++ b/packages/backend-core/src/cache/tests/writethrough.spec.ts @@ -72,16 +72,12 @@ describe("writethrough", () => { writethrough.put({ ...current, value: 4 }), ]) + // with a lock, this will work const newRev = responses.map(x => x.rev).find(x => x !== current._rev) expect(newRev).toBeDefined() expect(responses.map(x => x.rev)).toEqual( expect.arrayContaining([current._rev, current._rev, newRev]) ) - expectFunctionWasCalledTimesWith( - mocks.alerts.logWarn, - 2, - "Ignoring redlock conflict in write-through cache" - ) const output = await db.get(current._id) expect(output.value).toBe(4) diff --git a/packages/backend-core/src/constants/misc.ts b/packages/backend-core/src/constants/misc.ts index 2bb8f815cf..ba2533cf4a 100644 --- a/packages/backend-core/src/constants/misc.ts +++ b/packages/backend-core/src/constants/misc.ts @@ -16,6 +16,7 @@ export enum Header { LICENSE_KEY = "x-budibase-license-key", API_VER = "x-budibase-api-version", APP_ID = "x-budibase-app-id", + SESSION_ID = "x-budibase-session-id", TYPE = "x-budibase-type", PREVIEW_ROLE = "x-budibase-role", TENANT_ID = "x-budibase-tenant-id", diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 9163dfeba6..eab8cd4c45 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -97,7 +97,6 @@ const environment = { REDIS_URL: process.env.REDIS_URL || "localhost:6379", REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_CLUSTERED: process.env.REDIS_CLUSTERED, - MOCK_REDIS: process.env.MOCK_REDIS, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, AWS_REGION: process.env.AWS_REGION, @@ -129,6 +128,7 @@ const environment = { PLUGIN_BUCKET_NAME: process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, USE_COUCH: process.env.USE_COUCH || true, + MOCK_REDIS: process.env.MOCK_REDIS, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, SERVICE: process.env.SERVICE || "budibase", LOG_LEVEL: process.env.LOG_LEVEL || "info", diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 40233b3827..7b98674788 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -21,6 +21,7 @@ export * as context from "./context" export * as cache from "./cache" export * as objectStore from "./objectStore" export * as redis from "./redis" +export { Client as RedisClient } from "./redis" export * as locks from "./redis/redlockImpl" export * as utils from "./utils" export * as errors from "./errors" diff --git a/packages/backend-core/src/logging/pino/logger.ts b/packages/backend-core/src/logging/pino/logger.ts index cebc78ffc7..c96bc83e04 100644 --- a/packages/backend-core/src/logging/pino/logger.ts +++ b/packages/backend-core/src/logging/pino/logger.ts @@ -96,6 +96,7 @@ if (!env.DISABLE_PINO_LOGGER) { const mergingObject: any = { err: error, + pid: process.pid, ...contextObject, } diff --git a/packages/backend-core/src/redis/init.ts b/packages/backend-core/src/redis/init.ts index 485268edad..55ffe3dd12 100644 --- a/packages/backend-core/src/redis/init.ts +++ b/packages/backend-core/src/redis/init.ts @@ -6,7 +6,8 @@ let userClient: Client, appClient: Client, cacheClient: Client, writethroughClient: Client, - lockClient: Client + lockClient: Client, + socketClient: Client async function init() { userClient = await new Client(utils.Databases.USER_CACHE).init() @@ -14,9 +15,10 @@ async function init() { appClient = await new Client(utils.Databases.APP_METADATA).init() cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init() lockClient = await new Client(utils.Databases.LOCKS).init() - writethroughClient = await new Client( - utils.Databases.WRITE_THROUGH, - utils.SelectableDatabase.WRITE_THROUGH + writethroughClient = await new Client(utils.Databases.WRITE_THROUGH).init() + socketClient = await new Client( + utils.Databases.SOCKET_IO, + utils.SelectableDatabase.SOCKET_IO ).init() } @@ -27,6 +29,7 @@ export async function shutdown() { if (cacheClient) await cacheClient.finish() if (writethroughClient) await writethroughClient.finish() if (lockClient) await lockClient.finish() + if (socketClient) await socketClient.finish() } process.on("exit", async () => { @@ -74,3 +77,10 @@ export async function getLockClient() { } return lockClient } + +export async function getSocketClient() { + if (!socketClient) { + await init() + } + return socketClient +} diff --git a/packages/backend-core/src/redis/redis.ts b/packages/backend-core/src/redis/redis.ts index 2d54b51a9f..5056a5d549 100644 --- a/packages/backend-core/src/redis/redis.ts +++ b/packages/backend-core/src/redis/redis.ts @@ -1,6 +1,15 @@ import env from "../environment" -// ioredis mock is all in memory -const Redis = env.MOCK_REDIS ? require("ioredis-mock") : require("ioredis") +import Redis from "ioredis" +// mock-redis doesn't have any typing +let MockRedis: any | undefined +if (env.MOCK_REDIS) { + try { + // ioredis mock is all in memory + MockRedis = require("ioredis-mock") + } catch (err) { + console.log("Mock redis unavailable") + } +} import { addDbPrefix, removeDbPrefix, @@ -18,7 +27,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT // for testing just generate the client once let CLOSED = false let CLIENTS: { [key: number]: any } = {} - +0 let CONNECTED = false // mock redis always connected @@ -55,6 +64,7 @@ function connectionError( * will return the ioredis client which will be ready to use. */ function init(selectDb = DEFAULT_SELECT_DB) { + const RedisCore = env.MOCK_REDIS && MockRedis ? MockRedis : Redis let timeout: NodeJS.Timeout CLOSED = false let client = pickClient(selectDb) @@ -64,7 +74,7 @@ function init(selectDb = DEFAULT_SELECT_DB) { } // testing uses a single in memory client if (env.MOCK_REDIS) { - CLIENTS[selectDb] = new Redis(getRedisOptions()) + CLIENTS[selectDb] = new RedisCore(getRedisOptions()) } // start the timer - only allowed 5 seconds to connect timeout = setTimeout(() => { @@ -84,11 +94,11 @@ function init(selectDb = DEFAULT_SELECT_DB) { const { redisProtocolUrl, opts, host, port } = getRedisOptions() if (CLUSTERED) { - client = new Redis.Cluster([{ host, port }], opts) + client = new RedisCore.Cluster([{ host, port }], opts) } else if (redisProtocolUrl) { - client = new Redis(redisProtocolUrl) + client = new RedisCore(redisProtocolUrl) } else { - client = new Redis(opts) + client = new RedisCore(opts) } // attach handlers client.on("end", (err: Error) => { @@ -183,6 +193,9 @@ class RedisWrapper { CLOSED = false init(this._select) await waitForConnection(this._select) + if (this._select && !env.isTest()) { + this.getClient().select(this._select) + } return this } @@ -209,6 +222,11 @@ class RedisWrapper { return this.getClient().keys(addDbPrefix(db, pattern)) } + async exists(key: string) { + const db = this._db + return await this.getClient().exists(addDbPrefix(db, key)) + } + async get(key: string) { const db = this._db let response = await this.getClient().get(addDbPrefix(db, key)) diff --git a/packages/backend-core/src/redis/redlockImpl.ts b/packages/backend-core/src/redis/redlockImpl.ts index 55b891ea84..7fe61a409e 100644 --- a/packages/backend-core/src/redis/redlockImpl.ts +++ b/packages/backend-core/src/redis/redlockImpl.ts @@ -4,10 +4,10 @@ import { LockOptions, LockType } from "@budibase/types" import * as context from "../context" import env from "../environment" -const getClient = async ( +async function getClient( type: LockType, opts?: Redlock.Options -): Promise => { +): Promise { if (type === LockType.CUSTOM) { return newRedlock(opts) } @@ -18,6 +18,9 @@ const getClient = async ( case LockType.TRY_ONCE: { return newRedlock(OPTIONS.TRY_ONCE) } + case LockType.TRY_TWICE: { + return newRedlock(OPTIONS.TRY_TWICE) + } case LockType.DEFAULT: { return newRedlock(OPTIONS.DEFAULT) } @@ -35,6 +38,9 @@ const OPTIONS = { // immediately throws an error if the lock is already held retryCount: 0, }, + TRY_TWICE: { + retryCount: 1, + }, TEST: { // higher retry count in unit tests // due to high contention. @@ -62,7 +68,7 @@ const OPTIONS = { }, } -const newRedlock = async (opts: Redlock.Options = {}) => { +export async function newRedlock(opts: Redlock.Options = {}) { let options = { ...OPTIONS.DEFAULT, ...opts } const redisWrapper = await getLockClient() const client = redisWrapper.getClient() @@ -81,22 +87,26 @@ type RedlockExecution = | SuccessfulRedlockExecution | UnsuccessfulRedlockExecution -export const doWithLock = async ( +function getLockName(opts: LockOptions) { + // determine lock name + // by default use the tenantId for uniqueness, unless using a system lock + const prefix = opts.systemLock ? "system" : context.getTenantId() + let name: string = `lock:${prefix}_${opts.name}` + // add additional unique name if required + if (opts.resource) { + name = name + `_${opts.resource}` + } + return name +} + +export async function doWithLock( opts: LockOptions, task: () => Promise -): Promise> => { +): Promise> { const redlock = await getClient(opts.type, opts.customOptions) let lock try { - // determine lock name - // by default use the tenantId for uniqueness, unless using a system lock - const prefix = opts.systemLock ? "system" : context.getTenantId() - let name: string = `lock:${prefix}_${opts.name}` - - // add additional unique name if required - if (opts.resource) { - name = name + `_${opts.resource}` - } + const name = getLockName(opts) // create the lock lock = await redlock.lock(name, opts.ttl) @@ -112,7 +122,6 @@ export const doWithLock = async ( if (opts.type === LockType.TRY_ONCE) { // don't throw for try-once locks, they will always error // due to retry count (0) exceeded - console.warn(e) return { executed: false } } else { console.error(e) diff --git a/packages/backend-core/src/redis/utils.ts b/packages/backend-core/src/redis/utils.ts index 2c49ee4941..34b7275a2b 100644 --- a/packages/backend-core/src/redis/utils.ts +++ b/packages/backend-core/src/redis/utils.ts @@ -27,6 +27,7 @@ export enum Databases { GENERIC_CACHE = "data_cache", WRITE_THROUGH = "writeThrough", LOCKS = "locks", + SOCKET_IO = "socket_io", } /** @@ -40,7 +41,7 @@ export enum Databases { */ export enum SelectableDatabase { DEFAULT = 0, - WRITE_THROUGH = 1, + SOCKET_IO = 1, UNUSED_1 = 2, UNUSED_2 = 3, UNUSED_3 = 4, @@ -94,7 +95,7 @@ export function getRedisOptions() { opts.port = port opts.password = password } - return { opts, host, port, redisProtocolUrl } + return { opts, host, port: parseInt(port), redisProtocolUrl } } export function addDbPrefix(db: string, key: string) { diff --git a/packages/backend-core/tests/core/utilities/mocks/licenses.ts b/packages/backend-core/tests/core/utilities/mocks/licenses.ts index 839b22e5f9..4272e78eb8 100644 --- a/packages/backend-core/tests/core/utilities/mocks/licenses.ts +++ b/packages/backend-core/tests/core/utilities/mocks/licenses.ts @@ -90,6 +90,10 @@ export const useScimIntegration = () => { return useFeature(Feature.SCIM) } +export const useSyncAutomations = () => { + return useFeature(Feature.SYNC_AUTOMATIONS) +} + // QUOTAS export const setAutomationLogsQuota = (value: number) => { diff --git a/packages/bbui/package.json b/packages/bbui/package.json index de3c45b09d..b03c83d71b 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "0.0.1", + "version": "0.0.0", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,8 +38,8 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "1.2.1", - "@budibase/shared-core": "0.0.1", - "@budibase/string-templates": "0.0.1", + "@budibase/shared-core": "0.0.0", + "@budibase/string-templates": "0.0.0", "@spectrum-css/accordion": "3.0.24", "@spectrum-css/actionbutton": "1.0.1", "@spectrum-css/actiongroup": "1.0.1", diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 2cb681670b..01555446d9 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -56,6 +56,8 @@ export default function positionDropdown(element, opts) { styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width } else if (align === "right-outside") { styles.left = anchorBounds.right + offset + } else if (align === "left-outside") { + styles.left = anchorBounds.left - elementBounds.width - offset } else { styles.left = anchorBounds.left } diff --git a/packages/bbui/src/Avatar/Avatar.svelte b/packages/bbui/src/Avatar/Avatar.svelte index 1e4cefd8ce..0faf50f55a 100644 --- a/packages/bbui/src/Avatar/Avatar.svelte +++ b/packages/bbui/src/Avatar/Avatar.svelte @@ -13,10 +13,12 @@ export let url = "" export let disabled = false export let initials = "JD" + export let color = null const DefaultColor = "#3aab87" - $: color = getColor(initials) + $: avatarColor = color || getColor(initials) + $: style = getStyle(size, avatarColor) const getColor = initials => { if (!initials?.length) { @@ -26,6 +28,12 @@ const hue = ((code % 26) / 26) * 360 return `hsl(${hue}, 50%, 50%)` } + + const getStyle = (sizeKey, color) => { + const size = `var(${sizes.get(sizeKey)})` + const fontSize = `calc(${size} / 2)` + return `width:${size}; height:${size}; font-size:${fontSize}; background:${color};` + } {#if url} @@ -37,13 +45,7 @@ style="width: var({sizes.get(size)}); height: var({sizes.get(size)});" /> {:else} -
+
{initials || ""}
{/if} diff --git a/packages/bbui/src/Drawer/Drawer.svelte b/packages/bbui/src/Drawer/Drawer.svelte index 932236bc0c..782c3c6ed2 100644 --- a/packages/bbui/src/Drawer/Drawer.svelte +++ b/packages/bbui/src/Drawer/Drawer.svelte @@ -3,11 +3,13 @@ import Button from "../Button/Button.svelte" import Body from "../Typography/Body.svelte" import Heading from "../Typography/Heading.svelte" + import { setContext } from "svelte" export let title export let fillWidth export let left = "314px" export let width = "calc(100% - 626px)" + export let headless = false let visible = false @@ -25,6 +27,11 @@ visible = false } + setContext("drawer-actions", { + hide, + show, + }) + const easeInOutQuad = x => { return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2 } @@ -47,27 +54,34 @@
-
-
- {title} - - - -
-
- - -
-
+ {#if !headless} +
+
+ {title} + + + +
+
+ + +
+
+ {/if}
{/if} diff --git a/packages/builder/package.json b/packages/builder/package.json index 01820d8b91..73bc003343 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "0.0.1", + "version": "0.0.0", "license": "GPL-3.0", "private": true, "scripts": { @@ -58,11 +58,18 @@ } }, "dependencies": { - "@budibase/bbui": "0.0.1", - "@budibase/frontend-core": "0.0.1", - "@budibase/shared-core": "0.0.1", - "@budibase/string-templates": "0.0.1", - "@budibase/types": "0.0.1", + "@budibase/bbui": "0.0.0", + "@budibase/frontend-core": "0.0.0", + "@budibase/shared-core": "0.0.0", + "@budibase/string-templates": "0.0.0", + "@budibase/types": "0.0.0", + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/commands": "^6.2.4", + "@codemirror/lang-javascript": "^6.1.8", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.2.0", + "@codemirror/theme-one-dark": "^6.1.2", + "@codemirror/view": "^6.11.2", "@fortawesome/fontawesome-svg-core": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1", diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 0d41931a55..e9c8643bce 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -77,7 +77,7 @@ export const getAuthBindings = () => { runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`, readable: `Current User.OAuthToken`, key: "accessToken", - display: { name: "OAuthToken" }, + display: { name: "OAuthToken", type: "text" }, }, ] @@ -434,6 +434,9 @@ export const getUserBindings = () => { providerId: "user", category: "Current User", icon: "User", + display: { + name: key, + }, }) return acc }, []) @@ -550,7 +553,7 @@ const getUrlBindings = asset => { readableBinding: `URL.${param}`, category: "URL", icon: "RailTop", - display: { type: "string" }, + display: { type: "string", name: param }, })) const queryParamsBinding = { type: "context", @@ -558,7 +561,7 @@ const getUrlBindings = asset => { readableBinding: "Query params", category: "URL", icon: "RailTop", - display: { type: "object" }, + display: { type: "object", name: "Query params" }, } return urlParamBindings.concat([queryParamsBinding]) } @@ -589,7 +592,6 @@ export const getEventContextBindings = ( actionId ) => { let bindings = [] - // Check if any context bindings are provided by the component for this // setting const component = findComponent(asset.props, componentId) @@ -605,6 +607,9 @@ export const getEventContextBindings = ( )}`, category: component._instanceName, icon: def.icon, + display: { + name: contextEntry.label, + }, }) }) } @@ -628,6 +633,9 @@ export const getEventContextBindings = ( runtimeBinding: `actions.${idx}.${contextValue.value}`, category: "Actions", icon: "JourneyAction", + display: { + name: contextValue.label, + }, }) }) } diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index d15cdb6e98..9dca6a64e6 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -2,6 +2,7 @@ import { getFrontendStore } from "./store/frontend" import { getAutomationStore } from "./store/automation" import { getTemporalStore } from "./store/temporal" import { getThemeStore } from "./store/theme" +import { getUserStore } from "./store/users" import { derived } from "svelte/store" import { findComponent, findComponentPath } from "./componentUtils" import { RoleUtils } from "@budibase/frontend-core" @@ -12,6 +13,7 @@ export const store = getFrontendStore() export const automationStore = getAutomationStore() export const themeStore = getThemeStore() export const temporalStore = getTemporalStore() +export const userStore = getUserStore() // Setup history for screens export const screenHistoryStore = createHistoryStore({ diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index e264dc099b..7f83b2b464 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -37,8 +37,10 @@ import { } from "builderStore/dataBinding" import { makePropSafe as safe } from "@budibase/string-templates" import { getComponentFieldOptions } from "helpers/formFields" +import { createBuilderWebsocket } from "builderStore/websocket" const INITIAL_FRONTEND_STATE = { + initialised: false, apps: [], name: "", url: "", @@ -69,7 +71,9 @@ const INITIAL_FRONTEND_STATE = { customTheme: {}, previewDevice: "desktop", highlightedSettingKey: null, + propertyFocus: null, builderSidePanel: false, + hasLock: true, // URL params selectedScreenId: null, @@ -86,6 +90,7 @@ const INITIAL_FRONTEND_STATE = { export const getFrontendStore = () => { const store = writable({ ...INITIAL_FRONTEND_STATE }) + let websocket // This is a fake implementation of a "patch" API endpoint to try and prevent // 409s. All screen doc mutations (aside from creation) use this function, @@ -110,10 +115,11 @@ export const getFrontendStore = () => { store.actions = { reset: () => { store.set({ ...INITIAL_FRONTEND_STATE }) + websocket?.disconnect() }, initialise: async pkg => { - const { layouts, screens, application, clientLibPath } = pkg - + const { layouts, screens, application, clientLibPath, hasLock } = pkg + websocket = createBuilderWebsocket(application.appId) await store.actions.components.refreshDefinitions(application.appId) // Reset store state @@ -137,6 +143,8 @@ export const getFrontendStore = () => { upgradableVersion: application.upgradableVersion, navigation: application.navigation || {}, usedPlugins: application.usedPlugins || [], + hasLock, + initialised: true, })) screenHistoryStore.reset() automationHistoryStore.reset() @@ -1319,6 +1327,12 @@ export const getFrontendStore = () => { highlightedSettingKey: key, })) }, + propertyFocus: key => { + store.update(state => ({ + ...state, + propertyFocus: key, + })) + }, }, dnd: { start: component => { diff --git a/packages/builder/src/builderStore/store/users.js b/packages/builder/src/builderStore/store/users.js new file mode 100644 index 0000000000..6e10b081d6 --- /dev/null +++ b/packages/builder/src/builderStore/store/users.js @@ -0,0 +1,42 @@ +import { writable, get } from "svelte/store" + +export const getUserStore = () => { + const store = writable([]) + + const init = users => { + store.set(users) + } + + const updateUser = user => { + const $users = get(store) + if (!$users.some(x => x.sessionId === user.sessionId)) { + store.set([...$users, user]) + } else { + store.update(state => { + const index = state.findIndex(x => x.sessionId === user.sessionId) + state[index] = user + return state.slice() + }) + } + } + + const removeUser = sessionId => { + store.update(state => { + return state.filter(x => x.sessionId !== sessionId) + }) + } + + const reset = () => { + store.set([]) + } + + return { + ...store, + actions: { + init, + updateUser, + removeUser, + reset, + }, + } +} diff --git a/packages/builder/src/builderStore/utils.js b/packages/builder/src/builderStore/utils.js index 3ddf6fb667..86c63f20ee 100644 --- a/packages/builder/src/builderStore/utils.js +++ b/packages/builder/src/builderStore/utils.js @@ -1,3 +1,4 @@ +import { ActionStepID } from "constants/backend/automations" import { TableNames } from "../constants" import { AUTO_COLUMN_DISPLAY_NAMES, @@ -53,3 +54,9 @@ export function buildAutoColumn(tableName, name, subtype) { } return base } + +export function checkForCollectStep(automation) { + return automation.definition.steps.some( + step => step.stepId === ActionStepID.COLLECT + ) +} diff --git a/packages/builder/src/builderStore/websocket.js b/packages/builder/src/builderStore/websocket.js new file mode 100644 index 0000000000..e27e08e31d --- /dev/null +++ b/packages/builder/src/builderStore/websocket.js @@ -0,0 +1,37 @@ +import { createWebsocket } from "@budibase/frontend-core" +import { userStore } from "builderStore" +import { datasources, tables } from "stores/backend" +import { SocketEvent, BuilderSocketEvent } from "@budibase/shared-core" + +export const createBuilderWebsocket = appId => { + const socket = createWebsocket("/socket/builder") + + // Built-in events + socket.on("connect", () => { + socket.emit(BuilderSocketEvent.SelectApp, appId, response => { + userStore.actions.init(response.users) + }) + }) + socket.on("connect_error", err => { + console.log("Failed to connect to builder websocket:", err.message) + }) + socket.on("disconnect", () => { + userStore.actions.reset() + }) + + // User events + socket.onOther(SocketEvent.UserUpdate, userStore.actions.updateUser) + socket.onOther(SocketEvent.UserDisconnect, userStore.actions.removeUser) + + // Table events + socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => { + tables.replaceTable(id, table) + }) + + // Datasource events + socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => { + datasources.replaceDatasource(id, datasource) + }) + + return socket +} diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index 7c9f31352f..c5e4eaf61f 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -6,24 +6,48 @@ Body, Icon, notifications, + Tags, + Tag, } from "@budibase/bbui" - import { automationStore } from "builderStore" - import { admin } from "stores/portal" + import { automationStore, selectedAutomation } from "builderStore" + import { admin, licensing } from "stores/portal" import { externalActions } from "./ExternalActions" + import { TriggerStepID } from "constants/backend/automations" + import { checkForCollectStep } from "builderStore/utils" export let blockIdx + export let lastStep - const disabled = { - SEND_EMAIL_SMTP: { - disabled: !$admin.checklist.smtp.checked, - message: "Please configure SMTP", - }, - } - + let syncAutomationsEnabled = $licensing.syncAutomationsEnabled + let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK] let selectedAction let actionVal let actions = Object.entries($automationStore.blockDefinitions.ACTION) + $: collectBlockExists = checkForCollectStep($selectedAutomation) + + const disabled = () => { + return { + SEND_EMAIL_SMTP: { + disabled: !$admin.checklist.smtp.checked, + message: "Please configure SMTP", + }, + COLLECT: { + disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists, + message: collectDisabledMessage(), + }, + } + } + + const collectDisabledMessage = () => { + if (collectBlockExists) { + return "Only one Collect step allowed" + } + if (!lastStep) { + return "Only available as the last step" + } + } + const external = actions.reduce((acc, elm) => { const [k, v] = elm if (!v.internal && !v.custom) { @@ -38,6 +62,15 @@ acc[k] = v } delete acc.LOOP + + // Filter out Collect block if not App Action or Webhook + if ( + !collectBlockAllowedSteps.includes( + $selectedAutomation.definition.trigger.stepId + ) + ) { + delete acc.COLLECT + } return acc }, {}) @@ -48,7 +81,6 @@ } return acc }, {}) - console.log(plugins) const selectAction = action => { actionVal = action @@ -72,7 +104,7 @@ @@ -107,7 +139,7 @@ Actions
{#each Object.entries(internal) as [idx, action]} - {@const isDisabled = disabled[idx] && disabled[idx].disabled} + {@const isDisabled = disabled()[idx] && disabled()[idx].disabled}
{action.name} - {#if isDisabled} - + {#if isDisabled && !syncAutomationsEnabled} +
+ + Business + +
+ {:else if isDisabled} + {/if}
@@ -152,6 +190,7 @@ display: flex; margin-left: var(--spacing-m); gap: var(--spacing-m); + align-items: center; } .item-list { display: grid; @@ -181,4 +220,8 @@ .disabled :global(.spectrum-Body) { color: var(--spectrum-global-color-gray-600); } + + .tag-color :global(.spectrum-Tags-item) { + background: var(--spectrum-global-color-gray-200); + } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte index 7484a60502..092138170f 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte @@ -17,7 +17,11 @@ import ActionModal from "./ActionModal.svelte" import FlowItemHeader from "./FlowItemHeader.svelte" import RoleSelect from "components/design/settings/controls/RoleSelect.svelte" - import { ActionStepID, TriggerStepID } from "constants/backend/automations" + import { + ActionStepID, + TriggerStepID, + Features, + } from "constants/backend/automations" import { permissions } from "stores/backend" export let block @@ -31,6 +35,9 @@ let showLooping = false let role + $: collectBlockExists = $selectedAutomation.definition.steps.some( + step => step.stepId === ActionStepID.COLLECT + ) $: automationId = $selectedAutomation?._id $: showBindingPicker = block.stepId === ActionStepID.CREATE_ROW || @@ -184,7 +191,7 @@ {#if !isTrigger}
- {#if !loopBlock} + {#if block?.features?.[Features.LOOPING] || !block.features} addLooping()} icon="Reuse"> Add Looping @@ -224,21 +231,28 @@
{/if} - - - - - - - -
-
- actionModal.show()} hoverable name="AddCircle" size="S" /> -{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2} +{#if !collectBlockExists || !lastStep}
+ actionModal.show()} + hoverable + name="AddCircle" + size="S" + /> + {#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2} +
+ {/if} {/if} + + + + + + + + diff --git a/packages/builder/src/components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte b/packages/builder/src/components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte index 4c47720692..1696a1870b 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte @@ -18,7 +18,6 @@ import { DatasourceFeature } from "@budibase/types" export let integration - export let modal // kill the reference so the input isn't saved let datasource = cloneDeep(integration) diff --git a/packages/builder/src/components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte b/packages/builder/src/components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte index de9ecce778..0783a9fe53 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte @@ -8,7 +8,6 @@ import { onMount } from "svelte" export let integration - export let modal // kill the reference so the input isn't saved let datasource = cloneDeep(integration) @@ -21,7 +20,6 @@ modal.show()} cancelText="Back" size="L" > diff --git a/packages/builder/src/components/backend/TableNavigator/TableDataImport.svelte b/packages/builder/src/components/backend/TableNavigator/TableDataImport.svelte index 8d8418eb81..f34a3e9c98 100644 --- a/packages/builder/src/components/backend/TableNavigator/TableDataImport.svelte +++ b/packages/builder/src/components/backend/TableNavigator/TableDataImport.svelte @@ -4,6 +4,7 @@ import { API } from "api" import { parseFile } from "./utils" + let fileInput let error = null let fileName = null let fileType = null @@ -16,6 +17,7 @@ export let schema = {} export let allValid = true export let displayColumn = null + export let promptUpload = false const typeOptions = [ { @@ -99,10 +101,19 @@ schema[name].type = e.detail schema[name].constraints = FIELDS[e.detail.toUpperCase()].constraints } + + const openFileUpload = (promptUpload, fileInput) => { + if (promptUpload && fileInput) { + fileInput.click() + } + } + + $: openFileUpload(promptUpload, fileInput)
{} export let afterSave = async table => { @@ -136,7 +137,13 @@ - +
diff --git a/packages/builder/src/components/common/AppLockModal.svelte b/packages/builder/src/components/common/AppLockModal.svelte deleted file mode 100644 index 875b4afce0..0000000000 --- a/packages/builder/src/components/common/AppLockModal.svelte +++ /dev/null @@ -1,143 +0,0 @@ - - -{#if lockedBy} -
- { - e.stopPropagation() - appLockModal.show() - }} - /> -
-{/if} - - - - - - Apps are locked to prevent work being lost from overlapping changes - between your team. - - {#if lockedByYou && getExpiryDuration(app) > 0} - - {processStringSync( - "This lock will expire in {{ duration time 'millisecond' }} from now.", - { - time: getExpiryDuration(app), - } - )} - - {/if} -
- - - {#if lockedByYou} - - {/if} - -
-
-
-
- - diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte new file mode 100644 index 0000000000..dadca85fac --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -0,0 +1,289 @@ + + +{#if label} +
+ +
+{/if} + +
+
+
+ + diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js new file mode 100644 index 0000000000..7987deff52 --- /dev/null +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -0,0 +1,387 @@ +import { EditorView } from "@codemirror/view" +import { getManifest } from "@budibase/string-templates" +import sanitizeHtml from "sanitize-html" +import { groupBy } from "lodash" + +export const EditorModes = { + JS: { + name: "javascript", + json: false, + match: /\$$/, + }, + Handlebars: { + name: "handlebars", + base: "text/html", + match: /{{[\s]*[\w\s]*/, + }, + Text: { + name: "text/html", + }, +} + +export const SECTIONS = { + HB_HELPER: { + name: "Helper", + type: "helper", + icon: "Code", + }, +} + +export const getDefaultTheme = opts => { + const { height, resize, dark } = opts + return EditorView.theme( + { + "&.cm-focused .cm-cursor": { + borderLeftColor: "var(--spectrum-alias-text-color)", + }, + "&": { + height: height ? `${height}` : "", + lineHeight: "1.3", + border: + "var(--spectrum-alias-border-size-thin) solid var(--spectrum-alias-border-color)", + borderRadius: "var(--border-radius-s)", + backgroundColor: + "var( --spectrum-textfield-m-background-color, var(--spectrum-global-color-gray-50) )", + resize: resize ? `${resize}` : "", + overflow: "hidden", + color: "var(--spectrum-alias-text-color)", + }, + "& .cm-tooltip.cm-tooltip-autocomplete > ul": { + fontFamily: + "var(--spectrum-alias-body-text-font-family, var(--spectrum-global-font-family-base))", + maxHeight: "16em", + }, + "& .cm-placeholder": { + color: "var(--spectrum-alias-text-color)", + fontStyle: "italic", + }, + "&.cm-focused": { + outline: "none", + borderColor: "var(--spectrum-alias-border-color-mouse-focus)", + }, + // AUTO COMPLETE + "& .cm-completionDetail": { + fontStyle: "unset", + textTransform: "uppercase", + fontSize: "10px", + backgroundColor: "var(--spectrum-global-color-gray-100)", + color: "var(--spectrum-global-color-gray-600)", + }, + "& .cm-completionLabel": { + marginLeft: + "calc(var(--spectrum-alias-workflow-icon-size-m) + var(--spacing-m))", + }, + "& .info-bubble": { + fontSize: "var(--font-size-s)", + display: "grid", + gridGap: "var(--spacing-s)", + gridTemplateColumns: "1fr", + color: "var(--spectrum-global-color-gray-800)", + }, + "& .cm-tooltip": { + marginLeft: "var(--spacing-s)", + border: "1px solid var(--spectrum-global-color-gray-300)", + borderRadius: + "var( --spectrum-popover-border-radius, var(--spectrum-alias-border-radius-regular) )", + backgroundColor: "var(--spectrum-global-color-gray-50)", + }, + // Section header + "& .info-section": { + display: "flex", + padding: "var(--spacing-s)", + gap: "var(--spacing-m)", + borderBottom: "1px solid var(--spectrum-global-color-gray-200)", + color: "var(--spectrum-global-color-gray-800)", + fontWeight: "bold", + }, + "& .info-section .spectrum-Icon": { + color: "var(--spectrum-global-color-gray-600)", + }, + // Autocomplete Option + "& .cm-tooltip.cm-tooltip-autocomplete .autocomplete-option": { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + fontSize: "var(--spectrum-alias-font-size-default)", + padding: "var(--spacing-s)", + color: "var(--spectrum-global-color-gray-800)", + }, + "& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": { + backgroundColor: "var(--spectrum-global-color-gray-200)", + }, + "& .binding-wrap": { + color: "var(--spectrum-global-color-blue-700)", + fontFamily: "monospace", + }, + }, + { dark } + ) +} + +export const buildHelperInfoNode = (completion, helper) => { + const ele = document.createElement("div") + ele.classList.add("info-bubble") + + const exampleNodeHtml = helper.example + ? `
${helper.example}
` + : "" + const descriptionMarkup = sanitizeHtml(helper.description, { + allowedTags: [], + allowedAttributes: {}, + }) + const descriptionNodeHtml = `
${descriptionMarkup}
` + + ele.innerHTML = ` + ${exampleNodeHtml} + ${descriptionNodeHtml} + ` + return ele +} + +const toSpectrumIcon = name => { + return ` + + ` +} + +export const buildSectionHeader = (type, sectionName, icon, rank) => { + const ele = document.createElement("div") + ele.classList.add("info-section") + ele.classList.add(type) + ele.innerHTML = `${toSpectrumIcon(icon)}${sectionName}` + return { + name: sectionName, + header: () => ele, + rank, + } +} + +export const helpersToCompletion = (helpers, mode) => { + const { type, name: sectionName, icon } = SECTIONS.HB_HELPER + const helperSection = buildSectionHeader(type, sectionName, icon, 99) + + return Object.keys(helpers).reduce((acc, key) => { + let helper = helpers[key] + acc.push({ + label: key, + info: completion => { + return buildHelperInfoNode(completion, helper) + }, + type: "helper", + section: helperSection, + detail: "FUNCTION", + apply: (view, completion, from, to) => { + insertBinding(view, from, to, key, mode) + }, + }) + return acc + }, []) +} + +export const getHelperCompletions = mode => { + const manifest = getManifest() + return Object.keys(manifest).reduce((acc, key) => { + acc = acc || [] + return [...acc, ...helpersToCompletion(manifest[key], mode)] + }, []) +} + +const bindingFilter = (options, query) => { + return options.filter(completion => { + const section_parsed = completion.section.name.toLowerCase() + const label_parsed = completion.label.toLowerCase() + const query_parsed = query.toLowerCase() + + return ( + section_parsed.includes(query_parsed) || + label_parsed.includes(query_parsed) + ) + }) +} + +export const hbAutocomplete = baseCompletions => { + async function coreCompletion(context) { + let bindingStart = context.matchBefore(EditorModes.Handlebars.match) + + let options = baseCompletions || [] + + if (!bindingStart) { + return null + } + // Accommodate spaces + const match = bindingStart.text.match(/{{[\s]*/) + const query = bindingStart.text.replace(match[0], "") + let filtered = bindingFilter(options, query) + + return { + from: bindingStart.from + match[0].length, + filter: false, + options: filtered, + } + } + + return coreCompletion +} + +export const jsAutocomplete = baseCompletions => { + async function coreCompletion(context) { + let jsBinding = context.matchBefore(/\$\("[\s\w]*/) + let options = baseCompletions || [] + + if (jsBinding) { + // Accommodate spaces + const match = jsBinding.text.match(/\$\("[\s]*/) + const query = jsBinding.text.replace(match[0], "") + let filtered = bindingFilter(options, query) + return { + from: jsBinding.from + match[0].length, + filter: false, + options: filtered, + } + } + + return null + } + + return coreCompletion +} + +export const buildBindingInfoNode = (completion, binding) => { + const ele = document.createElement("div") + ele.classList.add("info-bubble") + + const exampleNodeHtml = binding.readableBinding + ? `
{{ ${binding.readableBinding} }}
` + : "" + + const descriptionNodeHtml = binding.description + ? `
${binding.description}
` + : "" + + ele.innerHTML = ` + ${exampleNodeHtml} + ${descriptionNodeHtml} + ` + return ele +} + +// Readdress these methods. They shouldn't be used +export const hbInsert = (value, from, to, text) => { + let parsedInsert = "" + + const left = from ? value.substring(0, from) : "" + const right = to ? value.substring(to) : "" + + if (!left.includes("{{") || !right.includes("}}")) { + parsedInsert = `{{ ${text} }}` + } else { + parsedInsert = ` ${text} ` + } + + return parsedInsert +} + +export function jsInsert(value, from, to, text, { helper } = {}) { + let parsedInsert = "" + + const left = from ? value.substring(0, from) : "" + const right = to ? value.substring(to) : "" + + if (helper) { + parsedInsert = `helpers.${text}()` + } else if (!left.includes('$("') || !right.includes('")')) { + parsedInsert = `$("${text}")` + } else { + parsedInsert = text + } + + return parsedInsert +} + +// Autocomplete apply behaviour +export const insertBinding = (view, from, to, text, mode) => { + let parsedInsert + + if (mode.name == "javascript") { + parsedInsert = jsInsert(view.state.doc?.toString(), from, to, text) + } else if (mode.name == "handlebars") { + parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text) + } else { + console.log("Unsupported") + return + } + + let bindingClosePattern = mode.name == "javascript" ? /[\s]*"\)/ : /[\s]*}}/ + let sliced = view.state.doc?.toString().slice(to) + + const rightBrace = sliced.match(bindingClosePattern) + let cursorPos = from + parsedInsert.length + + if (rightBrace) { + cursorPos = from + parsedInsert.length + rightBrace[0].length + } + + view.dispatch({ + changes: { + from, + to, + insert: parsedInsert, + }, + selection: { + anchor: cursorPos, + }, + }) +} + +export const bindingsToCompletions = (bindings, mode) => { + const bindingByCategory = groupBy(bindings, "category") + const categoryMeta = bindings?.reduce((acc, ele) => { + acc[ele.category] = acc[ele.category] || {} + + if (ele.icon) { + acc[ele.category]["icon"] = acc[ele.category]["icon"] || ele.icon + } + if (typeof ele.display?.rank == "number") { + acc[ele.category]["rank"] = acc[ele.category]["rank"] || ele.display.rank + } + return acc + }, {}) + + const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => { + const { icon, rank } = categoryMeta[catKey] || {} + + const bindindSectionHeader = buildSectionHeader( + bindingByCategory.type, + catKey, + icon || "", + typeof rank == "number" ? rank : 1 + ) + + return [ + ...comps, + ...bindingByCategory[catKey].reduce((acc, binding) => { + let displayType = binding.fieldSchema?.type || binding.display?.type + acc.push({ + label: binding.display?.name || "NO NAME", + info: completion => { + return buildBindingInfoNode(completion, binding) + }, + type: "binding", + detail: displayType, + section: bindindSectionHeader, + apply: (view, completion, from, to) => { + insertBinding(view, from, to, binding.readableBinding, mode) + }, + }) + return acc + }, []), + ] + }, []) + + return completions +} diff --git a/packages/builder/src/components/common/DashCard.svelte b/packages/builder/src/components/common/DashCard.svelte index ac621ae68e..c6b89d56ff 100644 --- a/packages/builder/src/components/common/DashCard.svelte +++ b/packages/builder/src/components/common/DashCard.svelte @@ -28,7 +28,6 @@ .dash-card { background: var(--spectrum-alias-background-color-primary); border-radius: var(--border-radius-s); - overflow: hidden; min-height: 170px; } .dash-card-header { diff --git a/packages/builder/src/components/common/FontAwesomeIcon.svelte b/packages/builder/src/components/common/FontAwesomeIcon.svelte index 84c16abeda..364b3af25f 100644 --- a/packages/builder/src/components/common/FontAwesomeIcon.svelte +++ b/packages/builder/src/components/common/FontAwesomeIcon.svelte @@ -8,6 +8,7 @@ faLock, faFileArrowUp, faChevronLeft, + faCircleInfo, } from "@fortawesome/free-solid-svg-icons" import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons" @@ -20,7 +21,8 @@ faDiscord, faEnvelope, faFileArrowUp, - faChevronLeft + faChevronLeft, + faCircleInfo ) dom.watch() diff --git a/packages/builder/src/components/common/HelpMenu.svelte b/packages/builder/src/components/common/HelpMenu.svelte index 274565b255..06ff277d18 100644 --- a/packages/builder/src/components/common/HelpMenu.svelte +++ b/packages/builder/src/components/common/HelpMenu.svelte @@ -83,7 +83,7 @@ .help { z-index: 2; position: absolute; - bottom: var(--spacing-xl); + bottom: 24px; right: 24px; } diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 3a1c6c4fee..3843eabf45 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -1,17 +1,13 @@ - - - -
- {#if hoverTarget.title} -
{hoverTarget.title}
- {/if} - {#if hoverTarget.description} -
- {@html hoverTarget.description} -
- {/if} - {#if hoverTarget.example} -
{hoverTarget.example}
- {/if} -
-
-
-
+ + +
+ { + if (selectedMode == mode) { + return true + } - - - - {#if selectedCategory} -
- { - selectedCategory = null - }} - > - Back - -
- {/if} + //Get the current mode value + const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue - {#if !selectedCategory} -
Search
- - {/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 (editorValue) { + targetMode = selectedMode + return false + } + return true + }} + > + +
    +
    +
    + {#if targetMode} +
    +
    + + {`Switch to ${targetMode}?`} + + This will discard anything in your binding +
    + + +
    +
    +
    + {/if} + +
    +
  • - {/each} -
- {/if} - {/each} - - {#if selectedCategory === "Helpers" || search} - {#if filteredHelpers?.length} -
Helpers
-
    - {#each filteredHelpers as helper} -
  • addHelper(helper, usingJS)} - on:mouseenter={e => { - popoverAnchor = e.target - if (!helper.displayText && helper.description) { - return - } - hoverTarget = { - title: helper.displayText, - description: helper.description, - example: getHelperExample(helper, usingJS), - } - popover.show() - e.stopPropagation() - }} - on:mouseleave={() => { - popover.hide() - popoverAnchor = null - hoverTarget = null - }} - on:focus={() => {}} - on:blur={() => {}} - > - {helper.displayText} - - function - -
  • - {/each} -
- {/if} - {/if} - {/if} -
-
-
- - -
-