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}
-
+
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
+ })
+ }