diff --git a/.eslintrc.json b/.eslintrc.json index 2c810eecc5..ce4b07ca2f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,5 @@ { + "root": true, "env": { "browser": true, "es6": true, diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index 7d09451614..7f1e08601a 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -170,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/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/globalSetup.ts b/globalSetup.ts index dd1a7dbaa0..dd1454b6e1 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -46,7 +46,7 @@ export default async function setup() { 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", @@ -69,7 +69,20 @@ export default async function setup() { ).withStartupTimeout(20000) ) - await couchdb.start() + 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 Promise.all([couchdb.start(), minio.start()]) } finally { 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/lerna.json b/lerna.json index 94631c6820..16dc73aa30 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.23.12", + "version": "2.25.0", "npmClient": "yarn", "packages": [ "packages/*", 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/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 32841e4c3a..1a25bb28f4 100644 --- a/packages/backend-core/tests/core/utilities/testContainerUtils.ts +++ b/packages/backend-core/tests/core/utilities/testContainerUtils.ts @@ -86,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 b4a9a3969c..1b3510cf3b 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -83,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/Actions/click_outside.js b/packages/bbui/src/Actions/click_outside.js index ee478c70c0..124f43ff04 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -1,23 +1,25 @@ +// These class names will never trigger a callback if clicked, no matter what const ignoredClasses = [ ".download-js-link", - ".flatpickr-calendar", ".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 => { - // 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 @@ -30,11 +32,6 @@ 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 @@ -52,17 +49,43 @@ const handleClick = event => { handler.callback?.(event) }) } -document.documentElement.addEventListener("click", handleClick, true) -document.documentElement.addEventListener("mousedown", handleClick, true) -document.documentElement.addEventListener("contextmenu", 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 } @@ -89,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/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}
{ - sidePanelVisble = false + sidePanelVisible = false }} > diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index e100362359..90b447f3c1 100644 --- a/packages/bbui/src/helpers.js +++ b/packages/bbui/src/helpers.js @@ -154,7 +154,7 @@ export const parseDate = (value, { enableTime = true }) => { // schema flags export const stringifyDate = ( value, - { enableTime = true, timeOnly = false, ignoreTimezones = false } + { enableTime = true, timeOnly = false, ignoreTimezones = false } = {} ) => { if (!value) { return null @@ -210,7 +210,7 @@ const localeDateFormat = new Intl.DateTimeFormat() // Formats a dayjs date according to schema flags export const getDateDisplayValue = ( value, - { enableTime = true, timeOnly = false } + { enableTime = true, timeOnly = false } = {} ) => { if (!value?.isValid()) { return "" 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..0cf0f6c740 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -358,7 +358,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/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/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 4b4294dd2e..be8bd75d93 100644 --- a/packages/builder/src/constants/backend/index.js +++ b/packages/builder/src/constants/backend/index.js @@ -253,6 +253,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..aaccbd0e6b 100644 --- a/packages/builder/src/dataBinding.js +++ b/packages/builder/src/dataBinding.js @@ -1106,50 +1106,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 @@ + +