Merge branch 'develop' of github.com:Budibase/budibase into feature/table-fetching-frontend

This commit is contained in:
mike12345567 2023-06-05 14:21:35 +01:00
commit 805e417553
233 changed files with 5188 additions and 2599 deletions

View File

@ -37,14 +37,17 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 14.x node-version: 14.x
cache: "yarn" cache: "yarn"
- run: 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: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -52,7 +55,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
@ -72,7 +75,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
@ -82,7 +85,7 @@ jobs:
- run: yarn test --scope=@budibase/worker --scope=@budibase/server - run: yarn test --scope=@budibase/worker --scope=@budibase/server
- uses: codecov/codecov-action@v3 - uses: codecov/codecov-action@v3
with: with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella name: codecov-umbrella
verbose: true verbose: true
@ -92,7 +95,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
@ -107,7 +110,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x - name: Use Node.js 14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
@ -131,7 +134,7 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Check submodule - name: Check submodule
run: | run: |

View File

@ -1,5 +1,7 @@
name: Budibase Prerelease name: Budibase Prerelease
concurrency: release-prerelease concurrency:
group: release-prerelease
cancel-in-progress: false
on: on:
push: push:

View File

@ -1,5 +1,7 @@
name: Budibase Release name: Budibase Release
concurrency: release concurrency:
group: release
cancel-in-progress: false
on: on:
push: push:

View File

@ -1,5 +1,7 @@
name: Tag prerelease name: Tag prerelease
concurrency: release-prerelease concurrency:
group: tag-prerelease
cancel-in-progress: false
on: on:
push: push:

View File

@ -1,5 +1,7 @@
name: Tag release name: Tag release
concurrency: release-prerelease concurrency:
group: tag-release
cancel-in-progress: false
on: on:
push: push:

View File

@ -1,5 +1,5 @@
{ {
"version": "2.6.19-alpha.11", "version": "2.6.19-alpha.52",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/backend-core", "packages/backend-core",

View File

@ -4,7 +4,6 @@
"devDependencies": { "devDependencies": {
"@esbuild-plugins/node-resolve": "^0.2.2", "@esbuild-plugins/node-resolve": "^0.2.2",
"@esbuild-plugins/tsconfig-paths": "^0.1.2", "@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/esbuild": "16.2.1",
"@nx/js": "16.2.1", "@nx/js": "16.2.1",
"@rollup/plugin-json": "^4.0.2", "@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0", "@typescript-eslint/parser": "5.45.0",
@ -34,6 +33,7 @@
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'", "bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "yarn nx run-many -t=build", "build": "yarn nx run-many -t=build",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", "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:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'", "backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"build:sdk": "lerna run --stream build:sdk", "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: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: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: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", "test": "lerna run --stream test --stream",
"lint:eslint": "eslint packages && eslint qa-core", "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}\"", "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" "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": {} "dependencies": {}
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "0.0.1", "version": "0.0.0",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"types": "dist/src/index.d.ts", "types": "dist/src/index.d.ts",
@ -22,7 +22,7 @@
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.2", "@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "0.0.1", "@budibase/types": "0.0.0",
"@shopify/jest-koa-mocks": "5.0.1", "@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2", "@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0", "aws-cloudfront-sign": "2.2.0",
@ -33,7 +33,7 @@
"correlation-id": "4.0.0", "correlation-id": "4.0.0",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"emitter-listener": "1.1.2", "emitter-listener": "1.1.2",
"ioredis": "4.28.0", "ioredis": "5.3.2",
"joi": "17.6.0", "joi": "17.6.0",
"jsonwebtoken": "9.0.0", "jsonwebtoken": "9.0.0",
"koa-passport": "4.1.4", "koa-passport": "4.1.4",
@ -62,7 +62,6 @@
"@swc/jest": "^0.2.24", "@swc/jest": "^0.2.24",
"@trendyol/jest-testcontainers": "^2.1.1", "@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/ioredis": "4.28.0",
"@types/jest": "29.5.0", "@types/jest": "29.5.0",
"@types/koa": "2.13.4", "@types/koa": "2.13.4",
"@types/lodash": "4.14.180", "@types/lodash": "4.14.180",
@ -74,7 +73,7 @@
"@types/tar-fs": "2.0.1", "@types/tar-fs": "2.0.1",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"chance": "1.1.8", "chance": "1.1.8",
"ioredis-mock": "5.8.0", "ioredis-mock": "8.7.0",
"jest": "29.5.0", "jest": "29.5.0",
"jest-environment-node": "29.5.0", "jest-environment-node": "29.5.0",
"jest-serial-runner": "^1.2.1", "jest-serial-runner": "^1.2.1",

View File

@ -72,16 +72,12 @@ describe("writethrough", () => {
writethrough.put({ ...current, value: 4 }), writethrough.put({ ...current, value: 4 }),
]) ])
// with a lock, this will work
const newRev = responses.map(x => x.rev).find(x => x !== current._rev) const newRev = responses.map(x => x.rev).find(x => x !== current._rev)
expect(newRev).toBeDefined() expect(newRev).toBeDefined()
expect(responses.map(x => x.rev)).toEqual( expect(responses.map(x => x.rev)).toEqual(
expect.arrayContaining([current._rev, current._rev, newRev]) 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) const output = await db.get(current._id)
expect(output.value).toBe(4) expect(output.value).toBe(4)

View File

@ -16,6 +16,7 @@ export enum Header {
LICENSE_KEY = "x-budibase-license-key", LICENSE_KEY = "x-budibase-license-key",
API_VER = "x-budibase-api-version", API_VER = "x-budibase-api-version",
APP_ID = "x-budibase-app-id", APP_ID = "x-budibase-app-id",
SESSION_ID = "x-budibase-session-id",
TYPE = "x-budibase-type", TYPE = "x-budibase-type",
PREVIEW_ROLE = "x-budibase-role", PREVIEW_ROLE = "x-budibase-role",
TENANT_ID = "x-budibase-tenant-id", TENANT_ID = "x-budibase-tenant-id",

View File

@ -97,7 +97,6 @@ const environment = {
REDIS_URL: process.env.REDIS_URL || "localhost:6379", REDIS_URL: process.env.REDIS_URL || "localhost:6379",
REDIS_PASSWORD: process.env.REDIS_PASSWORD, REDIS_PASSWORD: process.env.REDIS_PASSWORD,
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED, REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
MOCK_REDIS: process.env.MOCK_REDIS,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
AWS_REGION: process.env.AWS_REGION, AWS_REGION: process.env.AWS_REGION,
@ -129,6 +128,7 @@ const environment = {
PLUGIN_BUCKET_NAME: PLUGIN_BUCKET_NAME:
process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS,
USE_COUCH: process.env.USE_COUCH || true, USE_COUCH: process.env.USE_COUCH || true,
MOCK_REDIS: process.env.MOCK_REDIS,
DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE,
SERVICE: process.env.SERVICE || "budibase", SERVICE: process.env.SERVICE || "budibase",
LOG_LEVEL: process.env.LOG_LEVEL || "info", LOG_LEVEL: process.env.LOG_LEVEL || "info",

View File

@ -21,6 +21,7 @@ export * as context from "./context"
export * as cache from "./cache" export * as cache from "./cache"
export * as objectStore from "./objectStore" export * as objectStore from "./objectStore"
export * as redis from "./redis" export * as redis from "./redis"
export { Client as RedisClient } from "./redis"
export * as locks from "./redis/redlockImpl" export * as locks from "./redis/redlockImpl"
export * as utils from "./utils" export * as utils from "./utils"
export * as errors from "./errors" export * as errors from "./errors"

View File

@ -96,6 +96,7 @@ if (!env.DISABLE_PINO_LOGGER) {
const mergingObject: any = { const mergingObject: any = {
err: error, err: error,
pid: process.pid,
...contextObject, ...contextObject,
} }

View File

@ -6,7 +6,8 @@ let userClient: Client,
appClient: Client, appClient: Client,
cacheClient: Client, cacheClient: Client,
writethroughClient: Client, writethroughClient: Client,
lockClient: Client lockClient: Client,
socketClient: Client
async function init() { async function init() {
userClient = await new Client(utils.Databases.USER_CACHE).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() appClient = await new Client(utils.Databases.APP_METADATA).init()
cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init() cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init()
lockClient = await new Client(utils.Databases.LOCKS).init() lockClient = await new Client(utils.Databases.LOCKS).init()
writethroughClient = await new Client( writethroughClient = await new Client(utils.Databases.WRITE_THROUGH).init()
utils.Databases.WRITE_THROUGH, socketClient = await new Client(
utils.SelectableDatabase.WRITE_THROUGH utils.Databases.SOCKET_IO,
utils.SelectableDatabase.SOCKET_IO
).init() ).init()
} }
@ -27,6 +29,7 @@ export async function shutdown() {
if (cacheClient) await cacheClient.finish() if (cacheClient) await cacheClient.finish()
if (writethroughClient) await writethroughClient.finish() if (writethroughClient) await writethroughClient.finish()
if (lockClient) await lockClient.finish() if (lockClient) await lockClient.finish()
if (socketClient) await socketClient.finish()
} }
process.on("exit", async () => { process.on("exit", async () => {
@ -74,3 +77,10 @@ export async function getLockClient() {
} }
return lockClient return lockClient
} }
export async function getSocketClient() {
if (!socketClient) {
await init()
}
return socketClient
}

View File

@ -1,6 +1,15 @@
import env from "../environment" import env from "../environment"
// ioredis mock is all in memory import Redis from "ioredis"
const Redis = env.MOCK_REDIS ? require("ioredis-mock") : require("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 { import {
addDbPrefix, addDbPrefix,
removeDbPrefix, removeDbPrefix,
@ -18,7 +27,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
// for testing just generate the client once // for testing just generate the client once
let CLOSED = false let CLOSED = false
let CLIENTS: { [key: number]: any } = {} let CLIENTS: { [key: number]: any } = {}
0
let CONNECTED = false let CONNECTED = false
// mock redis always connected // mock redis always connected
@ -55,6 +64,7 @@ function connectionError(
* will return the ioredis client which will be ready to use. * will return the ioredis client which will be ready to use.
*/ */
function init(selectDb = DEFAULT_SELECT_DB) { function init(selectDb = DEFAULT_SELECT_DB) {
const RedisCore = env.MOCK_REDIS && MockRedis ? MockRedis : Redis
let timeout: NodeJS.Timeout let timeout: NodeJS.Timeout
CLOSED = false CLOSED = false
let client = pickClient(selectDb) let client = pickClient(selectDb)
@ -64,7 +74,7 @@ function init(selectDb = DEFAULT_SELECT_DB) {
} }
// testing uses a single in memory client // testing uses a single in memory client
if (env.MOCK_REDIS) { if (env.MOCK_REDIS) {
CLIENTS[selectDb] = new Redis(getRedisOptions()) CLIENTS[selectDb] = new RedisCore(getRedisOptions())
} }
// start the timer - only allowed 5 seconds to connect // start the timer - only allowed 5 seconds to connect
timeout = setTimeout(() => { timeout = setTimeout(() => {
@ -84,11 +94,11 @@ function init(selectDb = DEFAULT_SELECT_DB) {
const { redisProtocolUrl, opts, host, port } = getRedisOptions() const { redisProtocolUrl, opts, host, port } = getRedisOptions()
if (CLUSTERED) { if (CLUSTERED) {
client = new Redis.Cluster([{ host, port }], opts) client = new RedisCore.Cluster([{ host, port }], opts)
} else if (redisProtocolUrl) { } else if (redisProtocolUrl) {
client = new Redis(redisProtocolUrl) client = new RedisCore(redisProtocolUrl)
} else { } else {
client = new Redis(opts) client = new RedisCore(opts)
} }
// attach handlers // attach handlers
client.on("end", (err: Error) => { client.on("end", (err: Error) => {
@ -183,6 +193,9 @@ class RedisWrapper {
CLOSED = false CLOSED = false
init(this._select) init(this._select)
await waitForConnection(this._select) await waitForConnection(this._select)
if (this._select && !env.isTest()) {
this.getClient().select(this._select)
}
return this return this
} }
@ -209,6 +222,11 @@ class RedisWrapper {
return this.getClient().keys(addDbPrefix(db, pattern)) 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) { async get(key: string) {
const db = this._db const db = this._db
let response = await this.getClient().get(addDbPrefix(db, key)) let response = await this.getClient().get(addDbPrefix(db, key))

View File

@ -4,10 +4,10 @@ import { LockOptions, LockType } from "@budibase/types"
import * as context from "../context" import * as context from "../context"
import env from "../environment" import env from "../environment"
const getClient = async ( async function getClient(
type: LockType, type: LockType,
opts?: Redlock.Options opts?: Redlock.Options
): Promise<Redlock> => { ): Promise<Redlock> {
if (type === LockType.CUSTOM) { if (type === LockType.CUSTOM) {
return newRedlock(opts) return newRedlock(opts)
} }
@ -18,6 +18,9 @@ const getClient = async (
case LockType.TRY_ONCE: { case LockType.TRY_ONCE: {
return newRedlock(OPTIONS.TRY_ONCE) return newRedlock(OPTIONS.TRY_ONCE)
} }
case LockType.TRY_TWICE: {
return newRedlock(OPTIONS.TRY_TWICE)
}
case LockType.DEFAULT: { case LockType.DEFAULT: {
return newRedlock(OPTIONS.DEFAULT) return newRedlock(OPTIONS.DEFAULT)
} }
@ -35,6 +38,9 @@ const OPTIONS = {
// immediately throws an error if the lock is already held // immediately throws an error if the lock is already held
retryCount: 0, retryCount: 0,
}, },
TRY_TWICE: {
retryCount: 1,
},
TEST: { TEST: {
// higher retry count in unit tests // higher retry count in unit tests
// due to high contention. // 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 } let options = { ...OPTIONS.DEFAULT, ...opts }
const redisWrapper = await getLockClient() const redisWrapper = await getLockClient()
const client = redisWrapper.getClient() const client = redisWrapper.getClient()
@ -81,22 +87,26 @@ type RedlockExecution<T> =
| SuccessfulRedlockExecution<T> | SuccessfulRedlockExecution<T>
| UnsuccessfulRedlockExecution | UnsuccessfulRedlockExecution
export const doWithLock = async <T>( 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<T>(
opts: LockOptions, opts: LockOptions,
task: () => Promise<T> task: () => Promise<T>
): Promise<RedlockExecution<T>> => { ): Promise<RedlockExecution<T>> {
const redlock = await getClient(opts.type, opts.customOptions) const redlock = await getClient(opts.type, opts.customOptions)
let lock let lock
try { try {
// determine lock name const name = getLockName(opts)
// 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}`
}
// create the lock // create the lock
lock = await redlock.lock(name, opts.ttl) lock = await redlock.lock(name, opts.ttl)
@ -112,7 +122,6 @@ export const doWithLock = async <T>(
if (opts.type === LockType.TRY_ONCE) { if (opts.type === LockType.TRY_ONCE) {
// don't throw for try-once locks, they will always error // don't throw for try-once locks, they will always error
// due to retry count (0) exceeded // due to retry count (0) exceeded
console.warn(e)
return { executed: false } return { executed: false }
} else { } else {
console.error(e) console.error(e)

View File

@ -27,6 +27,7 @@ export enum Databases {
GENERIC_CACHE = "data_cache", GENERIC_CACHE = "data_cache",
WRITE_THROUGH = "writeThrough", WRITE_THROUGH = "writeThrough",
LOCKS = "locks", LOCKS = "locks",
SOCKET_IO = "socket_io",
} }
/** /**
@ -40,7 +41,7 @@ export enum Databases {
*/ */
export enum SelectableDatabase { export enum SelectableDatabase {
DEFAULT = 0, DEFAULT = 0,
WRITE_THROUGH = 1, SOCKET_IO = 1,
UNUSED_1 = 2, UNUSED_1 = 2,
UNUSED_2 = 3, UNUSED_2 = 3,
UNUSED_3 = 4, UNUSED_3 = 4,
@ -94,7 +95,7 @@ export function getRedisOptions() {
opts.port = port opts.port = port
opts.password = password opts.password = password
} }
return { opts, host, port, redisProtocolUrl } return { opts, host, port: parseInt(port), redisProtocolUrl }
} }
export function addDbPrefix(db: string, key: string) { export function addDbPrefix(db: string, key: string) {

View File

@ -90,6 +90,10 @@ export const useScimIntegration = () => {
return useFeature(Feature.SCIM) return useFeature(Feature.SCIM)
} }
export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS)
}
// QUOTAS // QUOTAS
export const setAutomationLogsQuota = (value: number) => { export const setAutomationLogsQuota = (value: number) => {

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "0.0.1", "version": "0.0.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,8 +38,8 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1", "@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/shared-core": "0.0.1", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.1", "@budibase/string-templates": "0.0.0",
"@spectrum-css/accordion": "3.0.24", "@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1", "@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1", "@spectrum-css/actiongroup": "1.0.1",

View File

@ -56,6 +56,8 @@ export default function positionDropdown(element, opts) {
styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") { } else if (align === "right-outside") {
styles.left = anchorBounds.right + offset styles.left = anchorBounds.right + offset
} else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset
} else { } else {
styles.left = anchorBounds.left styles.left = anchorBounds.left
} }

View File

@ -13,10 +13,12 @@
export let url = "" export let url = ""
export let disabled = false export let disabled = false
export let initials = "JD" export let initials = "JD"
export let color = null
const DefaultColor = "#3aab87" const DefaultColor = "#3aab87"
$: color = getColor(initials) $: avatarColor = color || getColor(initials)
$: style = getStyle(size, avatarColor)
const getColor = initials => { const getColor = initials => {
if (!initials?.length) { if (!initials?.length) {
@ -26,6 +28,12 @@
const hue = ((code % 26) / 26) * 360 const hue = ((code % 26) / 26) * 360
return `hsl(${hue}, 50%, 50%)` 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};`
}
</script> </script>
{#if url} {#if url}
@ -37,13 +45,7 @@
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});" style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
/> />
{:else} {:else}
<div <div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
class="spectrum-Avatar"
class:is-disabled={disabled}
style="width: var({sizes.get(size)}); height: var({sizes.get(
size
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
>
{initials || ""} {initials || ""}
</div> </div>
{/if} {/if}

View File

@ -3,11 +3,13 @@
import Button from "../Button/Button.svelte" import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte" import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte" import Heading from "../Typography/Heading.svelte"
import { setContext } from "svelte"
export let title export let title
export let fillWidth export let fillWidth
export let left = "314px" export let left = "314px"
export let width = "calc(100% - 626px)" export let width = "calc(100% - 626px)"
export let headless = false
let visible = false let visible = false
@ -25,6 +27,11 @@
visible = false visible = false
} }
setContext("drawer-actions", {
hide,
show,
})
const easeInOutQuad = x => { const easeInOutQuad = x => {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2 return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2
} }
@ -47,27 +54,34 @@
<section <section
class:fillWidth class:fillWidth
class="drawer" class="drawer"
class:headless
transition:slide|local transition:slide|local
style={`width: ${width}; left: ${left};`} style={`width: ${width}; left: ${left};`}
> >
<header> {#if !headless}
<div class="text"> <header>
<Heading size="XS">{title}</Heading> <div class="text">
<Body size="S"> <Heading size="XS">{title}</Heading>
<slot name="description" /> <Body size="S">
</Body> <slot name="description" />
</div> </Body>
<div class="buttons"> </div>
<Button secondary quiet on:click={hide}>Cancel</Button> <div class="buttons">
<slot name="buttons" /> <Button secondary quiet on:click={hide}>Cancel</Button>
</div> <slot name="buttons" />
</header> </div>
</header>
{/if}
<slot name="body" /> <slot name="body" />
</section> </section>
</Portal> </Portal>
{/if} {/if}
<style> <style>
.drawer.headless :global(.drawer-contents) {
height: calc(40vh + 75px);
}
.buttons { .buttons {
display: flex; display: flex;
gap: var(--spacing-m); gap: var(--spacing-m);

View File

@ -165,7 +165,7 @@
{/if} {/if}
{#if !disabled} {#if !disabled}
<div class="delete-button" on:click={removeFile}> <div class="delete-button" on:click={removeFile}>
<Icon name="Close" /> <Icon name="Delete" />
</div> </div>
{/if} {/if}
</div> </div>
@ -209,7 +209,7 @@
{/if} {/if}
{#if !disabled} {#if !disabled}
<div class="delete-button" on:click={removeFile}> <div class="delete-button" on:click={removeFile}>
<Icon name="Close" /> <Icon name="Delete" />
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -12,6 +12,7 @@
export let emphasized = false export let emphasized = false
export let onTop = false export let onTop = false
export let size = "M" export let size = "M"
export let beforeSwitch = null
let thisSelected = undefined let thisSelected = undefined
@ -28,9 +29,18 @@
thisSelected = selected thisSelected = selected
dispatch("select", thisSelected) dispatch("select", thisSelected)
} else if ($tab.title !== thisSelected) { } else if ($tab.title !== thisSelected) {
thisSelected = $tab.title if (typeof beforeSwitch == "function") {
selected = $tab.title const proceed = beforeSwitch($tab.title)
dispatch("select", thisSelected) if (proceed) {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
}
} else {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
}
} }
if ($tab.title !== thisSelected) { if ($tab.title !== thisSelected) {
tab.update(state => { tab.update(state => {

View File

@ -31,4 +31,12 @@
.spectrum-Tooltip-tip { .spectrum-Tooltip-tip {
border-top-color: var(--spectrum-global-color-gray-500); border-top-color: var(--spectrum-global-color-gray-500);
} }
.spectrum-Tooltip {
max-width: 280px;
}
.spectrum-Tooltip-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style> </style>

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "0.0.1", "version": "0.0.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -58,11 +58,18 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "0.0.1", "@budibase/bbui": "0.0.0",
"@budibase/frontend-core": "0.0.1", "@budibase/frontend-core": "0.0.0",
"@budibase/shared-core": "0.0.1", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.1", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.1", "@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/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1", "@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1", "@fortawesome/free-solid-svg-icons": "^6.2.1",

View File

@ -77,7 +77,7 @@ export const getAuthBindings = () => {
runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`, runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
readable: `Current User.OAuthToken`, readable: `Current User.OAuthToken`,
key: "accessToken", key: "accessToken",
display: { name: "OAuthToken" }, display: { name: "OAuthToken", type: "text" },
}, },
] ]
@ -434,6 +434,9 @@ export const getUserBindings = () => {
providerId: "user", providerId: "user",
category: "Current User", category: "Current User",
icon: "User", icon: "User",
display: {
name: key,
},
}) })
return acc return acc
}, []) }, [])
@ -550,7 +553,7 @@ const getUrlBindings = asset => {
readableBinding: `URL.${param}`, readableBinding: `URL.${param}`,
category: "URL", category: "URL",
icon: "RailTop", icon: "RailTop",
display: { type: "string" }, display: { type: "string", name: param },
})) }))
const queryParamsBinding = { const queryParamsBinding = {
type: "context", type: "context",
@ -558,7 +561,7 @@ const getUrlBindings = asset => {
readableBinding: "Query params", readableBinding: "Query params",
category: "URL", category: "URL",
icon: "RailTop", icon: "RailTop",
display: { type: "object" }, display: { type: "object", name: "Query params" },
} }
return urlParamBindings.concat([queryParamsBinding]) return urlParamBindings.concat([queryParamsBinding])
} }
@ -589,7 +592,6 @@ export const getEventContextBindings = (
actionId actionId
) => { ) => {
let bindings = [] let bindings = []
// Check if any context bindings are provided by the component for this // Check if any context bindings are provided by the component for this
// setting // setting
const component = findComponent(asset.props, componentId) const component = findComponent(asset.props, componentId)
@ -605,6 +607,9 @@ export const getEventContextBindings = (
)}`, )}`,
category: component._instanceName, category: component._instanceName,
icon: def.icon, icon: def.icon,
display: {
name: contextEntry.label,
},
}) })
}) })
} }
@ -628,6 +633,9 @@ export const getEventContextBindings = (
runtimeBinding: `actions.${idx}.${contextValue.value}`, runtimeBinding: `actions.${idx}.${contextValue.value}`,
category: "Actions", category: "Actions",
icon: "JourneyAction", icon: "JourneyAction",
display: {
name: contextValue.label,
},
}) })
}) })
} }

