diff --git a/.github/workflows/deploy-cloud.yaml b/.github/workflows/deploy-cloud.yaml index d54e6c9c68..afb28d42be 100644 --- a/.github/workflows/deploy-cloud.yaml +++ b/.github/workflows/deploy-cloud.yaml @@ -38,6 +38,17 @@ jobs: fi echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV + - name: Tag and release Proxy service docker image + run: | + docker login -u $DOCKER_USER -p $DOCKER_PASSWORD + yarn build:docker:proxy:prod + docker tag budibase/proxy:$release_tag budibase/proxy:$PROD_TAG + docker push budibase/proxy:$PROD_TAG + env: + DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} + PROD_TAG: k8s + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v1 with: diff --git a/.github/workflows/deploy-preprod.yml b/.github/workflows/deploy-preprod.yml index 10fadc36c5..7abe3bd533 100644 --- a/.github/workflows/deploy-preprod.yml +++ b/.github/workflows/deploy-preprod.yml @@ -23,12 +23,24 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: eu-west-1 + - name: Get the latest budibase release version id: version run: | release_version=$(cat lerna.json | jq -r '.version') echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV + - name: Tag and release Proxy service docker image + run: | + docker login -u $DOCKER_USER -p $DOCKER_PASSWORD + yarn build:docker:proxy:preprod + docker tag budibase/proxy:$release_tag budibase/proxy:$PREPROD_TAG + docker push budibase/proxy:$PREPROD_TAG + env: + DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} + PREPROD_TAG: k8s-preprod + - name: Pull values.yaml from budibase-infra run: | curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \ diff --git a/.github/workflows/release-selfhost.yml b/.github/workflows/release-selfhost.yml index f51648074c..c0e6225a38 100644 --- a/.github/workflows/release-selfhost.yml +++ b/.github/workflows/release-selfhost.yml @@ -47,6 +47,13 @@ jobs: yarn yarn build popd + + - name: Build OpenAPI sepc + run: | + pushd packages/server + yarn + yarn specs + popd - name: Setup Helm uses: azure/setup-helm@v1 @@ -77,3 +84,5 @@ jobs: packages/cli/build/cli-win.exe packages/cli/build/cli-linux packages/cli/build/cli-macos + packages/server/specs/openapi.yaml + packages/server/specs/openapi.json diff --git a/.gitignore b/.gitignore index da62ecb153..41fed0978c 100644 --- a/.gitignore +++ b/.gitignore @@ -96,4 +96,5 @@ hosting/proxy/.generated-nginx.prod.conf *.sublime-workspace bin/ +hosting/.generated* packages/builder/cypress.env.json diff --git a/.prettierignore b/.prettierignore index 4bdb64f60c..6103408e00 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,4 @@ node_modules -public dist *.spec.js packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte @@ -8,4 +7,4 @@ packages/server/coverage packages/server/client packages/builder/.routify packages/builder/cypress/support/queryLevelTransformerFunction.js -packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js \ No newline at end of file +packages/builder/cypress/support/queryLevelTransformerFunctionWithData.js diff --git a/hosting/nginx.dev.conf.hbs b/hosting/nginx.dev.conf.hbs index 624b4c2653..441fffa9f7 100644 --- a/hosting/nginx.dev.conf.hbs +++ b/hosting/nginx.dev.conf.hbs @@ -76,6 +76,7 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $http_host; proxy_connect_timeout 300; proxy_http_version 1.1; @@ -91,4 +92,4 @@ http { gzip off; gzip_comp_level 4; } -} \ No newline at end of file +} diff --git a/hosting/nginx.prod.conf.hbs b/hosting/nginx.prod.conf.hbs index 23ef1651d1..f3009baf40 100644 --- a/hosting/nginx.prod.conf.hbs +++ b/hosting/nginx.prod.conf.hbs @@ -19,13 +19,7 @@ http { tcp_nodelay on; server_tokens off; types_hash_max_size 2048; - {{#if compose}} - resolver 127.0.0.11 ipv6=off; - {{/if}} - {{#if k8s}} - resolver kube-dns.kube-system.svc.cluster.local valid=10s; - {{/if}} - + resolver {{ resolver }} valid=10s ipv6=off; # buffering client_body_buffer_size 1K; diff --git a/hosting/proxy/nginx.conf b/hosting/proxy/nginx.conf deleted file mode 100644 index 95b06d4fff..0000000000 --- a/hosting/proxy/nginx.conf +++ /dev/null @@ -1,145 +0,0 @@ -user nginx; -error_log /var/log/nginx/error.log debug; -pid /var/run/nginx.pid; -worker_processes auto; -worker_rlimit_nofile 33282; - -events { - worker_connections 1024; -} - -http { - limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=20r/s; - include /etc/nginx/mime.types; - default_type application/octet-stream; - charset utf-8; - sendfile on; - tcp_nopush on; - tcp_nodelay on; - server_tokens off; - types_hash_max_size 2048; - - # buffering - client_body_buffer_size 1K; - client_header_buffer_size 1k; - client_max_body_size 1k; - ignore_invalid_headers off; - - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - map $http_upgrade $connection_upgrade { - default "upgrade"; - } - - server { - listen 10000 default_server; - listen [::]:10000 default_server; - server_name _; - client_max_body_size 1000m; - ignore_invalid_headers off; - proxy_buffering off; - port_in_redirect off; - - # Security Headers - add_header X-Frame-Options SAMEORIGIN always; - add_header X-Content-Type-Options nosniff always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me; frame-src 'self'; img-src http: https: data:; manifest-src 'self'; media-src 'self'; worker-src 'none';" always; - - location /app { - proxy_pass http://app-service:4002; - rewrite ^/app/(.*)$ /$1 break; - } - - location = / { - port_in_redirect off; - proxy_pass http://app-service:4002; - } - - location = /v1/update { - proxy_pass http://watchtower-service:8080; - } - - location /builder/ { - port_in_redirect off; - proxy_http_version 1.1; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass http://app-service:4002; - } - - location ~ ^/(builder|app_) { - port_in_redirect off; - proxy_http_version 1.1; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_pass http://app-service:4002; - } - - location ~ ^/api/(system|admin|global)/ { - proxy_pass http://worker-service:4003; - } - - location /worker/ { - proxy_pass http://worker-service:4003; - rewrite ^/worker/(.*)$ /$1 break; - } - - location /api/ { - # calls to the API are rate limited with bursting - limit_req zone=ratelimit burst=20 nodelay; - - # 120s timeout on API requests - proxy_read_timeout 120s; - proxy_connect_timeout 120s; - proxy_send_timeout 120s; - - proxy_http_version 1.1; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - - proxy_pass http://app-service:4002; - } - - location /db/ { - proxy_pass http://couchdb-service:5984; - rewrite ^/db/(.*)$ /$1 break; - } - - location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Host $http_host; - - proxy_connect_timeout 300; - proxy_http_version 1.1; - proxy_set_header Connection ""; - chunked_transfer_encoding off; - proxy_pass http://minio-service:9000; - } - - client_header_timeout 60; - client_body_timeout 60; - keepalive_timeout 60; - - # gzip - gzip on; - gzip_vary on; - gzip_proxied any; - gzip_comp_level 6; - gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; - } -} \ No newline at end of file diff --git a/package.json b/package.json index 5111ad204f..69c7967409 100644 --- a/package.json +++ b/package.json @@ -42,13 +42,14 @@ "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\"", "lint:fix:ts": "lerna run lint:fix", "lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint", - "test:e2e": "lerna run cy:test", - "test:e2e:ci": "lerna run cy:ci", + "test:e2e": "lerna run cy:test --stream", + "test:e2e:ci": "lerna run cy:ci --stream", + "build:specs": "lerna run specs", "build:docker": "lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -", "build:docker:proxy": "docker build hosting/proxy -t proxy-service", - "build:docker:proxy:compose": "lerna run generate:proxy:compose && npm run build:docker:proxy", - "build:docker:proxy:preprod": "lerna run generate:proxy:preprod && npm run build:docker:proxy", - "build:docker:proxy:prod": "lerna run generate:proxy:prod && npm run build:docker:proxy", + "build:docker:proxy:compose": "node scripts/proxy/generateProxyConfig compose && npm run build:docker:proxy", + "build:docker:proxy:preprod": "node scripts/proxy/generateProxyConfig preprod && npm run build:docker:proxy", + "build:docker:proxy:prod": "node scripts/proxy/generateProxyConfig prod && npm run build:docker:proxy", "build:docker:selfhost": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -", "build:docker:develop": "node scripts/pinVersions && lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", diff --git a/packages/backend-core/encryption.js b/packages/backend-core/encryption.js new file mode 100644 index 0000000000..4ccb6e3a99 --- /dev/null +++ b/packages/backend-core/encryption.js @@ -0,0 +1 @@ +module.exports = require("./src/security/encryption") diff --git a/packages/backend-core/src/cache/user.js b/packages/backend-core/src/cache/user.js index 60a2d341a8..b10f854002 100644 --- a/packages/backend-core/src/cache/user.js +++ b/packages/backend-core/src/cache/user.js @@ -32,11 +32,10 @@ const populateFromDB = async (userId, tenantId) => { * @param {*} populateUser function to provide the user for re-caching. default to couch db * @returns */ -exports.getUser = async ( - userId, - tenantId = null, - populateUser = populateFromDB -) => { +exports.getUser = async (userId, tenantId = null, populateUser = null) => { + if (!populateUser) { + populateUser = populateFromDB + } if (!tenantId) { try { tenantId = getTenantId() diff --git a/packages/backend-core/src/db/constants.js b/packages/backend-core/src/db/constants.js index b41a9a9c08..5ee8033e05 100644 --- a/packages/backend-core/src/db/constants.js +++ b/packages/backend-core/src/db/constants.js @@ -14,6 +14,7 @@ exports.DocumentTypes = { APP_METADATA: `${PRE_APP}${exports.SEPARATOR}metadata`, ROLE: "role", MIGRATIONS: "migrations", + DEV_INFO: "devinfo", } exports.StaticDatabases = { diff --git a/packages/backend-core/src/db/utils.js b/packages/backend-core/src/db/utils.js index c4dcb8248b..6d6f9a782b 100644 --- a/packages/backend-core/src/db/utils.js +++ b/packages/backend-core/src/db/utils.js @@ -30,6 +30,7 @@ const UNICODE_MAX = "\ufff0" exports.ViewNames = { USER_BY_EMAIL: "by_email", + BY_API_KEY: "by_api_key", } exports.StaticDatabases = StaticDatabases @@ -67,6 +68,7 @@ function getDocParams(docType, docId = null, otherProps = {}) { endkey: `${docType}${SEPARATOR}${docId}${UNICODE_MAX}`, } } +exports.getDocParams = getDocParams /** * Generates a new workspace ID. @@ -339,6 +341,14 @@ const getConfigParams = ({ type, workspace, user }, otherProps = {}) => { } } +/** + * Generates a new dev info document ID - this is scoped to a user. + * @returns {string} The new dev info ID which info for dev (like api key) can be stored under. + */ +const generateDevInfoID = userId => { + return `${DocumentTypes.DEV_INFO}${SEPARATOR}${userId}` +} + /** * Returns the most granular configuration document from the DB based on the type, workspace and userID passed. * @param {Object} db - db instance to query @@ -454,3 +464,4 @@ exports.generateConfigID = generateConfigID exports.getConfigParams = getConfigParams exports.getScopedFullConfig = getScopedFullConfig exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc +exports.generateDevInfoID = generateDevInfoID diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index fd004ca0c2..e5be8e6b40 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -1,4 +1,5 @@ const { DocumentTypes, ViewNames } = require("./utils") +const { getGlobalDB } = require("../tenancy") function DesignDoc() { return { @@ -9,7 +10,8 @@ function DesignDoc() { } } -exports.createUserEmailView = async db => { +exports.createUserEmailView = async () => { + const db = getGlobalDB() let designDoc try { designDoc = await db.get("_design/database") @@ -31,3 +33,51 @@ exports.createUserEmailView = async db => { } await db.put(designDoc) } + +exports.createApiKeyView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + designDoc = DesignDoc() + } + const view = { + map: `function(doc) { + if (doc._id.startsWith("${DocumentTypes.DEV_INFO}") && doc.apiKey) { + emit(doc.apiKey, doc.userId) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewNames.BY_API_KEY]: view, + } + await db.put(designDoc) +} + +exports.queryGlobalView = async (viewName, params, db = null) => { + const CreateFuncByName = { + [ViewNames.USER_BY_EMAIL]: exports.createUserEmailView, + [ViewNames.BY_API_KEY]: exports.createApiKeyView, + } + // can pass DB in if working with something specific + if (!db) { + db = getGlobalDB() + } + try { + let response = (await db.query(`database/${viewName}`, params)).rows + response = response.map(resp => + params.include_docs ? resp.doc : resp.value + ) + return response.length <= 1 ? response[0] : response + } catch (err) { + if (err != null && err.name === "not_found") { + const createFunc = CreateFuncByName[viewName] + await createFunc() + return exports.queryGlobalView(viewName, params) + } else { + throw err + } + } +} diff --git a/packages/backend-core/src/middleware/authenticated.js b/packages/backend-core/src/middleware/authenticated.js index 4978f7b9dc..ee815ea330 100644 --- a/packages/backend-core/src/middleware/authenticated.js +++ b/packages/backend-core/src/middleware/authenticated.js @@ -4,6 +4,9 @@ const { getUser } = require("../cache/user") const { getSession, updateSessionTTL } = require("../security/sessions") const { buildMatcherRegex, matches } = require("./matchers") const env = require("../environment") +const { SEPARATOR, ViewNames, queryGlobalView } = require("../../db") +const { getGlobalDB } = require("../tenancy") +const { decrypt } = require("../security/encryption") function finalise( ctx, @@ -16,6 +19,28 @@ function finalise( ctx.version = version } +async function checkApiKey(apiKey, populateUser) { + if (apiKey === env.INTERNAL_API_KEY) { + return { valid: true } + } + const decrypted = decrypt(apiKey) + const tenantId = decrypted.split(SEPARATOR)[0] + const db = getGlobalDB(tenantId) + // api key is encrypted in the database + const userId = await queryGlobalView( + ViewNames.BY_API_KEY, + { + key: apiKey, + }, + db + ) + if (userId) { + return { valid: true, user: await getUser(userId, tenantId, populateUser) } + } else { + throw "Invalid API key" + } +} + /** * This middleware is tenancy aware, so that it does not depend on other middlewares being used. * The tenancy modules should not be used here and it should be assumed that the tenancy context @@ -79,9 +104,19 @@ module.exports = ( const apiKey = ctx.request.headers[Headers.API_KEY] const tenantId = ctx.request.headers[Headers.TENANT_ID] // this is an internal request, no user made it - if (!authenticated && apiKey && apiKey === env.INTERNAL_API_KEY) { - authenticated = true - internal = true + if (!authenticated && apiKey) { + const populateUser = opts.populateUser ? opts.populateUser(ctx) : null + const { valid, user: foundUser } = await checkApiKey( + apiKey, + populateUser + ) + if (valid && foundUser) { + authenticated = true + user = foundUser + } else if (valid) { + authenticated = true + internal = true + } } if (!user && tenantId) { user = { tenantId } @@ -101,6 +136,7 @@ module.exports = ( // allow configuring for public access if ((opts && opts.publicAllowed) || publicEndpoint) { finalise(ctx, { authenticated: false, version, publicEndpoint }) + return next() } else { ctx.throw(err.status || 403, err) } diff --git a/packages/backend-core/src/objectStore/index.js b/packages/backend-core/src/objectStore/index.js index b5d8475cee..2385149f4d 100644 --- a/packages/backend-core/src/objectStore/index.js +++ b/packages/backend-core/src/objectStore/index.js @@ -78,6 +78,7 @@ exports.ObjectStore = bucket => { const config = { s3ForcePathStyle: true, signatureVersion: "v4", + apiVersion: "2006-03-01", params: { Bucket: sanitizeBucket(bucket), }, @@ -102,17 +103,21 @@ exports.makeSureBucketExists = async (client, bucketName) => { .promise() } catch (err) { const promises = STATE.bucketCreationPromises + const doesntExist = err.statusCode === 404, + noAccess = err.statusCode === 403 if (promises[bucketName]) { await promises[bucketName] - } else if (err.statusCode === 404) { - // bucket doesn't exist create it - promises[bucketName] = client - .createBucket({ - Bucket: bucketName, - }) - .promise() - await promises[bucketName] - delete promises[bucketName] + } else if (doesntExist || noAccess) { + if (doesntExist) { + // bucket doesn't exist create it + promises[bucketName] = client + .createBucket({ + Bucket: bucketName, + }) + .promise() + await promises[bucketName] + delete promises[bucketName] + } // public buckets are quite hidden in the system, make sure // no bucket is set accidentally if (PUBLIC_BUCKETS.includes(bucketName)) { @@ -124,7 +129,7 @@ exports.makeSureBucketExists = async (client, bucketName) => { .promise() } } else { - throw err + throw new Error("Unable to write to object store bucket.") } } } diff --git a/packages/backend-core/src/security/apiKeys.js b/packages/backend-core/src/security/apiKeys.js new file mode 100644 index 0000000000..e90418abb8 --- /dev/null +++ b/packages/backend-core/src/security/apiKeys.js @@ -0,0 +1 @@ +exports.lookupApiKey = async () => {} diff --git a/packages/backend-core/src/security/encryption.js b/packages/backend-core/src/security/encryption.js new file mode 100644 index 0000000000..c31f597652 --- /dev/null +++ b/packages/backend-core/src/security/encryption.js @@ -0,0 +1,33 @@ +const crypto = require("crypto") +const env = require("../environment") + +const ALGO = "aes-256-ctr" +const SECRET = env.JWT_SECRET +const SEPARATOR = "-" +const ITERATIONS = 10000 +const RANDOM_BYTES = 16 +const STRETCH_LENGTH = 32 + +function stretchString(string, salt) { + return crypto.pbkdf2Sync(string, salt, ITERATIONS, STRETCH_LENGTH, "sha512") +} + +exports.encrypt = input => { + const salt = crypto.randomBytes(RANDOM_BYTES) + const stretched = stretchString(SECRET, salt) + const cipher = crypto.createCipheriv(ALGO, stretched, salt) + const base = cipher.update(input) + const final = cipher.final() + const encrypted = Buffer.concat([base, final]).toString("hex") + return `${salt.toString("hex")}${SEPARATOR}${encrypted}` +} + +exports.decrypt = input => { + const [salt, encrypted] = input.split(SEPARATOR) + const saltBuffer = Buffer.from(salt, "hex") + const stretched = stretchString(SECRET, saltBuffer) + const decipher = crypto.createDecipheriv(ALGO, stretched, saltBuffer) + const base = decipher.update(Buffer.from(encrypted, "hex")) + const final = decipher.final() + return Buffer.concat([base, final]).toString() +} diff --git a/packages/backend-core/src/security/permissions.js b/packages/backend-core/src/security/permissions.js index 3b05c10e20..28044a5129 100644 --- a/packages/backend-core/src/security/permissions.js +++ b/packages/backend-core/src/security/permissions.js @@ -10,6 +10,7 @@ const PermissionLevels = { // these are the global types, that govern the underlying default behaviour const PermissionTypes = { + APP: "app", TABLE: "table", USER: "user", AUTOMATION: "automation", diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 45fb4acd55..4183fa64d5 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -6,7 +6,7 @@ const { } = require("./db/utils") const jwt = require("jsonwebtoken") const { options } = require("./middleware/passport/jwt") -const { createUserEmailView } = require("./db/views") +const { queryGlobalView } = require("./db/views") const { Headers, UserStatus, Cookies, MAX_VALID_DATE } = require("./constants") const { getGlobalDB, @@ -139,25 +139,11 @@ exports.getGlobalUserByEmail = async email => { if (email == null) { throw "Must supply an email address to view" } - const db = getGlobalDB() - try { - let users = ( - await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { - key: email.toLowerCase(), - include_docs: true, - }) - ).rows - users = users.map(user => user.doc) - return users.length <= 1 ? users[0] : users - } catch (err) { - if (err != null && err.name === "not_found") { - await createUserEmailView(db) - return exports.getGlobalUserByEmail(email) - } else { - throw err - } - } + return queryGlobalView(ViewNames.USER_BY_EMAIL, { + key: email.toLowerCase(), + include_docs: true, + }) } exports.saveUser = async ( diff --git a/packages/bbui/package.json b/packages/bbui/package.json index b0dd85e75d..1b5d5436c1 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "^1.0.72-alpha.0", + "@budibase/string-templates": "^1.0.80-alpha.5", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Form/Core/Checkbox.svelte b/packages/bbui/src/Form/Core/Checkbox.svelte index a5b366c262..8384c8ca09 100644 --- a/packages/bbui/src/Form/Core/Checkbox.svelte +++ b/packages/bbui/src/Form/Core/Checkbox.svelte @@ -47,7 +47,9 @@ - {text || ""} + {#if text} + {text} + {/if} diff --git a/packages/bbui/src/Table/SelectEditRenderer.svelte b/packages/bbui/src/Table/SelectEditRenderer.svelte index e7a70979a0..c6eafa3ed0 100644 --- a/packages/bbui/src/Table/SelectEditRenderer.svelte +++ b/packages/bbui/src/Table/SelectEditRenderer.svelte @@ -8,9 +8,21 @@ export let allowEditRows = false -{#if allowSelectRows} - -{/if} -{#if allowEditRows} - Edit -{/if} +
+ {#if allowSelectRows} + + {/if} + {#if allowEditRows} + Edit + {/if} +
+ + diff --git a/packages/bbui/src/Table/Table.svelte b/packages/bbui/src/Table/Table.svelte index e89b4e849a..c9d7f12339 100644 --- a/packages/bbui/src/Table/Table.svelte +++ b/packages/bbui/src/Table/Table.svelte @@ -5,6 +5,7 @@ import SelectEditRenderer from "./SelectEditRenderer.svelte" import { cloneDeep, deepGet } from "../helpers" import ProgressCircle from "../ProgressCircle/ProgressCircle.svelte" + import Checkbox from "../Form/Checkbox.svelte" /** * The expected schema is our normal couch schemas for our tables. @@ -31,7 +32,6 @@ export let allowEditRows = true export let allowEditColumns = true export let selectedRows = [] - export let editColumnTitle = "Edit" export let customRenderers = [] export let disableSorting = false export let autoSortColumns = true @@ -50,6 +50,8 @@ // Table state let height = 0 let loaded = false + let checkboxStatus = false + $: schema = fixSchema(schema) $: if (!loading) loaded = true $: fields = getFields(schema, showAutoColumns, autoSortColumns) @@ -67,6 +69,16 @@ $: showEditColumn = allowEditRows || allowSelectRows $: cellStyles = computeCellStyles(schema) + // Deselect the "select all" checkbox when the user navigates to a new page + $: { + let checkRowCount = rows.filter(o1 => + selectedRows.some(o2 => o1._id === o2._id) + ) + if (checkRowCount.length === 0) { + checkboxStatus = false + } + } + const fixSchema = schema => { let fixedSchema = {} Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => { @@ -197,13 +209,32 @@ if (!allowSelectRows) { return } - if (selectedRows.includes(row)) { - selectedRows = selectedRows.filter(selectedRow => selectedRow !== row) + if (selectedRows.some(selectedRow => selectedRow._id === row._id)) { + selectedRows = selectedRows.filter( + selectedRow => selectedRow._id !== row._id + ) } else { selectedRows = [...selectedRows, row] } } + const toggleSelectAll = e => { + const select = !!e.detail + if (select) { + // Add any rows which are not already in selected rows + rows.forEach(row => { + if (selectedRows.findIndex(x => x._id === row._id) === -1) { + selectedRows.push(row) + } + }) + } else { + // Remove any rows from selected rows that are in the current data set + selectedRows = selectedRows.filter(el => + rows.every(f => f._id !== el._id) + ) + } + } + const computeCellStyles = schema => { let styles = {} Object.keys(schema || {}).forEach(field => { @@ -244,7 +275,14 @@
- {editColumnTitle || ""} + {#if allowSelectRows} + + {:else} + Edit + {/if}
{/if} {#each fields as field} @@ -302,11 +340,16 @@ {#if showEditColumn}
{ + toggleSelectRow(row) + e.stopPropagation() + }} > toggleSelectRow(row)} + selected={selectedRows.findIndex( + selectedRow => selectedRow._id === row._id + ) !== -1} onEdit={e => editRow(e, row)} {allowSelectRows} {allowEditRows} diff --git a/packages/bbui/yarn.lock b/packages/bbui/yarn.lock index 28c009b331..33c3c391be 100644 --- a/packages/bbui/yarn.lock +++ b/packages/bbui/yarn.lock @@ -53,10 +53,10 @@ to-gfm-code-block "^0.1.1" year "^0.2.1" -"@budibase/string-templates@^1.0.66-alpha.0": - version "1.0.72" - resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-1.0.72.tgz#acc154e402cce98ea30eedde9c6124183ee9b37c" - integrity sha512-w715TjgO6NUHkZNqoOEo8lAKJ/PQ4b00ATWSX5VB523SAu7y/uOiqKqV1E3fgwxq1o8L+Ff7rn9FTkiYtjkV/g== +"@budibase/string-templates@^1.0.72-alpha.0": + version "1.0.75" + resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-1.0.75.tgz#5b4061f1a626160ec092f32f036541376298100c" + integrity sha512-hPgr6n5cpSCGFEha5DS/P+rtRXOLc72M6y4J/scl59JvUi/ZUJkjRgJdpQPdBLu04CNKp89V59+rAqAuDjOC0g== dependencies: "@budibase/handlebars-helpers" "^0.11.7" dayjs "^1.10.4" diff --git a/packages/builder/cypress.json b/packages/builder/cypress.json index fb9953ae6c..edad1f39d3 100644 --- a/packages/builder/cypress.json +++ b/packages/builder/cypress.json @@ -1,9 +1,10 @@ { - "baseUrl": "http://localhost:10001", + "baseUrl": "http://localhost:4100", "video": false, "projectId": "bmbemn", "env": { - "PORT": "10001", + "PORT": "4100", + "WORKER_PORT": "4200", "JWT_SECRET": "test", "HOST_IP": "" } diff --git a/packages/builder/cypress/integration/createAutomation.spec.js b/packages/builder/cypress/integration/createAutomation.spec.js index e8892d16e2..b9355f7faf 100644 --- a/packages/builder/cypress/integration/createAutomation.spec.js +++ b/packages/builder/cypress/integration/createAutomation.spec.js @@ -33,7 +33,7 @@ filterTests(['smoke', 'all'], () => { cy.get(".spectrum-Button--cta").click() }) cy.contains("Setup").click() - cy.get(".spectrum-Picker-label").click() + cy.get(".spectrum-Picker-label").eq(1).click() cy.contains("dog").click() cy.get(".spectrum-Textfield-input") .first() diff --git a/packages/builder/cypress/integration/createTable.spec.js b/packages/builder/cypress/integration/createTable.spec.js index bac6806bcd..81b7c2f045 100644 --- a/packages/builder/cypress/integration/createTable.spec.js +++ b/packages/builder/cypress/integration/createTable.spec.js @@ -27,10 +27,13 @@ filterTests(["smoke", "all"], () => { it("updates a column on the table", () => { cy.get(".title").click() cy.get(".spectrum-Table-editIcon > use").click() - cy.get("input").eq(1).type("updated", { force: true }) + cy.get(".modal-inner-wrapper").within(() => { + + cy.get("input").eq(0).type("updated", { force: true }) // Unset table display column cy.get(".spectrum-Switch-input").eq(1).click() cy.contains("Save Column").click() + }) cy.contains("nameupdated ").should("contain", "nameupdated") }) diff --git a/packages/builder/cypress/integration/datasources/rest.spec.js b/packages/builder/cypress/integration/datasources/rest.spec.js index f39d174831..58ba74795a 100644 --- a/packages/builder/cypress/integration/datasources/rest.spec.js +++ b/packages/builder/cypress/integration/datasources/rest.spec.js @@ -1,43 +1,45 @@ import filterTests from "../../support/filterTests" -filterTests(['smoke', 'all'], () => { - context("REST Datasource Testing", () => { - before(() => { - cy.login() - cy.createTestApp() - }) - - const datasource = "REST" - const restUrl = "https://api.openbrewerydb.org/breweries" - - it("Should add REST data source with incorrect API", () => { - // Select REST data source - cy.selectExternalDatasource(datasource) - // Enter incorrect api & attempt to send query - cy.wait(500) - cy.get(".spectrum-Button").contains("Add query").click({ force: true }) - cy.intercept('**/preview').as('queryError') - cy.get("input").clear().type("random text") - cy.get(".spectrum-Button").contains("Send").click({ force: true }) - // Intercept Request after button click & apply assertions - cy.wait("@queryError") - cy.get("@queryError").its('response.body') - .should('have.property', 'message', 'Invalid URL: http://random text?') - cy.get("@queryError").its('response.body') - .should('have.property', 'status', 400) - }) - - it("should add and configure a REST datasource", () => { - // Select REST datasource and create query - cy.selectExternalDatasource(datasource) - cy.wait(500) - // createRestQuery confirms query creation - cy.createRestQuery("GET", restUrl) - // Confirm status code response within REST datasource - cy.get(".spectrum-FieldLabel") - .contains("Status") - .children() - .should('contain', 200) - }) +filterTests(["smoke", "all"], () => { + context("REST Datasource Testing", () => { + before(() => { + cy.login() + cy.createTestApp() }) + + const datasource = "REST" + const restUrl = "https://api.openbrewerydb.org/breweries" + + it("Should add REST data source with incorrect API", () => { + // Select REST data source + cy.selectExternalDatasource(datasource) + // Enter incorrect api & attempt to send query + cy.wait(500) + cy.get(".spectrum-Button").contains("Add query").click({ force: true }) + cy.intercept("**/preview").as("queryError") + cy.get("input").clear().type("random text") + cy.get(".spectrum-Button").contains("Send").click({ force: true }) + // Intercept Request after button click & apply assertions + cy.wait("@queryError") + cy.get("@queryError") + .its("response.body") + .should("have.property", "message", "Invalid URL: http://random text?") + cy.get("@queryError") + .its("response.body") + .should("have.property", "status", 400) + }) + + it("should add and configure a REST datasource", () => { + // Select REST datasource and create query + cy.selectExternalDatasource(datasource) + cy.wait(500) + // createRestQuery confirms query creation + cy.createRestQuery("GET", restUrl, "/breweries") + // Confirm status code response within REST datasource + cy.get(".spectrum-FieldLabel") + .contains("Status") + .children() + .should("contain", 200) + }) + }) }) diff --git a/packages/builder/cypress/integration/queryLevelTransformers.spec.js b/packages/builder/cypress/integration/queryLevelTransformers.spec.js index d6d4278eb4..e96a6dba29 100644 --- a/packages/builder/cypress/integration/queryLevelTransformers.spec.js +++ b/packages/builder/cypress/integration/queryLevelTransformers.spec.js @@ -1,115 +1,139 @@ import filterTests from "../support/filterTests" -filterTests(['smoke', 'all'], () => { +filterTests(["smoke", "all"], () => { context("Query Level Transformers", () => { before(() => { cy.login() cy.deleteApp("Cypress Tests") cy.createApp("Cypress Tests") }) - + it("should write a transformer function", () => { - // Add REST datasource - contains API for breweries - const datasource = "REST" - const restUrl = "https://api.openbrewerydb.org/breweries" - cy.selectExternalDatasource(datasource) - cy.createRestQuery("GET", restUrl) - cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click() - // Get Transformer Function from file - cy.readFile("cypress/support/queryLevelTransformerFunction.js").then((transformerFunction) => { + // Add REST datasource - contains API for breweries + const datasource = "REST" + const restUrl = "https://api.openbrewerydb.org/breweries" + cy.selectExternalDatasource(datasource) + cy.createRestQuery("GET", restUrl, "/breweries") + cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click() + // Get Transformer Function from file + cy.readFile("cypress/support/queryLevelTransformerFunction.js").then( + transformerFunction => { cy.get(".CodeMirror textarea") - // Highlight current text and overwrite with file contents - .type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true }) - .type(transformerFunction, { parseSpecialCharSequences: false }) - }) - // Send Query - cy.intercept('**/queries/preview').as('query') - cy.get(".spectrum-Button").contains("Send").click({ force: true }) - cy.wait("@query") - // Assert against Status Code, body, & body rows - cy.get("@query").its('response.statusCode') - .should('eq', 200) - cy.get("@query").its('response.body').should('not.be.empty') - cy.get("@query").its('response.body.rows').should('not.be.empty') - }) - + // Highlight current text and overwrite with file contents + .type(Cypress.platform === "darwin" ? "{cmd}a" : "{ctrl}a", { + force: true, + }) + .type(transformerFunction, { parseSpecialCharSequences: false }) + } + ) + // Send Query + cy.intercept("**/queries/preview").as("query") + cy.get(".spectrum-Button").contains("Send").click({ force: true }) + cy.wait("@query") + // Assert against Status Code, body, & body rows + cy.get("@query").its("response.statusCode").should("eq", 200) + cy.get("@query").its("response.body").should("not.be.empty") + cy.get("@query").its("response.body.rows").should("not.be.empty") + }) + it("should add data to the previous query", () => { // Add REST datasource - contains API for breweries const datasource = "REST" const restUrl = "https://api.openbrewerydb.org/breweries" cy.selectExternalDatasource(datasource) - cy.createRestQuery("GET", restUrl) + cy.createRestQuery("GET", restUrl, "/breweries") cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click() // Get Transformer Function with Data from file - cy.readFile("cypress/support/queryLevelTransformerFunctionWithData.js").then((transformerFunction) => { + cy.readFile( + "cypress/support/queryLevelTransformerFunctionWithData.js" + ).then(transformerFunction => { //console.log(transformerFunction[1]) cy.get(".CodeMirror textarea") - // Highlight current text and overwrite with file contents - .type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true }) - .type(transformerFunction, { parseSpecialCharSequences: false }) + // Highlight current text and overwrite with file contents + .type(Cypress.platform === "darwin" ? "{cmd}a" : "{ctrl}a", { + force: true, + }) + .type(transformerFunction, { parseSpecialCharSequences: false }) }) // Send Query - cy.intercept('**/queries/preview').as('query') + cy.intercept("**/queries/preview").as("query") cy.get(".spectrum-Button").contains("Send").click({ force: true }) cy.wait("@query") // Assert against Status Code, body, & body rows - cy.get("@query").its('response.statusCode') - .should('eq', 200) - cy.get("@query").its('response.body').should('not.be.empty') - cy.get("@query").its('response.body.rows').should('not.be.empty') + cy.get("@query").its("response.statusCode").should("eq", 200) + cy.get("@query").its("response.body").should("not.be.empty") + cy.get("@query").its("response.body.rows").should("not.be.empty") }) - + it("should run an invalid query within the transformer section", () => { // Add REST datasource - contains API for breweries const datasource = "REST" const restUrl = "https://api.openbrewerydb.org/breweries" cy.selectExternalDatasource(datasource) - cy.createRestQuery("GET", restUrl) + cy.createRestQuery("GET", restUrl, "/breweries") cy.get(".spectrum-Tabs-itemLabel").contains("Transformer").click() // Clear the code box and add "test" cy.get(".CodeMirror textarea") - .type(Cypress.platform === 'darwin' ? '{cmd}a' : '{ctrl}a', { force: true }) - .type("test") + .type(Cypress.platform === "darwin" ? "{cmd}a" : "{ctrl}a", { + force: true, + }) + .type("test") // Run Query and intercept - cy.intercept('**/preview').as('queryError') + cy.intercept("**/preview").as("queryError") cy.get(".spectrum-Button").contains("Send").click({ force: true }) cy.wait("@queryError") cy.wait(500) // Assert against message and status for the query error - cy.get("@queryError").its('response.body').should('have.property', 'message', "test is not defined") - cy.get("@queryError").its('response.body').should('have.property', 'status', 400) + cy.get("@queryError") + .its("response.body") + .should("have.property", "message", "test is not defined") + cy.get("@queryError") + .its("response.body") + .should("have.property", "status", 400) }) - + xit("should run an invalid query via POST request", () => { // POST request with transformer as null - cy.request({method: 'POST', - url: `${Cypress.config().baseUrl}/api/queries/`, - body: {fields : {"headers":{},"queryString":null,"path":null}, - parameters : [], - schema : {}, - name : "test", - queryVerb : "read", - transformer : null, - datasourceId: "test"}, - // Expected 400 error - Transformer must be a string - failOnStatusCode: false}).then((response) => { + cy.request({ + method: "POST", + url: `${Cypress.config().baseUrl}/api/queries/`, + body: { + fields: { headers: {}, queryString: null, path: null }, + parameters: [], + schema: {}, + name: "test", + queryVerb: "read", + transformer: null, + datasourceId: "test", + }, + // Expected 400 error - Transformer must be a string + failOnStatusCode: false, + }).then(response => { expect(response.status).to.equal(400) - expect(response.body.message).to.include('Invalid body - "transformer" must be a string') + expect(response.body.message).to.include( + 'Invalid body - "transformer" must be a string' + ) }) }) - + xit("should run an empty query", () => { // POST request with Transformer as an empty string - cy.request({method: 'POST', - url: `${Cypress.config().baseUrl}/api/queries/preview`, - body: {fields : {"headers":{},"queryString":null,"path":null}, - queryVerb : "read", - transformer : "", - datasourceId: "test"}, - // Expected 400 error - Transformer is not allowed to be empty - failOnStatusCode: false}).then((response) => { + cy.request({ + method: "POST", + url: `${Cypress.config().baseUrl}/api/queries/preview`, + body: { + fields: { headers: {}, queryString: null, path: null }, + queryVerb: "read", + transformer: "", + datasourceId: "test", + }, + // Expected 400 error - Transformer is not allowed to be empty + failOnStatusCode: false, + }).then(response => { expect(response.status).to.equal(400) - expect(response.body.message).to.include('Invalid body - "transformer" is not allowed to be empty') + expect(response.body.message).to.include( + 'Invalid body - "transformer" is not allowed to be empty' + ) }) }) }) diff --git a/packages/builder/cypress/setup.js b/packages/builder/cypress/setup.js index ca5a65c7f5..e19c931ed9 100644 --- a/packages/builder/cypress/setup.js +++ b/packages/builder/cypress/setup.js @@ -4,17 +4,17 @@ const path = require("path") const tmpdir = path.join(require("os").tmpdir(), ".budibase") // normal development system -const WORKER_PORT = "10002" -const MAIN_PORT = cypressConfig.env.PORT +const SERVER_PORT = cypressConfig.env.PORT +const WORKER_PORT = cypressConfig.env.WORKER_PORT + process.env.BUDIBASE_API_KEY = "6BE826CB-6B30-4AEC-8777-2E90464633DE" process.env.NODE_ENV = "cypress" process.env.ENABLE_ANALYTICS = "false" -process.env.PORT = MAIN_PORT process.env.JWT_SECRET = cypressConfig.env.JWT_SECRET process.env.COUCH_URL = `leveldb://${tmpdir}/.data/` process.env.SELF_HOSTED = 1 -process.env.WORKER_URL = "http://localhost:10002/" -process.env.APPS_URL = `http://localhost:${MAIN_PORT}/` +process.env.WORKER_URL = `http://localhost:${WORKER_PORT}/` +process.env.APPS_URL = `http://localhost:${SERVER_PORT}/` process.env.MINIO_URL = `http://localhost:4004` process.env.MINIO_ACCESS_KEY = "budibase" process.env.MINIO_SECRET_KEY = "budibase" @@ -33,11 +33,14 @@ exports.run = ( // require("dotenv").config({ path: resolve(dir, ".env") }) // don't make this a variable or top level require // it will cause environment module to be loaded prematurely - require(serverLoc) + + // override the port with the worker port temporarily process.env.PORT = WORKER_PORT require(workerLoc) - // reload main port for rest of system - process.env.PORT = MAIN_PORT + + // override the port with the server port + process.env.PORT = SERVER_PORT + require(serverLoc) } if (require.main === module) { diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index 40fe6706c9..ede1038a58 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -39,7 +39,7 @@ Cypress.Commands.add("createApp", name => { cy.get(".spectrum-Modal").within(() => { cy.get("input").eq(0).type(name).should("have.value", name).blur() cy.get(".spectrum-ButtonGroup").contains("Create app").click() - cy.wait(5000) + cy.wait(10000) }) cy.createTable("Cypress Tests", true) }) @@ -116,10 +116,10 @@ Cypress.Commands.add("createTestTableWithData", () => { Cypress.Commands.add("createTable", (tableName, initialTable) => { if (!initialTable) { cy.navigateToDataSection() - cy.get(".add-button").click() + cy.get(`[data-cy="new-table"]`).click() } - cy.wait(7000) - cy.get(".spectrum-Modal") + cy.wait(5000) + cy.get(".spectrum-Dialog-grid") .contains("Budibase DB") .click({ force: true }) .then(() => { @@ -172,17 +172,19 @@ Cypress.Commands.add("addRow", values => { Cypress.Commands.add("addRowMultiValue", values => { cy.contains("Create row").click() - cy.get(".spectrum-Form-itemField") - .click() - .then(() => { - cy.get(".spectrum-Popover").within(() => { - for (let i = 0; i < values.length; i++) { - cy.get(".spectrum-Menu-item").eq(i).click() - } + cy.get(".spectrum-Modal").within(() => { + cy.get(".spectrum-Form-itemField") + .click() + .then(() => { + cy.get(".spectrum-Popover").within(() => { + for (let i = 0; i < values.length; i++) { + cy.get(".spectrum-Menu-item").eq(i).click() + } + }) + cy.get(".spectrum-Dialog-grid").click("top") + cy.get(".spectrum-ButtonGroup").contains("Create").click() }) - cy.get(".spectrum-Dialog-grid").click("top") - cy.get(".spectrum-ButtonGroup").contains("Create").click() - }) + }) }) Cypress.Commands.add("createUser", email => { @@ -435,7 +437,7 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => { } }) -Cypress.Commands.add("createRestQuery", (method, restUrl) => { +Cypress.Commands.add("createRestQuery", (method, restUrl, queryPrettyName) => { // addExternalDatasource should be called prior to this // Configures REST datasource & sends query cy.wait(1000) @@ -450,5 +452,5 @@ Cypress.Commands.add("createRestQuery", (method, restUrl) => { cy.get(".spectrum-Button").contains("Save").click({ force: true }) cy.get(".hierarchy-items-container") .should("contain", method) - .and("contain", restUrl) + .and("contain", queryPrettyName) }) diff --git a/packages/builder/package.json b/packages/builder/package.json index c4f4a74854..67d9f5c107 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -11,12 +11,13 @@ "rollup": "rollup -c -w", "cy:setup": "ts-node ./cypress/ts/setup.ts", "cy:setup:ci": "node ./cypress/setup.js", - "cy:run": "xvfb-run cypress run --headed --browser chrome", "cy:open": "cypress open", - "cy:run:ci": "cypress run --record", - "cy:test": "start-server-and-test cy:setup http://localhost:10001/builder cy:run", - "cy:ci": "start-server-and-test cy:setup:ci http://localhost:10001/builder cy:run", - "cy:debug": "start-server-and-test cy:setup http://localhost:10001/builder cy:open" + "cy:run": "cypress run", + "cy:run:ci": "xvfb-run cypress run --headed --browser chrome", + "cy:test": "start-server-and-test cy:setup http://localhost:4100/builder cy:run", + "cy:ci": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:run:ci", + "cy:debug": "start-server-and-test cy:setup http://localhost:4100/builder cy:open", + "cy:debug:ci": "start-server-and-test cy:setup:ci http://localhost:4100/builder cy:open" }, "jest": { "globals": { diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index edb12c7e74..5b9bebcbf5 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -32,12 +32,14 @@ export const getBindableProperties = (asset, componentId) => { const urlBindings = getUrlBindings(asset) const deviceBindings = getDeviceBindings() const stateBindings = getStateBindings() + const selectedRowsBindings = getSelectedRowsBindings(asset) return [ ...contextBindings, ...urlBindings, ...stateBindings, ...userBindings, ...deviceBindings, + ...selectedRowsBindings, ] } @@ -315,6 +317,44 @@ const getDeviceBindings = () => { return bindings } +/** + * Gets all selected rows bindings for tables in the current asset. + */ +const getSelectedRowsBindings = asset => { + let bindings = [] + if (get(store).clientFeatures?.rowSelection) { + // Add bindings for table components + let tables = findAllMatchingComponents(asset?.props, component => + component._component.endsWith("table") + ) + const safeState = makePropSafe("rowSelection") + bindings = bindings.concat( + tables.map(table => ({ + type: "context", + runtimeBinding: `${safeState}.${makePropSafe(table._id)}.${makePropSafe( + "selectedRows" + )}`, + readableBinding: `${table._instanceName}.Selected rows`, + })) + ) + + // Add bindings for table blocks + let tableBlocks = findAllMatchingComponents(asset?.props, component => + component._component.endsWith("tableblock") + ) + bindings = bindings.concat( + tableBlocks.map(block => ({ + type: "context", + runtimeBinding: `${safeState}.${makePropSafe( + block._id + "-table" + )}.${makePropSafe("selectedRows")}`, + readableBinding: `${block._instanceName}.Selected rows`, + })) + ) + } + return bindings +} + /** * Gets all state bindings that are globally available. */ @@ -597,14 +637,9 @@ const buildFormSchema = component => { * in the app. */ export const getAllStateVariables = () => { - // Get all component containing assets - let allAssets = [] - allAssets = allAssets.concat(get(store).layouts || []) - allAssets = allAssets.concat(get(store).screens || []) - // Find all button action settings in all components let eventSettings = [] - allAssets.forEach(asset => { + getAllAssets().forEach(asset => { findAllMatchingComponents(asset.props, component => { const settings = getComponentSettings(component._component) settings @@ -635,6 +670,15 @@ export const getAllStateVariables = () => { return Array.from(bindingSet) } +export const getAllAssets = () => { + // Get all component containing assets + let allAssets = [] + allAssets = allAssets.concat(get(store).layouts || []) + allAssets = allAssets.concat(get(store).screens || []) + + return allAssets +} + /** * Recurses the input object to remove any instances of bindings. */ diff --git a/packages/builder/src/builderStore/store/automation/index.js b/packages/builder/src/builderStore/store/automation/index.js index b901a71cb1..84e6033439 100644 --- a/packages/builder/src/builderStore/store/automation/index.js +++ b/packages/builder/src/builderStore/store/automation/index.js @@ -57,6 +57,7 @@ const automationActions = store => ({ return state }) }, + save: async automation => { const response = await API.updateAutomation(automation) store.update(state => { @@ -130,6 +131,12 @@ const automationActions = store => ({ name: block.name, }) }, + toggleFieldControl: value => { + store.update(state => { + state.selectedBlock.rowControl = value + return state + }) + }, deleteAutomationBlock: block => { store.update(state => { const idx = diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 9ce66db3c0..d8118c9c60 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -41,6 +41,7 @@ const INITIAL_FRONTEND_STATE = { intelligentLoading: false, deviceAwareness: false, state: false, + rowSelection: false, customThemes: false, devicePreview: false, messagePassing: false, diff --git a/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte b/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte index 7ce77a58e3..e852ee1a0d 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/AutomationBuilder.svelte @@ -3,14 +3,8 @@ import Flowchart from "./FlowChart/FlowChart.svelte" $: automation = $automationStore.selectedAutomation?.automation - function onSelect(block) { - automationStore.update(state => { - state.selectedBlock = block - return state - }) - } {#if automation} - + {/if} diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte index 777fcd710a..ca04fed8df 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte @@ -14,7 +14,7 @@ } from "@budibase/bbui" export let automation - export let onSelect + let testDataModal let blocks let confirmDeleteDialog @@ -45,7 +45,7 @@
{automation.name} -
+
- +
{/each}
diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte index f13a827f31..69dd67724a 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte @@ -10,6 +10,7 @@ Button, StatusLight, ActionButton, + Select, notifications, } from "@budibase/bbui" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" @@ -18,7 +19,6 @@ import ActionModal from "./ActionModal.svelte" import { externalActions } from "./ExternalActions" - export let onSelect export let block export let testDataModal let selected @@ -28,6 +28,10 @@ let setupToggled let blockComplete + $: rowControl = $automationStore.selectedAutomation.automation.rowControl + $: showBindingPicker = + block.stepId === "CREATE_ROW" || block.stepId === "UPDATE_ROW" + $: testResult = $automationStore.selectedAutomation.testResults?.steps.filter( step => (block.id ? step.id === block.id : step.stepId === block.stepId) ) @@ -44,12 +48,6 @@ $automationStore.selectedAutomation?.automation?.definition?.steps.length + 1 - // Logic for hiding / showing the add button.first we check if it has a child - // then we check to see whether its inputs have been commpleted - $: disableAddButton = isTrigger - ? $automationStore.selectedAutomation?.automation?.definition?.steps - .length > 0 - : !isTrigger && steps.length - blockIdx > 1 $: hasCompletedInputs = Object.keys( block.schema?.inputs?.properties || {} ).every(x => block?.inputs[x]) @@ -64,6 +62,26 @@ notifications.error("Error saving notification") } } + function toggleFieldControl(evt) { + onSelect(block) + let rowControl + if (evt.detail === "Use values") { + rowControl = false + } else { + rowControl = true + } + automationStore.actions.toggleFieldControl(rowControl) + automationStore.actions.save( + $automationStore.selectedAutomation?.automation + ) + } + + async function onSelect(block) { + await automationStore.update(state => { + state.selectedBlock = block + return state + }) + }
(setupToggled = !setupToggled)} + on:click={() => { + onSelect(block) + setupToggled = !setupToggled + }} quiet icon={setupToggled ? "ChevronDown" : "ChevronRight"} > Setup {#if !isTrigger} -
deleteStep()}> - +
+ {#if showBindingPicker} +
+ {#each schemaFields as [field, schema]} {#if !schema.autocolumn} - {#if schemaHasOptions(schema) && schema.type !== "array"} -