diff --git a/.eslintrc.json b/.eslintrc.json index 624c2b8f26..2c810eecc5 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -42,6 +42,8 @@ }, "rules": { "no-unused-vars": "off", + "local-rules/no-budibase-imports": "error", + "local-rules/no-console-error": "error", "@typescript-eslint/no-unused-vars": [ "error", { diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index fd4d8cf7c8..7d09451614 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 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/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/eslint-local-rules/index.js b/eslint-local-rules/index.js index a4866bc1f8..e88642c905 100644 --- a/eslint-local-rules/index.js +++ b/eslint-local-rules/index.js @@ -1,4 +1,25 @@ module.exports = { + "no-console-error": { + create: function(context) { + return { + CallExpression(node) { + if ( + node.callee.type === "MemberExpression" && + node.callee.object.name === "console" && + node.callee.property.name === "error" && + node.arguments.length === 1 && + node.arguments[0].name && + node.arguments[0].name.startsWith("err") + ) { + context.report({ + node, + message: 'Using console.error(err) on its own is not allowed. Either provide context to the error (console.error(msg, err)) or throw it.', + }) + } + }, + }; + }, + }, "no-budibase-imports": { create: function (context) { return { diff --git a/globalSetup.ts b/globalSetup.ts index 115796c395..dd1a7dbaa0 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -1,16 +1,49 @@ -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") @@ -28,20 +61,16 @@ 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() - } - await couchdb.start() } finally { - if (process.env.REUSE_CONTAINERS) { - lockfile.unlockSync(lockPath) - } + lockfile.unlockSync(lockPath) } } 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 2ce2b69508..94631c6820 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.23.9", + "version": "2.23.12", "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/account-portal b/packages/account-portal index eb7d5da233..c167c331ff 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit eb7d5da233885c5cffd9c255d3e954d0cd39185e +Subproject commit c167c331ff9b8161fc18e2ecbaaf1ea5815ba964 diff --git a/packages/backend-core/src/auth/auth.ts b/packages/backend-core/src/auth/auth.ts index 87ac46cf1c..098e874863 100644 --- a/packages/backend-core/src/auth/auth.ts +++ b/packages/backend-core/src/auth/auth.ts @@ -64,7 +64,6 @@ async function refreshOIDCAccessToken( } strategy = await oidc.strategyFactory(enrichedConfig, ssoSaveUserNoOp) } catch (err) { - console.error(err) throw new Error("Could not refresh OAuth Token") } @@ -99,7 +98,6 @@ async function refreshGoogleAccessToken( ssoSaveUserNoOp ) } catch (err: any) { - console.error(err) throw new Error( `Error constructing OIDC refresh strategy: message=${err.message}` ) diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 8dbc904643..9ade81b9d7 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -29,6 +29,7 @@ const DefaultBucketName = { TEMPLATES: "templates", GLOBAL: "global", PLUGINS: "plugins", + TEMP: "tmp-file-attachments", } const selfHosted = !!parseInt(process.env.SELF_HOSTED || "") @@ -146,6 +147,7 @@ const environment = { process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL, PLUGIN_BUCKET_NAME: process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, + TEMP_BUCKET_NAME: process.env.TEMP_BUCKET_NAME || DefaultBucketName.TEMP, USE_COUCH: process.env.USE_COUCH || true, MOCK_REDIS: process.env.MOCK_REDIS, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index d357dbdbdc..69dba27c43 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -138,7 +138,6 @@ export default function ( } catch (err: any) { authenticated = false console.error(`Auth Error: ${err.message}`) - console.error(err) // remove the cookie as the user does not exist anymore clearCookie(ctx, Cookie.Auth) } @@ -187,7 +186,6 @@ export default function ( } } catch (err: any) { console.error(`Auth Error: ${err.message}`) - console.error(err) // invalid token, clear the cookie if (err?.name === "JsonWebTokenError") { clearCookie(ctx, Cookie.Auth) diff --git a/packages/backend-core/src/middleware/errorHandling.ts b/packages/backend-core/src/middleware/errorHandling.ts index 2b8f7195ed..08f9f3214d 100644 --- a/packages/backend-core/src/middleware/errorHandling.ts +++ b/packages/backend-core/src/middleware/errorHandling.ts @@ -12,7 +12,7 @@ export async function errorHandling(ctx: any, next: any) { if (status >= 400 && status < 500) { console.warn(err) } else { - console.error(err) + console.error("Got 400 response code", err) } let error: APIError = { diff --git a/packages/backend-core/src/middleware/passport/sso/google.ts b/packages/backend-core/src/middleware/passport/sso/google.ts index 2a08ad7665..37a043cf0b 100644 --- a/packages/backend-core/src/middleware/passport/sso/google.ts +++ b/packages/backend-core/src/middleware/passport/sso/google.ts @@ -68,7 +68,6 @@ export async function strategyFactory( verify ) } catch (err: any) { - console.error(err) throw new Error(`Error constructing google authentication strategy: ${err}`) } } diff --git a/packages/backend-core/src/middleware/passport/sso/oidc.ts b/packages/backend-core/src/middleware/passport/sso/oidc.ts index 061e0507aa..35e6ee9fb0 100644 --- a/packages/backend-core/src/middleware/passport/sso/oidc.ts +++ b/packages/backend-core/src/middleware/passport/sso/oidc.ts @@ -103,7 +103,6 @@ export async function strategyFactory( strategy.name = "oidc" return strategy } catch (err: any) { - console.error(err) throw new Error(`Error constructing OIDC authentication strategy - ${err}`) } } @@ -142,7 +141,6 @@ export async function fetchStrategyConfig( callbackURL: callbackUrl, } } catch (err) { - console.error(err) throw new Error( `Error constructing OIDC authentication configuration - ${err}` ) diff --git a/packages/backend-core/src/migrations/migrations.ts b/packages/backend-core/src/migrations/migrations.ts index 3f033b8cdb..fe6bc17386 100644 --- a/packages/backend-core/src/migrations/migrations.ts +++ b/packages/backend-core/src/migrations/migrations.ts @@ -26,7 +26,6 @@ export const getMigrationsDoc = async (db: any) => { if (err.status && err.status === 404) { return { _id: DocumentType.MIGRATIONS } } else { - console.error(err) throw err } } diff --git a/packages/backend-core/src/objectStore/objectStore.ts b/packages/backend-core/src/objectStore/objectStore.ts index 8d18fb97fd..aa5365c5c3 100644 --- a/packages/backend-core/src/objectStore/objectStore.ts +++ b/packages/backend-core/src/objectStore/objectStore.ts @@ -7,31 +7,41 @@ import tar from "tar-fs" import zlib from "zlib" import { promisify } from "util" import { join } from "path" -import fs, { ReadStream } from "fs" +import fs, { PathLike, ReadStream } from "fs" import env from "../environment" -import { budibaseTempDir } from "./utils" +import { bucketTTLConfig, budibaseTempDir } from "./utils" import { v4 } from "uuid" import { APP_PREFIX, APP_DEV_PREFIX } from "../db" +import fsp from "fs/promises" const streamPipeline = promisify(stream.pipeline) // use this as a temporary store of buckets that are being created const STATE = { bucketCreationPromises: {}, } +const signedFilePrefix = "/files/signed" type ListParams = { ContinuationToken?: string } -type UploadParams = { +type BaseUploadParams = { bucket: string filename: string - path: string type?: string | null - // can be undefined, we will remove it - metadata?: { - [key: string]: string | undefined - } + metadata?: { [key: string]: string | undefined } + body?: ReadableStream | Buffer + ttl?: number + addTTL?: boolean + extra?: any +} + +type UploadParams = BaseUploadParams & { + path?: string | PathLike +} + +type StreamUploadParams = BaseUploadParams & { + stream: ReadStream } const CONTENT_TYPE_MAP: any = { @@ -41,6 +51,8 @@ const CONTENT_TYPE_MAP: any = { js: "application/javascript", json: "application/json", gz: "application/gzip", + svg: "image/svg+xml", + form: "multipart/form-data", } const STRING_CONTENT_TYPES = [ @@ -105,7 +117,10 @@ export function ObjectStore( * Given an object store and a bucket name this will make sure the bucket exists, * if it does not exist then it will create it. */ -export async function makeSureBucketExists(client: any, bucketName: string) { +export async function createBucketIfNotExists( + client: any, + bucketName: string +): Promise<{ created: boolean; exists: boolean }> { bucketName = sanitizeBucket(bucketName) try { await client @@ -113,15 +128,16 @@ export async function makeSureBucketExists(client: any, bucketName: string) { Bucket: bucketName, }) .promise() + return { created: false, exists: true } } catch (err: any) { const promises: any = STATE.bucketCreationPromises const doesntExist = err.statusCode === 404, noAccess = err.statusCode === 403 if (promises[bucketName]) { await promises[bucketName] + return { created: false, exists: true } } else if (doesntExist || noAccess) { if (doesntExist) { - // bucket doesn't exist create it promises[bucketName] = client .createBucket({ Bucket: bucketName, @@ -129,13 +145,15 @@ export async function makeSureBucketExists(client: any, bucketName: string) { .promise() await promises[bucketName] delete promises[bucketName] + return { created: true, exists: false } + } else { + throw new Error("Access denied to object store bucket." + err) } } else { throw new Error("Unable to write to object store bucket.") } } } - /** * Uploads the contents of a file given the required parameters, useful when * temp files in use (for example file uploaded as an attachment). @@ -146,12 +164,22 @@ export async function upload({ path, type, metadata, + body, + ttl, }: UploadParams) { const extension = filename.split(".").pop() - const fileBytes = fs.readFileSync(path) + + const fileBytes = path ? (await fsp.open(path)).createReadStream() : body const objectStore = ObjectStore(bucketName) - await makeSureBucketExists(objectStore, bucketName) + const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) + + if (ttl && (bucketCreated.created || bucketCreated.exists)) { + let ttlConfig = bucketTTLConfig(bucketName, ttl) + if (objectStore.putBucketLifecycleConfiguration) { + await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise() + } + } let contentType = type if (!contentType) { @@ -174,6 +202,7 @@ export async function upload({ } config.Metadata = metadata } + return objectStore.upload(config).promise() } @@ -181,14 +210,24 @@ export async function upload({ * Similar to the upload function but can be used to send a file stream * through to the object store. */ -export async function streamUpload( - bucketName: string, - filename: string, - stream: ReadStream | ReadableStream, - extra = {} -) { +export async function streamUpload({ + bucket: bucketName, + stream, + filename, + type, + extra, + ttl, +}: StreamUploadParams) { + const extension = filename.split(".").pop() const objectStore = ObjectStore(bucketName) - await makeSureBucketExists(objectStore, bucketName) + const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) + + if (ttl && (bucketCreated.created || bucketCreated.exists)) { + let ttlConfig = bucketTTLConfig(bucketName, ttl) + if (objectStore.putBucketLifecycleConfiguration) { + await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise() + } + } // Set content type for certain known extensions if (filename?.endsWith(".js")) { @@ -203,10 +242,18 @@ export async function streamUpload( } } + let contentType = type + if (!contentType) { + contentType = extension + ? CONTENT_TYPE_MAP[extension.toLowerCase()] + : CONTENT_TYPE_MAP.txt + } + const params = { Bucket: sanitizeBucket(bucketName), Key: sanitizeKey(filename), Body: stream, + ContentType: contentType, ...extra, } return objectStore.upload(params).promise() @@ -286,7 +333,7 @@ export function getPresignedUrl( const signedUrl = new URL(url) const path = signedUrl.pathname const query = signedUrl.search - return `/files/signed${path}${query}` + return `${signedFilePrefix}${path}${query}` } } @@ -341,7 +388,7 @@ export async function retrieveDirectory(bucketName: string, path: string) { */ export async function deleteFile(bucketName: string, filepath: string) { const objectStore = ObjectStore(bucketName) - await makeSureBucketExists(objectStore, bucketName) + await createBucketIfNotExists(objectStore, bucketName) const params = { Bucket: bucketName, Key: sanitizeKey(filepath), @@ -351,7 +398,7 @@ export async function deleteFile(bucketName: string, filepath: string) { export async function deleteFiles(bucketName: string, filepaths: string[]) { const objectStore = ObjectStore(bucketName) - await makeSureBucketExists(objectStore, bucketName) + await createBucketIfNotExists(objectStore, bucketName) const params = { Bucket: bucketName, Delete: { @@ -412,7 +459,13 @@ export async function uploadDirectory( if (file.isDirectory()) { uploads.push(uploadDirectory(bucketName, local, path)) } else { - uploads.push(streamUpload(bucketName, path, fs.createReadStream(local))) + uploads.push( + streamUpload({ + bucket: bucketName, + filename: path, + stream: fs.createReadStream(local), + }) + ) } } await Promise.all(uploads) @@ -467,3 +520,23 @@ export async function getReadStream( } return client.getObject(params).createReadStream() } + +/* +Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract +the bucket and the path from it +*/ +export function extractBucketAndPath( + url: string +): { bucket: string; path: string } | null { + const baseUrl = url.split("?")[0] + + const regex = new RegExp(`^${signedFilePrefix}/(?[^/]+)/(?.+)$`) + const match = baseUrl.match(regex) + + if (match && match.groups) { + const { bucket, path } = match.groups + return { bucket, path } + } + + return null +} diff --git a/packages/backend-core/src/objectStore/utils.ts b/packages/backend-core/src/objectStore/utils.ts index 4c3a84ba91..08b5238ff6 100644 --- a/packages/backend-core/src/objectStore/utils.ts +++ b/packages/backend-core/src/objectStore/utils.ts @@ -2,6 +2,7 @@ import { join } from "path" import { tmpdir } from "os" import fs from "fs" import env from "../environment" +import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3" /**************************************************** * NOTE: When adding a new bucket - name * @@ -15,6 +16,7 @@ export const ObjectStoreBuckets = { TEMPLATES: env.TEMPLATES_BUCKET_NAME, GLOBAL: env.GLOBAL_BUCKET_NAME, PLUGINS: env.PLUGIN_BUCKET_NAME, + TEMP: env.TEMP_BUCKET_NAME, } const bbTmp = join(tmpdir(), ".budibase") @@ -29,3 +31,27 @@ try { export function budibaseTempDir() { return bbTmp } + +export const bucketTTLConfig = ( + bucketName: string, + days: number +): PutBucketLifecycleConfigurationRequest => { + const lifecycleRule = { + ID: `${bucketName}-ExpireAfter${days}days`, + Prefix: "", + Status: "Enabled", + Expiration: { + Days: days, + }, + } + const lifecycleConfiguration = { + Rules: [lifecycleRule], + } + + const params = { + Bucket: bucketName, + LifecycleConfiguration: lifecycleConfiguration, + } + + return params +} diff --git a/packages/backend-core/tests/core/utilities/index.ts b/packages/backend-core/tests/core/utilities/index.ts index 787d69be2c..b2f19a0286 100644 --- a/packages/backend-core/tests/core/utilities/index.ts +++ b/packages/backend-core/tests/core/utilities/index.ts @@ -4,3 +4,6 @@ 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 new file mode 100644 index 0000000000..cef33daa91 --- /dev/null +++ b/packages/backend-core/tests/core/utilities/minio.ts @@ -0,0 +1,34 @@ +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..32841e4c3a 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) { diff --git a/packages/bbui/package.json b/packages/bbui/package.json index a1baa2a38b..b4a9a3969c 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", 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..ee478c70c0 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -1,7 +1,13 @@ const ignoredClasses = [ - ".flatpickr-calendar", - ".spectrum-Popover", ".download-js-link", + ".flatpickr-calendar", + ".spectrum-Menu", + ".date-time-popover", +] +const conditionallyIgnoredClasses = [ + ".spectrum-Underlay", + ".drawer-wrapper", + ".spectrum-Popover", ] let clickHandlers = [] @@ -9,6 +15,9 @@ let clickHandlers = [] * Handle a body click event */ const handleClick = event => { + // Treat right clicks (context menu events) as normal clicks + const eventType = event.type === "contextmenu" ? "click" : event.type + // Ignore click if this is an ignored class if (event.target.closest('[data-ignore-click-outside="true"]')) { return @@ -21,26 +30,23 @@ const handleClick = event => { // Process handlers clickHandlers.forEach(handler => { + // Check that we're the right kind of click event + if (handler.allowedType && eventType !== handler.allowedType) { + return + } + + // 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) @@ -48,6 +54,7 @@ const handleClick = event => { } document.documentElement.addEventListener("click", handleClick, true) document.documentElement.addEventListener("mousedown", handleClick, true) +document.documentElement.addEventListener("contextmenu", handleClick, true) /** * Adds or updates a click handler 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/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte deleted file mode 100644 index 348488ff52..0000000000 --- a/packages/bbui/src/Form/Core/DatePicker.svelte +++ /dev/null @@ -1,268 +0,0 @@ - - -{#key redrawOptions} - -
- - -
- -
- -
-
-{/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..bc06530044 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte @@ -0,0 +1,249 @@ + + +
+
+
+
+ +
+ {#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/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..e100362359 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/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 6434c7710d..2d2022299c 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -32,6 +32,7 @@ import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte" + import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import { BindingHelpers, BindingType } from "components/common/bindings/utils" import { bindingsToCompletions, @@ -356,7 +357,8 @@ value.customType !== "queryParams" && value.customType !== "cron" && value.customType !== "triggerSchema" && - value.customType !== "automationFields" + value.customType !== "automationFields" && + value.type !== "attachment" ) } @@ -372,6 +374,15 @@ console.error(error) } }) + const handleAttachmentParams = keyValuObj => { + let params = {} + if (keyValuObj?.length) { + for (let param of keyValuObj) { + params[param.url] = param.filename + } + } + return params + }
@@ -437,6 +448,33 @@ value={inputData[key]} options={Object.keys(table?.schema || {})} /> + {:else if value.type === "attachment"} +
+
+ +
+
+ + onChange( + { + detail: e.detail.map(({ name, value }) => ({ + url: name, + filename: value, + })), + }, + key + )} + object={handleAttachmentParams(inputData[key])} + allowJS + {bindings} + keyBindings + customButtonText={"Add attachment"} + keyPlaceholder={"URL"} + valuePlaceholder={"Filename"} + /> +
+
{:else if value.customType === "filters"} Define filters @@ -651,14 +689,22 @@ } .block-field { - display: flex; /* Use Flexbox */ + display: flex; justify-content: space-between; - flex-direction: row; /* Arrange label and field side by side */ - align-items: center; /* Align vertically in the center */ - gap: 10px; /* Add some space between label and field */ + flex-direction: row; + align-items: center; + gap: 10px; flex: 1; } + .attachment-field-width { + margin-top: var(--spacing-xs); + } + + .label-wrapper { + margin-top: var(--spacing-s); + } + .test :global(.drawer) { width: 10000px !important; } 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/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index d271462f3e..81d5545c40 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -29,7 +29,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 { + FieldType, + BBReferenceFieldSubType, + 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 +45,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"] @@ -263,9 +265,9 @@ delete saveColumn.fieldName } if (isUsersColumn(saveColumn)) { - if (saveColumn.subtype === USER_TYPE) { + if (saveColumn.subtype === BBReferenceFieldSubType.USER) { saveColumn.relationshipType = RelationshipType.ONE_TO_MANY - } else if (saveColumn.subtype === USERS_TYPE) { + } else if (saveColumn.subtype === BBReferenceFieldSubType.USERS) { saveColumn.relationshipType = RelationshipType.MANY_TO_MANY } } @@ -363,19 +365,17 @@ function getAllowedTypes() { if (originalName) { - const possibleTypes = ( - SWITCHABLE_TYPES[field.type] || [editableColumn.type] - ).map(t => t.toLowerCase()) + const possibleTypes = SWITCHABLE_TYPES[field.type] || [ + editableColumn.type, + ] 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 + editableColumn.subtype === BBReferenceFieldSubType.USERS if (!externalTable) { return [ @@ -485,7 +485,9 @@ function isUsersColumn(column) { return ( column.type === FieldType.BB_REFERENCE && - [FieldSubtype.USER, FieldSubtype.USERS].includes(column.subtype) + [BBReferenceFieldSubType.USER, BBReferenceFieldSubType.USERS].includes( + column.subtype + ) ) } @@ -513,6 +515,7 @@ /> {/if} + {#if keyBindings} + { + field.name = e.detail + changed() + }} + disabled={readOnly} + value={field.name} + {allowJS} + {allowHelpers} + drawerLeft={bindingDrawerLeft} + /> + {:else} + + {/if} {#if isJsonArray(field.value)} delete condition.settingValue} />
TO
{#if definition} diff --git a/packages/builder/src/pages/builder/app/[application]/settings/backups/index.svelte b/packages/builder/src/pages/builder/app/[application]/settings/backups/index.svelte index bbf28b2fe8..39d61ffc31 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/backups/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/backups/index.svelte @@ -1,7 +1,6 @@ @@ -207,7 +206,7 @@ View plans
- {:else if !backupData?.length && !loading && !filterOpt && !startDate} + {:else if !backupData?.length && !loading && !filterOpt && !dateRange?.length}
BackupsDefault @@ -236,21 +235,15 @@ bind:value={filterOpt} />
- { - if (e.detail[0].length > 1) { - startDate = e.detail[0][0].toISOString() - endDate = e.detail[0][1].toISOString() - } - }} + (dateRange = e.detail)} />
- +
diff --git a/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte b/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte index 7f12c9ac36..41254af970 100644 --- a/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte +++ b/packages/builder/src/pages/builder/portal/account/auditLogs/index.svelte @@ -12,7 +12,6 @@ Icon, clickOutside, CoreTextArea, - DatePicker, Pagination, Helpers, Divider, @@ -27,6 +26,8 @@ import TimeRenderer from "./_components/TimeRenderer.svelte" import AppColumnRenderer from "./_components/AppColumnRenderer.svelte" import { cloneDeep } from "lodash" + import DateRangePicker from "components/common/DateRangePicker.svelte" + import dayjs from "dayjs" const schema = { date: { width: "0.8fr" }, @@ -69,16 +70,13 @@ let sidePanelVisible = false let wideSidePanel = false let timer - let startDate = new Date() - startDate.setDate(startDate.getDate() - 30) - let endDate = new Date() + let dateRange = [dayjs().subtract(30, "days"), dayjs()] $: fetchUsers(userPage, userSearchTerm) $: fetchLogs({ logsPage, logSearchTerm, - startDate, - endDate, + dateRange, selectedUsers, selectedApps, selectedEvents, @@ -136,8 +134,7 @@ const fetchLogs = async ({ logsPage, logSearchTerm, - startDate, - endDate, + dateRange, selectedUsers, selectedApps, selectedEvents, @@ -155,8 +152,8 @@ logsPageInfo.loading() await auditLogs.search({ bookmark: logsPage, - startDate, - endDate, + startDate: dateRange[0], + endDate: dateRange[1], fullSearch: logSearchTerm, userIds: selectedUsers, appIds: selectedApps, @@ -214,8 +211,8 @@ const downloadLogs = async () => { try { window.location = auditLogs.getDownloadUrl({ - startDate, - endDate, + startDate: dateRange[0], + endDate: dateRange[1], fullSearch: logSearchTerm, userIds: selectedUsers, appIds: selectedApps, @@ -302,22 +299,9 @@
- { - if (e.detail[0]?.length === 1) { - startDate = e.detail[0][0].toISOString() - endDate = "" - } else if (e.detail[0]?.length > 1) { - startDate = e.detail[0][0].toISOString() - endDate = e.detail[0][1].toISOString() - } else { - startDate = "" - endDate = "" - } - }} + (dateRange = e.detail)} />
@@ -488,7 +472,7 @@ flex-direction: row; gap: var(--spacing-l); flex-wrap: wrap; - align-items: center; + align-items: flex-end; } .side-panel-icons { @@ -505,6 +489,13 @@ .date-picker { flex-basis: calc(70% - 32px); min-width: 100px; + display: flex; + flex-direction: row; + } + .date-picker :global(.date-range-picker), + .date-picker :global(.spectrum-Form-item) { + flex: 1 1 auto; + width: 0; } .freeSearch { diff --git a/packages/cli/src/backups/objectStore.ts b/packages/cli/src/backups/objectStore.ts index 32fc07c05b..2a24199603 100644 --- a/packages/cli/src/backups/objectStore.ts +++ b/packages/cli/src/backups/objectStore.ts @@ -9,7 +9,7 @@ const { ObjectStore, retrieve, uploadDirectory, - makeSureBucketExists, + createBucketIfNotExists, } = objectStore const bucketList = Object.values(ObjectStoreBuckets) @@ -61,7 +61,7 @@ export async function importObjects() { let count = 0 for (let bucket of buckets) { const client = ObjectStore(bucket) - await makeSureBucketExists(client, bucket) + await createBucketIfNotExists(client, bucket) const files = await uploadDirectory(bucket, join(path, bucket), "/") count += files.length bar.update(count) diff --git a/packages/cli/src/hosting/utils.ts b/packages/cli/src/hosting/utils.ts index cbf6d9b0c3..5c3ac33f44 100644 --- a/packages/cli/src/hosting/utils.ts +++ b/packages/cli/src/hosting/utils.ts @@ -54,11 +54,9 @@ export async function downloadDockerCompose() { export async function checkDockerConfigured() { const error = - "docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose" + "docker has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose" const docker = await lookpath("docker") - const compose = await lookpath("docker-compose") - const composeV2 = await lookpath("docker compose") - if (!docker || (!compose && !composeV2)) { + if (!docker) { throw error } } diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 91fd141704..a71deb9751 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -3869,12 +3869,6 @@ "key": "timeOnly", "defaultValue": false }, - { - "type": "boolean", - "label": "24-hour time", - "key": "time24hr", - "defaultValue": false - }, { "type": "boolean", "label": "Ignore time zones", @@ -6974,6 +6968,12 @@ "key": "stripeRows", "defaultValue": false }, + { + "type": "boolean", + "label": "Quiet", + "key": "quiet", + "defaultValue": false + }, { "section": true, "name": "Columns", diff --git a/packages/client/package.json b/packages/client/package.json index 00f89a6445..0f93adfd7d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -24,14 +24,7 @@ "@budibase/shared-core": "0.0.0", "@budibase/string-templates": "0.0.0", "@budibase/types": "0.0.0", - "@spectrum-css/button": "^3.0.3", - "@spectrum-css/card": "^3.0.3", - "@spectrum-css/divider": "^1.0.3", - "@spectrum-css/link": "^3.1.3", - "@spectrum-css/page": "^3.0.1", - "@spectrum-css/tag": "^3.1.4", - "@spectrum-css/typography": "^3.0.2", - "@spectrum-css/vars": "^3.0.1", + "@spectrum-css/card": "3.0.3", "apexcharts": "^3.22.1", "dayjs": "^1.10.8", "downloadjs": "1.4.7", diff --git a/packages/client/src/components/app/DateRangePicker.svelte b/packages/client/src/components/app/DateRangePicker.svelte index 5c710ad766..8131f3bd89 100644 --- a/packages/client/src/components/app/DateRangePicker.svelte +++ b/packages/client/src/components/app/DateRangePicker.svelte @@ -38,10 +38,8 @@ if (!field || !value) { return null } - let low = dayjs.utc().subtract(1, "year") let high = dayjs.utc().add(1, "day") - if (value === "Last 1 day") { low = dayjs.utc().subtract(1, "day") } else if (value === "Last 7 days") { @@ -53,7 +51,6 @@ } else if (value === "Last 6 months") { low = dayjs.utc().subtract(6, "months") } - return { range: { [field]: { diff --git a/packages/client/src/components/app/GridBlock.svelte b/packages/client/src/components/app/GridBlock.svelte index 0ee2cf1487..e365429cb6 100644 --- a/packages/client/src/components/app/GridBlock.svelte +++ b/packages/client/src/components/app/GridBlock.svelte @@ -11,6 +11,7 @@ export let allowEditRows = true export let allowDeleteRows = true export let stripeRows = false + export let quiet = false export let initialFilter = null export let initialSortColumn = null export let initialSortOrder = null @@ -49,6 +50,8 @@ metadata: { dataSource: table }, }, ] + $: height = $component.styles?.normal?.height || "408px" + $: styles = getSanitisedStyles($component.styles) // Provide additional data context for live binding eval export const getAdditionalDataContext = () => { @@ -105,38 +108,48 @@ }, })) } + + const getSanitisedStyles = styles => { + return { + ...styles, + normal: { + ...styles?.normal, + height: undefined, + }, + } + } -
- - onRowClick?.({ row: e.detail })} - /> - +
+ + + onRowClick?.({ row: e.detail })} + /> + +
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 1b252d5b06..074c2dbd9b 100644 --- a/packages/frontend-core/src/components/FilterBuilder.svelte +++ b/packages/frontend-core/src/components/FilterBuilder.svelte @@ -67,6 +67,11 @@ const removeFilter = id => { filters = filters.filter(field => field.id !== id) + + // Clear all filters when no fields are specified + if (filters.length === 1 && filters[0].onEmptyFilter) { + filters = [] + } } const duplicateFilter = id => { diff --git a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte index e7dc51e5d5..1a2494987a 100644 --- a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte @@ -1,6 +1,7 @@ -
+ + +
- {#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..af1f1b6b78 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 +252,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..a01baa5c8e 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..cef4144833 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -20,6 +20,7 @@ const ignoredOriginSelectors = [ ".spectrum-Modal", + ".date-time-popover", "#builder-side-panel-container", "[data-grid-ignore]", ] diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 55308d4c6e..13430a3f7d 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -1,7 +1,8 @@ +