View File

@ -2,6 +2,7 @@ import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation" import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal" import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users"
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
@ -12,6 +13,7 @@ export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore() export const temporalStore = getTemporalStore()
export const userStore = getUserStore()
// Setup history for screens // Setup history for screens
export const screenHistoryStore = createHistoryStore({ export const screenHistoryStore = createHistoryStore({

View File

@ -37,8 +37,10 @@ import {
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields" import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket"
const INITIAL_FRONTEND_STATE = { const INITIAL_FRONTEND_STATE = {
initialised: false,
apps: [], apps: [],
name: "", name: "",
url: "", url: "",
@ -69,7 +71,9 @@ const INITIAL_FRONTEND_STATE = {
customTheme: {}, customTheme: {},
previewDevice: "desktop", previewDevice: "desktop",
highlightedSettingKey: null, highlightedSettingKey: null,
propertyFocus: null,
builderSidePanel: false, builderSidePanel: false,
hasLock: true,
// URL params // URL params
selectedScreenId: null, selectedScreenId: null,
@ -86,6 +90,7 @@ const INITIAL_FRONTEND_STATE = {
export const getFrontendStore = () => { export const getFrontendStore = () => {
const store = writable({ ...INITIAL_FRONTEND_STATE }) const store = writable({ ...INITIAL_FRONTEND_STATE })
let websocket
// This is a fake implementation of a "patch" API endpoint to try and prevent // 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, // 409s. All screen doc mutations (aside from creation) use this function,
@ -110,10 +115,11 @@ export const getFrontendStore = () => {
store.actions = { store.actions = {
reset: () => { reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE }) store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
}, },
initialise: async pkg => { 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) await store.actions.components.refreshDefinitions(application.appId)
// Reset store state // Reset store state
@ -137,6 +143,8 @@ export const getFrontendStore = () => {
upgradableVersion: application.upgradableVersion, upgradableVersion: application.upgradableVersion,
navigation: application.navigation || {}, navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [], usedPlugins: application.usedPlugins || [],
hasLock,
initialised: true,
})) }))
screenHistoryStore.reset() screenHistoryStore.reset()
automationHistoryStore.reset() automationHistoryStore.reset()
@ -1319,6 +1327,12 @@ export const getFrontendStore = () => {
highlightedSettingKey: key, highlightedSettingKey: key,
})) }))
}, },
propertyFocus: key => {
store.update(state => ({
...state,
propertyFocus: key,
}))
},
}, },
dnd: { dnd: {
start: component => { start: component => {

View File

@ -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,
},
}
}

View File

@ -1,3 +1,4 @@
import { ActionStepID } from "constants/backend/automations"
import { TableNames } from "../constants" import { TableNames } from "../constants"
import { import {
AUTO_COLUMN_DISPLAY_NAMES, AUTO_COLUMN_DISPLAY_NAMES,
@ -53,3 +54,9 @@ export function buildAutoColumn(tableName, name, subtype) {
} }
return base return base
} }
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -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
}

View File

