diff --git a/.eslintrc.json b/.eslintrc.json index d475bba8d1..9dab2f1a88 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -55,7 +55,9 @@ } ], "no-redeclare": "off", - "@typescript-eslint/no-redeclare": "error" + "@typescript-eslint/no-redeclare": "error", + // have to turn this off to allow function overloading in typescript + "no-dupe-class-members": "off" } }, { @@ -88,7 +90,9 @@ "jest/expect-expect": "off", // We do this in some tests where the behaviour of internal tables // differs to external, but the API is broadly the same - "jest/no-conditional-expect": "off" + "jest/no-conditional-expect": "off", + // have to turn this off to allow function overloading in typescript + "no-dupe-class-members": "off" } }, { diff --git a/.github/workflows/force-release.yml b/.github/workflows/force-release.yml index 8a9d444f51..3d96d51484 100644 --- a/.github/workflows/force-release.yml +++ b/.github/workflows/force-release.yml @@ -9,7 +9,7 @@ on: jobs: ensure-is-master-tag: name: Ensure is a master tag - runs-on: qa-arc-runner-set + runs-on: ubuntu-latest steps: - name: Checkout monorepo uses: actions/checkout@v4 diff --git a/charts/budibase/Chart.yaml b/charts/budibase/Chart.yaml index e2c9378f2c..83a72d203f 100644 --- a/charts/budibase/Chart.yaml +++ b/charts/budibase/Chart.yaml @@ -17,6 +17,6 @@ version: 0.0.0 appVersion: 0.0.0 dependencies: - name: couchdb - version: 4.3.0 + version: 4.5.3 repository: https://apache.github.io/couchdb-helm condition: services.couchdb.enabled diff --git a/hosting/docker-compose.build.yaml b/hosting/docker-compose.build.yaml index dbc3613599..253dda0232 100644 --- a/hosting/docker-compose.build.yaml +++ b/hosting/docker-compose.build.yaml @@ -29,6 +29,7 @@ services: BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL} BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD} PLUGINS_DIR: ${PLUGINS_DIR} + SQS_SEARCH_ENABLE: 1 depends_on: - worker-service - redis-service @@ -56,6 +57,7 @@ services: INTERNAL_API_KEY: ${INTERNAL_API_KEY} REDIS_URL: redis-service:6379 REDIS_PASSWORD: ${REDIS_PASSWORD} + SQS_SEARCH_ENABLE: 1 depends_on: - redis-service - minio-service diff --git a/hosting/docker-compose.dev.yaml b/hosting/docker-compose.dev.yaml index 9dba5d427c..77f6bd053b 100644 --- a/hosting/docker-compose.dev.yaml +++ b/hosting/docker-compose.dev.yaml @@ -42,12 +42,13 @@ services: couchdb-service: container_name: budi-couchdb3-dev restart: on-failure - image: budibase/couchdb + image: budibase/couchdb:v3.2.1-sqs environment: - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_USER=${COUCH_DB_USER} ports: - "${COUCH_DB_PORT}:5984" + - "${COUCH_DB_SQS_PORT}:4984" volumes: - couchdb_data:/data diff --git a/hosting/proxy/nginx.prod.conf b/hosting/proxy/nginx.prod.conf index 217106b1bf..59722dac5c 100644 --- a/hosting/proxy/nginx.prod.conf +++ b/hosting/proxy/nginx.prod.conf @@ -61,7 +61,7 @@ http { set $csp_img "img-src http: https: data: blob:"; set $csp_manifest "manifest-src 'self'"; set $csp_media "media-src 'self' https://js.intercomcdn.com https://cdn.budi.live"; - set $csp_worker "worker-src 'none'"; + set $csp_worker "worker-src blob:"; error_page 502 503 504 /error.html; location = /error.html { diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index e8ac306c51..7dd3c1ed22 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -1,3 +1,4 @@ +ARG BASEIMG=budibase/couchdb:v3.3.3 FROM node:20-slim as build # install node-gyp dependencies @@ -32,7 +33,7 @@ COPY packages/worker/dist packages/worker/dist COPY packages/worker/pm2.config.js packages/worker/pm2.config.js -FROM budibase/couchdb:v3.3.3 as runner +FROM $BASEIMG as runner ARG TARGETARCH ENV TARGETARCH $TARGETARCH #TARGETBUILD can be set to single (for single docker image) or aas (for azure app service) diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh index 3126eedfb1..fc8d4d8b2d 100644 --- a/hosting/single/runner.sh +++ b/hosting/single/runner.sh @@ -53,6 +53,11 @@ done if [[ -z "${COUCH_DB_URL}" ]]; then export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@127.0.0.1:5984 fi + +if [[ -z "${COUCH_DB_SQL_URL}" ]]; then + export COUCH_DB_SQL_URL=http://127.0.0.1:4984 +fi + if [ ! -f "${DATA_DIR}/.env" ]; then touch ${DATA_DIR}/.env for ENV_VAR in "${ENV_VARS[@]}" diff --git a/lerna.json b/lerna.json index f72b908897..d90f7732a2 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.26.3", + "version": "2.27.6", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/package.json b/package.json index 98524e0ee4..08176fae90 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,12 @@ "get-past-client-version": "node scripts/getPastClientVersion.js", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev", "build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream", + "build:apps": "yarn build --scope @budibase/server --scope @budibase/worker", + "build:cli": "yarn build --scope @budibase/cli", "build:oss": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --ignore @budibase/account-portal --ignore @budibase/account-portal-server --ignore @budibase/account-portal-ui", "build:account-portal": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --scope @budibase/account-portal --scope @budibase/account-portal-server --scope @budibase/account-portal-ui", "build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput", - "check:types": "lerna run check:types", + "check:types": "lerna run --concurrency 2 check:types", "build:sdk": "lerna run --stream build:sdk", "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular", "release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset", @@ -73,9 +75,10 @@ "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", "build:docker:single:multiarch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/single/Dockerfile -t budibase:latest .", "build:docker:single": "./scripts/build-single-image.sh", + "build:docker:single:sqs": "./scripts/build-single-image-sqs.sh", "build:docker:dependencies": "docker build -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest ./hosting", - "publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.2.1 --push ./hosting/couchdb", - "publish:docker:couch-sqs": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile.v2 -t budibase/couchdb:v3.2.1-sqs --push ./hosting/couchdb", + "publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.3.3 --push ./hosting/couchdb", + "publish:docker:couch-sqs": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile.v2 -t budibase/couchdb:v3.3.3-sqs --push ./hosting/couchdb", "publish:docker:dependencies": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest -t budibase/dependencies:v3.2.1 --push ./hosting", "release:helm": "node scripts/releaseHelmChart", "env:multi:enable": "lerna run --stream env:multi:enable", diff --git a/packages/account-portal b/packages/account-portal index e8136bd1ea..a03225549e 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit e8136bd1ea9fa4c61a4bcbeda482abea0b6c3d9f +Subproject commit a03225549e3ce61f43d0da878da162e08941b939 diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index ff35ccee22..f61059cc97 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -54,7 +54,8 @@ "sanitize-s3-objectkey": "0.0.1", "semver": "^7.5.4", "tar-fs": "2.1.1", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "knex": "2.4.2" }, "devDependencies": { "@shopify/jest-koa-mocks": "5.1.1", diff --git a/packages/backend-core/src/constants/db.ts b/packages/backend-core/src/constants/db.ts index c11c227b66..2fd713119b 100644 --- a/packages/backend-core/src/constants/db.ts +++ b/packages/backend-core/src/constants/db.ts @@ -65,5 +65,11 @@ export const StaticDatabases = { export const APP_PREFIX = prefixed(DocumentType.APP) export const APP_DEV = prefixed(DocumentType.APP_DEV) export const APP_DEV_PREFIX = APP_DEV +export const SQS_DATASOURCE_INTERNAL = "internal" export const BUDIBASE_DATASOURCE_TYPE = "budibase" export const SQLITE_DESIGN_DOC_ID = "_design/sqlite" +export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs" +export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory" +export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses" +export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee" +export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default" diff --git a/packages/backend-core/src/context/types.ts b/packages/backend-core/src/context/types.ts index f297d3089f..6694320978 100644 --- a/packages/backend-core/src/context/types.ts +++ b/packages/backend-core/src/context/types.ts @@ -1,4 +1,6 @@ import { IdentityContext, Snippet, VM } from "@budibase/types" +import { OAuth2Client } from "google-auth-library" +import { GoogleSpreadsheet } from "google-spreadsheet" // keep this out of Budibase types, don't want to expose context info export type ContextMap = { @@ -12,4 +14,8 @@ export type ContextMap = { vm?: VM cleanup?: (() => void | Promise)[] snippets?: Snippet[] + googleSheets?: { + oauthClient: OAuth2Client + clients: Record + } } diff --git a/packages/backend-core/src/db/Replication.ts b/packages/backend-core/src/db/Replication.ts index 735c2fa86e..617269df10 100644 --- a/packages/backend-core/src/db/Replication.ts +++ b/packages/backend-core/src/db/Replication.ts @@ -1,14 +1,31 @@ import PouchDB from "pouchdb" import { getPouchDB, closePouchDB } from "./couch" -import { DocumentType } from "../constants" +import { DocumentType } from "@budibase/types" + +enum ReplicationDirection { + TO_PRODUCTION = "toProduction", + TO_DEV = "toDev", +} class Replication { source: PouchDB.Database target: PouchDB.Database + direction: ReplicationDirection | undefined constructor({ source, target }: { source: string; target: string }) { this.source = getPouchDB(source) this.target = getPouchDB(target) + if ( + source.startsWith(DocumentType.APP_DEV) && + target.startsWith(DocumentType.APP) + ) { + this.direction = ReplicationDirection.TO_PRODUCTION + } else if ( + source.startsWith(DocumentType.APP) && + target.startsWith(DocumentType.APP_DEV) + ) { + this.direction = ReplicationDirection.TO_DEV + } } async close() { @@ -40,12 +57,18 @@ class Replication { } const filter = opts.filter + const direction = this.direction + const toDev = direction === ReplicationDirection.TO_DEV delete opts.filter return { ...opts, filter: (doc: any, params: any) => { - if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) { + // don't sync design documents + if (toDev && doc._id?.startsWith("_design")) { + return false + } + if (doc._id?.startsWith(DocumentType.AUTOMATION_LOG)) { return false } if (doc._id === DocumentType.APP_METADATA) { diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index ef351f7d4d..8194d1aabf 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -12,6 +12,7 @@ import { isDocument, RowResponse, RowValue, + SQLiteDefinition, SqlQueryBinding, } from "@budibase/types" import { getCouchInfo } from "./connections" @@ -21,6 +22,8 @@ import { ReadStream, WriteStream } from "fs" import { newid } from "../../docIds/newid" import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { DDInstrumentedDatabase } from "../instrumentation" +import { checkSlashesInUrl } from "../../helpers" +import env from "../../environment" const DATABASE_NOT_FOUND = "Database does not exist." @@ -281,25 +284,61 @@ export class DatabaseImpl implements Database { }) } + async _sqlQuery( + url: string, + method: "POST" | "GET", + body?: Record + ): Promise { + url = checkSlashesInUrl(`${this.couchInfo.sqlUrl}/${url}`) + const args: { url: string; method: string; cookie: string; body?: any } = { + url, + method, + cookie: this.couchInfo.cookie, + } + if (body) { + args.body = body + } + return this.performCall(() => { + return async () => { + const response = await directCouchUrlCall(args) + const json = await response.json() + if (response.status > 300) { + throw json + } + return json as T + } + }) + } + async sql( sql: string, parameters?: SqlQueryBinding ): Promise { const dbName = this.name const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}` - const response = await directCouchUrlCall({ - url: `${this.couchInfo.sqlUrl}/${url}`, - method: "POST", - cookie: this.couchInfo.cookie, - body: { - query: sql, - args: parameters, - }, + return await this._sqlQuery(url, "POST", { + query: sql, + args: parameters, }) - if (response.status > 300) { - throw new Error(await response.text()) + } + + // checks design document is accurate (cleans up tables) + // this will check the design document and remove anything from + // disk which is not supposed to be there + async sqlDiskCleanup(): Promise { + const dbName = this.name + const url = `/${dbName}/_cleanup` + return await this._sqlQuery(url, "POST") + } + + // removes a document from sqlite + async sqlPurgeDocument(docIds: string[] | string): Promise { + if (!Array.isArray(docIds)) { + docIds = [docIds] } - return (await response.json()) as T[] + const dbName = this.name + const url = `/${dbName}/_purge` + return await this._sqlQuery(url, "POST", { docs: docIds }) } async query( @@ -314,6 +353,17 @@ export class DatabaseImpl implements Database { async destroy() { try { + if (env.SQS_SEARCH_ENABLE) { + // delete the design document, then run the cleanup operation + try { + const definition = await this.get( + SQLITE_DESIGN_DOC_ID + ) + await this.remove(SQLITE_DESIGN_DOC_ID, definition._rev) + } finally { + await this.sqlDiskCleanup() + } + } return await this.nano().db.destroy(this.name) } catch (err: any) { // didn't exist, don't worry diff --git a/packages/backend-core/src/db/couch/utils.ts b/packages/backend-core/src/db/couch/utils.ts index 005b02a896..270d953320 100644 --- a/packages/backend-core/src/db/couch/utils.ts +++ b/packages/backend-core/src/db/couch/utils.ts @@ -21,7 +21,7 @@ export async function directCouchUrlCall({ url: string cookie: string method: string - body?: any + body?: Record }) { const params: any = { method: method, diff --git a/packages/backend-core/src/db/instrumentation.ts b/packages/backend-core/src/db/instrumentation.ts index 32ba81ebd8..4e2b147ef3 100644 --- a/packages/backend-core/src/db/instrumentation.ts +++ b/packages/backend-core/src/db/instrumentation.ts @@ -56,12 +56,17 @@ export class DDInstrumentedDatabase implements Database { }) } + remove(idOrDoc: Document): Promise + remove(idOrDoc: string, rev?: string): Promise remove( - id: string | Document, - rev?: string | undefined + idOrDoc: string | Document, + rev?: string ): Promise { return tracer.trace("db.remove", span => { - span?.addTags({ db_name: this.name, doc_id: id }) + span?.addTags({ db_name: this.name, doc_id: idOrDoc }) + const isDocument = typeof idOrDoc === "object" + const id = isDocument ? idOrDoc._id! : idOrDoc + rev = isDocument ? idOrDoc._rev : rev return this.db.remove(id, rev) }) } @@ -160,4 +165,18 @@ export class DDInstrumentedDatabase implements Database { return this.db.sql(sql, parameters) }) } + + sqlPurgeDocument(docIds: string[] | string): Promise { + return tracer.trace("db.sqlPurgeDocument", span => { + span?.addTags({ db_name: this.name }) + return this.db.sqlPurgeDocument(docIds) + }) + } + + sqlDiskCleanup(): Promise { + return tracer.trace("db.sqlDiskCleanup", span => { + span?.addTags({ db_name: this.name }) + return this.db.sqlDiskCleanup() + }) + } } diff --git a/packages/backend-core/src/docIds/ids.ts b/packages/backend-core/src/docIds/ids.ts index 9627b2b94c..a828c1b91e 100644 --- a/packages/backend-core/src/docIds/ids.ts +++ b/packages/backend-core/src/docIds/ids.ts @@ -18,6 +18,14 @@ export const generateAppID = (tenantId?: string | null) => { return `${id}${newid()}` } +/** + * Generates a new table ID. + * @returns The new table ID which the table doc can be stored under. + */ +export function generateTableID() { + return `${DocumentType.TABLE}${SEPARATOR}${newid()}` +} + /** * Gets a new row ID for the specified table. * @param tableId The table which the row is being created for. diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 9ade81b9d7..1e7da2f9a2 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -109,6 +109,7 @@ const environment = { API_ENCRYPTION_KEY: getAPIEncryptionKey(), COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005", COUCH_DB_SQL_URL: process.env.COUCH_DB_SQL_URL || "http://localhost:4006", + SQS_SEARCH_ENABLE: process.env.SQS_SEARCH_ENABLE, COUCH_DB_USERNAME: process.env.COUCH_DB_USER, COUCH_DB_PASSWORD: process.env.COUCH_DB_PASSWORD, GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, @@ -158,6 +159,9 @@ const environment = { process.env.DEPLOYMENT_ENVIRONMENT || "docker-compose", HTTP_LOGGING: httpLogging(), ENABLE_AUDIT_LOG_IP_ADDR: process.env.ENABLE_AUDIT_LOG_IP_ADDR, + // Couch/search + SQL_LOGGING_ENABLE: process.env.SQL_LOGGING_ENABLE, + SQL_MAX_ROWS: process.env.SQL_MAX_ROWS, // smtp SMTP_FALLBACK_ENABLED: process.env.SMTP_FALLBACK_ENABLED, SMTP_USER: process.env.SMTP_USER, diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 5ce35ee760..30c5fbdd7a 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -34,6 +34,7 @@ export * as docUpdates from "./docUpdates" export * from "./utils/Duration" export * as docIds from "./docIds" export * as security from "./security" +export * as sql from "./sql" // Add context to tenancy for backwards compatibility // only do this for external usages to prevent internal // circular dependencies diff --git a/packages/backend-core/src/objectStore/objectStore.ts b/packages/backend-core/src/objectStore/objectStore.ts index 0ac2c35179..de94e3968b 100644 --- a/packages/backend-core/src/objectStore/objectStore.ts +++ b/packages/backend-core/src/objectStore/objectStore.ts @@ -14,6 +14,7 @@ import { v4 } from "uuid" import { APP_PREFIX, APP_DEV_PREFIX } from "../db" import fsp from "fs/promises" import { HeadObjectOutput } from "aws-sdk/clients/s3" +import { ReadableStream } from "stream/web" const streamPipeline = promisify(stream.pipeline) // use this as a temporary store of buckets that are being created @@ -41,10 +42,7 @@ type UploadParams = BaseUploadParams & { path?: string | PathLike } -export type StreamTypes = - | ReadStream - | NodeJS.ReadableStream - | ReadableStream +export type StreamTypes = ReadStream | NodeJS.ReadableStream export type StreamUploadParams = BaseUploadParams & { stream?: StreamTypes @@ -222,6 +220,9 @@ export async function streamUpload({ extra, ttl, }: StreamUploadParams) { + if (!stream) { + throw new Error("Stream to upload is invalid/undefined") + } const extension = filename.split(".").pop() const objectStore = ObjectStore(bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) @@ -251,14 +252,27 @@ export async function streamUpload({ : CONTENT_TYPE_MAP.txt } + const bucket = sanitizeBucket(bucketName), + objKey = sanitizeKey(filename) const params = { - Bucket: sanitizeBucket(bucketName), - Key: sanitizeKey(filename), + Bucket: bucket, + Key: objKey, Body: stream, ContentType: contentType, ...extra, } - return objectStore.upload(params).promise() + + const details = await objectStore.upload(params).promise() + const headDetails = await objectStore + .headObject({ + Bucket: bucket, + Key: objKey, + }) + .promise() + return { + ...details, + ContentLength: headDetails.ContentLength, + } } /** diff --git a/packages/backend-core/src/sql/designDoc.ts b/packages/backend-core/src/sql/designDoc.ts new file mode 100644 index 0000000000..dc334496a0 --- /dev/null +++ b/packages/backend-core/src/sql/designDoc.ts @@ -0,0 +1,17 @@ +import { PreSaveSQLiteDefinition } from "@budibase/types" +import { SQLITE_DESIGN_DOC_ID } from "../constants" + +// the table id property defines which property in the document +// to use when splitting the documents into different sqlite tables +export function base(tableIdProp: string): PreSaveSQLiteDefinition { + return { + _id: SQLITE_DESIGN_DOC_ID, + language: "sqlite", + sql: { + tables: {}, + options: { + table_name: tableIdProp, + }, + }, + } +} diff --git a/packages/backend-core/src/sql/index.ts b/packages/backend-core/src/sql/index.ts new file mode 100644 index 0000000000..16b718d2e6 --- /dev/null +++ b/packages/backend-core/src/sql/index.ts @@ -0,0 +1,5 @@ +export * as utils from "./utils" + +export { default as Sql } from "./sql" +export { default as SqlTable } from "./sqlTable" +export * as designDoc from "./designDoc" diff --git a/packages/server/src/integrations/base/sql.ts b/packages/backend-core/src/sql/sql.ts similarity index 86% rename from packages/server/src/integrations/base/sql.ts rename to packages/backend-core/src/sql/sql.ts index 85db642e47..61d5849058 100644 --- a/packages/server/src/integrations/base/sql.ts +++ b/packages/backend-core/src/sql/sql.ts @@ -1,13 +1,12 @@ import { Knex, knex } from "knex" -import { db as dbCore } from "@budibase/backend-core" -import { QueryOptions } from "../../definitions/datasource" +import * as dbCore from "../db" import { isIsoDateString, - SqlClient, isValidFilter, getNativeSql, - SqlStatements, -} from "../utils" + isExternalTable, +} from "./utils" +import { SqlStatements } from "./sqlStatements" import SqlTableQueryBuilder from "./sqlTable" import { BBReferenceFieldMetadata, @@ -24,8 +23,12 @@ import { Table, TableSourceType, INTERNAL_TABLE_SOURCE_ID, + SqlClient, + QueryOptions, + JsonTypes, + prefixed, } from "@budibase/types" -import environment from "../../environment" +import environment from "../environment" import { helpers } from "@budibase/shared-core" type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any @@ -45,6 +48,7 @@ function likeKey(client: string, key: string): string { case SqlClient.MY_SQL: start = end = "`" break + case SqlClient.SQL_LITE: case SqlClient.ORACLE: case SqlClient.POSTGRES: start = end = '"' @@ -53,9 +57,6 @@ function likeKey(client: string, key: string): string { start = "[" end = "]" break - case SqlClient.SQL_LITE: - start = end = "'" - break default: throw new Error("Unknown client generating like key") } @@ -122,11 +123,8 @@ function generateSelectStatement( const fieldNames = field.split(/\./g) const tableName = fieldNames[0] const columnName = fieldNames[1] - if ( - columnName && - schema?.[columnName] && - knex.client.config.client === SqlClient.POSTGRES - ) { + const columnSchema = schema?.[columnName] + if (columnSchema && knex.client.config.client === SqlClient.POSTGRES) { const externalType = schema[columnName].externalType if (externalType?.includes("money")) { return knex.raw( @@ -134,6 +132,14 @@ function generateSelectStatement( ) } } + if ( + knex.client.config.client === SqlClient.MS_SQL && + columnSchema?.type === FieldType.DATETIME && + columnSchema.timeOnly + ) { + // Time gets returned as timestamp from mssql, not matching the expected HH:mm format + return knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`) + } return `${field} as ${field}` }) } @@ -202,17 +208,20 @@ class InternalBuilder { const updatedKey = dbCore.removeKeyNumbering(key) const isRelationshipField = updatedKey.includes(".") if (!opts.relationship && !isRelationshipField) { - fn(`${getTableAlias(tableName)}.${updatedKey}`, value) + const alias = getTableAlias(tableName) + fn(alias ? `${alias}.${updatedKey}` : updatedKey, value) } if (opts.relationship && isRelationshipField) { const [filterTableName, property] = updatedKey.split(".") - fn(`${getTableAlias(filterTableName)}.${property}`, value) + const alias = getTableAlias(filterTableName) + fn(alias ? `${alias}.${property}` : property, value) } } } const like = (key: string, value: any) => { - const fnc = allOr ? "orWhere" : "where" + const fuzzyOr = filters?.fuzzyOr + const fnc = fuzzyOr || allOr ? "orWhere" : "where" // postgres supports ilike, nothing else does if (this.client === SqlClient.POSTGRES) { query = query[fnc](key, "ilike", `%${value}%`) @@ -226,8 +235,7 @@ class InternalBuilder { } const contains = (mode: object, any: boolean = false) => { - const fnc = allOr ? "orWhere" : "where" - const rawFnc = `${fnc}Raw` + const rawFnc = allOr ? "orWhereRaw" : "whereRaw" const not = mode === filters?.notContains ? "NOT " : "" function stringifyArray(value: Array, quoteStyle = '"'): string { for (let i in value) { @@ -240,24 +248,24 @@ class InternalBuilder { if (this.client === SqlClient.POSTGRES) { iterate(mode, (key: string, value: Array) => { const wrap = any ? "" : "'" - const containsOp = any ? "\\?| array" : "@>" + const op = any ? "\\?| array" : "@>" const fieldNames = key.split(/\./g) - const tableName = fieldNames[0] - const columnName = fieldNames[1] - // @ts-ignore + const table = fieldNames[0] + const col = fieldNames[1] query = query[rawFnc]( - `${not}"${tableName}"."${columnName}"::jsonb ${containsOp} ${wrap}${stringifyArray( + `${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray( value, any ? "'" : '"' - )}${wrap}` + )}${wrap}, FALSE)` ) }) } else if (this.client === SqlClient.MY_SQL) { const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS" iterate(mode, (key: string, value: Array) => { - // @ts-ignore query = query[rawFnc]( - `${not}${jsonFnc}(${key}, '${stringifyArray(value)}')` + `${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray( + value + )}'), FALSE)` ) }) } else { @@ -272,7 +280,7 @@ class InternalBuilder { } statement += (statement ? andOr : "") + - `LOWER(${likeKey(this.client, key)}) LIKE ?` + `COALESCE(LOWER(${likeKey(this.client, key)}), '') LIKE ?` } if (statement === "") { @@ -337,14 +345,34 @@ class InternalBuilder { } if (filters.equal) { iterate(filters.equal, (key, value) => { - const fnc = allOr ? "orWhere" : "where" - query = query[fnc]({ [key]: value }) + const fnc = allOr ? "orWhereRaw" : "whereRaw" + if (this.client === SqlClient.MS_SQL) { + query = query[fnc]( + `CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 1`, + [value] + ) + } else { + query = query[fnc]( + `COALESCE(${likeKey(this.client, key)} = ?, FALSE)`, + [value] + ) + } }) } if (filters.notEqual) { iterate(filters.notEqual, (key, value) => { - const fnc = allOr ? "orWhereNot" : "whereNot" - query = query[fnc]({ [key]: value }) + const fnc = allOr ? "orWhereRaw" : "whereRaw" + if (this.client === SqlClient.MS_SQL) { + query = query[fnc]( + `CASE WHEN ${likeKey(this.client, key)} = ? THEN 1 ELSE 0 END = 0`, + [value] + ) + } else { + query = query[fnc]( + `COALESCE(${likeKey(this.client, key)} != ?, TRUE)`, + [value] + ) + } }) } if (filters.empty) { @@ -369,6 +397,16 @@ class InternalBuilder { contains(filters.containsAny, true) } + // when searching internal tables make sure long looking for rows + if (filters.documentType && !isExternalTable(table)) { + const tableRef = opts?.aliases?.[table._id!] || table._id + // has to be its own option, must always be AND onto the search + query.andWhereLike( + `${tableRef}._id`, + `${prefixed(filters.documentType)}%` + ) + } + return query } @@ -383,7 +421,13 @@ class InternalBuilder { for (let [key, value] of Object.entries(sort)) { const direction = value.direction === SortDirection.ASCENDING ? "asc" : "desc" - query = query.orderBy(`${aliased}.${key}`, direction) + let nulls + if (this.client === SqlClient.POSTGRES) { + // All other clients already sort this as expected by default, and adding this to the rest of the clients is causing issues + nulls = value.direction === SortDirection.ASCENDING ? "first" : "last" + } + + query = query.orderBy(`${aliased}.${key}`, direction, nulls) } } else if (this.client === SqlClient.MS_SQL && paginate?.limit) { // @ts-ignore @@ -564,6 +608,7 @@ class InternalBuilder { query = this.addFilters(query, filters, json.meta.table, { aliases: tableAliases, }) + // add sorting to pre-query query = this.addSorting(query, json) const alias = tableAliases?.[tableName] || tableName @@ -634,12 +679,13 @@ class SqlQueryBuilder extends SqlTableQueryBuilder { */ _query(json: QueryJson, opts: QueryOptions = {}): SqlQuery | SqlQuery[] { const sqlClient = this.getSqlClient() - const config: { client: string; useNullAsDefault?: boolean } = { + const config: Knex.Config = { client: sqlClient, } if (sqlClient === SqlClient.SQL_LITE) { config.useNullAsDefault = true } + const client = knex(config) let query: Knex.QueryBuilder const builder = new InternalBuilder(sqlClient) @@ -757,11 +803,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder { return results.length ? results : [{ [operation.toLowerCase()]: true }] } - convertJsonStringColumns( + convertJsonStringColumns>( table: Table, - results: Record[], + results: T[], aliases?: Record - ): Record[] { + ): T[] { const tableName = getTableName(table) for (const [name, field] of Object.entries(table.schema)) { if (!this._isJsonColumn(field)) { @@ -770,11 +816,11 @@ class SqlQueryBuilder extends SqlTableQueryBuilder { const aliasedTableName = (tableName && aliases?.[tableName]) || tableName const fullName = `${aliasedTableName}.${name}` for (let row of results) { - if (typeof row[fullName] === "string") { - row[fullName] = JSON.parse(row[fullName]) + if (typeof row[fullName as keyof T] === "string") { + row[fullName as keyof T] = JSON.parse(row[fullName]) } - if (typeof row[name] === "string") { - row[name] = JSON.parse(row[name]) + if (typeof row[name as keyof T] === "string") { + row[name as keyof T] = JSON.parse(row[name]) } } } @@ -785,9 +831,8 @@ class SqlQueryBuilder extends SqlTableQueryBuilder { field: FieldSchema ): field is JsonFieldMetadata | BBReferenceFieldMetadata { return ( - field.type === FieldType.JSON || - (field.type === FieldType.BB_REFERENCE && - !helpers.schema.isDeprecatedSingleUserColumn(field)) + JsonTypes.includes(field.type) && + !helpers.schema.isDeprecatedSingleUserColumn(field) ) } diff --git a/packages/backend-core/src/sql/sqlStatements.ts b/packages/backend-core/src/sql/sqlStatements.ts new file mode 100644 index 0000000000..a80defd8b8 --- /dev/null +++ b/packages/backend-core/src/sql/sqlStatements.ts @@ -0,0 +1,79 @@ +import { FieldType, Table, FieldSchema, SqlClient } from "@budibase/types" +import { Knex } from "knex" + +export class SqlStatements { + client: string + table: Table + allOr: boolean | undefined + constructor( + client: string, + table: Table, + { allOr }: { allOr?: boolean } = {} + ) { + this.client = client + this.table = table + this.allOr = allOr + } + + getField(key: string): FieldSchema | undefined { + const fieldName = key.split(".")[1] + return this.table.schema[fieldName] + } + + between( + query: Knex.QueryBuilder, + key: string, + low: number | string, + high: number | string + ) { + // Use a between operator if we have 2 valid range values + const field = this.getField(key) + if ( + field?.type === FieldType.BIGINT && + this.client === SqlClient.SQL_LITE + ) { + query = query.whereRaw( + `CAST(${key} AS INTEGER) BETWEEN CAST(? AS INTEGER) AND CAST(? AS INTEGER)`, + [low, high] + ) + } else { + const fnc = this.allOr ? "orWhereBetween" : "whereBetween" + query = query[fnc](key, [low, high]) + } + return query + } + + lte(query: Knex.QueryBuilder, key: string, low: number | string) { + // Use just a single greater than operator if we only have a low + const field = this.getField(key) + if ( + field?.type === FieldType.BIGINT && + this.client === SqlClient.SQL_LITE + ) { + query = query.whereRaw(`CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`, [ + low, + ]) + } else { + const fnc = this.allOr ? "orWhere" : "where" + query = query[fnc](key, ">=", low) + } + return query + } + + gte(query: Knex.QueryBuilder, key: string, high: number | string) { + const field = this.getField(key) + // Use just a single less than operator if we only have a high + if ( + field?.type === FieldType.BIGINT && + this.client === SqlClient.SQL_LITE + ) { + query = query.whereRaw(`CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`, [ + high, + ]) + } else { + const fnc = this.allOr ? "orWhere" : "where" + query = query[fnc](key, "<=", high) + } + return query + } +} diff --git a/packages/server/src/integrations/base/sqlTable.ts b/packages/backend-core/src/sql/sqlTable.ts similarity index 96% rename from packages/server/src/integrations/base/sqlTable.ts rename to packages/backend-core/src/sql/sqlTable.ts index a82a9fcea8..09f9908baa 100644 --- a/packages/server/src/integrations/base/sqlTable.ts +++ b/packages/backend-core/src/sql/sqlTable.ts @@ -9,8 +9,9 @@ import { SqlQuery, Table, TableSourceType, + SqlClient, } from "@budibase/types" -import { breakExternalTableId, getNativeSql, SqlClient } from "../utils" +import { breakExternalTableId, getNativeSql } from "./utils" import { helpers, utils } from "@budibase/shared-core" import SchemaBuilder = Knex.SchemaBuilder import CreateTableBuilder = Knex.CreateTableBuilder @@ -79,9 +80,13 @@ function generateSchema( schema.boolean(key) break case FieldType.DATETIME: - schema.datetime(key, { - useTz: !column.ignoreTimezones, - }) + if (!column.timeOnly) { + schema.datetime(key, { + useTz: !column.ignoreTimezones, + }) + } else { + schema.time(key) + } break case FieldType.ARRAY: case FieldType.BB_REFERENCE: @@ -125,6 +130,7 @@ function generateSchema( break case FieldType.ATTACHMENTS: case FieldType.ATTACHMENT_SINGLE: + case FieldType.SIGNATURE_SINGLE: case FieldType.AUTO: case FieldType.JSON: case FieldType.INTERNAL: diff --git a/packages/backend-core/src/sql/utils.ts b/packages/backend-core/src/sql/utils.ts new file mode 100644 index 0000000000..2d9b289417 --- /dev/null +++ b/packages/backend-core/src/sql/utils.ts @@ -0,0 +1,134 @@ +import { DocumentType, SqlQuery, Table, TableSourceType } from "@budibase/types" +import { DEFAULT_BB_DATASOURCE_ID } from "../constants" +import { Knex } from "knex" +import { SEPARATOR } from "../db" + +const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` +const ROW_ID_REGEX = /^\[.*]$/g +const ENCODED_SPACE = encodeURIComponent(" ") + +export function isExternalTableID(tableId: string) { + return tableId.startsWith(DocumentType.DATASOURCE + SEPARATOR) +} + +export function isInternalTableID(tableId: string) { + return !isExternalTableID(tableId) +} + +export function getNativeSql( + query: Knex.SchemaBuilder | Knex.QueryBuilder +): SqlQuery | SqlQuery[] { + let sql = query.toSQL() + if (Array.isArray(sql)) { + return sql as SqlQuery[] + } + let native: Knex.SqlNative | undefined + if (sql.toNative) { + native = sql.toNative() + } + return { + sql: native?.sql || sql.sql, + bindings: native?.bindings || sql.bindings, + } as SqlQuery +} + +export function isExternalTable(table: Table) { + if ( + table?.sourceId && + table.sourceId.includes(DocumentType.DATASOURCE + SEPARATOR) && + table?.sourceId !== DEFAULT_BB_DATASOURCE_ID + ) { + return true + } else if (table?.sourceType === TableSourceType.EXTERNAL) { + return true + } else if (table?._id && isExternalTableID(table._id)) { + return true + } + return false +} + +export function buildExternalTableId(datasourceId: string, tableName: string) { + // encode spaces + if (tableName.includes(" ")) { + tableName = encodeURIComponent(tableName) + } + return `${datasourceId}${DOUBLE_SEPARATOR}${tableName}` +} + +export function breakExternalTableId(tableId: string | undefined) { + if (!tableId) { + return {} + } + const parts = tableId.split(DOUBLE_SEPARATOR) + let datasourceId = parts.shift() + // if they need joined + let tableName = parts.join(DOUBLE_SEPARATOR) + // if contains encoded spaces, decode it + if (tableName.includes(ENCODED_SPACE)) { + tableName = decodeURIComponent(tableName) + } + return { datasourceId, tableName } +} + +export function generateRowIdField(keyProps: any[] = []) { + if (!Array.isArray(keyProps)) { + keyProps = [keyProps] + } + for (let index in keyProps) { + if (keyProps[index] instanceof Buffer) { + keyProps[index] = keyProps[index].toString() + } + } + // this conserves order and types + // we have to swap the double quotes to single quotes for use in HBS statements + // when using the literal helper the double quotes can break things + return encodeURIComponent(JSON.stringify(keyProps).replace(/"/g, "'")) +} + +export function isRowId(field: any) { + return ( + Array.isArray(field) || + (typeof field === "string" && field.match(ROW_ID_REGEX) != null) + ) +} + +export function convertRowId(field: any) { + if (Array.isArray(field)) { + return field[0] + } + if (typeof field === "string" && field.match(ROW_ID_REGEX) != null) { + return field.substring(1, field.length - 1) + } + return field +} + +// should always return an array +export function breakRowIdField(_id: string | { _id: string }): any[] { + if (!_id) { + return [] + } + // have to replace on the way back as we swapped out the double quotes + // when encoding, but JSON can't handle the single quotes + const id = typeof _id === "string" ? _id : _id._id + const decoded: string = decodeURIComponent(id).replace(/'/g, '"') + try { + const parsed = JSON.parse(decoded) + return Array.isArray(parsed) ? parsed : [parsed] + } catch (err) { + // wasn't json - likely was handlebars for a many to many + return [_id] + } +} + +export function isIsoDateString(str: string) { + const trimmedValue = str.trim() + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/.test(trimmedValue)) { + return false + } + let d = new Date(trimmedValue) + return d.toISOString() === trimmedValue +} + +export function isValidFilter(value: any) { + return value != null && value !== "" +} diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index f77c6385ba..37547573bd 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -492,7 +492,7 @@ export class UserDB { await platform.users.removeUser(dbUser) - await db.remove(userId, dbUser._rev) + await db.remove(userId, dbUser._rev!) const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0 await UserDB.quotas.removeUsers(1, creatorsToDelete) diff --git a/packages/backend-core/tests/core/utilities/mocks/licenses.ts b/packages/backend-core/tests/core/utilities/mocks/licenses.ts index 1cbc282575..ec7d2af794 100644 --- a/packages/backend-core/tests/core/utilities/mocks/licenses.ts +++ b/packages/backend-core/tests/core/utilities/mocks/licenses.ts @@ -106,6 +106,10 @@ export const useViewPermissions = () => { return useFeature(Feature.VIEW_PERMISSIONS) } +export const useViewReadonlyColumns = () => { + return useFeature(Feature.VIEW_READONLY_COLUMNS) +} + // QUOTAS export const setAutomationLogsQuota = (value: number) => { diff --git a/packages/bbui/src/ActionButton/ActionButton.svelte b/packages/bbui/src/ActionButton/ActionButton.svelte index c346e34d54..d3cec0f307 100644 --- a/packages/bbui/src/ActionButton/ActionButton.svelte +++ b/packages/bbui/src/ActionButton/ActionButton.svelte @@ -57,6 +57,7 @@ class:fullWidth class="spectrum-ActionButton spectrum-ActionButton--size{size}" class:active + class:disabled {disabled} on:longPress on:click|preventDefault @@ -109,19 +110,22 @@ background: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-500); } - .noPadding { - padding: 0; - min-width: 0; - } .spectrum-ActionButton--quiet { padding: 0 8px; } .spectrum-ActionButton--quiet.is-selected { color: var(--spectrum-global-color-gray-900); } + .noPadding { + padding: 0; + min-width: 0; + } .is-selected:not(.emphasized) .spectrum-Icon { color: var(--spectrum-global-color-gray-900); } + .is-selected.disabled .spectrum-Icon { + color: var(--spectrum-global-color-gray-500); + } .tooltip { position: absolute; pointer-events: none; diff --git a/packages/bbui/src/Actions/click_outside.js b/packages/bbui/src/Actions/click_outside.js index 124f43ff04..526659cb7a 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -71,8 +71,8 @@ const handleMouseDown = e => { // Clear any previous listeners in case of multiple down events, and register // a single mouse up listener - document.removeEventListener("mouseup", handleMouseUp) - document.addEventListener("mouseup", handleMouseUp, true) + document.removeEventListener("click", handleMouseUp) + document.addEventListener("click", handleMouseUp, true) } // Global singleton listeners for our events diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 6c4fcab757..21635592d2 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -155,6 +155,8 @@ export default function positionDropdown(element, opts) { applyXStrategy(Strategies.StartToEnd) } else if (align === "left-outside") { applyXStrategy(Strategies.EndToStart) + } else if (align === "center") { + applyXStrategy(Strategies.MidPoint) } else { applyXStrategy(Strategies.StartToStart) } diff --git a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte index dc4886d28d..6c06ce4e79 100644 --- a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte @@ -4,13 +4,14 @@ export let max export let hideArrows = false export let width + export let type = "number" $: style = width ? `width:${width}px;` : "" diff --git a/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte index 047e5a4f08..4f070bdcfb 100644 --- a/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte @@ -1,5 +1,4 @@
- : -
@@ -50,10 +36,4 @@ flex-direction: row; align-items: center; } - .time-picker span { - font-weight: bold; - font-size: 18px; - z-index: 0; - margin-bottom: 1px; - } diff --git a/packages/bbui/src/Form/Core/Multiselect.svelte b/packages/bbui/src/Form/Core/Multiselect.svelte index 2243570cd5..4873430fa0 100644 --- a/packages/bbui/src/Form/Core/Multiselect.svelte +++ b/packages/bbui/src/Form/Core/Multiselect.svelte @@ -17,6 +17,8 @@ export let customPopoverHeight export let open = false export let loading + export let onOptionMouseenter = () => {} + export let onOptionMouseleave = () => {} const dispatch = createEventDispatcher() @@ -97,4 +99,6 @@ {autoWidth} {customPopoverHeight} {loading} + {onOptionMouseenter} + {onOptionMouseleave} /> diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index eb7a5db655..88866196a7 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -41,6 +41,8 @@ export let footer = null export let customAnchor = null export let loading + export let onOptionMouseenter = () => {} + export let onOptionMouseleave = () => {} const dispatch = createEventDispatcher() @@ -155,7 +157,7 @@ useAnchorWidth={!autoWidth} maxWidth={autoWidth ? 400 : null} customHeight={customPopoverHeight} - maxHeight={240} + maxHeight={360} >
onSelectOption(getOptionValue(option, idx))} + on:mouseenter={e => onOptionMouseenter(e, option)} + on:mouseleave={e => onOptionMouseleave(e, option)} class:is-disabled={!isOptionEnabled(option)} > {#if getOptionIcon(option, idx)} diff --git a/packages/bbui/src/Form/Core/Select.svelte b/packages/bbui/src/Form/Core/Select.svelte index 5754c683ed..0111bd6bcf 100644 --- a/packages/bbui/src/Form/Core/Select.svelte +++ b/packages/bbui/src/Form/Core/Select.svelte @@ -26,6 +26,8 @@ export let tag = null export let searchTerm = null export let loading + export let onOptionMouseenter = () => {} + export let onOptionMouseleave = () => {} const dispatch = createEventDispatcher() @@ -95,6 +97,8 @@ {autocomplete} {sort} {tag} + {onOptionMouseenter} + {onOptionMouseleave} isPlaceholder={value == null || value === ""} placeholderOption={placeholder === false ? null : placeholder} isOptionSelected={option => compareOptionAndValue(option, value)} diff --git a/packages/bbui/src/Form/Core/Signature.svelte b/packages/bbui/src/Form/Core/Signature.svelte new file mode 100644 index 0000000000..729d3ac5e2 --- /dev/null +++ b/packages/bbui/src/Form/Core/Signature.svelte @@ -0,0 +1,267 @@ + + +
+ {#if !disabled} +
+ {#if updated && saveIcon} + + { + dispatch("change", toDataUrl()) + }} + /> + + {/if} + {#if signatureFile?.url && !updated} + + { + if (editable) { + clearCanvas() + } + dispatch("clear") + }} + /> + + {/if} +
+ {/if} + {#if !editable && signatureFile?.url} + + {#if !urlFailed} + { + urlFailed = true + }} + /> + {:else} + Could not load signature + {/if} + {:else} +
+ + {#if editable} +
+
+ +
+
+
+ {/if} +
+ {/if} +
+ + diff --git a/packages/bbui/src/Form/Core/index.js b/packages/bbui/src/Form/Core/index.js index 7117b90081..6395fe2fac 100644 --- a/packages/bbui/src/Form/Core/index.js +++ b/packages/bbui/src/Form/Core/index.js @@ -16,3 +16,4 @@ export { default as CoreStepper } from "./Stepper.svelte" export { default as CoreRichTextField } from "./RichTextField.svelte" export { default as CoreSlider } from "./Slider.svelte" export { default as CoreFile } from "./File.svelte" +export { default as CoreSignature } from "./Signature.svelte" diff --git a/packages/bbui/src/Form/Multiselect.svelte b/packages/bbui/src/Form/Multiselect.svelte index b0246c8530..9878605f4b 100644 --- a/packages/bbui/src/Form/Multiselect.svelte +++ b/packages/bbui/src/Form/Multiselect.svelte @@ -19,6 +19,8 @@ export let searchTerm = null export let customPopoverHeight export let helpText = null + export let onOptionMouseenter = () => {} + export let onOptionMouseleave = () => {} const dispatch = createEventDispatcher() const onChange = e => { @@ -41,6 +43,8 @@ {autoWidth} {autocomplete} {customPopoverHeight} + {onOptionMouseenter} + {onOptionMouseleave} bind:searchTerm on:change={onChange} on:click diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index 2119a37980..260090c7b7 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -29,6 +29,9 @@ export let tag = null export let helpText = null export let compare + export let onOptionMouseenter = () => {} + export let onOptionMouseleave = () => {} + const dispatch = createEventDispatcher() const onChange = e => { value = e.detail @@ -67,6 +70,8 @@ {customPopoverHeight} {tag} {compare} + {onOptionMouseenter} + {onOptionMouseleave} on:change={onChange} on:click /> diff --git a/packages/bbui/src/Modal/Modal.svelte b/packages/bbui/src/Modal/Modal.svelte index be9c338892..4656be69d1 100644 --- a/packages/bbui/src/Modal/Modal.svelte +++ b/packages/bbui/src/Modal/Modal.svelte @@ -173,6 +173,7 @@ } .spectrum-Modal { + border: 2px solid var(--spectrum-global-color-gray-200); overflow: visible; max-height: none; margin: 40px 0; diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index 189ef70c2b..61ceaeb00a 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -27,6 +27,7 @@ export let secondaryButtonText = undefined export let secondaryAction = undefined export let secondaryButtonWarning = false + export let custom = false const { hide, cancel } = getContext(Context.Modal) let loading = false @@ -63,12 +64,13 @@ class:spectrum-Dialog--medium={size === "M"} class:spectrum-Dialog--large={size === "L"} class:spectrum-Dialog--extraLarge={size === "XL"} + class:no-grid={custom} style="position: relative;" role="dialog" tabindex="-1" aria-modal="true" > -
+ +
+ +
diff --git a/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte b/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte index ac1c4f91cb..7898e13ec8 100644 --- a/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte +++ b/packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte @@ -61,6 +61,7 @@ selected={automation._id === selectedAutomationId} on:click={() => selectAutomation(automation._id)} selectedBy={$userSelectedResourceMap[automation._id]} + disabled={automation.disabled} > diff --git a/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte b/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte index 1bc4b0f18e..9465374ae2 100644 --- a/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte +++ b/packages/builder/src/components/automation/AutomationPanel/EditAutomationPopover.svelte @@ -39,6 +39,15 @@ >Duplicate Edit + + {automation.disabled ? "Activate" : "Pause"} + Delete diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 879927343f..85ae1924d0 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -364,6 +364,7 @@ value.customType !== "cron" && value.customType !== "triggerSchema" && value.customType !== "automationFields" && + value.type !== "signature_single" && value.type !== "attachment" && value.type !== "attachment_single" ) @@ -456,7 +457,7 @@ value={inputData[key]} options={Object.keys(table?.schema || {})} /> - {:else if value.type === "attachment"} + {:else if value.type === "attachment" || value.type === "signature_single"}
diff --git a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte index ab020aad08..b5a54138ca 100644 --- a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte +++ b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte @@ -24,6 +24,11 @@ let table let schemaFields + let attachmentTypes = [ + FieldType.ATTACHMENTS, + FieldType.ATTACHMENT_SINGLE, + FieldType.SIGNATURE_SINGLE, + ] $: { table = $tables.list.find(table => table._id === value?.tableId) @@ -120,15 +125,9 @@ {#if schemaFields.length} {#each schemaFields as [field, schema]} {#if !schema.autocolumn} -
+
-
+
{#if isTestModal} onChange(e, field)} useLabel={false} /> -{:else if schema.type === "bb_reference"} +{:else if schema.type === "bb_reference" || schema.type === "bb_reference_single"} onChange(e, field)} useLabel={false} /> -{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE} +{:else if attachmentTypes.includes(schema.type)}
onChange( { detail: - schema.type === FieldType.ATTACHMENT_SINGLE + schema.type === FieldType.ATTACHMENT_SINGLE || + schema.type === FieldType.SIGNATURE_SINGLE ? e.detail.length > 0 - ? { url: e.detail[0].name, filename: e.detail[0].value } + ? { + url: e.detail[0].name, + filename: e.detail[0].value, + } : {} : e.detail.map(({ name, value }) => ({ url: name, @@ -125,7 +136,8 @@ customButtonText={"Add attachment"} keyPlaceholder={"URL"} valuePlaceholder={"Filename"} - actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE && + actionButtonDisabled={(schema.type === FieldType.ATTACHMENT_SINGLE || + schema.type === FieldType.SIGNATURE) && Object.keys(value[field]).length >= 1} />
diff --git a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte index 1ec32cb3fd..b012766171 100644 --- a/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte +++ b/packages/builder/src/components/backend/DataTable/RowFieldControl.svelte @@ -1,4 +1,5 @@ + { + const signatureFile = sigCanvas.toFile() + + let attachRequest = new FormData() + attachRequest.append("file", signatureFile) + + try { + const uploadReq = await API.uploadBuilderAttachment(attachRequest) + const [signatureAttachment] = uploadReq + value = signatureAttachment + } catch (error) { + $notifications.error(error.message || "Failed to save signature") + value = [] + } + }} + title={meta.name} + {value} + bind:this={signatureModal} +/> + {#if type === "options" && meta.constraints.inclusion.length !== 0} {/if} + + diff --git a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte index 77229f3a17..e8e1008e3c 100644 --- a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte @@ -1,5 +1,6 @@ + + + +
+
options.set(e.detail.items)} + on:finalize={e => options.set(e.detail.items)} + > + {#each $enrichedOptions as option (option.id)} +
+
+ +
+
openColorPicker(option.id)} + > +
+ +
+ {#each OptionColours as colorOption} +
handleColorChange(option.id, colorOption)} + style="--color:{colorOption};" + class="circle" + class:selected={colorOption === option.color} + /> + {/each} +
+ +
+
+ handleNameChange(option.id, e.target.value)} + /> + removeInput(option.id)} + /> +
+ {/each} +
+
+ +
Add option
+
+
+ + diff --git a/packages/builder/src/components/backend/Datasources/ConfigEditor/stores/validatedConfig.js b/packages/builder/src/components/backend/Datasources/ConfigEditor/stores/validatedConfig.js index 7b8b2c0975..edd39cc49f 100644 --- a/packages/builder/src/components/backend/Datasources/ConfigEditor/stores/validatedConfig.js +++ b/packages/builder/src/components/backend/Datasources/ConfigEditor/stores/validatedConfig.js @@ -86,8 +86,9 @@ export const createValidatedConfigStore = (integration, config) => { ([$configStore, $errorsStore, $selectedValidatorsStore]) => { const validatedConfig = [] + const allowedRestKeys = ["rejectUnauthorized", "downloadImages"] Object.entries(integration.datasource).forEach(([key, properties]) => { - if (integration.name === "REST" && key !== "rejectUnauthorized") { + if (integration.name === "REST" && !allowedRestKeys.includes(key)) { return } diff --git a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte index 318f459f46..de56fa8ce5 100644 --- a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte +++ b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte @@ -54,6 +54,10 @@ label: "Attachment", value: FieldType.ATTACHMENT_SINGLE, }, + { + label: "Signature", + value: FieldType.SIGNATURE_SINGLE, + }, { label: "Attachment list", value: FieldType.ATTACHMENTS, diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index ba5f75083a..0102d8f7a9 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -54,6 +54,7 @@ export let autofocus = false export let jsBindingWrapping = true export let readonly = false + export let readonlyLineNumbers = false const dispatch = createEventDispatcher() @@ -240,6 +241,9 @@ if (readonly) { complete.push(EditorState.readOnly.of(true)) + if (readonlyLineNumbers) { + complete.push(lineNumbers()) + } } else { complete = [ ...complete, diff --git a/packages/builder/src/components/common/Dropzone.spec.js b/packages/builder/src/components/common/Dropzone.spec.js new file mode 100644 index 0000000000..8bf202223b --- /dev/null +++ b/packages/builder/src/components/common/Dropzone.spec.js @@ -0,0 +1,50 @@ +import { it, expect, describe, vi } from "vitest" +import Dropzone from "./Dropzone.svelte" +import { render, fireEvent } from "@testing-library/svelte" +import { notifications } from "@budibase/bbui" +import { admin } from "stores/portal" + +vi.spyOn(notifications, "error").mockImplementation(() => {}) + +describe("Dropzone", () => { + let instance = null + + afterEach(() => { + vi.restoreAllMocks() + }) + + it("that the Dropzone is rendered", () => { + instance = render(Dropzone, {}) + expect(instance).toBeDefined() + }) + + it("Ensure the correct error message is shown when uploading the file in cloud", async () => { + admin.subscribe = vi.fn().mockImplementation(callback => { + callback({ cloud: true }) + return () => {} + }) + instance = render(Dropzone, { props: { fileSizeLimit: 1000000 } }) // 1MB + const fileInput = instance.getByLabelText("Select a file to upload") + const file = new File(["hello".repeat(2000000)], "hello.png", { + type: "image/png", + }) + await fireEvent.change(fileInput, { target: { files: [file] } }) + expect(notifications.error).toHaveBeenCalledWith( + "Files cannot exceed 1MB. Please try again with smaller files." + ) + }) + + it("Ensure the file size error message is not shown when running on self host", async () => { + admin.subscribe = vi.fn().mockImplementation(callback => { + callback({ cloud: false }) + return () => {} + }) + instance = render(Dropzone, { props: { fileSizeLimit: 1000000 } }) // 1MB + const fileInput = instance.getByLabelText("Select a file to upload") + const file = new File(["hello".repeat(2000000)], "hello.png", { + type: "image/png", + }) + await fireEvent.change(fileInput, { target: { files: [file] } }) + expect(notifications.error).not.toHaveBeenCalled() + }) +}) diff --git a/packages/builder/src/components/common/Dropzone.svelte b/packages/builder/src/components/common/Dropzone.svelte index a864e1d028..37569df0d5 100644 --- a/packages/builder/src/components/common/Dropzone.svelte +++ b/packages/builder/src/components/common/Dropzone.svelte @@ -1,9 +1,11 @@ @@ -14,12 +15,16 @@
-
- -
-
+ {#if !disabled} +
+ +
+
+ +
+ {:else} -
+ {/if}
diff --git a/packages/builder/src/components/common/LinkedRowSelector.svelte b/packages/builder/src/components/common/LinkedRowSelector.svelte index b042d3a9ae..8db4a7dc03 100644 --- a/packages/builder/src/components/common/LinkedRowSelector.svelte +++ b/packages/builder/src/components/common/LinkedRowSelector.svelte @@ -43,7 +43,7 @@ {linkedTable.name} table. -{:else if schema.relationshipType === "one-to-many"} +{:else if schema.relationshipType === "one-to-many" || schema.type === "bb_reference_single"} ($validation.touched.name = true)} + on:change={nameToUrl($values.name)} + disabled={appDeployed} + /> +
+
+ + ($validation.touched.url = true)} + on:change={tidyUrl($values.url)} + placeholder={$values.url + ? $values.url + : `/${resolveAppUrl(null, $values.name)}`} + disabled={appDeployed} + /> +
+
+ + +
+
+ {#if !appDeployed} + + {:else} +
+ Unpublish your app to edit name and URL +
+ {/if} +
+
+
+ + diff --git a/packages/builder/src/components/common/UpdateAppTopNav.svelte b/packages/builder/src/components/common/UpdateAppTopNav.svelte new file mode 100644 index 0000000000..f4a76c4576 --- /dev/null +++ b/packages/builder/src/components/common/UpdateAppTopNav.svelte @@ -0,0 +1,68 @@ + + +
+ + +
{ + formPopover.show() + }} + > + + + + +
+
+ + { + formPopoverOpen = false + }} + on:open={() => { + formPopoverOpen = true + }} +> + +
+ { + formPopover.hide() + }} + /> +
+
+
+ + diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 8c94455c58..d8edf0cbb1 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -11,6 +11,7 @@ import { decodeJSBinding, encodeJSBinding, + processObjectSync, processStringSync, } from "@budibase/string-templates" import { readableToRuntimeBinding } from "dataBinding" @@ -153,13 +154,6 @@ debouncedEval(expression, context, snippets) } - const getBindingValue = (binding, context, snippets) => { - const js = `return $("${binding.runtimeBinding}")` - const hbs = encodeJSBinding(js) - const res = processStringSync(hbs, { ...context, snippets }) - return JSON.stringify(res, null, 2) - } - const highlightJSON = json => { return formatHighlight(json, { keyColor: "#e06c75", @@ -172,11 +166,27 @@ } const enrichBindings = (bindings, context, snippets) => { - return bindings.map(binding => { + // Create a single big array to enrich in one go + const bindingStrings = bindings.map(binding => { + if (binding.runtimeBinding.startsWith('trim "')) { + // Account for nasty hardcoded HBS bindings for roles, for legacy + // compatibility + return `{{ ${binding.runtimeBinding} }}` + } else { + return `{{ literal ${binding.runtimeBinding} }}` + } + }) + const bindingEvauations = processObjectSync(bindingStrings, { + ...context, + snippets, + }) + + // Enrich bindings with evaluations and highlighted HTML + return bindings.map((binding, idx) => { if (!context) { return binding } - const value = getBindingValue(binding, context, snippets) + const value = JSON.stringify(bindingEvauations[idx], null, 2) return { ...binding, value, @@ -237,7 +247,12 @@ const onChangeJSValue = e => { jsValue = encodeJSBinding(e.detail) - updateValue(jsValue) + if (!e.detail?.trim()) { + // Don't bother saving empty values as JS + updateValue(null) + } else { + updateValue(jsValue) + } } onMount(() => { diff --git a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte index 6ef2d35a6c..f364b39ba9 100644 --- a/packages/builder/src/components/common/bindings/BindingSidePanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingSidePanel.svelte @@ -75,13 +75,6 @@ if (!context || !binding.value || binding.value === "") { return } - - // Roles have always been broken for JS. We need to exclude them from - // showing a popover as it will show "Error while executing JS". - if (binding.category === "Role") { - return - } - stopHidingPopover() popoverAnchor = target hoverTarget = { diff --git a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte index d11ebcf87a..0cb10d1aa5 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte @@ -4,7 +4,6 @@ readableToRuntimeBinding, runtimeToReadableBinding, } from "dataBinding" - import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import { createEventDispatcher, setContext } from "svelte" import { isJSBinding } from "@budibase/string-templates" diff --git a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte index fb448cca8d..3a787a70cb 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte @@ -28,6 +28,12 @@ let bindingDrawer let currentVal = value + let attachmentTypes = [ + FieldType.ATTACHMENT_SINGLE, + FieldType.ATTACHMENTS, + FieldType.SIGNATURE_SINGLE, + ] + $: readableValue = runtimeToReadableBinding(bindings, value) $: tempValue = readableValue $: isJS = isJSBinding(value) @@ -105,6 +111,7 @@ boolean: isValidBoolean, attachment: false, attachment_single: false, + signature_single: false, } const isValid = value => { @@ -126,6 +133,7 @@ "bigint", "barcodeqr", "attachment", + "signature_single", "attachment_single", ].includes(type) ) { @@ -169,7 +177,7 @@ {updateOnChange} /> {/if} - {#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE} + {#if !disabled && type !== "formula" && !disabled && !attachmentTypes.includes(type)}
{ diff --git a/packages/builder/src/components/deploy/AppActions.svelte b/packages/builder/src/components/deploy/AppActions.svelte index 105d1ed958..bb950983a6 100644 --- a/packages/builder/src/components/deploy/AppActions.svelte +++ b/packages/builder/src/components/deploy/AppActions.svelte @@ -8,13 +8,11 @@ ActionButton, Icon, Link, - Modal, StatusLight, AbsTooltip, } from "@budibase/bbui" import RevertModal from "components/deploy/RevertModal.svelte" import VersionModal from "components/deploy/VersionModal.svelte" - import UpdateAppModal from "components/start/UpdateAppModal.svelte" import { processStringSync } from "@budibase/string-templates" import ConfirmDialog from "components/common/ConfirmDialog.svelte" import analytics, { Events, EventSource } from "analytics" @@ -26,7 +24,6 @@ isOnlyUser, appStore, deploymentStore, - initialise, sortedScreens, } from "stores/builder" import TourWrap from "components/portal/onboarding/TourWrap.svelte" @@ -37,7 +34,6 @@ export let loaded let unpublishModal - let updateAppModal let revertModal let versionModal let appActionPopover @@ -61,11 +57,6 @@ $: canPublish = !publishing && loaded && $sortedScreens.length > 0 $: lastDeployed = getLastDeployedString($deploymentStore, lastOpened) - const initialiseApp = async () => { - const applicationPkg = await API.fetchAppPackage($appStore.devId) - await initialise(applicationPkg) - } - const getLastDeployedString = deployments => { return deployments?.length ? processStringSync("Published {{ duration time 'millisecond' }} ago", { @@ -247,16 +238,12 @@ appActionPopover.hide() if (isPublished) { viewApp() - } else { - updateAppModal.show() } }} > {$appStore.url} {#if isPublished} - {:else} - {/if} @@ -330,20 +317,6 @@ Are you sure you want to unpublish the app {selectedApp?.name}? - - { - await initialiseApp() - }} - /> - - diff --git a/packages/builder/src/components/design/settings/componentSettings.js b/packages/builder/src/components/design/settings/componentSettings.js index 2a5483d77c..e174a2e6f7 100644 --- a/packages/builder/src/components/design/settings/componentSettings.js +++ b/packages/builder/src/components/design/settings/componentSettings.js @@ -76,6 +76,7 @@ const componentMap = { "field/array": FormFieldSelect, "field/json": FormFieldSelect, "field/barcodeqr": FormFieldSelect, + "field/signature_single": FormFieldSelect, "field/bb_reference": FormFieldSelect, // Some validation types are the same as others, so not all types are // explicitly listed here. e.g. options uses string validation @@ -85,6 +86,8 @@ const componentMap = { "validation/boolean": ValidationEditor, "validation/datetime": ValidationEditor, "validation/attachment": ValidationEditor, + "validation/attachment_single": ValidationEditor, + "validation/signature_single": ValidationEditor, "validation/link": ValidationEditor, "validation/bb_reference": ValidationEditor, } diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/TriggerAutomation.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/TriggerAutomation.svelte index 0a52a693c3..5cd5658063 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/TriggerAutomation.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/TriggerAutomation.svelte @@ -24,7 +24,9 @@ parameters } $: automations = $automationStore.automations - .filter(a => a.definition.trigger?.stepId === TriggerStepID.APP) + .filter( + a => a.definition.trigger?.stepId === TriggerStepID.APP && !a.disabled + ) .map(automation => { const schema = Object.entries( automation.definition.trigger.inputs.fields || {} diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte index 9a88875140..2387fda683 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte @@ -1,5 +1,5 @@ - { - if (!open) { - popover.show() - open = true - } - }} -/> + { - drawers = [] - $draggable.actions.select(componentInstance._id) - }} - on:close={() => { - open = false - if ($draggable.selected === componentInstance._id) { - $draggable.actions.select() - } - }} + open={isOpen} + on:close={close} {anchor} align="left-outside" showPopover={drawers.length === 0} clickOutsideOverride={drawers.length > 0} maxHeight={600} offset={18} - handlePostionUpdate={customPositionHandler} > diff --git a/packages/builder/src/components/design/settings/controls/EditComponentPopover/index.js b/packages/builder/src/components/design/settings/controls/EditComponentPopover/index.js deleted file mode 100644 index 2dc3f60185..0000000000 --- a/packages/builder/src/components/design/settings/controls/EditComponentPopover/index.js +++ /dev/null @@ -1,18 +0,0 @@ -export const customPositionHandler = (anchorBounds, eleBounds, cfg) => { - let { left, top, offset } = cfg - let percentageOffset = 30 - // left-outside - left = anchorBounds.left - eleBounds.width - (offset || 5) - - // shift up from the anchor, if space allows - let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset - let defaultTop = anchorBounds.top - offsetPos - - if (window.innerHeight - defaultTop < eleBounds.height) { - top = window.innerHeight - eleBounds.height - 5 - } else { - top = anchorBounds.top - offsetPos - } - - return { ...cfg, left, top } -} diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/index.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/index.svelte new file mode 100644 index 0000000000..d9714c2e7a --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/index.svelte @@ -0,0 +1,48 @@ + + + +
+ {#if subject === subjects.column} + + {:else if subject === subjects.support} + + {:else if subject === subjects.stringsAsNumbers} + + {:else if subject === subjects.notRequired} + + {:else if subject === subjects.datesAsNumbers} + + {:else if subject === subjects.scalarJsonOnly} + + {:else if subject === subjects.numbersAsDates} + + {:else if subject === subjects.stringsAsDates} + + {/if} +
+
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/Column.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/Column.svelte new file mode 100644 index 0000000000..7fbac09827 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/Column.svelte @@ -0,0 +1,147 @@ + + + +
+ Column Overview for {columnName} +
+
+ {#if schema.type === "string"} + + {:else if schema.type === "datetime"} + + + + + {:else if schema.type === "number"} + + + {:else if schema.type === "array"} + {#each schema?.constraints?.inclusion ?? [] as option, index} + + {option} + + {/each} + {:else if schema.type === "options"} + {#each schema?.constraints?.inclusion ?? [] as option, index} + + {option} + + {/each} + {:else if schema.type === "json"} + + + + {:else if schema.type === "formula"} + + + + + {:else if schema.type === "link"} + + table._id === schema?.tableId) + ?.name} + /> + + {:else if schema.type === "bb_reference"} + + {/if} + +
+
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/DatesAsNumbers.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/DatesAsNumbers.svelte new file mode 100644 index 0000000000..e5cab5dc15 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/DatesAsNumbers.svelte @@ -0,0 +1,63 @@ + + + +
+ A datetime value can be used in place of a numeric value, but it will be + converted to a UNIX time timestamp, which is the number of milliseconds + since Jan 1st 1970. A more recent moment in time will be a higher number. +
+ + + + + {new Date(946684800000).toLocaleString()} + + {"->"} 946684800000 + + + + {new Date(1577836800000).toLocaleString()} + + {"->"} 1577836800000 + + + Now{"->"} {timestamp} + + +
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/NotRequired.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/NotRequired.svelte new file mode 100644 index 0000000000..f28e9a227e --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/NotRequired.svelte @@ -0,0 +1,11 @@ + + + +
+ A required constraint can be applied to columns to ensure a value + is always present. If a column doesn't have this constraint, then its value for + a particular row could he missing. +
+
diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/NumbersAsDates.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/NumbersAsDates.svelte new file mode 100644 index 0000000000..d69228544e --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/NumbersAsDates.svelte @@ -0,0 +1,65 @@ + + + +
+ A number value can be used in place of a datetime value, but it will be + parsed as a UNIX time timestamp, which is the number of milliseconds + since Jan 1st 1970. A more recent moment in time will be a higher number. +
+ + + + 946684800000 + {"->"} + + {new Date(946684800000).toLocaleString()} + + + + 1577836800000 + {"->"} + + {new Date(1577836800000).toLocaleString()} + + + + {timestamp} + {"->"} + Now + + +
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/ScalarJsonOnly.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/ScalarJsonOnly.svelte new file mode 100644 index 0000000000..11fe3c7838 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/ScalarJsonOnly.svelte @@ -0,0 +1,71 @@ + + + +
+ JSON objects can't be used here, but any number, string or boolean values nested within said + object can be if they are otherwise compatible with the input. These scalar values + can be selected from the same menu as this parent and take the form parent.child. +
+ + {#if scalarDescendants.length > 0} + + {#each scalarDescendants as descendant} + + {descendant.name}-{descendant.type} + + {/each} + + {/if} +
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/StringsAsDates.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/StringsAsDates.svelte new file mode 100644 index 0000000000..7303eac7cb --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/StringsAsDates.svelte @@ -0,0 +1,107 @@ + + + +
+ A string value can be used in place of a datetime value, but it will be + parsed as: +
+
+ A UNIX time timestamp, which is the number of milliseconds since + Jan 1st 1970. A more recent moment in time will be a higher number. +
+ + + + 946684800000 + {"->"} + + {new Date(946684800000).toLocaleString()} + + + + 1577836800000 + {"->"} + + {new Date(1577836800000).toLocaleString()} + + + + {timestamp} + {"->"} + Now + + +
+ An ISO 8601 datetime string, which represents an exact moment + in time as well as the potentional to store the timezone it occured in. +
+
+ + + 2000-01-01T00:00:00.000Z + ↓ + + {new Date(946684800000).toLocaleString()} + + + + 2000-01-01T00:00:00.000Z + ↓ + + {new Date(1577836800000).toLocaleString()} + + + + {iso} + ↓ + Now + + +
+
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/StringsAsNumbers.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/StringsAsNumbers.svelte new file mode 100644 index 0000000000..e30a450d80 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/StringsAsNumbers.svelte @@ -0,0 +1,56 @@ + + + +
+ Text can be used in place of numbers in certain scenarios, but care needs to + be taken; if the value isn't purely numerical it may be converted in an + unexpected way. +
+ + + + "100"{"->"}100 + + + "100k"{"->"}100 + + + "100,000"{"->"}100 + + + "100 million"{"->"}100 + + + "100.9"{"->"}100.9 + + + "One hundred"{"->"}Error + + +
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/Support.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/Support.svelte new file mode 100644 index 0000000000..0419c0ad16 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/Support.svelte @@ -0,0 +1,35 @@ + + + +
+ + Fully compatible with the input as long as the data is present. +
+
+ + Partially compatible with the input, but beware of other caveats + mentioned. +
+
+ + Incompatible with the component. +
+
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/BindingValue.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/BindingValue.svelte new file mode 100644 index 0000000000..811283ba51 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/BindingValue.svelte @@ -0,0 +1,39 @@ + + +
+ +
+ + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Block.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Block.svelte new file mode 100644 index 0000000000..449b40304d --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Block.svelte @@ -0,0 +1,30 @@ + + + + + + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/ExampleLine.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/ExampleLine.svelte new file mode 100644 index 0000000000..6dd3e10a39 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/ExampleLine.svelte @@ -0,0 +1,12 @@ +
  • +
    + +
    +
  • + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/ExampleSection.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/ExampleSection.svelte new file mode 100644 index 0000000000..4da4f5141b --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/ExampleSection.svelte @@ -0,0 +1,32 @@ + + +
    + + + {heading} + + +
      + +
    +
    + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/JSONValue.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/JSONValue.svelte new file mode 100644 index 0000000000..4703398b2d --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/JSONValue.svelte @@ -0,0 +1,22 @@ + + +
    +  {value}
    +
    + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Property.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Property.svelte new file mode 100644 index 0000000000..8d6e853ab4 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Property.svelte @@ -0,0 +1,49 @@ + + +
    + + + {name} + + + - + + + {value} + + +
    + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Section.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Section.svelte new file mode 100644 index 0000000000..486e111725 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Section.svelte @@ -0,0 +1,11 @@ +
    + +
    + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Subject.svelte b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Subject.svelte new file mode 100644 index 0000000000..4e21160cae --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/Subject.svelte @@ -0,0 +1,51 @@ + + +
    + + + {heading} + + +
    +
    +
    + +
    + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/index.js b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/index.js new file mode 100644 index 0000000000..d174f4d6cc --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/components/index.js @@ -0,0 +1,8 @@ +export { default as Subject } from "./Subject.svelte" +export { default as Property } from "./Property.svelte" +export { default as JSONValue } from "./JSONValue.svelte" +export { default as BindingValue } from "./BindingValue.svelte" +export { default as Section } from "./Section.svelte" +export { default as Block } from "./Block.svelte" +export { default as ExampleSection } from "./ExampleSection.svelte" +export { default as ExampleLine } from "./ExampleLine.svelte" diff --git a/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/index.js b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/index.js new file mode 100644 index 0000000000..c7f54d6415 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/DetailsModal/subjects/index.js @@ -0,0 +1,8 @@ +export { default as Column } from "./Column.svelte" +export { default as NotRequired } from "./NotRequired.svelte" +export { default as StringsAsNumbers } from "./StringsAsNumbers.svelte" +export { default as Support } from "./Support.svelte" +export { default as DatesAsNumbers } from "./DatesAsNumbers.svelte" +export { default as ScalarJsonOnly } from "./ScalarJsonOnly.svelte" +export { default as StringsAsDates } from "./StringsAsDates.svelte" +export { default as NumbersAsDates } from "./NumbersAsDates.svelte" diff --git a/packages/builder/src/components/design/settings/controls/Explanation/Explanation.svelte b/packages/builder/src/components/design/settings/controls/Explanation/Explanation.svelte new file mode 100644 index 0000000000..bc45e410c9 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/Explanation.svelte @@ -0,0 +1,102 @@ + + +
    + + + {#if messages.includes(messageConstants.stringAsNumber)} + + {/if} + {#if messages.includes(messageConstants.notRequired)} + + {/if} + {#if messages.includes(messageConstants.jsonPrimitivesOnly)} + + {/if} + {#if messages.includes(messageConstants.dateAsNumber)} + + {/if} + {#if messages.includes(messageConstants.numberAsDate)} + + {/if} + {#if messages.includes(messageConstants.stringAsDate)} + + {/if} +
    + +{#if detailsModalSubject !== subjects.none} + +{/if} + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/explanation.js b/packages/builder/src/components/design/settings/controls/Explanation/explanation.js new file mode 100644 index 0000000000..4e024c67fc --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/explanation.js @@ -0,0 +1,100 @@ +export const messages = { + jsonPrimitivesOnly: Symbol("explanation-json-primitives-only"), + stringAsNumber: Symbol("explanation-string-as-number"), + dateAsNumber: Symbol("explanation-date-as-number"), + numberAsDate: Symbol("explanation-number-as-date"), + stringAsDate: Symbol("explanation-string-as-date"), + notRequired: Symbol("explanation-not-required"), + contextError: Symbol("explanation-context-error"), +} + +export const support = { + unsupported: Symbol("explanation-unsupported"), + partialSupport: Symbol("explanation-partialSupport"), + supported: Symbol("explanation-supported"), +} + +const getSupport = (type, explanation) => { + if (!explanation?.typeSupport) { + return support.supported + } + + if ( + explanation?.typeSupport?.supported?.find( + mapping => mapping === type || mapping?.type === type + ) + ) { + return support.supported + } + + if ( + explanation?.typeSupport?.partialSupport?.find( + mapping => mapping === type || mapping?.type === type + ) + ) { + return support.partialSupport + } + + return support.unsupported +} + +const getSupportMessage = (type, explanation) => { + if (!explanation?.typeSupport) { + return null + } + + const supported = explanation?.typeSupport?.supported?.find( + mapping => mapping?.type === type + ) + if (supported) { + return messages[supported?.message] + } + + const partialSupport = explanation?.typeSupport?.partialSupport?.find( + mapping => mapping?.type === type + ) + if (partialSupport) { + return messages[partialSupport?.message] + } + + const unsupported = explanation?.typeSupport?.unsupported?.find( + mapping => mapping?.type === type + ) + if (unsupported) { + return messages[unsupported?.message] + } + + return null +} + +export const getExplanationMessagesAndSupport = (fieldSchema, explanation) => { + try { + const explanationMessagesAndSupport = { + support: getSupport(fieldSchema.type, explanation), + messages: [getSupportMessage(fieldSchema.type, explanation)], + } + + const isRequired = fieldSchema?.constraints?.presence?.allowEmpty === false + if (!isRequired) { + explanationMessagesAndSupport.messages.push(messages.notRequired) + } + + return explanationMessagesAndSupport + } catch (e) { + return { + support: support.partialSupport, + messages: [messages.contextError], + } + } +} + +export const getExplanationWithPresets = (explanation, presets) => { + if (explanation?.typeSupport?.preset) { + return { + ...explanation, + typeSupport: presets[explanation?.typeSupport?.preset], + } + } + + return explanation +} diff --git a/packages/builder/src/components/design/settings/controls/Explanation/index.js b/packages/builder/src/components/design/settings/controls/Explanation/index.js new file mode 100644 index 0000000000..5780c1de14 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/index.js @@ -0,0 +1 @@ +export { default as Explanation } from "./Explanation.svelte" diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/Column.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/Column.svelte new file mode 100644 index 0000000000..9c3b87f8b9 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/Column.svelte @@ -0,0 +1,84 @@ + + + + setExplanationSubject(subjects.column)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + href={tableHref} + text={columnName} + /> + + + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/DateAsNumber.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/DateAsNumber.svelte new file mode 100644 index 0000000000..ba5eb9b0e1 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/DateAsNumber.svelte @@ -0,0 +1,16 @@ + + + + + setExplanationSubject(subjects.datesAsNumbers)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + text="UNIX time value" + /> + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/JSONPrimitivesOnly.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/JSONPrimitivesOnly.svelte new file mode 100644 index 0000000000..2286c09044 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/JSONPrimitivesOnly.svelte @@ -0,0 +1,21 @@ + + + + setExplanationSubject(subjects.scalarJsonOnly)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + >Scalar JSON values + + + + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/NotRequired.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/NotRequired.svelte new file mode 100644 index 0000000000..e705bd68e6 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/NotRequired.svelte @@ -0,0 +1,25 @@ + + + + + setExplanationSubject(subjects.notRequired)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + text="required" + /> + + + + + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/NumberAsDate.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/NumberAsDate.svelte new file mode 100644 index 0000000000..c6413c13a5 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/NumberAsDate.svelte @@ -0,0 +1,16 @@ + + + + + setExplanationSubject(subjects.numbersAsDates)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + text="UNIX time value" + /> + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/StringAsDate.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/StringAsDate.svelte new file mode 100644 index 0000000000..72267b6f47 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/StringAsDate.svelte @@ -0,0 +1,16 @@ + + + + + setExplanationSubject(subjects.stringsAsDates)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + text="UNIX time or ISO 8601 value" + /> + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/StringAsNumber.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/StringAsNumber.svelte new file mode 100644 index 0000000000..937545c1c3 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/StringAsNumber.svelte @@ -0,0 +1,16 @@ + + + + + setExplanationSubject(subjects.stringsAsNumbers)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + text="non-numerical values" + /> + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/Support.svelte b/packages/builder/src/components/design/settings/controls/Explanation/lines/Support.svelte new file mode 100644 index 0000000000..848ab208fb --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/Support.svelte @@ -0,0 +1,59 @@ + + + + setExplanationSubject(subjects.support)} + on:mouseleave={() => setExplanationSubject(subjects.none)} + {icon} + {color} + {text} + /> + + + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/lines/index.js b/packages/builder/src/components/design/settings/controls/Explanation/lines/index.js new file mode 100644 index 0000000000..beff239398 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/lines/index.js @@ -0,0 +1,8 @@ +export { default as Column } from "./Column.svelte" +export { default as NotRequired } from "./NotRequired.svelte" +export { default as StringAsNumber } from "./StringAsNumber.svelte" +export { default as Support } from "./Support.svelte" +export { default as JSONPrimitivesOnly } from "./JSONPrimitivesOnly.svelte" +export { default as DateAsNumber } from "./DateAsNumber.svelte" +export { default as NumberAsDate } from "./NumberAsDate.svelte" +export { default as StringAsDate } from "./StringAsDate.svelte" diff --git a/packages/builder/src/components/design/settings/controls/Explanation/subjects.js b/packages/builder/src/components/design/settings/controls/Explanation/subjects.js new file mode 100644 index 0000000000..1f94fa6fd0 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/subjects.js @@ -0,0 +1,13 @@ +const subjects = { + column: Symbol("details-modal-column"), + support: Symbol("details-modal-support"), + stringsAsNumbers: Symbol("details-modal-strings-as-numbers"), + datesAsNumbers: Symbol("details-modal-dates-as-numbers"), + numbersAsDates: Symbol("explanation-numbers-as-dates"), + stringsAsDates: Symbol("explanation-strings-as-dates"), + notRequired: Symbol("details-modal-not-required"), + scalarJsonOnly: Symbol("explanation-scalar-json-only"), + none: Symbol("details-modal-none"), +} + +export default subjects diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/Comma.svelte b/packages/builder/src/components/design/settings/controls/Explanation/typography/Comma.svelte new file mode 100644 index 0000000000..f16bd16054 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/Comma.svelte @@ -0,0 +1,14 @@ +, + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/DocumentationLink.svelte b/packages/builder/src/components/design/settings/controls/Explanation/typography/DocumentationLink.svelte new file mode 100644 index 0000000000..55b9732d4a --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/DocumentationLink.svelte @@ -0,0 +1,66 @@ + + + + + + + {text} + + + + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/InfoWord.svelte b/packages/builder/src/components/design/settings/controls/Explanation/typography/InfoWord.svelte new file mode 100644 index 0000000000..cac12cbfb5 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/InfoWord.svelte @@ -0,0 +1,78 @@ + + +{#if href !== null} + + {#if icon} + + {/if} + + + {text} + + + +{:else} + +{/if} + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/Line.svelte b/packages/builder/src/components/design/settings/controls/Explanation/typography/Line.svelte new file mode 100644 index 0000000000..5d28ba0423 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/Line.svelte @@ -0,0 +1,41 @@ + + +
    + • +
    + +
    +
    + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/Period.svelte b/packages/builder/src/components/design/settings/controls/Explanation/typography/Period.svelte new file mode 100644 index 0000000000..d1bbafe6a6 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/Period.svelte @@ -0,0 +1,13 @@ +. + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/Space.svelte b/packages/builder/src/components/design/settings/controls/Explanation/typography/Space.svelte new file mode 100644 index 0000000000..b88831d760 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/Space.svelte @@ -0,0 +1,9 @@ +{" "} + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/Text.svelte b/packages/builder/src/components/design/settings/controls/Explanation/typography/Text.svelte new file mode 100644 index 0000000000..6562c9e864 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/Text.svelte @@ -0,0 +1,64 @@ + + +{#each words as word} + {#if word === " "} + + {:else if word === ","} + + {:else if word === "."} + + {:else} + + {word} + + {/if} +{/each} + + diff --git a/packages/builder/src/components/design/settings/controls/Explanation/typography/index.js b/packages/builder/src/components/design/settings/controls/Explanation/typography/index.js new file mode 100644 index 0000000000..102b65190d --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/Explanation/typography/index.js @@ -0,0 +1,7 @@ +export { default as Space } from "./Space.svelte" +export { default as Comma } from "./Comma.svelte" +export { default as Period } from "./Period.svelte" +export { default as Text } from "./Text.svelte" +export { default as InfoWord } from "./InfoWord.svelte" +export { default as DocumentationLink } from "./DocumentationLink.svelte" +export { default as Line } from "./Line.svelte" diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte index 771bcf20e0..27590a9858 100644 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte @@ -1,5 +1,5 @@ - + +{#if explanation} + + + +{/if} diff --git a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte index 0ab67cbada..d6ad696e82 100644 --- a/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte +++ b/packages/builder/src/components/design/settings/controls/FilterEditor/FilterBuilder.svelte @@ -4,6 +4,7 @@ import { dataFilters } from "@budibase/shared-core" import { FilterBuilder } from "@budibase/frontend-core" + import { tables } from "stores/builder" import { createEventDispatcher, onMount } from "svelte" @@ -58,6 +59,7 @@ - import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte" + import EditComponentPopover from "../EditComponentPopover.svelte" import { Toggle, Icon } from "@budibase/bbui" import { createEventDispatcher } from "svelte" import { cloneDeep } from "lodash/fp" diff --git a/packages/builder/src/components/design/settings/controls/GridColumnConfiguration/PrimaryColumnFieldSetting.svelte b/packages/builder/src/components/design/settings/controls/GridColumnConfiguration/PrimaryColumnFieldSetting.svelte index 14cffb5df9..57423e8667 100644 --- a/packages/builder/src/components/design/settings/controls/GridColumnConfiguration/PrimaryColumnFieldSetting.svelte +++ b/packages/builder/src/components/design/settings/controls/GridColumnConfiguration/PrimaryColumnFieldSetting.svelte @@ -1,5 +1,5 @@ @@ -123,49 +123,52 @@
    - +
    -
    -
    - {#each alphabet as letter, idx} - switchLetter(letter)} - > - {letter} - - {#if idx !== alphabet.length - 1}-{/if} - {/each} -
    -
    -
    - { - if (event.key === "Enter") { - searchForIcon() - } - }} - thin - placeholder="Search Icon" - /> -
    - - {#if value} - - {/if} -
    -
    -
    - pageClick(false)}> - - - {pagerText} - pageClick(true)}> - - -
    +
    + {#each alphabet as letter, idx} + switchLetter(letter)} + > + {letter} + + {#if idx !== alphabet.length - 1}-{/if} + {/each} +
    +
    + { + if (event.key === "Enter") { + searchForIcon() + } + }} + thin + placeholder="Search icons" + /> + + {#if value} + + {/if} +
    +
    +
    + pageClick(false)} + class="page-btn ri-arrow-left-line ri-sm" + /> + {pagerText} + pageClick(true)} + class="page-btn ri-arrow-right-line ri-sm" + />
    {#if pagedIcons.length > 0} @@ -175,7 +178,7 @@
    (value = `ri-${icon}-fill`)} + on:click={() => select(`ri-${icon}-fill`)} >
    @@ -185,7 +188,7 @@
    (value = `ri-${icon}-line`)} + on:click={() => select(`ri-${icon}-line`)} >
    @@ -196,11 +199,7 @@ {/if}
    {:else} -
    -
    - {`There is no icons for this ${searchTerm ? "search" : "page"}`} -
    -
    +
    No icons found
    {/if}
    @@ -208,11 +207,13 @@ diff --git a/packages/builder/src/pages/builder/invite/index.svelte b/packages/builder/src/pages/builder/invite/index.svelte index 12d6ae77cc..245309dc32 100644 --- a/packages/builder/src/pages/builder/invite/index.svelte +++ b/packages/builder/src/pages/builder/invite/index.svelte @@ -32,8 +32,14 @@ onboarding = true try { const { password, firstName, lastName } = formData - await users.acceptInvite(inviteCode, password, firstName, lastName) + const user = await users.acceptInvite( + inviteCode, + password, + firstName, + lastName + ) notifications.success("Invitation accepted successfully") + auth.setOrg(user.tenantId) await login() } catch (error) { notifications.error(error.message) @@ -66,7 +72,7 @@ notifications.success("Logged in successfully") $goto("../portal") } catch (err) { - notifications.error(err.message ? err.message : "Invalid credentials") //not likely, considering. + notifications.error(err.message ? err.message : "Something went wrong") } } @@ -141,12 +147,19 @@ password: e.detail, } }} + validateOn="blur" validate={() => { let fieldError = {} - fieldError["password"] = !formData.password - ? "Please enter a password" - : undefined + function validatePassword() { + if (!formData.password) { + return "Please enter a password" + } else if (formData.password.length < 8) { + return "Please enter at least 8 characters" + } + return undefined + } + fieldError["password"] = validatePassword() fieldError["confirmationPassword"] = !passwordsMatch( diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte index c2fbce5747..58da310104 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -60,6 +60,7 @@ userLimitReachedModal let searchEmail = undefined let selectedRows = [] + let selectedInvites = [] let bulkSaveResponse let customRenderers = [ { column: "email", component: EmailTableRenderer }, @@ -123,7 +124,7 @@ return {} } let pendingSchema = JSON.parse(JSON.stringify(tblSchema)) - pendingSchema.email.displayName = "Pending Invites" + pendingSchema.email.displayName = "Pending Users" return pendingSchema } @@ -132,6 +133,7 @@ const { admin, builder, userGroups, apps } = invite.info return { + _id: invite.code, email: invite.email, builder, admin, @@ -260,9 +262,26 @@ return } - await users.bulkDelete(ids) - notifications.success(`Successfully deleted ${selectedRows.length} rows`) + if (ids.length > 0) { + await users.bulkDelete(ids) + } + + if (selectedInvites.length > 0) { + await users.removeInvites( + selectedInvites.map(invite => ({ + code: invite._id, + })) + ) + pendingInvites = await users.getInvites() + } + + notifications.success( + `Successfully deleted ${ + selectedRows.length + selectedInvites.length + } users` + ) selectedRows = [] + selectedInvites = [] await fetch.refresh() } catch (error) { notifications.error("Error deleting users") @@ -328,15 +347,15 @@
    {/if}
    - - {#if selectedRows.length > 0} + {#if selectedRows.length > 0 || selectedInvites.length > 0} {/if} +
    ({ + ...state, + typeSupportPresets, + })) + } + async syncAppRoutes() { const resp = await API.fetchAppRoutes() this.update(state => ({ diff --git a/packages/builder/src/stores/builder/automations.js b/packages/builder/src/stores/builder/automations.js index cbe48cef33..ff53dda86b 100644 --- a/packages/builder/src/stores/builder/automations.js +++ b/packages/builder/src/stores/builder/automations.js @@ -82,6 +82,7 @@ const automationActions = store => ({ steps: [], trigger, }, + disabled: false, } const response = await store.actions.save(automation) await store.actions.fetch() @@ -134,6 +135,28 @@ const automationActions = store => ({ }) await store.actions.fetch() }, + toggleDisabled: async automationId => { + let automation + try { + automation = store.actions.getDefinition(automationId) + if (!automation) { + return + } + automation.disabled = !automation.disabled + await store.actions.save(automation) + notifications.success( + `Automation ${ + automation.disabled ? "enabled" : "disabled" + } successfully` + ) + } catch (error) { + notifications.error( + `Error ${ + automation && automation.disabled ? "enabling" : "disabling" + } automation` + ) + } + }, updateBlockInputs: async (block, data) => { // Create new modified block let newBlock = { diff --git a/packages/builder/src/stores/builder/builder.js b/packages/builder/src/stores/builder/builder.js index 055498bc91..d002062da9 100644 --- a/packages/builder/src/stores/builder/builder.js +++ b/packages/builder/src/stores/builder/builder.js @@ -14,7 +14,6 @@ export const INITIAL_BUILDER_STATE = { tourKey: null, tourStepKey: null, hoveredComponentId: null, - fonts: null, } export class BuilderStore extends BudiStore { @@ -37,16 +36,6 @@ export class BuilderStore extends BudiStore { this.websocket } - loadFonts(fontFaces) { - const ff = fontFaces.map( - fontFace => `${fontFace.family}-${fontFace.weight}` - ) - this.update(state => ({ - ...state, - fonts: [...(state.fonts || []), ...ff], - })) - } - init(app) { if (!app?.appId) { console.error("BuilderStore: No appId supplied for websocket") diff --git a/packages/builder/src/stores/builder/components.js b/packages/builder/src/stores/builder/components.js index 19a4f41532..c281c73dfe 100644 --- a/packages/builder/src/stores/builder/components.js +++ b/packages/builder/src/stores/builder/components.js @@ -20,7 +20,7 @@ import { previewStore, tables, componentTreeNodesStore, -} from "stores/builder/index" +} from "stores/builder" import { buildFormSchema, getSchemaForDatasource } from "dataBinding" import { BUDIBASE_INTERNAL_DB_ID, @@ -30,6 +30,7 @@ import { } from "constants/backend" import BudiStore from "../BudiStore" import { Utils } from "@budibase/frontend-core" +import { FieldType } from "@budibase/types" export const INITIAL_COMPONENTS_STATE = { components: {}, @@ -108,6 +109,7 @@ export class ComponentStore extends BudiStore { // Sync client features to app store appStore.syncClientFeatures(components.features) + appStore.syncClientTypeSupportPresets(components?.typeSupportPresets ?? {}) return components } @@ -295,6 +297,80 @@ export class ComponentStore extends BudiStore { } } }) + + // Add default bindings to card blocks + if (component._component.endsWith("/cardsblock")) { + // Only proceed if the card is empty, i.e. we just changed datasource or + // just created the card + const cardKeys = ["cardTitle", "cardSubtitle", "cardDescription"] + if (cardKeys.every(key => !component[key]) && !component.cardImageURL) { + const { _id, dataSource } = component + if (dataSource) { + const { schema, table } = getSchemaForDatasource(screen, dataSource) + + // Finds fields by types from the schema of the configured datasource + const findFieldTypes = fieldTypes => { + if (!Array.isArray(fieldTypes)) { + fieldTypes = [fieldTypes] + } + return Object.entries(schema || {}) + .filter(([name, fieldSchema]) => { + return ( + fieldTypes.includes(fieldSchema.type) && + !fieldSchema.autoColumn && + name !== table?.primaryDisplay && + !name.startsWith("_") + ) + }) + .map(([name]) => name) + } + + // Inserts a card binding for a certain setting + const addBinding = (key, fallback, ...parts) => { + if (parts.some(x => x == null)) { + component[key] = fallback + } else { + parts.unshift(`${_id}-repeater`) + component[key] = `{{ ${parts.map(safe).join(".")} }}` + } + } + + // Extract good field candidates to prefill our cards with. + // Use the primary display as the best field, if it exists. + const shortFields = [ + ...findFieldTypes(FieldType.STRING), + ...findFieldTypes(FieldType.OPTIONS), + ...findFieldTypes(FieldType.ARRAY), + ...findFieldTypes(FieldType.NUMBER), + ] + const longFields = findFieldTypes(FieldType.LONGFORM) + if (schema?.[table?.primaryDisplay]) { + shortFields.unshift(table.primaryDisplay) + } + + // Fill title and subtitle with short fields + addBinding("cardTitle", "Title", shortFields[0]) + addBinding("cardSubtitle", "Subtitle", shortFields[1]) + + // Fill description with a long field if possible + const longField = longFields[0] ?? shortFields[2] + addBinding("cardDescription", "Description", longField) + + // Attempt to fill the image setting. + // Check single attachment fields first. + let imgField = findFieldTypes(FieldType.ATTACHMENT_SINGLE)[0] + if (imgField) { + addBinding("cardImageURL", null, imgField, "url") + } else { + // Then try multi-attachment fields if no single ones exist + imgField = findFieldTypes(FieldType.ATTACHMENTS)[0] + if (imgField) { + addBinding("cardImageURL", null, imgField, 0, "url") + } + } + } + } + } } /** @@ -323,21 +399,21 @@ export class ComponentStore extends BudiStore { ...presetProps, } - // Enrich empty settings + // Standard post processing this.enrichEmptySettings(instance, { parent, screen: get(selectedScreen), useDefaultValues: true, }) - - // Migrate nested component settings this.migrateSettings(instance) - // Add any extra properties the component needs + // Custom post processing for creation only let extras = {} if (definition.hasChildren) { extras._children = [] } + + // Add step name to form steps if (componentName.endsWith("/formstep")) { const parentForm = findClosestMatchingComponent( get(selectedScreen).props, @@ -350,6 +426,7 @@ export class ComponentStore extends BudiStore { extras.step = formSteps.length + 1 extras._instanceName = `Step ${formSteps.length + 1}` } + return { ...cloneDeep(instance), ...extras, @@ -462,7 +539,6 @@ export class ComponentStore extends BudiStore { if (!componentId || !screenId) { const state = get(this.store) componentId = componentId || state.selectedComponentId - const screenState = get(screenStore) screenId = screenId || screenState.selectedScreenId } @@ -470,7 +546,6 @@ export class ComponentStore extends BudiStore { return } const patchScreen = screen => { - // findComponent looks in the tree not comp.settings[0] let component = findComponent(screen.props, componentId) if (!component) { return false @@ -479,7 +554,7 @@ export class ComponentStore extends BudiStore { // Mutates the fetched component with updates const patchResult = patchFn(component, screen) - // Mutates the component with any required settings updates + // Post processing const migrated = this.migrateSettings(component) // Returning an explicit false signifies that we should skip this diff --git a/packages/builder/src/stores/builder/tests/app.test.js b/packages/builder/src/stores/builder/tests/app.test.js index e0e5d17ba6..728f472317 100644 --- a/packages/builder/src/stores/builder/tests/app.test.js +++ b/packages/builder/src/stores/builder/tests/app.test.js @@ -91,6 +91,14 @@ describe("Application Meta Store", () => { }) }) + it("Sync type support information to state", async ctx => { + ctx.test.appStore.syncClientTypeSupportPresets({ preset: "information" }) + + expect(ctx.test.store.typeSupportPresets).toStrictEqual({ + preset: "information", + }) + }) + it("Sync component feature flags to state", async ctx => { ctx.test.appStore.syncClientFeatures(clientFeaturesResp) diff --git a/packages/builder/src/stores/builder/tests/component.test.js b/packages/builder/src/stores/builder/tests/component.test.js index b6c9ca27cd..80a0c8077d 100644 --- a/packages/builder/src/stores/builder/tests/component.test.js +++ b/packages/builder/src/stores/builder/tests/component.test.js @@ -23,6 +23,7 @@ import { DB_TYPE_EXTERNAL, DEFAULT_BB_DATASOURCE_ID, } from "constants/backend" +import { makePropSafe as safe } from "@budibase/string-templates" // Could move to fixtures const COMP_PREFIX = "@budibase/standard-components" @@ -42,6 +43,7 @@ vi.mock("stores/builder", async () => { update: mockAppStore.update, set: mockAppStore.set, syncClientFeatures: vi.fn(), + syncClientTypeSupportPresets: vi.fn(), } const mockTableStore = writable() const tables = { @@ -359,8 +361,30 @@ describe("Component store", () => { resourceId: internalTableDoc._id, type: "table", }) + + return comp } + it("enrichEmptySettings - initialise cards blocks with correct fields", async ctx => { + const comp = enrichSettingsDS("cardsblock", ctx) + const expectBinding = (setting, ...parts) => { + expect(comp[setting]).toStrictEqual( + `{{ ${safe(`${comp._id}-repeater`)}.${parts.map(safe).join(".")} }}` + ) + } + expectBinding("cardTitle", internalTableDoc.schema.MediaTitle.name) + expectBinding("cardSubtitle", internalTableDoc.schema.MediaVersion.name) + expectBinding( + "cardDescription", + internalTableDoc.schema.MediaDescription.name + ) + expectBinding( + "cardImageURL", + internalTableDoc.schema.MediaImage.name, + "url" + ) + }) + it("enrichEmptySettings - set default datasource for 'table' setting type", async ctx => { enrichSettingsDS("formblock", ctx) }) diff --git a/packages/builder/src/stores/builder/tests/fixtures/index.js b/packages/builder/src/stores/builder/tests/fixtures/index.js index f636790f53..fbad17e374 100644 --- a/packages/builder/src/stores/builder/tests/fixtures/index.js +++ b/packages/builder/src/stores/builder/tests/fixtures/index.js @@ -8,6 +8,7 @@ import { DB_TYPE_EXTERNAL, DEFAULT_BB_DATASOURCE_ID, } from "constants/backend" +import { FieldType } from "@budibase/types" const getDocId = () => { return v4().replace(/-/g, "") @@ -45,6 +46,52 @@ export const COMPONENT_DEFINITIONS = { }, ], }, + cardsblock: { + block: true, + name: "Cards Block", + settings: [ + { + type: "dataSource", + label: "Data", + key: "dataSource", + required: true, + }, + { + section: true, + name: "Cards", + settings: [ + { + type: "text", + key: "cardTitle", + label: "Title", + nested: true, + resetOn: "dataSource", + }, + { + type: "text", + key: "cardSubtitle", + label: "Subtitle", + nested: true, + resetOn: "dataSource", + }, + { + type: "text", + key: "cardDescription", + label: "Description", + nested: true, + resetOn: "dataSource", + }, + { + type: "text", + key: "cardImageURL", + label: "Image URL", + nested: true, + resetOn: "dataSource", + }, + ], + }, + ], + }, container: { name: "Container", }, @@ -262,14 +309,23 @@ export const internalTableDoc = { name: "Media", sourceId: BUDIBASE_INTERNAL_DB_ID, sourceType: DB_TYPE_INTERNAL, + primaryDisplay: "MediaTitle", schema: { MediaTitle: { name: "MediaTitle", - type: "string", + type: FieldType.STRING, }, MediaVersion: { name: "MediaVersion", - type: "string", + type: FieldType.STRING, + }, + MediaDescription: { + name: "MediaDescription", + type: FieldType.LONGFORM, + }, + MediaImage: { + name: "MediaImage", + type: FieldType.ATTACHMENT_SINGLE, }, }, } diff --git a/packages/builder/src/stores/portal/apps.js b/packages/builder/src/stores/portal/apps.js index 6af9fa56ac..4d75cac55e 100644 --- a/packages/builder/src/stores/portal/apps.js +++ b/packages/builder/src/stores/portal/apps.js @@ -131,7 +131,7 @@ export class AppsStore extends BudiStore { if (updatedAppIndex !== -1) { let updatedApp = state.apps[updatedAppIndex] updatedApp = { ...updatedApp, ...value } - state.apps = state.apps.splice(updatedAppIndex, 1, updatedApp) + state.apps.splice(updatedAppIndex, 1, updatedApp) } return state }) diff --git a/packages/builder/src/stores/portal/users.js b/packages/builder/src/stores/portal/users.js index 5da5d4038d..80ddf0a737 100644 --- a/packages/builder/src/stores/portal/users.js +++ b/packages/builder/src/stores/portal/users.js @@ -38,6 +38,10 @@ export function createUsersStore() { return API.inviteUsers(payload) } + async function removeInvites(payload) { + return API.removeUserInvites(payload) + } + async function acceptInvite(inviteCode, password, firstName, lastName) { return API.acceptInvite({ inviteCode, @@ -154,6 +158,7 @@ export function createUsersStore() { onboard, fetchInvite, getInvites, + removeInvites, updateInvite, getUserCountByApp, addAppBuilder, diff --git a/packages/cli/src/environment.ts b/packages/cli/src/environment.ts index 9d017f99b2..51f6e9b972 100644 --- a/packages/cli/src/environment.ts +++ b/packages/cli/src/environment.ts @@ -1,3 +1,4 @@ +process.env.DISABLE_PINO_LOGGER = "1" process.env.NO_JS = "1" process.env.JS_BCRYPT = "1" process.env.DISABLE_JWT_WARNING = "1" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 86a6d2b7b3..317ccea974 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -process.env.DISABLE_PINO_LOGGER = "1" +// have to import this before anything else import "./environment" import { getCommands } from "./options" import { Command } from "commander" diff --git a/packages/cli/start.sh b/packages/cli/start.sh index 9650f0bbfa..ae39f73753 100755 --- a/packages/cli/start.sh +++ b/packages/cli/start.sh @@ -1,3 +1,3 @@ #!/bin/bash dir="$(dirname -- "$(readlink -f "${BASH_SOURCE}")")" -${dir}/node_modules/ts-node/dist/bin.js ${dir}/src/index.ts $@ +${dir}/../../node_modules/ts-node/dist/bin.js ${dir}/src/index.ts $@ diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 2fae9c0213..d3dbb74280 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -13,6 +13,42 @@ "sidePanel": true, "skeletonLoader": true }, + "typeSupportPresets": { + "numberLike": { + "supported": ["number", "boolean"], + "partialSupport": [ + { "type": "longform", "message": "stringAsNumber" }, + { "type": "string", "message": "stringAsNumber" }, + { "type": "bigint", "message": "stringAsNumber" }, + { "type": "options", "message": "stringAsNumber" }, + { "type": "formula", "message": "stringAsNumber" }, + { "type": "datetime", "message": "dateAsNumber"} + ], + "unsupported": [ + { "type": "json", "message": "jsonPrimitivesOnly" } + ] + }, + "stringLike": { + "supported": ["string", "number", "bigint", "options", "longform", "boolean", "datetime"], + "unsupported": [ + { "type": "json", "message": "jsonPrimitivesOnly" } + ] + }, + "datetimeLike": { + "supported": ["datetime"], + "partialSupport": [ + { "type": "longform", "message": "stringAsDate" }, + { "type": "string", "message": "stringAsDate" }, + { "type": "options", "message": "stringAsDate" }, + { "type": "formula", "message": "stringAsDate" }, + { "type": "bigint", "message": "stringAsDate" }, + { "type": "number", "message": "numberAsDate"} + ], + "unsupported": [ + { "type": "json", "message": "jsonPrimitivesOnly" } + ] + } + }, "layout": { "name": "Layout", "description": "This component is specific only to layouts", @@ -1602,6 +1638,7 @@ ] }, "bar": { + "documentationLink": "https://docs.budibase.com/docs/bar-chart", "name": "Bar Chart", "description": "Bar chart", "icon": "GraphBarVertical", @@ -1626,6 +1663,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -1633,6 +1675,11 @@ "label": "Data columns", "key": "valueColumns", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -1760,6 +1807,7 @@ ] }, "line": { + "documentationLink": "https://docs.budibase.com/docs/line-chart", "name": "Line Chart", "description": "Line chart", "icon": "GraphTrend", @@ -1784,6 +1832,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -1791,6 +1844,11 @@ "label": "Data columns", "key": "valueColumns", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -1913,6 +1971,7 @@ ] }, "area": { + "documentationLink": "https://docs.budibase.com/docs/area-chart", "name": "Area Chart", "description": "Line chart", "icon": "GraphAreaStacked", @@ -1937,6 +1996,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -1944,6 +2008,11 @@ "label": "Data columns", "key": "valueColumns", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2078,6 +2147,7 @@ ] }, "pie": { + "documentationLink": "https://docs.budibase.com/docs/pie-donut-chart", "name": "Pie Chart", "description": "Pie chart", "icon": "GraphPie", @@ -2102,13 +2172,23 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { "type": "field", - "label": "Data columns", + "label": "Data column", "key": "valueColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2207,6 +2287,7 @@ ] }, "donut": { + "documentationLink": "https://docs.budibase.com/docs/pie-donut-chart", "name": "Donut Chart", "description": "Donut chart", "icon": "GraphDonut", @@ -2231,6 +2312,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -2238,6 +2324,11 @@ "label": "Data columns", "key": "valueColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2336,6 +2427,7 @@ ] }, "candlestick": { + "documentationLink": "https://docs.budibase.com/docs/candlestick-chart", "name": "Candlestick Chart", "description": "Candlestick chart", "icon": "GraphBarVerticalStacked", @@ -2360,6 +2452,11 @@ "label": "Date column", "key": "dateColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "datetimeLike" + } + }, "required": true }, { @@ -2367,6 +2464,11 @@ "label": "Open column", "key": "openColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2374,6 +2476,11 @@ "label": "Close column", "key": "closeColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2381,6 +2488,11 @@ "label": "High column", "key": "highColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2388,6 +2500,11 @@ "label": "Low column", "key": "lowColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2427,6 +2544,7 @@ ] }, "histogram": { + "documentationLink": "https://docs.budibase.com/docs/histogram-chart", "name": "Histogram Chart", "description": "Histogram chart", "icon": "Histogram", @@ -2434,7 +2552,6 @@ "width": 600, "height": 400 }, - "requiredAncestors": ["dataprovider"], "settings": [ { "type": "text", @@ -2452,6 +2569,11 @@ "label": "Data column", "key": "valueColumn", "dependsOn": "dataProvider", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -2746,6 +2868,14 @@ "type": "plainText", "label": "Label", "key": "label" + }, + { + "type": "number", + "label": "Initial width", + "key": "width", + "placeholder": "Auto", + "min": 80, + "max": 9999 } ] }, @@ -4107,6 +4237,55 @@ } ] }, + "signaturesinglefield": { + "name": "Signature", + "icon": "AnnotatePen", + "styles": ["size"], + "size": { + "width": 400, + "height": 50 + }, + "settings": [ + { + "type": "field/signature_single", + "label": "Field", + "key": "field", + "required": true + }, + { + "type": "text", + "label": "Label", + "key": "label" + }, + { + "type": "text", + "label": "Help text", + "key": "helpText" + }, + { + "type": "boolean", + "label": "Disabled", + "key": "disabled", + "defaultValue": false + }, + { + "type": "event", + "label": "On change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, + { + "type": "validation/signature_single", + "label": "Validation", + "key": "validation" + } + ] + }, "embeddedmap": { "name": "Embedded Map", "icon": "Location", @@ -4372,7 +4551,7 @@ "defaultValue": false }, { - "type": "validation/attachment", + "type": "validation/attachment_single", "label": "Validation", "key": "validation" }, @@ -5256,6 +5435,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -5263,6 +5447,11 @@ "label": "Data column", "key": "valueColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true } ] @@ -5281,6 +5470,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -5288,6 +5482,11 @@ "label": "Data column", "key": "valueColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true } ] @@ -5306,6 +5505,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -5313,6 +5517,11 @@ "label": "Data columns", "key": "valueColumns", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5360,6 +5569,11 @@ "label": "Value column", "key": "valueColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5401,6 +5615,11 @@ "label": "Label column", "key": "labelColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -5408,6 +5627,11 @@ "label": "Data columns", "key": "valueColumns", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5450,6 +5674,11 @@ "label": "Label columns", "key": "labelColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "stringLike" + } + }, "required": true }, { @@ -5457,6 +5686,11 @@ "label": "Data columns", "key": "valueColumns", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5511,6 +5745,11 @@ "label": "Date column", "key": "dateColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "datetimeLike" + } + }, "required": true }, { @@ -5518,6 +5757,11 @@ "label": "Open column", "key": "openColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5525,6 +5769,11 @@ "label": "Close column", "key": "closeColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5532,6 +5781,11 @@ "label": "High column", "key": "highColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5539,6 +5793,11 @@ "label": "Low column", "key": "lowColumn", "dependsOn": "dataSource", + "explanation": { + "typeSupport": { + "preset": "numberLike" + } + }, "required": true }, { @@ -5984,27 +6243,28 @@ "key": "cardTitle", "label": "Title", "nested": true, - "defaultValue": "Title" + "resetOn": "dataSource" }, { "type": "text", "key": "cardSubtitle", "label": "Subtitle", "nested": true, - "defaultValue": "Subtitle" + "resetOn": "dataSource" }, { "type": "text", "key": "cardDescription", "label": "Description", "nested": true, - "defaultValue": "Description" + "resetOn": "dataSource" }, { "type": "text", "key": "cardImageURL", "label": "Image URL", - "nested": true + "nested": true, + "resetOn": "dataSource" }, { "type": "boolean", diff --git a/packages/client/package.json b/packages/client/package.json index 71a9e2e055..68c8ec15b1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -25,7 +25,7 @@ "@budibase/string-templates": "0.0.0", "@budibase/types": "0.0.0", "@spectrum-css/card": "3.0.3", - "apexcharts": "^3.22.1", + "apexcharts": "^3.48.0", "dayjs": "^1.10.8", "downloadjs": "1.4.7", "html5-qrcode": "^2.2.1", @@ -33,8 +33,8 @@ "sanitize-html": "^2.7.0", "screenfull": "^6.0.1", "shortid": "^2.2.15", - "svelte-apexcharts": "^1.0.2", - "svelte-spa-router": "^4.0.1" + "svelte-spa-router": "^4.0.1", + "atrament": "^4.3.0" }, "devDependencies": { "@rollup/plugin-alias": "^5.1.0", diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index 378fa64b73..8eacbdf041 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -193,6 +193,9 @@ $: pad = pad || (interactive && hasChildren && inDndPath) $: $dndIsDragging, (pad = false) + $: currentTheme = $context?.device?.theme + $: darkMode = !currentTheme?.includes("light") + // Update component context $: store.set({ id, @@ -222,6 +225,7 @@ parent: id, ancestors: [...($component?.ancestors ?? []), instance._component], path: [...($component?.path ?? []), id], + darkMode, }) const initialise = (instance, force = false) => { @@ -283,10 +287,23 @@ const dependsOnKey = setting.dependsOn.setting || setting.dependsOn const dependsOnValue = setting.dependsOn.value const realDependentValue = instance[dependsOnKey] + + const sectionDependsOnKey = + setting.sectionDependsOn?.setting || setting.sectionDependsOn + const sectionDependsOnValue = setting.sectionDependsOn?.value + const sectionRealDependentValue = instance[sectionDependsOnKey] + if (dependsOnValue == null && realDependentValue == null) { return false } - if (dependsOnValue !== realDependentValue) { + if (dependsOnValue != null && dependsOnValue !== realDependentValue) { + return false + } + + if ( + sectionDependsOnValue != null && + sectionDependsOnValue !== sectionRealDependentValue + ) { return false } } diff --git a/packages/client/src/components/app/GridBlock.svelte b/packages/client/src/components/app/GridBlock.svelte index 4ed8f91f2a..dbd651b533 100644 --- a/packages/client/src/components/app/GridBlock.svelte +++ b/packages/client/src/components/app/GridBlock.svelte @@ -22,6 +22,7 @@ const context = getContext("context") const component = getContext("component") + const { environmentStore } = getContext("sdk") const { styleable, API, @@ -36,12 +37,16 @@ let grid let gridContext + let minHeight = 0 + $: currentTheme = $context?.device?.theme + $: darkMode = !currentTheme?.includes("light") $: parsedColumns = getParsedColumns(columns) $: columnWhitelist = parsedColumns.filter(x => x.active).map(x => x.field) $: schemaOverrides = getSchemaOverrides(parsedColumns) $: enrichedButtons = enrichButtons(buttons) $: selectedRows = deriveSelectedRows(gridContext) + $: styles = patchStyles($component.styles, minHeight) $: data = { selectedRows: $selectedRows } $: actions = [ { @@ -50,8 +55,6 @@ metadata: { dataSource: table }, }, ] - $: height = $component.styles?.normal?.height || "408px" - $: styles = getSanitisedStyles($component.styles) // Provide additional data context for live binding eval export const getAdditionalDataContext = () => { @@ -84,9 +87,11 @@ const getSchemaOverrides = columns => { let overrides = {} - columns.forEach(column => { + columns.forEach((column, idx) => { overrides[column.field] = { displayName: column.label, + width: column.width, + order: idx, } }) return overrides @@ -100,6 +105,7 @@ size: "M", text: settings.text, type: settings.type, + icon: settings.icon, onClick: async row => { // Create a fake, ephemeral context to run the buttons actions with const id = get(component).id @@ -128,49 +134,50 @@ ) } - const getSanitisedStyles = styles => { + const patchStyles = (styles, minHeight) => { return { ...styles, normal: { ...styles?.normal, - height: undefined, + "min-height": `${minHeight}px`, }, } } onMount(() => { gridContext = grid.getContext() + gridContext.minHeight.subscribe($height => (minHeight = $height)) })
    - - onRowClick?.({ row: e.detail })} - /> - + onRowClick?.({ row: e.detail })} + />
    @@ -183,14 +190,9 @@ border: 1px solid var(--spectrum-global-color-gray-300); border-radius: 4px; overflow: hidden; + height: 410px; } div.in-builder :global(*) { pointer-events: none; } - span { - display: contents; - } - span :global(.grid) { - height: var(--height); - } diff --git a/packages/client/src/components/app/NavItem.svelte b/packages/client/src/components/app/NavItem.svelte index fcdda57ace..a5ac61a39f 100644 --- a/packages/client/src/components/app/NavItem.svelte +++ b/packages/client/src/components/app/NavItem.svelte @@ -1,5 +1,5 @@ @@ -84,8 +82,7 @@ dataLabels, legend, animate, - ...colors, - yAxisUnits, + valueUnits, yAxisLabel, xAxisLabel, stacked, @@ -98,6 +95,11 @@ lowColumn, dateColumn, bucketCount, + c1, + c2, + c3, + c4, + c5, }} /> {/if} diff --git a/packages/client/src/components/app/blocks/FormBlockComponent.svelte b/packages/client/src/components/app/blocks/FormBlockComponent.svelte index 31610f79ac..396dfcf808 100644 --- a/packages/client/src/components/app/blocks/FormBlockComponent.svelte +++ b/packages/client/src/components/app/blocks/FormBlockComponent.svelte @@ -15,6 +15,7 @@ [FieldType.BOOLEAN]: "booleanfield", [FieldType.LONGFORM]: "longformfield", [FieldType.DATETIME]: "datetimefield", + [FieldType.SIGNATURE_SINGLE]: "signaturesinglefield", [FieldType.ATTACHMENTS]: "attachmentfield", [FieldType.ATTACHMENT_SINGLE]: "attachmentsinglefield", [FieldType.LINK]: "relationshipfield", diff --git a/packages/client/src/components/app/charts/ApexChart.svelte b/packages/client/src/components/app/charts/ApexChart.svelte index b25be421f7..33e6cdfa80 100644 --- a/packages/client/src/components/app/charts/ApexChart.svelte +++ b/packages/client/src/components/app/charts/ApexChart.svelte @@ -1,25 +1,85 @@ -{#if options} - {#key options.customColor} -
    - {/key} -{:else if $builderStore.inBuilder} -
    - -
    -{/if} +{#key optionsCopy?.customColor} +
    + {#if $builderStore.inBuilder && noData} +
    + + Add rows to your data source to start using your component +
    + {/if} +{/key} diff --git a/packages/client/src/components/app/charts/ApexOptionsBuilder.js b/packages/client/src/components/app/charts/ApexOptionsBuilder.js deleted file mode 100644 index 04b5805df3..0000000000 --- a/packages/client/src/components/app/charts/ApexOptionsBuilder.js +++ /dev/null @@ -1,195 +0,0 @@ -export class ApexOptionsBuilder { - 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, - }, - chart: { - toolbar: { - show: false, - }, - zoom: { - enabled: false, - }, - }, - xaxis: { - labels: { - formatter: this.formatters.Default, - }, - }, - yaxis: { - labels: { - formatter: this.formatters.Default, - }, - }, - } - } - - setOption(path, value) { - if (value == null || value === "") { - return this - } - let tmp = this.options - for (let i = 0; i < path.length - 1; i++) { - const step = path[i] - if (!tmp[step]) { - tmp[step] = {} - } - tmp = tmp[step] - } - tmp[path[path.length - 1]] = value - return this - } - - getOptions() { - return this.options - } - - type(type) { - return this.setOption(["chart", "type"], type) - } - - title(title) { - return this.setOption(["title", "text"], title) - } - - colors(colors) { - if (!colors) { - delete this.options.colors - this.options["customColor"] = false - return this - } - this.options["customColor"] = true - return this.setOption(["colors"], colors) - } - - width(width) { - return this.setOption(["chart", "width"], width || undefined) - } - - height(height) { - return this.setOption(["chart", "height"], height || undefined) - } - - xLabel(label) { - return this.setOption(["xaxis", "title", "text"], label) - } - - yLabel(label) { - return this.setOption(["yaxis", "title", "text"], label) - } - - xCategories(categories) { - return this.setOption(["xaxis", "categories"], categories) - } - - yCategories(categories) { - return this.setOption(["yaxis", "categories"], categories) - } - - series(series) { - return this.setOption(["series"], series) - } - - horizontal(horizontal) { - return this.setOption(["plotOptions", "bar", "horizontal"], horizontal) - } - - dataLabels(dataLabels) { - return this.setOption(["dataLabels", "enabled"], dataLabels) - } - - animate(animate) { - return this.setOption(["chart", "animations", "enabled"], animate) - } - - curve(curve) { - return this.setOption(["stroke", "curve"], curve) - } - - gradient(gradient) { - const fill = { - type: "gradient", - gradient: { - shadeIntensity: 1, - opacityFrom: 0.7, - opacityTo: 0.9, - stops: [0, 90, 100], - }, - } - return this.setOption(["fill"], gradient ? fill : undefined) - } - - legend(legend) { - return this.setOption(["legend", "show"], legend) - } - - legendPosition(position) { - return this.setOption(["legend", "position"], position) - } - - stacked(stacked) { - return this.setOption(["chart", "stacked"], stacked) - } - - labels(labels) { - return this.setOption(["labels"], labels) - } - - xUnits(units) { - return this.setOption( - ["xaxis", "labels", "formatter"], - this.formatters[units || "Default"] - ) - } - - yUnits(units) { - return this.setOption( - ["yaxis", "labels", "formatter"], - this.formatters[units || "Default"] - ) - } - - clearXFormatter() { - delete this.options.xaxis.labels - return this - } - - clearYFormatter() { - delete this.options.yaxis.labels - return this - } - - xType(type) { - return this.setOption(["xaxis", "type"], type) - } - - yType(type) { - return this.setOption(["yaxis", "type"], type) - } - - yTooltip(yTooltip) { - return this.setOption(["yaxis", "tooltip", "enabled"], yTooltip) - } - - palette(palette) { - if (!palette) { - return this - } - return this.setOption( - ["theme", "palette"], - palette.toLowerCase().replace(/[\W]/g, "") - ) - } -} diff --git a/packages/client/src/components/app/charts/AreaChart.svelte b/packages/client/src/components/app/charts/AreaChart.svelte index dc80b2b9da..a9a61e59f6 100644 --- a/packages/client/src/components/app/charts/AreaChart.svelte +++ b/packages/client/src/components/app/charts/AreaChart.svelte @@ -1,5 +1,159 @@ - + diff --git a/packages/client/src/components/app/charts/BarChart.svelte b/packages/client/src/components/app/charts/BarChart.svelte index fd8443e2d6..aeebfe9461 100644 --- a/packages/client/src/components/app/charts/BarChart.svelte +++ b/packages/client/src/components/app/charts/BarChart.svelte @@ -1,11 +1,12 @@ diff --git a/packages/client/src/components/app/charts/CandleStickChart.svelte b/packages/client/src/components/app/charts/CandleStickChart.svelte index b2760b005e..61cdef180b 100644 --- a/packages/client/src/components/app/charts/CandleStickChart.svelte +++ b/packages/client/src/components/app/charts/CandleStickChart.svelte @@ -1,6 +1,6 @@ diff --git a/packages/client/src/components/app/charts/DonutChart.svelte b/packages/client/src/components/app/charts/DonutChart.svelte index 721a09053a..dad9edfd67 100644 --- a/packages/client/src/components/app/charts/DonutChart.svelte +++ b/packages/client/src/components/app/charts/DonutChart.svelte @@ -1,5 +1,99 @@ - + diff --git a/packages/client/src/components/app/charts/HistogramChart.svelte b/packages/client/src/components/app/charts/HistogramChart.svelte index 26b9028550..37c395d45f 100644 --- a/packages/client/src/components/app/charts/HistogramChart.svelte +++ b/packages/client/src/components/app/charts/HistogramChart.svelte @@ -1,135 +1,154 @@ diff --git a/packages/client/src/components/app/charts/LineChart.svelte b/packages/client/src/components/app/charts/LineChart.svelte index 7f82a833d2..c2dac189e1 100644 --- a/packages/client/src/components/app/charts/LineChart.svelte +++ b/packages/client/src/components/app/charts/LineChart.svelte @@ -1,8 +1,7 @@ diff --git a/packages/client/src/components/app/charts/PieChart.svelte b/packages/client/src/components/app/charts/PieChart.svelte index 8cb7317d94..3250a2ca95 100644 --- a/packages/client/src/components/app/charts/PieChart.svelte +++ b/packages/client/src/components/app/charts/PieChart.svelte @@ -1,6 +1,6 @@ diff --git a/packages/client/src/components/app/charts/utils.js b/packages/client/src/components/app/charts/utils.js new file mode 100644 index 0000000000..1aea22c991 --- /dev/null +++ b/packages/client/src/components/app/charts/utils.js @@ -0,0 +1,51 @@ +export const formatters = { + ["Default"]: val => val, + ["Thousands"]: val => `${Math.round(val / 1000)}K`, + ["Millions"]: val => `${Math.round(val / 1000000)}M`, + ["Datetime"]: val => new Date(val).toLocaleString(), +} + +export const parsePalette = paletteName => { + if (paletteName === "Custom") { + // return null in this case so that the palette option doesn't get consumed by Apex Charts + return null + } + + const [_, number] = paletteName.split(" ") + + return `palette${number}` +} + +// Deep clone which copies function references +export const cloneDeep = value => { + const typesToNaiveCopy = ["string", "boolean", "number", "function", "symbol"] + + if (value === null) { + return null + } + + if (value === undefined) { + return undefined + } + + if (typesToNaiveCopy.includes(typeof value)) { + return value + } + + if (Array.isArray(value)) { + return value.map(element => cloneDeep(element)) + } + + // Only copy "pure" objects, we want to error on stuff like Maps or Sets + if (typeof value === "object" && value.constructor.name === "Object") { + const cloneObject = {} + + Object.entries(value).forEach(([key, childValue]) => { + cloneObject[key] = cloneDeep(childValue) + }) + + return cloneObject + } + + throw `Unsupported value: "${value}" of type: "${typeof value}"` +} diff --git a/packages/client/src/components/app/charts/utils.test.js b/packages/client/src/components/app/charts/utils.test.js new file mode 100644 index 0000000000..1b065e2fae --- /dev/null +++ b/packages/client/src/components/app/charts/utils.test.js @@ -0,0 +1,31 @@ +import { expect, describe, it, vi } from "vitest" +import { cloneDeep } from "./utils" + +describe("utils", () => { + let context + + beforeEach(() => { + vi.clearAllMocks() + context = {} + }) + + describe("cloneDeep", () => { + beforeEach(() => { + context.value = { + obj: { one: 1, two: 2 }, + arr: [1, { first: null, second: undefined }, 2], + str: "test", + num: 123, + bool: true, + sym: Symbol("test"), + func: () => "some value", + } + context.cloneValue = cloneDeep(context.value) + }) + + it("to clone the object and not copy object references", () => { + expect(context.cloneValue.obj.one).toEqual(1) + expect(context.cloneValue.obj.two).toEqual(2) + }) + }) +}) diff --git a/packages/client/src/components/app/forms/AttachmentField.svelte b/packages/client/src/components/app/forms/AttachmentField.svelte index 3489fd809c..27286a8666 100644 --- a/packages/client/src/components/app/forms/AttachmentField.svelte +++ b/packages/client/src/components/app/forms/AttachmentField.svelte @@ -25,7 +25,7 @@ let fieldState let fieldApi - const { API, notificationStore } = getContext("sdk") + const { API, notificationStore, environmentStore } = getContext("sdk") const formContext = getContext("form") const BYTES_IN_MB = 1000000 @@ -87,7 +87,7 @@ error={fieldState.error} on:change={handleChange} {processFiles} - {handleFileTooLarge} + handleFileTooLarge={$environmentStore.cloud ? handleFileTooLarge : null} {handleTooManyFiles} {maximum} {extensions} diff --git a/packages/client/src/components/app/forms/Field.svelte b/packages/client/src/components/app/forms/Field.svelte index adf5d10df7..74ff5442a9 100644 --- a/packages/client/src/components/app/forms/Field.svelte +++ b/packages/client/src/components/app/forms/Field.svelte @@ -127,7 +127,7 @@ flex-direction: column; } label { - white-space: nowrap; + word-wrap: break-word; } label.hidden { padding: 0; diff --git a/packages/client/src/components/app/forms/SignatureField.svelte b/packages/client/src/components/app/forms/SignatureField.svelte new file mode 100644 index 0000000000..bdae148368 --- /dev/null +++ b/packages/client/src/components/app/forms/SignatureField.svelte @@ -0,0 +1,129 @@ + + + + + + {#if fieldState} + {#if (Array.isArray(fieldState?.value) && !fieldState?.value?.length) || !fieldState?.value} + { + if (!$builderStore.inBuilder) { + modal.show() + } + }} + > + Add signature + + {:else} +
    + +
    + {/if} + {/if} +
    + + diff --git a/packages/client/src/components/app/forms/index.js b/packages/client/src/components/app/forms/index.js index 15966c3765..391b5fa19f 100644 --- a/packages/client/src/components/app/forms/index.js +++ b/packages/client/src/components/app/forms/index.js @@ -16,5 +16,6 @@ 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" +export { default as signaturesinglefield } from "./SignatureField.svelte" export { default as bbreferencefield } from "./BBReferenceField.svelte" export { default as bbreferencesinglefield } from "./BBReferenceSingleField.svelte" diff --git a/packages/client/src/components/app/forms/validation.js b/packages/client/src/components/app/forms/validation.js index bdd7213cb0..46a5330cf3 100644 --- a/packages/client/src/components/app/forms/validation.js +++ b/packages/client/src/components/app/forms/validation.js @@ -200,6 +200,17 @@ const parseType = (value, type) => { return value } + // Parse attachment/signature single, treating no key as null + if ( + type === FieldTypes.ATTACHMENT_SINGLE || + type === FieldTypes.SIGNATURE_SINGLE + ) { + if (!value?.key) { + return null + } + return value + } + // Parse links, treating no elements as null if (type === FieldTypes.LINK) { if (!Array.isArray(value) || !value.length) { @@ -246,10 +257,8 @@ const maxLengthHandler = (value, rule) => { // Evaluates a max file size (MB) constraint const maxFileSizeHandler = (value, rule) => { const limit = parseType(rule.value, "number") - return ( - value == null || - !value.some(attachment => attachment.size / 1000000 > limit) - ) + const check = attachment => attachment.size / 1000000 > limit + return value == null || !(value?.key ? check(value) : value.some(check)) } // Evaluates a max total upload size (MB) constraint @@ -257,8 +266,11 @@ const maxUploadSizeHandler = (value, rule) => { const limit = parseType(rule.value, "number") return ( value == null || - value.reduce((acc, currentItem) => acc + currentItem.size, 0) / 1000000 <= - limit + (value?.key + ? value.size / 1000000 <= limit + : value.reduce((acc, currentItem) => acc + currentItem.size, 0) / + 1000000 <= + limit) ) } diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index 0c3866768e..25e0892fc0 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -238,7 +238,13 @@ const triggerAutomationHandler = async action => { } } const navigationHandler = action => { - const { url, peek, externalNewTab } = action.parameters + let { url, peek, externalNewTab, type } = action.parameters + + // Ensure in-app navigation starts with a slash + if (type === "screen" && url && !url.startsWith("/")) { + url = `/${url}` + } + routeStore.actions.navigate(url, peek, externalNewTab) closeSidePanelHandler() } diff --git a/packages/client/src/utils/componentProps.js b/packages/client/src/utils/componentProps.js index efe69938e7..bdf74c8014 100644 --- a/packages/client/src/utils/componentProps.js +++ b/packages/client/src/utils/componentProps.js @@ -108,7 +108,12 @@ export const getSettingsDefinition = definition => { let settings = [] definition.settings?.forEach(setting => { if (setting.section) { - settings = settings.concat(setting.settings || []) + settings = settings.concat( + (setting.settings || [])?.map(childSetting => ({ + ...childSetting, + sectionDependsOn: setting.dependsOn, + })) + ) } else { settings.push(setting) } diff --git a/packages/frontend-core/src/api/user.js b/packages/frontend-core/src/api/user.js index 3a815b768e..6a4932d4af 100644 --- a/packages/frontend-core/src/api/user.js +++ b/packages/frontend-core/src/api/user.js @@ -234,6 +234,16 @@ export const buildUserEndpoints = API => ({ }) }, + /** + * Removes multiple user invites from Redis cache + */ + removeUserInvites: async inviteCodes => { + return await API.post({ + url: "/api/global/users/multi/invite/delete", + body: inviteCodes, + }) + }, + /** * Accepts an invite to join the platform and creates a user. * @param inviteCode the invite code sent in the email diff --git a/packages/frontend-core/src/components/FilterBuilder.svelte b/packages/frontend-core/src/components/FilterBuilder.svelte index fb7aa98405..82c3a04dc2 100644 --- a/packages/frontend-core/src/components/FilterBuilder.svelte +++ b/packages/frontend-core/src/components/FilterBuilder.svelte @@ -16,11 +16,13 @@ import { LuceneUtils, Constants } from "@budibase/frontend-core" import { getContext } from "svelte" import FilterUsers from "./FilterUsers.svelte" + import { getFields } from "../utils/searchFields" const { OperatorOptions } = Constants export let schemaFields export let filters = [] + export let tables = [] export let datasource export let behaviourFilters = false export let allowBindings = false @@ -45,12 +47,12 @@ const context = getContext("context") - $: fieldOptions = (schemaFields ?? []) - .filter(field => getValidOperatorsForType(field).length) - .map(field => ({ - label: field.displayName || field.name, - value: field.name, - })) + $: fieldOptions = getFields(tables, schemaFields || [], { + allowLinks: true, + }).map(field => ({ + label: field.displayName || field.name, + value: field.name, + })) const addFilter = () => { filters = [ diff --git a/packages/frontend-core/src/components/SignatureModal.svelte b/packages/frontend-core/src/components/SignatureModal.svelte new file mode 100644 index 0000000000..8132c5bced --- /dev/null +++ b/packages/frontend-core/src/components/SignatureModal.svelte @@ -0,0 +1,59 @@ + + + + { + onConfirm(canvas) + }} + > +
    + {title} +
    + +
    +
    + + diff --git a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte index 1a2494987a..f485593c46 100644 --- a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte @@ -8,11 +8,10 @@ export let onChange export let readonly = false export let api - export let invertX = false export let schema export let maximum - const { API, notifications } = getContext("grid") + const { API, notifications, props } = getContext("grid") const imageExtensions = ["png", "tiff", "gif", "raw", "jpg", "jpeg"] let isOpen = false @@ -92,13 +91,7 @@
    {#if isOpen} - +
    onChange(e.detail)} maximum={maximum || schema.constraints?.length?.maximum} {processFiles} - {handleFileTooLarge} + handleFileTooLarge={$props.isCloud ? handleFileTooLarge : null} />
    diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index d8cff26b9d..0156313d03 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -18,8 +18,6 @@ export let row export let cellId export let updateValue = rows.actions.updateValue - export let invertX = false - export let invertY = false export let contentLines = 1 export let hidden = false @@ -93,8 +91,6 @@ onChange={cellAPI.setValue} {focused} {readonly} - {invertY} - {invertX} {contentLines} /> diff --git a/packages/frontend-core/src/components/grid/cells/DateCell.svelte b/packages/frontend-core/src/components/grid/cells/DateCell.svelte index 3c86fe3605..8922422b43 100644 --- a/packages/frontend-core/src/components/grid/cells/DateCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DateCell.svelte @@ -10,7 +10,6 @@ export let focused = false export let readonly = false export let api - export let invertX = false let isOpen let anchor @@ -111,7 +110,7 @@
    {#if isOpen} - + { open = false @@ -158,17 +160,13 @@ } const makeDisplayColumn = () => { - columns.actions.changePrimaryDisplay(column.name) + datasource.actions.changePrimaryDisplay(column.name) open = false } const hideColumn = () => { - columns.update(state => { - const index = state.findIndex(col => col.name === column.name) - state[index].visible = false - return state.slice() - }) - columns.actions.saveChanges() + datasource.actions.addSchemaMutation(column.name, { visible: false }) + datasource.actions.saveSchemaMutations() open = false } @@ -236,6 +234,14 @@ } const debouncedUpdateFilter = debounce(updateFilter, 250) + const handleDoubleClick = () => { + if (!editable || searching) { + return + } + open = true + editColumn() + } + onMount(() => subscribe("close-edit-column", close)) @@ -246,14 +252,15 @@
    {:else} - + Edit column {#if isOpen} - +