diff --git a/docs/DEV-SETUP-DEBIAN.md b/docs/DEV-SETUP-DEBIAN.md index 88a124708c..9edd8286cb 100644 --- a/docs/DEV-SETUP-DEBIAN.md +++ b/docs/DEV-SETUP-DEBIAN.md @@ -1,12 +1,15 @@ ## Dev Environment on Debian 11 -### Install Node +### Install NVM & Node 14 +NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating -Budibase requires a recent version of node (14+): +Install NVM ``` -curl -sL https://deb.nodesource.com/setup_16.x | sudo bash - -apt -y install nodejs -node -v +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +``` +Install Node 14 +``` +nvm install 14 ``` ### Install npm requirements @@ -31,7 +34,7 @@ This setup process was tested on Debian 11 (bullseye) with version numbers show - Docker: 20.10.5 - Docker-Compose: 1.29.2 -- Node: v16.15.1 +- Node: v14.20.1 - Yarn: 1.22.19 - Lerna: 5.1.4 diff --git a/docs/DEV-SETUP-MACOSX.md b/docs/DEV-SETUP-MACOSX.md index c5990e58da..d9e2dcad6a 100644 --- a/docs/DEV-SETUP-MACOSX.md +++ b/docs/DEV-SETUP-MACOSX.md @@ -11,7 +11,7 @@ through brew. ### Install Node -Budibase requires a recent version of node (14+): +Budibase requires a recent version of node 14: ``` brew install node npm node -v @@ -38,7 +38,7 @@ This setup process was tested on Mac OSX 12 (Monterey) with version numbers show - Docker: 20.10.14 - Docker-Compose: 2.6.0 -- Node: 18.3.0 +- Node: 14.20.1 - Yarn: 1.22.19 - Lerna: 5.1.4 @@ -59,4 +59,7 @@ The dev version will be available on port 10000 i.e. http://127.0.0.1:10000/builder/admin | **NOTE**: If you are working on a M1 Apple Silicon, you will need to uncomment `# platform: linux/amd64` line in -[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml) \ No newline at end of file +[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml) + +### Troubleshooting +If there are errors with the `yarn setup` command, you can try installing nvm and node 14. This is the same as the instructions for Debian 11. diff --git a/docs/DEV-SETUP-WINDOWS.md b/docs/DEV-SETUP-WINDOWS.md new file mode 100644 index 0000000000..c5608b7567 --- /dev/null +++ b/docs/DEV-SETUP-WINDOWS.md @@ -0,0 +1,81 @@ +## Dev Environment on Windows 10/11 (WSL2) + + +### Install WSL with Ubuntu LTS + +Enable WSL 2 on Windows 10/11 for docker support. +``` +wsl --set-default-version 2 +``` +Install Ubuntu LTS. +``` +wsl --install Ubuntu +``` + +Or follow the instruction here: +https://learn.microsoft.com/en-us/windows/wsl/install + +### Install Docker in windows +Download the installer from docker and install it. + +Check this url for more detailed instructions: +https://docs.docker.com/desktop/install/windows-install/ + +You should follow the next steps from within the Ubuntu terminal. + +### Install NVM & Node 14 +NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating + +Install NVM +``` +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash +``` +Install Node 14 +``` +nvm install 14 +``` + + +### Install npm requirements + +``` +npm install -g yarn jest lerna +``` + +### Clone the repo +``` +git clone https://github.com/Budibase/budibase.git +``` + +### Check Versions + +This setup process was tested on Windows 11 with version numbers show below. Your mileage may vary using anything else. + +- Docker: 20.10.7 +- Docker-Compose: 2.10.2 +- Node: v14.20.1 +- Yarn: 1.22.19 +- Lerna: 5.5.4 + +### Build + +``` +cd budibase +yarn setup +``` +The yarn setup command runs several build steps i.e. +``` +node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev +``` +So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose. + +The dev version will be available on port 10000 i.e. + +http://127.0.0.1:10000/builder/admin + +### Working with the code +Here are the instructions to work on the application from within Visual Studio Code (in Windows) through the WSL. All the commands and files are within the Ubuntu system and it should run as if you were working on a Linux machine. + +https://code.visualstudio.com/docs/remote/wsl + +Note you will be able to run the application from within the WSL terminal and you will be able to access the application from the a browser in Windows. \ No newline at end of file diff --git a/hosting/proxy/10-listen-on-ipv6-by-default.sh b/hosting/proxy/10-listen-on-ipv6-by-default.sh new file mode 100644 index 0000000000..e2e89388a9 --- /dev/null +++ b/hosting/proxy/10-listen-on-ipv6-by-default.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# vim:sw=4:ts=4:et + +set -e + +ME=$(basename $0) +NGINX_CONF_FILE="/etc/nginx/nginx.conf" +DEFAULT_CONF_FILE="/etc/nginx/conf.d/default.conf" + +# check if we have ipv6 available +if [ ! -f "/proc/net/if_inet6" ]; then + # ipv6 not available so delete lines from nginx conf + if [ -f "$NGINX_CONF_FILE" ]; then + sed -i '/listen \[::\]/d' $NGINX_CONF_FILE + fi + if [ -f "$DEFAULT_CONF_FILE" ]; then + sed -i '/listen \[::\]/d' $DEFAULT_CONF_FILE + fi + echo "$ME: info: ipv6 not available so delete lines from nginx conf" +else + echo "$ME: info: ipv6 is available so no need to delete lines from nginx conf" +fi + +exit 0 diff --git a/hosting/proxy/Dockerfile b/hosting/proxy/Dockerfile index 298762aaf1..5fd0dc7d11 100644 --- a/hosting/proxy/Dockerfile +++ b/hosting/proxy/Dockerfile @@ -5,7 +5,7 @@ FROM nginx:latest # override the output dir to output directly to /etc/nginx instead of /etc/nginx/conf.d ENV NGINX_ENVSUBST_OUTPUT_DIR=/etc/nginx COPY .generated-nginx.prod.conf /etc/nginx/templates/nginx.conf.template - +COPY 10-listen-on-ipv6-by-default.sh /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh # Error handling COPY error.html /usr/share/nginx/html/error.html diff --git a/hosting/scripts/build-target-paths.sh b/hosting/scripts/build-target-paths.sh index c974d9a304..67e1765ca8 100644 --- a/hosting/scripts/build-target-paths.sh +++ b/hosting/scripts/build-target-paths.sh @@ -4,6 +4,7 @@ echo ${TARGETBUILD} > /buildtarget.txt if [[ "${TARGETBUILD}" = "aas" ]]; then # Azure AppService uses /home for persisent data & SSH on port 2222 DATA_DIR=/home + WEBSITES_ENABLE_APP_SERVICE_STORAGE=true mkdir -p $DATA_DIR/{search,minio,couch} mkdir -p $DATA_DIR/couch/{dbs,views} chown -R couchdb:couchdb $DATA_DIR/couch/ diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index f34290f627..58796f0362 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -19,8 +19,8 @@ ADD packages/worker . RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh FROM couchdb:3.2.1 -# TARGETARCH can be amd64 or arm e.g. docker build --build-arg TARGETARCH=amd64 -ARG TARGETARCH=amd64 +ARG TARGETARCH +ENV TARGETARCH $TARGETARCH #TARGETBUILD can be set to single (for single docker image) or aas (for azure app service) # e.g. docker build --build-arg TARGETBUILD=aas .... ARG TARGETBUILD=single diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh index e02b33d771..6770d27ee0 100644 --- a/hosting/single/runner.sh +++ b/hosting/single/runner.sh @@ -21,6 +21,7 @@ declare -a DOCKER_VARS=("APP_PORT" "APPS_URL" "ARCHITECTURE" "BUDIBASE_ENVIRONME # Azure App Service customisations if [[ "${TARGETBUILD}" = "aas" ]]; then DATA_DIR=/home + WEBSITES_ENABLE_APP_SERVICE_STORAGE=true /etc/init.d/ssh start else DATA_DIR=${DATA_DIR:-/data} diff --git a/lerna.json b/lerna.json index 111a13702b..d31cde4be4 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 579e86802e..7733a6df95 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "private": true, "devDependencies": { "@rollup/plugin-json": "^4.0.2", - "@types/mongodb": "3.6.3", "@typescript-eslint/parser": "4.28.0", "babel-eslint": "^10.0.3", "eslint": "^7.28.0", diff --git a/packages/backend-core/context.js b/packages/backend-core/context.js index aaa0f56f92..c6fa87a337 100644 --- a/packages/backend-core/context.js +++ b/packages/backend-core/context.js @@ -6,6 +6,7 @@ const { updateAppId, doInAppContext, doInTenant, + doInContext, } = require("./src/context") const identity = require("./src/context/identity") @@ -19,4 +20,5 @@ module.exports = { doInAppContext, doInTenant, identity, + doInContext, } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 146e41e2e0..39ffe93a4c 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -20,7 +20,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/types": "1.4.18-alpha.1", + "@budibase/types": "2.0.30-alpha.7", "@shopify/jest-koa-mocks": "5.0.1", "@techpass/passport-openidconnect": "0.3.2", "aws-sdk": "2.1030.0", @@ -62,6 +62,7 @@ ] }, "devDependencies": { + "@types/chance": "1.1.3", "@types/jest": "27.5.1", "@types/koa": "2.0.52", "@types/lodash": "4.14.180", @@ -72,6 +73,7 @@ "@types/semver": "7.3.7", "@types/tar-fs": "2.0.1", "@types/uuid": "8.3.4", + "chance": "1.1.3", "ioredis-mock": "5.8.0", "jest": "27.5.1", "koa": "2.7.0", diff --git a/packages/backend-core/src/context/index.ts b/packages/backend-core/src/context/index.ts index 7cc90e3c67..35eeee608b 100644 --- a/packages/backend-core/src/context/index.ts +++ b/packages/backend-core/src/context/index.ts @@ -65,7 +65,16 @@ export const getTenantIDFromAppID = (appId: string) => { } } -// used for automations, API endpoints should always be in context already +export const doInContext = async (appId: string, task: any) => { + // gets the tenant ID from the app ID + const tenantId = getTenantIDFromAppID(appId) + return doInTenant(tenantId, async () => { + return doInAppContext(appId, async () => { + return task() + }) + }) +} + export const doInTenant = (tenantId: string | null, task: any) => { // make sure default always selected in single tenancy if (!env.MULTI_TENANCY) { diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index 45ca675fa6..a61e8a2af2 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -46,6 +46,9 @@ export enum DocumentType { AUTOMATION_LOG = "log_au", ACCOUNT_METADATA = "acc_metadata", PLUGIN = "plg", + TABLE = "ta", + DATASOURCE = "datasource", + DATASOURCE_PLUS = "datasource_plus", } export const StaticDatabases = { diff --git a/packages/backend-core/src/db/conversions.js b/packages/backend-core/src/db/conversions.js index 90c04e9251..5b1a785ecc 100644 --- a/packages/backend-core/src/db/conversions.js +++ b/packages/backend-core/src/db/conversions.js @@ -36,6 +36,7 @@ exports.getDevelopmentAppID = appId => { const rest = split.join(APP_PREFIX) return `${APP_DEV_PREFIX}${rest}` } +exports.getDevAppID = exports.getDevelopmentAppID /** * Convert a development app ID to a deployed app ID. diff --git a/packages/backend-core/src/db/utils.ts b/packages/backend-core/src/db/utils.ts index a12c6bed4f..1c4be7e366 100644 --- a/packages/backend-core/src/db/utils.ts +++ b/packages/backend-core/src/db/utils.ts @@ -64,6 +64,28 @@ export function getQueryIndex(viewName: ViewName) { return `database/${viewName}` } +/** + * Check if a given ID is that of a table. + * @returns {boolean} + */ +export const isTableId = (id: string) => { + // this includes datasource plus tables + return ( + id && + (id.startsWith(`${DocumentType.TABLE}${SEPARATOR}`) || + id.startsWith(`${DocumentType.DATASOURCE_PLUS}${SEPARATOR}`)) + ) +} + +/** + * Check if a given ID is that of a datasource or datasource plus. + * @returns {boolean} + */ +export const isDatasourceId = (id: string) => { + // this covers both datasources and datasource plus + return id && id.startsWith(`${DocumentType.DATASOURCE}${SEPARATOR}`) +} + /** * Generates a new workspace ID. * @returns {string} The new workspace ID which the workspace doc can be stored under. diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 83b23b479d..42cad17620 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -37,6 +37,7 @@ const core = { db, ...dbConstants, redis, + locks: redis.redlock, objectStore, utils, users, diff --git a/packages/backend-core/src/migrations/definitions.ts b/packages/backend-core/src/migrations/definitions.ts index 946fc3f364..6eba56ab43 100644 --- a/packages/backend-core/src/migrations/definitions.ts +++ b/packages/backend-core/src/migrations/definitions.ts @@ -11,7 +11,7 @@ export const DEFINITIONS: MigrationDefinition[] = [ }, { type: MigrationType.GLOBAL, - name: MigrationName.QUOTAS_1, + name: MigrationName.SYNC_QUOTAS, }, { type: MigrationType.APP, @@ -33,8 +33,4 @@ export const DEFINITIONS: MigrationDefinition[] = [ type: MigrationType.GLOBAL, name: MigrationName.GLOBAL_INFO_SYNC_USERS, }, - { - type: MigrationType.GLOBAL, - name: MigrationName.PLUGIN_COUNT, - }, ] diff --git a/packages/backend-core/src/objectStore/index.ts b/packages/backend-core/src/objectStore/index.ts index a97aa8f65d..17e002cc49 100644 --- a/packages/backend-core/src/objectStore/index.ts +++ b/packages/backend-core/src/objectStore/index.ts @@ -182,6 +182,11 @@ export const streamUpload = async ( ...extra, ContentType: "application/javascript", } + } else if (filename?.endsWith(".svg")) { + extra = { + ...extra, + ContentType: "image", + } } const params = { diff --git a/packages/backend-core/src/pkg/context.ts b/packages/backend-core/src/pkg/context.ts index 5caa82ab0c..4915cc6e41 100644 --- a/packages/backend-core/src/pkg/context.ts +++ b/packages/backend-core/src/pkg/context.ts @@ -8,6 +8,7 @@ import { updateAppId, doInAppContext, doInTenant, + doInContext, } from "../context" import * as identity from "../context/identity" @@ -20,5 +21,6 @@ export = { updateAppId, doInAppContext, doInTenant, + doInContext, identity, } diff --git a/packages/backend-core/src/pkg/redis.ts b/packages/backend-core/src/pkg/redis.ts index 65ab186d9a..297c2b54f4 100644 --- a/packages/backend-core/src/pkg/redis.ts +++ b/packages/backend-core/src/pkg/redis.ts @@ -3,9 +3,11 @@ import Client from "../redis" import utils from "../redis/utils" import clients from "../redis/init" +import * as redlock from "../redis/redlock" export = { Client, utils, clients, + redlock, } diff --git a/packages/backend-core/src/plugin/utils.js b/packages/backend-core/src/plugin/utils.js index ade84bf44a..60a40f3a76 100644 --- a/packages/backend-core/src/plugin/utils.js +++ b/packages/backend-core/src/plugin/utils.js @@ -67,12 +67,8 @@ function validateDatasource(schema) { description: joi.string().required(), datasource: joi.object().pattern(joi.string(), fieldValidator).required(), query: joi - .object({ - create: queryValidator, - read: queryValidator, - update: queryValidator, - delete: queryValidator, - }) + .object() + .pattern(joi.string(), queryValidator) .unknown(true) .required(), extra: joi.object().pattern( diff --git a/packages/backend-core/src/redis/index.ts b/packages/backend-core/src/redis/index.ts index 206110366f..8a15320ff3 100644 --- a/packages/backend-core/src/redis/index.ts +++ b/packages/backend-core/src/redis/index.ts @@ -214,6 +214,34 @@ export = class RedisWrapper { } } + async bulkGet(keys: string[]) { + const db = this._db + if (keys.length === 0) { + return {} + } + const prefixedKeys = keys.map(key => addDbPrefix(db, key)) + let response = await this.getClient().mget(prefixedKeys) + if (Array.isArray(response)) { + let final: any = {} + let count = 0 + for (let result of response) { + if (result) { + let parsed + try { + parsed = JSON.parse(result) + } catch (err) { + parsed = result + } + final[keys[count]] = parsed + } + count++ + } + return final + } else { + throw new Error(`Invalid response: ${response}`) + } + } + async store(key: string, value: any, expirySeconds: number | null = null) { const db = this._db if (typeof value === "object") { diff --git a/packages/backend-core/src/redis/init.js b/packages/backend-core/src/redis/init.js index 8e5d10f838..3150ef2c1c 100644 --- a/packages/backend-core/src/redis/init.js +++ b/packages/backend-core/src/redis/init.js @@ -1,27 +1,23 @@ const Client = require("./index") const utils = require("./utils") -const { getRedlock } = require("./redlock") -let userClient, sessionClient, appClient, cacheClient, writethroughClient -let migrationsRedlock - -// turn retry off so that only one instance can ever hold the lock -const migrationsRedlockConfig = { retryCount: 0 } +let userClient, + sessionClient, + appClient, + cacheClient, + writethroughClient, + lockClient async function init() { userClient = await new Client(utils.Databases.USER_CACHE).init() sessionClient = await new Client(utils.Databases.SESSIONS).init() appClient = await new Client(utils.Databases.APP_METADATA).init() cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init() + lockClient = await new Client(utils.Databases.LOCKS).init() writethroughClient = await new Client( utils.Databases.WRITE_THROUGH, utils.SelectableDatabases.WRITE_THROUGH ).init() - // pass the underlying ioredis client to redlock - migrationsRedlock = getRedlock( - cacheClient.getClient(), - migrationsRedlockConfig - ) } process.on("exit", async () => { @@ -30,6 +26,7 @@ process.on("exit", async () => { if (appClient) await appClient.finish() if (cacheClient) await cacheClient.finish() if (writethroughClient) await writethroughClient.finish() + if (lockClient) await lockClient.finish() }) module.exports = { @@ -63,10 +60,10 @@ module.exports = { } return writethroughClient }, - getMigrationsRedlock: async () => { - if (!migrationsRedlock) { + getLockClient: async () => { + if (!lockClient) { await init() } - return migrationsRedlock + return lockClient }, } diff --git a/packages/backend-core/src/redis/redlock.ts b/packages/backend-core/src/redis/redlock.ts index beef375b55..abb13b2534 100644 --- a/packages/backend-core/src/redis/redlock.ts +++ b/packages/backend-core/src/redis/redlock.ts @@ -1,14 +1,37 @@ -import Redlock from "redlock" +import Redlock, { Options } from "redlock" +import { getLockClient } from "./init" +import { LockOptions, LockType } from "@budibase/types" +import * as tenancy from "../tenancy" -export const getRedlock = (redisClient: any, opts = { retryCount: 10 }) => { - return new Redlock([redisClient], { +let noRetryRedlock: Redlock | undefined + +const getClient = async (type: LockType): Promise => { + switch (type) { + case LockType.TRY_ONCE: { + if (!noRetryRedlock) { + noRetryRedlock = await newRedlock(OPTIONS.TRY_ONCE) + } + return noRetryRedlock + } + default: { + throw new Error(`Could not get redlock client: ${type}`) + } + } +} + +export const OPTIONS = { + TRY_ONCE: { + // immediately throws an error if the lock is already held + retryCount: 0, + }, + DEFAULT: { // the expected clock drift; for more details // see http://redis.io/topics/distlock driftFactor: 0.01, // multiplied by lock ttl to determine drift time // the max number of times Redlock will attempt // to lock a resource before erroring - retryCount: opts.retryCount, + retryCount: 10, // the time in ms between attempts retryDelay: 200, // time in ms @@ -16,6 +39,45 @@ export const getRedlock = (redisClient: any, opts = { retryCount: 10 }) => { // the max time in ms randomly added to retries // to improve performance under high contention // see https://www.awsarchitectureblog.com/2015/03/backoff.html - retryJitter: 200, // time in ms - }) + retryJitter: 100, // time in ms + }, +} + +export const newRedlock = async (opts: Options = {}) => { + let options = { ...OPTIONS.DEFAULT, ...opts } + const redisWrapper = await getLockClient() + const client = redisWrapper.getClient() + return new Redlock([client], options) +} + +export const doWithLock = async (opts: LockOptions, task: any) => { + const redlock = await getClient(opts.type) + let lock + try { + // aquire lock + let name: string = `${tenancy.getTenantId()}_${opts.name}` + if (opts.nameSuffix) { + name = name + `_${opts.nameSuffix}` + } + lock = await redlock.lock(name, opts.ttl) + // perform locked task + return task() + } catch (e: any) { + // lock limit exceeded + if (e.name === "LockError") { + if (opts.type === LockType.TRY_ONCE) { + // don't throw for try-once locks, they will always error + // due to retry count (0) exceeded + return + } else { + throw e + } + } else { + throw e + } + } finally { + if (lock) { + await lock.unlock() + } + } } diff --git a/packages/backend-core/src/redis/utils.js b/packages/backend-core/src/redis/utils.js index 90b3561f31..af719197b5 100644 --- a/packages/backend-core/src/redis/utils.js +++ b/packages/backend-core/src/redis/utils.js @@ -28,6 +28,7 @@ exports.Databases = { LICENSES: "license", GENERIC_CACHE: "data_cache", WRITE_THROUGH: "writeThrough", + LOCKS: "locks", } /** diff --git a/packages/backend-core/tests/utilities/structures/accounts.ts b/packages/backend-core/tests/utilities/structures/accounts.ts new file mode 100644 index 0000000000..5d23962575 --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/accounts.ts @@ -0,0 +1,23 @@ +import { generator, uuid } from "." +import { AuthType, CloudAccount, Hosting } from "@budibase/types" +import * as db from "../../../src/db/utils" + +export const cloudAccount = (): CloudAccount => { + return { + accountId: uuid(), + createdAt: Date.now(), + verified: true, + verificationSent: true, + tier: "", + email: generator.email(), + tenantId: generator.word(), + hosting: Hosting.CLOUD, + authType: AuthType.PASSWORD, + password: generator.word(), + tenantName: generator.word(), + name: generator.name(), + size: "10+", + profession: "Software Engineer", + budibaseUserId: db.generateGlobalUserID(), + } +} diff --git a/packages/backend-core/tests/utilities/structures/common.ts b/packages/backend-core/tests/utilities/structures/common.ts new file mode 100644 index 0000000000..51ae220254 --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/common.ts @@ -0,0 +1 @@ +export { v4 as uuid } from "uuid" diff --git a/packages/backend-core/tests/utilities/structures/index.ts b/packages/backend-core/tests/utilities/structures/index.ts index 12b6ab7ad6..68064b9715 100644 --- a/packages/backend-core/tests/utilities/structures/index.ts +++ b/packages/backend-core/tests/utilities/structures/index.ts @@ -1 +1,8 @@ +export * from "./common" + +import Chance from "chance" +export const generator = new Chance() + export * as koa from "./koa" +export * as accounts from "./accounts" +export * as licenses from "./licenses" diff --git a/packages/backend-core/tests/utilities/structures/licenses.ts b/packages/backend-core/tests/utilities/structures/licenses.ts new file mode 100644 index 0000000000..a541e91860 --- /dev/null +++ b/packages/backend-core/tests/utilities/structures/licenses.ts @@ -0,0 +1,18 @@ +import { AccountPlan, License, PlanType, Quotas } from "@budibase/types" + +const newPlan = (type: PlanType = PlanType.FREE): AccountPlan => { + return { + type, + } +} + +export const newLicense = (opts: { + quotas: Quotas + planType?: PlanType +}): License => { + return { + features: [], + quotas: opts.quotas, + plan: newPlan(opts.planType), + } +} diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index 2e62aea734..6bc9b63728 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -663,6 +663,11 @@ "@types/connect" "*" "@types/node" "*" +"@types/chance@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@types/chance/-/chance-1.1.3.tgz#d19fe9391288d60fdccd87632bfc9ab2b4523fea" + integrity sha512-X6c6ghhe4/sQh4XzcZWSFaTAUOda38GQHmq9BUanYkOE/EO7ZrkazwKmtsj3xzTjkLWmwULE++23g3d3CCWaWw== + "@types/connect@*": version "3.4.35" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" @@ -1555,6 +1560,11 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chance@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/chance/-/chance-1.1.3.tgz#414f08634ee479c7a316b569050ea20751b82dd3" + integrity sha512-XeJsdoVAzDb1WRPRuMBesRSiWpW1uNTo5Fd7mYxPJsAfgX71+jfuCOHOdbyBz2uAUZ8TwKcXgWk3DMedFfJkbg== + char-regex@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index b081f17943..7d1ac8bbf6 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "1.4.18-alpha.1", + "@budibase/string-templates": "2.0.30-alpha.7", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index cdaf00aded..d80ca98153 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -19,6 +19,7 @@ export let placeholderOption = null export let options = [] export let isOptionSelected = () => false + export let isOptionEnabled = () => true export let onSelectOption = () => {} export let getOptionLabel = option => option export let getOptionValue = option => option @@ -164,6 +165,7 @@ aria-selected="true" tabindex="0" on:click={() => onSelectOption(getOptionValue(option, idx))} + class:is-disabled={!isOptionEnabled(option)} > {#if getOptionIcon(option, idx)} @@ -256,4 +258,7 @@ .spectrum-Popover :global(.spectrum-Search .spectrum-Textfield-icon) { top: 9px; } + .spectrum-Menu-item.is-disabled { + pointer-events: none; + } diff --git a/packages/bbui/src/Form/Core/Select.svelte b/packages/bbui/src/Form/Core/Select.svelte index f549f58d0c..3e15b7f6ef 100644 --- a/packages/bbui/src/Form/Core/Select.svelte +++ b/packages/bbui/src/Form/Core/Select.svelte @@ -12,6 +12,7 @@ export let getOptionValue = option => option export let getOptionIcon = () => null export let getOptionColour = () => null + export let isOptionEnabled export let readonly = false export let quiet = false export let autoWidth = false @@ -66,6 +67,7 @@ {getOptionValue} {getOptionIcon} {getOptionColour} + {isOptionEnabled} {autocomplete} {sort} isPlaceholder={value == null || value === ""} diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index 1b68746c5e..69126e648d 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -15,6 +15,7 @@ export let getOptionValue = option => extractProperty(option, "value") export let getOptionIcon = option => option?.icon export let getOptionColour = option => option?.colour + export let isOptionEnabled export let quiet = false export let autoWidth = false export let sort = false @@ -49,6 +50,7 @@ {getOptionValue} {getOptionIcon} {getOptionColour} + {isOptionEnabled} on:change={onChange} on:click /> diff --git a/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js b/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js index 000ca7cb54..4844a0c670 100644 --- a/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js +++ b/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js @@ -20,7 +20,9 @@ filterTests(["smoke", "all"], () => { cy.get(".spectrum-Form-itemField").eq(3).should('contain', 'App User') // User should not have app access - cy.get(interact.LIST_ITEMS, { timeout: 500 }).should("contain", "No apps") + cy.get(".spectrum-Heading").contains("Apps").parent().within(() => { + cy.get(interact.LIST_ITEMS, { timeout: 500 }).should("contain", "This user has access to no apps") + }) }) if (Cypress.env("TEST_ENV")) { diff --git a/packages/builder/cypress/integration/autoScreensUI.spec.js b/packages/builder/cypress/integration/autoScreensUI.spec.js index 0253675c5b..581e5c431b 100644 --- a/packages/builder/cypress/integration/autoScreensUI.spec.js +++ b/packages/builder/cypress/integration/autoScreensUI.spec.js @@ -2,7 +2,7 @@ import filterTests from "../support/filterTests" const interact = require('../support/interact') filterTests(['smoke', 'all'], () => { - context("Auto Screens UI", () => { + xcontext("Auto Screens UI", () => { before(() => { cy.login() cy.deleteAllApps() @@ -54,6 +54,7 @@ filterTests(['smoke', 'all'], () => { cy.createDatasourceScreen([initialTable, secondTable]) // Confirm screens have been auto generated // Previously generated tables are suffixed with numbers - as expected + cy.wait(1000) cy.get(interact.BODY).should('contain', 'cypress-tests-2') .and('contain', 'cypress-tests-2/:id') .and('contain', 'cypress-tests-2/new/row') diff --git a/packages/builder/cypress/integration/datasources/postgreSql.spec.js b/packages/builder/cypress/integration/datasources/postgreSql.spec.js index 8ef574566e..e1aa0ff128 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -1,7 +1,7 @@ import filterTests from "../../support/filterTests" filterTests(["all"], () => { - context("PostgreSQL Datasource Testing", () => { + xcontext("PostgreSQL Datasource Testing", () => { if (Cypress.env("TEST_ENV")) { before(() => { cy.login() diff --git a/packages/builder/cypress/integration/datasources/rest.spec.js b/packages/builder/cypress/integration/datasources/rest.spec.js index ec9864a47d..7cfe1ce9bb 100644 --- a/packages/builder/cypress/integration/datasources/rest.spec.js +++ b/packages/builder/cypress/integration/datasources/rest.spec.js @@ -22,7 +22,7 @@ filterTests(["smoke", "all"], () => { cy.wait("@queryError") cy.get("@queryError") .its("response.body") - .should("have.property", "message", "Invalid URL: http://random text?") + .should("have.property", "message", "Invalid URL: http://random text") cy.get("@queryError") .its("response.body") .should("have.property", "status", 400) diff --git a/packages/builder/cypress/integration/queryLevelTransformers.spec.js b/packages/builder/cypress/integration/queryLevelTransformers.spec.js index 2b74e0c2e5..d16f8075f9 100644 --- a/packages/builder/cypress/integration/queryLevelTransformers.spec.js +++ b/packages/builder/cypress/integration/queryLevelTransformers.spec.js @@ -1,5 +1,5 @@ import filterTests from "../support/filterTests" -const interact = require('../support/interact') +const interact = require("../support/interact") filterTests(["smoke", "all"], () => { context("Query Level Transformers", () => { diff --git a/packages/builder/package.json b/packages/builder/package.json index 6cfbdb9710..912c64a1b6 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "license": "GPL-3.0", "private": true, "scripts": { @@ -71,10 +71,10 @@ } }, "dependencies": { - "@budibase/bbui": "1.4.18-alpha.1", - "@budibase/client": "1.4.18-alpha.1", - "@budibase/frontend-core": "1.4.18-alpha.1", - "@budibase/string-templates": "1.4.18-alpha.1", + "@budibase/bbui": "2.0.30-alpha.7", + "@budibase/client": "2.0.30-alpha.7", + "@budibase/frontend-core": "2.0.30-alpha.7", + "@budibase/string-templates": "2.0.30-alpha.7", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/src/builderStore/componentUtils.js b/packages/builder/src/builderStore/componentUtils.js index 2ad7e82075..54997df38f 100644 --- a/packages/builder/src/builderStore/componentUtils.js +++ b/packages/builder/src/builderStore/componentUtils.js @@ -143,7 +143,10 @@ export const getComponentSettings = componentType => { } // Ensure whole component name is used - if (!componentType.startsWith("@budibase")) { + if ( + !componentType.startsWith("plugin/") && + !componentType.startsWith("@budibase") + ) { componentType = `@budibase/standard-components/${componentType}` } @@ -182,43 +185,42 @@ export const makeComponentUnique = component => { // Replace component ID const oldId = component._id const newId = Helpers.uuid() - component._id = newId + let definition = JSON.stringify(component) - if (component._children?.length) { - let children = JSON.stringify(component._children) + // Replace all instances of this ID in HBS bindings + definition = definition.replace(new RegExp(oldId, "g"), newId) - // Replace all instances of this ID in child HBS bindings - children = children.replace(new RegExp(oldId, "g"), newId) + // Replace all instances of this ID in JS bindings + const bindings = findHBSBlocks(definition) + bindings.forEach(binding => { + // JSON.stringify will have escaped double quotes, so we need + // to account for that + let sanitizedBinding = binding.replace(/\\"/g, '"') - // Replace all instances of this ID in child JS bindings - const bindings = findHBSBlocks(children) - bindings.forEach(binding => { - // JSON.stringify will have escaped double quotes, so we need - // to account for that - let sanitizedBinding = binding.replace(/\\"/g, '"') + // Check if this is a valid JS binding + let js = decodeJSBinding(sanitizedBinding) + if (js != null) { + // Replace ID inside JS binding + js = js.replace(new RegExp(oldId, "g"), newId) - // Check if this is a valid JS binding - let js = decodeJSBinding(sanitizedBinding) - if (js != null) { - // Replace ID inside JS binding - js = js.replace(new RegExp(oldId, "g"), newId) + // Create new valid JS binding + let newBinding = encodeJSBinding(js) - // Create new valid JS binding - let newBinding = encodeJSBinding(js) + // Replace escaped double quotes + newBinding = newBinding.replace(/"/g, '\\"') - // Replace escaped double quotes - newBinding = newBinding.replace(/"/g, '\\"') + // Insert new JS back into binding. + // A single string replace here is better than a regex as + // the binding contains special characters, and we only need + // to replace a single instance. + definition = definition.replace(binding, newBinding) + } + }) - // Insert new JS back into binding. - // A single string replace here is better than a regex as - // the binding contains special characters, and we only need - // to replace a single instance. - children = children.replace(binding, newBinding) - } - }) - - // Recurse on all children - component._children = JSON.parse(children) - component._children.forEach(makeComponentUnique) + // Recurse on all children + component = JSON.parse(definition) + return { + ...component, + _children: component._children?.map(makeComponentUnique), } } diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index ca36380077..536692eecc 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -169,7 +169,12 @@ export const getComponentBindableProperties = (asset, componentId) => { /** * Gets all data provider components above a component. */ -export const getContextProviderComponents = (asset, componentId, type) => { +export const getContextProviderComponents = ( + asset, + componentId, + type, + options = { includeSelf: false } +) => { if (!asset || !componentId) { return [] } @@ -177,7 +182,9 @@ export const getContextProviderComponents = (asset, componentId, type) => { // Get the component tree leading up to this component, ignoring the component // itself const path = findComponentPath(asset.props, componentId) - path.pop() + if (!options?.includeSelf) { + path.pop() + } // Filter by only data provider components return path.filter(component => { @@ -243,18 +250,18 @@ export const getDatasourceForProvider = (asset, component) => { return null } - // There are different types of setting which can be a datasource, for - // example an actual datasource object, or a table ID string. - // Convert the datasource setting into a proper datasource object so that - // we can use it properly - if (datasourceSetting.type === "table") { + // For legacy compatibility, we need to be able to handle datasources that are + // just strings. These are not generated any more, so could be removed in + // future. + // TODO: remove at some point + const datasource = component[datasourceSetting?.key] + if (typeof datasource === "string") { return { - tableId: component[datasourceSetting?.key], + tableId: datasource, type: "table", } - } else { - return component[datasourceSetting?.key] } + return datasource } /** @@ -396,19 +403,17 @@ export const getUserBindings = () => { bindings = keys.reduce((acc, key) => { const fieldSchema = schema[key] - if (fieldSchema.type !== "link") { - acc.push({ - type: "context", - runtimeBinding: `${safeUser}.${makePropSafe(key)}`, - readableBinding: `Current User.${key}`, - // Field schema and provider are required to construct relationship - // datasource options, based on bindable properties - fieldSchema, - providerId: "user", - category: "Current User", - icon: "User", - }) - } + acc.push({ + type: "context", + runtimeBinding: `${safeUser}.${makePropSafe(key)}`, + readableBinding: `Current User.${key}`, + // Field schema and provider are required to construct relationship + // datasource options, based on bindable properties + fieldSchema, + providerId: "user", + category: "Current User", + icon: "User", + }) return acc }, []) @@ -800,6 +805,17 @@ export const buildFormSchema = component => { if (!component) { return schema } + + // If this is a form block, simply use the fields setting + if (component._component.endsWith("formblock")) { + let schema = {} + component.fields?.forEach(field => { + schema[field] = { type: "string" } + }) + return schema + } + + // Otherwise find all field component children const settings = getComponentSettings(component._component) const fieldSetting = settings.find( setting => setting.key === "field" && setting.type.startsWith("field/") diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 25a97e8f97..c8ebf258ca 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -88,27 +88,12 @@ export const getFrontendStore = () => { initialise: async pkg => { const { layouts, screens, application, clientLibPath } = pkg - // Fetch component definitions. - // Allow errors to propagate. - const components = await API.fetchComponentLibDefinitions( - application.appId - ) - - // Filter out custom component keys so we can flag them - const customComponents = Object.keys(components).filter(name => - name.startsWith("plugin/") - ) + await store.actions.components.refreshDefinitions(application.appId) // Reset store state store.update(state => ({ ...state, libraries: application.componentLibraries, - components, - customComponents, - clientFeatures: { - ...INITIAL_FRONTEND_STATE.clientFeatures, - ...components.features, - }, name: application.name, description: application.description, appId: application.appId, @@ -345,6 +330,16 @@ export const getFrontendStore = () => { return state }) }, + sendEvent: (name, payload) => { + const { previewEventHandler } = get(store) + previewEventHandler?.(name, payload) + }, + registerEventHandler: handler => { + store.update(state => { + state.previewEventHandler = handler + return state + }) + }, }, layouts: { select: layoutId => { @@ -385,6 +380,29 @@ export const getFrontendStore = () => { }, }, components: { + refreshDefinitions: async appId => { + if (!appId) { + appId = get(store).appId + } + + // Fetch definitions and filter out custom component definitions so we + // can flag them + const components = await API.fetchComponentLibDefinitions(appId) + const customComponents = Object.keys(components).filter(name => + name.startsWith("plugin/") + ) + + // Update store + store.update(state => ({ + ...state, + components, + customComponents, + clientFeatures: { + ...INITIAL_FRONTEND_STATE.clientFeatures, + ...components.features, + }, + })) + }, getDefinition: componentName => { if (!componentName) { return null @@ -437,12 +455,12 @@ export const getFrontendStore = () => { hover: {}, active: {}, }, - _instanceName: `New ${definition.name}`, + _instanceName: `New ${definition.friendlyName || definition.name}`, ...cloneDeep(props), ...extras, } }, - create: async (componentName, presetProps) => { + create: async (componentName, presetProps, parent, index) => { const state = get(store) const componentInstance = store.actions.components.createInstance( componentName, @@ -452,48 +470,62 @@ export const getFrontendStore = () => { return } - // Patch selected screen - await store.actions.screens.patch(screen => { - // Find the selected component - const currentComponent = findComponent( - screen.props, - state.selectedComponentId - ) - if (!currentComponent) { - return false - } - - // Find parent node to attach this component to - let parentComponent - if (currentComponent) { - // Use selected component as parent if one is selected - const definition = store.actions.components.getDefinition( - currentComponent._component - ) - if (definition?.hasChildren) { - // Use selected component if it allows children - parentComponent = currentComponent + // Insert in position if specified + if (parent && index != null) { + await store.actions.screens.patch(screen => { + let parentComponent = findComponent(screen.props, parent) + if (!parentComponent._children?.length) { + parentComponent._children = [componentInstance] } else { - // Otherwise we need to use the parent of this component - parentComponent = findComponentParent( - screen.props, - currentComponent._id - ) + parentComponent._children.splice(index, 0, componentInstance) } - } else { - // Use screen or layout if no component is selected - parentComponent = screen.props - } + }) + } - // Attach new component - if (!parentComponent) { - return false - } - if (!parentComponent._children) { - parentComponent._children = [] - } - parentComponent._children.push(componentInstance) - }) + // Otherwise we work out where this component should be inserted + else { + await store.actions.screens.patch(screen => { + // Find the selected component + const currentComponent = findComponent( + screen.props, + state.selectedComponentId + ) + if (!currentComponent) { + return false + } + + // Find parent node to attach this component to + let parentComponent + if (currentComponent) { + // Use selected component as parent if one is selected + const definition = store.actions.components.getDefinition( + currentComponent._component + ) + if (definition?.hasChildren) { + // Use selected component if it allows children + parentComponent = currentComponent + } else { + // Otherwise we need to use the parent of this component + parentComponent = findComponentParent( + screen.props, + currentComponent._id + ) + } + } else { + // Use screen or layout if no component is selected + parentComponent = screen.props + } + + // Attach new component + if (!parentComponent) { + return false + } + if (!parentComponent._children) { + parentComponent._children = [] + } + parentComponent._children.push(componentInstance) + }) + } // Select new component store.update(state => { @@ -612,7 +644,7 @@ export const getFrontendStore = () => { // Make new component unique if copying if (!cut) { - makeComponentUnique(componentToPaste) + componentToPaste = makeComponentUnique(componentToPaste) } newComponentId = componentToPaste._id @@ -900,6 +932,50 @@ export const getFrontendStore = () => { component[name] = value }) }, + requestEjectBlock: componentId => { + store.actions.preview.sendEvent("eject-block", componentId) + }, + handleEjectBlock: async (componentId, ejectedDefinition) => { + let nextSelectedComponentId + + await store.actions.screens.patch(screen => { + const block = findComponent(screen.props, componentId) + const parent = findComponentParent(screen.props, componentId) + + // Sanity check + if (!block || !parent?._children?.length) { + return false + } + + // Attach block children back into ejected definition, using the + // _containsSlot flag to know where to insert them + const slotContainer = findAllMatchingComponents( + ejectedDefinition, + x => x._containsSlot + )[0] + if (slotContainer) { + delete slotContainer._containsSlot + slotContainer._children = [ + ...(slotContainer._children || []), + ...(block._children || []), + ] + } + + // Replace block with ejected definition + ejectedDefinition = makeComponentUnique(ejectedDefinition) + const index = parent._children.findIndex(x => x._id === componentId) + parent._children[index] = ejectedDefinition + nextSelectedComponentId = ejectedDefinition._id + }) + + // Select new root component + if (nextSelectedComponentId) { + store.update(state => { + state.selectedComponentId = nextSelectedComponentId + return state + }) + } + }, }, links: { save: async (url, title) => { @@ -945,6 +1021,19 @@ export const getFrontendStore = () => { })) }, }, + dnd: { + start: component => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: true, + component, + }) + }, + stop: () => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: false, + }) + }, + }, } return store diff --git a/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js b/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js index dd97c511e5..6564bf6050 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js @@ -1,13 +1,8 @@ import sanitizeUrl from "./utils/sanitizeUrl" import { Screen } from "./utils/Screen" import { Component } from "./utils/Component" -import { - makeBreadcrumbContainer, - makeMainForm, - makeTitleContainer, - makeSaveButton, - makeDatasourceFormComponents, -} from "./utils/commonComponents" +import { makeBreadcrumbContainer } from "./utils/commonComponents" +import { getSchemaForDatasource } from "../../dataBinding" export default function (tables) { return tables.map(table => { @@ -23,48 +18,55 @@ export default function (tables) { export const newRowUrl = table => sanitizeUrl(`/${table.name}/new/row`) export const NEW_ROW_TEMPLATE = "NEW_ROW_TEMPLATE" -function generateTitleContainer(table, formId) { - return makeTitleContainer("New Row").addChild(makeSaveButton(table, formId)) +const rowListUrl = table => sanitizeUrl(`/${table.name}`) + +const getFields = schema => { + let columns = [] + Object.entries(schema || {}).forEach(([field, fieldSchema]) => { + if (!field || !fieldSchema) { + return + } + if (!fieldSchema?.autocolumn) { + columns.push(field) + } + }) + return columns } -const createScreen = table => { - const screen = new Screen() - .instanceName(`${table.name} - New`) - .customProps({ - hAlign: "center", - }) - .route(newRowUrl(table)) - - const form = makeMainForm() - .instanceName("Form") +const generateFormBlock = table => { + const datasource = { type: "table", tableId: table._id } + const { schema } = getSchemaForDatasource(null, datasource, { + formSchema: true, + }) + const formBlock = new Component("@budibase/standard-components/formblock") + formBlock .customProps({ + title: "New row", actionType: "Create", + actionUrl: rowListUrl(table), + showDeleteButton: false, + showSaveButton: true, + fields: getFields(schema), dataSource: { label: table.name, tableId: table._id, type: "table", }, + labelPosition: "left", size: "spectrum--medium", }) - - const fieldGroup = new Component("@budibase/standard-components/fieldgroup") - .instanceName("Field Group") - .customProps({ - labelPosition: "left", - }) - - // Add all form fields from this schema to the field group - const datasource = { type: "table", tableId: table._id } - makeDatasourceFormComponents(datasource).forEach(component => { - fieldGroup.addChild(component) - }) - - // Add all children to the form - const formId = form._json._id - form - .addChild(makeBreadcrumbContainer(table.name, "New")) - .addChild(generateTitleContainer(table, formId)) - .addChild(fieldGroup) - - return screen.addChild(form).json() + .instanceName(`${table.name} - Form block`) + return formBlock +} + +const createScreen = table => { + const formBlock = generateFormBlock(table) + const screen = new Screen() + .instanceName(`${table.name} - New`) + .route(newRowUrl(table)) + + return screen + .addChild(makeBreadcrumbContainer(table.name, "New row")) + .addChild(formBlock) + .json() } diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js index a1916769c9..22b39aba3e 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js @@ -1,15 +1,8 @@ import sanitizeUrl from "./utils/sanitizeUrl" -import { rowListUrl } from "./rowListScreen" import { Screen } from "./utils/Screen" import { Component } from "./utils/Component" -import { makePropSafe } from "@budibase/string-templates" -import { - makeBreadcrumbContainer, - makeTitleContainer, - makeSaveButton, - makeMainForm, - makeDatasourceFormComponents, -} from "./utils/commonComponents" +import { makeBreadcrumbContainer } from "./utils/commonComponents" +import { getSchemaForDatasource } from "../../dataBinding" export default function (tables) { return tables.map(table => { @@ -25,125 +18,53 @@ export default function (tables) { export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE" export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`) -function generateTitleContainer(table, title, formId, repeaterId) { - const saveButton = makeSaveButton(table, formId) - const deleteButton = new Component("@budibase/standard-components/button") - .text("Delete") - .customProps({ - type: "secondary", - quiet: true, - size: "M", - onClick: [ - { - parameters: { - tableId: table._id, - rowId: `{{ ${makePropSafe(repeaterId)}.${makePropSafe("_id")} }}`, - revId: `{{ ${makePropSafe(repeaterId)}.${makePropSafe("_rev")} }}`, - confirm: true, - }, - "##eventHandlerType": "Delete Row", - }, - { - parameters: { - url: rowListUrl(table), - }, - "##eventHandlerType": "Navigate To", - }, - ], - }) - .instanceName("Delete Button") +const rowListUrl = table => sanitizeUrl(`/${table.name}`) - const buttons = new Component("@budibase/standard-components/container") - .instanceName("Button Container") - .customProps({ - direction: "row", - hAlign: "right", - vAlign: "middle", - size: "shrink", - gap: "M", - }) - .addChild(deleteButton) - .addChild(saveButton) +const getFields = schema => { + let columns = [] + Object.entries(schema || {}).forEach(([field, fieldSchema]) => { + if (!field || !fieldSchema) { + return + } + if (!fieldSchema?.autocolumn) { + columns.push(field) + } + }) + return columns +} - return makeTitleContainer(title).addChild(buttons) +const generateFormBlock = table => { + const datasource = { type: "table", tableId: table._id } + const { schema } = getSchemaForDatasource(null, datasource, { + formSchema: true, + }) + + const formBlock = new Component("@budibase/standard-components/formblock") + formBlock + .customProps({ + title: "Edit row", + actionType: "Update", + actionUrl: rowListUrl(table), + showDeleteButton: true, + showSaveButton: true, + fields: getFields(schema), + dataSource: { + label: table.name, + tableId: table._id, + type: "table", + }, + labelPosition: "left", + size: "spectrum--medium", + }) + .instanceName(`${table.name} - Form block`) + return formBlock } const createScreen = table => { - const provider = new Component("@budibase/standard-components/dataprovider") - .instanceName(`Data Provider`) - .customProps({ - dataSource: { - label: table.name, - name: table._id, - tableId: table._id, - type: "table", - }, - filter: [ - { - field: "_id", - operator: "equal", - type: "string", - value: `{{ ${makePropSafe("url")}.${makePropSafe("id")} }}`, - valueType: "Binding", - }, - ], - limit: 1, - paginate: false, - }) - - const repeater = new Component("@budibase/standard-components/repeater") - .instanceName("Repeater") - .customProps({ - dataProvider: `{{ literal ${makePropSafe(provider._json._id)} }}`, - noRowsMessage: "We couldn't find a row to display", - }) - - const form = makeMainForm() - .instanceName("Form") - .customProps({ - actionType: "Update", - size: "spectrum--medium", - dataSource: { - label: table.name, - tableId: table._id, - type: "table", - }, - }) - - const fieldGroup = new Component("@budibase/standard-components/fieldgroup") - .instanceName("Field Group") - .customProps({ - labelPosition: "left", - }) - - // Add all form fields from this schema to the field group - const datasource = { type: "table", tableId: table._id } - makeDatasourceFormComponents(datasource).forEach(component => { - fieldGroup.addChild(component) - }) - - // Add all children to the form - const formId = form._json._id - const repeaterId = repeater._json._id - const heading = table.primaryDisplay - ? `{{ ${makePropSafe(repeaterId)}.${makePropSafe(table.primaryDisplay)} }}` - : null - form - .addChild(makeBreadcrumbContainer(table.name, heading || "Edit")) - .addChild( - generateTitleContainer(table, heading || "Edit Row", formId, repeaterId) - ) - .addChild(fieldGroup) - - repeater.addChild(form) - provider.addChild(repeater) - return new Screen() .instanceName(`${table.name} - Detail`) .route(rowDetailUrl(table)) - .customProps({ - hAlign: "center", - }) - .addChild(provider) + .addChild(makeBreadcrumbContainer(table.name, "Edit row")) + .addChild(generateFormBlock(table)) .json() } diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js index 39e88ae69e..b04d588ded 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js @@ -2,7 +2,6 @@ import sanitizeUrl from "./utils/sanitizeUrl" import { newRowUrl } from "./newRowScreen" import { Screen } from "./utils/Screen" import { Component } from "./utils/Component" -import { makePropSafe } from "@budibase/string-templates" export default function (tables) { return tables.map(table => { @@ -18,48 +17,17 @@ export default function (tables) { export const ROW_LIST_TEMPLATE = "ROW_LIST_TEMPLATE" export const rowListUrl = table => sanitizeUrl(`/${table.name}`) -function generateTitleContainer(table) { - const newButton = new Component("@budibase/standard-components/button") - .text("Create New") - .customProps({ - size: "M", - type: "primary", - onClick: [ - { - parameters: { - url: newRowUrl(table), - }, - "##eventHandlerType": "Navigate To", - }, - ], - }) - .instanceName("New Button") - - const heading = new Component("@budibase/standard-components/heading") - .instanceName("Title") - .text(table.name) - .customProps({ - size: "M", - align: "left", - }) - - return new Component("@budibase/standard-components/container") - .customProps({ - direction: "row", - hAlign: "stretch", - vAlign: "middle", - size: "shrink", - gap: "M", - }) - .instanceName("Title Container") - .addChild(heading) - .addChild(newButton) -} - -const createScreen = table => { - const provider = new Component("@budibase/standard-components/dataprovider") - .instanceName(`Data Provider`) +const generateTableBlock = table => { + const tableBlock = new Component("@budibase/standard-components/tableblock") + tableBlock .customProps({ + linkRows: true, + linkURL: `${rowListUrl(table)}/:id`, + showAutoColumns: false, + showTitleButton: true, + titleButtonText: "Create new", + titleButtonURL: newRowUrl(table), + title: table.name, dataSource: { label: table.name, name: table._id, @@ -68,41 +36,16 @@ const createScreen = table => { }, size: "spectrum--medium", paginate: true, - limit: 8, - }) - - const spectrumTable = new Component("@budibase/standard-components/table") - .customProps({ - dataProvider: `{{ literal ${makePropSafe(provider._json._id)} }}`, - showAutoColumns: false, - quiet: false, rowCount: 8, }) - .instanceName(`${table.name} Table`) - - const safeTableId = makePropSafe(spectrumTable._json._id) - const safeRowId = makePropSafe("_id") - const viewLink = new Component("@budibase/standard-components/link") - .customProps({ - text: "View", - url: `${rowListUrl(table)}/{{ ${safeTableId}.${safeRowId} }}`, - size: "S", - color: "var(--spectrum-global-color-gray-600)", - align: "left", - }) - .normalStyle({ - ["margin-left"]: "16px", - ["margin-right"]: "16px", - }) - .instanceName("View Link") - - spectrumTable.addChild(viewLink) - provider.addChild(spectrumTable) + .instanceName(`${table.name} - Table block`) + return tableBlock +} +const createScreen = table => { return new Screen() .route(rowListUrl(table)) .instanceName(`${table.name} - List`) - .addChild(generateTitleContainer(table)) - .addChild(provider) + .addChild(generateTableBlock(table)) .json() } diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js index 9176d535ab..f00cd9c215 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js @@ -65,6 +65,11 @@ export function makeBreadcrumbContainer(tableName, text) { vAlign: "middle", size: "shrink", }) + .normalStyle({ + width: "600px", + "margin-right": "auto", + "margin-left": "auto", + }) .instanceName("Breadcrumbs") .addChild(link) .addChild(arrowText) @@ -138,6 +143,7 @@ const fieldTypeToComponentMap = { attachment: "attachmentfield", link: "relationshipfield", json: "jsonfield", + barcodeqr: "codescanner", } export function makeDatasourceFormComponents(datasource) { diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 21059b32dd..b7249ad60c 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -261,6 +261,7 @@ } else { return [ FIELDS.STRING, + FIELDS.BARCODEQR, FIELDS.LONGFORM, FIELDS.OPTIONS, FIELDS.DATETIME, @@ -314,7 +315,7 @@ const relatedTable = $tables.list.find( tbl => tbl._id === fieldInfo.tableId ) - if (inUse(relatedTable, fieldInfo.fieldName)) { + if (inUse(relatedTable, fieldInfo.fieldName) && !originalName) { newError.relatedName = `Column name already in use in table ${relatedTable.name}` } } diff --git a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte b/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte index e2ccab11af..600e331d3e 100644 --- a/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/EditRoles.svelte @@ -17,12 +17,21 @@ $: selectedRoleId = selectedRole._id $: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId) $: isCreating = selectedRoleId == null || selectedRoleId === "" + + $: hasUniqueRoleName = !otherRoles + ?.map(role => role.name) + ?.includes(selectedRole.name) + $: valid = selectedRole.name && selectedRole.inherits && selectedRole.permissionId && !builtInRoles.includes(selectedRole.name) + $: shouldDisableRoleInput = + builtInRoles.includes(selectedRole.name) && + selectedRole.name?.toLowerCase() === selectedRoleId?.toLowerCase() + const fetchBasePermissions = async () => { try { basePermissions = await API.getBasePermissions() @@ -99,7 +108,7 @@ title="Edit Roles" confirmText={isCreating ? "Create" : "Save"} onConfirm={saveRole} - disabled={!valid} + disabled={!valid || !hasUniqueRoleName} > {#if errors.length} @@ -119,15 +128,16 @@ x._id} getOptionLabel={x => x.name} - disabled={builtInRoles.includes(selectedRole.name)} + disabled={shouldDisableRoleInput} /> {/if}
- {#if !isCreating} + {#if !isCreating && !builtInRoles.includes(selectedRole.name)} {/if}
diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte index 19946a2386..a3531513fb 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte @@ -13,7 +13,7 @@ customQueryIconColor, customQueryText, } from "helpers/data/utils" - import { getIcon } from "./icons" + import IntegrationIcon from "./IntegrationIcon.svelte" import { notifications } from "@budibase/bbui" let openDataSources = [] @@ -123,10 +123,10 @@ on:iconClick={() => toggleNode(datasource)} >
-
{#if datasource._id !== BUDIBASE_INTERNAL_DB} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte b/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte new file mode 100644 index 0000000000..e6cfbf7db8 --- /dev/null +++ b/packages/builder/src/components/backend/DatasourceNavigator/IntegrationIcon.svelte @@ -0,0 +1,32 @@ + + +{#if iconInfo.icon} + +{:else if iconInfo.url} + {#await getSvgFromUrl(iconInfo) then retrievedSvg} + + {/await} +{/if} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte index cef49d81a1..ff413094a0 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte @@ -209,27 +209,29 @@ {:else} No tables found. {/if} - -
- Relationships - -
- - Tell budibase how your tables are related to get even more smart features. - -{#if relationshipInfo && relationshipInfo.length > 0} - openRelationshipModal(detail.from, detail.to)} - schema={relationshipSchema} - data={relationshipInfo} - allowEditColumns={false} - allowEditRows={false} - allowSelectRows={false} - /> -{:else} - No relationships configured. +{#if integration.relationships !== false} + +
+ Relationships + +
+ + Tell budibase how your tables are related to get even more smart features. + + {#if relationshipInfo && relationshipInfo.length > 0} +
openRelationshipModal(detail.from, detail.to)} + schema={relationshipSchema} + data={relationshipInfo} + allowEditColumns={false} + allowEditRows={false} + allowSelectRows={false} + /> + {:else} + No relationships configured. + {/if} {/if} diff --git a/packages/builder/src/components/design/settings/controls/TableSelect.svelte b/packages/builder/src/components/design/settings/controls/TableSelect.svelte index b41098da2d..384bbe1e3a 100644 --- a/packages/builder/src/components/design/settings/controls/TableSelect.svelte +++ b/packages/builder/src/components/design/settings/controls/TableSelect.svelte @@ -1,26 +1,28 @@ -
- -
- - + s.key)} + on:change={({ detail }) => { + setEditorTemplate(step.key, detail, index) + }} + /> + { + query.fields.steps[index].value = detail + }} + /> + + + + + +
+ {#if index === query.fields.steps.length - 1} + { + query.fields.steps = [ + ...query.fields.steps, + { + key: "$match", + value: "{\n\t\n}", + }, + ] + }} + /> +
+ {/if} +
+ {/each} + {/if} {/if} {/key} {/if} @@ -67,4 +223,57 @@ grid-gap: var(--spacing-l); align-items: center; } + .blockSection { + padding: var(--spacing-xl); + } + .block { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + margin-top: -6px; + } + .subblock { + width: 480px; + font-size: 16px; + background-color: var(--background); + border: 1px solid var(--spectrum-global-color-gray-300); + border-radius: 4px 4px 4px 4px; + } + .block-options { + justify-content: space-between; + display: flex; + align-items: center; + padding-bottom: 24px; + } + .block-actions { + justify-content: space-between; + display: flex; + align-items: right; + } + + .fields { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + gap: var(--spacing-s); + } + .block-field { + display: grid; + grid-gap: 5px; + } + .separator { + width: 1px; + height: 25px; + border-left: 1px dashed var(--grey-4); + color: var(--grey-4); + /* center horizontally */ + align-self: center; + } + .controls { + display: flex; + align-items: center; + justify-content: right; + } diff --git a/packages/builder/src/components/portal/overview/automation/HistoryTab.svelte b/packages/builder/src/components/portal/overview/automation/HistoryTab.svelte index c676e00d2d..bd32e423c9 100644 --- a/packages/builder/src/components/portal/overview/automation/HistoryTab.svelte +++ b/packages/builder/src/components/portal/overview/automation/HistoryTab.svelte @@ -1,5 +1,5 @@ {#each sections as section, idx (section.name)} - - {#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen} - updateSetting("_instanceName", val)} - /> - {/if} - {#each section.settings as setting (setting.key)} - {#if canRenderControl(setting, isScreen)} + {#if section.visible} + + {#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen} updateSetting(setting.key, val)} - highlighted={$store.highlightedSettingKey === setting.key} - props={{ - // Generic settings - placeholder: setting.placeholder || null, - - // Select settings - options: setting.options || [], - - // Number fields - min: setting.min || null, - max: setting.max || null, - }} - {bindings} - {componentBindings} - {componentInstance} - {componentDefinition} + control={Input} + label="Name" + key="_instanceName" + value={componentInstance._instanceName} + onChange={val => updateSetting("_instanceName", val)} /> {/if} - {/each} - {#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")} - - {/if} - {#if section?.info} -
- {@html section.info} -
- {/if} -
-{/each} + {#each section.settings as setting (setting.key)} + {#if setting.visible} + updateSetting(setting.key, val)} + highlighted={$store.highlightedSettingKey === setting.key} + info={setting.info} + props={{ + // Generic settings + placeholder: setting.placeholder || null, - + // Select settings + options: setting.options || [], + + // Number fields + min: setting.min || null, + max: setting.max || null, + }} + {bindings} + {componentBindings} + {componentInstance} + {componentDefinition} + /> + {/if} + {/each} + {#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")} + + {/if} + {#if idx === 0 && componentDefinition?.block} + + {/if} +
+ {/if} +{/each} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte index 530ef44452..e6cbbf71fe 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/_layout.svelte @@ -7,6 +7,18 @@ import ComponentListPanel from "./_components/navigation/ComponentListPanel.svelte" import ComponentSettingsPanel from "./_components/settings/ComponentSettingsPanel.svelte" + const cleanUrl = url => { + // Strip trailing slashes + if (url?.endsWith("/index")) { + url = url.replace("/index", "") + } + // Hide new component panel whenever component ID changes + if (url?.endsWith("/new")) { + url = url.replace("/new", "") + } + return { url } + } + // Keep URL and state in sync for selected component ID const stopSyncing = syncURLToState({ urlParam: "componentId", @@ -15,6 +27,7 @@ fallbackUrl: "../", store, routify, + beforeNavigate: cleanUrl, }) onDestroy(stopSyncing) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte index 8cef10fb26..778a14ffff 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/NewComponentPanel.svelte @@ -169,6 +169,14 @@ window.removeEventListener("keydown", handleKeyDown) } }) + + const onDragStart = component => { + store.actions.dnd.start(component) + } + + const onDragEnd = () => { + store.actions.dnd.stop() + }
@@ -206,6 +214,9 @@
{category.name}
{#each category.children as component}
onDragStart(component.component)} + on:dragend={onDragEnd} data-cy={`component-${component.name}`} class="component" class:selected={selectedIndex === @@ -229,8 +240,11 @@ {#each blocks as block}
addComponent(block.component)} + on:dragstart={() => onDragStart(block.component)} + on:dragend={onDragEnd} > {block.name} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json index 406d027def..5dc607f7c7 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/components/[componentId]/new/_components/componentStructure.json @@ -5,7 +5,8 @@ "children": [ "tableblock", "cardsblock", - "repeaterblock" + "repeaterblock", + "formblock" ] }, { @@ -66,7 +67,8 @@ "relationshipfield", "datetimefield", "multifieldselect", - "s3upload" + "s3upload", + "codescanner" ] }, { diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenDropdownMenu.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenDropdownMenu.svelte index 0c35fa391e..ec965ed659 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenDropdownMenu.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/screens/_components/ScreenDropdownMenu.svelte @@ -38,7 +38,7 @@ let duplicateScreen = Helpers.cloneDeep(screen) delete duplicateScreen._id delete duplicateScreen._rev - makeComponentUnique(duplicateScreen.props) + duplicateScreen.props = makeComponentUnique(duplicateScreen.props) // Attach the new name and URL duplicateScreen.routing.route = sanitizeUrl(screenUrl) diff --git a/packages/builder/src/pages/builder/apps/index.svelte b/packages/builder/src/pages/builder/apps/index.svelte index ac907ad556..d175edabad 100644 --- a/packages/builder/src/pages/builder/apps/index.svelte +++ b/packages/builder/src/pages/builder/apps/index.svelte @@ -133,7 +133,7 @@ - {#if $licensing.usageMetrics.dayPasses >= 100} + {#if $licensing.usageMetrics?.dayPasses >= 100}
spaceman diff --git a/packages/builder/src/pages/builder/portal/_layout.svelte b/packages/builder/src/pages/builder/portal/_layout.svelte index 7a5d289ddd..cf9cd55b19 100644 --- a/packages/builder/src/pages/builder/portal/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/_layout.svelte @@ -56,7 +56,7 @@ { title: "Plugins", href: "/builder/portal/manage/plugins", - badge: "Beta", + badge: "New", }, { diff --git a/packages/builder/src/pages/builder/portal/manage/groups/[groupId].svelte b/packages/builder/src/pages/builder/portal/manage/groups/[groupId].svelte index 23cdbff877..5e8fb0d170 100644 --- a/packages/builder/src/pages/builder/portal/manage/groups/[groupId].svelte +++ b/packages/builder/src/pages/builder/portal/manage/groups/[groupId].svelte @@ -25,6 +25,7 @@ import ConfirmDialog from "components/common/ConfirmDialog.svelte" import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte" import GroupIcon from "./_components/GroupIcon.svelte" + import AppAddModal from "./_components/AppAddModal.svelte" export let groupId @@ -34,15 +35,14 @@ let prevSearch = undefined let pageInfo = createPaginationStore() let loaded = false - let editModal - let deleteModal + let editModal, deleteModal, appAddModal $: page = $pageInfo.page $: fetchUsers(page, searchTerm) $: group = $groups.find(x => x._id === groupId) $: filtered = $users.data $: groupApps = $apps.filter(app => - groups.actions.getGroupAppIds(group).includes(apps.getProdAppID(app.appId)) + groups.actions.getGroupAppIds(group).includes(apps.getProdAppID(app.devId)) ) $: { if (loaded && !group?._id) { @@ -182,7 +182,14 @@ - Apps +
+ Apps +
+ +
+
{#if groupApps.length} {#each groupApps as app} @@ -197,12 +204,24 @@ - {getRoleLabel(app.appId)} + {getRoleLabel(app.devId)}
+ { + groups.actions.removeApp( + groupId, + apps.getProdAppID(app.devId) + ) + e.stopPropagation() + }} + hoverable + size="S" + name="Close" + /> {/each} {:else} @@ -216,6 +235,11 @@ + + + + + + import { Body, ModalContent, Select } from "@budibase/bbui" + import { apps, groups } from "stores/portal" + import { roles } from "stores/backend" + import RoleSelect from "components/common/RoleSelect.svelte" + + export let group + + $: appOptions = $apps.map(app => ({ + label: app.name, + value: app, + })) + $: confirmDisabled = + (!selectingRole && !selectedApp) || (selectingRole && !selectedRoleId) + let selectedApp, selectedRoleId + let selectingRole = false + + async function appSelected() { + const prodAppId = apps.getProdAppID(selectedApp.devId) + if (!selectingRole) { + selectingRole = true + await roles.fetchByAppId(prodAppId) + // return false to stop closing modal + return false + } else { + await groups.actions.addApp(group._id, prodAppId, selectedRoleId) + } + } + + + (selectingRole = false)} + disabled={confirmDisabled} +> + {#if !selectingRole} + Select an app to assign roles for members of "{group.name}" +
$goto(`./${detail._id}`)} - {schema} - data={filteredGroups} - allowEditColumns={false} - allowEditRows={false} - {customRenderers} - /> + {#if $licensing.groupsEnabled} +
$goto(`./${detail._id}`)} + {schema} + data={filteredGroups} + allowEditColumns={false} + allowEditRows={false} + {customRenderers} + /> + {/if} @@ -176,8 +178,11 @@ .controls-right :global(.spectrum-Search) { width: 200px; } - .tag { - margin-top: var(--spacing-xs); - margin-left: var(--spacing-m); + .title { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: var(--spacing-m); } diff --git a/packages/builder/src/pages/builder/portal/manage/plugins/index.svelte b/packages/builder/src/pages/builder/portal/manage/plugins/index.svelte index b1f2480c28..84722c27be 100644 --- a/packages/builder/src/pages/builder/portal/manage/plugins/index.svelte +++ b/packages/builder/src/pages/builder/portal/manage/plugins/index.svelte @@ -55,18 +55,20 @@ Add plugin -
-
- +
+
- - + {/if} {#if filteredPlugins?.length} diff --git a/packages/builder/src/pages/builder/portal/overview/_components/AccessTab.svelte b/packages/builder/src/pages/builder/portal/overview/_components/AccessTab.svelte index a4046513fc..71a86f2fca 100644 --- a/packages/builder/src/pages/builder/portal/overview/_components/AccessTab.svelte +++ b/packages/builder/src/pages/builder/portal/overview/_components/AccessTab.svelte @@ -156,8 +156,8 @@ page={$usersFetch.pageNumber + 1} hasPrevPage={$usersFetch.hasPrevPage} hasNextPage={$usersFetch.hasNextPage} - goToPrevPage={$usersFetch.loading ? null : fetch.prevPage} - goToNextPage={$usersFetch.loading ? null : fetch.nextPage} + goToPrevPage={$usersFetch.loading ? null : usersFetch.prevPage} + goToNextPage={$usersFetch.loading ? null : usersFetch.nextPage} /> diff --git a/packages/builder/src/pages/builder/portal/settings/usage.svelte b/packages/builder/src/pages/builder/portal/settings/usage.svelte index 75ceccc0a3..7609a4742e 100644 --- a/packages/builder/src/pages/builder/portal/settings/usage.svelte +++ b/packages/builder/src/pages/builder/portal/settings/usage.svelte @@ -11,7 +11,7 @@ } from "@budibase/bbui" import { onMount } from "svelte" import { admin, auth, licensing } from "../../../../stores/portal" - import { PlanType } from "../../../../constants" + import { Constants } from "@budibase/frontend-core" import { DashCard, Usage } from "../../../../components/usage" let staticUsage = [] @@ -125,7 +125,7 @@ } const goToAccountPortal = () => { - if (license?.plan.type === PlanType.FREE) { + if (license?.plan.type === Constants.PlanType.FREE) { window.location.href = upgradeUrl } else { window.location.href = manageUrl @@ -133,7 +133,7 @@ } const setPrimaryActionText = () => { - if (license?.plan.type === PlanType.FREE) { + if (license?.plan.type === Constants.PlanType.FREE) { primaryActionText = "Upgrade" return } diff --git a/packages/builder/src/stores/backend/roles.js b/packages/builder/src/stores/backend/roles.js index 4d9ab303c9..ac395aa232 100644 --- a/packages/builder/src/stores/backend/roles.js +++ b/packages/builder/src/stores/backend/roles.js @@ -5,16 +5,24 @@ import { RoleUtils } from "@budibase/frontend-core" export function createRolesStore() { const { subscribe, update, set } = writable([]) + function setRoles(roles) { + set( + roles.sort((a, b) => { + const priorityA = RoleUtils.getRolePriority(a._id) + const priorityB = RoleUtils.getRolePriority(b._id) + return priorityA > priorityB ? -1 : 1 + }) + ) + } + const actions = { fetch: async () => { const roles = await API.getRoles() - set( - roles.sort((a, b) => { - const priorityA = RoleUtils.getRolePriority(a._id) - const priorityB = RoleUtils.getRolePriority(b._id) - return priorityA > priorityB ? -1 : 1 - }) - ) + setRoles(roles) + }, + fetchByAppId: async appId => { + const { roles } = await API.getRolesForApp(appId) + setRoles(roles) }, delete: async role => { await API.deleteRole({ diff --git a/packages/builder/src/stores/portal/apps.js b/packages/builder/src/stores/portal/apps.js index 41fdc232b7..a83e35e941 100644 --- a/packages/builder/src/stores/portal/apps.js +++ b/packages/builder/src/stores/portal/apps.js @@ -21,6 +21,8 @@ const getProdAppID = appId => { } else if (!appId.startsWith("app")) { rest = appId separator = "_" + } else { + return appId } return `app${separator}${rest}` } diff --git a/packages/cli/package.json b/packages/cli/package.json index fe5878e73e..af539df866 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/cli", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "description": "Budibase CLI, for developers, self hosting and migrations.", "main": "src/index.js", "bin": { @@ -26,9 +26,9 @@ "outputPath": "build" }, "dependencies": { - "@budibase/backend-core": "1.4.18-alpha.1", - "@budibase/string-templates": "1.4.18-alpha.1", - "@budibase/types": "1.4.18-alpha.1", + "@budibase/backend-core": "2.0.30-alpha.7", + "@budibase/string-templates": "2.0.30-alpha.7", + "@budibase/types": "2.0.30-alpha.7", "axios": "0.21.2", "chalk": "4.1.0", "cli-progress": "3.11.2", @@ -36,6 +36,7 @@ "docker-compose": "0.23.6", "dotenv": "16.0.1", "download": "8.0.0", + "find-free-port": "^2.0.0", "inquirer": "8.0.0", "joi": "17.6.0", "lookpath": "1.1.0", @@ -45,7 +46,8 @@ "pouchdb": "7.3.0", "pouchdb-replication-stream": "1.2.9", "randomstring": "1.1.5", - "tar": "6.1.11" + "tar": "6.1.11", + "yaml": "^2.1.1" }, "devDependencies": { "copyfiles": "^2.4.1", diff --git a/packages/cli/src/constants.js b/packages/cli/src/constants.js index aa49523d4e..6b0265ffd2 100644 --- a/packages/cli/src/constants.js +++ b/packages/cli/src/constants.js @@ -21,3 +21,5 @@ exports.AnalyticsEvents = { } exports.POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS" + +exports.GENERATED_USER_EMAIL = "admin@admin.com" diff --git a/packages/cli/src/exec.js b/packages/cli/src/exec.js index 72fd8e00eb..4df486aed6 100644 --- a/packages/cli/src/exec.js +++ b/packages/cli/src/exec.js @@ -22,6 +22,6 @@ exports.runPkgCommand = async (command, dir = "./") => { throw new Error("Must have yarn or npm installed to run build.") } const npmCmd = command === "install" ? `npm ${command}` : `npm run ${command}` - const cmd = yarn ? `yarn ${command}` : npmCmd + const cmd = yarn ? `yarn ${command} --ignore-engines` : npmCmd await exports.exec(cmd, dir) } diff --git a/packages/cli/src/hosting/genUser.js b/packages/cli/src/hosting/genUser.js new file mode 100644 index 0000000000..7ee11179af --- /dev/null +++ b/packages/cli/src/hosting/genUser.js @@ -0,0 +1,22 @@ +const { success } = require("../utils") +const { updateDockerComposeService } = require("./utils") +const randomString = require("randomstring") +const { GENERATED_USER_EMAIL } = require("../constants") + +exports.generateUser = async (password, silent) => { + const email = GENERATED_USER_EMAIL + if (!password) { + password = randomString.generate({ length: 6 }) + } + updateDockerComposeService(service => { + service.environment["BB_ADMIN_USER_EMAIL"] = email + service.environment["BB_ADMIN_USER_PASSWORD"] = password + }) + if (!silent) { + console.log( + success( + `User admin credentials configured, access with email: ${email} - password: ${password}` + ) + ) + } +} diff --git a/packages/cli/src/hosting/index.js b/packages/cli/src/hosting/index.js index ae62c45992..d8133c4959 100644 --- a/packages/cli/src/hosting/index.js +++ b/packages/cli/src/hosting/index.js @@ -1,164 +1,18 @@ const Command = require("../structures/Command") -const { CommandWords, InitTypes, AnalyticsEvents } = require("../constants") -const { lookpath } = require("lookpath") -const { - downloadFile, - logErrorToFile, - success, - info, - parseEnv, -} = require("../utils") -const { confirmation } = require("../questions") -const fs = require("fs") -const compose = require("docker-compose") -const makeEnv = require("./makeEnv") -const axios = require("axios") -const { captureEvent } = require("../events") - -const BUDIBASE_SERVICES = ["app-service", "worker-service", "proxy-service"] -const ERROR_FILE = "docker-error.log" -const FILE_URLS = [ - "https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml", -] -const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data" - -async function downloadFiles() { - const promises = [] - for (let url of FILE_URLS) { - const fileName = url.split("/").slice(-1)[0] - promises.push(downloadFile(url, `./${fileName}`)) - } - await Promise.all(promises) -} - -async function checkDockerConfigured() { - const error = - "docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose" - const docker = await lookpath("docker") - const compose = await lookpath("docker-compose") - if (!docker || !compose) { - throw error - } -} - -function checkInitComplete() { - if (!fs.existsSync(makeEnv.filePath)) { - throw "Please run the hosting --init command before any other hosting command." - } -} - -async function handleError(func) { - try { - await func() - } catch (err) { - if (err && err.err) { - logErrorToFile(ERROR_FILE, err.err) - } - throw `Failed to start - logs written to file: ${ERROR_FILE}` - } -} - -async function init(type) { - const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN - await checkDockerConfigured() - if (!isQuick) { - const shouldContinue = await confirmation( - "This will create multiple files in current directory, should continue?" - ) - if (!shouldContinue) { - console.log("Stopping.") - return - } - } - captureEvent(AnalyticsEvents.SelfHostInit, { - type, - }) - await downloadFiles() - const config = isQuick ? makeEnv.QUICK_CONFIG : {} - if (type === InitTypes.DIGITAL_OCEAN) { - try { - const output = await axios.get(DO_USER_DATA_URL) - const response = parseEnv(output.data) - for (let [key, value] of Object.entries(makeEnv.ConfigMap)) { - if (response[key]) { - config[value] = response[key] - } - } - } catch (err) { - // don't need to handle error, just don't do anything - } - } - await makeEnv.make(config) -} - -async function start() { - await checkDockerConfigured() - checkInitComplete() - console.log( - info( - "Starting services, this may take a moment - first time this may take a few minutes to download images." - ) - ) - const port = makeEnv.get("MAIN_PORT") - await handleError(async () => { - // need to log as it makes it more clear - await compose.upAll({ cwd: "./", log: true }) - }) - console.log( - success( - `Services started, please go to http://localhost:${port} for next steps.` - ) - ) -} - -async function status() { - await checkDockerConfigured() - checkInitComplete() - console.log(info("Budibase status")) - await handleError(async () => { - const response = await compose.ps() - console.log(response.out) - }) -} - -async function stop() { - await checkDockerConfigured() - checkInitComplete() - console.log(info("Stopping services, this may take a moment.")) - await handleError(async () => { - await compose.stop() - }) - console.log(success("Services have been stopped successfully.")) -} - -async function update() { - await checkDockerConfigured() - checkInitComplete() - if (await confirmation("Do you wish to update you docker-compose.yaml?")) { - await downloadFiles() - } - await handleError(async () => { - const status = await compose.ps() - const parts = status.out.split("\n") - const isUp = parts[2] && parts[2].indexOf("Up") !== -1 - if (isUp) { - console.log(info("Stopping services, this may take a moment.")) - await compose.stop() - } - console.log(info("Beginning update, this may take a few minutes.")) - await compose.pullMany(BUDIBASE_SERVICES, { log: true }) - if (isUp) { - console.log(success("Update complete, restarting services...")) - await start() - } - }) -} +const { CommandWords } = require("../constants") +const { init } = require("./init") +const { start } = require("./start") +const { stop } = require("./stop") +const { status } = require("./status") +const { update } = require("./update") +const { generateUser } = require("./genUser") +const { watchPlugins } = require("./watch") const command = new Command(`${CommandWords.HOSTING}`) .addHelp("Controls self hosting on the Budibase platform.") .addSubOption( "--init [type]", - "Configure a self hosted platform in current directory, type can be unspecified or 'quick'.", + "Configure a self hosted platform in current directory, type can be unspecified, 'quick' or 'single'.", init ) .addSubOption( @@ -181,5 +35,16 @@ const command = new Command(`${CommandWords.HOSTING}`) "Update the Budibase images to the latest version.", update ) + .addSubOption( + "--watch-plugin-dir [directory]", + "Add plugin directory watching to a Budibase install.", + watchPlugins + ) + .addSubOption( + "--gen-user", + "Create an admin user automatically as part of first start.", + generateUser + ) + .addSubOption("--single", "Specify this with init to use the single image.") exports.command = command diff --git a/packages/cli/src/hosting/init.js b/packages/cli/src/hosting/init.js new file mode 100644 index 0000000000..88063f1732 --- /dev/null +++ b/packages/cli/src/hosting/init.js @@ -0,0 +1,75 @@ +const { InitTypes, AnalyticsEvents } = require("../constants") +const { confirmation } = require("../questions") +const { captureEvent } = require("../events") +const makeFiles = require("./makeFiles") +const axios = require("axios") +const { parseEnv } = require("../utils") +const { checkDockerConfigured, downloadFiles } = require("./utils") +const { watchPlugins } = require("./watch") +const { generateUser } = require("./genUser") + +const DO_USER_DATA_URL = "http://169.254.169.254/metadata/v1/user-data" + +async function getInitConfig(type, isQuick, port) { + const config = isQuick ? makeFiles.QUICK_CONFIG : {} + if (type === InitTypes.DIGITAL_OCEAN) { + try { + const output = await axios.get(DO_USER_DATA_URL) + const response = parseEnv(output.data) + for (let [key, value] of Object.entries(makeFiles.ConfigMap)) { + if (response[key]) { + config[value] = response[key] + } + } + } catch (err) { + // don't need to handle error, just don't do anything + } + } + // override port + if (port) { + config[makeFiles.ConfigMap.MAIN_PORT] = port + } + return config +} + +exports.init = async opts => { + let type, isSingle, watchDir, genUser, port, silent + if (typeof opts === "string") { + type = opts + } else { + type = opts["init"] + isSingle = opts["single"] + watchDir = opts["watchPluginDir"] + genUser = opts["genUser"] + port = opts["port"] + silent = opts["silent"] + } + const isQuick = type === InitTypes.QUICK || type === InitTypes.DIGITAL_OCEAN + await checkDockerConfigured() + if (!isQuick) { + const shouldContinue = await confirmation( + "This will create multiple files in current directory, should continue?" + ) + if (!shouldContinue) { + console.log("Stopping.") + return + } + } + captureEvent(AnalyticsEvents.SelfHostInit, { + type, + }) + const config = await getInitConfig(type, isQuick, port) + if (!isSingle) { + await downloadFiles() + await makeFiles.makeEnv(config, silent) + } else { + await makeFiles.makeSingleCompose(config, silent) + } + if (watchDir) { + await watchPlugins(watchDir, silent) + } + if (genUser) { + const inputPassword = typeof genUser === "string" ? genUser : null + await generateUser(inputPassword, silent) + } +} diff --git a/packages/cli/src/hosting/makeEnv.js b/packages/cli/src/hosting/makeEnv.js deleted file mode 100644 index d1d23999f8..0000000000 --- a/packages/cli/src/hosting/makeEnv.js +++ /dev/null @@ -1,66 +0,0 @@ -const { number } = require("../questions") -const { success } = require("../utils") -const fs = require("fs") -const path = require("path") -const randomString = require("randomstring") - -const FILE_PATH = path.resolve("./.env") - -function getContents(port) { - return ` -# Use the main port in the builder for your self hosting URL, e.g. localhost:10000 -MAIN_PORT=${port} - -# This section contains all secrets pertaining to the system -JWT_SECRET=${randomString.generate()} -MINIO_ACCESS_KEY=${randomString.generate()} -MINIO_SECRET_KEY=${randomString.generate()} -COUCH_DB_PASSWORD=${randomString.generate()} -COUCH_DB_USER=${randomString.generate()} -REDIS_PASSWORD=${randomString.generate()} -INTERNAL_API_KEY=${randomString.generate()} - -# This section contains variables that do not need to be altered under normal circumstances -APP_PORT=4002 -WORKER_PORT=4003 -MINIO_PORT=4004 -COUCH_DB_PORT=4005 -REDIS_PORT=6379 -WATCHTOWER_PORT=6161 -BUDIBASE_ENVIRONMENT=PRODUCTION` -} - -module.exports.filePath = FILE_PATH -module.exports.ConfigMap = { - MAIN_PORT: "port", -} -module.exports.QUICK_CONFIG = { - key: "budibase", - port: 10000, -} - -module.exports.make = async (inputs = {}) => { - const hostingPort = - inputs.port || - (await number( - "Please enter the port on which you want your installation to run: ", - 10000 - )) - const fileContents = getContents(hostingPort) - fs.writeFileSync(FILE_PATH, fileContents) - console.log( - success( - "Configuration has been written successfully - please check .env file for more details." - ) - ) -} - -module.exports.get = property => { - const props = fs.readFileSync(FILE_PATH, "utf8").split(property) - if (props[0].charAt(0) === "=") { - property = props[0] - } else { - property = props[1] - } - return property.split("=")[1].split("\n")[0] -} diff --git a/packages/cli/src/hosting/makeFiles.js b/packages/cli/src/hosting/makeFiles.js new file mode 100644 index 0000000000..abb0736858 --- /dev/null +++ b/packages/cli/src/hosting/makeFiles.js @@ -0,0 +1,137 @@ +const { number } = require("../questions") +const { success, stringifyToDotEnv } = require("../utils") +const fs = require("fs") +const path = require("path") +const randomString = require("randomstring") +const yaml = require("yaml") +const { getAppService } = require("./utils") + +const SINGLE_IMAGE = "budibase/budibase:latest" +const VOL_NAME = "budibase_data" +const COMPOSE_PATH = path.resolve("./docker-compose.yaml") +const ENV_PATH = path.resolve("./.env") + +function getSecrets(opts = { single: false }) { + const secrets = [ + "JWT_SECRET", + "MINIO_ACCESS_KEY", + "MINIO_SECRET_KEY", + "REDIS_PASSWORD", + "INTERNAL_API_KEY", + ] + const obj = {} + secrets.forEach(secret => (obj[secret] = randomString.generate())) + // setup couch creds separately + if (opts && opts.single) { + obj["COUCHDB_USER"] = "admin" + obj["COUCHDB_PASSWORD"] = randomString.generate() + } else { + obj["COUCH_DB_USER"] = "admin" + obj["COUCH_DB_PASSWORD"] = randomString.generate() + } + return obj +} + +function getSingleCompose(port) { + const singleComposeObj = { + version: "3", + services: { + budibase: { + restart: "unless-stopped", + image: SINGLE_IMAGE, + ports: [`${port}:80`], + environment: getSecrets({ single: true }), + volumes: [`${VOL_NAME}:/data`], + }, + }, + volumes: { + [VOL_NAME]: { + driver: "local", + }, + }, + } + return yaml.stringify(singleComposeObj) +} + +function getEnv(port) { + const partOne = stringifyToDotEnv({ + MAIN_PORT: port, + }) + const partTwo = stringifyToDotEnv(getSecrets()) + const partThree = stringifyToDotEnv({ + APP_PORT: 4002, + WORKER_PORT: 4003, + MINIO_PORT: 4004, + COUCH_DB_PORT: 4005, + REDIS_PORT: 6379, + WATCHTOWER_PORT: 6161, + BUDIBASE_ENVIRONMENT: "PRODUCTION", + }) + return [ + "# Use the main port in the builder for your self hosting URL, e.g. localhost:10000", + partOne, + "# This section contains all secrets pertaining to the system", + partTwo, + "# This section contains variables that do not need to be altered under normal circumstances", + partThree, + ].join("\n") +} + +exports.ENV_PATH = ENV_PATH +exports.COMPOSE_PATH = COMPOSE_PATH + +module.exports.ConfigMap = { + MAIN_PORT: "port", +} + +module.exports.QUICK_CONFIG = { + key: "budibase", + port: 10000, +} + +async function make(path, contentsFn, inputs = {}, silent) { + const port = + inputs.port || + (await number( + "Please enter the port on which you want your installation to run: ", + 10000 + )) + const fileContents = contentsFn(port) + fs.writeFileSync(path, fileContents) + if (!silent) { + console.log( + success( + `Configuration has been written successfully - please check ${path} for more details.` + ) + ) + } +} + +module.exports.makeEnv = async (inputs = {}, silent) => { + return make(ENV_PATH, getEnv, inputs, silent) +} + +module.exports.makeSingleCompose = async (inputs = {}, silent) => { + return make(COMPOSE_PATH, getSingleCompose, inputs, silent) +} + +module.exports.getEnvProperty = property => { + const props = fs.readFileSync(ENV_PATH, "utf8").split(property) + if (props[0].charAt(0) === "=") { + property = props[0] + } else { + property = props[1] + } + return property.split("=")[1].split("\n")[0] +} + +module.exports.getComposeProperty = property => { + const { service } = getAppService(COMPOSE_PATH) + if (property === "port" && Array.isArray(service.ports)) { + const port = service.ports[0] + return port.split(":")[0] + } else if (service.environment) { + return service.environment[property] + } + return null +} diff --git a/packages/cli/src/hosting/start.js b/packages/cli/src/hosting/start.js new file mode 100644 index 0000000000..33b5eb92ce --- /dev/null +++ b/packages/cli/src/hosting/start.js @@ -0,0 +1,34 @@ +const { + checkDockerConfigured, + checkInitComplete, + handleError, +} = require("./utils") +const { info, success } = require("../utils") +const makeFiles = require("./makeFiles") +const compose = require("docker-compose") +const fs = require("fs") + +exports.start = async () => { + await checkDockerConfigured() + checkInitComplete() + console.log( + info( + "Starting services, this may take a moment - first time this may take a few minutes to download images." + ) + ) + let port + if (fs.existsSync(makeFiles.ENV_PATH)) { + port = makeFiles.getEnvProperty("MAIN_PORT") + } else { + port = makeFiles.getComposeProperty("port") + } + await handleError(async () => { + // need to log as it makes it more clear + await compose.upAll({ cwd: "./", log: true }) + }) + console.log( + success( + `Services started, please go to http://localhost:${port} for next steps.` + ) + ) +} diff --git a/packages/cli/src/hosting/status.js b/packages/cli/src/hosting/status.js new file mode 100644 index 0000000000..2b98392133 --- /dev/null +++ b/packages/cli/src/hosting/status.js @@ -0,0 +1,17 @@ +const { + checkDockerConfigured, + checkInitComplete, + handleError, +} = require("./utils") +const { info } = require("../utils") +const compose = require("docker-compose") + +exports.status = async () => { + await checkDockerConfigured() + checkInitComplete() + console.log(info("Budibase status")) + await handleError(async () => { + const response = await compose.ps() + console.log(response.out) + }) +} diff --git a/packages/cli/src/hosting/stop.js b/packages/cli/src/hosting/stop.js new file mode 100644 index 0000000000..5f38c93484 --- /dev/null +++ b/packages/cli/src/hosting/stop.js @@ -0,0 +1,17 @@ +const { + checkDockerConfigured, + checkInitComplete, + handleError, +} = require("./utils") +const { info, success } = require("../utils") +const compose = require("docker-compose") + +exports.stop = async () => { + await checkDockerConfigured() + checkInitComplete() + console.log(info("Stopping services, this may take a moment.")) + await handleError(async () => { + await compose.stop() + }) + console.log(success("Services have been stopped successfully.")) +} diff --git a/packages/cli/src/hosting/update.js b/packages/cli/src/hosting/update.js new file mode 100644 index 0000000000..7d3367ce57 --- /dev/null +++ b/packages/cli/src/hosting/update.js @@ -0,0 +1,49 @@ +const { + checkDockerConfigured, + checkInitComplete, + downloadFiles, + handleError, + getServices, +} = require("./utils") +const { confirmation } = require("../questions") +const compose = require("docker-compose") +const { COMPOSE_PATH } = require("./makeFiles") +const { info, success } = require("../utils") +const { start } = require("./start") + +const BB_COMPOSE_SERVICES = ["app-service", "worker-service", "proxy-service"] +const BB_SINGLE_SERVICE = ["budibase"] + +exports.update = async () => { + const { services } = getServices(COMPOSE_PATH) + const isSingle = Object.keys(services).length === 1 + await checkDockerConfigured() + checkInitComplete() + if ( + !isSingle && + (await confirmation("Do you wish to update you docker-compose.yaml?")) + ) { + await downloadFiles() + } + await handleError(async () => { + const status = await compose.ps() + const parts = status.out.split("\n") + const isUp = parts[2] && parts[2].indexOf("Up") !== -1 + if (isUp) { + console.log(info("Stopping services, this may take a moment.")) + await compose.stop() + } + console.log(info("Beginning update, this may take a few minutes.")) + let services + if (isSingle) { + services = BB_SINGLE_SERVICE + } else { + services = BB_COMPOSE_SERVICES + } + await compose.pullMany(services, { log: true }) + if (isUp) { + console.log(success("Update complete, restarting services...")) + await start() + } + }) +} diff --git a/packages/cli/src/hosting/utils.js b/packages/cli/src/hosting/utils.js new file mode 100644 index 0000000000..952974ee83 --- /dev/null +++ b/packages/cli/src/hosting/utils.js @@ -0,0 +1,87 @@ +const { lookpath } = require("lookpath") +const fs = require("fs") +const makeFiles = require("./makeFiles") +const { logErrorToFile, downloadFile, error } = require("../utils") +const yaml = require("yaml") + +const ERROR_FILE = "docker-error.log" +const FILE_URLS = [ + "https://raw.githubusercontent.com/Budibase/budibase/master/hosting/docker-compose.yaml", +] + +exports.downloadFiles = async () => { + const promises = [] + for (let url of FILE_URLS) { + const fileName = url.split("/").slice(-1)[0] + promises.push(downloadFile(url, `./${fileName}`)) + } + await Promise.all(promises) +} + +exports.checkDockerConfigured = async () => { + const error = + "docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose" + const docker = await lookpath("docker") + const compose = await lookpath("docker-compose") + if (!docker || !compose) { + throw error + } +} + +exports.checkInitComplete = () => { + if ( + !fs.existsSync(makeFiles.ENV_PATH) && + !fs.existsSync(makeFiles.COMPOSE_PATH) + ) { + throw "Please run the hosting --init command before any other hosting command." + } +} + +exports.handleError = async func => { + try { + await func() + } catch (err) { + if (err && err.err) { + logErrorToFile(ERROR_FILE, err.err) + } + throw `Failed to start - logs written to file: ${ERROR_FILE}` + } +} + +exports.getServices = path => { + const dockerYaml = fs.readFileSync(path, "utf8") + const parsedYaml = yaml.parse(dockerYaml) + return { yaml: parsedYaml, services: parsedYaml.services } +} + +exports.getAppService = path => { + const { yaml, services } = exports.getServices(path), + serviceList = Object.keys(services) + let service + if (services["app-service"]) { + service = services["app-service"] + } else if (serviceList.length === 1) { + service = services[serviceList[0]] + } + return { yaml, service } +} + +exports.updateDockerComposeService = updateFn => { + const opts = ["docker-compose.yaml", "docker-compose.yml"] + const dockerFilePath = opts.find(name => fs.existsSync(name)) + if (!dockerFilePath) { + console.log(error("Unable to locate docker-compose YAML.")) + return + } + const { yaml: parsedYaml, service } = exports.getAppService(dockerFilePath) + if (!service) { + console.log( + error( + "Unable to locate service within compose file, is it a valid Budibase configuration?" + ) + ) + return + } + updateFn(service) + fs.writeFileSync(dockerFilePath, yaml.stringify(parsedYaml)) +} diff --git a/packages/cli/src/hosting/watch.js b/packages/cli/src/hosting/watch.js new file mode 100644 index 0000000000..56f1c8e201 --- /dev/null +++ b/packages/cli/src/hosting/watch.js @@ -0,0 +1,36 @@ +const { resolve } = require("path") +const fs = require("fs") +const { error, success } = require("../utils") +const { updateDockerComposeService } = require("./utils") + +exports.watchPlugins = async (pluginPath, silent) => { + const PLUGIN_PATH = "/plugins" + // get absolute path + pluginPath = resolve(pluginPath) + if (!fs.existsSync(pluginPath)) { + console.log( + error( + `The directory "${pluginPath}" does not exist, please create and then try again.` + ) + ) + return + } + updateDockerComposeService(service => { + // set environment variable + service.environment["PLUGINS_DIR"] = PLUGIN_PATH + // add volumes to parsed yaml + if (!service.volumes) { + service.volumes = [] + } + const found = service.volumes.find(vol => vol.includes(PLUGIN_PATH)) + if (found) { + service.volumes.splice(service.volumes.indexOf(found), 1) + } + service.volumes.push(`${pluginPath}:${PLUGIN_PATH}`) + }) + if (!silent) { + console.log( + success(`Docker compose configured to watch directory: ${pluginPath}`) + ) + } +} diff --git a/packages/cli/src/plugins/index.js b/packages/cli/src/plugins/index.js index 66cca8c19d..873be10612 100644 --- a/packages/cli/src/plugins/index.js +++ b/packages/cli/src/plugins/index.js @@ -1,5 +1,5 @@ const Command = require("../structures/Command") -const { CommandWords, AnalyticsEvents } = require("../constants") +const { CommandWords, AnalyticsEvents, InitTypes } = require("../constants") const { getSkeleton, fleshOutSkeleton } = require("./skeleton") const questions = require("../questions") const fs = require("fs") @@ -9,6 +9,10 @@ const { runPkgCommand } = require("../exec") const { join } = require("path") const { success, error, info, moveDirectory } = require("../utils") const { captureEvent } = require("../events") +const fp = require("find-free-port") +const { GENERATED_USER_EMAIL } = require("../constants") +const { init: hostingInit } = require("../hosting/init") +const { start: hostingStart } = require("../hosting/start") function checkInPlugin() { if (!fs.existsSync("package.json")) { @@ -141,6 +145,29 @@ async function watch() { } } +async function dev() { + const pluginDir = await questions.string("Directory to watch", "./") + const [port] = await fp(10000) + const password = "admin" + await hostingInit({ + init: InitTypes.QUICK, + single: true, + watchPluginDir: pluginDir, + genUser: password, + port, + silent: true, + }) + await hostingStart() + console.log(success(`Configuration has been written to docker-compose.yaml`)) + console.log( + success("Development environment started successfully - connect at: ") + + info(`http://localhost:${port}`) + ) + console.log(success("Use the following credentials to login:")) + console.log(success("Email: ") + info(GENERATED_USER_EMAIL)) + console.log(success("Password: ") + info(password)) +} + const command = new Command(`${CommandWords.PLUGIN}`) .addHelp( "Custom plugins for Budibase, init, build and verify your components and datasources with this tool." @@ -160,5 +187,10 @@ const command = new Command(`${CommandWords.PLUGIN}`) "Automatically build any changes to your plugin.", watch ) + .addSubOption( + "--dev", + "Run a development environment which automatically watches the current directory.", + dev + ) exports.command = command diff --git a/packages/cli/src/structures/Command.js b/packages/cli/src/structures/Command.js index dfce96504d..e383c14263 100644 --- a/packages/cli/src/structures/Command.js +++ b/packages/cli/src/structures/Command.js @@ -1,4 +1,9 @@ -const { getSubHelpDescription, getHelpDescription, error } = require("../utils") +const { + getSubHelpDescription, + getHelpDescription, + error, + capitaliseFirstLetter, +} = require("../utils") class Command { constructor(command, func = null) { @@ -8,6 +13,15 @@ class Command { this.func = func } + convertToCommander(lookup) { + const parts = lookup.toLowerCase().split("-") + // camel case, separate out first + const first = parts.shift() + return [first] + .concat(parts.map(part => capitaliseFirstLetter(part))) + .join("") + } + addHelp(help) { this.help = help return this @@ -25,10 +39,7 @@ class Command { command = command.description(getHelpDescription(thisCmd.help)) } for (let opt of thisCmd.opts) { - command = command.option( - `${opt.command}`, - getSubHelpDescription(opt.help) - ) + command = command.option(opt.command, getSubHelpDescription(opt.help)) } command.helpOption( "--help", @@ -36,17 +47,25 @@ class Command { ) command.action(async options => { try { - let executed = false + let executed = false, + found = false for (let opt of thisCmd.opts) { - const lookup = opt.command.split(" ")[0].replace("--", "") - if (!executed && options[lookup]) { + let lookup = opt.command.split(" ")[0].replace("--", "") + // need to handle how commander converts watch-plugin-dir to watchPluginDir + lookup = this.convertToCommander(lookup) + found = !executed && options[lookup] + if (found && opt.func) { const input = Object.keys(options).length > 1 ? options : options[lookup] await opt.func(input) executed = true } } - if (!executed) { + if (found && !executed) { + console.log( + error(`${Object.keys(options)[0]} is an option, not an operation.`) + ) + } else if (!executed) { console.log(error(`Unknown ${this.command} option.`)) command.help() } diff --git a/packages/cli/src/structures/ConfigManager.js b/packages/cli/src/structures/ConfigManager.js index 04b7875b57..35799b8e92 100644 --- a/packages/cli/src/structures/ConfigManager.js +++ b/packages/cli/src/structures/ConfigManager.js @@ -33,11 +33,10 @@ class ConfigManager { } setValue(key, value) { - const updated = { + this.config = { ...this.config, [key]: value, } - this.config = updated } removeKey(key) { diff --git a/packages/cli/src/utils.js b/packages/cli/src/utils.js index 91f3263cc1..ba793420e7 100644 --- a/packages/cli/src/utils.js +++ b/packages/cli/src/utils.js @@ -84,3 +84,15 @@ exports.moveDirectory = (oldPath, newPath) => { } fs.rmdirSync(oldPath) } + +exports.capitaliseFirstLetter = str => { + return str.charAt(0).toUpperCase() + str.slice(1) +} + +exports.stringifyToDotEnv = json => { + let str = "" + for (let [key, value] of Object.entries(json)) { + str += `${key}=${value}\n` + } + return str +} diff --git a/packages/cli/yarn.lock b/packages/cli/yarn.lock index 547e9fd3e2..0850f94154 100644 --- a/packages/cli/yarn.lock +++ b/packages/cli/yarn.lock @@ -1113,6 +1113,11 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +find-free-port@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-free-port/-/find-free-port-2.0.0.tgz#4b22e5f6579eb1a38c41ac6bcb3efed1b6da9b1b" + integrity sha512-J1j8gfEVf5FN4PR5w5wrZZ7NYs2IvqsHcd03cAeQx3Ec/mo+lKceaVNhpsRKoZpZKbId88o8qh+dwUwzBV6WCg== + find-replace@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" @@ -3080,6 +3085,11 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== +yaml@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.1.1.tgz#1e06fb4ca46e60d9da07e4f786ea370ed3c3cfec" + integrity sha512-o96x3OPo8GjWeSLF+wOAbrPfhFOGY0W00GNaxCDv+9hkcDJEnev1yh8S7pgHF0ik6zc8sQLuL8hjHjJULZp8bw== + yargs-parser@^20.2.2: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" diff --git a/packages/client/manifest.json b/packages/client/manifest.json index fedc8ea15b..80ed9623cd 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -85,6 +85,10 @@ "icon": "Selection", "hasChildren": true, "showSettingsBar": true, + "size": { + "width": 400, + "height": 100 + }, "styles": [ "grid", "padding", @@ -256,6 +260,10 @@ "section" ], "showEmptyState": false, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "section", @@ -277,6 +285,10 @@ "icon": "Button", "editable": true, "showSettingsBar": true, + "size": { + "width": 105, + "height": 35 + }, "settings": [ { "type": "text", @@ -369,6 +381,10 @@ "illegalChildren": [ "section" ], + "size": { + "width": 400, + "height": 10 + }, "settings": [ { "type": "select", @@ -406,6 +422,10 @@ ], "hasChildren": true, "showSettingsBar": true, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "dataProvider", @@ -585,6 +605,7 @@ ] }, "card": { + "deprecated": true, "name": "Vertical Card", "description": "A basic card component that can contain content and actions.", "icon": "ViewColumn", @@ -665,6 +686,10 @@ ], "showSettingsBar": true, "editable": true, + "size": { + "width": 400, + "height": 30 + }, "settings": [ { "type": "text", @@ -787,6 +812,10 @@ ], "showSettingsBar": true, "editable": true, + "size": { + "width": 400, + "height": 40 + }, "settings": [ { "type": "text", @@ -904,6 +933,10 @@ "name": "Tag", "icon": "Label", "showSettingsBar": true, + "size": { + "width": 100, + "height": 25 + }, "settings": [ { "type": "text", @@ -955,12 +988,13 @@ "name": "Image", "description": "A basic component for displaying images", "icon": "Image", - "illegalChildren": [ - "section" - ], "styles": [ "size" ], + "size": { + "width": 400, + "height": 300 + }, "settings": [ { "type": "text", @@ -977,9 +1011,10 @@ "styles": [ "size" ], - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 300 + }, "settings": [ { "type": "text", @@ -1037,9 +1072,10 @@ "name": "Icon", "description": "A basic component for displaying icons", "icon": "Shapes", - "illegalChildren": [ - "section" - ], + "size": { + "width": 25, + "height": 25 + }, "settings": [ { "type": "icon", @@ -1156,9 +1192,10 @@ "icon": "Link", "showSettingsBar": true, "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 200, + "height": 30 + }, "settings": [ { "type": "text", @@ -1268,12 +1305,10 @@ ] }, "cardhorizontal": { + "deprecated": true, "name": "Horizontal Card", "description": "A basic card component that can contain content and actions.", "icon": "ViewRow", - "illegalChildren": [ - "section" - ], "settings": [ { "type": "text", @@ -1364,27 +1399,31 @@ "name": "Stat Card", "description": "A card component for displaying numbers.", "icon": "Card", - "illegalChildren": [ - "section" - ], + "size": { + "width": 260, + "height": 143 + }, "settings": [ { "type": "text", "label": "Title", "key": "title", - "placeholder": "Total Revenue" + "placeholder": "Total Revenue", + "defaultValue": "Title" }, { "type": "text", "label": "Value", "key": "value", - "placeholder": "$1,981,983" + "placeholder": "$1,981,983", + "defaultValue": "Value" }, { "type": "text", "label": "Label", "key": "label", - "placeholder": "Stripe" + "placeholder": "Stripe", + "defaultValue": "Label" } ] }, @@ -1392,12 +1431,13 @@ "name": "Embed", "icon": "Code", "description": "Embed content from 3rd party sources", - "illegalChildren": [ - "section" - ], "styles": [ "size" ], + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "text", @@ -1411,9 +1451,10 @@ "name": "Bar Chart", "description": "Bar chart", "icon": "GraphBarVertical", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -1572,9 +1613,10 @@ "name": "Line Chart", "description": "Line chart", "icon": "GraphTrend", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -1732,9 +1774,10 @@ "name": "Area Chart", "description": "Line chart", "icon": "GraphAreaStacked", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -1904,9 +1947,10 @@ "name": "Pie Chart", "description": "Pie chart", "icon": "GraphPie", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -2032,9 +2076,10 @@ "name": "Donut Chart", "description": "Donut chart", "icon": "GraphDonut", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -2160,9 +2205,10 @@ "name": "Candlestick Chart", "description": "Candlestick chart", "icon": "GraphBarVerticalStacked", - "illegalChildren": [ - "section" - ], + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -2267,6 +2313,10 @@ "styles": [ "size" ], + "size": { + "width": 400, + "height": 400 + }, "settings": [ { "type": "select", @@ -2353,6 +2403,10 @@ "styles": [ "size" ], + "size": { + "width": 400, + "height": 400 + }, "settings": [ { "type": "number", @@ -2373,6 +2427,10 @@ "size" ], "hasChildren": true, + "size": { + "width": 400, + "height": 400 + }, "settings": [ { "type": "select", @@ -2399,13 +2457,14 @@ "stringfield": { "name": "Text Field", "icon": "Text", - "illegalChildren": [ - "section" - ], "styles": [ "size" ], "editable": true, + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/string", @@ -2493,9 +2552,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/number", @@ -2549,9 +2609,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/string", @@ -2605,9 +2666,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/options", @@ -2772,9 +2834,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/array", @@ -2930,9 +2993,10 @@ "name": "Checkbox", "icon": "SelectBox", "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/boolean", @@ -3010,6 +3074,10 @@ "size" ], "editable": true, + "size": { + "width": 400, + "height": 150 + }, "settings": [ { "type": "field/longform", @@ -3085,9 +3153,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/datetime", @@ -3158,6 +3227,57 @@ } ] }, + "codescanner": { + "name": "Barcode/QR Scanner", + "icon": "Camera", + "styles": [ + "size" + ], + "size": { + "width": 400, + "height": 50 + }, + "settings": [ + { + "type": "field/barcode/qr", + "label": "Field", + "key": "field", + "required": true + }, + { + "type": "text", + "label": "Label", + "key": "label" + }, + { + "type": "text", + "label": "Button text", + "key": "scanButtonText" + }, + { + "type": "text", + "label": "Default value", + "key": "defaultValue" + }, + { + "type": "boolean", + "label": "Disabled", + "key": "disabled", + "defaultValue": false + }, + { + "type": "boolean", + "label": "Allow manual entry", + "key": "allowManualEntry", + "defaultValue": false + }, + { + "type": "validation/string", + "label": "Validation", + "key": "validation" + } + ] + }, "embeddedmap": { "name": "Embedded Map", "icon": "Location", @@ -3165,29 +3285,27 @@ "size" ], "draggable": false, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 320 + }, "settings": [ { "type": "dataProvider", "label": "Provider", - "key": "dataProvider", - "required": true + "key": "dataProvider" }, { "type": "field", "label": "Latitude Key", "key": "latitudeKey", - "dependsOn": "dataProvider", - "required": true + "dependsOn": "dataProvider" }, { "type": "field", "label": "Longitude Key", "key": "longitudeKey", - "dependsOn": "dataProvider", - "required": true + "dependsOn": "dataProvider" }, { "type": "field", @@ -3281,9 +3399,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 200 + }, "settings": [ { "type": "field/attachment", @@ -3338,9 +3457,10 @@ "size" ], "editable": true, - "illegalChildren": [ - "section" - ], + "size": { + "width": 400, + "height": 50 + }, "settings": [ { "type": "field/link", @@ -3400,6 +3520,10 @@ "size" ], "editable": true, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "field/json", @@ -3443,12 +3567,15 @@ }, "s3upload": { "name": "S3 File Upload", - "info": "This component can't be used with S3 datasources that use custom endpoints.", "icon": "UploadToCloud", "styles": [ "size" ], "editable": true, + "size": { + "width": 400, + "height": 200 + }, "settings": [ { "type": "field/attachment", @@ -3464,7 +3591,8 @@ { "type": "dataSource/s3", "label": "S3 Datasource", - "key": "datasourceId" + "key": "datasourceId", + "info": "This component can't be used with S3 datasources that use custom endpoints" }, { "type": "text", @@ -3502,7 +3630,6 @@ }, "dataprovider": { "name": "Data Provider", - "info": "Pagination is only available for data stored in tables.", "icon": "Data", "illegalChildren": [ "section" @@ -3511,6 +3638,10 @@ "actions": [ "RefreshDatasource" ], + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "dataSource", @@ -3548,7 +3679,8 @@ "type": "boolean", "label": "Paginate", "key": "paginate", - "defaultValue": true + "defaultValue": true, + "info": "Pagination is only available for data stored in tables" } ], "context": { @@ -3590,7 +3722,10 @@ ], "hasChildren": true, "showEmptyState": false, - "info": "Row selection is only compatible with internal or SQL tables", + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "dataProvider", @@ -3647,7 +3782,8 @@ "type": "boolean", "label": "Allow row selection", "key": "allowSelectRows", - "defaultValue": false + "defaultValue": false, + "info": "Row selection is only compatible with internal or SQL tables" }, { "type": "boolean", @@ -3688,13 +3824,17 @@ "size" ], "hasChildren": false, - "info": "Your data provider will be automatically filtered to the given date range.", + "size": { + "width": 200, + "height": 50 + }, "settings": [ { "type": "dataProvider", "label": "Provider", "key": "dataProvider", - "required": true + "required": true, + "info": "Your data provider will be automatically filtered to the given date range." }, { "type": "field", @@ -3724,21 +3864,28 @@ "styles": [ "size" ], + "size": { + "width": 300, + "height": 120 + }, "settings": [ { "type": "text", "key": "title", - "label": "Title" + "label": "Title", + "defaultValue": "Title" }, { "type": "text", "key": "subtitle", - "label": "Subtitle" + "label": "Subtitle", + "defaultValue": "Subtitle" }, { "type": "text", "key": "description", - "label": "Description" + "label": "Description", + "defaultValue": "Description" }, { "type": "text", @@ -3782,6 +3929,10 @@ "name": "Dynamic Filter", "icon": "Filter", "showSettingsBar": true, + "size": { + "width": 100, + "height": 35 + }, "settings": [ { "type": "dataProvider", @@ -3829,7 +3980,10 @@ "styles": [ "size" ], - "info": "Only the first 3 search columns will be used.", + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -3846,7 +4000,8 @@ "type": "searchfield", "label": "Search Columns", "key": "searchColumns", - "placeholder": "Choose search columns" + "placeholder": "Choose search columns", + "info": "Only the first 5 search columns will be used" }, { "type": "filter", @@ -3893,7 +4048,6 @@ { "section": true, "name": "Table", - "info": "Row selection is only compatible with internal or SQL tables", "settings": [ { "type": "number", @@ -3927,7 +4081,8 @@ { "type": "boolean", "label": "Allow row selection", - "key": "allowSelectRows" + "key": "allowSelectRows", + "info": "Row selection is only compatible with internal or SQL tables" }, { "type": "boolean", @@ -3994,7 +4149,10 @@ "styles": [ "size" ], - "info": "Only the first 3 search columns will be used.", + "size": { + "width": 600, + "height": 400 + }, "settings": [ { "type": "text", @@ -4011,7 +4169,8 @@ "type": "searchfield", "label": "Search Columns", "key": "searchColumns", - "placeholder": "Choose search columns" + "placeholder": "Choose search columns", + "info": "Only the first 5 search columns will be used" }, { "type": "filter", @@ -4052,19 +4211,22 @@ "type": "text", "key": "cardTitle", "label": "Title", - "nested": true + "nested": true, + "defaultValue": "Title" }, { "type": "text", "key": "cardSubtitle", "label": "Subtitle", - "nested": true + "nested": true, + "defaultValue": "Subtitle" }, { "type": "text", "key": "cardDescription", "label": "Description", - "nested": true + "nested": true, + "defaultValue": "Description" }, { "type": "text", @@ -4158,6 +4320,7 @@ } }, "repeaterblock": { + "block": true, "name": "Repeater block", "icon": "ViewList", "illegalChildren": [ @@ -4165,6 +4328,10 @@ ], "hasChildren": true, "showSettingsBar": true, + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "dataSource", @@ -4387,6 +4554,10 @@ "styles": [ "size" ], + "size": { + "width": 400, + "height": 100 + }, "settings": [ { "type": "text", @@ -4403,5 +4574,155 @@ "styles": [ "size" ] + }, + "formblock": { + "name": "Form Block", + "icon": "Form", + "styles": [ + "size" + ], + "block": true, + "info": "Form blocks are only compatible with internal or SQL tables", + "size": { + "width": 400, + "height": 400 + }, + "settings": [ + { + "type": "select", + "label": "Type", + "key": "actionType", + "options": [ + "Create", + "Update", + "View" + ], + "defaultValue": "Create" + }, + { + "type": "table", + "label": "Table", + "key": "dataSource" + }, + { + "type": "text", + "label": "Row ID", + "key": "rowId", + "nested": true, + "dependsOn": { + "setting": "actionType", + "value": "Create", + "invert": true + } + }, + { + "type": "text", + "label": "Title", + "key": "title", + "nested": true + }, + { + "type": "select", + "label": "Size", + "key": "size", + "options": [ + { + "label": "Medium", + "value": "spectrum--medium" + }, + { + "label": "Large", + "value": "spectrum--large" + } + ], + "defaultValue": "spectrum--medium" + }, + { + "section": true, + "name": "Fields", + "settings": [ + { + "type": "multifield", + "label": "Fields", + "key": "fields" + }, + { + "type": "select", + "label": "Field labels", + "key": "labelPosition", + "defaultValue": "left", + "options": [ + { + "label": "Left", + "value": "left" + }, + { + "label": "Above", + "value": "above" + } + ] + }, + { + "type": "boolean", + "label": "Disabled", + "key": "disabled", + "defaultValue": false, + "dependsOn": { + "setting": "actionType", + "value": "View", + "invert": true + } + } + ] + }, + { + "section": true, + "name": "Buttons", + "settings": [ + { + "type": "boolean", + "label": "Show save button", + "key": "showSaveButton", + "defaultValue": true, + "dependsOn": { + "setting": "actionType", + "value": "View", + "invert": true + } + }, + { + "type": "boolean", + "label": "Show delete button", + "key": "showDeleteButton", + "defaultValue": false, + "dependsOn": { + "setting": "actionType", + "value": "Update" + } + }, + { + "type": "url", + "label": "Navigate after button press", + "key": "actionUrl", + "placeholder": "Choose a screen", + "dependsOn": { + "setting": "actionType", + "value": "View", + "invert": true + } + } + ] + } + ], + "context": [ + { + "type": "form", + "suffix": "form" + }, + { + "type": "schema", + "suffix": "repeater" + } + ] } } \ No newline at end of file diff --git a/packages/client/package.json b/packages/client/package.json index 0d8c92a6cb..1b1e1d0f57 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/client", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "license": "MPL-2.0", "module": "dist/budibase-client.js", "main": "dist/budibase-client.js", @@ -19,9 +19,9 @@ "dev:builder": "rollup -cw" }, "dependencies": { - "@budibase/bbui": "1.4.18-alpha.1", - "@budibase/frontend-core": "1.4.18-alpha.1", - "@budibase/string-templates": "1.4.18-alpha.1", + "@budibase/bbui": "2.0.30-alpha.7", + "@budibase/frontend-core": "2.0.30-alpha.7", + "@budibase/string-templates": "2.0.30-alpha.7", "@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "^3.0.3", "@spectrum-css/divider": "^1.0.3", @@ -33,6 +33,7 @@ "apexcharts": "^3.22.1", "dayjs": "^1.10.5", "downloadjs": "1.4.7", + "html5-qrcode": "^2.2.1", "leaflet": "^1.7.1", "regexparam": "^1.3.0", "sanitize-html": "^2.7.0", diff --git a/packages/client/rollup.config.js b/packages/client/rollup.config.js index 5206b63884..be94ace90c 100644 --- a/packages/client/rollup.config.js +++ b/packages/client/rollup.config.js @@ -27,6 +27,15 @@ export default { file: `./dist/budibase-client.js`, }, ], + onwarn(warning, warn) { + if ( + warning.code === "THIS_IS_UNDEFINED" || + warning.code === "CIRCULAR_DEPENDENCY" + ) { + return + } + warn(warning) + }, plugins: [ alias({ entries: [ diff --git a/packages/client/src/api/api.js b/packages/client/src/api/api.js index 563126fdd0..8d29d37bd6 100644 --- a/packages/client/src/api/api.js +++ b/packages/client/src/api/api.js @@ -1,5 +1,7 @@ import { createAPIClient } from "@budibase/frontend-core" -import { notificationStore, authStore, devToolsStore } from "../stores" +import { notificationStore } from "../stores/notification.js" +import { authStore } from "../stores/auth.js" +import { devToolsStore } from "../stores/devTools.js" import { get } from "svelte/store" export const API = createAPIClient({ diff --git a/packages/client/src/components/Block.svelte b/packages/client/src/components/Block.svelte index b5e610c1bb..75474dfd6f 100644 --- a/packages/client/src/components/Block.svelte +++ b/packages/client/src/components/Block.svelte @@ -1,12 +1,95 @@ - +
+ +
diff --git a/packages/client/src/components/BlockComponent.svelte b/packages/client/src/components/BlockComponent.svelte index c23f18f55c..2f756ce296 100644 --- a/packages/client/src/components/BlockComponent.svelte +++ b/packages/client/src/components/BlockComponent.svelte @@ -1,17 +1,21 @@ diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index c212fcf0f5..537e963ff3 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -92,7 +92,7 @@ {#if $builderStore.usedPlugins?.length} {#each $builderStore.usedPlugins as plugin (plugin.hash)} - + {/each} {/if} diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index 1450fda399..742b93bb42 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -16,7 +16,14 @@ propsAreSame, getSettingsDefinition, } from "utils/componentProps" - import { builderStore, devToolsStore, componentStore, appStore } from "stores" + import { + builderStore, + devToolsStore, + componentStore, + appStore, + dndIsDragging, + dndComponentPath, + } from "stores" import { Helpers } from "@budibase/bbui" import { getActiveConditions, reduceConditionActions } from "utils/conditions" import Placeholder from "components/app/Placeholder.svelte" @@ -27,6 +34,7 @@ export let isLayout = false export let isScreen = false export let isBlock = false + export let parent = null // Get parent contexts const context = getContext("context") @@ -97,6 +105,7 @@ $builderStore.inBuilder && $builderStore.selectedComponentId === id $: inSelectedPath = $componentStore.selectedComponentPath?.includes(id) $: inDragPath = inSelectedPath && $builderStore.editMode + $: inDndPath = $dndComponentPath?.includes(id) // Derive definition properties which can all be optional, so need to be // coerced to booleans @@ -108,7 +117,7 @@ // Interactive components can be selected, dragged and highlighted inside // the builder preview $: builderInteractive = - $builderStore.inBuilder && insideScreenslot && !isBlock + $builderStore.inBuilder && insideScreenslot && !isBlock && !instance.static $: devToolsInteractive = $devToolsStore.allowSelection && !isBlock $: interactive = builderInteractive || devToolsInteractive $: editing = editable && selected && $builderStore.editMode @@ -118,7 +127,7 @@ !isLayout && !isScreen && definition?.draggable !== false - $: droppable = interactive && !isLayout && !isScreen + $: droppable = interactive $: builderHidden = $builderStore.inBuilder && $builderStore.hiddenComponentIds?.includes(id) @@ -126,8 +135,9 @@ // Empty states can be shown for these components, but can be disabled // in the component manifest. $: empty = - (interactive && !children.length && hasChildren) || - hasMissingRequiredSettings + !isBlock && + ((interactive && !children.length && hasChildren) || + hasMissingRequiredSettings) $: emptyState = empty && showEmptyState // Enrich component settings @@ -149,6 +159,12 @@ // Scroll the selected element into view $: selected && scrollIntoView() + // When dragging and dropping, pad components to allow dropping between + // nested layers. Only reset this when dragging stops. + let pad = false + $: pad = pad || (interactive && hasChildren && inDndPath) + $: $dndIsDragging, (pad = false) + // Update component context $: store.set({ id, @@ -409,6 +425,11 @@ } const scrollIntoView = () => { + // Don't scroll into view if we selected this component because we were + // starting dragging on it + if (get(dndIsDragging)) { + return + } const node = document.getElementsByClassName(id)?.[0]?.children[0] if (!node) { return @@ -456,17 +477,20 @@ class:empty class:interactive class:editing + class:pad + class:parent={hasChildren} class:block={isBlock} data-id={id} data-name={name} data-icon={icon} + data-parent={parent} > {#if hasMissingRequiredSettings} {:else if children.length} {#each children as child (child._id)} - + {/each} {:else if emptyState} {#if isScreen} @@ -485,16 +509,14 @@ .component { display: contents; } - - .interactive :global(*:hover) { - cursor: pointer; + .component.pad :global(> *) { + padding: var(--spacing-l) !important; + gap: var(--spacing-l) !important; + border: 2px dashed var(--spectrum-global-color-gray-400) !important; + border-radius: 4px !important; + transition: padding 260ms ease-out, border 260ms ease-out; } - - .draggable :global(*:hover) { - cursor: grab; - } - - .editing :global(*:hover) { - cursor: auto; + .interactive :global(*) { + cursor: default; } diff --git a/packages/client/src/components/Screen.svelte b/packages/client/src/components/Screen.svelte index af895c6b82..0ce89f1ff4 100644 --- a/packages/client/src/components/Screen.svelte +++ b/packages/client/src/components/Screen.svelte @@ -1,5 +1,6 @@ diff --git a/packages/client/src/components/app/Placeholder.svelte b/packages/client/src/components/app/Placeholder.svelte index 203071e0b1..54553cb934 100644 --- a/packages/client/src/components/app/Placeholder.svelte +++ b/packages/client/src/components/app/Placeholder.svelte @@ -3,13 +3,14 @@ const { builderStore } = getContext("sdk") const component = getContext("component") + const block = getContext("block") export let text {#if $builderStore.inBuilder}
- {text || $component.name || "Placeholder"} + {text || block?.name || $component.name || "Placeholder"}
{/if} diff --git a/packages/client/src/components/app/blocks/CardsBlock.svelte b/packages/client/src/components/app/blocks/CardsBlock.svelte index a13364833a..9c110d7097 100644 --- a/packages/client/src/components/app/blocks/CardsBlock.svelte +++ b/packages/client/src/components/app/blocks/CardsBlock.svelte @@ -2,7 +2,6 @@ import { getContext } from "svelte" import Block from "components/Block.svelte" import BlockComponent from "components/BlockComponent.svelte" - import { Heading } from "@budibase/bbui" import { makePropSafe as safe } from "@budibase/string-templates" import { enrichSearchColumns, enrichFilter } from "utils/blocks.js" @@ -31,9 +30,7 @@ export let cardButtonOnClick export let linkColumn - const { fetchDatasourceSchema, styleable } = getContext("sdk") - const context = getContext("context") - const component = getContext("component") + const { fetchDatasourceSchema } = getContext("sdk") let formId let dataProviderId @@ -84,163 +81,132 @@ {#if schemaLoaded} -
- - {#if title || enrichedSearchColumns?.length || showTitleButton} -
-
- {title || ""} -
-
- {#if enrichedSearchColumns?.length} - - {/if} - {#if showTitleButton} - - {/if} -
-
- {/if} + + {#if title || enrichedSearchColumns?.length || showTitleButton} + - + {#if enrichedSearchColumns?.length} + {#each enrichedSearchColumns as column, idx} + + {/each} + {/if} + {#if showTitleButton} + + {/if} + {/if} + + + + -
+
{/if} - - diff --git a/packages/client/src/components/app/blocks/FormBlock.svelte b/packages/client/src/components/app/blocks/FormBlock.svelte new file mode 100644 index 0000000000..2d2d76a2b7 --- /dev/null +++ b/packages/client/src/components/app/blocks/FormBlock.svelte @@ -0,0 +1,247 @@ + + + + {#if fields?.length} + + + + + {#if renderHeader} + + + {#if renderButtons} + + {#if renderDeleteButton} + + {/if} + {#if renderSaveButton} + + {/if} + + {/if} + + {/if} + + {#each fields as field, idx} + {#if getComponentForField(field)} + + {/if} + {/each} + + + + + + {:else} + + {/if} + diff --git a/packages/client/src/components/app/blocks/RepeaterBlock.svelte b/packages/client/src/components/app/blocks/RepeaterBlock.svelte index 247a8b0d51..30fbdddcdc 100644 --- a/packages/client/src/components/app/blocks/RepeaterBlock.svelte +++ b/packages/client/src/components/app/blocks/RepeaterBlock.svelte @@ -17,45 +17,43 @@ export let vAlign export let gap - let providerId - const component = getContext("component") - const { styleable } = getContext("sdk") + + let providerId -
- - {#if $component.empty} - - {:else} - - - - {/if} - -
+ + {#if $component.empty} + + {:else} + + + + {/if} +
diff --git a/packages/client/src/components/app/blocks/TableBlock.svelte b/packages/client/src/components/app/blocks/TableBlock.svelte index e67124fc4f..f75a71a3ee 100644 --- a/packages/client/src/components/app/blocks/TableBlock.svelte +++ b/packages/client/src/components/app/blocks/TableBlock.svelte @@ -2,7 +2,6 @@ import { getContext } from "svelte" import Block from "components/Block.svelte" import BlockComponent from "components/BlockComponent.svelte" - import { Heading } from "@budibase/bbui" import { makePropSafe as safe } from "@budibase/string-templates" import { enrichSearchColumns, enrichFilter } from "utils/blocks.js" @@ -29,9 +28,7 @@ export let titleButtonURL export let titleButtonPeek - const { fetchDatasourceSchema, styleable } = getContext("sdk") - const context = getContext("context") - const component = getContext("component") + const { fetchDatasourceSchema } = getContext("sdk") let formId let dataProviderId @@ -64,145 +61,116 @@ {#if schemaLoaded} -
- - {#if title || enrichedSearchColumns?.length || showTitleButton} -
-
- {title || ""} -
-
- {#if enrichedSearchColumns?.length} - - {/if} - {#if showTitleButton} - - {/if} -
-
- {/if} + + {#if title || enrichedSearchColumns?.length || showTitleButton} + + {#if enrichedSearchColumns?.length} + {#each enrichedSearchColumns as column, idx} + + {/each} + {/if} + {#if showTitleButton} + + {/if} + + {/if} + + -
+
{/if} - - diff --git a/packages/client/src/components/app/blocks/index.js b/packages/client/src/components/app/blocks/index.js index db4de8fc13..32b2b98c06 100644 --- a/packages/client/src/components/app/blocks/index.js +++ b/packages/client/src/components/app/blocks/index.js @@ -1,3 +1,4 @@ export { default as tableblock } from "./TableBlock.svelte" export { default as cardsblock } from "./CardsBlock.svelte" export { default as repeaterblock } from "./RepeaterBlock.svelte" +export { default as formblock } from "./FormBlock.svelte" diff --git a/packages/client/src/components/app/charts/ApexOptionsBuilder.js b/packages/client/src/components/app/charts/ApexOptionsBuilder.js index 31c5a820f7..6b3e3a4440 100644 --- a/packages/client/src/components/app/charts/ApexOptionsBuilder.js +++ b/packages/client/src/components/app/charts/ApexOptionsBuilder.js @@ -1,37 +1,39 @@ export class ApexOptionsBuilder { - formatters = { - ["Default"]: val => (isNaN(val) ? val : Math.round(val * 100) / 100), - ["Thousands"]: val => `${Math.round(val / 1000)}K`, - ["Millions"]: val => `${Math.round(val / 1000000)}M`, - } - options = { - series: [], - legend: { - show: false, - position: "top", - horizontalAlign: "right", - showForSingleSeries: true, - showForNullSeries: true, - showForZeroSeries: true, - }, - chart: { - toolbar: { + constructor() { + this.formatters = { + ["Default"]: val => (isNaN(val) ? val : Math.round(val * 100) / 100), + ["Thousands"]: val => `${Math.round(val / 1000)}K`, + ["Millions"]: val => `${Math.round(val / 1000000)}M`, + } + this.options = { + series: [], + legend: { show: false, + position: "top", + horizontalAlign: "right", + showForSingleSeries: true, + showForNullSeries: true, + showForZeroSeries: true, }, - zoom: { - enabled: false, + chart: { + toolbar: { + show: false, + }, + zoom: { + enabled: false, + }, }, - }, - xaxis: { - labels: { - formatter: this.formatters.Default, + xaxis: { + labels: { + formatter: this.formatters.Default, + }, }, - }, - yaxis: { - labels: { - formatter: this.formatters.Default, + yaxis: { + labels: { + formatter: this.formatters.Default, + }, }, - }, + } } setOption(path, value) { diff --git a/packages/client/src/components/app/forms/CodeScanner.svelte b/packages/client/src/components/app/forms/CodeScanner.svelte new file mode 100644 index 0000000000..5dff3a96fa --- /dev/null +++ b/packages/client/src/components/app/forms/CodeScanner.svelte @@ -0,0 +1,234 @@ + + +
+ {#if value && !manualMode} +
+ + {value} +
+ {/if} + + {#if allowManualEntry && manualMode} +
+ { + dispatch("change", value) + }} + /> +
+ {/if} + + {#if value} + { + dispatch("change", "") + }} + {disabled} + > + Clear + + {:else} + { + showReaderModal() + }} + {disabled} + > + {scanButtonText} + + {/if} +
+ + + + diff --git a/packages/client/src/components/app/forms/CodeScannerField.svelte b/packages/client/src/components/app/forms/CodeScannerField.svelte new file mode 100644 index 0000000000..7e020aa9c7 --- /dev/null +++ b/packages/client/src/components/app/forms/CodeScannerField.svelte @@ -0,0 +1,47 @@ + + + + {#if fieldState} + + {/if} + diff --git a/packages/client/src/components/app/forms/Form.svelte b/packages/client/src/components/app/forms/Form.svelte index 26922ff312..8eddc11fa5 100644 --- a/packages/client/src/components/app/forms/Form.svelte +++ b/packages/client/src/components/app/forms/Form.svelte @@ -48,36 +48,7 @@ // Fetches the form schema from this form's dataSource const fetchSchema = async dataSource => { - if (!dataSource) { - schema = {} - } - - // If the datasource is a query, then we instead use a schema of the query - // parameters rather than the output schema - else if ( - dataSource.type === "query" && - dataSource._id && - actionType === "Create" - ) { - try { - const query = await API.fetchQueryDefinition(dataSource._id) - let paramSchema = {} - const params = query.parameters || [] - params.forEach(param => { - paramSchema[param.name] = { ...param, type: "string" } - }) - schema = paramSchema - } catch (error) { - schema = {} - } - } - - // For all other cases, just grab the normal schema - else { - const dataSourceSchema = await fetchDatasourceSchema(dataSource) - schema = dataSourceSchema || {} - } - + schema = (await fetchDatasourceSchema(dataSource)) || {} if (!loaded) { loaded = true } @@ -95,7 +66,7 @@ $: initialValues = getInitialValues(actionType, dataSource, $context) $: resetKey = Helpers.hashString( - JSON.stringify(initialValues) + JSON.stringify(schema) + JSON.stringify(initialValues) + JSON.stringify(schema) + disabled ) diff --git a/packages/client/src/components/app/forms/index.js b/packages/client/src/components/app/forms/index.js index 0ff82cea94..44c1516885 100644 --- a/packages/client/src/components/app/forms/index.js +++ b/packages/client/src/components/app/forms/index.js @@ -13,3 +13,4 @@ export { default as passwordfield } from "./PasswordField.svelte" export { default as formstep } from "./FormStep.svelte" export { default as jsonfield } from "./JSONField.svelte" export { default as s3upload } from "./S3Upload.svelte" +export { default as codescanner } from "./CodeScannerField.svelte" diff --git a/packages/client/src/components/preview/DNDHandler.svelte b/packages/client/src/components/preview/DNDHandler.svelte index 7a89b958ae..a9c52f0099 100644 --- a/packages/client/src/components/preview/DNDHandler.svelte +++ b/packages/client/src/components/preview/DNDHandler.svelte @@ -1,315 +1,298 @@ - - - +{#if $dndIsDragging} + +{/if} diff --git a/packages/client/src/components/preview/DNDPlaceholder.svelte b/packages/client/src/components/preview/DNDPlaceholder.svelte new file mode 100644 index 0000000000..3725f9e06e --- /dev/null +++ b/packages/client/src/components/preview/DNDPlaceholder.svelte @@ -0,0 +1,33 @@ + + +{#if style} +
+
+
+{/if} + + diff --git a/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte new file mode 100644 index 0000000000..6ed2df6a87 --- /dev/null +++ b/packages/client/src/components/preview/DNDPlaceholderOverlay.svelte @@ -0,0 +1,47 @@ + + +{#if left != null && top != null && width && height} +
+{/if} + + diff --git a/packages/client/src/components/preview/DNDPositionIndicator.svelte b/packages/client/src/components/preview/DNDPositionIndicator.svelte deleted file mode 100644 index 4af4674126..0000000000 --- a/packages/client/src/components/preview/DNDPositionIndicator.svelte +++ /dev/null @@ -1,66 +0,0 @@ - - -{#key renderKey} - {#if dimensions && dropInfo?.mode !== "inside"} - - {/if} -{/key} diff --git a/packages/client/src/components/preview/GridDNDHandler.svelte b/packages/client/src/components/preview/GridDNDHandler.svelte new file mode 100644 index 0000000000..a15292a604 --- /dev/null +++ b/packages/client/src/components/preview/GridDNDHandler.svelte @@ -0,0 +1,563 @@ + + + + +{#if $dndIsDragging} + +{/if} diff --git a/packages/client/src/components/preview/HoverIndicator.svelte b/packages/client/src/components/preview/HoverIndicator.svelte index 1a9e6477ac..d5583ed3db 100644 --- a/packages/client/src/components/preview/HoverIndicator.svelte +++ b/packages/client/src/components/preview/HoverIndicator.svelte @@ -1,7 +1,7 @@ - import { builderStore } from "stores" + import { builderStore, dndIsDragging } from "stores" import IndicatorSet from "./IndicatorSet.svelte" $: color = $builderStore.editMode @@ -8,7 +8,7 @@ { diff --git a/packages/client/src/constants.js b/packages/client/src/constants.js index 51ef3fd124..bd387c7f9d 100644 --- a/packages/client/src/constants.js +++ b/packages/client/src/constants.js @@ -1,5 +1,6 @@ export const FieldTypes = { STRING: "string", + BARCODEQR: "barcodeqr", LONGFORM: "longform", OPTIONS: "options", NUMBER: "number", @@ -29,3 +30,7 @@ export const ActionTypes = { ClearForm: "ClearForm", ChangeFormStep: "ChangeFormStep", } + +export const DNDPlaceholderID = "dnd-placeholder" +export const DNDPlaceholderType = "dnd-placeholder" +export const ScreenslotType = "screenslot" diff --git a/packages/client/src/index.js b/packages/client/src/index.js index a494b89225..8a622f911b 100644 --- a/packages/client/src/index.js +++ b/packages/client/src/index.js @@ -1,5 +1,13 @@ import ClientApp from "./components/ClientApp.svelte" -import { componentStore, builderStore, appStore, devToolsStore } from "./stores" +import { + builderStore, + appStore, + devToolsStore, + blockStore, + componentStore, + environmentStore, + dndStore, +} from "./stores" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-rollup.js" import { get } from "svelte/store" import { initWebsocket } from "./websocket.js" @@ -15,13 +23,14 @@ loadSpectrumIcons() let app -const loadBudibase = () => { +const loadBudibase = async () => { if (get(builderStore).clearGridNextLoad) { builderStore.actions.setDragging(false) } // Update builder store with any builder flags builderStore.set({ + ...get(builderStore), inBuilder: !!window["##BUDIBASE_IN_BUILDER##"], layout: window["##BUDIBASE_PREVIEW_LAYOUT##"], screen: window["##BUDIBASE_PREVIEW_SCREEN##"], @@ -42,11 +51,34 @@ const loadBudibase = () => { // server rendered app HTML appStore.actions.setAppId(window["##BUDIBASE_APP_ID##"]) + // Fetch environment info + await environmentStore.actions.fetchEnvironment() + // Enable dev tools or not. We need to be using a dev app and not inside // the builder preview to enable them. const enableDevTools = !get(builderStore).inBuilder && get(appStore).isDevApp devToolsStore.actions.setEnabled(enableDevTools) + // Register handler for runtime events from the builder + window.handleBuilderRuntimeEvent = (name, payload) => { + if (!window["##BUDIBASE_IN_BUILDER##"]) { + return + } + if (name === "eject-block") { + const block = blockStore.actions.getBlock(payload) + block?.eject() + } else if (name === "dragging-new-component") { + const { dragging, component } = payload + if (dragging) { + const definition = + componentStore.actions.getComponentDefinition(component) + dndStore.actions.startDraggingNewComponent({ component, definition }) + } else { + dndStore.actions.reset() + } + } + } + // Register any custom components if (window["##BUDIBASE_CUSTOM_COMPONENTS##"]) { window["##BUDIBASE_CUSTOM_COMPONENTS##"].forEach(component => { diff --git a/packages/client/src/licensing/constants.js b/packages/client/src/licensing/constants.js deleted file mode 100644 index 57454bc37a..0000000000 --- a/packages/client/src/licensing/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -export const PlanType = { - FREE: "free", - PRO: "pro", - TEAM: "team", - BUSINESS: "business", - ENTERPRISE: "enterprise", -} diff --git a/packages/client/src/licensing/utils.js b/packages/client/src/licensing/utils.js index efe1839ecb..effed6867f 100644 --- a/packages/client/src/licensing/utils.js +++ b/packages/client/src/licensing/utils.js @@ -1,6 +1,6 @@ import { authStore } from "../stores/auth.js" import { get } from "svelte/store" -import { PlanType } from "./constants" +import { Constants } from "@budibase/frontend-core" const getLicense = () => { const user = get(authStore) @@ -12,7 +12,7 @@ const getLicense = () => { export const isFreePlan = () => { const license = getLicense() if (license) { - return license.plan.type === PlanType.FREE + return license.plan.type === Constants.PlanType.FREE } else { // safety net - no license means free plan return true diff --git a/packages/client/src/stores/blocks.js b/packages/client/src/stores/blocks.js new file mode 100644 index 0000000000..98381ec79b --- /dev/null +++ b/packages/client/src/stores/blocks.js @@ -0,0 +1,34 @@ +import { get, writable } from "svelte/store" + +const createBlockStore = () => { + const store = writable({}) + + const registerBlock = (id, instance) => { + store.update(state => ({ + ...state, + [id]: instance, + })) + } + + const unregisterBlock = id => { + store.update(state => { + delete state[id] + return state + }) + } + + const getBlock = id => { + return get(store)[id] + } + + return { + subscribe: store.subscribe, + actions: { + registerBlock, + unregisterBlock, + getBlock, + }, + } +} + +export const blockStore = createBlockStore() diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index 356805e503..176b8942fc 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -16,11 +16,8 @@ const createBuilderStore = () => { theme: null, customTheme: null, previewDevice: "desktop", - isDragging: false, navigation: null, hiddenComponentIds: [], - gridStyles: null, - clearGridNextLoad: false, usedPlugins: null, // Legacy - allow the builder to specify a layout @@ -52,6 +49,9 @@ const createBuilderStore = () => { duplicateComponent: id => { dispatchEvent("duplicate-component", { id }) }, + deleteComponent: id => { + dispatchEvent("delete-component", { id }) + }, notifyLoaded: () => { dispatchEvent("preview-loaded") }, @@ -69,16 +69,12 @@ const createBuilderStore = () => { mode, }) }, - setDragging: dragging => { - if (dragging === get(store).isDragging) { - return - } - store.update(state => ({ - ...state, - isDragging: dragging, - gridStyles: null, - clearGridNextLoad: false, - })) + dropNewComponent: (component, parent, index) => { + dispatchEvent("drop-new-component", { + component, + parent, + index, + }) }, setEditMode: enabled => { if (enabled === get(store).editMode) { @@ -95,17 +91,8 @@ const createBuilderStore = () => { highlightSetting: setting => { dispatchEvent("highlight-setting", { setting }) }, - setGridStyles: styles => { - store.update(state => { - state.gridStyles = styles - return state - }) - }, - clearGridNextLoad: () => { - store.update(state => { - state.clearGridNextLoad = true - return state - }) + ejectBlock: (id, definition) => { + dispatchEvent("eject-block", { id, definition }) }, updateUsedPlugin: (name, hash) => { // Check if we used this plugin @@ -120,6 +107,9 @@ const createBuilderStore = () => { return state }) } + + // Notify the builder so we can reload component definitions + dispatchEvent("reload-plugin") }, } return { diff --git a/packages/client/src/stores/components.js b/packages/client/src/stores/components.js index 98edfaddae..b34dfe375d 100644 --- a/packages/client/src/stores/components.js +++ b/packages/client/src/stores/components.js @@ -5,7 +5,9 @@ import { devToolsStore } from "./devTools" import { screenStore } from "./screens" import { builderStore } from "./builder" import Router from "../components/Router.svelte" +import DNDPlaceholder from "../components/preview/DNDPlaceholder.svelte" import * as AppComponents from "../components/app/index.js" +import { DNDPlaceholderType, ScreenslotType } from "../constants.js" const budibasePrefix = "@budibase/standard-components/" @@ -18,26 +20,21 @@ const createComponentStore = () => { const derivedStore = derived( [store, builderStore, devToolsStore, screenStore], - ([$store, $builderState, $devToolsState, $screenState]) => { + ([$store, $builderStore, $devToolsStore, $screenStore]) => { + const { inBuilder, selectedComponentId } = $builderStore + // Avoid any of this logic if we aren't in the builder preview - if (!$builderState.inBuilder && !$devToolsState.visible) { + if (!inBuilder && !$devToolsStore.visible) { return {} } - // Derive the selected component instance and definition - let asset - const { screen, selectedComponentId } = $builderState - if ($builderState.inBuilder) { - asset = screen - } else { - asset = $screenState.activeScreen - } - const component = findComponentById(asset?.props, selectedComponentId) + const root = $screenStore.activeScreen?.props + const component = findComponentById(root, selectedComponentId) const definition = getComponentDefinition(component?._component) // Derive the selected component path - const path = - findComponentPathById(asset?.props, selectedComponentId) || [] + const selectedPath = + findComponentPathById(root, selectedComponentId) || [] return { customComponentManifest: $store.customComponentManifest, @@ -45,9 +42,8 @@ const createComponentStore = () => { $store.mountedComponents[selectedComponentId], selectedComponent: component, selectedComponentDefinition: definition, - selectedComponentPath: path?.map(component => component._id), + selectedComponentPath: selectedPath?.map(component => component._id), mountedComponentCount: Object.keys($store.mountedComponents).length, - currentAsset: asset, } } ) @@ -95,8 +91,8 @@ const createComponentStore = () => { } const getComponentById = id => { - const asset = get(derivedStore).currentAsset - return findComponentById(asset?.props, id) + const root = get(screenStore).activeScreen?.props + return findComponentById(root, id) } const getComponentDefinition = type => { @@ -105,8 +101,10 @@ const createComponentStore = () => { } // Screenslot is an edge case - if (type === "screenslot") { + if (type === ScreenslotType) { type = `${budibasePrefix}${type}` + } else if (type === DNDPlaceholderType) { + return {} } // Handle built-in components @@ -124,8 +122,10 @@ const createComponentStore = () => { if (!type) { return null } - if (type === "screenslot") { + if (type === ScreenslotType) { return Router + } else if (type === DNDPlaceholderType) { + return DNDPlaceholder } // Handle budibase components diff --git a/packages/client/src/stores/derived/currentRole.js b/packages/client/src/stores/derived/currentRole.js new file mode 100644 index 0000000000..28287e1ea4 --- /dev/null +++ b/packages/client/src/stores/derived/currentRole.js @@ -0,0 +1,11 @@ +import { derived } from "svelte/store" +import { devToolsStore } from "../devTools.js" +import { authStore } from "../auth.js" + +// Derive the current role of the logged-in user +export const currentRole = derived( + [devToolsStore, authStore], + ([$devToolsStore, $authStore]) => { + return ($devToolsStore.enabled && $devToolsStore.role) || $authStore?.roleId + } +) diff --git a/packages/client/src/stores/derived/dndComponentPath.js b/packages/client/src/stores/derived/dndComponentPath.js new file mode 100644 index 0000000000..58fb395dd6 --- /dev/null +++ b/packages/client/src/stores/derived/dndComponentPath.js @@ -0,0 +1,13 @@ +import { derived } from "svelte/store" +import { findComponentPathById } from "utils/components.js" +import { dndParent } from "../dnd.js" +import { screenStore } from "../screens.js" + +export const dndComponentPath = derived( + [dndParent, screenStore], + ([$dndParent, $screenStore]) => { + const root = $screenStore.activeScreen?.props + const path = findComponentPathById(root, $dndParent) || [] + return path?.map(component => component._id) + } +) diff --git a/packages/client/src/stores/derived/index.js b/packages/client/src/stores/derived/index.js new file mode 100644 index 0000000000..4f6a6ab91d --- /dev/null +++ b/packages/client/src/stores/derived/index.js @@ -0,0 +1,5 @@ +// These derived stores are pulled out from their parent stores to avoid +// dependency loops. By inverting store dependencies and extracting them +// separately we can keep our actual stores lean and performant. +export { currentRole } from "./currentRole.js" +export { dndComponentPath } from "./dndComponentPath.js" diff --git a/packages/client/src/stores/dnd.js b/packages/client/src/stores/dnd.js new file mode 100644 index 0000000000..12a77df3c5 --- /dev/null +++ b/packages/client/src/stores/dnd.js @@ -0,0 +1,101 @@ +import { writable, derived } from "svelte/store" + +const createDndStore = () => { + const initialState = { + // Info about the dragged component + source: null, + + // Info about the target component being hovered over + target: null, + + // Info about where the component would be dropped + drop: null, + + // Grid info + gridStyles: null, + } + const store = writable(initialState) + + const startDraggingExistingComponent = ({ id, parent, bounds, index }) => { + store.set({ + ...initialState, + source: { id, parent, bounds, index }, + }) + } + + const startDraggingNewComponent = ({ component, definition }) => { + if (!component) { + return + } + + // Get size of new component so we can show a properly sized placeholder + const width = definition?.size?.width || 128 + const height = definition?.size?.height || 64 + + store.set({ + ...initialState, + source: { + id: null, + parent: null, + bounds: { height, width }, + index: null, + newComponentType: component, + }, + }) + } + + const updateTarget = ({ id, parent, node, empty, acceptsChildren }) => { + store.update(state => { + state.target = { id, parent, node, empty, acceptsChildren } + return state + }) + } + + const updateDrop = ({ parent, index }) => { + store.update(state => { + state.drop = { parent, index } + return state + }) + } + + const reset = () => { + store.set(initialState) + } + + const setGridStyles = styles => { + store.update(state => { + state.gridStyles = styles + return state + }) + } + + return { + subscribe: store.subscribe, + actions: { + startDraggingExistingComponent, + startDraggingNewComponent, + updateTarget, + updateDrop, + reset, + setGridStyles, + }, + } +} + +export const dndStore = createDndStore() + +// The DND store is updated extremely frequently, so we can greatly improve +// performance by deriving any state that needs to be externally observed. +// By doing this and using primitives, we can avoid invalidating other stores +// or components which depend on DND state unless values actually change. +export const dndIsDragging = derived(dndStore, $dndStore => !!$dndStore.source) +export const dndParent = derived(dndStore, $dndStore => $dndStore.drop?.parent) +export const dndIndex = derived(dndStore, $dndStore => $dndStore.drop?.index) +export const dndBounds = derived( + dndStore, + $dndStore => $dndStore.source?.bounds +) +export const dndIsNewComponent = derived( + dndStore, + $dndStore => $dndStore.source?.newComponentType != null +) diff --git a/packages/client/src/stores/index.js b/packages/client/src/stores/index.js index 378d3febd2..c431302d43 100644 --- a/packages/client/src/stores/index.js +++ b/packages/client/src/stores/index.js @@ -1,7 +1,3 @@ -import { derived } from "svelte/store" -import { devToolsStore } from "./devTools.js" -import { authStore } from "./auth.js" - export { authStore } from "./auth" export { appStore } from "./app" export { notificationStore } from "./notification" @@ -17,7 +13,16 @@ export { devToolsStore } from "./devTools" export { componentStore } from "./components" export { uploadStore } from "./uploads.js" export { rowSelectionStore } from "./rowSelection.js" +export { blockStore } from "./blocks.js" export { environmentStore } from "./environment" +export { + dndStore, + dndIndex, + dndParent, + dndBounds, + dndIsNewComponent, + dndIsDragging, +} from "./dnd" // Context stores are layered and duplicated, so it is not a singleton export { createContextStore } from "./context" @@ -25,10 +30,5 @@ export { createContextStore } from "./context" // Initialises an app by loading screens and routes export { initialise } from "./initialise" -// Derive the current role of the logged-in user -export const currentRole = derived( - [devToolsStore, authStore], - ([$devToolsStore, $authStore]) => { - return ($devToolsStore.enabled && $devToolsStore.role) || $authStore?.roleId - } -) +// Derived state +export * from "./derived" diff --git a/packages/client/src/stores/initialise.js b/packages/client/src/stores/initialise.js index 4ad85dfd40..1900e62ce1 100644 --- a/packages/client/src/stores/initialise.js +++ b/packages/client/src/stores/initialise.js @@ -1,9 +1,7 @@ import { routeStore } from "./routes" import { appStore } from "./app" -import { environmentStore } from "./environment" export async function initialise() { await routeStore.actions.fetchRoutes() await appStore.actions.fetchAppDefinition() - await environmentStore.actions.fetchEnvironment() } diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js index 84cd4000c1..0787610d80 100644 --- a/packages/client/src/stores/screens.js +++ b/packages/client/src/stores/screens.js @@ -2,18 +2,36 @@ import { derived } from "svelte/store" import { routeStore } from "./routes" import { builderStore } from "./builder" import { appStore } from "./app" +import { dndIndex, dndParent, dndIsNewComponent } from "./dnd.js" import { RoleUtils } from "@budibase/frontend-core" +import { findComponentById, findComponentParent } from "../utils/components.js" +import { Helpers } from "@budibase/bbui" +import { DNDPlaceholderID, DNDPlaceholderType } from "constants" const createScreenStore = () => { const store = derived( - [appStore, routeStore, builderStore], - ([$appStore, $routeStore, $builderStore]) => { + [ + appStore, + routeStore, + builderStore, + dndParent, + dndIndex, + dndIsNewComponent, + ], + ([ + $appStore, + $routeStore, + $builderStore, + $dndParent, + $dndIndex, + $dndIsNewComponent, + ]) => { let activeLayout, activeScreen let screens if ($builderStore.inBuilder) { // Use builder defined definitions if inside the builder preview - activeScreen = $builderStore.screen + activeScreen = Helpers.cloneDeep($builderStore.screen) screens = [activeScreen] // Legacy - allow the builder to specify a layout @@ -24,8 +42,10 @@ const createScreenStore = () => { // Find the correct screen by matching the current route screens = $appStore.screens || [] if ($routeStore.activeRoute) { - activeScreen = screens.find( - screen => screen._id === $routeStore.activeRoute.screenId + activeScreen = Helpers.cloneDeep( + screens.find( + screen => screen._id === $routeStore.activeRoute.screenId + ) ) } @@ -40,6 +60,37 @@ const createScreenStore = () => { } } + // Insert DND placeholder if required + if (activeScreen && $dndParent && $dndIndex != null) { + // Remove selected component from tree if we are moving an existing + // component + const { selectedComponentId } = $builderStore + if (!$dndIsNewComponent) { + let selectedParent = findComponentParent( + activeScreen.props, + selectedComponentId + ) + if (selectedParent) { + selectedParent._children = selectedParent._children?.filter( + x => x._id !== selectedComponentId + ) + } + } + + // Insert placeholder component + const placeholder = { + _component: DNDPlaceholderID, + _id: DNDPlaceholderType, + static: true, + } + let parent = findComponentById(activeScreen.props, $dndParent) + if (!parent._children?.length) { + parent._children = [placeholder] + } else { + parent._children.splice($dndIndex, 0, placeholder) + } + } + // Assign ranks to screens, preferring higher roles and home screens screens.forEach(screen => { const roleId = screen.routing.roleId diff --git a/packages/client/src/utils/components.js b/packages/client/src/utils/components.js index 4b1b8a7ada..1812175c2c 100644 --- a/packages/client/src/utils/components.js +++ b/packages/client/src/utils/components.js @@ -60,3 +60,25 @@ export const findChildrenByType = (component, type, children = []) => { findChildrenByType(child, type, children) }) } + +/** + * Recursively searches for the parent component of a specific component ID + */ +export const findComponentParent = (rootComponent, id, parentComponent) => { + if (!rootComponent || !id) { + return null + } + if (rootComponent._id === id) { + return parentComponent + } + if (!rootComponent._children) { + return null + } + for (const child of rootComponent._children) { + const childResult = findComponentParent(child, id, rootComponent) + if (childResult) { + return childResult + } + } + return null +} diff --git a/packages/client/src/utils/schema.js b/packages/client/src/utils/schema.js index 46c352a29f..433a1c5fee 100644 --- a/packages/client/src/utils/schema.js +++ b/packages/client/src/utils/schema.js @@ -16,7 +16,7 @@ import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js" */ export const fetchDatasourceSchema = async ( datasource, - options = { enrichRelationships: false } + options = { enrichRelationships: false, formSchema: false } ) => { const handler = { table: TableFetch, @@ -34,7 +34,17 @@ export const fetchDatasourceSchema = async ( // Get the datasource definition and then schema const definition = await instance.getDefinition(datasource) - let schema = instance.getSchema(datasource, definition) + + // Get the normal schema as long as we aren't wanting a form schema + let schema + if (datasource?.type !== "query" || !options?.formSchema) { + schema = instance.getSchema(datasource, definition) + } else if (definition.parameters?.length) { + schema = {} + definition.parameters.forEach(param => { + schema[param.name] = { ...param, type: "string" } + }) + } if (!schema) { return null } diff --git a/packages/client/src/utils/styleable.js b/packages/client/src/utils/styleable.js index 5cbe74bb68..9ad17ceff0 100644 --- a/packages/client/src/utils/styleable.js +++ b/packages/client/src/utils/styleable.js @@ -27,7 +27,7 @@ export const styleable = (node, styles = {}) => { const setupStyles = (newStyles = {}) => { let baseStyles = {} if (newStyles.empty) { - // baseStyles.border = "2px dashed var(--spectrum-global-color-gray-600)" + baseStyles.border = "2px dashed var(--spectrum-global-color-gray-400)" baseStyles.padding = "var(--spacing-l)" baseStyles.overflow = "hidden" } diff --git a/packages/client/src/websocket.js b/packages/client/src/websocket.js index 827453fad6..0a99fa8606 100644 --- a/packages/client/src/websocket.js +++ b/packages/client/src/websocket.js @@ -1,13 +1,19 @@ -import { builderStore, environmentStore } from "./stores/index.js" +import { + builderStore, + environmentStore, + notificationStore, +} from "./stores/index.js" import { get } from "svelte/store" import { io } from "socket.io-client" +let socket + export const initWebsocket = () => { const { inBuilder, location } = get(builderStore) const { cloud } = get(environmentStore) // Only connect when we're inside the builder preview, for now - if (!inBuilder || !location || cloud) { + if (!inBuilder || !location || cloud || socket) { return } @@ -16,20 +22,20 @@ export const initWebsocket = () => { const proto = tls ? "wss:" : "ws:" const host = location.hostname const port = location.port || (tls ? 443 : 80) - const socket = io(`${proto}//${host}:${port}`, { + socket = io(`${proto}//${host}:${port}`, { path: "/socket/client", - // Cap reconnection attempts to 10 (total of 95 seconds before giving up) - reconnectionAttempts: 10, - // Delay initial reconnection attempt by 5 seconds + // Cap reconnection attempts to 3 (total of 15 seconds before giving up) + reconnectionAttempts: 3, + // Delay reconnection attempt by 5 seconds reconnectionDelay: 5000, - // Then decrease to 10 second intervals - reconnectionDelayMax: 10000, - // Timeout after 5 seconds so we never stack requests - timeout: 5000, + reconnectionDelayMax: 5000, + // Timeout after 4 seconds so we never stack requests + timeout: 4000, }) // Event handlers socket.on("plugin-update", data => { builderStore.actions.updateUsedPlugin(data.name, data.hash) + notificationStore.actions.info(`"${data.name}" plugin reloaded`) }) } diff --git a/packages/frontend-core/package.json b/packages/frontend-core/package.json index f2d6741b75..50f28f646e 100644 --- a/packages/frontend-core/package.json +++ b/packages/frontend-core/package.json @@ -1,12 +1,12 @@ { "name": "@budibase/frontend-core", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "description": "Budibase frontend core libraries used in builder and client", "author": "Budibase", "license": "MPL-2.0", "svelte": "src/index.js", "dependencies": { - "@budibase/bbui": "1.4.18-alpha.1", + "@budibase/bbui": "2.0.30-alpha.7", "lodash": "^4.17.21", "svelte": "^3.46.2" } diff --git a/packages/frontend-core/src/api/roles.js b/packages/frontend-core/src/api/roles.js index 15c27091c4..0b8a866a00 100644 --- a/packages/frontend-core/src/api/roles.js +++ b/packages/frontend-core/src/api/roles.js @@ -29,4 +29,13 @@ export const buildRoleEndpoints = API => ({ url: "/api/roles", }) }, + + /** + * Gets a list of roles within a specified app. + */ + getRolesForApp: async appId => { + return await API.get({ + url: `/api/global/roles/${appId}`, + }) + }, }) diff --git a/packages/frontend-core/src/api/rows.js b/packages/frontend-core/src/api/rows.js index 70030d7f80..3734310eed 100644 --- a/packages/frontend-core/src/api/rows.js +++ b/packages/frontend-core/src/api/rows.js @@ -8,10 +8,9 @@ export const buildRowEndpoints = API => ({ if (!tableId || !rowId) { return null } - const row = await API.get({ + return await API.get({ url: `/api/${tableId}/rows/${rowId}`, }) - return (await API.enrichRows([row], tableId))[0] }, /** diff --git a/packages/frontend-core/src/constants.js b/packages/frontend-core/src/constants.js index 633534dddb..9a5acf8a9b 100644 --- a/packages/frontend-core/src/constants.js +++ b/packages/frontend-core/src/constants.js @@ -40,7 +40,7 @@ export const OperatorOptions = { }, NotContains: { value: "notContains", - label: "Does Not Contain", + label: "Does not contain", }, In: { value: "oneOf", @@ -98,6 +98,7 @@ export const BuilderRoleDescriptions = [ export const PlanType = { FREE: "free", TEAM: "team", + PRO: "pro", BUSINESS: "business", ENTERPRISE: "enterprise", } diff --git a/packages/frontend-core/src/fetch/DataFetch.js b/packages/frontend-core/src/fetch/DataFetch.js index a3cc1c231c..31007121f1 100644 --- a/packages/frontend-core/src/fetch/DataFetch.js +++ b/packages/frontend-core/src/fetch/DataFetch.js @@ -14,52 +14,52 @@ import { convertJSONSchemaToTableSchema } from "../utils/json" * For other types of datasource, this class is overridden and extended. */ export default class DataFetch { - // API client - API = null - - // Feature flags - featureStore = writable({ - supportsSearch: false, - supportsSort: false, - supportsPagination: false, - }) - - // Config - options = { - datasource: null, - limit: 10, - - // Search config - filter: null, - query: null, - - // Sorting config - sortColumn: null, - sortOrder: "ascending", - sortType: null, - - // Pagination config - paginate: true, - } - - // State of the fetch - store = writable({ - rows: [], - info: null, - schema: null, - loading: false, - loaded: false, - query: null, - pageNumber: 0, - cursor: null, - cursors: [], - }) - /** * Constructs a new DataFetch instance. * @param opts the fetch options */ constructor(opts) { + // API client + this.API = null + + // Feature flags + this.featureStore = writable({ + supportsSearch: false, + supportsSort: false, + supportsPagination: false, + }) + + // Config + this.options = { + datasource: null, + limit: 10, + + // Search config + filter: null, + query: null, + + // Sorting config + sortColumn: null, + sortOrder: "ascending", + sortType: null, + + // Pagination config + paginate: true, + } + + // State of the fetch + this.store = writable({ + rows: [], + info: null, + schema: null, + loading: false, + loaded: false, + query: null, + pageNumber: 0, + cursor: null, + cursors: [], + }) + // Merge options with their default values this.API = opts?.API this.options = { diff --git a/packages/frontend-core/src/utils/lucene.js b/packages/frontend-core/src/utils/lucene.js index 1221e20664..774ddbd834 100644 --- a/packages/frontend-core/src/utils/lucene.js +++ b/packages/frontend-core/src/utils/lucene.js @@ -121,7 +121,12 @@ export const buildLuceneQuery = filter => { query.allOr = true return } - if (type === "datetime" && !isHbs) { + if ( + type === "datetime" && + !isHbs && + operator !== "empty" && + operator !== "notEmpty" + ) { // Ensure date value is a valid date and parse into correct format if (!value) { return diff --git a/packages/frontend-core/src/utils/utils.js b/packages/frontend-core/src/utils/utils.js index 587d057351..8aa49392fb 100644 --- a/packages/frontend-core/src/utils/utils.js +++ b/packages/frontend-core/src/utils/utils.js @@ -40,3 +40,37 @@ export const debounce = (callback, minDelay = 1000) => { }) } } + +/** + * Utility to throttle invocations of a synchronous function. This is better + * than a simple debounce invocation for a number of reasons. Features include: + * - First invocation is immediate (no initial delay) + * - Every invocation has the latest params (no stale params) + * - There will always be a final invocation with the last params (no missing + * final update) + * @param callback + * @param minDelay + * @returns {Function} a throttled version function + */ +export const throttle = (callback, minDelay = 1000) => { + let lastParams + let stalled = false + let pending = false + const invoke = (...params) => { + lastParams = params + if (stalled) { + pending = true + return + } + callback(...lastParams) + stalled = true + setTimeout(() => { + stalled = false + if (pending) { + pending = false + invoke(...lastParams) + } + }, minDelay) + } + return invoke +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 027d06d0ab..f31347f878 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/sdk", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "description": "Budibase Public API SDK", "author": "Budibase", "license": "MPL-2.0", diff --git a/packages/server/__mocks__/mongodb.ts b/packages/server/__mocks__/mongodb.ts index 4a1867f6f9..01b6e76fc4 100644 --- a/packages/server/__mocks__/mongodb.ts +++ b/packages/server/__mocks__/mongodb.ts @@ -33,7 +33,7 @@ module MongoMock { }) } - mongodb.ObjectID = jest.requireActual("mongodb").ObjectID + mongodb.ObjectId = jest.requireActual("mongodb").ObjectId module.exports = mongodb } diff --git a/packages/server/package.json b/packages/server/package.json index 5b33b111d1..78791885cf 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/server", "email": "hi@budibase.com", - "version": "1.4.18-alpha.1", + "version": "2.0.30-alpha.7", "description": "Budibase Web Server", "main": "src/index.ts", "repository": { @@ -77,11 +77,11 @@ "license": "GPL-3.0", "dependencies": { "@apidevtools/swagger-parser": "10.0.3", - "@budibase/backend-core": "1.4.18-alpha.1", - "@budibase/client": "1.4.18-alpha.1", - "@budibase/pro": "1.4.18-alpha.1", - "@budibase/string-templates": "1.4.18-alpha.1", - "@budibase/types": "1.4.18-alpha.1", + "@budibase/backend-core": "2.0.30-alpha.7", + "@budibase/client": "2.0.30-alpha.7", + "@budibase/pro": "2.0.30-alpha.7", + "@budibase/string-templates": "2.0.30-alpha.7", + "@budibase/types": "2.0.30-alpha.7", "@bull-board/api": "3.7.0", "@bull-board/koa": "3.9.4", "@elastic/elasticsearch": "7.10.0", @@ -123,7 +123,7 @@ "koa2-ratelimit": "1.1.1", "lodash": "4.17.21", "memorystream": "0.3.1", - "mongodb": "3.6.3", + "mongodb": "4.9", "mssql": "6.2.3", "mysql2": "2.3.3", "node-fetch": "2.6.7", @@ -146,7 +146,7 @@ "to-json-schema": "0.2.5", "uuid": "3.3.2", "validate.js": "0.13.1", - "vm2": "3.9.6", + "vm2": "3.9.11", "worker-farm": "1.7.0", "xml2js": "0.4.23", "yargs": "13.2.4", @@ -166,7 +166,6 @@ "@types/koa": "2.13.4", "@types/koa__router": "8.0.0", "@types/lodash": "4.14.180", - "@types/mongodb": "3.6.3", "@types/node": "14.18.20", "@types/node-fetch": "2.6.1", "@types/oracledb": "5.2.2", diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 6771f7e4e0..a7caf85e94 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -32,7 +32,7 @@ const { import { USERS_TABLE_SCHEMA } from "../../constants" import { removeAppFromUserRoles } from "../../utilities/workerRequests" import { clientLibraryPath, stringToReadStream } from "../../utilities" -import { getAllLocks } from "../../utilities/redis" +import { getLocksById } from "../../utilities/redis" import { updateClientLibrary, backupClientLibrary, @@ -45,11 +45,11 @@ import { cleanupAutomations } from "../../automations/utils" import { context } from "@budibase/backend-core" import { checkAppMetadata } from "../../automations/logging" import { getUniqueRows } from "../../utilities/usageQuota/rows" -import { quotas } from "@budibase/pro" +import { quotas, groups } from "@budibase/pro" import { errors, events, migrations } from "@budibase/backend-core" import { App, Layout, Screen, MigrationType } from "@budibase/types" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" -import { groups } from "@budibase/pro" +import { enrichPluginURLs } from "../../utilities/plugins" const URL_REGEX_SLASH = /\/|\\/g @@ -171,16 +171,16 @@ export const fetch = async (ctx: any) => { const all = ctx.query && ctx.query.status === AppStatus.ALL const apps = await getAllApps({ dev, all }) + const appIds = apps + .filter((app: any) => app.status === "development") + .map((app: any) => app.appId) // get the locks for all the dev apps if (dev || all) { - const locks = await getAllLocks() + const locks = await getLocksById(appIds) for (let app of apps) { - if (app.status !== "development") { - continue - } - const lock = locks.find((lock: any) => lock.appId === app.appId) + const lock = locks[app.appId] if (lock) { - app.lockedBy = lock.user + app.lockedBy = lock } else { // make sure its definitely not present delete app.lockedBy @@ -208,10 +208,13 @@ export const fetchAppDefinition = async (ctx: any) => { export const fetchAppPackage = async (ctx: any) => { const db = context.getAppDB() - const application = await db.get(DocumentType.APP_METADATA) + let application = await db.get(DocumentType.APP_METADATA) const layouts = await getLayouts() let screens = await getScreens() + // Enrich plugin URLs + application.usedPlugins = enrichPluginURLs(application.usedPlugins) + // Only filter screens if the user is not a builder if (!(ctx.user.builder && ctx.user.builder.global)) { const userRoleId = getUserRoleId(ctx) @@ -356,7 +359,7 @@ const appPostCreate = async (ctx: any, app: App) => { await creationEvents(ctx.request, app) // app import & template creation if (ctx.request.body.useTemplate === "true") { - const rows = await getUniqueRows([app.appId]) + const { rows } = await getUniqueRows([app.appId]) const rowCount = rows ? rows.length : 0 if (rowCount) { try { @@ -490,7 +493,7 @@ const destroyApp = async (ctx: any) => { } const preDestroyApp = async (ctx: any) => { - const rows = await getUniqueRows([ctx.params.appId]) + const { rows } = await getUniqueRows([ctx.params.appId]) ctx.rowCount = rows.length } diff --git a/packages/server/src/api/controllers/datasource.js b/packages/server/src/api/controllers/datasource.js index 4fafaa546c..af52be8e26 100644 --- a/packages/server/src/api/controllers/datasource.js +++ b/packages/server/src/api/controllers/datasource.js @@ -68,6 +68,7 @@ exports.buildSchemaFromDb = async function (ctx) { datasource.entities = tables } + setDefaultDisplayColumns(datasource) const dbResp = await db.put(datasource) datasource._rev = dbResp.rev @@ -78,6 +79,24 @@ exports.buildSchemaFromDb = async function (ctx) { ctx.body = response } +/** + * Make sure all datasource entities have a display name selected + */ +const setDefaultDisplayColumns = datasource => { + // + for (let entity of Object.values(datasource.entities)) { + if (entity.primaryDisplay) { + continue + } + const notAutoColumn = Object.values(entity.schema).find( + schema => !schema.autocolumn + ) + if (notAutoColumn) { + entity.primaryDisplay = notAutoColumn.name + } + } +} + /** * Check for variables that have been updated or removed and invalidate them. */ @@ -155,6 +174,7 @@ exports.save = async function (ctx) { const { tables, error } = await buildSchemaHelper(datasource) schemaError = error datasource.entities = tables + setDefaultDisplayColumns(datasource) } const dbResp = await db.put(datasource) @@ -238,19 +258,6 @@ const buildSchemaHelper = async datasource => { const connector = new Connector(datasource.config) await connector.buildSchema(datasource._id, datasource.entities) - // make sure they all have a display name selected - for (let entity of Object.values(datasource.entities ?? {})) { - if (entity.primaryDisplay) { - continue - } - const notAutoColumn = Object.values(entity.schema).find( - schema => !schema.autocolumn - ) - if (notAutoColumn) { - entity.primaryDisplay = notAutoColumn.name - } - } - const errors = connector.schemaErrors let error = null if (errors && Object.keys(errors).length > 0) { diff --git a/packages/server/src/api/controllers/deploy/index.ts b/packages/server/src/api/controllers/deploy/index.ts index 5edf862706..a51e7ad6ec 100644 --- a/packages/server/src/api/controllers/deploy/index.ts +++ b/packages/server/src/api/controllers/deploy/index.ts @@ -17,7 +17,6 @@ import { getProdAppDB, getDevAppDB, } from "@budibase/backend-core/context" -import { quotas } from "@budibase/pro" import { events } from "@budibase/backend-core" // the max time we can wait for an invalidation to complete before considering it failed diff --git a/packages/server/src/api/controllers/dev.js b/packages/server/src/api/controllers/dev.js index 8438175ca8..c8f134756b 100644 --- a/packages/server/src/api/controllers/dev.js +++ b/packages/server/src/api/controllers/dev.js @@ -103,7 +103,7 @@ exports.revert = async ctx => { target: appId, }) try { - if (!env.isTest()) { + if (env.COUCH_DB_URL) { // in-memory db stalls on rollback await replication.rollback() } diff --git a/packages/server/src/api/controllers/integration.js b/packages/server/src/api/controllers/integration.js index 3d1643601b..2f11ec19ed 100644 --- a/packages/server/src/api/controllers/integration.js +++ b/packages/server/src/api/controllers/integration.js @@ -1,17 +1,9 @@ const { getDefinitions } = require("../../integrations") -const { SourceName } = require("@budibase/types") -const googlesheets = require("../../integrations/googlesheets") -const { featureFlags } = require("@budibase/backend-core") exports.fetch = async function (ctx) { ctx.status = 200 const defs = await getDefinitions() - // for google sheets integration google verification - if (featureFlags.isEnabled(featureFlags.TenantFeatureFlag.GOOGLE_SHEETS)) { - defs[SourceName.GOOGLE_SHEETS] = googlesheets.schema - } - ctx.body = defs } diff --git a/packages/server/src/api/controllers/public/rows.ts b/packages/server/src/api/controllers/public/rows.ts index 4daccd9542..67059ec2f5 100644 --- a/packages/server/src/api/controllers/public/rows.ts +++ b/packages/server/src/api/controllers/public/rows.ts @@ -52,14 +52,19 @@ export async function read(ctx: any, next: any) { } export async function update(ctx: any, next: any) { - ctx.request.body = await addRev(fixRow(ctx.request.body, ctx.params)) + const { tableId } = ctx.params + ctx.request.body = await addRev(fixRow(ctx.request.body, ctx.params), tableId) await rowController.save(ctx) await next() } export async function destroy(ctx: any, next: any) { + const { tableId } = ctx.params // set the body as expected, with the _id and _rev fields - ctx.request.body = await addRev(fixRow({ _id: ctx.params.rowId }, ctx.params)) + ctx.request.body = await addRev( + fixRow({ _id: ctx.params.rowId }, ctx.params), + tableId + ) await rowController.destroy(ctx) // destroy controller doesn't currently return the row as the body, need to adjust this // in the public API to be correct diff --git a/packages/server/src/api/controllers/public/utils.ts b/packages/server/src/api/controllers/public/utils.ts index d86eced9ba..6909db9628 100644 --- a/packages/server/src/api/controllers/public/utils.ts +++ b/packages/server/src/api/controllers/public/utils.ts @@ -22,7 +22,7 @@ export async function addRev( } /** - * Performs a case insensitive search on the provided documents, using the + * Performs a case in-sensitive search on the provided documents, using the * provided key and value. This will be a string based search, using the * startsWith function. */ diff --git a/packages/server/src/api/controllers/query/index.ts b/packages/server/src/api/controllers/query/index.ts index 187f16a573..c92f942986 100644 --- a/packages/server/src/api/controllers/query/index.ts +++ b/packages/server/src/api/controllers/query/index.ts @@ -56,6 +56,7 @@ const _import = async (ctx: any) => { config: { url: info.url, defaultHeaders: [], + rejectUnauthorized: true, }, name: info.name, } @@ -153,7 +154,10 @@ export async function preview(ctx: any) { auth: { ...authConfigCtx }, }, }) - const { rows, keys, info, extra } = await quotas.addQuery(runFn) + + const { rows, keys, info, extra } = await quotas.addQuery(runFn, { + datasourceId: datasource._id, + }) const schemaFields: any = {} if (rows?.length > 0) { for (let key of [...new Set(keys)] as string[]) { @@ -234,7 +238,13 @@ async function execute( }, }) - const { rows, pagination, extra } = await quotas.addQuery(runFn) + const { rows, pagination, extra } = await quotas.addQuery(runFn, { + datasourceId: datasource._id, + }) + // remove the raw from execution incase transformer being used to hide data + if (extra?.raw) { + delete extra.raw + } if (opts && opts.rowsOnly) { ctx.body = rows } else { diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 27810008d3..901589970b 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -31,8 +31,11 @@ export async function patch(ctx: any): Promise { return save(ctx) } try { - const { row, table } = await quotas.addQuery(() => - pickApi(tableId).patch(ctx) + const { row, table } = await quotas.addQuery( + () => pickApi(tableId).patch(ctx), + { + datasourceId: tableId, + } ) ctx.status = 200 ctx.eventEmitter && @@ -54,7 +57,9 @@ export const save = async (ctx: any) => { } try { const { row, table } = await quotas.addRow(() => - quotas.addQuery(() => pickApi(tableId).save(ctx)) + quotas.addQuery(() => pickApi(tableId).save(ctx), { + datasourceId: tableId, + }) ) ctx.status = 200 ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table) @@ -68,7 +73,9 @@ export const save = async (ctx: any) => { export async function fetchView(ctx: any) { const tableId = getTableId(ctx) try { - ctx.body = await quotas.addQuery(() => pickApi(tableId).fetchView(ctx)) + ctx.body = await quotas.addQuery(() => pickApi(tableId).fetchView(ctx), { + datasourceId: tableId, + }) } catch (err) { ctx.throw(400, err) } @@ -77,7 +84,9 @@ export async function fetchView(ctx: any) { export async function fetch(ctx: any) { const tableId = getTableId(ctx) try { - ctx.body = await quotas.addQuery(() => pickApi(tableId).fetch(ctx)) + ctx.body = await quotas.addQuery(() => pickApi(tableId).fetch(ctx), { + datasourceId: tableId, + }) } catch (err) { ctx.throw(400, err) } @@ -86,7 +95,9 @@ export async function fetch(ctx: any) { export async function find(ctx: any) { const tableId = getTableId(ctx) try { - ctx.body = await quotas.addQuery(() => pickApi(tableId).find(ctx)) + ctx.body = await quotas.addQuery(() => pickApi(tableId).find(ctx), { + datasourceId: tableId, + }) } catch (err) { ctx.throw(400, err) } @@ -98,8 +109,11 @@ export async function destroy(ctx: any) { const tableId = getTableId(ctx) let response, row if (inputs.rows) { - let { rows } = await quotas.addQuery(() => - pickApi(tableId).bulkDestroy(ctx) + let { rows } = await quotas.addQuery( + () => pickApi(tableId).bulkDestroy(ctx), + { + datasourceId: tableId, + } ) await quotas.removeRows(rows.length) response = rows @@ -107,7 +121,9 @@ export async function destroy(ctx: any) { ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) } } else { - let resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx)) + let resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx), { + datasourceId: tableId, + }) await quotas.removeRow() response = resp.response row = resp.row @@ -123,7 +139,9 @@ export async function search(ctx: any) { const tableId = getTableId(ctx) try { ctx.status = 200 - ctx.body = await quotas.addQuery(() => pickApi(tableId).search(ctx)) + ctx.body = await quotas.addQuery(() => pickApi(tableId).search(ctx), { + datasourceId: tableId, + }) } catch (err) { ctx.throw(400, err) } @@ -141,8 +159,11 @@ export async function validate(ctx: any) { export async function fetchEnrichedRow(ctx: any) { const tableId = getTableId(ctx) try { - ctx.body = await quotas.addQuery(() => - pickApi(tableId).fetchEnrichedRow(ctx) + ctx.body = await quotas.addQuery( + () => pickApi(tableId).fetchEnrichedRow(ctx), + { + datasourceId: tableId, + } ) } catch (err) { ctx.throw(400, err) @@ -152,7 +173,9 @@ export async function fetchEnrichedRow(ctx: any) { export const exportRows = async (ctx: any) => { const tableId = getTableId(ctx) try { - ctx.body = await quotas.addQuery(() => pickApi(tableId).exportRows(ctx)) + ctx.body = await quotas.addQuery(() => pickApi(tableId).exportRows(ctx), { + datasourceId: tableId, + }) } catch (err) { ctx.throw(400, err) } diff --git a/packages/server/src/api/controllers/row/internalSearch.js b/packages/server/src/api/controllers/row/internalSearch.js index 3cf60fbcc0..051a55aa9f 100644 --- a/packages/server/src/api/controllers/row/internalSearch.js +++ b/packages/server/src/api/controllers/row/internalSearch.js @@ -145,7 +145,7 @@ class QueryBuilder { * @param options The preprocess options * @returns {string|*} */ - preprocess(value, { escape, lowercase, wrap } = {}) { + preprocess(value, { escape, lowercase, wrap, type } = {}) { const hasVersion = !!this.version // Determine if type needs wrapped const originalType = typeof value @@ -157,8 +157,11 @@ class QueryBuilder { if (escape && originalType === "string") { value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&") } + // Wrap in quotes - if (hasVersion && wrap) { + if (originalType === "string" && !isNaN(value) && !type) { + value = `"${value}"` + } else if (hasVersion && wrap) { value = originalType === "number" ? value : `"${value}"` } return value @@ -253,6 +256,7 @@ class QueryBuilder { value = builder.preprocess(value, { escape: true, lowercase: true, + type: "string", }) return `${key}:${value}*` }) @@ -281,6 +285,7 @@ class QueryBuilder { value = builder.preprocess(value, { escape: true, lowercase: true, + type: "fuzzy", }) return `${key}:${value}~` }) diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts index 80116a21f5..08213c2cf8 100644 --- a/packages/server/src/api/controllers/static/index.ts +++ b/packages/server/src/api/controllers/static/index.ts @@ -1,3 +1,5 @@ +import { enrichPluginURLs } from "../../../utilities/plugins" + require("svelte/register") const send = require("koa-send") @@ -107,12 +109,13 @@ export const serveApp = async function (ctx: any) { if (!env.isJest()) { const App = require("./templates/BudibaseApp.svelte").default + const plugins = enrichPluginURLs(appInfo.usedPlugins) const { head, html, css } = App.render({ title: appInfo.name, production: env.isProd(), appId, clientLibPath: clientLibraryPath(appId, appInfo.version, ctx), - usedPlugins: appInfo.usedPlugins, + usedPlugins: plugins, }) const appHbs = loadHandlebarsFile(`${__dirname}/templates/app.hbs`) diff --git a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte index 4bf54f2c91..227f980896 100644 --- a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte +++ b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte @@ -88,9 +88,7 @@ {#if usedPlugins?.length} {#each usedPlugins as plugin} - + {/each} {/if}