diff --git a/.eslintrc.json b/.eslintrc.json index 2c810eecc5..d475bba8d1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,5 @@ { + "root": true, "env": { "browser": true, "es6": true, @@ -53,7 +54,8 @@ "ignoreRestSiblings": true } ], - "local-rules/no-budibase-imports": "error" + "no-redeclare": "off", + "@typescript-eslint/no-redeclare": "error" } }, { diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index fd4d8cf7c8..7f1e08601a 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -92,8 +92,6 @@ jobs: test-libraries: runs-on: ubuntu-latest - env: - REUSE_CONTAINERS: true steps: - name: Checkout repo uses: actions/checkout@v4 @@ -150,8 +148,6 @@ jobs: test-server: runs-on: budi-tubby-tornado-quad-core-150gb - env: - REUSE_CONTAINERS: true steps: - name: Checkout repo uses: actions/checkout@v4 @@ -174,7 +170,8 @@ jobs: docker pull mongo:7.0-jammy & docker pull mariadb:lts & docker pull testcontainers/ryuk:0.5.1 & - docker pull budibase/couchdb:v3.2.1-sql & + docker pull budibase/couchdb:v3.2.1-sqs & + docker pull minio/minio & docker pull redis & wait $(jobs -p) diff --git a/.prettierignore b/.prettierignore index 87f0191a94..72cdc75a23 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,4 +12,5 @@ packages/pro/coverage packages/account-portal/packages/ui/build packages/account-portal/packages/ui/.routify packages/account-portal/packages/server/build +packages/account-portal/packages/server/coverage **/*.ivm.bundle.js \ No newline at end of file diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index b380908dd1..ed7166ec5d 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -106,6 +106,8 @@ spec: value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: BACKUPS_BUCKET_NAME value: {{ .Values.services.objectStore.backupsBucketName | quote }} + - name: TEMP_BUCKET_NAME + value: {{ .Values.globals.tempBucketName | quote }} - name: PORT value: {{ .Values.services.apps.port | quote }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }} diff --git a/charts/budibase/templates/automation-worker-service-deployment.yaml b/charts/budibase/templates/automation-worker-service-deployment.yaml index 51fa9ee4bb..3c6f94ae9e 100644 --- a/charts/budibase/templates/automation-worker-service-deployment.yaml +++ b/charts/budibase/templates/automation-worker-service-deployment.yaml @@ -107,6 +107,8 @@ spec: value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: BACKUPS_BUCKET_NAME value: {{ .Values.services.objectStore.backupsBucketName | quote }} + - name: TEMP_BUCKET_NAME + value: {{ .Values.globals.tempBucketName | quote }} - name: PORT value: {{ .Values.services.automationWorkers.port | quote }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }} diff --git a/charts/budibase/templates/automation-worker-service-hpa.yaml b/charts/budibase/templates/automation-worker-service-hpa.yaml index f29223b61b..18f9690c00 100644 --- a/charts/budibase/templates/automation-worker-service-hpa.yaml +++ b/charts/budibase/templates/automation-worker-service-hpa.yaml @@ -2,7 +2,7 @@ apiVersion: {{ ternary "autoscaling/v2" "autoscaling/v2beta2" (.Capabilities.APIVersions.Has "autoscaling/v2") }} kind: HorizontalPodAutoscaler metadata: - name: {{ include "budibase.fullname" . }}-apps + name: {{ include "budibase.fullname" . }}-automation-worker labels: {{- include "budibase.labels" . | nindent 4 }} spec: diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index e37b2bc0e4..66a9bb6c14 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -106,6 +106,8 @@ spec: value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: BACKUPS_BUCKET_NAME value: {{ .Values.services.objectStore.backupsBucketName | quote }} + - name: TEMP_BUCKET_NAME + value: {{ .Values.globals.tempBucketName | quote }} - name: PORT value: {{ .Values.services.worker.port | quote }} - name: MULTI_TENANCY diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 9ace768625..27037cdaa8 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -121,6 +121,9 @@ globals: # to the old value for the duration of the rotation. jwtSecretFallback: "" + ## -- If using S3 the bucket name to be used for storing temporary files + tempBucketName: "" + smtp: # -- Whether to enable SMTP or not. enabled: false diff --git a/globalSetup.ts b/globalSetup.ts index 115796c395..dd1454b6e1 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -1,19 +1,52 @@ -import { GenericContainer, Wait } from "testcontainers" +import { + GenericContainer, + Wait, + getContainerRuntimeClient, +} from "testcontainers" +import { ContainerInfo } from "dockerode" import path from "path" import lockfile from "proper-lockfile" +async function getBudibaseContainers() { + const client = await getContainerRuntimeClient() + const conatiners = await client.container.list() + return conatiners.filter( + container => + container.Labels["com.budibase"] === "true" && + container.Labels["org.testcontainers"] === "true" + ) +} + +async function killContainers(containers: ContainerInfo[]) { + const client = await getContainerRuntimeClient() + for (const container of containers) { + const c = client.container.getById(container.Id) + await c.kill() + await c.remove() + } +} + export default async function setup() { const lockPath = path.resolve(__dirname, "globalSetup.ts") - if (process.env.REUSE_CONTAINERS) { - // If you run multiple tests at the same time, it's possible for the CouchDB - // shared container to get started multiple times despite having an - // identical reuse hash. To avoid that, we do a filesystem-based lock so - // that only one globalSetup.ts is running at a time. - lockfile.lockSync(lockPath) - } + // If you run multiple tests at the same time, it's possible for the CouchDB + // shared container to get started multiple times despite having an + // identical reuse hash. To avoid that, we do a filesystem-based lock so + // that only one globalSetup.ts is running at a time. + lockfile.lockSync(lockPath) + + // Remove any containers that are older than 24 hours. This is to prevent + // containers getting full volumes or accruing any other problems from being + // left up for very long periods of time. + const threshold = new Date(Date.now() - 1000 * 60 * 60 * 24) + const containers = (await getBudibaseContainers()).filter(container => { + const created = new Date(container.Created * 1000) + return created < threshold + }) + + await killContainers(containers) try { - let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs") + const couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs") .withExposedPorts(5984, 4984) .withEnvironment({ COUCHDB_PASSWORD: "budibase", @@ -28,20 +61,29 @@ export default async function setup() { target: "/opt/couchdb/etc/local.d/test-couchdb.ini", }, ]) + .withLabels({ "com.budibase": "true" }) + .withReuse() .withWaitStrategy( Wait.forSuccessfulCommand( "curl http://budibase:budibase@localhost:5984/_up" ).withStartupTimeout(20000) ) - if (process.env.REUSE_CONTAINERS) { - couchdb = couchdb.withReuse() - } + const minio = new GenericContainer("minio/minio") + .withExposedPorts(9000) + .withCommand(["server", "/data"]) + .withEnvironment({ + MINIO_ACCESS_KEY: "budibase", + MINIO_SECRET_KEY: "budibase", + }) + .withLabels({ "com.budibase": "true" }) + .withReuse() + .withWaitStrategy( + Wait.forHttp("/minio/health/ready", 9000).withStartupTimeout(10000) + ) - await couchdb.start() + await Promise.all([couchdb.start(), minio.start()]) } finally { - if (process.env.REUSE_CONTAINERS) { - lockfile.unlockSync(lockPath) - } + lockfile.unlockSync(lockPath) } } diff --git a/hosting/couchdb/runner.v2.sh b/hosting/couchdb/runner.v2.sh index 7ee24327a1..f8cbe49b8f 100644 --- a/hosting/couchdb/runner.v2.sh +++ b/hosting/couchdb/runner.v2.sh @@ -70,10 +70,10 @@ sed -i "s#COUCHDB_ERLANG_COOKIE#${COUCHDB_ERLANG_COOKIE}#g" /opt/clouseau/clouse /opt/clouseau/bin/clouseau > /dev/stdout 2>&1 & # Start CouchDB. -/docker-entrypoint.sh /opt/couchdb/bin/couchdb & +/docker-entrypoint.sh /opt/couchdb/bin/couchdb > /dev/stdout 2>&1 & -# Start SQS. -/opt/sqs/sqs --server "http://localhost:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 & +# Start SQS. Use 127.0.0.1 instead of localhost to avoid IPv6 issues. +/opt/sqs/sqs --server "http://127.0.0.1:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 > /dev/stdout 2>&1 & # Wait for CouchDB to start up. while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index be01056b53..e8ac306c51 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -19,9 +19,6 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN ./scripts/removeWorkspaceDependencies.sh packages/server/package.json RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json - -# We will never want to sync pro, but the script is still required -RUN echo '' > scripts/syncProPackage.js RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile diff --git a/lerna.json b/lerna.json index 728cddc194..16dc73aa30 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.23.11", + "version": "2.25.0", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/package.json b/package.json index e60a086e17..98524e0ee4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "yargs": "^17.7.2" }, "scripts": { - "preinstall": "node scripts/syncProPackage.js", "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", @@ -60,7 +59,8 @@ "dev:all": "yarn run kill-all && lerna run --stream dev", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:docker": "./scripts/devDocker.sh", - "test": "REUSE_CONTAINERS=1 lerna run --concurrency 1 --stream test --stream", + "test": "lerna run --concurrency 1 --stream test --stream", + "test:containers:kill": "./scripts/killTestcontainers.sh", "lint:eslint": "eslint packages --max-warnings=0", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", "lint": "yarn run lint:eslint && yarn run lint:prettier", @@ -107,6 +107,7 @@ "@budibase/shared-core": "0.0.0", "@budibase/string-templates": "0.0.0", "@budibase/types": "0.0.0", + "@budibase/pro": "npm:@budibase/pro@latest", "tough-cookie": "4.1.3", "node-fetch": "2.6.7", "semver": "7.5.3", diff --git a/packages/backend-core/src/cache/user.ts b/packages/backend-core/src/cache/user.ts index ecfa20f99e..d319c5dcb6 100644 --- a/packages/backend-core/src/cache/user.ts +++ b/packages/backend-core/src/cache/user.ts @@ -69,7 +69,7 @@ async function populateUsersFromDB( export async function getUser( userId: string, tenantId?: string, - populateUser?: any + populateUser?: (userId: string, tenantId: string) => Promise ) { if (!populateUser) { populateUser = populateFromDB @@ -83,7 +83,7 @@ export async function getUser( } const client = await redis.getUserClient() // try cache - let user = await client.get(userId) + let user: User = await client.get(userId) if (!user) { user = await populateUser(userId, tenantId) await client.store(userId, user, EXPIRY_SECONDS) diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index 6cea7efeba..4beb02c9c7 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -281,7 +281,7 @@ export function doInScimContext(task: any) { return newContext(updates, task) } -export async function ensureSnippetContext() { +export async function ensureSnippetContext(enabled = !env.isTest()) { const ctx = getCurrentContext() // If we've already added snippets to context, continue @@ -292,7 +292,7 @@ export async function ensureSnippetContext() { // Otherwise get snippets for this app and update context let snippets: Snippet[] | undefined const db = getAppDB() - if (db && !env.isTest()) { + if (db && enabled) { const app = await db.get(DocumentType.APP_METADATA) snippets = app.snippets } diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index d220d0a8ac..ef351f7d4d 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -3,11 +3,11 @@ import { AllDocsResponse, AnyDocument, Database, - DatabaseOpts, - DatabaseQueryOpts, - DatabasePutOpts, DatabaseCreateIndexOpts, DatabaseDeleteIndexOpts, + DatabaseOpts, + DatabasePutOpts, + DatabaseQueryOpts, Document, isDocument, RowResponse, @@ -17,7 +17,7 @@ import { import { getCouchInfo } from "./connections" import { directCouchUrlCall } from "./utils" import { getPouchDB } from "./pouchDB" -import { WriteStream, ReadStream } from "fs" +import { ReadStream, WriteStream } from "fs" import { newid } from "../../docIds/newid" import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { DDInstrumentedDatabase } from "../instrumentation" @@ -38,6 +38,39 @@ function buildNano(couchInfo: { url: string; cookie: string }) { type DBCall = () => Promise +class CouchDBError extends Error { + status: number + statusCode: number + reason: string + name: string + errid: string + error: string + description: string + + constructor( + message: string, + info: { + status: number | undefined + statusCode: number | undefined + name: string + errid: string + description: string + reason: string + error: string + } + ) { + super(message) + const statusCode = info.status || info.statusCode || 500 + this.status = statusCode + this.statusCode = statusCode + this.reason = info.reason + this.name = info.name + this.errid = info.errid + this.description = info.description + this.error = info.error + } +} + export function DatabaseWithConnection( dbName: string, connection: string, @@ -119,7 +152,7 @@ export class DatabaseImpl implements Database { } catch (err: any) { // Handling race conditions if (err.statusCode !== 412) { - throw err + throw new CouchDBError(err.message, err) } } } @@ -138,10 +171,9 @@ export class DatabaseImpl implements Database { if (err.statusCode === 404 && err.reason === DATABASE_NOT_FOUND) { await this.checkAndCreateDb() return await this.performCall(call) - } else if (err.statusCode) { - err.status = err.statusCode } - throw err + // stripping the error down the props which are safe/useful, drop everything else + throw new CouchDBError(`CouchDB error: ${err.message}`, err) } } @@ -288,7 +320,7 @@ export class DatabaseImpl implements Database { if (err.statusCode === 404) { return } else { - throw { ...err, status: err.statusCode } + throw new CouchDBError(err.message, err) } } } diff --git a/packages/backend-core/src/db/lucene.ts b/packages/backend-core/src/db/lucene.ts index d9dddd0097..f5ad7e6433 100644 --- a/packages/backend-core/src/db/lucene.ts +++ b/packages/backend-core/src/db/lucene.ts @@ -12,6 +12,10 @@ import { dataFilters } from "@budibase/shared-core" export const removeKeyNumbering = dataFilters.removeKeyNumbering +function isEmpty(value: any) { + return value == null || value === "" +} + /** * Class to build lucene query URLs. * Optionally takes a base lucene query object. @@ -282,15 +286,14 @@ export class QueryBuilder { } const equal = (key: string, value: any) => { - // 0 evaluates to false, which means we would return all rows if we don't check it - if (!value && value !== 0) { + if (isEmpty(value)) { return null } return `${key}:${builder.preprocess(value, allPreProcessingOpts)}` } const contains = (key: string, value: any, mode = "AND") => { - if (!value || (Array.isArray(value) && value.length === 0)) { + if (isEmpty(value)) { return null } if (!Array.isArray(value)) { @@ -306,7 +309,7 @@ export class QueryBuilder { } const fuzzy = (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } value = builder.preprocess(value, { @@ -328,7 +331,7 @@ export class QueryBuilder { } const oneOf = (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return `*:*` } if (!Array.isArray(value)) { @@ -386,7 +389,7 @@ export class QueryBuilder { // Construct the actual lucene search query string from JSON structure if (this.#query.string) { build(this.#query.string, (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } value = builder.preprocess(value, { @@ -399,7 +402,7 @@ export class QueryBuilder { } if (this.#query.range) { build(this.#query.range, (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } if (value.low == null || value.low === "") { @@ -421,7 +424,7 @@ export class QueryBuilder { } if (this.#query.notEqual) { build(this.#query.notEqual, (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } if (typeof value === "boolean") { @@ -431,10 +434,28 @@ export class QueryBuilder { }) } if (this.#query.empty) { - build(this.#query.empty, (key: string) => `(*:* -${key}:["" TO *])`) + build(this.#query.empty, (key: string) => { + // Because the structure of an empty filter looks like this: + // { empty: { someKey: null } } + // + // The check inside of `build` does not set `allFiltersEmpty`, which results + // in weird behaviour when the empty filter is the only filter. We get around + // this by setting `allFiltersEmpty` to false here. + allFiltersEmpty = false + return `(*:* -${key}:["" TO *])` + }) } if (this.#query.notEmpty) { - build(this.#query.notEmpty, (key: string) => `${key}:["" TO *]`) + build(this.#query.notEmpty, (key: string) => { + // Because the structure of a notEmpty filter looks like this: + // { notEmpty: { someKey: null } } + // + // The check inside of `build` does not set `allFiltersEmpty`, which results + // in weird behaviour when the empty filter is the only filter. We get around + // this by setting `allFiltersEmpty` to false here. + allFiltersEmpty = false + return `${key}:["" TO *]` + }) } if (this.#query.oneOf) { build(this.#query.oneOf, oneOf) diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index 69dba27c43..51cd4ec2af 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -13,7 +13,7 @@ import { getGlobalDB, doInTenant } from "../context" import { decrypt } from "../security/encryption" import * as identity from "../context/identity" import env from "../environment" -import { Ctx, EndpointMatcher, SessionCookie } from "@budibase/types" +import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types" import { InvalidAPIKeyError, ErrorCode } from "../errors" import tracer from "dd-trace" @@ -41,7 +41,10 @@ function finalise(ctx: any, opts: FinaliseOpts = {}) { ctx.version = opts.version } -async function checkApiKey(apiKey: string, populateUser?: Function) { +async function checkApiKey( + apiKey: string, + populateUser?: (userId: string, tenantId: string) => Promise +) { // check both the primary and the fallback internal api keys // this allows for rotation if (isValidInternalAPIKey(apiKey)) { @@ -128,6 +131,7 @@ export default function ( } else { user = await getUser(userId, session.tenantId) } + // @ts-ignore user.csrfToken = session.csrfToken if (session?.lastAccessedAt < timeMinusOneMinute()) { @@ -167,19 +171,25 @@ export default function ( authenticated = false } - if (user) { + const isUser = ( + user: any + ): user is User & { budibaseAccess?: string } => { + return user && user.email + } + + if (isUser(user)) { tracer.setUser({ - id: user?._id, - tenantId: user?.tenantId, - budibaseAccess: user?.budibaseAccess, - status: user?.status, + id: user._id!, + tenantId: user.tenantId, + budibaseAccess: user.budibaseAccess, + status: user.status, }) } // isAuthenticated is a function, so use a variable to be able to check authed state finalise(ctx, { authenticated, user, internal, version, publicEndpoint }) - if (user && user.email) { + if (isUser(user)) { return identity.doInUserContext(user, ctx, next) } else { return next() diff --git a/packages/backend-core/src/objectStore/objectStore.ts b/packages/backend-core/src/objectStore/objectStore.ts index aa5365c5c3..0ac2c35179 100644 --- a/packages/backend-core/src/objectStore/objectStore.ts +++ b/packages/backend-core/src/objectStore/objectStore.ts @@ -13,13 +13,14 @@ import { bucketTTLConfig, budibaseTempDir } from "./utils" import { v4 } from "uuid" import { APP_PREFIX, APP_DEV_PREFIX } from "../db" import fsp from "fs/promises" +import { HeadObjectOutput } from "aws-sdk/clients/s3" const streamPipeline = promisify(stream.pipeline) // use this as a temporary store of buckets that are being created const STATE = { bucketCreationPromises: {}, } -const signedFilePrefix = "/files/signed" +export const SIGNED_FILE_PREFIX = "/files/signed" type ListParams = { ContinuationToken?: string @@ -40,8 +41,13 @@ type UploadParams = BaseUploadParams & { path?: string | PathLike } -type StreamUploadParams = BaseUploadParams & { - stream: ReadStream +export type StreamTypes = + | ReadStream + | NodeJS.ReadableStream + | ReadableStream + +export type StreamUploadParams = BaseUploadParams & { + stream?: StreamTypes } const CONTENT_TYPE_MAP: any = { @@ -83,7 +89,7 @@ export function ObjectStore( bucket: string, opts: { presigning: boolean } = { presigning: false } ) { - const config: any = { + const config: AWS.S3.ClientConfiguration = { s3ForcePathStyle: true, signatureVersion: "v4", apiVersion: "2006-03-01", @@ -174,11 +180,9 @@ export async function upload({ const objectStore = ObjectStore(bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) - if (ttl && (bucketCreated.created || bucketCreated.exists)) { + if (ttl && bucketCreated.created) { let ttlConfig = bucketTTLConfig(bucketName, ttl) - if (objectStore.putBucketLifecycleConfiguration) { - await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise() - } + await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise() } let contentType = type @@ -222,11 +226,9 @@ export async function streamUpload({ const objectStore = ObjectStore(bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) - if (ttl && (bucketCreated.created || bucketCreated.exists)) { + if (ttl && bucketCreated.created) { let ttlConfig = bucketTTLConfig(bucketName, ttl) - if (objectStore.putBucketLifecycleConfiguration) { - await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise() - } + await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise() } // Set content type for certain known extensions @@ -333,7 +335,7 @@ export function getPresignedUrl( const signedUrl = new URL(url) const path = signedUrl.pathname const query = signedUrl.search - return `${signedFilePrefix}${path}${query}` + return `${SIGNED_FILE_PREFIX}${path}${query}` } } @@ -521,6 +523,26 @@ export async function getReadStream( return client.getObject(params).createReadStream() } +export async function getObjectMetadata( + bucket: string, + path: string +): Promise { + bucket = sanitizeBucket(bucket) + path = sanitizeKey(path) + + const client = ObjectStore(bucket) + const params = { + Bucket: bucket, + Key: path, + } + + try { + return await client.headObject(params).promise() + } catch (err: any) { + throw new Error("Unable to retrieve metadata from object") + } +} + /* Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract the bucket and the path from it @@ -530,7 +552,9 @@ export function extractBucketAndPath( ): { bucket: string; path: string } | null { const baseUrl = url.split("?")[0] - const regex = new RegExp(`^${signedFilePrefix}/(?[^/]+)/(?.+)$`) + const regex = new RegExp( + `^${SIGNED_FILE_PREFIX}/(?[^/]+)/(?.+)$` + ) const match = baseUrl.match(regex) if (match && match.groups) { diff --git a/packages/backend-core/src/objectStore/utils.ts b/packages/backend-core/src/objectStore/utils.ts index 08b5238ff6..5b9c2e3646 100644 --- a/packages/backend-core/src/objectStore/utils.ts +++ b/packages/backend-core/src/objectStore/utils.ts @@ -1,9 +1,14 @@ -import { join } from "path" +import path, { join } from "path" import { tmpdir } from "os" import fs from "fs" import env from "../environment" import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3" - +import * as objectStore from "./objectStore" +import { + AutomationAttachment, + AutomationAttachmentContent, + BucketedContent, +} from "@budibase/types" /**************************************************** * NOTE: When adding a new bucket - name * * sure that S3 usages (like budibase-infra) * @@ -55,3 +60,50 @@ export const bucketTTLConfig = ( return params } + +async function processUrlAttachment( + attachment: AutomationAttachment +): Promise { + const response = await fetch(attachment.url) + if (!response.ok || !response.body) { + throw new Error(`Unexpected response ${response.statusText}`) + } + const fallbackFilename = path.basename(new URL(attachment.url).pathname) + return { + filename: attachment.filename || fallbackFilename, + content: response.body, + } +} + +export async function processObjectStoreAttachment( + attachment: AutomationAttachment +): Promise { + const result = objectStore.extractBucketAndPath(attachment.url) + + if (result === null) { + throw new Error("Invalid signed URL") + } + + const { bucket, path: objectPath } = result + const readStream = await objectStore.getReadStream(bucket, objectPath) + const fallbackFilename = path.basename(objectPath) + return { + bucket, + path: objectPath, + filename: attachment.filename || fallbackFilename, + content: readStream, + } +} + +export async function processAutomationAttachment( + attachment: AutomationAttachment +): Promise { + const isFullyFormedUrl = + attachment.url?.startsWith("http://") || + attachment.url?.startsWith("https://") + if (isFullyFormedUrl) { + return await processUrlAttachment(attachment) + } else { + return await processObjectStoreAttachment(attachment) + } +} diff --git a/packages/backend-core/tests/core/utilities/index.ts b/packages/backend-core/tests/core/utilities/index.ts index b2f19a0286..787d69be2c 100644 --- a/packages/backend-core/tests/core/utilities/index.ts +++ b/packages/backend-core/tests/core/utilities/index.ts @@ -4,6 +4,3 @@ export { generator } from "./structures" export * as testContainerUtils from "./testContainerUtils" export * as utils from "./utils" export * from "./jestUtils" -import * as minio from "./minio" - -export const objectStoreTestProviders = { minio } diff --git a/packages/backend-core/tests/core/utilities/minio.ts b/packages/backend-core/tests/core/utilities/minio.ts deleted file mode 100644 index cef33daa91..0000000000 --- a/packages/backend-core/tests/core/utilities/minio.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { GenericContainer, Wait, StartedTestContainer } from "testcontainers" -import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy" -import env from "../../../src/environment" - -let container: StartedTestContainer | undefined - -class ObjectStoreWaitStrategy extends AbstractWaitStrategy { - async waitUntilReady(container: any, boundPorts: any, startTime?: Date) { - const logs = Wait.forListeningPorts() - await logs.waitUntilReady(container, boundPorts, startTime) - } -} - -export async function start(): Promise { - container = await new GenericContainer("minio/minio") - .withExposedPorts(9000) - .withCommand(["server", "/data"]) - .withEnvironment({ - MINIO_ACCESS_KEY: "budibase", - MINIO_SECRET_KEY: "budibase", - }) - .withWaitStrategy(new ObjectStoreWaitStrategy().withStartupTimeout(30000)) - .start() - - const port = container.getMappedPort(9000) - env._set("MINIO_URL", `http://0.0.0.0:${port}`) -} - -export async function stop() { - if (container) { - await container.stop() - container = undefined - } -} diff --git a/packages/backend-core/tests/core/utilities/testContainerUtils.ts b/packages/backend-core/tests/core/utilities/testContainerUtils.ts index d0dd2c9b4d..1a25bb28f4 100644 --- a/packages/backend-core/tests/core/utilities/testContainerUtils.ts +++ b/packages/backend-core/tests/core/utilities/testContainerUtils.ts @@ -28,7 +28,11 @@ function getTestcontainers(): ContainerInfo[] { .split("\n") .filter(x => x.length > 0) .map(x => JSON.parse(x) as ContainerInfo) - .filter(x => x.Labels.includes("org.testcontainers=true")) + .filter( + x => + x.Labels.includes("org.testcontainers=true") && + x.Labels.includes("com.budibase=true") + ) } export function getContainerByImage(image: string) { @@ -82,10 +86,18 @@ export function setupEnv(...envs: any[]) { throw new Error("CouchDB SQL port not found") } + const minio = getContainerByImage("minio/minio") + + const minioPort = getExposedV4Port(minio, 9000) + if (!minioPort) { + throw new Error("Minio port not found") + } + const configs = [ { key: "COUCH_DB_PORT", value: `${couchPort}` }, { key: "COUCH_DB_URL", value: `http://127.0.0.1:${couchPort}` }, { key: "COUCH_DB_SQL_URL", value: `http://127.0.0.1:${couchSqlPort}` }, + { key: "MINIO_URL", value: `http://127.0.0.1:${minioPort}` }, ] for (const config of configs.filter(x => !!x.value)) { diff --git a/packages/bbui/package.json b/packages/bbui/package.json index a1baa2a38b..1b3510cf3b 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -43,6 +43,7 @@ "@spectrum-css/avatar": "3.0.2", "@spectrum-css/button": "3.0.1", "@spectrum-css/buttongroup": "3.0.2", + "@spectrum-css/calendar": "3.2.7", "@spectrum-css/checkbox": "3.0.2", "@spectrum-css/dialog": "3.0.1", "@spectrum-css/divider": "1.0.3", @@ -82,7 +83,6 @@ "dayjs": "^1.10.8", "easymde": "^2.16.1", "svelte-dnd-action": "^0.9.8", - "svelte-flatpickr": "3.2.3", "svelte-portal": "^1.0.0" }, "resolutions": { diff --git a/packages/bbui/src/ActionMenu/ActionMenu.svelte b/packages/bbui/src/ActionMenu/ActionMenu.svelte index c55d1cb43d..75ddd679da 100644 --- a/packages/bbui/src/ActionMenu/ActionMenu.svelte +++ b/packages/bbui/src/ActionMenu/ActionMenu.svelte @@ -38,7 +38,15 @@
- + diff --git a/packages/bbui/src/Actions/click_outside.js b/packages/bbui/src/Actions/click_outside.js index eafca657f3..124f43ff04 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -1,13 +1,24 @@ +// These class names will never trigger a callback if clicked, no matter what const ignoredClasses = [ - ".flatpickr-calendar", - ".spectrum-Popover", ".download-js-link", + ".spectrum-Menu", + ".date-time-popover", +] + +// These class names will only trigger a callback when clicked if the registered +// component is not nested inside them. For example, clicking inside a modal +// will not close the modal, or clicking inside a popover will not close the +// popover. +const conditionallyIgnoredClasses = [ + ".spectrum-Underlay", + ".drawer-wrapper", + ".spectrum-Popover", ] let clickHandlers = [] +let candidateTarget -/** - * Handle a body click event - */ +// Processes a "click outside" event and invokes callbacks if our source element +// is valid const handleClick = event => { // Ignore click if this is an ignored class if (event.target.closest('[data-ignore-click-outside="true"]')) { @@ -21,41 +32,60 @@ const handleClick = event => { // Process handlers clickHandlers.forEach(handler => { + // Check that the click isn't inside the target if (handler.element.contains(event.target)) { return } - // Ignore clicks for modals, unless the handler is registered from a modal - const sourceInModal = handler.anchor.closest(".spectrum-Underlay") != null - const clickInModal = event.target.closest(".spectrum-Underlay") != null - if (clickInModal && !sourceInModal) { - return - } - - // Ignore clicks for drawers, unless the handler is registered from a drawer - const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null - const clickInDrawer = event.target.closest(".drawer-wrapper") != null - if (clickInDrawer && !sourceInDrawer) { - return - } - - if (handler.allowedType && event.type !== handler.allowedType) { - return + // Ignore clicks for certain classes unless we're nested inside them + for (let className of conditionallyIgnoredClasses) { + const sourceInside = handler.anchor.closest(className) != null + const clickInside = event.target.closest(className) != null + if (clickInside && !sourceInside) { + return + } } handler.callback?.(event) }) } -document.documentElement.addEventListener("click", handleClick, true) -document.documentElement.addEventListener("mousedown", handleClick, true) + +// On mouse up we only trigger a "click outside" callback if we targetted the +// same element that we did on mouse down. This fixes all sorts of issues where +// we get annoying callbacks firing when we drag to select text. +const handleMouseUp = e => { + if (candidateTarget === e.target) { + handleClick(e) + } + candidateTarget = null +} + +// On mouse down we store which element was targetted for comparison later +const handleMouseDown = e => { + // Only handle the primary mouse button here. + // We handle context menu (right click) events in another handler. + if (e.button !== 0) { + return + } + candidateTarget = e.target + + // 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) +} + +// Global singleton listeners for our events +document.addEventListener("mousedown", handleMouseDown) +document.addEventListener("contextmenu", handleClick) /** * Adds or updates a click handler */ -const updateHandler = (id, element, anchor, callback, allowedType) => { +const updateHandler = (id, element, anchor, callback) => { let existingHandler = clickHandlers.find(x => x.id === id) if (!existingHandler) { - clickHandlers.push({ id, element, anchor, callback, allowedType }) + clickHandlers.push({ id, element, anchor, callback }) } else { existingHandler.callback = callback } @@ -82,8 +112,7 @@ export default (element, opts) => { const callback = newOpts?.callback || (typeof newOpts === "function" ? newOpts : null) const anchor = newOpts?.anchor || element - const allowedType = newOpts?.allowedType || "click" - updateHandler(id, element, anchor, callback, allowedType) + updateHandler(id, element, anchor, callback) } update(opts) return { diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 770d1bd507..6c4fcab757 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -1,3 +1,22 @@ +/** + * Valid alignment options are + * - left + * - right + * - left-outside + * - right-outside + **/ + +// Strategies are defined as [Popover]To[Anchor]. +// They can apply for both horizontal and vertical alignment. +const Strategies = { + StartToStart: "StartToStart", // e.g. left alignment + EndToEnd: "EndToEnd", // e.g. right alignment + StartToEnd: "StartToEnd", // e.g. right-outside alignment + EndToStart: "EndToStart", // e.g. left-outside alignment + MidPoint: "MidPoint", // centers relative to midpoints + ScreenEdge: "ScreenEdge", // locks to screen edge +} + export default function positionDropdown(element, opts) { let resizeObserver let latestOpts = opts @@ -19,6 +38,8 @@ export default function positionDropdown(element, opts) { useAnchorWidth, offset = 5, customUpdate, + resizable, + wrap, } = opts if (!anchor) { return @@ -27,56 +48,159 @@ export default function positionDropdown(element, opts) { // Compute bounds const anchorBounds = anchor.getBoundingClientRect() const elementBounds = element.getBoundingClientRect() + const winWidth = window.innerWidth + const winHeight = window.innerHeight + const screenOffset = 8 let styles = { - maxHeight: null, - minWidth, - maxWidth, + maxHeight, + minWidth: useAnchorWidth ? anchorBounds.width : minWidth, + maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth, left: null, top: null, } + // Ignore all our logic for custom logic if (typeof customUpdate === "function") { styles = customUpdate(anchorBounds, elementBounds, { ...styles, offset: opts.offset, }) - } else { - // Determine vertical styles - if (align === "right-outside" || align === "left-outside") { - styles.top = - anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2 - styles.maxHeight = maxHeight - if (styles.top + elementBounds.height > window.innerHeight) { - styles.top = window.innerHeight - elementBounds.height - } - } else if ( - window.innerHeight - anchorBounds.bottom < - (maxHeight || 100) - ) { - styles.top = anchorBounds.top - elementBounds.height - offset - styles.maxHeight = maxHeight || 240 - } else { - styles.top = anchorBounds.bottom + offset - styles.maxHeight = - maxHeight || window.innerHeight - anchorBounds.bottom - 20 + } + + // Otherwise position ourselves as normal + else { + // Checks if we overflow off the screen. We only report that we overflow + // when the alternative dimension is larger than the one we are checking. + const doesXOverflow = () => { + const overflows = styles.left + elementBounds.width > winWidth + return overflows && anchorBounds.left > winWidth - anchorBounds.right + } + const doesYOverflow = () => { + const overflows = styles.top + elementBounds.height > winHeight + return overflows && anchorBounds.top > winHeight - anchorBounds.bottom } - // Determine horizontal styles - if (!maxWidth && useAnchorWidth) { - styles.maxWidth = anchorBounds.width + // Applies a dynamic max height constraint if appropriate + const applyMaxHeight = height => { + if (!styles.maxHeight && resizable) { + styles.maxHeight = height + } } - if (useAnchorWidth) { - styles.minWidth = anchorBounds.width + + // Applies the X strategy to our styles + const applyXStrategy = strategy => { + switch (strategy) { + case Strategies.StartToStart: + default: + styles.left = anchorBounds.left + break + case Strategies.EndToEnd: + styles.left = anchorBounds.right - elementBounds.width + break + case Strategies.StartToEnd: + styles.left = anchorBounds.right + offset + break + case Strategies.EndToStart: + styles.left = anchorBounds.left - elementBounds.width - offset + break + case Strategies.MidPoint: + styles.left = + anchorBounds.left + + anchorBounds.width / 2 - + elementBounds.width / 2 + break + case Strategies.ScreenEdge: + styles.left = winWidth - elementBounds.width - screenOffset + break + } } + + // Applies the Y strategy to our styles + const applyYStrategy = strategy => { + switch (strategy) { + case Strategies.StartToStart: + styles.top = anchorBounds.top + applyMaxHeight(winHeight - anchorBounds.top - screenOffset) + break + case Strategies.EndToEnd: + styles.top = anchorBounds.bottom - elementBounds.height + applyMaxHeight(anchorBounds.bottom - screenOffset) + break + case Strategies.StartToEnd: + default: + styles.top = anchorBounds.bottom + offset + applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset) + break + case Strategies.EndToStart: + styles.top = anchorBounds.top - elementBounds.height - offset + applyMaxHeight(anchorBounds.top - screenOffset) + break + case Strategies.MidPoint: + styles.top = + anchorBounds.top + + anchorBounds.height / 2 - + elementBounds.height / 2 + break + case Strategies.ScreenEdge: + styles.top = winHeight - elementBounds.height - screenOffset + applyMaxHeight(winHeight - 2 * screenOffset) + break + } + } + + // Determine X strategy if (align === "right") { - styles.left = - anchorBounds.left + anchorBounds.width - elementBounds.width + applyXStrategy(Strategies.EndToEnd) } else if (align === "right-outside") { - styles.left = anchorBounds.right + offset + applyXStrategy(Strategies.StartToEnd) } else if (align === "left-outside") { - styles.left = anchorBounds.left - elementBounds.width - offset + applyXStrategy(Strategies.EndToStart) } else { - styles.left = anchorBounds.left + applyXStrategy(Strategies.StartToStart) + } + + // Determine Y strategy + if (align === "right-outside" || align === "left-outside") { + applyYStrategy(Strategies.MidPoint) + } else { + applyYStrategy(Strategies.StartToEnd) + } + + // Handle screen overflow + if (doesXOverflow()) { + // Swap left to right + if (align === "left") { + applyXStrategy(Strategies.EndToEnd) + } + // Swap right-outside to left-outside + else if (align === "right-outside") { + applyXStrategy(Strategies.EndToStart) + } + } + if (doesYOverflow()) { + // If wrapping, lock to the bottom of the screen and also reposition to + // the side to not block the anchor + if (wrap) { + applyYStrategy(Strategies.MidPoint) + if (doesYOverflow()) { + applyYStrategy(Strategies.ScreenEdge) + } + applyXStrategy(Strategies.StartToEnd) + if (doesXOverflow()) { + applyXStrategy(Strategies.EndToStart) + } + } + // Othewise invert as normal + else { + // If using an outside strategy then lock to the bottom of the screen + if (align === "left-outside" || align === "right-outside") { + applyYStrategy(Strategies.ScreenEdge) + } + // Otherwise flip above + else { + applyYStrategy(Strategies.EndToStart) + } + } } } diff --git a/packages/bbui/src/Banner/Banner.svelte b/packages/bbui/src/Banner/Banner.svelte index a04d469cc7..2ce9795d70 100644 --- a/packages/bbui/src/Banner/Banner.svelte +++ b/packages/bbui/src/Banner/Banner.svelte @@ -8,6 +8,8 @@ export let size = "S" export let extraButtonText export let extraButtonAction + export let extraLinkText + export let extraLinkAction export let showCloseButton = true let show = true @@ -28,8 +30,13 @@
-
+
+ {#if extraLinkText} + + {/if}
{#if extraButtonText && extraButtonAction} -
- -{/key} -{#if open} - -
-{/if} - - diff --git a/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte b/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte new file mode 100644 index 0000000000..d7056db6d6 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte @@ -0,0 +1,252 @@ + + +
+
+
+
+ +
+ {#if !disabled && !readonly} + + {/if} +
+ + diff --git a/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte new file mode 100644 index 0000000000..f5189c9edd --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte @@ -0,0 +1,83 @@ + + + + + + {#if isOpen} + + {/if} + diff --git a/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte b/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte new file mode 100644 index 0000000000..f1bb809b29 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte @@ -0,0 +1,102 @@ + + +
+ {#if showCalendar} + handleChange(e.detail)} + bind:this={calendar} + /> + {/if} + +
+ + diff --git a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte new file mode 100644 index 0000000000..dc4886d28d --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte new file mode 100644 index 0000000000..047e5a4f08 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte @@ -0,0 +1,59 @@ + + +
+ + : + +
+ + diff --git a/packages/bbui/src/Form/Core/DatePicker/utils.js b/packages/bbui/src/Form/Core/DatePicker/utils.js new file mode 100644 index 0000000000..953c3eb6c0 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/utils.js @@ -0,0 +1,14 @@ +export const cleanInput = ({ max, pad, fallback }) => { + return e => { + if (e.target.value) { + const value = parseInt(e.target.value) + if (isNaN(value)) { + e.target.value = fallback + } else { + e.target.value = Math.min(max, value).toString().padStart(pad, "0") + } + } else { + e.target.value = fallback + } + } +} diff --git a/packages/bbui/src/Form/Core/DateRangePicker.svelte b/packages/bbui/src/Form/Core/DateRangePicker.svelte new file mode 100644 index 0000000000..9084942ba7 --- /dev/null +++ b/packages/bbui/src/Form/Core/DateRangePicker.svelte @@ -0,0 +1,69 @@ + + +
+ (fromDate = e.detail)} + enableTime={false} + /> +
+ +
+ (toDate = e.detail)} + enableTime={false} + /> +
+ + diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index f6bffbbf10..eb7a5db655 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -155,6 +155,7 @@ useAnchorWidth={!autoWidth} maxWidth={autoWidth ? 400 : null} customHeight={customPopoverHeight} + maxHeight={240} >
import Field from "./Field.svelte" - import DatePicker from "./Core/DatePicker.svelte" + import DatePicker from "./Core/DatePicker/DatePicker.svelte" import { createEventDispatcher } from "svelte" export let value = null @@ -11,22 +11,15 @@ export let error = null export let enableTime = true export let timeOnly = false - export let time24hr = false export let placeholder = null export let appendTo = undefined export let ignoreTimezones = false - export let range = false export let helpText = null + const dispatch = createEventDispatcher() const onChange = e => { - if (range) { - // Flatpickr cant take two dates and work out what to display, needs to be provided a string. - // Like - "Date1 to Date2". Hence passing in that specifically from the array - value = e?.detail[1] - } else { - value = e.detail - } + value = e.detail dispatch("change", e.detail) } @@ -40,10 +33,8 @@ {placeholder} {enableTime} {timeOnly} - {time24hr} {appendTo} {ignoreTimezones} - {range} on:change={onChange} /> diff --git a/packages/bbui/src/Form/DateRangePicker.svelte b/packages/bbui/src/Form/DateRangePicker.svelte new file mode 100644 index 0000000000..39c2acb96a --- /dev/null +++ b/packages/bbui/src/Form/DateRangePicker.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/bbui/src/Layout/Page.svelte b/packages/bbui/src/Layout/Page.svelte index 2169a12459..62dd9cc909 100644 --- a/packages/bbui/src/Layout/Page.svelte +++ b/packages/bbui/src/Layout/Page.svelte @@ -7,11 +7,11 @@ export let narrower = false export let noPadding = false - let sidePanelVisble = false + let sidePanelVisible = false setContext("side-panel", { - open: () => (sidePanelVisble = true), - close: () => (sidePanelVisble = false), + open: () => (sidePanelVisible = true), + close: () => (sidePanelVisible = false), }) @@ -24,9 +24,9 @@
{ - sidePanelVisble = false + sidePanelVisible = false }} > diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index c51af48300..aa811afe1e 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -18,13 +18,15 @@ export let open = false export let useAnchorWidth = false export let dismissible = true - export let offset = 5 + export let offset = 4 export let customHeight export let animate = true export let customZindex export let handlePostionUpdate export let showPopover = true export let clickOutsideOverride = false + export let resizable = true + export let wrap = false $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" @@ -91,6 +93,8 @@ useAnchorWidth, offset, customUpdate: handlePostionUpdate, + resizable, + wrap, }} use:clickOutside={{ callback: dismissible ? handleOutsideClick : () => {}, @@ -116,12 +120,11 @@ min-width: var(--spectrum-global-dimension-size-2000); border-color: var(--spectrum-global-color-gray-300); overflow: auto; - transition: opacity 260ms ease-out, transform 260ms ease-out; + transition: opacity 260ms ease-out; } .hidden { opacity: 0; pointer-events: none; - transform: translateY(-20px); } .customZindex { z-index: var(--customZindex) !important; diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index abb7c9a8aa..90b447f3c1 100644 --- a/packages/bbui/src/helpers.js +++ b/packages/bbui/src/helpers.js @@ -1,4 +1,5 @@ import { helpers } from "@budibase/shared-core" +import dayjs from "dayjs" export const deepGet = helpers.deepGet @@ -115,3 +116,110 @@ export const copyToClipboard = value => { } }) } + +// Parsed a date value. This is usually an ISO string, but can be a +// bunch of different formats and shapes depending on schema flags. +export const parseDate = (value, { enableTime = true }) => { + // If empty then invalid + if (!value) { + return null + } + + // Certain string values need transformed + if (typeof value === "string") { + // Check for time only values + if (!isNaN(new Date(`0-${value}`))) { + value = `0-${value}` + } + + // If date only, check for cases where we received a UTC string + else if (!enableTime && value.endsWith("Z")) { + value = value.split("Z")[0] + } + } + + // Parse value and check for validity + const parsedDate = dayjs(value) + if (!parsedDate.isValid()) { + return null + } + + // By rounding to the nearest second we avoid locking up in an endless + // loop in the builder, caused by potentially enriching {{ now }} to every + // millisecond. + return dayjs(Math.floor(parsedDate.valueOf() / 1000) * 1000) +} + +// Stringifies a dayjs object to create an ISO string that respects the various +// schema flags +export const stringifyDate = ( + value, + { enableTime = true, timeOnly = false, ignoreTimezones = false } = {} +) => { + if (!value) { + return null + } + + // Time only fields always ignore timezones, otherwise they make no sense. + // For non-timezone-aware fields, create an ISO 8601 timestamp of the exact + // time picked, without timezone + const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly + if (offsetForTimezone) { + // Ensure we use the correct offset for the date + const referenceDate = timeOnly ? new Date() : value.toDate() + const offset = referenceDate.getTimezoneOffset() * 60000 + return new Date(value.valueOf() - offset).toISOString().slice(0, -1) + } + + // For date-only fields, construct a manual timestamp string without a time + // or time zone + else if (!enableTime) { + const year = value.year() + const month = `${value.month() + 1}`.padStart(2, "0") + const day = `${value.date()}`.padStart(2, "0") + return `${year}-${month}-${day}T00:00:00.000` + } + + // Otherwise use a normal ISO string with time and timezone + else { + return value.toISOString() + } +} + +// Determine the dayjs-compatible format of the browser's default locale +const getPatternForPart = part => { + switch (part.type) { + case "day": + return "D".repeat(part.value.length) + case "month": + return "M".repeat(part.value.length) + case "year": + return "Y".repeat(part.value.length) + case "literal": + return part.value + default: + console.log("Unsupported date part", part) + return "" + } +} +const localeDateFormat = new Intl.DateTimeFormat() + .formatToParts(new Date("2021-01-01")) + .map(getPatternForPart) + .join("") + +// Formats a dayjs date according to schema flags +export const getDateDisplayValue = ( + value, + { enableTime = true, timeOnly = false } = {} +) => { + if (!value?.isValid()) { + return "" + } + if (timeOnly) { + return value.format("HH:mm") + } else if (!enableTime) { + return value.format(localeDateFormat) + } else { + return value.format(`${localeDateFormat} HH:mm`) + } +} diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js index d18c4d6910..f28e185305 100644 --- a/packages/bbui/src/index.js +++ b/packages/bbui/src/index.js @@ -3,13 +3,34 @@ import "./bbui.css" // Spectrum icons import "@spectrum-css/icon/dist/index-vars.css" -// Components +// Form components export { default as Input } from "./Form/Input.svelte" export { default as Stepper } from "./Form/Stepper.svelte" export { default as TextArea } from "./Form/TextArea.svelte" export { default as Select } from "./Form/Select.svelte" export { default as Combobox } from "./Form/Combobox.svelte" export { default as Dropzone } from "./Form/Dropzone.svelte" +export { default as DatePicker } from "./Form/DatePicker.svelte" +export { default as DateRangePicker } from "./Form/DateRangePicker.svelte" +export { default as Toggle } from "./Form/Toggle.svelte" +export { default as RadioGroup } from "./Form/RadioGroup.svelte" +export { default as Checkbox } from "./Form/Checkbox.svelte" +export { default as InputDropdown } from "./Form/InputDropdown.svelte" +export { default as PickerDropdown } from "./Form/PickerDropdown.svelte" +export { default as EnvDropdown } from "./Form/EnvDropdown.svelte" +export { default as Multiselect } from "./Form/Multiselect.svelte" +export { default as Search } from "./Form/Search.svelte" +export { default as RichTextField } from "./Form/RichTextField.svelte" +export { default as Slider } from "./Form/Slider.svelte" +export { default as File } from "./Form/File.svelte" + +// Core form components to be used elsewhere (standard components) +export * from "./Form/Core" + +// Fancy form components +export * from "./FancyForm" + +// Components export { default as Drawer } from "./Drawer/Drawer.svelte" export { default as DrawerContent } from "./Drawer/DrawerContent.svelte" export { default as Avatar } from "./Avatar/Avatar.svelte" @@ -21,12 +42,6 @@ export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as Icon } from "./Icon/Icon.svelte" export { default as IconAvatar } from "./Icon/IconAvatar.svelte" -export { default as Toggle } from "./Form/Toggle.svelte" -export { default as RadioGroup } from "./Form/RadioGroup.svelte" -export { default as Checkbox } from "./Form/Checkbox.svelte" -export { default as InputDropdown } from "./Form/InputDropdown.svelte" -export { default as PickerDropdown } from "./Form/PickerDropdown.svelte" -export { default as EnvDropdown } from "./Form/EnvDropdown.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as Popover } from "./Popover/Popover.svelte" export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte" @@ -37,11 +52,6 @@ export { default as Page } from "./Layout/Page.svelte" export { default as Link } from "./Link/Link.svelte" export { default as Tooltip } from "./Tooltip/Tooltip.svelte" export { default as TempTooltip } from "./Tooltip/TempTooltip.svelte" -export { - default as AbsTooltip, - TooltipPosition, - TooltipType, -} from "./Tooltip/AbsTooltip.svelte" export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte" export { default as Menu } from "./Menu/Menu.svelte" export { default as MenuSection } from "./Menu/Section.svelte" @@ -53,8 +63,6 @@ export { default as NotificationDisplay } from "./Notification/NotificationDispl export { default as Notification } from "./Notification/Notification.svelte" export { default as SideNavigation } from "./SideNavigation/Navigation.svelte" export { default as SideNavigationItem } from "./SideNavigation/Item.svelte" -export { default as DatePicker } from "./Form/DatePicker.svelte" -export { default as Multiselect } from "./Form/Multiselect.svelte" export { default as Context } from "./context" export { default as Table } from "./Table/Table.svelte" export { default as Tabs } from "./Tabs/Tabs.svelte" @@ -64,7 +72,6 @@ export { default as Tag } from "./Tags/Tag.svelte" export { default as TreeView } from "./TreeView/Tree.svelte" export { default as TreeItem } from "./TreeView/Item.svelte" export { default as Divider } from "./Divider/Divider.svelte" -export { default as Search } from "./Form/Search.svelte" export { default as Pagination } from "./Pagination/Pagination.svelte" export { default as Badge } from "./Badge/Badge.svelte" export { default as StatusLight } from "./StatusLight/StatusLight.svelte" @@ -76,15 +83,15 @@ export { default as CopyInput } from "./Input/CopyInput.svelte" export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte" export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte" export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte" -export { default as RichTextField } from "./Form/RichTextField.svelte" export { default as List } from "./List/List.svelte" export { default as ListItem } from "./List/ListItem.svelte" export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte" export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte" -export { default as Slider } from "./Form/Slider.svelte" export { default as Accordion } from "./Accordion/Accordion.svelte" -export { default as File } from "./Form/File.svelte" export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte" +export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte" +export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte" + // Renderers export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" export { default as CodeRenderer } from "./Table/CodeRenderer.svelte" @@ -96,9 +103,6 @@ export { default as Heading } from "./Typography/Heading.svelte" export { default as Detail } from "./Typography/Detail.svelte" export { default as Code } from "./Typography/Code.svelte" -// Core form components to be used elsewhere (standard components) -export * from "./Form/Core" - // Actions export { default as autoResizeTextArea } from "./Actions/autoresize_textarea" export { default as positionDropdown } from "./Actions/position_dropdown" @@ -110,6 +114,3 @@ export { banner, BANNER_TYPES } from "./Stores/banner" // Helpers export * as Helpers from "./helpers" - -// Fancy form components -export * from "./FancyForm" diff --git a/packages/builder/assets/FreeTrial.svelte b/packages/builder/assets/FreeTrial.svelte new file mode 100644 index 0000000000..79a722d90c --- /dev/null +++ b/packages/builder/assets/FreeTrial.svelte @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 2d2022299c..85af5bbafd 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -48,6 +48,7 @@ import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { onMount } from "svelte" import { cloneDeep } from "lodash/fp" + import { FIELDS } from "constants/backend" export let block export let testData @@ -228,6 +229,10 @@ categoryName, bindingName ) => { + const field = Object.values(FIELDS).find( + field => field.type === value.type && field.subtype === value.subtype + ) + return { readableBinding: bindingName ? `${bindingName}.${name}` @@ -238,7 +243,7 @@ icon, category: categoryName, display: { - type: value.type, + type: field?.name || value.type, name, rank: isLoopBlock ? idx + 1 : idx - loopBlockCount, }, @@ -282,6 +287,7 @@ for (const key in table?.schema) { schema[key] = { type: table.schema[key].type, + subtype: table.schema[key].subtype, } } // remove the original binding @@ -358,7 +364,8 @@ value.customType !== "cron" && value.customType !== "triggerSchema" && value.customType !== "automationFields" && - value.type !== "attachment" + value.type !== "attachment" && + value.type !== "attachment_single" ) } diff --git a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte index 0d15df6c87..ab020aad08 100644 --- a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte +++ b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte @@ -2,6 +2,8 @@ import { tables } from "stores/builder" import { Select, Checkbox, Label } from "@budibase/bbui" import { createEventDispatcher } from "svelte" + import { FieldType } from "@budibase/types" + import RowSelectorTypes from "./RowSelectorTypes.svelte" import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" @@ -14,7 +16,6 @@ export let bindings export let isTestModal export let isUpdateRow - $: parsedBindings = bindings.map(binding => { let clone = Object.assign({}, binding) clone.icon = "ShareAndroid" @@ -26,15 +27,19 @@ $: { table = $tables.list.find(table => table._id === value?.tableId) - schemaFields = Object.entries(table?.schema ?? {}) - // surface the schema so the user can see it in the json - schemaFields.map(([, schema]) => { + + // Just sorting attachment types to the bottom here for a cleaner UX + schemaFields = Object.entries(table?.schema ?? {}).sort( + ([, schemaA], [, schemaB]) => + (schemaA.type === "attachment") - (schemaB.type === "attachment") + ) + + schemaFields.forEach(([, schema]) => { if (!schema.autocolumn && !value[schema.name]) { value[schema.name] = "" } }) } - const onChangeTable = e => { value["tableId"] = e.detail dispatch("change", value) @@ -114,10 +119,16 @@
{#if schemaFields.length} {#each schemaFields as [field, schema]} - {#if !schema.autocolumn && schema.type !== "attachment"} -
+ {#if !schema.autocolumn} +
-
+
{#if isTestModal} import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui" + import { FieldType } from "@budibase/types" import LinkedRowSelector from "components/common/LinkedRowSelector.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import Editor from "components/integration/QueryEditor.svelte" + import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" export let onChange export let field @@ -22,6 +24,27 @@ function schemaHasOptions(schema) { return !!schema.constraints?.inclusion?.length } + + const handleAttachmentParams = keyValuObj => { + let params = {} + + if ( + schema.type === FieldType.ATTACHMENT_SINGLE && + Object.keys(keyValuObj).length === 0 + ) { + return [] + } + if (!Array.isArray(keyValuObj)) { + keyValuObj = [keyValuObj] + } + + if (keyValuObj.length) { + for (let param of keyValuObj) { + params[param.url] = param.filename + } + } + return params + } {#if schemaHasOptions(schema) && schema.type !== "array"} @@ -77,6 +100,35 @@ on:change={e => onChange(e, field)} useLabel={false} /> +{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE} +
+ + onChange( + { + detail: + schema.type === FieldType.ATTACHMENT_SINGLE + ? e.detail.length > 0 + ? { url: e.detail[0].name, filename: e.detail[0].value } + : {} + : e.detail.map(({ name, value }) => ({ + url: name, + filename: value, + })), + }, + field + )} + object={handleAttachmentParams(value[field])} + allowJS + {bindings} + keyBindings + customButtonText={"Add attachment"} + keyPlaceholder={"URL"} + valuePlaceholder={"Filename"} + actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE && + Object.keys(value[field]).length >= 1} + /> +
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)} {/if} + + diff --git a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte index 6a5cd2f282..77229f3a17 100644 --- a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte @@ -106,6 +106,5 @@ display: flex; flex-direction: column; background: var(--background); - overflow: hidden; } diff --git a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte index e3937ab772..decf77069f 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte @@ -1,7 +1,9 @@ - + {text} - - dispatch("change", tempValue)} - > -
- (tempValue = e.detail)} - /> -
-
-
- + + + + (tempValue = e.detail)} + {bindings} + /> + + diff --git a/packages/builder/src/components/backend/DataTable/formula.js b/packages/builder/src/components/backend/DataTable/formula.js index b339729391..c59fb9c536 100644 --- a/packages/builder/src/components/backend/DataTable/formula.js +++ b/packages/builder/src/components/backend/DataTable/formula.js @@ -55,7 +55,7 @@ export function getBindings({ ) } const field = Object.values(FIELDS).find( - field => field.type === schema.type + field => field.type === schema.type && field.subtype === schema.subtype ) const label = path == null ? column : `${path}.0.${column}` diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index d271462f3e..622da2173d 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -12,8 +12,13 @@ OptionSelectDnD, Layout, AbsTooltip, + ProgressCircle, } from "@budibase/bbui" - import { SWITCHABLE_TYPES, ValidColumnNameRegex } from "@budibase/shared-core" + import { + SWITCHABLE_TYPES, + ValidColumnNameRegex, + helpers, + } from "@budibase/shared-core" import { createEventDispatcher, getContext, onMount } from "svelte" import { cloneDeep } from "lodash/fp" import { tables, datasources } from "stores/builder" @@ -29,7 +34,11 @@ import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import { getBindings } from "components/backend/DataTable/formula" import JSONSchemaModal from "./JSONSchemaModal.svelte" - import { FieldType, FieldSubtype, SourceName } from "@budibase/types" + import { + BBReferenceFieldSubType, + FieldType, + SourceName, + } from "@budibase/types" import RelationshipSelector from "components/common/RelationshipSelector.svelte" import { RowUtils } from "@budibase/frontend-core" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" @@ -41,8 +50,6 @@ const NUMBER_TYPE = FieldType.NUMBER const JSON_TYPE = FieldType.JSON const DATE_TYPE = FieldType.DATETIME - const USER_TYPE = FieldSubtype.USER - const USERS_TYPE = FieldSubtype.USERS const dispatch = createEventDispatcher() const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] @@ -65,7 +72,6 @@ let savingColumn let deleteColName let jsonSchemaModal - let allowedTypes = [] let editableColumn = { type: FIELDS.STRING.type, constraints: FIELDS.STRING.constraints, @@ -173,6 +179,11 @@ SWITCHABLE_TYPES[field.type] && !editableColumn?.autocolumn) + $: allowedTypes = getAllowedTypes(datasource).map(t => ({ + fieldId: makeFieldId(t.type, t.subtype), + ...t, + })) + const fieldDefinitions = Object.values(FIELDS).reduce( // Storing the fields by complex field id (acc, field) => ({ @@ -186,7 +197,10 @@ // don't make field IDs for auto types if (type === AUTO_TYPE || autocolumn) { return type.toUpperCase() - } else if (type === FieldType.BB_REFERENCE) { + } else if ( + type === FieldType.BB_REFERENCE || + type === FieldType.BB_REFERENCE_SINGLE + ) { return `${type}${subtype || ""}`.toUpperCase() } else { return type.toUpperCase() @@ -224,11 +238,6 @@ editableColumn.subtype, editableColumn.autocolumn ) - - allowedTypes = getAllowedTypes().map(t => ({ - fieldId: makeFieldId(t.type, t.subtype), - ...t, - })) } } @@ -243,11 +252,11 @@ } async function saveColumn() { - savingColumn = true if (errors?.length) { return } + savingColumn = true let saveColumn = cloneDeep(editableColumn) delete saveColumn.fieldId @@ -262,13 +271,6 @@ if (saveColumn.type !== LINK_TYPE) { delete saveColumn.fieldName } - if (isUsersColumn(saveColumn)) { - if (saveColumn.subtype === USER_TYPE) { - saveColumn.relationshipType = RelationshipType.ONE_TO_MANY - } else if (saveColumn.subtype === USERS_TYPE) { - saveColumn.relationshipType = RelationshipType.MANY_TO_MANY - } - } try { await tables.saveField({ @@ -287,6 +289,8 @@ } } catch (err) { notifications.error(`Error saving column: ${err.message}`) + } finally { + savingColumn = false } } @@ -361,22 +365,36 @@ deleteColName = "" } - function getAllowedTypes() { + function getAllowedTypes(datasource) { if (originalName) { - const possibleTypes = ( - SWITCHABLE_TYPES[field.type] || [editableColumn.type] - ).map(t => t.toLowerCase()) + let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type] + if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) { + // This will handle old single users columns + return [ + { + ...FIELDS.USER, + type: FieldType.BB_REFERENCE, + subtype: BBReferenceFieldSubType.USER, + }, + ] + } else if ( + editableColumn.type === FieldType.BB_REFERENCE && + editableColumn.subtype === BBReferenceFieldSubType.USERS + ) { + // This will handle old multi users columns + return [ + { + ...FIELDS.USERS, + subtype: BBReferenceFieldSubType.USERS, + }, + ] + } + return Object.entries(FIELDS) - .filter(([fieldType]) => - possibleTypes.includes(fieldType.toLowerCase()) - ) + .filter(([_, field]) => possibleTypes.includes(field.type)) .map(([_, fieldDefinition]) => fieldDefinition) } - const isUsers = - editableColumn.type === FieldType.BB_REFERENCE && - editableColumn.subtype === FieldSubtype.USERS - if (!externalTable) { return [ FIELDS.STRING, @@ -393,7 +411,8 @@ FIELDS.LINK, FIELDS.FORMULA, FIELDS.JSON, - isUsers ? FIELDS.USERS : FIELDS.USER, + FIELDS.USER, + FIELDS.USERS, FIELDS.AUTO, ] } else { @@ -407,8 +426,12 @@ FIELDS.BOOLEAN, FIELDS.FORMULA, FIELDS.BIGINT, - isUsers ? FIELDS.USERS : FIELDS.USER, + FIELDS.USER, ] + + if (datasource && datasource.source !== SourceName.GOOGLE_SHEETS) { + fields.push(FIELDS.USERS) + } // no-sql or a spreadsheet if (!externalTable || table.sql) { fields = [...fields, FIELDS.LINK, FIELDS.ARRAY] @@ -482,13 +505,6 @@ return newError } - function isUsersColumn(column) { - return ( - column.type === FieldType.BB_REFERENCE && - [FieldSubtype.USER, FieldSubtype.USERS].includes(column.subtype) - ) - } - onMount(() => { mounted = true }) @@ -513,6 +529,7 @@ /> {/if} Delete {/if} - +
diff --git a/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte b/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte index 020c58d19f..2ecb7dfd59 100644 --- a/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte @@ -13,7 +13,9 @@ onMount(() => subscribe("edit-column", editColumn)) - +{#if editableColumn} + +{/if} diff --git a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte index 6901503071..318f459f46 100644 --- a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte +++ b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte @@ -1,5 +1,5 @@ + +
+ + +
+ + diff --git a/packages/builder/src/components/common/RelationshipSelector.svelte b/packages/builder/src/components/common/RelationshipSelector.svelte index 63f0357a8f..5e840d4ffb 100644 --- a/packages/builder/src/components/common/RelationshipSelector.svelte +++ b/packages/builder/src/components/common/RelationshipSelector.svelte @@ -75,14 +75,12 @@ .relationship-container { display: flex; align-items: center; - gap: 20px; + gap: var(--spacing-m); } - .relationship-part { - flex-basis: 70%; + flex: 1 1 auto; } - .relationship-type { - flex-basis: 30%; + flex: 0 0 128px; } diff --git a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte index 8ce9dda209..fb448cca8d 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte @@ -4,6 +4,7 @@ readableToRuntimeBinding, runtimeToReadableBinding, } from "dataBinding" + import { FieldType } from "@budibase/types" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import { createEventDispatcher, setContext } from "svelte" @@ -102,6 +103,8 @@ longform: value => !isJSBinding(value), json: value => !isJSBinding(value), boolean: isValidBoolean, + attachment: false, + attachment_single: false, } const isValid = value => { @@ -116,7 +119,16 @@ if (type === "json" && !isJSBinding(value)) { return "json-slot-icon" } - if (!["string", "number", "bigint", "barcodeqr"].includes(type)) { + if ( + ![ + "string", + "number", + "bigint", + "barcodeqr", + "attachment", + "attachment_single", + ].includes(type) + ) { return "slot-icon" } return "" @@ -157,7 +169,7 @@ {updateOnChange} /> {/if} - {#if !disabled && type !== "formula"} + {#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
{ diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte index 85d395e4f4..b808733d08 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte @@ -1,8 +1,10 @@ + + + + + + diff --git a/packages/builder/src/components/portal/licensing/licensingBanners.js b/packages/builder/src/components/portal/licensing/licensingBanners.js index 34558e98e2..34b22c934b 100644 --- a/packages/builder/src/components/portal/licensing/licensingBanners.js +++ b/packages/builder/src/components/portal/licensing/licensingBanners.js @@ -12,7 +12,7 @@ const defaultCacheFn = key => { const upgradeAction = key => { return defaultNavigateAction( key, - "Upgrade Plan", + "Upgrade", `${get(admin).accountPortalUrl}/portal/upgrade` ) } diff --git a/packages/builder/src/components/portal/onboarding/EnterpriseBasicTrialModal.svelte b/packages/builder/src/components/portal/onboarding/EnterpriseBasicTrialModal.svelte new file mode 100644 index 0000000000..6652bd4104 --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/EnterpriseBasicTrialModal.svelte @@ -0,0 +1,66 @@ + + + + { + if (get(auth).user) { + try { + await API.updateSelf({ + freeTrialConfirmedAt: new Date().toISOString(), + }) + // Update the cached user + await auth.getSelf() + } finally { + freeTrialModal.hide() + } + } + }} + > +

Experience all of Budibase with a free 14-day trial

+
+ We've upgraded you to a free 14-day trial that allows you to try all our + features before deciding which plan is right for you. +

+ At the end of your trial, we'll automatically downgrade you to the Free + plan unless you choose to upgrade. +

+
+ +
+
+ + diff --git a/packages/builder/src/constants/backend/index.js b/packages/builder/src/constants/backend/index.js index 84975d93e2..75f6a053b5 100644 --- a/packages/builder/src/constants/backend/index.js +++ b/packages/builder/src/constants/backend/index.js @@ -1,6 +1,6 @@ import { FieldType, - FieldSubtype, + BBReferenceFieldSubType, INTERNAL_TABLE_SOURCE_ID, AutoFieldSubType, Hosting, @@ -159,15 +159,17 @@ export const FIELDS = { }, USER: { name: "User", - type: FieldType.BB_REFERENCE, - subtype: FieldSubtype.USER, - icon: TypeIconMap[FieldType.USER], + type: FieldType.BB_REFERENCE_SINGLE, + subtype: BBReferenceFieldSubType.USER, + icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][ + BBReferenceFieldSubType.USER + ], }, USERS: { - name: "Users", + name: "User List", type: FieldType.BB_REFERENCE, - subtype: FieldSubtype.USERS, - icon: TypeIconMap[FieldType.USERS], + subtype: BBReferenceFieldSubType.USER, + icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER], constraints: { type: "array", }, @@ -253,6 +255,7 @@ export const SchemaTypeOptions = [ { label: "Number", value: FieldType.NUMBER }, { label: "Boolean", value: FieldType.BOOLEAN }, { label: "Datetime", value: FieldType.DATETIME }, + { label: "JSON", value: FieldType.JSON }, ] export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({ diff --git a/packages/builder/src/dataBinding.js b/packages/builder/src/dataBinding.js index 5efbb79611..af229ce7e4 100644 --- a/packages/builder/src/dataBinding.js +++ b/packages/builder/src/dataBinding.js @@ -29,6 +29,7 @@ import { JSONUtils, Constants } from "@budibase/frontend-core" import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json" import { environment, licensing } from "stores/portal" import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils" +import { FIELDS } from "constants/backend" const { ContextScopes } = Constants @@ -491,7 +492,7 @@ const generateComponentContextBindings = (asset, componentContext) => { icon: bindingCategory.icon, display: { name: `${fieldSchema.name || key}`, - type: fieldSchema.type, + type: fieldSchema.display?.type || fieldSchema.type, }, }) }) @@ -1019,15 +1020,23 @@ export const getSchemaForDatasource = (asset, datasource, options) => { // are objects let fixedSchema = {} Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => { + const field = Object.values(FIELDS).find( + field => + field.type === fieldSchema.type && + field.subtype === fieldSchema.subtype + ) + if (typeof fieldSchema === "string") { fixedSchema[fieldName] = { type: fieldSchema, name: fieldName, + display: { type: fieldSchema }, } } else { fixedSchema[fieldName] = { ...fieldSchema, name: fieldName, + display: { type: field?.name || fieldSchema.type }, } } }) @@ -1106,50 +1115,51 @@ export const getAllStateVariables = () => { getAllAssets().forEach(asset => { findAllMatchingComponents(asset.props, component => { const settings = componentStore.getComponentSettings(component._component) + const nestedTypes = [ + "buttonConfiguration", + "fieldConfiguration", + "stepConfiguration", + ] + // Extracts all event settings from a component instance. + // Recurses into nested types to find all event-like settings at any + // depth. const parseEventSettings = (settings, comp) => { + if (!settings?.length) { + return + } + + // Extract top level event settings settings .filter(setting => setting.type === "event") .forEach(setting => { eventSettings.push(comp[setting.key]) }) - } - const parseComponentSettings = (settings, component) => { - // Parse the nested button configurations + // Recurse into any nested instance types settings - .filter(setting => setting.type === "buttonConfiguration") + .filter(setting => nestedTypes.includes(setting.type)) .forEach(setting => { - const buttonConfig = component[setting.key] + const instances = comp[setting.key] + if (Array.isArray(instances) && instances.length) { + instances.forEach(instance => { + let type = instance?._component - if (Array.isArray(buttonConfig)) { - buttonConfig.forEach(button => { - const nestedSettings = componentStore.getComponentSettings( - button._component - ) - parseEventSettings(nestedSettings, button) + // Backwards compatibility for multi-step from blocks which + // didn't set a proper component type previously. + if (setting.type === "stepConfiguration" && !type) { + type = "@budibase/standard-components/multistepformblockstep" + } + + // Parsed nested component instances inside this setting + const nestedSettings = componentStore.getComponentSettings(type) + parseEventSettings(nestedSettings, instance) }) } }) - - parseEventSettings(settings, component) } - // Parse the base component settings - parseComponentSettings(settings, component) - - // Parse step configuration - const stepSetting = settings.find( - setting => setting.type === "stepConfiguration" - ) - const steps = stepSetting ? component[stepSetting.key] : [] - const stepDefinition = componentStore.getComponentSettings( - "@budibase/standard-components/multistepformblockstep" - ) - - steps?.forEach(step => { - parseComponentSettings(stepDefinition, step) - }) + parseEventSettings(settings, component) }) }) diff --git a/packages/builder/src/helpers/planTitle.js b/packages/builder/src/helpers/planTitle.js index 79f2bc2382..c08b8bf3fe 100644 --- a/packages/builder/src/helpers/planTitle.js +++ b/packages/builder/src/helpers/planTitle.js @@ -20,6 +20,9 @@ export function getFormattedPlanName(userPlanType) { case PlanType.ENTERPRISE: planName = "Enterprise" break + case PlanType.ENTERPRISE_BASIC_TRIAL: + planName = "Trial" + break default: planName = "Free" // Default to "Free" if the type is not explicitly handled } diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index fd6a97560d..60c45fd2e4 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -32,6 +32,7 @@ import { UserAvatars } from "@budibase/frontend-core" import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import PreviewOverlay from "./_components/PreviewOverlay.svelte" + import EnterpriseBasicTrialModal from "components/portal/onboarding/EnterpriseBasicTrialModal.svelte" export let application @@ -192,6 +193,8 @@ + + diff --git a/packages/client/src/components/app/Layout.svelte b/packages/client/src/components/app/Layout.svelte index 8508e943ff..72da3e9012 100644 --- a/packages/client/src/components/app/Layout.svelte +++ b/packages/client/src/components/app/Layout.svelte @@ -73,7 +73,10 @@ $context.device.width, $context.device.height ) - $: autoCloseSidePanel = !$builderStore.inBuilder && $sidePanelStore.open + $: autoCloseSidePanel = + !$builderStore.inBuilder && + $sidePanelStore.open && + !$sidePanelStore.ignoreClicksOutside $: screenId = $builderStore.inBuilder ? `${$builderStore.screen?._id}-screen` : "screen" @@ -191,6 +194,11 @@ } return url } + + const handleClickLink = () => { + mobileOpen = false + sidePanelStore.actions.close() + } @@ -281,7 +289,7 @@ url={navItem.url} subLinks={navItem.subLinks} internalLink={navItem.internalLink} - on:clickLink={() => (mobileOpen = false)} + on:clickLink={handleClickLink} leftNav={navigation === "Left"} {mobile} {navStateStore} @@ -316,10 +324,7 @@
diff --git a/packages/client/src/components/app/SidePanel.svelte b/packages/client/src/components/app/SidePanel.svelte index 825b401bb8..bff5a78837 100644 --- a/packages/client/src/components/app/SidePanel.svelte +++ b/packages/client/src/components/app/SidePanel.svelte @@ -5,6 +5,9 @@ const { styleable, sidePanelStore, builderStore, dndIsDragging } = getContext("sdk") + export let onClose + export let ignoreClicksOutside + // Automatically show and hide the side panel when inside the builder. // For some unknown reason, svelte reactivity breaks if we reference the // reactive variable "open" inside the following expression, or if we define @@ -26,6 +29,10 @@ } } + // $: { + + // } + // Derive visibility $: open = $sidePanelStore.contentId === $component.id @@ -36,10 +43,17 @@ let renderKey = null $: { if (open) { + sidePanelStore.actions.setIgnoreClicksOutside(ignoreClicksOutside) renderKey = Math.random() } } + const handleSidePanelClose = async () => { + if (onClose) { + await onClose() + } + } + const showInSidePanel = (el, visible) => { const update = visible => { const target = document.getElementById("side-panel-container") @@ -51,6 +65,7 @@ } else { if (target.contains(node)) { target.removeChild(node) + handleSidePanelClose() } } } diff --git a/packages/client/src/components/app/blocks/FormBlockComponent.svelte b/packages/client/src/components/app/blocks/FormBlockComponent.svelte index 968ed36b8b..31610f79ac 100644 --- a/packages/client/src/components/app/blocks/FormBlockComponent.svelte +++ b/packages/client/src/components/app/blocks/FormBlockComponent.svelte @@ -21,6 +21,7 @@ [FieldType.JSON]: "jsonfield", [FieldType.BARCODEQR]: "codescanner", [FieldType.BB_REFERENCE]: "bbreferencefield", + [FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield", } const getFieldSchema = field => { diff --git a/packages/client/src/components/app/forms/BBReferenceField.svelte b/packages/client/src/components/app/forms/BBReferenceField.svelte index 5f00c503c2..c266268275 100644 --- a/packages/client/src/components/app/forms/BBReferenceField.svelte +++ b/packages/client/src/components/app/forms/BBReferenceField.svelte @@ -1,8 +1,10 @@ + + diff --git a/packages/client/src/components/app/forms/DateTimeField.svelte b/packages/client/src/components/app/forms/DateTimeField.svelte index 8ab8e65a15..499f0443cb 100644 --- a/packages/client/src/components/app/forms/DateTimeField.svelte +++ b/packages/client/src/components/app/forms/DateTimeField.svelte @@ -49,7 +49,6 @@ readonly={fieldState.readonly} error={fieldState.error} id={fieldState.fieldId} - appendTo={document.getElementById("flatpickr-root")} {enableTime} {timeOnly} {time24hr} diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 1fbd0df522..a6d0564f7f 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -1,9 +1,9 @@ { const initialState = { contentId: null, + ignoreClicksOutside: true, } const store = writable(initialState) const derivedStore = derived(store, $store => { @@ -32,11 +33,18 @@ export const createSidePanelStore = () => { }, 50) } + const setIgnoreClicksOutside = bool => { + store.update(state => { + state.ignoreClicksOutside = bool + return state + }) + } return { subscribe: derivedStore.subscribe, actions: { open, close, + setIgnoreClicksOutside, }, } } diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index d883ee1b55..4de1012b01 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -240,6 +240,7 @@ const triggerAutomationHandler = async action => { const navigationHandler = action => { const { url, peek, externalNewTab } = action.parameters routeStore.actions.navigate(url, peek, externalNewTab) + closeSidePanelHandler() } const queryExecutionHandler = async action => { @@ -541,16 +542,22 @@ export const enrichButtonActions = (actions, context) => { // then execute the rest of the actions in the chain const result = await callback() if (result !== false) { - // Generate a new total context to pass into the next enrichment + // Generate a new total context for the next enrichment buttonContext.push(result) const newContext = { ...context, actions: buttonContext } - // Enrich and call the next button action if there is more than one action remaining + // Enrich and call the next button action if there is more + // than one action remaining const next = enrichButtonActions( actions.slice(i + 1), newContext ) - resolve(typeof next === "function" ? await next() : true) + if (typeof next === "function") { + // Pass the event context back into the new action chain + resolve(await next(eventContext)) + } else { + resolve(true) + } } else { resolve(false) } diff --git a/packages/frontend-core/src/api/backups.js b/packages/frontend-core/src/api/backups.js index 7663ae09af..40546b6f66 100644 --- a/packages/frontend-core/src/api/backups.js +++ b/packages/frontend-core/src/api/backups.js @@ -1,7 +1,4 @@ export const buildBackupsEndpoints = API => ({ - /** - * Gets a list of users in the current tenant. - */ searchBackups: async ({ appId, trigger, type, page, startDate, endDate }) => { const opts = {} if (page) { diff --git a/packages/frontend-core/src/components/FilterBuilder.svelte b/packages/frontend-core/src/components/FilterBuilder.svelte index 074c2dbd9b..fb7aa98405 100644 --- a/packages/frontend-core/src/components/FilterBuilder.svelte +++ b/packages/frontend-core/src/components/FilterBuilder.svelte @@ -124,6 +124,8 @@ const fieldSchema = schemaFields.find(x => x.name === filter.field) filter.type = fieldSchema?.type filter.subtype = fieldSchema?.subtype + filter.formulaType = fieldSchema?.formulaType + filter.constraints = fieldSchema?.constraints // Update external type based on field filter.externalType = getSchema(filter)?.externalType @@ -280,7 +282,7 @@ timeOnly={getSchema(filter)?.timeOnly} bind:value={filter.value} /> - {:else if filter.type === FieldType.BB_REFERENCE} + {:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)} {:else} @@ -324,8 +327,6 @@ diff --git a/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte b/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte index 48b1279346..5d98ba903b 100644 --- a/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte @@ -1,25 +1,36 @@ + + diff --git a/packages/frontend-core/src/components/grid/cells/DateCell.svelte b/packages/frontend-core/src/components/grid/cells/DateCell.svelte index 53b159ee30..3c86fe3605 100644 --- a/packages/frontend-core/src/components/grid/cells/DateCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DateCell.svelte @@ -1,7 +1,8 @@ -
+ + +
- {#if value} - {displayValue} - {/if} + {displayValue}
{#if editable} {/if}
-{#if editable} -
- onChange(e.detail)} - appendTo={document.documentElement} - enableTime={!dateOnly} - {timeOnly} - time24hr - ignoreTimezones={schema.ignoreTimezones} - bind:flatpickr - on:open={() => (isOpen = true)} - on:close={() => (isOpen = false)} +{#if isOpen} + + (value = e.detail)} + {enableTime} + {timeOnly} + {ignoreTimezones} /> -
+ {/if} diff --git a/packages/frontend-core/src/components/grid/cells/GridCell.svelte b/packages/frontend-core/src/components/grid/cells/GridCell.svelte index 74d98ec130..32a9dea83b 100644 --- a/packages/frontend-core/src/components/grid/cells/GridCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/GridCell.svelte @@ -43,6 +43,9 @@ on:mouseup on:click on:contextmenu + on:touchstart + on:touchend + on:touchcancel {style} > {#if error} diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 657f618759..bb38c5094f 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -1,30 +1,22 @@ @@ -258,6 +258,9 @@
- - {#if editIsOpen} -
{ - editIsOpen = false - }} - class="content" - > - -
- {:else} - - - Edit column - - - Duplicate column - - - Use as display column - - - Sort {sortingLabels.ascending} - - - Sort {sortingLabels.descending} - - - Move left - - - Move right - - - Hide column - - {#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS} - - Migrate to user column +{#if open} + + {#if editIsOpen} +
+ +
+ {:else} + + + Edit column - {/if} - - {/if} - + + Duplicate column + + + Use as display column + + + Sort {sortingLabels.ascending} + + + Sort {sortingLabels.descending} + + + Move left + + + Move right + + + Hide column + + {#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS} + + Migrate to user column + + {/if} +
+ {/if} + +{/if} diff --git a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte index bf1fe92ef0..f5a8351604 100644 --- a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte @@ -1,10 +1,11 @@ + + +
dispatch("close")} + on:wheel={e => e.stopPropagation()} + > + +
+
+ + diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index ed09301bb9..78cb186f29 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -2,6 +2,7 @@ import { getContext, onMount } from "svelte" import { debounce } from "../../../utils/utils" import { NewRowID } from "../lib/constants" + import { getCellID, parseCellID } from "../lib/utils" const { rows, @@ -20,6 +21,7 @@ const ignoredOriginSelectors = [ ".spectrum-Modal", + ".date-time-popover", "#builder-side-panel-container", "[data-grid-ignore]", ] @@ -153,7 +155,7 @@ if (!firstColumn) { return } - focusedCellId.set(`${firstRow._id}-${firstColumn.name}`) + focusedCellId.set(getCellID(firstRow._id, firstColumn.name)) } // Changes the focused cell by moving it left or right to a different column @@ -162,8 +164,7 @@ return } const cols = $visibleColumns - const split = $focusedCellId.split("-") - const columnName = split[1] + const { id, field: columnName } = parseCellID($focusedCellId) let newColumnName if (columnName === $stickyColumn?.name) { const index = delta - 1 @@ -177,7 +178,7 @@ } } if (newColumnName) { - $focusedCellId = `${split[0]}-${newColumnName}` + $focusedCellId = getCellID(id, newColumnName) } } @@ -188,8 +189,8 @@ } const newRow = $rows[$focusedRow.__idx + delta] if (newRow) { - const split = $focusedCellId.split("-") - $focusedCellId = `${newRow._id}-${split[1]}` + const { field } = parseCellID($focusedCellId) + $focusedCellId = getCellID(newRow._id, field) } } diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 55308d4c6e..4a413d7dde 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -1,7 +1,9 @@ +