@ -6,24 +6,48 @@
Body, Body,
Icon, Icon,
notifications, notifications,
Tags,
Tag,
} from "@budibase/bbui" } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
import { admin } from "stores/portal" import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions" import { externalActions } from "./ExternalActions"
import { TriggerStepID } from "constants/backend/automations"
import { checkForCollectStep } from "builderStore/utils"
export let blockIdx export let blockIdx
export let lastStep
const disabled = { let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
SEND_EMAIL_SMTP: { let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
}
let selectedAction let selectedAction
let actionVal let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION) 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 external = actions.reduce((acc, elm) => {
const [k, v] = elm const [k, v] = elm
if (!v.internal && !v.custom) { if (!v.internal && !v.custom) {
@ -38,6 +62,15 @@
acc[k] = v acc[k] = v
} }
delete acc.LOOP 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 return acc
}, {}) }, {})
@ -48,7 +81,6 @@
} }
return acc return acc
}, {}) }, {})
console.log(plugins)
const selectAction = action => { const selectAction = action => {
actionVal = action actionVal = action
@ -72,7 +104,7 @@
<ModalContent <ModalContent
title="Add automation step" title="Add automation step"
confirmText="Save" confirmText="Save"
size="M" size="L"
disabled={!selectedAction} disabled={!selectedAction}
onConfirm={addBlockToAutomation} onConfirm={addBlockToAutomation}
> >
@ -107,7 +139,7 @@
<Detail size="S">Actions</Detail> <Detail size="S">Actions</Detail>
<div class="item-list"> <div class="item-list">
{#each Object.entries(internal) as [idx, action]} {#each Object.entries(internal) as [idx, action]}
{@const isDisabled = disabled[idx] && disabled[idx].disabled} {@const isDisabled = disabled()[idx] && disabled()[idx].disabled}
<div <div
class="item" class="item"
class:disabled={isDisabled} class:disabled={isDisabled}
@ -117,8 +149,14 @@
<div class="item-body"> <div class="item-body">
<Icon name={action.icon} /> <Icon name={action.icon} />
<Body size="XS">{action.name}</Body> <Body size="XS">{action.name}</Body>
{#if isDisabled} {#if isDisabled && !syncAutomationsEnabled}
<Icon name="Help" tooltip={disabled[idx].message} /> <div class="tag-color">
<Tags>
<Tag icon="LockClosed">Business</Tag>
</Tags>
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{/if} {/if}
</div> </div>
</div> </div>
@ -152,6 +190,7 @@
display: flex; display: flex;
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
gap: var(--spacing-m); gap: var(--spacing-m);
align-items: center;
} }
.item-list { .item-list {
display: grid; display: grid;
@ -181,4 +220,8 @@
.disabled :global(.spectrum-Body) { .disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
} }
.tag-color :global(.spectrum-Tags-item) {
background: var(--spectrum-global-color-gray-200);
}
</style> </style>

View File

@ -17,7 +17,11 @@
import ActionModal from "./ActionModal.svelte" import ActionModal from "./ActionModal.svelte"
import FlowItemHeader from "./FlowItemHeader.svelte" import FlowItemHeader from "./FlowItemHeader.svelte"
import RoleSelect from "components/design/settings/controls/RoleSelect.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" import { permissions } from "stores/backend"
export let block export let block
@ -31,6 +35,9 @@
let showLooping = false let showLooping = false
let role let role
$: collectBlockExists = $selectedAutomation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
$: automationId = $selectedAutomation?._id $: automationId = $selectedAutomation?._id
$: showBindingPicker = $: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW || block.stepId === ActionStepID.CREATE_ROW ||
@ -184,7 +191,7 @@
{#if !isTrigger} {#if !isTrigger}
<div> <div>
<div class="block-options"> <div class="block-options">
{#if !loopBlock} {#if block?.features?.[Features.LOOPING] || !block.features}
<ActionButton on:click={() => addLooping()} icon="Reuse"> <ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping Add Looping
</ActionButton> </ActionButton>
@ -224,21 +231,28 @@
</Layout> </Layout>
</div> </div>
{/if} {/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
</div> </div>
<div class="separator" /> {#if !collectBlockExists || !lastStep}
<Icon on:click={() => actionModal.show()} hoverable name="AddCircle" size="S" />
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" /> <div class="separator" />
<Icon
on:click={() => actionModal.show()}
hoverable
name="AddCircle"
size="S"
/>
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" />
{/if}
{/if} {/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
<style> <style>
.delete-padding { .delete-padding {
padding-left: 30px; padding-left: 30px;

View File

@ -11,8 +11,8 @@
ActionButton, ActionButton,
Drawer, Drawer,
Modal, Modal,
Detail,
notifications, notifications,
Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore" import { automationStore, selectedAutomation } from "builderStore"
@ -27,9 +27,18 @@
import CronBuilder from "./CronBuilder.svelte" import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import {
bindingsToCompletions,
jsAutocomplete,
EditorModes,
} from "components/common/CodeEditor"
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte" import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte"
import { LuceneUtils } from "@budibase/frontend-core" import { LuceneUtils } from "@budibase/frontend-core"
import { getSchemaForTable } from "builderStore/dataBinding" import {
getSchemaForTable,
getEnvironmentBindings,
} from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte" import { onMount } from "svelte"
@ -43,7 +52,6 @@
let webhookModal let webhookModal
let drawer let drawer
let fillWidth = true let fillWidth = true
let codeBindingOpen = false
let inputData let inputData
$: filters = lookForFilters(schemaProperties) || [] $: filters = lookForFilters(schemaProperties) || []
@ -210,6 +218,19 @@
} }
const outputs = Object.entries(schema) const outputs = Object.entries(schema)
let bindingIcon = ""
let bindindingRank = 0
if (idx === 0) {
bindingIcon = automation.trigger.icon
} else if (isLoopBlock) {
bindingIcon = "Reuse"
bindindingRank = idx + 1
} else {
bindingIcon = allSteps[idx].icon
bindindingRank = idx - loopBlockCount
}
bindings = bindings.concat( bindings = bindings.concat(
outputs.map(([name, value]) => { outputs.map(([name, value]) => {
let runtimeName = isLoopBlock let runtimeName = isLoopBlock
@ -218,17 +239,24 @@
? `steps[${idx - loopBlockCount}].${name}` ? `steps[${idx - loopBlockCount}].${name}`
: `steps.${idx - loopBlockCount}.${name}` : `steps.${idx - loopBlockCount}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : runtimeName const runtime = idx === 0 ? `trigger.${name}` : runtimeName
const categoryName =
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx - loopBlockCount} outputs`
return { return {
label: runtime, readableBinding: runtime,
runtimeBinding: runtime,
type: value.type, type: value.type,
description: value.description, description: value.description,
category: icon: bindingIcon,
idx === 0 category: categoryName,
? "Trigger outputs" display: {
: isLoopBlock type: value.type,
? "Loop Outputs" name: name,
: `Step ${idx - loopBlockCount} outputs`, rank: bindindingRank,
path: runtime, },
} }
}) })
) )
@ -237,15 +265,12 @@
// Environment bindings // Environment bindings
if ($licensing.environmentVariablesEnabled) { if ($licensing.environmentVariablesEnabled) {
bindings = bindings.concat( bindings = bindings.concat(
$environment.variables.map(variable => { getEnvironmentBindings().map(binding => {
return { return {
label: `env.${variable.name}`, ...binding,
path: `env.${variable.name}`,
icon: "Key",
category: "Environment",
display: { display: {
type: "string", ...binding.display,
name: variable.name, rank: 98,
}, },
} }
}) })
@ -437,25 +462,27 @@
<SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} /> <SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "code"} {:else if value.customType === "code"}
<CodeEditorModal> <CodeEditorModal>
<ActionButton <CodeEditor
on:click={() => (codeBindingOpen = !codeBindingOpen)} value={inputData[key]}
quiet
icon={codeBindingOpen ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Bindings</Detail>
</ActionButton>
{#if codeBindingOpen}
<pre>{JSON.stringify(bindings, null, 2)}</pre>
{/if}
<Editor
mode="javascript"
on:change={e => { on:change={e => {
// need to pass without the value inside // need to pass without the value inside
onChange({ detail: e.detail.value }, key) onChange({ detail: e.detail }, key)
inputData[key] = e.detail.value inputData[key] = e.detail
}} }}
value={inputData[key]} completions={[
jsAutocomplete([
...bindingsToCompletions(bindings, EditorModes.JS),
]),
]}
mode={EditorModes.JS}
height={500}
/> />
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>Add available bindings by typing <strong>$</strong></div>
</div>
</div>
</CodeEditorModal> </CodeEditorModal>
{:else if value.customType === "loopOption"} {:else if value.customType === "loopOption"}
<Select <Select
@ -505,6 +532,11 @@
{/if} {/if}
<style> <style>
.messaging {
display: flex;
align-items: center;
margin-top: var(--spacing-xl);
}
.fields { .fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -16,11 +16,11 @@
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte" import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
const userSchemaOverrides = { const userSchemaOverrides = {
firstName: { name: "First name", disabled: true }, firstName: { displayName: "First name", disabled: true },
lastName: { name: "Last name", disabled: true }, lastName: { displayName: "Last name", disabled: true },
email: { name: "Email", disabled: true }, email: { displayName: "Email", disabled: true },
roleId: { name: "Role", disabled: true }, roleId: { displayName: "Role", disabled: true },
status: { name: "Status", disabled: true }, status: { displayName: "Status", disabled: true },
} }
$: id = $tables.selected?._id $: id = $tables.selected?._id
@ -36,7 +36,8 @@
allowAddRows={!isUsersTable} allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable} allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null} schemaOverrides={isUsersTable ? userSchemaOverrides : null}
on:updatetable={e => tables.updateTable(e.detail)} showAvatars={false}
on:updatetable={e => tables.replaceTable(id, e.detail)}
> >
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
{#if isInternal} {#if isInternal}

View File

@ -3,6 +3,7 @@
export let query = {} export let query = {}
export let data = [] export let data = []
export let editRows = false
let loading = false let loading = false
let error = false let error = false
@ -12,7 +13,14 @@
{#if error} {#if error}
<div class="errors">{error}</div> <div class="errors">{error}</div>
{/if} {/if}
<Table schema={query.schema} {data} {loading} {type} rowCount={5} /> <Table
schema={query.schema}
{data}
{loading}
{type}
rowCount={5}
allowEditing={editRows}
/>
<style> <style>
.errors { .errors {

View File

@ -81,6 +81,7 @@
<Label>{label}</Label> <Label>{label}</Label>
<Editor <Editor
editorHeight="250" editorHeight="250"
editorWidth="320"
mode="json" mode="json"
on:change={({ detail }) => (value = detail.value)} on:change={({ detail }) => (value = detail.value)}
value={stringVal} value={stringVal}

View File

@ -22,8 +22,8 @@
export let rowCount export let rowCount
export let disableSorting = false export let disableSorting = false
export let customPlaceholder = false export let customPlaceholder = false
export let allowClickRows
export let allowEditing = true export let allowEditing = true
export let allowClickRows
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -113,17 +113,26 @@
}) })
download(data, `export.${exportFormat}`) download(data, `export.${exportFormat}`)
} else if (filters || sorting) { } else if (filters || sorting) {
const data = await API.exportRows({ let response
tableId: view, try {
format: exportFormat, response = await API.exportRows({
search: { tableId: view,
query: luceneFilter, format: exportFormat,
sort: sorting?.sortColumn, search: {
sortOrder: sorting?.sortOrder, query: luceneFilter,
paginate: false, sort: sorting?.sortColumn,
}, sortOrder: sorting?.sortOrder,
}) paginate: false,
download(data, `export.${exportFormat}`) },
})
} catch (e) {
console.error("Failed to export", e)
notifications.error("Export Failed")
}
if (response) {
download(response, `export.${exportFormat}`)
notifications.success("Export Successful")
}
} else { } else {
await exportView() await exportView()
} }

View File

@ -1,255 +0,0 @@
<script>
import {
ModalContent,
Modal,
Body,
Layout,
Detail,
Heading,
notifications,
} from "@budibase/bbui"
import { onMount } from "svelte"
import ICONS from "../icons"
import { API } from "api"
import { IntegrationTypes, DatasourceTypes } from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
import { goto } from "@roxi/routify"
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
import DatasourceCard from "../_components/DatasourceCard.svelte"
export let modal
let integrations = {}
let integration = {}
let internalTableModal
let externalDatasourceModal
let importModal
$: showImportButton = false
$: customIntegrations = Object.entries(integrations).filter(
entry => entry[1].custom
)
$: sortedIntegrations = sortIntegrations(integrations)
checkShowImport()
onMount(() => {
fetchIntegrations()
})
function selectIntegration(integrationType) {
const selected = integrations[integrationType]
// build the schema
const config = {}
for (let key of Object.keys(selected.datasource)) {
config[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
config,
schema: selected.datasource,
auth: selected.auth,
features: selected.features || [],
}
if (selected.friendlyName) {
integration.name = selected.friendlyName
}
checkShowImport()
}
function checkShowImport() {
showImportButton = integration.type === "REST"
}
function showImportModal() {
importModal.show()
}
async function chooseNextModal() {
if (integration.type === IntegrationTypes.INTERNAL) {
externalDatasourceModal.hide()
internalTableModal.show()
} else if (integration.type === IntegrationTypes.REST) {
try {
// Skip modal for rest, create straight away
const resp = await createRestDatasource(integration)
$goto(`./datasource/${resp._id}`)
} catch (error) {
notifications.error("Error creating datasource")
}
} else {
externalDatasourceModal.show()
}
}
async function fetchIntegrations() {
let newIntegrations = {
[IntegrationTypes.INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
}
try {
const integrationList = await API.getIntegrations()
newIntegrations = {
...newIntegrations,
...integrationList,
}
} catch (error) {
notifications.error("Error fetching integrations")
}
integrations = newIntegrations
}
function sortIntegrations(integrations) {
let integrationsArray = Object.entries(integrations)
function getTypeOrder(schema) {
if (schema.type === DatasourceTypes.API) {
return 1
}
if (schema.type === DatasourceTypes.RELATIONAL) {
return 2
}
return schema.type?.charCodeAt(0)
}
integrationsArray.sort((a, b) => {
let typeOrderA = getTypeOrder(a[1])
let typeOrderB = getTypeOrder(b[1])
if (typeOrderA === typeOrderB) {
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
}
return typeOrderA < typeOrderB ? -1 : 1
})
return integrationsArray
}
</script>
<Modal bind:this={internalTableModal}>
<CreateTableModal />
</Modal>
<Modal bind:this={externalDatasourceModal}>
{#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} {modal} />
{:else}
<DatasourceConfigModal {integration} {modal} />
{/if}
</Modal>
<Modal bind:this={importModal}>
{#if integration.type === "REST"}
<ImportRestQueriesModal
navigateDatasource={true}
createDatasource={true}
onCancel={() => modal.show()}
/>
{/if}
</Modal>
<Modal bind:this={modal}>
<ModalContent
disabled={!Object.keys(integration).length}
title="Add datasource"
confirmText="Continue"
showSecondaryButton={showImportButton}
secondaryButtonText="Import"
secondaryAction={() => showImportModal()}
showCancelButton={false}
size="M"
onConfirm={() => {
chooseNextModal()
}}
>
<Layout noPadding gap="XS">
<Body size="S">Get started with Budibase DB</Body>
<div
class:selected={integration.type === IntegrationTypes.INTERNAL}
on:click={() => selectIntegration(IntegrationTypes.INTERNAL)}
class="item hoverable"
>
<div class="item-body with-type">
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
<div class="text">
<Heading size="XXS">Budibase DB</Heading>
<Detail size="S" class="type">Non-relational</Detail>
</div>
</div>
</div>
</Layout>
<Layout noPadding gap="XS">
<Body size="S">Connect to an external datasource</Body>
<div class="item-list">
{#each sortedIntegrations.filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]}
<DatasourceCard
on:selected={evt => selectIntegration(evt.detail)}
{schema}
bind:integrationType
{integration}
/>
{/each}
</div>
</Layout>
{#if customIntegrations.length > 0}
<Layout noPadding gap="XS">
<Body size="S">Custom datasource</Body>
<div class="item-list">
{#each customIntegrations as [integrationType, schema]}
<DatasourceCard
on:selected={evt => selectIntegration(evt.detail)}
{schema}
bind:integrationType
{integration}
/>
{/each}
</div>
</Layout>
{/if}
</ModalContent>
</Modal>
<style>
.item-list {
display: grid;
grid-template-columns: repeat(2, minmax(150px, 1fr));
grid-gap: var(--spectrum-alias-grid-baseline);
}
.item {
cursor: pointer;
display: grid;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s)
var(--spectrum-alias-item-padding-m);
background: var(--spectrum-alias-background-color-secondary);
transition: background 0.13s ease-out;
border-radius: 5px;
box-sizing: border-box;
border-width: 2px;
}
.item:hover,
.item.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.item-body {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
.item-body.with-type {
align-items: flex-start;
}
.item-body.with-type :global(svg) {
margin-top: 4px;
}
.text :global(.spectrum-Detail) {
color: var(--spectrum-global-color-gray-700);
}
</style>

View File

@ -18,7 +18,6 @@
import { DatasourceFeature } from "@budibase/types" import { DatasourceFeature } from "@budibase/types"
export let integration export let integration
export let modal
// kill the reference so the input isn't saved // kill the reference so the input isn't saved
let datasource = cloneDeep(integration) let datasource = cloneDeep(integration)

View File

@ -8,7 +8,6 @@
import { onMount } from "svelte" import { onMount } from "svelte"
export let integration export let integration
export let modal
// kill the reference so the input isn't saved // kill the reference so the input isn't saved
let datasource = cloneDeep(integration) let datasource = cloneDeep(integration)
@ -21,7 +20,6 @@
<ModalContent <ModalContent
title={`Connect to ${IntegrationNames[datasource.type]}`} title={`Connect to ${IntegrationNames[datasource.type]}`}
onCancel={() => modal.show()}
cancelText="Back" cancelText="Back"
size="L" size="L"
> >

View File

@ -4,6 +4,7 @@
import { API } from "api" import { API } from "api"
import { parseFile } from "./utils" import { parseFile } from "./utils"
let fileInput
let error = null let error = null
let fileName = null let fileName = null
let fileType = null let fileType = null
@ -16,6 +17,7 @@
export let schema = {} export let schema = {}
export let allValid = true export let allValid = true
export let displayColumn = null export let displayColumn = null
export let promptUpload = false
const typeOptions = [ const typeOptions = [
{ {
@ -99,10 +101,19 @@
schema[name].type = e.detail schema[name].type = e.detail
schema[name].constraints = FIELDS[e.detail.toUpperCase()].constraints schema[name].constraints = FIELDS[e.detail.toUpperCase()].constraints
} }
const openFileUpload = (promptUpload, fileInput) => {
if (promptUpload && fileInput) {
fileInput.click()
}
}
$: openFileUpload(promptUpload, fileInput)
</script> </script>
<div class="dropzone"> <div class="dropzone">
<input <input
bind:this={fileInput}
disabled={loading} disabled={loading}
id="file-upload" id="file-upload"
accept="text/csv,application/json" accept="text/csv,application/json"

View File

@ -28,6 +28,7 @@
? selectedSource._id ? selectedSource._id
: BUDIBASE_INTERNAL_DB_ID : BUDIBASE_INTERNAL_DB_ID
export let promptUpload = false
export let name export let name
export let beforeSave = async () => {} export let beforeSave = async () => {}
export let afterSave = async table => { export let afterSave = async table => {
@ -136,7 +137,13 @@
<Label grey extraSmall <Label grey extraSmall
>Create a Table from a CSV or JSON file (Optional)</Label >Create a Table from a CSV or JSON file (Optional)</Label
> >
<TableDataImport bind:rows bind:schema bind:allValid bind:displayColumn /> <TableDataImport
{promptUpload}
bind:rows
bind:schema
bind:allValid
bind:displayColumn
/>
</Layout> </Layout>
</div> </div>
</ModalContent> </ModalContent>

View File

@ -1,143 +0,0 @@
<script>
import {
Button,
ButtonGroup,
ModalContent,
Modal,
notifications,
ProgressCircle,
Layout,
Body,
Icon,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
export let app
export let buttonSize = "M"
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
let appLockModal
let processing = false
$: lockedBy = app?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
}`
$: lockedByHeading =
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
const getExpiryDuration = app => {
if (!app?.lockedBy?.lockedAt) {
return -1
}
let expiry =
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
return expiry - new Date().getTime()
}
const releaseLock = async () => {
processing = true
if (app) {
try {
await API.releaseAppLock(app.devId)
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error("Error releasing lock")
}
} else {
notifications.error("No application is selected")
}
processing = false
}
</script>
{#if lockedBy}
<div class="lock-status">
<Icon
name="LockClosed"
hoverable
size={buttonSize}
on:click={e => {
e.stopPropagation()
appLockModal.show()
}}
/>
</div>
{/if}
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work being lost from overlapping changes
between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now.",
{
time: getExpiryDuration(app),
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
cta
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</Layout>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-l);
gap: var(--spacing-xl);
}
.lock-status {
display: flex;
gap: var(--spacing-s);
max-width: 175px;
}
</style>

View File

@ -0,0 +1,289 @@
<script>
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import {
autocompletion,
closeBrackets,
completionKeymap,
closeBracketsKeymap,
} from "@codemirror/autocomplete"
import {
EditorView,
lineNumbers,
keymap,
highlightSpecialChars,
drawSelection,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
highlightWhitespace,
placeholder as placeholderFn,
MatchDecorator,
ViewPlugin,
Decoration,
} from "@codemirror/view"
import {
bracketMatching,
foldKeymap,
foldGutter,
syntaxHighlighting,
} from "@codemirror/language"
import { oneDark, oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
import {
defaultKeymap,
historyKeymap,
history,
indentWithTab,
} from "@codemirror/commands"
import { Compartment } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./"
import { themeStore } from "builderStore"
export let label
export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
// Export a function to expose caret position
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
return {
start: selection_range.from,
end: selection_range.to,
}
}
export const insertAtPos = opts => {
// Updating the value inside.
// Retain focus
editor.dispatch({
changes: {
from: opts.start || editor.state.doc.length,
to: opts.end || editor.state.doc.length,
insert: opts.value,
},
selection: opts.cursor
? {
anchor: opts.start + opts.value.length,
}
: undefined,
})
}
// For handlebars only.
const bindStyle = new MatchDecorator({
regexp: /{{[."#\-\w\s\][]*}}/g,
decoration: () => {
return Decoration.mark({
tag: "span",
attributes: {
class: "binding-wrap",
},
})
},
})
let plugin = ViewPlugin.define(
view => ({
decorations: bindStyle.createDeco(view),
update(u) {
this.decorations = bindStyle.updateDeco(u, this.decorations)
},
}),
{
decorations: v => v.decorations,
}
)
const dispatch = createEventDispatcher()
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
const buildKeymap = () => {
const baseMap = [
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
]
return baseMap
}
const buildBaseExtensions = () => {
return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(),
autocompletion({
override: [...completions],
closeOnBlur: true,
icons: false,
optionClass: () => "autocomplete-option",
}),
EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
]
}
const buildExtensions = base => {
const complete = [...base]
if (mode.name == "javascript") {
complete.push(javascript())
complete.push(highlightWhitespace())
complete.push(lineNumbers())
complete.push(foldGutter())
complete.push(
EditorView.inputHandler.of((view, from, to, insert) => {
if (insert === "$") {
let { text } = view.state.doc.lineAt(from)
const left = from ? text.substring(0, from) : ""
const right = to ? text.substring(to) : ""
const wrap = !left.includes('$("') || !right.includes('")')
const tr = view.state.update(
{
changes: [{ from, insert: wrap ? '$("")' : "$" }],
selection: {
anchor: from + (wrap ? 3 : 1),
},
},
{
scrollIntoView: true,
userEvent: "input.type",
}
)
view.dispatch(tr)
return true
}
return false
})
)
}
if (placeholder) {
complete.push(placeholderFn(placeholder))
}
return complete
}
let textarea
let editor
let mounted = false
let isEditorInitialised = false
const initEditor = () => {
const baseExtensions = buildBaseExtensions()
editor = new EditorView({
doc: value,
extensions: buildExtensions(baseExtensions),
parent: textarea,
})
}
$: editorHeight = typeof height === "number" ? `${height}px` : height
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
initEditor()
}
// Theme change
$: if (mounted && isEditorInitialised && $themeStore?.theme) {
if (currentTheme != $themeStore?.theme) {
currentTheme = $themeStore?.theme
isDark = !currentTheme.includes("light")
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
})
}
}
onMount(async () => {
mounted = true
return () => {
if (editor) {
editor.destroy()
}
}
})
</script>
{#if label}
<div>
<Label small>{label}</Label>
</div>
{/if}
<div class={`code-editor ${mode?.name || ""}`}>
<div tabindex="-1" bind:this={textarea} />
</div>
<style>
.code-editor.handlebars :global(.cm-content) {
font-family: var(--font-sans);
}
.code-editor :global(.cm-tooltip.cm-completionInfo) {
padding: var(--spacing-m);
}
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) {
border-radius: var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
}
.code-editor :global(.autocomplete-option .cm-completionDetail) {
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 4px 6px;
}
</style>

View File

@ -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
? `<div class="binding__example">${helper.example}</div>`
: ""
const descriptionMarkup = sanitizeHtml(helper.description, {
allowedTags: [],
allowedAttributes: {},
})
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>`
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
return ele
}
const toSpectrumIcon = name => {
return `<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="false"
aria-label="${name}-section-icon"
>
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
</svg>`
}
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)}<span>${sectionName}</span>`
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
? `<div class="binding__example">{{ ${binding.readableBinding} }}</div>`
: ""
const descriptionNodeHtml = binding.description
? `<div class="binding__description">${binding.description}</div>`
: ""
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
}

View File

@ -28,7 +28,6 @@
.dash-card { .dash-card {
background: var(--spectrum-alias-background-color-primary); background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
overflow: hidden;
min-height: 170px; min-height: 170px;
} }
.dash-card-header { .dash-card-header {

View File

@ -8,6 +8,7 @@
faLock, faLock,
faFileArrowUp, faFileArrowUp,
faChevronLeft, faChevronLeft,
faCircleInfo,
} from "@fortawesome/free-solid-svg-icons" } from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons" import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
@ -20,7 +21,8 @@
faDiscord, faDiscord,
faEnvelope, faEnvelope,
faFileArrowUp, faFileArrowUp,
faChevronLeft faChevronLeft,
faCircleInfo
) )
dom.watch() dom.watch()
</script> </script>

View File

@ -83,7 +83,7 @@
.help { .help {
z-index: 2; z-index: 2;
position: absolute; position: absolute;
bottom: var(--spacing-xl); bottom: 24px;
right: 24px; right: 24px;
} }

View File

@ -1,17 +1,13 @@
<script> <script>
import groupBy from "lodash/fp/groupBy"
import { import {
Search,
TextArea,
DrawerContent, DrawerContent,
Tabs, Tabs,
Tab, Tab,
Body, Body,
Layout,
Button, Button,
ActionButton, ActionButton,
Heading,
Icon, Icon,
Popover,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { import {
@ -23,11 +19,21 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { handlebarsCompletions } from "constants/completions" import { store } from "builderStore"
import { addHBSBinding, addJSBinding } from "./utils"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { convertToJS } from "@budibase/string-templates" import { convertToJS } from "@budibase/string-templates"
import { admin } from "stores/portal" import { admin } from "stores/portal"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import {
getHelperCompletions,
jsAutocomplete,
hbAutocomplete,
EditorModes,
bindingsToCompletions,
hbInsert,
jsInsert,
} from "../CodeEditor"
import { getContext } from "svelte"
import BindingPicker from "./BindingPicker.svelte"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -41,54 +47,21 @@
export let allowJS = false export let allowJS = false
export let allowHelpers = true export let allowHelpers = true
let helpers = handlebarsCompletions() const drawerActions = getContext("drawer-actions")
const bindingDrawerActions = getContext("binding-drawer-actions")
let getCaretPosition let getCaretPosition
let search = "" let insertAtPos
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ") let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ")
let mode = initialValueJS ? "JavaScript" : "Handlebars" let mode = initialValueJS ? "JavaScript" : "Text"
let jsValue = initialValueJS ? value : null let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value let hbsValue = initialValueJS ? null : value
let sidebar = true
let selectedCategory = null let targetMode = null
let popover
let popoverAnchor
let hoverTarget
$: usingJS = mode === "JavaScript" $: usingJS = mode === "JavaScript"
$: searchRgx = new RegExp(search, "ig") $: editorMode = mode == "JavaScript" ? EditorModes.JS : EditorModes.Handlebars
$: categories = Object.entries(groupBy("category", bindings)) $: bindingCompletions = bindingsToCompletions(bindings, editorMode)
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
})
$: categoryNames = getCategoryNames(categories)
$: codeMirrorHints = bindings?.map(x => `$("${x.readableBinding}")`)
const updateValue = val => { const updateValue = val => {
valid = isValid(readableToRuntimeBinding(bindings, val)) valid = isValid(readableToRuntimeBinding(bindings, val))
@ -97,43 +70,30 @@
} }
} }
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
// Adds a JS/HBS helper to the expression // Adds a JS/HBS helper to the expression
const addHelper = (helper, js) => { const onSelectHelper = (helper, js) => {
let tempVal
const pos = getCaretPosition() const pos = getCaretPosition()
const { start, end } = pos
if (js) { if (js) {
const decoded = decodeJSBinding(jsValue) let js = decodeJSBinding(jsValue)
tempVal = jsValue = encodeJSBinding( const insertVal = jsInsert(js, start, end, helper.text, { helper: true })
addJSBinding(decoded, pos, helper.text, { helper: true }) insertAtPos({ start, end, value: insertVal })
)
} else { } else {
tempVal = hbsValue = addHBSBinding(hbsValue, pos, helper.text) const insertVal = hbInsert(hbsValue, start, end, helper.text)
insertAtPos({ start, end, value: insertVal })
} }
updateValue(tempVal)
} }
// Adds a data binding to the expression // Adds a data binding to the expression
const addBinding = (binding, { forceJS } = {}) => { const onSelectBinding = (binding, { forceJS } = {}) => {
const { start, end } = getCaretPosition()
if (usingJS || forceJS) { if (usingJS || forceJS) {
let js = decodeJSBinding(jsValue) let js = decodeJSBinding(jsValue)
js = addJSBinding(js, getCaretPosition(), binding.readableBinding) const insertVal = jsInsert(js, start, end, binding.readableBinding)
jsValue = encodeJSBinding(js) insertAtPos({ start, end, value: insertVal })
updateValue(jsValue)
} else { } else {
hbsValue = addHBSBinding( const insertVal = hbInsert(hbsValue, start, end, binding.readableBinding)
hbsValue, insertAtPos({ start, end, value: insertVal })
getCaretPosition(),
binding.readableBinding
)
updateValue(hbsValue)
} }
} }
@ -152,24 +112,25 @@
updateValue(jsValue) updateValue(jsValue)
} }
const switchMode = () => {
if (targetMode == "Text") {
jsValue = null
updateValue(jsValue)
} else {
hbsValue = null
updateValue(hbsValue)
}
mode = targetMode + ""
targetMode = null
}
const convert = () => { const convert = () => {
const runtime = readableToRuntimeBinding(bindings, hbsValue) const runtime = readableToRuntimeBinding(bindings, hbsValue)
const runtimeJs = encodeJSBinding(convertToJS(runtime)) const runtimeJs = encodeJSBinding(convertToJS(runtime))
jsValue = runtimeToReadableBinding(bindings, runtimeJs) jsValue = runtimeToReadableBinding(bindings, runtimeJs)
hbsValue = null hbsValue = null
mode = "JavaScript" mode = "JavaScript"
addBinding("", { forceJS: true }) onSelectBinding("", { forceJS: true })
}
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
} }
onMount(() => { onMount(() => {
@ -177,332 +138,301 @@
}) })
</script> </script>
<span class="detailPopover"> <span class="binding-drawer">
<Popover <DrawerContent>
align="right-outside" <div class="main">
bind:this={popover} <Tabs
anchor={popoverAnchor} selected={mode}
maxWidth={300} on:select={onChangeMode}
dismissible={false} beforeSwitch={selectedMode => {
> if (selectedMode == mode) {
<Layout gap="S"> return true
<div class="helper"> }
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<DrawerContent> //Get the current mode value
<svelte:fragment slot="sidebar"> const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue
<Layout noPadding gap="S">
{#if selectedCategory}
<div>
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory} if (editorValue) {
<div class="heading">Search</div> targetMode = selectedMode
<Search placeholder="Search" bind:value={search} /> return false
{/if} }
return true
{#if !selectedCategory && !search} }}
<ul class="category-list"> >
{#each categoryNames as categoryName} <Tab title="Text">
<li <div class="main-content" class:binding-panel={sidebar}>
on:click={() => { <div class="editor">
selectedCategory = categoryName <div class="overlay-wrap">
}} {#if targetMode}
> <div class="mode-overlay">
<Icon name={categoryIcons[categoryName]} /> <div class="prompt-body">
<span class="category-name">{categoryName} </span> <Heading size="S">
<span class="category-chevron"><Icon name="ChevronRight" /></span> {`Switch to ${targetMode}?`}
</li> </Heading>
{/each} <Body>This will discard anything in your binding</Body>
</ul> <div class="switch-actions">
{/if} <Button
secondary
{#if selectedCategory || search} size="S"
{#each filteredCategories as category} on:click={() => {
{#if category.bindings?.length} targetMode = null
<div class="cat-heading"> }}
<Icon name={categoryIcons[category.name]} />{category.name} >
</div> No - keep text
<ul> </Button>
{#each category.bindings as binding} <Button cta size="S" on:click={switchMode}>
<li Yes - discard text
class="binding" </Button>
on:mouseenter={e => { </div>
popoverAnchor = e.target </div>
if (!binding.description) { </div>
return {/if}
} <CodeEditor
hoverTarget = { value={hbsValue}
title: binding.display?.name || binding.fieldSchema?.name, on:change={onChangeHBSValue}
description: binding.description, bind:getCaretPosition
} bind:insertAtPos
popover.show() completions={[
e.stopPropagation() hbAutocomplete([
}} ...bindingCompletions,
on:mouseleave={() => { ...getHelperCompletions(editorMode),
popover.hide() ]),
popoverAnchor = null ]}
hoverTarget = null placeholder=""
}} height="100%"
on:focus={() => {}} />
on:blur={() => {}} </div>
on:click={() => addBinding(binding)} <div class="binding-footer">
> <div class="messaging">
<span class="binding__label"> {#if !valid}
{#if binding.display?.name} <div class="syntax-error">
{binding.display.name} Current Handlebars syntax is invalid, please check the
{:else if binding.fieldSchema?.name} guide
{binding.fieldSchema?.name} <a href="https://handlebarsjs.com/guide/">here</a>
{:else} for more details.
{binding.readableBinding} </div>
{/if} {:else}
</span> <Icon name="FlashOn" />
<div class="messaging-wrap">
{#if binding.display?.type || binding.fieldSchema?.type} <div>
<span class="binding__typeWrap"> Add available bindings by typing &#123;&#123; or use the
<span class="binding__type"> menu on the right
{binding.display?.type || binding.fieldSchema?.type} </div>
</span> </div>
</span>
{/if} {/if}
</li> </div>
{/each} <div class="actions">
</ul> {#if $admin.isDev && allowJS}
{/if} <ActionButton
{/each} secondary
on:click={() => {
{#if selectedCategory === "Helpers" || search} convert()
{#if filteredHelpers?.length} targetMode = null
<div class="heading">Helpers</div> }}
<ul class="helpers"> >
{#each filteredHelpers as helper} Convert To JS
<li </ActionButton>
class="binding" {/if}
on:click={() => addHelper(helper, usingJS)} <ActionButton
on:mouseenter={e => { secondary
popoverAnchor = e.target icon={sidebar ? "RailRightClose" : "RailRightOpen"}
if (!helper.displayText && helper.description) { on:click={() => {
return sidebar = !sidebar
} }}
hoverTarget = { />
title: helper.displayText, </div>
description: helper.description, </div>
example: getHelperExample(helper, usingJS),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
{/if}
{/if}
{/if}
</Layout>
</svelte:fragment>
<div class="main">
<Tabs selected={mode} on:select={onChangeMode}>
<Tab title="Handlebars">
<div class="main-content">
<TextArea
bind:getCaretPosition
value={hbsValue}
on:change={onChangeHBSValue}
placeholder="Add text, or click the objects on the left to add them to the textbox."
/>
{#if !valid}
<p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</p>
{/if}
{#if $admin.isDev && allowJS}
<div class="convert">
<Button secondary on:click={convert}>Convert to JS</Button>
</div> </div>
{/if}
</div> {#if sidebar}
</Tab> <div class="binding-picker">
{#if allowJS} <BindingPicker
<Tab title="JavaScript"> {bindings}
<div class="main-content"> {allowHelpers}
<Layout noPadding gap="XS"> addHelper={onSelectHelper}
<CodeMirrorEditor addBinding={onSelectBinding}
bind:getCaretPosition mode={editorMode}
height={200} />
value={decodeJSBinding(jsValue)} </div>
on:change={onChangeJSValue} {/if}
hints={codeMirrorHints}
/>
<Body size="S">
JavaScript expressions are executed as functions, so ensure that
your expression returns a value.
</Body>
</Layout>
</div> </div>
</Tab> </Tab>
{/if} {#if allowJS}
</Tabs> <Tab title="JavaScript">
</div> <div class="main-content" class:binding-panel={sidebar}>
</DrawerContent> <div class="editor">
<div class="overlay-wrap">
{#if targetMode}
<div class="mode-overlay">
<div class="prompt-body">
<Heading size="S">
{`Switch to ${targetMode}?`}
</Heading>
<Body>This will discard anything in your binding</Body>
<div class="switch-actions">
<Button
secondary
size="S"
on:click={() => {
targetMode = null
}}
>
No - keep javascript
</Button>
<Button cta size="S" on:click={switchMode}>
Yes - discard javascript
</Button>
</div>
</div>
</div>
{/if}
<CodeEditor
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
completions={[
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
height="100%"
/>
</div>
<div class="binding-footer">
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing $ or use the menu on
the right
</div>
</div>
</div>
<div class="actions">
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{/if}
<div class="drawer-actions">
<Button
secondary
quiet
on:click={() => {
store.actions.settings.propertyFocus(null)
drawerActions.hide()
}}
>
Cancel
</Button>
<Button
cta
disabled={!valid}
on:click={() => {
bindingDrawerActions.save()
}}
>
Save
</Button>
</div>
</Tabs>
</div>
</DrawerContent>
</span>
<style> <style>
ul.helpers li * { .binding-drawer :global(.container > .main) {
pointer-events: none; overflow: hidden;
height: 100%;
padding: 0px;
} }
ul.category-list li {
.binding-drawer :global(.container > .main > .main) {
overflow: hidden;
height: 100%;
display: flex; display: flex;
flex-direction: column;
}
.binding-drawer :global(.spectrum-Tabs-content) {
flex: 1;
overflow: hidden;
}
.binding-drawer :global(.spectrum-Tabs-content > div),
.binding-drawer :global(.spectrum-Tabs-content > div > div),
.binding-drawer :global(.spectrum-Tabs-content .main-content) {
height: 100%;
}
.binding-drawer .main-content {
grid-template-rows: unset;
}
.messaging {
display: flex;
align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
align-items: center; min-width: 0;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1; flex: 1;
text-align: right;
} }
ul.category-list .category-chevron :global(div.icon), .messaging-wrap {
.cat-heading :global(div.icon) { overflow: hidden;
display: inline-block;
} }
li.binding { .messaging-wrap > div {
display: flex; text-overflow: ellipsis;
align-items: center; white-space: nowrap;
} overflow: hidden;
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
} }
.main :global(textarea) { .main :global(textarea) {
min-height: 202px !important; min-height: 202px !important;
} }
.main {
margin: calc(-1 * var(--spacing-xl));
}
.main-content { .main-content {
padding: var(--spacing-s) var(--spacing-xl); padding: var(--spacing-s) var(--spacing-xl);
} }
.heading, .main :global(.spectrum-Tabs div.drawer-actions) {
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
display: flex; display: flex;
gap: var(--spacing-m); gap: var(--spacing-m);
align-items: center; margin-left: auto;
} }
ul { .main :global(.spectrum-Tabs-content),
list-style: none; .main :global(.spectrum-Tabs-content .main-content) {
padding: 0; margin-top: 0px;
margin: 0; padding: 0px;
} }
li { .main :global(.spectrum-Tabs) {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
.helper {
display: flex; display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xs);
}
.helper__name {
font-weight: bold;
}
.helper__description,
.helper__description :global(*) {
color: var(--spectrum-global-color-gray-700);
}
.helper__example {
white-space: normal;
margin: 0.5rem 0 0 0;
font-weight: 700;
}
.helper__description :global(p) {
margin: 0;
} }
.syntax-error { .syntax-error {
padding-top: var(--spacing-m);
color: var(--red); color: var(--red);
font-size: 12px; font-size: 12px;
} }
@ -511,7 +441,66 @@
text-decoration: underline; text-decoration: underline;
} }
.convert { .binding-footer {
padding-top: var(--spacing-m); width: 100%;
display: flex;
justify-content: space-between;
}
.main-content {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 380px;
}
.main-content.binding-panel {
grid-template-columns: 1fr 320px;
}
.binding-picker {
border-left: 2px solid var(--border-light);
border-left: var(--border-light);
overflow: scroll;
height: 100%;
}
.editor {
padding: var(--spacing-xl);
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.overlay-wrap {
position: relative;
flex: 1;
}
.mode-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border-radius: var(--border-radius-s);
}
.prompt-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-l);
}
.prompt-body .switch-actions {
display: flex;
gap: var(--spacing-l);
}
.binding-drawer :global(.code-editor),
.binding-drawer :global(.code-editor > div) {
height: 100%;
} }
</style> </style>

View File

@ -0,0 +1,393 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui"
import { handlebarsCompletions } from "constants/completions"
export let addHelper
export let addBinding
export let bindings
export let mode
export let allowHelpers
let search = ""
let popover
let popoverAnchor
let hoverTarget
let helpers = handlebarsCompletions()
let selectedCategory
$: searchRgx = new RegExp(search, "ig")
// Icons
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: categories = Object.entries(groupBy("category", bindings))
$: categoryNames = getCategoryNames(categories)
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
})
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
}
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
</script>
<span class="detailPopover">
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<Layout noPadding gap="S">
{#if selectedCategory}
<div class="sub-section-back">
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory}
<div class="search">
<span class="search-input">
<Input
placeholder={"Search for bindings"}
autocomplete="off"
bind:value={search}
/>
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="search-input-icon"
on:click={() => {
if (!search) {
return
}
search = null
}}
class:searching={search}
>
<Icon name={search ? "Close" : "Search"} />
</span>
</div>
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="sub-section">
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="binding"
on:mouseenter={e => {
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)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="sub-section">
<div class="cat-heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => 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={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</Layout>
<style>
.search :global(input) {
border: none;
border-radius: 0px;
background: none;
padding: 0px;
}
.search {
padding: var(--spacing-m) var(--spacing-l);
display: flex;
align-items: center;
border-top: 0px;
border-bottom: var(--border-light);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
margin-right: 1px;
position: sticky;
top: 0;
background-color: var(--background);
z-index: 2;
}
.search-input {
flex: 1;
}
.search-input-icon.searching {
cursor: pointer;
}
ul.category-list {
padding: 0px var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.sub-section {
padding: var(--spacing-l);
padding-top: 0px;
}
.sub-section-back {
padding: var(--spacing-l);
padding-top: var(--spacing-xl);
padding-bottom: 0px;
}
.cat-heading {
margin-bottom: var(--spacing-l);
}
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
:global(.drawer-actions) {
display: flex;
gap: var(--spacing-m);
}
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
</style>

View File

@ -5,7 +5,7 @@
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates" import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
@ -34,6 +34,10 @@
bindingDrawer.hide() bindingDrawer.hide()
} }
setContext("binding-drawer-actions", {
save: handleClose,
})
const onChange = (value, optionPicked) => { const onChange = (value, optionPicked) => {
// Add HBS braces if picking binding // Add HBS braces if picking binding
if (optionPicked && !options?.includes(value)) { if (optionPicked && !options?.includes(value)) {
@ -63,7 +67,6 @@
on:pick={e => onChange(e.detail, true)} on:pick={e => onChange(e.detail, true)}
on:blur={() => dispatch("blur")} on:blur={() => dispatch("blur")}
{placeholder} {placeholder}
options={allOptions}
{error} {error}
/> />
{#if !disabled} {#if !disabled}
@ -77,6 +80,7 @@
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={handleClose} disabled={!valid}> <Button cta slot="buttons" on:click={handleClose} disabled={!valid}>
Save Save
</Button> </Button>

View File

@ -4,8 +4,11 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { store } from "builderStore"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates" import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel export let panel = ClientBindingPanel
@ -20,6 +23,7 @@
export let allowHelpers = true export let allowHelpers = true
export let updateOnChange = true export let updateOnChange = true
export let drawerLeft export let drawerLeft
export let key
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
@ -32,10 +36,15 @@
const saveBinding = () => { const saveBinding = () => {
onChange(tempValue) onChange(tempValue)
store.actions.settings.propertyFocus(null)
onBlur() onBlur()
bindingDrawer.hide() bindingDrawer.hide()
} }
setContext("binding-drawer-actions", {
save: saveBinding,
})
const onChange = value => { const onChange = value => {
currentVal = readableToRuntimeBinding(bindings, value) currentVal = readableToRuntimeBinding(bindings, value)
dispatch("change", currentVal) dispatch("change", currentVal)
@ -58,12 +67,24 @@
{updateOnChange} {updateOnChange}
/> />
{#if !disabled} {#if !disabled}
<div class="icon" on:click={bindingDrawer.show}> <div
class="icon"
on:click={() => {
store.actions.settings.propertyFocus(key)
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" /> <Icon size="S" name="FlashOn" />
</div> </div>
{/if} {/if}
</div> </div>
<Drawer {fillWidth} bind:this={bindingDrawer} {title} left={drawerLeft}> <Drawer
{fillWidth}
bind:this={bindingDrawer}
{title}
left={drawerLeft}
headless
>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Add the objects on the left to enrich your text. Add the objects on the left to enrich your text.
</svelte:fragment> </svelte:fragment>

View File

@ -113,109 +113,113 @@
}) })
</script> </script>
<div class="action-top-nav"> {#if $store.hasLock}
<div class="action-buttons"> <div class="action-top-nav">
<div class="version"> <div class="action-buttons">
<VersionModal /> <div class="version">
</div> <VersionModal />
<RevertModal />
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div> </div>
{/if} <RevertModal />
{#if !isPublished} {#if isPublished}
<ActionButton <div class="publish-popover">
quiet <div bind:this={publishPopoverAnchor}>
icon="GlobeStrike" <ActionButton
size="M" quiet
tooltip="Your app has not been published yet" icon="Globe"
disabled size="M"
/> tooltip="Your published app"
{/if} on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div>
{/if}
<TourWrap {#if !isPublished}
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
<ActionButton <ActionButton
quiet quiet
icon="UserGroup" icon="GlobeStrike"
size="M" size="M"
on:click={() => { tooltip="Your app has not been published yet"
store.update(state => { disabled
state.builderSidePanel = true />
return state {/if}
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
<ConfirmDialog <TourWrap
bind:this={unpublishModal} tourStepKey={$store.onboarding
title="Confirm unpublish" ? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
okText="Unpublish app" : TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
onOk={confirmUnpublishApp} >
> <span id="builder-app-users-button">
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? <ActionButton
</ConfirmDialog> quiet
icon="UserGroup"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
{/if}
<div class="buttons"> <div class="buttons">
<Button on:click={previewApp} secondary>Preview</Button> <Button on:click={previewApp} secondary>Preview</Button>
<DeployModal onOk={completePublish} /> {#if $store.hasLock}
<DeployModal onOk={completePublish} />
{/if}
</div> </div>
<style> <style>

View File

@ -9,6 +9,8 @@
import { store } from "builderStore" import { store } from "builderStore"
import { API } from "api" import { API } from "api"
export let disabled = false
let revertModal let revertModal
let appName let appName
@ -34,6 +36,7 @@
size="M" size="M"
tooltip="Revert changes" tooltip="Revert changes"
on:click={revertModal.show} on:click={revertModal.show}
{disabled}
/> />
<Modal bind:this={revertModal}> <Modal bind:this={revertModal}>

View File

@ -58,6 +58,7 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
transition: width 130ms ease-out; transition: width 130ms ease-out;
overflow: hidden;
} }
.panel.borderLeft { .panel.borderLeft {
border-left: var(--border-light); border-left: var(--border-light);

View File

@ -17,14 +17,14 @@ import URLSelect from "./controls/URLSelect.svelte"
import OptionsEditor from "./controls/OptionsEditor/OptionsEditor.svelte" import OptionsEditor from "./controls/OptionsEditor/OptionsEditor.svelte"
import FormFieldSelect from "./controls/FormFieldSelect.svelte" import FormFieldSelect from "./controls/FormFieldSelect.svelte"
import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelte" import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte" import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte"
import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte" import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import BarButtonList from "./controls/BarButtonList.svelte" import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte" import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
const componentMap = { const componentMap = {
text: DrawerBindableCombobox, text: DrawerBindableInput,
select: Select, select: Select,
radio: RadioGroup, radio: RadioGroup,
dataSource: DataSourceSelect, dataSource: DataSourceSelect,

View File

@ -126,8 +126,7 @@
} }
const getAllBindings = (bindings, eventContextBindings, actions) => { const getAllBindings = (bindings, eventContextBindings, actions) => {
let allBindings = eventContextBindings.concat(bindings) let allBindings = []
if (!actions) { if (!actions) {
return [] return []
} }
@ -145,14 +144,35 @@
.forEach(action => { .forEach(action => {
// Check we have a binding for this action, and generate one if not // Check we have a binding for this action, and generate one if not
const stateBinding = makeStateBinding(action.parameters.key) const stateBinding = makeStateBinding(action.parameters.key)
const hasKey = allBindings.some(binding => { const hasKey = bindings.some(binding => {
return binding.runtimeBinding === stateBinding.runtimeBinding return binding.runtimeBinding === stateBinding.runtimeBinding
}) })
if (!hasKey) { if (!hasKey) {
allBindings.push(stateBinding) bindings.push(stateBinding)
} }
}) })
// Get which indexes are asynchronous automations as we want to filter them out from the bindings
const asynchronousAutomationIndexes = actions
.map((action, index) => {
if (
action[EVENT_TYPE_KEY] === "Trigger Automation" &&
!action.parameters?.synchronous
) {
return index
}
})
.filter(index => index !== undefined)
// Based on the above, filter out the asynchronous automations from the bindings
if (asynchronousAutomationIndexes) {
allBindings = eventContextBindings
.filter((binding, index) => {
return !asynchronousAutomationIndexes.includes(index)
})
.concat(bindings)
} else {
allBindings = eventContextBindings.concat(bindings)
}
return allBindings return allBindings
} }
</script> </script>

View File

@ -1,8 +1,8 @@
<script> <script>
import { Select, Label, Input, Checkbox } from "@budibase/bbui" import { Select, Label, Input, Checkbox, Icon } from "@budibase/bbui"
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
import { TriggerStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
export let parameters = {} export let parameters = {}
export let bindings = [] export let bindings = []
@ -16,6 +16,14 @@
? AUTOMATION_STATUS.EXISTING ? AUTOMATION_STATUS.EXISTING
: AUTOMATION_STATUS.NEW : AUTOMATION_STATUS.NEW
$: {
if (automationStatus === AUTOMATION_STATUS.NEW) {
parameters.synchronous = false
}
parameters.synchronous = automations.find(
automation => automation._id === parameters.automationId
)?.synchronous
}
$: automations = $automationStore.automations $: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP) .filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
.map(automation => { .map(automation => {
@ -23,10 +31,15 @@
automation.definition.trigger.inputs.fields || {} automation.definition.trigger.inputs.fields || {}
).map(([name, type]) => ({ name, type })) ).map(([name, type]) => ({ name, type }))
let hasCollectBlock = automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
return { return {
name: automation.name, name: automation.name,
_id: automation._id, _id: automation._id,
schema, schema,
synchronous: hasCollectBlock,
} }
}) })
$: hasAutomations = automations && automations.length > 0 $: hasAutomations = automations && automations.length > 0
@ -35,6 +48,8 @@
) )
$: selectedSchema = selectedAutomation?.schema $: selectedSchema = selectedAutomation?.schema
$: error = parameters.timeout > 120 ? "Timeout must be less than 120s" : null
const onFieldsChanged = e => { const onFieldsChanged = e => {
parameters.fields = Object.entries(e.detail || {}).reduce( parameters.fields = Object.entries(e.detail || {}).reduce(
(acc, [key, value]) => { (acc, [key, value]) => {
@ -57,6 +72,14 @@
parameters.fields = {} parameters.fields = {}
parameters.automationId = automations[0]?._id parameters.automationId = automations[0]?._id
} }
const onChange = value => {
let automationId = value.detail
parameters.synchronous = automations.find(
automation => automation._id === automationId
)?.synchronous
parameters.automationId = automationId
}
</script> </script>
<div class="root"> <div class="root">
@ -85,6 +108,7 @@
{#if automationStatus === AUTOMATION_STATUS.EXISTING} {#if automationStatus === AUTOMATION_STATUS.EXISTING}
<Select <Select
on:change={onChange}
bind:value={parameters.automationId} bind:value={parameters.automationId}
placeholder="Choose automation" placeholder="Choose automation"
options={automations} options={automations}
@ -98,6 +122,29 @@
/> />
{/if} {/if}
{#if parameters.synchronous}
<Label small />
<div class="synchronous-info">
<Icon name="Info" />
<div>
<i
>This automation will run synchronously as it contains a Collect
step</i
>
</div>
</div>
<Label small />
<div class="timeout-width">
<Input
label="Timeout in seconds (120 max)"
type="number"
{error}
bind:value={parameters.timeout}
/>
</div>
{/if}
<Label small /> <Label small />
<Checkbox <Checkbox
text="Do not display default notification" text="Do not display default notification"
@ -133,6 +180,9 @@
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
} }
.timeout-width {
width: 30%;
}
.params { .params {
display: grid; display: grid;
@ -142,6 +192,11 @@
align-items: center; align-items: center;
} }
.synchronous-info {
display: flex;
gap: var(--spacing-s);
}
.fields { .fields {
margin-top: var(--spacing-l); margin-top: var(--spacing-l);
display: grid; display: grid;

View File

@ -57,7 +57,13 @@
{ {
"name": "Trigger Automation", "name": "Trigger Automation",
"type": "application", "type": "application",
"component": "TriggerAutomation" "component": "TriggerAutomation",
"context": [
{
"label": "Automation Result",
"value": "result"
}
]
}, },
{ {
"name": "Update Field Value", "name": "Update Field Value",

View File

@ -21,6 +21,7 @@
export let componentBindings = [] export let componentBindings = []
export let nested = false export let nested = false
export let highlighted = false export let highlighted = false
export let propertyFocus = false
export let info = null export let info = null
$: nullishValue = value == null || value === "" $: nullishValue = value == null || value === ""
@ -72,6 +73,10 @@
if (highlighted) { if (highlighted) {
store.actions.settings.highlight(null) store.actions.settings.highlight(null)
} }
// To fix focus 'affect' when property is target of a drawer other actions in the builder.
if (propertyFocus) {
store.actions.settings.propertyFocus(null)
}
}) })
</script> </script>
@ -79,6 +84,7 @@
class="property-control" class="property-control"
class:wide={!label || labelHidden} class:wide={!label || labelHidden}
class:highlighted={highlighted && nullishValue} class:highlighted={highlighted && nullishValue}
class:property-focus={propertyFocus}
> >
{#if label && !labelHidden} {#if label && !labelHidden}
<div class="label"> <div class="label">
@ -125,6 +131,14 @@
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-static-red-600); border-color: var(--spectrum-global-color-static-red-600);
} }
.property-control.property-focus :global(input) {
border-color: var(
--spectrum-textfield-m-border-color-down,
var(--spectrum-alias-border-color-mouse-focus)
);
}
.label { .label {
margin-top: 16px; margin-top: 16px;
transform: translateY(-50%); transform: translateY(-50%);

View File

@ -1,5 +1,5 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto, beforeUrlChange } from "@roxi/routify"
import { import {
Icon, Icon,
Select, Select,
@ -12,6 +12,8 @@
Heading, Heading,
Tabs, Tabs,
Tab, Tab,
Modal,
ModalContent,
} from "@budibase/bbui" } from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui" import { notifications, Divider } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte" import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
@ -29,6 +31,12 @@
export let query export let query
const resumeNavigation = () => {
if (typeof navigateTo == "string") {
$goto(typeof navigateTo == "string" ? `${navigateTo}` : navigateTo)
}
}
const transformerDocs = "https://docs.budibase.com/docs/transformers" const transformerDocs = "https://docs.budibase.com/docs/transformers"
let fields = query?.schema ? schemaToFields(query.schema) : [] let fields = query?.schema ? schemaToFields(query.schema) : []
@ -36,6 +44,31 @@
let data = [] let data = []
let saveId let saveId
let currentTab = "JSON" let currentTab = "JSON"
let saveModal
let override = false
let navigateTo = null
// seed the transformer
if (query && !query.transformer) {
query.transformer = "return data"
}
// initialise a new empty schema
if (query && !query.schema) {
query.schema = {}
}
let queryStr = JSON.stringify(query)
$beforeUrlChange(event => {
const updated = JSON.stringify(query)
if (updated !== queryStr && !override) {
navigateTo = event.type == "pushstate" ? event.url : null
saveModal.show()
return false
} else return true
})
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId) $: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
$: query.schema = fieldsToSchema(fields) $: query.schema = fieldsToSchema(fields)
@ -60,11 +93,6 @@
} }
} }
// seed the transformer
if (query && !query.transformer) {
query.transformer = "return data"
}
function resetDependentFields() { function resetDependentFields() {
if (query.fields.extra) { if (query.fields.extra) {
query.fields.extra = {} query.fields.extra = {}
@ -101,22 +129,48 @@
} }
} }
// return the query.
async function saveQuery() { async function saveQuery() {
try { try {
const { _id } = await queries.save(query.datasourceId, query) const response = await queries.save(query.datasourceId, query)
saveId = _id saveId = response._id
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query if (response?._rev) {
if (!query._rev) { queryStr = JSON.stringify(query)
$goto(`../../${_id}`)
} }
return response
} catch (error) { } catch (error) {
notifications.error("Error saving query") notifications.error("Error saving query")
} }
} }
</script> </script>
<Modal
bind:this={saveModal}
on:hide={() => {
navigateTo = null
}}
>
<ModalContent
title="You have unsaved changes"
confirmText="Save and Continue"
cancelText="Discard Changes"
size="L"
onConfirm={async () => {
await saveQuery()
override = true
resumeNavigation()
}}
onCancel={async () => {
override = true
resumeNavigation()
}}
>
<Body>Leaving this section will mean losing and changes to your query</Body>
</ModalContent>
</Modal>
<div class="wrapper"> <div class="wrapper">
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
<Heading size="M">Query {integrationInfo?.friendlyName}</Heading> <Heading size="M">Query {integrationInfo?.friendlyName}</Heading>
@ -125,7 +179,13 @@
<div class="config"> <div class="config">
<div class="config-field"> <div class="config-field">
<Label>Query Name</Label> <Label>Query Name</Label>
<Input bind:value={query.name} /> <Input
value={query.name}
on:input={e => {
let newValue = e.target.value || ""
query.name = newValue.trim()
}}
/>
</div> </div>
{#if queryConfig} {#if queryConfig}
<div class="config-field"> <div class="config-field">
@ -149,18 +209,20 @@
/> />
{/if} {/if}
{#key query.parameters} {#key query.parameters}
<BindingBuilder <div class="binding-wrap">
queryBindings={query.parameters} <BindingBuilder
bindable={false} queryBindings={query.parameters}
on:change={e => { bindable={false}
query.parameters = e.detail.map(binding => { on:change={e => {
return { query.parameters = e.detail.map(binding => {
name: binding.name, return {
default: binding.value, name: binding.name,
} default: binding.value,
}) }
}} })
/> }}
/>
</div>
{/key} {/key}
{/if} {/if}
</div> </div>
@ -203,7 +265,18 @@
<div class="viewer-controls"> <div class="viewer-controls">
<Heading size="S">Results</Heading> <Heading size="S">Results</Heading>
<ButtonGroup gap="XS"> <ButtonGroup gap="XS">
<Button cta disabled={queryInvalid} on:click={saveQuery}> <Button
cta
disabled={queryInvalid}
on:click={async () => {
await saveQuery()
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${query._id}`)
}
}}
>
Save Query Save Query
</Button> </Button>
<Button secondary on:click={previewQuery}>Run Query</Button> <Button secondary on:click={previewQuery}>Run Query</Button>
@ -274,4 +347,9 @@
min-width: 150px; min-width: 150px;
align-items: center; align-items: center;
} }
.binding-wrap :global(div.container) {
padding-left: 0px;
padding-right: 0px;
}
</style> </style>

View File

@ -71,6 +71,9 @@
tourStep.onComplete() tourStep.onComplete()
} }
popover.hide() popover.hide()
if (tourStep.endRoute) {
$goto(tourStep.endRoute)
}
} }
} }

View File

@ -76,6 +76,7 @@ const getTours = () => {
title: "Publish", title: "Publish",
layout: OnboardingPublish, layout: OnboardingPublish,
route: "/builder/app/:application/design", route: "/builder/app/:application/design",
endRoute: "/builder/app/:application/data",
query: ".toprightnav #builder-app-publish-button", query: ".toprightnav #builder-app-publish-button",
onLoad: () => { onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_APP_PUBLISH) tourEvent(TOUR_STEP_KEYS.BUILDER_APP_PUBLISH)

View File

@ -1,13 +1,14 @@
<script> <script>
import { Heading, Body, Button, Icon, notifications } from "@budibase/bbui" import { Heading, Body, Button, Icon } from "@budibase/bbui"
import AppLockModal from "../common/AppLockModal.svelte"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { UserAvatar } from "@budibase/frontend-core"
export let app export let app
export let lockedAction export let lockedAction
$: editing = app?.lockedBy != null
const handleDefaultClick = () => { const handleDefaultClick = () => {
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
goToOverview() goToOverview()
@ -17,12 +18,6 @@
} }
const goToBuilder = () => { const goToBuilder = () => {
if (app.lockedOther) {
notifications.error(
`App locked by ${app.lockedBy.email}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../app/${app.devId}`) $goto(`../../app/${app.devId}`)
} }
@ -44,7 +39,10 @@
</div> </div>
<div class="updated"> <div class="updated">
{#if app.updatedAt} {#if editing}
Currently editing
<UserAvatar user={app.lockedBy} />
{:else if app.updatedAt}
{processStringSync("Updated {{ duration time 'millisecond' }} ago", { {processStringSync("Updated {{ duration time 'millisecond' }} ago", {
time: new Date().getTime() - new Date(app.updatedAt).getTime(), time: new Date().getTime() - new Date(app.updatedAt).getTime(),
})} })}
@ -59,12 +57,12 @@
</div> </div>
<div class="app-row-actions"> <div class="app-row-actions">
<AppLockModal {app} buttonSize="M" /> <Button size="S" secondary on:click={lockedAction || goToOverview}>
<Button size="S" secondary on:click={lockedAction || goToOverview} Manage
>Manage</Button </Button>
> <Button size="S" primary on:click={lockedAction || goToBuilder}>
<Button size="S" primary on:click={lockedAction || goToBuilder}>Edit</Button Edit
> </Button>
</div> </div>
</div> </div>
@ -87,6 +85,9 @@
.updated { .updated {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
display: flex;
align-items: center;
gap: 8px;
} }
.title, .title,

View File

@ -1,12 +1,6 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import { import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
notifications,
Input,
ModalContent,
Dropzone,
Toggle,
} from "@budibase/bbui"
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { API } from "api" import { API } from "api"
import { apps, admin, auth } from "stores/portal" import { apps, admin, auth } from "stores/portal"
@ -22,7 +16,6 @@
let creating = false let creating = false
let defaultAppName let defaultAppName
let includeSampleDB = true
const values = writable({ name: "", url: null }) const values = writable({ name: "", url: null })
const validation = createValidationStore() const validation = createValidationStore()
@ -117,8 +110,6 @@
data.append("templateName", template.name) data.append("templateName", template.name)
data.append("templateKey", template.key) data.append("templateKey", template.key)
data.append("templateFile", $values.file) data.append("templateFile", $values.file)
} else {
data.append("sampleData", includeSampleDB)
} }
// Create App // Create App
@ -213,15 +204,6 @@
</div> </div>
{/if} {/if}
</span> </span>
{#if !template && !template?.fromFile}
<span>
<Toggle
text="Include sample data"
bind:value={includeSampleDB}
disabled={creating}
/>
</span>
{/if}
</ModalContent> </ModalContent>
<style> <style>

View File

@ -20,9 +20,14 @@ export const ActionStepID = {
FILTER: "FILTER", FILTER: "FILTER",
QUERY_ROWS: "QUERY_ROWS", QUERY_ROWS: "QUERY_ROWS",
LOOP: "LOOP", LOOP: "LOOP",
COLLECT: "COLLECT",
// these used to be lowercase step IDs, maintain for backwards compat // these used to be lowercase step IDs, maintain for backwards compat
discord: "discord", discord: "discord",
slack: "slack", slack: "slack",
zapier: "zapier", zapier: "zapier",
integromat: "integromat", integromat: "integromat",
} }
export const Features = {
LOOPING: "LOOPING",
}

View File

@ -0,0 +1,28 @@
<script>
import { UserAvatar } from "@budibase/frontend-core"
export let users = []
$: uniqueUsers = unique(users)
const unique = users => {
let uniqueUsers = {}
users?.forEach(user => {
uniqueUsers[user.email] = user
})
return Object.values(uniqueUsers)
}
</script>
<div class="avatars">
{#each uniqueUsers as user}
<UserAvatar {user} tooltipDirection="bottom" />
{/each}
</div>
<style>
.avatars {
display: flex;
gap: 4px;
}
</style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { store, automationStore } from "builderStore" import { store, automationStore, userStore } from "builderStore"
import { roles, flags } from "stores/backend" import { roles, flags } from "stores/backend"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
@ -13,7 +13,6 @@
Modal, Modal,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import AppActions from "components/deploy/AppActions.svelte" import AppActions from "components/deploy/AppActions.svelte"
import { API } from "api" import { API } from "api"
import { isActive, goto, layout, redirect } from "@roxi/routify" import { isActive, goto, layout, redirect } from "@roxi/routify"
@ -23,6 +22,7 @@
import TourWrap from "components/portal/onboarding/TourWrap.svelte" import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte" import TourPopover from "components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte" import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import UserAvatars from "./_components/UserAvatars.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
export let application export let application
@ -30,7 +30,9 @@
let promise = getPackage() let promise = getPackage()
let hasSynced = false let hasSynced = false
let commandPaletteModal let commandPaletteModal
let loaded = false
$: loaded && initTour()
$: selected = capitalise( $: selected = capitalise(
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data" $layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
) )
@ -43,6 +45,7 @@
await automationStore.actions.fetch() await automationStore.actions.fetch()
await roles.fetch() await roles.fetch()
await flags.fetch() await flags.fetch()
loaded = true
return pkg return pkg
} catch (error) { } catch (error) {
notifications.error(`Error initialising app: ${error?.message}`) notifications.error(`Error initialising app: ${error?.message}`)
@ -67,13 +70,18 @@
// Event handler for the command palette // Event handler for the command palette
const handleKeyDown = e => { const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey)) { if (e.key === "k" && (e.ctrlKey || e.metaKey) && $store.hasLock) {
e.preventDefault() e.preventDefault()
commandPaletteModal.toggle() commandPaletteModal.toggle()
} }
} }
const initTour = async () => { const initTour = async () => {
// Skip tour if we don't have the lock
if (!$store.hasLock) {
return
}
// Check if onboarding is enabled. // Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) { if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
if (!$auth.user?.onboardedAt) { if (!$auth.user?.onboardedAt) {
@ -110,7 +118,6 @@
// check if user has beta access // check if user has beta access
// const betaResponse = await API.checkBetaAccess($auth?.user?.email) // const betaResponse = await API.checkBetaAccess($auth?.user?.email)
// betaAccess = betaResponse.access // betaAccess = betaResponse.access
initTour()
} catch (error) { } catch (error) {
notifications.error("Failed to sync with production database") notifications.error("Failed to sync with production database")
} }
@ -119,10 +126,11 @@
}) })
onDestroy(() => { onDestroy(() => {
store.update(state => { // Run async on a slight delay to let other cleanup logic run without
state.appId = null // being confused by the store wiping
return state setTimeout(() => {
}) store.actions.reset()
}, 10)
}) })
</script> </script>
@ -134,74 +142,89 @@
<div class="root"> <div class="root">
<div class="top-nav"> <div class="top-nav">
<div class="topleftnav"> {#if $store.initialised}
<ActionMenu> <div class="topleftnav">
<div slot="control"> <ActionMenu>
<Icon size="M" hoverable name="ShowMenu" /> <div slot="control">
</div> <Icon size="M" hoverable name="ShowMenu" />
<MenuItem on:click={() => $goto("../../portal/apps")}> </div>
Exit to portal <MenuItem on:click={() => $goto("../../portal/apps")}>
</MenuItem> Exit to portal
<MenuItem </MenuItem>
on:click={() => $goto(`../../portal/overview/${application}`)} <MenuItem
> on:click={() => $goto(`../../portal/overview/${application}`)}
Overview >
</MenuItem> Overview
<MenuItem </MenuItem>
on:click={() => $goto(`../../portal/overview/${application}/access`)} <MenuItem
> on:click={() =>
Access $goto(`../../portal/overview/${application}/access`)}
</MenuItem> >
<MenuItem Access
on:click={() => </MenuItem>
$goto(`../../portal/overview/${application}/automation-history`)} <MenuItem
> on:click={() =>
Automation history $goto(`../../portal/overview/${application}/automation-history`)}
</MenuItem> >
<MenuItem Automation history
on:click={() => $goto(`../../portal/overview/${application}/backups`)} </MenuItem>
> <MenuItem
Backups on:click={() =>
</MenuItem> $goto(`../../portal/overview/${application}/backups`)}
>
Backups
</MenuItem>
<MenuItem <MenuItem
on:click={() => on:click={() =>
$goto(`../../portal/overview/${application}/name-and-url`)} $goto(`../../portal/overview/${application}/name-and-url`)}
> >
Name and URL Name and URL
</MenuItem> </MenuItem>
<MenuItem <MenuItem
on:click={() => $goto(`../../portal/overview/${application}/version`)} on:click={() =>
> $goto(`../../portal/overview/${application}/version`)}
Version >
</MenuItem> Version
</ActionMenu> </MenuItem>
<Heading size="XS">{$store.name}</Heading> </ActionMenu>
</div> <Heading size="XS">{$store.name}</Heading>
<div class="topcenternav"> </div>
<Tabs {selected} size="M"> <div class="topcenternav">
{#each $layout.children as { path, title }} {#if $store.hasLock}
<TourWrap tourStepKey={`builder-${title}-section`}> <Tabs {selected} size="M">
<Tab {#each $layout.children as { path, title }}
quiet <TourWrap tourStepKey={`builder-${title}-section`}>
selected={$isActive(path)} <Tab
on:click={topItemNavigate(path)} quiet
title={capitalise(title)} selected={$isActive(path)}
id={`builder-${title}-tab`} on:click={topItemNavigate(path)}
/> title={capitalise(title)}
</TourWrap> id={`builder-${title}-tab`}
{/each} />
</Tabs> </TourWrap>
</div> {/each}
<div class="toprightnav"> </Tabs>
<AppActions {application} /> {:else}
</div> <div class="secondary-editor">
<Icon name="LockClosed" />
Another user is currently editing your screens and automations
</div>
{/if}
</div>
<div class="toprightnav">
<UserAvatars users={$userStore} />
<AppActions {application} />
</div>
{/if}
</div> </div>
{#await promise} {#await promise}
<!-- This should probably be some kind of loading state? --> <!-- This should probably be some kind of loading state? -->
<div class="loading" /> <div class="loading" />
{:then _} {:then _}
<slot /> <div class="body">
<slot />
</div>
{:catch error} {:catch error}
<p>Something went wrong: {error.message}</p> <p>Something went wrong: {error.message}</p>
{/await} {/await}
@ -237,6 +260,7 @@
box-sizing: border-box; box-sizing: border-box;
align-items: stretch; align-items: stretch;
border-bottom: var(--border-light); border-bottom: var(--border-light);
z-index: 2;
} }
.topleftnav { .topleftnav {
@ -270,4 +294,18 @@
align-items: center; align-items: center;
gap: var(--spacing-l); gap: var(--spacing-l);
} }
.secondary-editor {
align-self: center;
display: flex;
flex-direction: row;
gap: 8px;
}
.body {
flex: 1 1 auto;
z-index: 1;
display: flex;
flex-direction: column;
}
</style> </style>

View File

@ -8,6 +8,15 @@
import { onDestroy, onMount } from "svelte" import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync" import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify" import * as routify from "@roxi/routify"
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
// Keep URL and state in sync for selected screen ID // Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({

View File

@ -2,13 +2,11 @@
import { Button } from "@budibase/bbui" import { Button } from "@budibase/bbui"
</script> </script>
<div class="beta-background" />
<div class="beta"> <div class="beta">
Enjoying the Grid?
<Button <Button
size="M" size="M"
cta cta
on:click={() => window.open("https://t.maze.co/156382627", "_blank")} on:click={() => window.open("https://t.maze.co/165900794", "_blank")}
> >
Give Feedback Give Feedback
</Button> </Button>
@ -17,30 +15,16 @@
<style> <style>
.beta { .beta {
position: absolute; position: absolute;
bottom: 32px; bottom: 24px;
right: 32px; right: 24px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
z-index: 10;
} }
.beta :global(.spectrum-Button) { .beta :global(.spectrum-Button) {
background: var(--spectrum-global-color-magenta-400); background: var(--spectrum-global-color-magenta-400);
border-color: var(--spectrum-global-color-magenta-400); border-color: var(--spectrum-global-color-magenta-400);
} }
.beta-background {
z-index: 0;
pointer-events: none;
position: absolute;
bottom: -230px;
right: -105px;
width: 1400px;
height: 320px;
transform: rotate(-22deg);
background: linear-gradient(
to top,
var(--cell-background) 20%,
transparent
);
}
</style> </style>

View File

@ -0,0 +1,51 @@
<script>
import { Body, Label } from "@budibase/bbui"
export let title
export let description
export let disabled
</script>
<div on:click class:disabled class="option">
<div class="header">
<div class="icon">
<slot />
</div>
<Body>{title}</Body>
</div>
<Label>{description}</Label>
</div>
<style>
.option {
background-color: var(--background);
border: 1px solid var(--grey-4);
padding: 10px 16px 14px;
border-radius: 4px;
cursor: pointer;
}
.option :global(label) {
cursor: pointer;
}
.option:hover {
background-color: var(--background-alt);
}
.header {
display: flex;
margin-bottom: 8px;
align-items: center;
}
.icon {
display: flex;
margin-right: 8px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
</style>

View File

@ -1,25 +1,26 @@
<script> <script>
import { Button, Layout } from "@budibase/bbui" import { Button, Layout } from "@budibase/bbui"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte" import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { isActive, goto } from "@roxi/routify"
let modal import BetaButton from "./_components/BetaButton.svelte"
</script> </script>
<!-- routify:options index=1 --> <!-- routify:options index=1 -->
<div class="data"> <div class="data">
<Panel title="Sources" borderRight> {#if !$isActive("./new")}
<Layout paddingX="L" paddingY="XL" gap="S"> <Panel title="Sources" borderRight>
<Button cta on:click={modal.show}>Add source</Button> <Layout paddingX="L" paddingY="XL" gap="S">
<CreateDatasourceModal bind:modal /> <Button cta on:click={() => $goto("./new")}>Add source</Button>
<DatasourceNavigator /> <DatasourceNavigator />
</Layout> </Layout>
</Panel> </Panel>
{/if}
<div class="content"> <div class="content">
<slot /> <slot />
</div> </div>
<BetaButton />
</div> </div>
<style> <style>
@ -40,5 +41,6 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
flex: 1 1 auto; flex: 1 1 auto;
z-index: 1;
} }
</style> </style>

View File

@ -1,22 +1,17 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { onMount } from "svelte" import { onMount } from "svelte"
import { admin } from "stores/portal"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
import { datasources } from "stores/backend" import { datasources } from "stores/backend"
let modal $: hasData =
$: setupComplete =
$datasources.list.find(x => (x._id = "bb_internal"))?.entities?.length > $datasources.list.find(x => (x._id = "bb_internal"))?.entities?.length >
1 || $datasources.list.length > 1 1 || $datasources.list.length > 1
onMount(() => { onMount(() => {
if (!setupComplete && !$admin.isDev) { if (!hasData) {
modal.show() $redirect("./new")
} else { } else {
$redirect("./table") $redirect("./table")
} }
}) })
</script> </script>
<CreateDatasourceModal bind:modal />

View File

@ -0,0 +1,257 @@
<script>
import { API } from "api"
import { tables, datasources } from "stores/backend"
import { Icon, Modal, notifications, Heading, Body } from "@budibase/bbui"
import { params, goto } from "@roxi/routify"
import {
IntegrationTypes,
DatasourceTypes,
DEFAULT_BB_DATASOURCE_ID,
} from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
import DatasourceOption from "./_components/DatasourceOption.svelte"
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons/index.js"
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
let internalTableModal
let externalDatasourceModal
let integrations = []
let integration = null
let disabled = false
let promptUpload = false
$: hasData = $datasources.list.length > 1 || $tables.list.length > 1
$: hasDefaultData =
$datasources.list.findIndex(
datasource => datasource._id === DEFAULT_BB_DATASOURCE_ID
) !== -1
const createSampleData = async () => {
disabled = true
try {
await API.addSampleData($params.application)
await tables.fetch()
await datasources.fetch()
$goto("./table")
} catch (e) {
disabled = false
notifications.error("Error creating datasource")
}
}
const handleIntegrationSelect = integrationType => {
const selected = integrations.find(([type]) => type === integrationType)[1]
// build the schema
const config = {}
for (let key of Object.keys(selected.datasource)) {
config[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
config,
schema: selected.datasource,
auth: selected.auth,
features: selected.features || [],
}
if (selected.friendlyName) {
integration.name = selected.friendlyName
}
if (integration.type === IntegrationTypes.REST) {
disabled = true
// Skip modal for rest, create straight away
createRestDatasource(integration)
.then(response => {
$goto(`./datasource/${response._id}`)
})
.catch(() => {
disabled = false
notifications.error("Error creating datasource")
})
} else {
externalDatasourceModal.show()
}
}
const handleInternalTable = () => {
promptUpload = false
internalTableModal.show()
}
const handleDataImport = () => {
promptUpload = true
internalTableModal.show()
}
const handleInternalTableSave = table => {
notifications.success(`Table created successfully.`)
$goto(`./table/${table._id}`)
}
function sortIntegrations(integrations) {
let integrationsArray = Object.entries(integrations)
function getTypeOrder(schema) {
if (schema.type === DatasourceTypes.API) {
return 1
}
if (schema.type === DatasourceTypes.RELATIONAL) {
return 2
}
return schema.type?.charCodeAt(0)
}
integrationsArray.sort((a, b) => {
let typeOrderA = getTypeOrder(a[1])
let typeOrderB = getTypeOrder(b[1])
if (typeOrderA === typeOrderB) {
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
}
return typeOrderA < typeOrderB ? -1 : 1
})
return integrationsArray
}
const fetchIntegrations = async () => {
const unsortedIntegrations = await API.getIntegrations()
integrations = sortIntegrations(unsortedIntegrations)
}
$: fetchIntegrations()
</script>
<Modal bind:this={internalTableModal}>
<CreateTableModal {promptUpload} afterSave={handleInternalTableSave} />
</Modal>
<Modal bind:this={externalDatasourceModal}>
{#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} />
{:else}
<DatasourceConfigModal {integration} />
{/if}
</Modal>
<div class="page">
<div class="closeButton">
{#if hasData}
<Icon hoverable name="Close" on:click={$goto("./table")} />
{/if}
</div>
<div class="heading">
<Heading weight="light">Add new data source</Heading>
</div>
<div class="subHeading">
<Body>Get started with our Budibase DB</Body>
<div
role="tooltip"
title="Budibase DB is built with CouchDB"
class="tooltip"
>
<FontAwesomeIcon name="fa-solid fa-circle-info" />
</div>
</div>
<div class="options">
<DatasourceOption
on:click={handleInternalTable}
title="Create new table"
description="Non-relational"
{disabled}
>
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption>
<DatasourceOption
on:click={createSampleData}
title="Use sample data"
description="Non-relational"
disabled={disabled || hasDefaultData}
>
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption>
<DatasourceOption
on:click={handleDataImport}
title="Upload data"
description="Non-relational"
{disabled}
>
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
</DatasourceOption>
</div>
<div class="subHeading">
<Body>Or connect to an external datasource</Body>
</div>
<div class="options">
{#each integrations as [key, value]}
<DatasourceOption
on:click={() => handleIntegrationSelect(key)}
title={value.friendlyName}
description={value.type}
{disabled}
>
<IntegrationIcon integrationType={key} schema={value} />
</DatasourceOption>
{/each}
</div>
</div>
<style>
.page {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.closeButton {
height: 38px;
display: flex;
justify-content: right;
width: 100%;
}
.heading {
margin-bottom: 12px;
}
.subHeading {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.tooltip {
margin-left: 6px;
}
.options {
width: 100%;
display: grid;
column-gap: 24px;
row-gap: 24px;
grid-template-columns: repeat(auto-fit, 235px);
justify-content: center;
margin-bottom: 48px;
max-width: 1050px;
}
</style>

View File

@ -3,8 +3,10 @@
import QueryViewer from "components/integration/QueryViewer.svelte" import QueryViewer from "components/integration/QueryViewer.svelte"
import RestQueryViewer from "components/integration/RestQueryViewer.svelte" import RestQueryViewer from "components/integration/RestQueryViewer.svelte"
import { IntegrationTypes } from "constants/backend" import { IntegrationTypes } from "constants/backend"
import { cloneDeep } from "lodash/fp"
$: query = $queries.selected $: query = $queries.selected
$: editableQuery = cloneDeep(query)
$: datasource = $datasources.list.find(ds => ds._id === query?.datasourceId) $: datasource = $datasources.list.find(ds => ds._id === query?.datasourceId)
$: isRestQuery = datasource?.source === IntegrationTypes.REST $: isRestQuery = datasource?.source === IntegrationTypes.REST
</script> </script>
@ -13,6 +15,6 @@
{#if isRestQuery} {#if isRestQuery}
<RestQueryViewer queryId={$queries.selectedQueryId} /> <RestQueryViewer queryId={$queries.selectedQueryId} />
{:else} {:else}
<QueryViewer {query} /> <QueryViewer query={editableQuery} />
{/if} {/if}
{/if} {/if}

View File

@ -101,7 +101,12 @@
} }
// Ignore events when typing // Ignore events when typing
const activeTag = document.activeElement?.tagName.toLowerCase() const activeTag = document.activeElement?.tagName.toLowerCase()
if (["input", "textarea"].indexOf(activeTag) !== -1 && e.key !== "Escape") { const inCodeEditor =
document.activeElement?.classList?.contains("cm-content")
if (
(inCodeEditor || ["input", "textarea"].indexOf(activeTag) !== -1) &&
e.key !== "Escape"
) {
return return
} }
// Key events are always for the selected component // Key events are always for the selected component

View File

@ -140,6 +140,7 @@
nested={setting.nested} nested={setting.nested}
onChange={val => updateSetting(setting, val)} onChange={val => updateSetting(setting, val)}
highlighted={$store.highlightedSettingKey === setting.key} highlighted={$store.highlightedSettingKey === setting.key}
propertyFocus={$store.propertyFocus === setting.key}
info={setting.info} info={setting.info}
props={{ props={{
// Generic settings // Generic settings

View File

@ -1,2 +1,14 @@
<script>
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
</script>
<!-- routify:options index=2 --> <!-- routify:options index=2 -->
<slot /> <slot />

View File

@ -5,7 +5,6 @@
Divider, Divider,
ActionMenu, ActionMenu,
MenuItem, MenuItem,
Avatar,
Page, Page,
Icon, Icon,
Body, Body,
@ -22,6 +21,8 @@
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg" import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { UserAvatar } from "@budibase/frontend-core"
import { helpers } from "@budibase/shared-core"
let loaded = false let loaded = false
let userInfoModal let userInfoModal
@ -96,11 +97,7 @@
<img class="logo" alt="logo" src={$organisation.logoUrl || Logo} /> <img class="logo" alt="logo" src={$organisation.logoUrl || Logo} />
<ActionMenu align="right"> <ActionMenu align="right">
<div slot="control" class="avatar"> <div slot="control" class="avatar">
<Avatar <UserAvatar user={$auth.user} showTooltip={false} />
size="M"
initials={$auth.initials}
url={$auth.user.pictureUrl}
/>
<Icon size="XL" name="ChevronDown" /> <Icon size="XL" name="ChevronDown" />
</div> </div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}> <MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
@ -125,7 +122,7 @@
</div> </div>
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Heading size="M"> <Heading size="M">
Hey {$auth.user.firstName || $auth.user.email} Hey {helpers.getUserLabel($auth.user)}
</Heading> </Heading>
<Body> <Body>
Welcome to the {$organisation.company} portal. Below you'll find the Welcome to the {$organisation.company} portal. Below you'll find the

View File

@ -1,11 +1,12 @@
<script> <script>
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { ActionMenu, Avatar, MenuItem, Icon, Modal } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ProfileModal from "components/settings/ProfileModal.svelte" import ProfileModal from "components/settings/ProfileModal.svelte"
import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte"
import ThemeModal from "components/settings/ThemeModal.svelte" import ThemeModal from "components/settings/ThemeModal.svelte"
import APIKeyModal from "components/settings/APIKeyModal.svelte" import APIKeyModal from "components/settings/APIKeyModal.svelte"
import { UserAvatar } from "@budibase/frontend-core"
let themeModal let themeModal
let profileModal let profileModal
@ -23,7 +24,7 @@
<ActionMenu align="right"> <ActionMenu align="right">
<div slot="control" class="user-dropdown"> <div slot="control" class="user-dropdown">
<Avatar size="M" initials={$auth.initials} url={$auth.user.pictureUrl} /> <UserAvatar user={$auth.user} showTooltip={false} />
<Icon size="XL" name="ChevronDown" /> <Icon size="XL" name="ChevronDown" />
</div> </div>
<MenuItem icon="UserEdit" on:click={() => profileModal.show()}> <MenuItem icon="UserEdit" on:click={() => profileModal.show()}>

View File

@ -1,47 +1,9 @@
<script> <script>
import { Avatar, Tooltip } from "@budibase/bbui" import { UserAvatar } from "@budibase/frontend-core"
export let row export let row
let showTooltip
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
</script> </script>
{#if row?.user?.email} {#if row?.user?.email}
<div <UserAvatar user={row.user} />
class="container"
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
<Avatar size="M" initials={getInitials(row.user)} />
</div>
{#if showTooltip}
<div class="tooltip">
<Tooltip textWrapping text={row.user.email} direction="bottom" />
</div>
{/if}
{/if} {/if}
<style>
.container {
position: relative;
}
.tooltip {
z-index: 1;
position: absolute;
top: 75%;
left: 120%;
transform: translateX(-100%) translateY(-50%);
display: flex;
flex-direction: row;
justify-content: flex-end;
width: 130px;
pointer-events: none;
}
</style>

View File

@ -355,7 +355,6 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
gap: var(--spacing-xl); gap: var(--spacing-xl);
overflow: hidden;
} }
.empty-wrapper { .empty-wrapper {

View File

@ -1,13 +0,0 @@
<script>
import PanelHeader from "./PanelHeader.svelte"
export let onBack = () => {}
</script>
<div>
<PanelHeader
title="Give it some data"
subtitle="Not ready to add yours? Get started with sample data!"
{onBack}
/>
<slot />
</div>

View File

@ -1,120 +0,0 @@
<script>
import { Button, FancyForm, FancyInput, FancyCheckbox } from "@budibase/bbui"
import GoogleButton from "components/backend/DatasourceNavigator/_components/GoogleButton.svelte"
import { capitalise } from "helpers/helpers"
import PanelHeader from "./PanelHeader.svelte"
import { helpers } from "@budibase/shared-core"
export let title = ""
export let onBack = null
export let onNext = () => {}
export let fields = {}
export let type = ""
let errors = {}
const formatName = name => {
if (name === "ca") {
return "CA"
}
if (name === "ssl") {
return "SSL"
}
if (name === "rejectUnauthorized") {
return "Reject Unauthorized"
}
return capitalise(name)
}
const getDefaultValues = fields => {
const newValues = {}
Object.entries(fields).forEach(([name, { default: defaultValue }]) => {
if (defaultValue) {
newValues[name] = defaultValue
}
})
return newValues
}
const values = getDefaultValues(fields)
const validateRequired = value => {
if (value.length < 1) {
return "Required field"
}
}
const getIsValid = (fields, errors, values) => {
for (const [name, { required }] of Object.entries(fields)) {
if (required && !values[name]) {
return false
}
}
return Object.values(errors).every(error => !error)
}
$: isValid = getIsValid(fields, errors, values)
$: isGoogle = helpers.isGoogleSheets(type)
const handleNext = async () => {
const parsedValues = {}
Object.entries(values).forEach(([name, value]) => {
if (fields[name].type === "number") {
parsedValues[name] = parseInt(value, 10)
} else {
parsedValues[name] = value
}
})
if (isGoogle) {
parsedValues.isGoogle = isGoogle
}
return await onNext(parsedValues)
}
</script>
<div>
<PanelHeader
{title}
subtitle="Fill in the required fields to fetch your tables"
{onBack}
/>
<div class="form">
<FancyForm>
{#each Object.entries(fields) as [name, { type, default: defaultValue, required }]}
{#if type !== "boolean"}
<FancyInput
bind:value={values[name]}
bind:error={errors[name]}
validate={required ? validateRequired : () => {}}
label={formatName(name)}
{type}
/>
{/if}
{/each}
{#each Object.entries(fields) as [name, { type, default: defaultValue, required }]}
{#if type === "boolean"}
<FancyCheckbox bind:value={values[name]} text={formatName(name)} />
{/if}
{/each}
</FancyForm>
</div>
{#if isGoogle}
<GoogleButton disabled={!isValid} preAuthStep={handleNext} samePage />
{:else}
<Button cta disabled={!isValid} on:click={handleNext}>Connect</Button>
{/if}
</div>
<style>
.form {
margin-bottom: 36px;
}
</style>

View File

@ -1,6 +1,5 @@
<script> <script>
export let name = "" export let name = ""
export let showData = false
const rows = [ const rows = [
{ {
@ -49,7 +48,7 @@
<h1>{name}</h1> <h1>{name}</h1>
</div> </div>
<div class="nav">Home</div> <div class="nav">Home</div>
<table class={`table ${showData ? "tableVisible" : ""}`}> <table>
<thead> <thead>
<tr> <tr>
<th>FIRST NAME</th> <th>FIRST NAME</th>
@ -71,7 +70,7 @@
{/each} {/each}
</tbody> </tbody>
</table> </table>
<div class={`sidePanel ${showData ? "sidePanelVisible" : ""}`}> <div class="sidePanel">
<h2>{rows[0].firstName}</h2> <h2>{rows[0].firstName}</h2>
<div class="field"> <div class="field">
<label for="exampleLastName">lastName</label> <label for="exampleLastName">lastName</label>
@ -199,14 +198,6 @@
text-align: left; text-align: left;
} }
.table {
opacity: 0;
}
.tableVisible {
opacity: 1;
}
.sidePanel { .sidePanel {
position: absolute; position: absolute;
width: 300px; width: 300px;
@ -216,9 +207,6 @@
top: 0; top: 0;
right: -364px; right: -364px;
padding: 42px 32px; padding: 42px 32px;
}
.sidePanelVisible {
right: 0; right: 0;
} }

View File

@ -3,6 +3,7 @@
import PanelHeader from "./PanelHeader.svelte" import PanelHeader from "./PanelHeader.svelte"
import { APP_URL_REGEX } from "constants" import { APP_URL_REGEX } from "constants"
export let disabled
export let name = "" export let name = ""
export let url = "" export let url = ""
export let onNext = () => {} export let onNext = () => {}
@ -71,7 +72,9 @@
{:else} {:else}
<p></p> <p></p>
{/if} {/if}
<Button size="L" cta disabled={!isValid} on:click={onNext}>Lets go!</Button> <Button size="L" cta disabled={!isValid || disabled} on:click={onNext}
>Lets go!</Button
>
</div> </div>
<style> <style>

View File

@ -1,102 +1,50 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import NamePanel from "./_components/NamePanel.svelte" import NamePanel from "./_components/NamePanel.svelte"
import DataPanel from "./_components/DataPanel.svelte"
import DatasourceConfigPanel from "./_components/DatasourceConfigPanel.svelte"
import ExampleApp from "./_components/ExampleApp.svelte" import ExampleApp from "./_components/ExampleApp.svelte"
import { FancyButton, notifications, Modal, Body } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import IntegrationIcon from "components/backend/DatasourceNavigator/IntegrationIcon.svelte"
import { SplitPage } from "@budibase/frontend-core" import { SplitPage } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { saveDatasource } from "builderStore/datasource" import { auth, admin } from "stores/portal"
import { integrations } from "stores/backend"
import { auth, admin, organisation } from "stores/portal"
import FontAwesomeIcon from "components/common/FontAwesomeIcon.svelte"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen" import createFromScratchScreen from "builderStore/store/screenTemplates/createFromScratchScreen"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
import Spinner from "components/common/Spinner.svelte"
import { helpers } from "@budibase/shared-core"
import { validateDatasourceConfig } from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
let name = "My first app" let name = "My first app"
let url = "my-first-app" let url = "my-first-app"
let stage = "name"
let appId = null let appId = null
let plusIntegrations = {} let loading = false
let integrationsLoading = true
let creationLoading = false
let uploadModal
let googleComplete = false
$: getIntegrations() const createApp = async () => {
loading = true
const createApp = async useSampleData => {
creationLoading = true
// Create form data to create app // Create form data to create app
// This is form based and not JSON // This is form based and not JSON
try { let data = new FormData()
let data = new FormData() data.append("name", name.trim())
data.append("name", name.trim()) data.append("url", url.trim())
data.append("url", url.trim()) data.append("useTemplate", false)
data.append("useTemplate", false)
if (useSampleData) { const createdApp = await API.createApp(data)
data.append("sampleData", true)
}
const createdApp = await API.createApp(data) // Select Correct Application/DB in prep for creating user
const pkg = await API.fetchAppPackage(createdApp.instance._id)
await store.actions.initialise(pkg)
await automationStore.actions.fetch()
// Update checklist - in case first app
await admin.init()
// Select Correct Application/DB in prep for creating user // Create user
const pkg = await API.fetchAppPackage(createdApp.instance._id) await auth.setInitInfo({})
await store.actions.initialise(pkg)
await automationStore.actions.fetch()
// Update checklist - in case first app
await admin.init()
// Create user let defaultScreenTemplate = createFromScratchScreen.create()
await auth.setInitInfo({}) defaultScreenTemplate.routing.route = "/home"
defaultScreenTemplate.routing.roldId = Roles.BASIC
await store.actions.screens.save(defaultScreenTemplate)
let defaultScreenTemplate = createFromScratchScreen.create() appId = createdApp.instance._id
defaultScreenTemplate.routing.route = "/home" return createdApp
defaultScreenTemplate.routing.roldId = Roles.BASIC
await store.actions.screens.save(defaultScreenTemplate)
appId = createdApp.instance._id
return createdApp
} catch (e) {
creationLoading = false
throw e
}
}
const getIntegrations = async () => {
try {
await integrations.init()
const newPlusIntegrations = {}
Object.entries($integrations).forEach(([integrationType, schema]) => {
// google sheets not available in self-host
if (
helpers.isGoogleSheets(integrationType) &&
!$organisation.googleDatasourceConfigured
) {
return
}
if (schema?.plus) {
newPlusIntegrations[integrationType] = schema
}
})
plusIntegrations = newPlusIntegrations
} catch (e) {
notifications.error("There was a problem communicating with the server.")
} finally {
integrationsLoading = false
}
} }
const goToApp = () => { const goToApp = () => {
@ -104,152 +52,23 @@
notifications.success(`App created successfully`) notifications.success(`App created successfully`)
} }
const handleCreateApp = async ({ const handleCreateApp = async () => {
datasourceConfig,
useSampleData,
isGoogle,
}) => {
let app
try { try {
if ( await createApp()
datasourceConfig &&
plusIntegrations[stage].features[DatasourceFeature.CONNECTION_CHECKING]
) {
const resp = await validateDatasourceConfig({
config: datasourceConfig,
type: stage,
})
if (!resp.connected) {
notifications.error(
`Unable to connect - ${resp.error ?? "Error validating datasource"}`
)
return false
}
}
app = await createApp(useSampleData) goToApp()
let datasource
if (datasourceConfig) {
datasource = await saveDatasource({
plus: true,
auth: undefined,
name: plusIntegrations[stage].friendlyName,
schema: plusIntegrations[stage].datasource,
config: datasourceConfig,
type: stage,
})
}
store.set()
if (isGoogle) {
googleComplete = true
return { datasource, appId: app.appId }
} else {
goToApp()
}
} catch (e) { } catch (e) {
console.log(e) loading = false
creationLoading = false
notifications.error("There was a problem creating your app") notifications.error("There was a problem creating your app")
// Reset the store so that we don't send up stale headers
store.actions.reset()
// If we successfully created an app, delete it again so that we
// can try again once the error has been corrected.
// This also ensures onboarding can't be skipped by entering invalid
// data credentials.
if (app?.appId) {
await API.deleteApp(app.appId)
}
} }
} }
</script> </script>
<Modal bind:this={uploadModal}>
<CreateTableModal
name="Your Data"
beforeSave={createApp}
afterSave={goToApp}
/>
</Modal>
<div class="full-width"> <div class="full-width">
<SplitPage> <SplitPage>
{#if stage === "name"} <NamePanel bind:name bind:url disabled={loading} onNext={handleCreateApp} />
<NamePanel bind:name bind:url onNext={() => (stage = "data")} />
{:else if googleComplete}
<div class="centered">
<Body
>Please login to your Google account in the new tab which as opened to
continue.</Body
>
</div>
{:else if integrationsLoading || creationLoading}
<div class="centered">
<Spinner />
</div>
{:else if stage === "data"}
<DataPanel onBack={() => (stage = "name")}>
<div class="dataButton">
<FancyButton
on:click={() => handleCreateApp({ useSampleData: true })}
>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<img
alt="Budibase Logo"
class="budibaseLogo"
src={"https://i.imgur.com/Xhdt1YP.png"}
/>
</div>
Budibase Sample data
</div>
</FancyButton>
</div>
<div class="dataButton">
<FancyButton on:click={uploadModal.show}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<FontAwesomeIcon name="fa-solid fa-file-arrow-up" />
</div>
Upload data (CSV or JSON)
</div>
</FancyButton>
</div>
{#each Object.entries(plusIntegrations) as [integrationType, schema]}
<div class="dataButton">
<FancyButton on:click={() => (stage = integrationType)}>
<div class="dataButtonContent">
<div class="dataButtonIcon">
<IntegrationIcon {integrationType} {schema} />
</div>
{schema.friendlyName}
</div>
</FancyButton>
</div>
{/each}
</DataPanel>
{:else if stage in plusIntegrations}
<DatasourceConfigPanel
title={plusIntegrations[stage].friendlyName}
fields={plusIntegrations[stage].datasource}
type={stage}
onBack={() => (stage = "data")}
onNext={data => {
const isGoogle = data.isGoogle
delete data.isGoogle
return handleCreateApp({ datasourceConfig: data, isGoogle })
}}
/>
{:else}
<p>There was an problem. Please refresh the page and try again.</p>
{/if}
<div slot="right"> <div slot="right">
<ExampleApp {name} showData={stage !== "name"} /> <ExampleApp {name} />
</div> </div>
</SplitPage> </SplitPage>
</div> </div>
@ -258,35 +77,4 @@
.full-width { .full-width {
width: 100%; width: 100%;
} }
.centered {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.dataButton {
margin-bottom: 12px;
}
.dataButtonContent {
display: flex;
align-items: center;
}
.budibaseLogo {
height: 20px;
}
.dataButtonIcon {
width: 22px;
display: flex;
justify-content: center;
margin-right: 16px;
}
.dataButtonContent :global(svg) {
font-size: 18px;
color: white;
}
</style> </style>

View File

@ -20,11 +20,10 @@
Breadcrumb, Breadcrumb,
Header, Header,
} from "components/portal/page" } from "components/portal/page"
import { apps, auth, overview } from "stores/portal" import { apps, overview } from "stores/portal"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore" import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte" import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api" import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -53,8 +52,6 @@
$: appId = $overview.selectedAppId $: appId = $overview.selectedAppId
$: initialiseApp(appId) $: initialiseApp(appId)
$: isPublished = app?.status === AppStatus.DEPLOYED $: isPublished = app?.status === AppStatus.DEPLOYED
$: appLocked = !!app?.lockedBy
$: lockedByYou = $auth.user.email === app?.lockedBy?.email
const initialiseApp = async appId => { const initialiseApp = async appId => {
loaded = false loaded = false
@ -80,13 +77,6 @@
} }
const editApp = () => { const editApp = () => {
if (appLocked && !lockedByYou) {
const identifier = app?.lockedBy?.firstName || app?.lockedBy?.email
notifications.warning(
`App locked by ${identifier}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../../app/${app.devId}`) $goto(`../../../app/${app.devId}`)
} }
@ -135,7 +125,6 @@
/> />
</div> </div>
<div slot="buttons"> <div slot="buttons">
<AppLockModal {app} />
<span class="desktop"> <span class="desktop">
<Button <Button
size="M" size="M"
@ -148,14 +137,7 @@
</Button> </Button>
</span> </span>
<span class="desktop"> <span class="desktop">
<Button <Button size="M" cta on:click={editApp}>Edit</Button>
size="M"
cta
disabled={appLocked && !lockedByYou}
on:click={editApp}
>
Edit
</Button>
</span> </span>
<ActionMenu align="right"> <ActionMenu align="right">
<span slot="control" class="app-overview-actions-icon"> <span slot="control" class="app-overview-actions-icon">
@ -167,13 +149,7 @@
</MenuItem> </MenuItem>
</span> </span>
<span class="mobile"> <span class="mobile">
<MenuItem <MenuItem icon="Edit" on:click={editApp}>Edit</MenuItem>
icon="Edit"
disabled={appLocked && !lockedByYou}
on:click={editApp}
>
Edit
</MenuItem>
</span> </span>
<MenuItem <MenuItem
on:click={() => exportApp({ published: false })} on:click={() => exportApp({ published: false })}

View File

@ -1,14 +1,11 @@
<script> <script>
import getUserInitials from "helpers/userInitials.js" import { UserAvatar } from "@budibase/frontend-core"
import { Avatar } from "@budibase/bbui"
export let value export let value
$: initials = getUserInitials(value)
</script> </script>
<div title={value.email} class="cell"> <div class="cell">
<Avatar size="M" {initials} /> <UserAvatar user={value} />
</div> </div>
<style> <style>

View File

@ -7,7 +7,6 @@
Icon, Icon,
Heading, Heading,
Link, Link,
Avatar,
Layout, Layout,
Body, Body,
notifications, notifications,
@ -15,7 +14,7 @@
import { store } from "builderStore" import { store } from "builderStore"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps, groups, overview } from "stores/portal" import { users, auth, apps, groups, overview } from "stores/portal"
import { fetchData } from "@budibase/frontend-core" import { fetchData, UserAvatar } from "@budibase/frontend-core"
import { API } from "api" import { API } from "api"
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte" import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -52,18 +51,30 @@
return groups.actions.getGroupAppIds(group).includes(prodAppId) return groups.actions.getGroupAppIds(group).includes(prodAppId)
}) })
const updateDeploymentString = () => {
return deployments?.length
? processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(deployments[0].updatedAt).getTime(),
}
)
: ""
}
// App is updating in the layout asynchronously
$: if ($store.appId?.length) {
fetchDeployments().then(resp => {
deployments = resp
})
}
$: deploymentString = updateDeploymentString(deployments)
async function fetchAppEditor(editorId) { async function fetchAppEditor(editorId) {
appEditor = await users.get(editorId) appEditor = await users.get(editorId)
} }
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials === "" ? user.email[0] : initials
}
const confirmUnpublishApp = async () => { const confirmUnpublishApp = async () => {
try { try {
await API.unpublishApp(app.prodId) await API.unpublishApp(app.prodId)
@ -116,19 +127,11 @@
</div> </div>
<div class="status-text"> <div class="status-text">
{#if deployments?.length} {#if isPublished}
{processStringSync( {deploymentString}
"Last published {{ duration time 'millisecond' }} ago", - <Link on:click={unpublishModal.show}>Unpublish</Link>
{
time:
new Date().getTime() -
new Date(deployments[0].updatedAt).getTime(),
}
)}
{#if isPublished}
- <Link on:click={unpublishModal.show}>Unpublish</Link>
{/if}
{/if} {/if}
{#if !deployments?.length} {#if !deployments?.length}
- -
{/if} {/if}
@ -140,7 +143,7 @@
<div class="last-edited-content"> <div class="last-edited-content">
<div class="updated-by"> <div class="updated-by">
{#if appEditor} {#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} /> <UserAvatar user={appEditor} showTooltip={false} />
<div class="editor-name"> <div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText} {appEditor._id === $auth.user._id ? "You" : appEditorText}
</div> </div>
@ -201,7 +204,7 @@
<div class="users"> <div class="users">
<div class="list"> <div class="list">
{#each appUsers.slice(0, 4) as user} {#each appUsers.slice(0, 4) as user}
<Avatar size="M" initials={getInitials(user)} /> <UserAvatar {user} />
{/each} {/each}
</div> </div>
<div class="text"> <div class="text">

View File

@ -58,7 +58,7 @@
} }
onMount(async () => { onMount(async () => {
await Promise.all(fetchConfig(), fetchAPIKey()) await Promise.all([fetchConfig(), fetchAPIKey()])
}) })
const copyToClipboard = async value => { const copyToClipboard = async value => {

View File

@ -2,7 +2,6 @@
import { goto, url } from "@roxi/routify" import { goto, url } from "@roxi/routify"
import { import {
ActionMenu, ActionMenu,
Avatar,
Button, Button,
Layout, Layout,
Heading, Heading,
@ -25,13 +24,14 @@
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
import DeleteUserModal from "./_components/DeleteUserModal.svelte" import DeleteUserModal from "./_components/DeleteUserModal.svelte"
import GroupIcon from "../groups/_components/GroupIcon.svelte" import GroupIcon from "../groups/_components/GroupIcon.svelte"
import { Constants } from "@budibase/frontend-core" import { Constants, UserAvatar } from "@budibase/frontend-core"
import { Breadcrumbs, Breadcrumb } from "components/portal/page" import { Breadcrumbs, Breadcrumb } from "components/portal/page"
import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte" import RemoveGroupTableRenderer from "./_components/RemoveGroupTableRenderer.svelte"
import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte" import GroupNameTableRenderer from "../groups/_components/GroupNameTableRenderer.svelte"
import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte" import AppNameTableRenderer from "./_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte" import AppRoleTableRenderer from "./_components/AppRoleTableRenderer.svelte"
import ScimBanner from "../_components/SCIMBanner.svelte" import ScimBanner from "../_components/SCIMBanner.svelte"
import { helpers } from "@budibase/shared-core"
export let userId export let userId
@ -91,7 +91,7 @@
$: readonly = !$auth.isAdmin || scimEnabled $: readonly = !$auth.isAdmin || scimEnabled
$: privileged = user?.admin?.global || user?.builder?.global $: privileged = user?.admin?.global || user?.builder?.global
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: initials = getInitials(nameLabel) $: initials = helpers.getUserInitials(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm) $: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => { $: userGroups = $groups.filter(x => {
@ -150,17 +150,6 @@
return label return label
} }
const getInitials = nameLabel => {
if (!nameLabel) {
return "?"
}
return nameLabel
.split(" ")
.slice(0, 2)
.map(x => x[0])
.join("")
}
async function updateUserFirstName(evt) { async function updateUserFirstName(evt) {
try { try {
await users.save({ ...user, firstName: evt.target.value }) await users.save({ ...user, firstName: evt.target.value })
@ -238,7 +227,7 @@
<div class="title"> <div class="title">
<div class="user-info"> <div class="user-info">
<Avatar size="XXL" {initials} /> <UserAvatar size="XXL" {user} showTooltip={false} />
<div class="subtitle"> <div class="subtitle">
<Heading size="M">{nameLabel}</Heading> <Heading size="M">{nameLabel}</Heading>
{#if nameLabel !== user?.email} {#if nameLabel !== user?.email}

View File

@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store" import { writable, derived, get } from "svelte/store"
import { queries, tables } from "./" import { queries, tables } from "./"
import { API } from "api" import { API } from "api"
@ -91,6 +91,39 @@ export function createDatasourcesStore() {
}) })
} }
// Handles external updates of datasources
const replaceDatasource = (datasourceId, datasource) => {
if (!datasourceId) {
return
}
// Handle deletion
if (!datasource) {
store.update(state => ({
...state,
list: state.list.filter(x => x._id !== datasourceId),
}))
return
}
// Add new datasource
const index = get(store).list.findIndex(x => x._id === datasource._id)
if (index === -1) {
store.update(state => ({
...state,
list: [...state.list, datasource],
}))
}
// Update existing datasource
else if (datasource) {
store.update(state => {
state.list[index] = datasource
return state
})
}
}
return { return {
subscribe: derivedStore.subscribe, subscribe: derivedStore.subscribe,
fetch, fetch,
@ -100,6 +133,7 @@ export function createDatasourcesStore() {
save, save,
delete: deleteDatasource, delete: deleteDatasource,
removeSchemaError, removeSchemaError,
replaceDatasource,
} }
} }

View File

@ -22,18 +22,6 @@ export function createTablesStore() {
})) }))
} }
const fetchTable = async tableId => {
const table = await API.fetchTableDefinition(tableId)
store.update(state => {
const indexToUpdate = state.list.findIndex(t => t._id === table._id)
state.list[indexToUpdate] = table
return {
...state,
}
})
}
const select = tableId => { const select = tableId => {
store.update(state => ({ store.update(state => ({
...state, ...state,
@ -74,20 +62,21 @@ export function createTablesStore() {
} }
const savedTable = await API.saveTable(updatedTable) const savedTable = await API.saveTable(updatedTable)
await fetch() replaceTable(savedTable._id, savedTable)
if (table.type === "external") { await datasources.fetch()
await datasources.fetch() select(savedTable._id)
}
await select(savedTable._id)
return savedTable return savedTable
} }
const deleteTable = async table => { const deleteTable = async table => {
if (!table?._id || !table?._rev) {
return
}
await API.deleteTable({ await API.deleteTable({
tableId: table?._id, tableId: table._id,
tableRev: table?._rev, tableRev: table._rev,
}) })
await fetch() replaceTable(table._id, null)
} }
const saveField = async ({ const saveField = async ({
@ -135,35 +124,56 @@ export function createTablesStore() {
await save(draft) await save(draft)
} }
const updateTable = table => { // Handles external updates of tables
const index = get(store).list.findIndex(x => x._id === table._id) const replaceTable = (tableId, table) => {
if (index === -1) { if (!tableId) {
return return
} }
// This function has to merge state as there discrepancies with the table // Handle deletion
// API endpoints. The table list endpoint and get table endpoint use the if (!table) {
// "type" property to mean different things. store.update(state => ({
store.update(state => { ...state,
state.list[index] = { list: state.list.filter(x => x._id !== tableId),
...table, }))
type: state.list[index].type, return
} }
return state
}) // Add new table
const index = get(store).list.findIndex(x => x._id === table._id)
if (index === -1) {
store.update(state => ({
...state,
list: [...state.list, table],
}))
}
// Update existing table
else if (table) {
// This function has to merge state as there discrepancies with the table
// API endpoints. The table list endpoint and get table endpoint use the
// "type" property to mean different things.
store.update(state => {
state.list[index] = {
...table,
type: state.list[index].type,
}
return state
})
}
} }
return { return {
...store,
subscribe: derivedStore.subscribe, subscribe: derivedStore.subscribe,
fetch, fetch,
fetchTable,
init: fetch, init: fetch,
select, select,
save, save,
delete: deleteTable, delete: deleteTable,
saveField, saveField,
deleteField, deleteField,
updateTable, replaceTable,
} }
} }

View File

@ -1,4 +1,4 @@
import { writable, get, derived } from "svelte/store" import { writable, derived } from "svelte/store"
import { tables } from "./" import { tables } from "./"
import { API } from "api" import { API } from "api"
@ -27,21 +27,31 @@ export function createViewsStore() {
const deleteView = async view => { const deleteView = async view => {
await API.deleteView(view) await API.deleteView(view)
await tables.fetch()
// Update tables
tables.update(state => {
const table = state.list.find(table => table._id === view.tableId)
if (table) {
delete table.views[view.name]
}
return { ...state }
})
} }
const save = async view => { const save = async view => {
const savedView = await API.saveView(view) const savedView = await API.saveView(view)
const viewMeta = {
name: view.name,
...savedView,
}
const viewTable = get(tables).list.find(table => table._id === view.tableId) // Update tables
tables.update(state => {
if (view.originalName) delete viewTable.views[view.originalName] const table = state.list.find(table => table._id === view.tableId)
viewTable.views[view.name] = viewMeta if (table) {
await tables.save(viewTable) if (view.originalName) {
delete table.views[view.originalName]
}
table.views[view.name] = savedView
}
return { ...state }
})
} }
return { return {

View File

@ -116,6 +116,9 @@ export const createLicensingStore = () => {
const auditLogsEnabled = license.features.includes( const auditLogsEnabled = license.features.includes(
Constants.Features.AUDIT_LOGS Constants.Features.AUDIT_LOGS
) )
const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS
)
store.update(state => { store.update(state => {
return { return {
...state, ...state,
@ -130,6 +133,7 @@ export const createLicensingStore = () => {
environmentVariablesEnabled, environmentVariablesEnabled,
auditLogsEnabled, auditLogsEnabled,
enforceableSSO, enforceableSSO,
syncAutomationsEnabled,
} }
}) })
}, },

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "0.0.1", "version": "0.0.0",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "dist/src/index.js", "main": "dist/src/index.js",
"bin": { "bin": {
@ -29,9 +29,9 @@
"outputPath": "build" "outputPath": "build"
}, },
"dependencies": { "dependencies": {
"@budibase/backend-core": "0.0.1", "@budibase/backend-core": "0.0.0",
"@budibase/string-templates": "0.0.1", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.1", "@budibase/types": "0.0.0",
"axios": "0.21.2", "axios": "0.21.2",
"chalk": "4.1.0", "chalk": "4.1.0",
"cli-progress": "3.11.2", "cli-progress": "3.11.2",

Some files were not shown because too many files have changed in this diff Show More