diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2a57d6f388..bd21123709 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -93,6 +93,8 @@ then `cd ` into your local copy. #### 3. Install and Build +| **NOTE**: On Windows, all yarn commands must be executed on a bash shell (e.g. git bash) + To develop the Budibase platform you'll need [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) installed. ##### Quick method diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d1ba2bc046..b4f7739293 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,15 @@ assignees: '' --- +**Hosting** + +- Self + - Method: + - Budibase Version: + - App Version: +- Cloud + - Tenant ID: + **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/stale.yml b/.github/stale.yml index 5875ed1282..2a2c10cb7d 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -14,7 +14,6 @@ staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. + recent activity. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index cb27b30f3f..0881d2528d 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -12,6 +12,11 @@ on: - master - develop +env: + BRANCH: ${{ github.event.pull_request.head.ref }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref}} + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} + jobs: build: runs-on: ubuntu-latest @@ -27,6 +32,10 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + + - name: Install Pro + run: yarn install:pro $BRANCH $BASE_BRANCH + - run: yarn - run: yarn bootstrap - run: yarn lint diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 4467cd6c81..87e4f0988a 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -19,6 +19,7 @@ env: POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }} INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }} + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} jobs: release: @@ -29,6 +30,10 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 14.x + + - name: Install Pro + run: yarn install:pro develop + - run: yarn - run: yarn bootstrap - run: yarn lint @@ -46,9 +51,9 @@ jobs: env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - # setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default - git config user.name "Budibase Staging Release Bot" - git config user.email "<>" + # setup the username and email. + git config --global user.name "Budibase Staging Release Bot" + git config --global user.email "<>" echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc yarn release:develop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82848c78e4..b3b2b01316 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,7 @@ env: INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} jobs: release: @@ -30,6 +31,10 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 14.x + + - name: Install Pro + run: yarn install:pro master + - run: yarn - run: yarn bootstrap - run: yarn lint @@ -66,3 +71,57 @@ jobs: DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }} + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + + - name: Tag and release Proxy service docker image + run: | + docker login -u $DOCKER_USER -p $DOCKER_PASSWORD + yarn build:docker:proxy:preprod + docker tag proxy-service 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 }}" \ + -H 'Accept: application/vnd.github.v3.raw' \ + -o values.preprod.yaml \ + -L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml + wc -l values.preprod.yaml + + - name: Deploy to Preprod Environment + uses: glopezep/helm@v1.7.1 + with: + release: budibase-preprod + namespace: budibase + chart: charts/budibase + token: ${{ github.token }} + helm: helm3 + values: | + globals: + appVersion: ${{ steps.previoustag.outputs.tag }} + ingress: + enabled: true + nginx: true + value-files: >- + [ + "values.preprod.yaml" + ] + env: + KUBECONFIG_FILE: '${{ secrets.PREPROD_KUBECONFIG }}' + + - name: Discord Webhook Action + uses: tsickert/discord-webhook@v4.0.0 + with: + webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }} + content: "Preprod Deployment Complete: ${{ steps.previoustag.outputs.tag }} deployed to Budibase Pre-prod." + embed-title: ${{ steps.previoustag.outputs.tag }} diff --git a/charts/budibase/Chart.yaml b/charts/budibase/Chart.yaml index 134d29441f..694c8c77fe 100644 --- a/charts/budibase/Chart.yaml +++ b/charts/budibase/Chart.yaml @@ -11,7 +11,7 @@ sources: - https://github.com/Budibase/budibase - https://budibase.com type: application -version: 0.2.8 +version: 0.2.9 appVersion: 1.0.48 dependencies: - name: couchdb diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index d80d213416..c80cfa2ecc 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -110,12 +110,23 @@ spec: value: {{ .Values.globals.google.clientId | quote }} - name: GOOGLE_CLIENT_SECRET value: {{ .Values.globals.google.secret | quote }} + - name: AUTOMATION_MAX_ITERATIONS + value: {{ .Values.globals.automationMaxIterations | quote }} + image: budibase/apps:{{ .Values.globals.appVersion }} imagePullPolicy: Always name: bbapps ports: - containerPort: {{ .Values.services.apps.port }} resources: {} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" status: {} diff --git a/charts/budibase/templates/couchdb-backup.yaml b/charts/budibase/templates/couchdb-backup.yaml index 1072046c8c..ae062475ce 100644 --- a/charts/budibase/templates/couchdb-backup.yaml +++ b/charts/budibase/templates/couchdb-backup.yaml @@ -39,5 +39,13 @@ spec: imagePullPolicy: Always name: couchdb-backup resources: {} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} status: {} {{- end }} diff --git a/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml b/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml index d122ad0a3e..7a6e05a66a 100644 --- a/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml +++ b/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml @@ -12,5 +12,10 @@ spec: resources: requests: storage: {{ .Values.services.objectStore.storage }} + {{- if (eq "-" .Values.services.objectStore.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: "{{ .Values.services.objectStore.storageClass }}" + {{- end }} status: {} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/budibase/templates/minio-service-deployment.yaml b/charts/budibase/templates/minio-service-deployment.yaml index a23d0c1d89..901fb61ad9 100644 --- a/charts/budibase/templates/minio-service-deployment.yaml +++ b/charts/budibase/templates/minio-service-deployment.yaml @@ -60,6 +60,14 @@ spec: volumeMounts: - mountPath: /data name: minio-data + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" volumes: diff --git a/charts/budibase/templates/proxy-service-deployment.yaml b/charts/budibase/templates/proxy-service-deployment.yaml index 05bf4a7f1a..bd6a5e311f 100644 --- a/charts/budibase/templates/proxy-service-deployment.yaml +++ b/charts/budibase/templates/proxy-service-deployment.yaml @@ -32,6 +32,14 @@ spec: - containerPort: {{ .Values.services.proxy.port }} resources: {} volumeMounts: + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" volumes: diff --git a/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml b/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml index 2cb5ee8eab..5f063dc664 100644 --- a/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml +++ b/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml @@ -12,5 +12,10 @@ spec: resources: requests: storage: {{ .Values.services.redis.storage }} + {{- if (eq "-" .Values.services.redis.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: "{{ .Values.services.redis.storageClass }}" + {{- end }} status: {} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/budibase/templates/redis-service-deployment.yaml b/charts/budibase/templates/redis-service-deployment.yaml index 9235b0b11d..0b6cb12562 100644 --- a/charts/budibase/templates/redis-service-deployment.yaml +++ b/charts/budibase/templates/redis-service-deployment.yaml @@ -39,6 +39,14 @@ spec: volumeMounts: - mountPath: /data name: redis-data + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" volumes: diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index b6c757cb9f..c2180aca2b 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -121,6 +121,14 @@ spec: ports: - containerPort: {{ .Values.services.worker.port }} resources: {} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" status: {} diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 1902cfe684..5ada89de6c 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -47,6 +47,8 @@ ingress: className: "" annotations: kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/client-max-body-size: 150M + nginx.ingress.kubernetes.io/proxy-body-size: 50m hosts: - host: # change if using custom domain paths: @@ -101,6 +103,7 @@ globals: google: clientId: "" secret: "" + automationMaxIterations: "500" createSecrets: true # creates an internal API key, JWT secrets and redis password for you @@ -148,6 +151,11 @@ services: url: "" # only change if pointing to existing redis cluster and enabled: false password: "budibase" # recommended to override if using built-in redis storage: 100Mi + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. + storageClass: "-" objectStore: minio: true @@ -159,6 +167,11 @@ services: region: "" # AWS_REGION if using S3 or existing minio secret url: "http://minio-service:9000" # only change if pointing to existing minio cluster or S3 and minio: false storage: 100Mi + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. + storageClass: "-" # Override values in couchDB subchart couchdb: @@ -228,6 +241,8 @@ couchdb: ## Optional tolerations tolerations: [] + affinity: {} + service: # annotations: enabled: true diff --git a/hosting/docker-compose.dev.yaml b/hosting/docker-compose.dev.yaml index 43b8526e9e..be0bc74a26 100644 --- a/hosting/docker-compose.dev.yaml +++ b/hosting/docker-compose.dev.yaml @@ -27,6 +27,7 @@ services: image: nginx:latest volumes: - ./.generated-nginx.dev.conf:/etc/nginx/nginx.conf + - ./proxy/error.html:/usr/share/nginx/html/error.html ports: - "${MAIN_PORT}:10000" depends_on: diff --git a/hosting/nginx.dev.conf.hbs b/hosting/nginx.dev.conf.hbs index 9fc2345fb2..9398b7e719 100644 --- a/hosting/nginx.dev.conf.hbs +++ b/hosting/nginx.dev.conf.hbs @@ -28,6 +28,12 @@ http { ignore_invalid_headers off; proxy_buffering off; + error_page 502 503 504 /error.html; + location = /error.html { + root /usr/share/nginx/html; + internal; + } + location /db/ { proxy_pass http://couchdb-service:5984; rewrite ^/db/(.*)$ /$1 break; diff --git a/hosting/nginx.prod.conf.hbs b/hosting/nginx.prod.conf.hbs index 88570a4a2d..7ef597051b 100644 --- a/hosting/nginx.prod.conf.hbs +++ b/hosting/nginx.prod.conf.hbs @@ -56,6 +56,12 @@ http { set $csp_media "media-src 'self' https://js.intercomcdn.com"; set $csp_worker "worker-src 'none'"; + error_page 502 503 504 /error.html; + location = /error.html { + root /usr/share/nginx/html; + internal; + } + # Security Headers add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; diff --git a/hosting/proxy/Dockerfile b/hosting/proxy/Dockerfile index b577e3e40f..a2b17d3333 100644 --- a/hosting/proxy/Dockerfile +++ b/hosting/proxy/Dockerfile @@ -1,2 +1,3 @@ FROM nginx:latest -COPY .generated-nginx.prod.conf /etc/nginx/nginx.conf \ No newline at end of file +COPY .generated-nginx.prod.conf /etc/nginx/nginx.conf +COPY error.html /usr/share/nginx/html/error.html \ No newline at end of file diff --git a/hosting/proxy/error.html b/hosting/proxy/error.html new file mode 100644 index 0000000000..023c1ebaff --- /dev/null +++ b/hosting/proxy/error.html @@ -0,0 +1,175 @@ + + + + + Budibase + + + + + + + + + + +
+
+
+ Budibase Logo +
+
+
+

+

+ Houston we have a problem! +

+

+

+
+
+ + +
+
+
+
+ + + \ No newline at end of file diff --git a/lerna.json b/lerna.json index 157d6fe849..3f69ccefda 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.0.105-alpha.10", + "version": "1.0.124-alpha.0", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index ad1b1c4c6f..727104d830 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,12 @@ }, "scripts": { "setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev", - "bootstrap": "lerna link && lerna bootstrap", + "bootstrap": "lerna link && lerna bootstrap && ./scripts/link-dependencies.sh", "build": "lerna run build", - "publishnpm": "yarn build && lerna publish --force-publish", - "release": "lerna publish patch --yes --force-publish", - "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop", + "release": "lerna publish patch --yes --force-publish && yarn release:pro", + "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop && yarn release:pro:develop", + "release:pro": "bash scripts/pro/release.sh", + "release:pro:develop": "bash scripts/pro/release.sh develop", "restore": "yarn run clean && yarn run bootstrap && yarn run build", "nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke:packages": "yarn run restore", @@ -72,6 +73,8 @@ "mode:cloud": "yarn env:selfhost:disable && yarn env:multi:enable && yarn env:account:disable", "mode:account": "yarn mode:cloud && yarn env:account:enable", "security:audit": "node scripts/audit.js", - "postinstall": "husky install" + "postinstall": "husky install", + "install:pro": "bash scripts/pro/install.sh", + "dep:clean": "yarn clean && yarn bootstrap" } } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 118ce19dd3..534d32d0b8 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.0.105-alpha.10", + "version": "1.0.124-alpha.0", "description": "Budibase backend core libraries used in server and worker", "main": "src/index.js", "author": "Budibase", @@ -25,6 +25,7 @@ "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", "posthog-node": "^1.3.0", + "pouchdb": "7.3.0", "pouchdb-find": "^7.2.2", "pouchdb-replication-stream": "^1.2.9", "sanitize-s3-objectkey": "^0.0.1", @@ -41,7 +42,6 @@ "@shopify/jest-koa-mocks": "^3.1.5", "ioredis-mock": "^5.5.5", "jest": "^26.6.3", - "pouchdb": "^7.2.1", "pouchdb-adapter-memory": "^7.2.2", "pouchdb-all-dbs": "^1.0.2", "timekeeper": "^2.2.0" diff --git a/packages/backend-core/src/cache/appMetadata.js b/packages/backend-core/src/cache/appMetadata.js index 15a25006ab..effdc886d7 100644 --- a/packages/backend-core/src/cache/appMetadata.js +++ b/packages/backend-core/src/cache/appMetadata.js @@ -1,5 +1,5 @@ const redis = require("../redis/authRedis") -const { getDB } = require("../db") +const { doWithDB } = require("../db") const { DocumentTypes } = require("../db/constants") const AppState = { @@ -11,8 +11,13 @@ const EXPIRY_SECONDS = 3600 * The default populate app metadata function */ const populateFromDB = async appId => { - const db = getDB(appId, { skip_setup: true }) - return db.get(DocumentTypes.APP_METADATA) + return doWithDB( + appId, + db => { + return db.get(DocumentTypes.APP_METADATA) + }, + { skip_setup: true } + ) } const isInvalid = metadata => { diff --git a/packages/backend-core/src/cache/user.js b/packages/backend-core/src/cache/user.js index b10f854002..faac6de725 100644 --- a/packages/backend-core/src/cache/user.js +++ b/packages/backend-core/src/cache/user.js @@ -1,5 +1,5 @@ const redis = require("../redis/authRedis") -const { getTenantId, lookupTenantId, getGlobalDB } = require("../tenancy") +const { getTenantId, lookupTenantId, doWithGlobalDB } = require("../tenancy") const env = require("../environment") const accounts = require("../cloud/accounts") @@ -9,9 +9,8 @@ const EXPIRY_SECONDS = 3600 * The default populate user function */ const populateFromDB = async (userId, tenantId) => { - const user = await getGlobalDB(tenantId).get(userId) + const user = await doWithGlobalDB(tenantId, db => db.get(userId)) user.budibaseAccess = true - if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { const account = await accounts.getAccount(user.email) if (account) { diff --git a/packages/backend-core/src/cloud/api.js b/packages/backend-core/src/cloud/api.js index ffa785d02a..d4d4b6c8bb 100644 --- a/packages/backend-core/src/cloud/api.js +++ b/packages/backend-core/src/cloud/api.js @@ -29,9 +29,7 @@ class API { credentials: "include", } - const resp = await fetch(`${this.host}${url}`, requestOptions) - - return resp + return await fetch(`${this.host}${url}`, requestOptions) } post = this.apiCall("POST") diff --git a/packages/backend-core/src/constants.js b/packages/backend-core/src/constants.js index 1becb2e298..172e66e603 100644 --- a/packages/backend-core/src/constants.js +++ b/packages/backend-core/src/constants.js @@ -17,6 +17,7 @@ exports.Headers = { API_VER: "x-budibase-api-version", APP_ID: "x-budibase-app-id", TYPE: "x-budibase-type", + PREVIEW_ROLE: "x-budibase-role", TENANT_ID: "x-budibase-tenant-id", TOKEN: "x-budibase-token", CSRF_TOKEN: "x-csrf-token", diff --git a/packages/backend-core/src/context/FunctionContext.js b/packages/backend-core/src/context/FunctionContext.js index 1a3f65056e..34d39492f9 100644 --- a/packages/backend-core/src/context/FunctionContext.js +++ b/packages/backend-core/src/context/FunctionContext.js @@ -4,7 +4,11 @@ const { newid } = require("../hashing") const REQUEST_ID_KEY = "requestId" class FunctionContext { - static getMiddleware(updateCtxFn = null, contextName = "session") { + static getMiddleware( + updateCtxFn = null, + destroyFn = null, + contextName = "session" + ) { const namespace = this.createNamespace(contextName) return async function (ctx, next) { @@ -18,7 +22,14 @@ class FunctionContext { if (updateCtxFn) { updateCtxFn(ctx) } - next().then(resolve).catch(reject) + next() + .then(resolve) + .catch(reject) + .finally(() => { + if (destroyFn) { + return destroyFn(ctx) + } + }) }) ) } diff --git a/packages/backend-core/src/context/deprovision.js b/packages/backend-core/src/context/deprovision.js index 9f89dbbfa9..ba3c2d8449 100644 --- a/packages/backend-core/src/context/deprovision.js +++ b/packages/backend-core/src/context/deprovision.js @@ -1,6 +1,6 @@ const { getGlobalUserParams, getAllApps } = require("../db/utils") -const { getDB } = require("../db") -const { getGlobalDB } = require("../tenancy") +const { doWithDB } = require("../db") +const { doWithGlobalDB } = require("../tenancy") const { StaticDatabases } = require("../db/constants") const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants @@ -8,11 +8,12 @@ const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name const removeTenantFromInfoDB = async tenantId => { try { - const infoDb = getDB(PLATFORM_INFO_DB) - let tenants = await infoDb.get(TENANT_DOC) - tenants.tenantIds = tenants.tenantIds.filter(id => id !== tenantId) + await doWithDB(PLATFORM_INFO_DB, async infoDb => { + let tenants = await infoDb.get(TENANT_DOC) + tenants.tenantIds = tenants.tenantIds.filter(id => id !== tenantId) - await infoDb.put(tenants) + await infoDb.put(tenants) + }) } catch (err) { console.error(`Error removing tenant ${tenantId} from info db`, err) throw err @@ -20,36 +21,8 @@ const removeTenantFromInfoDB = async tenantId => { } exports.removeUserFromInfoDB = async dbUser => { - const infoDb = getDB(PLATFORM_INFO_DB) - const keys = [dbUser._id, dbUser.email] - const userDocs = await infoDb.allDocs({ - keys, - include_docs: true, - }) - const toDelete = userDocs.rows.map(row => { - return { - ...row.doc, - _deleted: true, - } - }) - await infoDb.bulkDocs(toDelete) -} - -const removeUsersFromInfoDB = async tenantId => { - try { - const globalDb = getGlobalDB(tenantId) - const infoDb = getDB(PLATFORM_INFO_DB) - const allUsers = await globalDb.allDocs( - getGlobalUserParams(null, { - include_docs: true, - }) - ) - const allEmails = allUsers.rows.map(row => row.doc.email) - // get the id docs - let keys = allUsers.rows.map(row => row.id) - // and the email docs - keys = keys.concat(allEmails) - // retrieve the docs and delete them + await doWithDB(PLATFORM_INFO_DB, async infoDb => { + const keys = [dbUser._id, dbUser.email] const userDocs = await infoDb.allDocs({ keys, include_docs: true, @@ -61,26 +34,60 @@ const removeUsersFromInfoDB = async tenantId => { } }) await infoDb.bulkDocs(toDelete) - } catch (err) { - console.error(`Error removing tenant ${tenantId} users from info db`, err) - throw err - } + }) +} + +const removeUsersFromInfoDB = async tenantId => { + return doWithGlobalDB(tenantId, async db => { + try { + const allUsers = await db.allDocs( + getGlobalUserParams(null, { + include_docs: true, + }) + ) + await doWithDB(PLATFORM_INFO_DB, async infoDb => { + const allEmails = allUsers.rows.map(row => row.doc.email) + // get the id docs + let keys = allUsers.rows.map(row => row.id) + // and the email docs + keys = keys.concat(allEmails) + // retrieve the docs and delete them + const userDocs = await infoDb.allDocs({ + keys, + include_docs: true, + }) + const toDelete = userDocs.rows.map(row => { + return { + ...row.doc, + _deleted: true, + } + }) + await infoDb.bulkDocs(toDelete) + }) + } catch (err) { + console.error(`Error removing tenant ${tenantId} users from info db`, err) + throw err + } + }) } const removeGlobalDB = async tenantId => { - try { - const globalDb = getGlobalDB(tenantId) - await globalDb.destroy() - } catch (err) { - console.error(`Error removing tenant ${tenantId} users from info db`, err) - throw err - } + return doWithGlobalDB(tenantId, async db => { + try { + await db.destroy() + } catch (err) { + console.error(`Error removing tenant ${tenantId} users from info db`, err) + throw err + } + }) } const removeTenantApps = async tenantId => { try { const apps = await getAllApps({ all: true }) - const destroyPromises = apps.map(app => getDB(app.appId).destroy()) + const destroyPromises = apps.map(app => + doWithDB(app.appId, db => db.destroy()) + ) await Promise.allSettled(destroyPromises) } catch (err) { console.error(`Error removing tenant ${tenantId} apps`, err) diff --git a/packages/backend-core/src/context/index.js b/packages/backend-core/src/context/index.js index 4f49ebb298..b6b6f2380c 100644 --- a/packages/backend-core/src/context/index.js +++ b/packages/backend-core/src/context/index.js @@ -1,9 +1,11 @@ const env = require("../environment") const { Headers } = require("../../constants") const { SEPARATOR, DocumentTypes } = require("../db/constants") +const { DEFAULT_TENANT_ID } = require("../constants") const cls = require("./FunctionContext") -const { getDB } = require("../db") +const { dangerousGetDB, closeDB } = require("../db") const { getProdAppID, getDevelopmentAppID } = require("../db/conversions") +const { baseGlobalDBName } = require("../tenancy/utils") const { isEqual } = require("lodash") // some test cases call functions directly, need to @@ -12,6 +14,7 @@ let TEST_APP_ID = null const ContextKeys = { TENANT_ID: "tenantId", + GLOBAL_DB: "globalDb", APP_ID: "appId", // whatever the request app DB was CURRENT_DB: "currentDb", @@ -20,9 +23,37 @@ const ContextKeys = { // get the dev app DB from the request DEV_DB: "devDb", DB_OPTS: "dbOpts", + // check if something else is using the context, don't close DB + IN_USE: "inUse", } -exports.DEFAULT_TENANT_ID = "default" +exports.DEFAULT_TENANT_ID = DEFAULT_TENANT_ID + +// this function makes sure the PouchDB objects are closed and +// fully deleted when finished - this protects against memory leaks +async function closeAppDBs() { + const dbKeys = [ + ContextKeys.CURRENT_DB, + ContextKeys.PROD_DB, + ContextKeys.DEV_DB, + ] + for (let dbKey of dbKeys) { + const db = cls.getFromContext(dbKey) + if (!db) { + continue + } + await closeDB(db) + // clear the DB from context, incase someone tries to use it again + cls.setOnContext(dbKey, null) + } + // clear the app ID now that the databases are closed + if (cls.getFromContext(ContextKeys.APP_ID)) { + cls.setOnContext(ContextKeys.APP_ID, null) + } + if (cls.getFromContext(ContextKeys.DB_OPTS)) { + cls.setOnContext(ContextKeys.DB_OPTS, null) + } +} exports.isDefaultTenant = () => { return exports.getTenantId() === exports.DEFAULT_TENANT_ID @@ -34,13 +65,44 @@ exports.isMultiTenant = () => { // used for automations, API endpoints should always be in context already exports.doInTenant = (tenantId, task) => { - return cls.run(() => { + // the internal function is so that we can re-use an existing + // context - don't want to close DB on a parent context + async function internal(opts = { existing: false }) { // set the tenant id - cls.setOnContext(ContextKeys.TENANT_ID, tenantId) + if (!opts.existing) { + cls.setOnContext(ContextKeys.TENANT_ID, tenantId) + if (env.USE_COUCH) { + exports.setGlobalDB(tenantId) + } + } - // invoke the task - return task() - }) + try { + // invoke the task + return await task() + } finally { + const using = cls.getFromContext(ContextKeys.IN_USE) + if (!using || using <= 1) { + if (env.USE_COUCH) { + await closeDB(exports.getGlobalDB()) + } + // clear from context now that database is closed/task is finished + cls.setOnContext(ContextKeys.TENANT_ID, null) + cls.setOnContext(ContextKeys.GLOBAL_DB, null) + } else { + cls.setOnContext(using - 1) + } + } + } + const using = cls.getFromContext(ContextKeys.IN_USE) + if (using && cls.getFromContext(ContextKeys.TENANT_ID) === tenantId) { + cls.setOnContext(ContextKeys.IN_USE, using + 1) + return internal({ existing: true }) + } else { + return cls.run(async () => { + cls.setOnContext(ContextKeys.IN_USE, 1) + return internal() + }) + } } /** @@ -64,37 +126,59 @@ exports.getTenantIDFromAppID = appId => { } const setAppTenantId = appId => { - const appTenantId = this.getTenantIDFromAppID(appId) || this.DEFAULT_TENANT_ID - this.updateTenantId(appTenantId) + const appTenantId = + exports.getTenantIDFromAppID(appId) || exports.DEFAULT_TENANT_ID + exports.updateTenantId(appTenantId) } exports.doInAppContext = (appId, task) => { if (!appId) { throw new Error("appId is required") } - return cls.run(() => { - // set the app tenant id - setAppTenantId(appId) + // the internal function is so that we can re-use an existing + // context - don't want to close DB on a parent context + async function internal(opts = { existing: false }) { + // set the app tenant id + if (!opts.existing) { + setAppTenantId(appId) + } // set the app ID cls.setOnContext(ContextKeys.APP_ID, appId) - - // invoke the task - return task() - }) + try { + // invoke the task + return await task() + } finally { + const using = cls.getFromContext(ContextKeys.IN_USE) + if (!using || using <= 1) { + await closeAppDBs() + } else { + cls.setOnContext(using - 1) + } + } + } + const using = cls.getFromContext(ContextKeys.IN_USE) + if (using && cls.getFromContext(ContextKeys.APP_ID) === appId) { + cls.setOnContext(ContextKeys.IN_USE, using + 1) + return internal({ existing: true }) + } else { + return cls.run(async () => { + cls.setOnContext(ContextKeys.IN_USE, 1) + return internal() + }) + } } exports.updateTenantId = tenantId => { cls.setOnContext(ContextKeys.TENANT_ID, tenantId) + exports.setGlobalDB(tenantId) } -exports.updateAppId = appId => { +exports.updateAppId = async appId => { try { + // have to close first, before removing the databases from context + await closeAppDBs() cls.setOnContext(ContextKeys.APP_ID, appId) - cls.setOnContext(ContextKeys.PROD_DB, null) - cls.setOnContext(ContextKeys.DEV_DB, null) - cls.setOnContext(ContextKeys.CURRENT_DB, null) - cls.setOnContext(ContextKeys.DB_OPTS, null) } catch (err) { if (env.isTest()) { TEST_APP_ID = appId @@ -111,8 +195,8 @@ exports.setTenantId = ( let tenantId // exit early if not multi-tenant if (!exports.isMultiTenant()) { - cls.setOnContext(ContextKeys.TENANT_ID, this.DEFAULT_TENANT_ID) - return + cls.setOnContext(ContextKeys.TENANT_ID, exports.DEFAULT_TENANT_ID) + return exports.DEFAULT_TENANT_ID } const allowQs = opts && opts.allowQs @@ -140,6 +224,22 @@ exports.setTenantId = ( if (tenantId) { cls.setOnContext(ContextKeys.TENANT_ID, tenantId) } + return tenantId +} + +exports.setGlobalDB = tenantId => { + const dbName = baseGlobalDBName(tenantId) + const db = dangerousGetDB(dbName) + cls.setOnContext(ContextKeys.GLOBAL_DB, db) + return db +} + +exports.getGlobalDB = () => { + const db = cls.getFromContext(ContextKeys.GLOBAL_DB) + if (!db) { + throw new Error("Global DB not found") + } + return db } exports.isTenantIdSet = () => { @@ -174,8 +274,10 @@ function getContextDB(key, opts) { if (db && isEqual(opts, storedOpts)) { return db } + const appId = exports.getAppId() let toUseAppId + switch (key) { case ContextKeys.CURRENT_DB: toUseAppId = appId @@ -187,7 +289,7 @@ function getContextDB(key, opts) { toUseAppId = getDevelopmentAppID(appId) break } - db = getDB(toUseAppId, opts) + db = dangerousGetDB(toUseAppId, opts) try { cls.setOnContext(key, db) if (opts) { diff --git a/packages/backend-core/src/db/Replication.js b/packages/backend-core/src/db/Replication.js index 7af3c2eb9d..437d07e536 100644 --- a/packages/backend-core/src/db/Replication.js +++ b/packages/backend-core/src/db/Replication.js @@ -1,4 +1,4 @@ -const { getDB } = require(".") +const { dangerousGetDB, closeDB } = require(".") class Replication { /** @@ -7,8 +7,12 @@ class Replication { * @param {String} target - the DB you want to replicate to, or rollback from */ constructor({ source, target }) { - this.source = getDB(source) - this.target = getDB(target) + this.source = dangerousGetDB(source) + this.target = dangerousGetDB(target) + } + + close() { + return Promise.all([closeDB(this.source), closeDB(this.target)]) } promisify(operation, opts = {}) { @@ -51,7 +55,7 @@ class Replication { async rollback() { await this.target.destroy() // Recreate the DB again - this.target = getDB(this.target.name) + this.target = dangerousGetDB(this.target.name) await this.replicate() } diff --git a/packages/backend-core/src/db/index.js b/packages/backend-core/src/db/index.js index eb837dc254..5224110e90 100644 --- a/packages/backend-core/src/db/index.js +++ b/packages/backend-core/src/db/index.js @@ -1,4 +1,5 @@ const pouch = require("./pouch") +const env = require("../environment") let PouchDB let initialised = false @@ -24,7 +25,10 @@ exports.init = opts => { initialised = true } -exports.getDB = (dbName, opts) => { +// NOTE: THIS IS A DANGEROUS FUNCTION - USE WITH CAUTION +// this function is prone to leaks, should only be used +// in situations that using the function doWithDB does not work +exports.dangerousGetDB = (dbName, opts) => { checkInitialised() const db = new PouchDB(dbName, opts) const dbPut = db.put @@ -32,6 +36,33 @@ exports.getDB = (dbName, opts) => { return db } +// use this function if you have called dangerousGetDB - close +// the databases you've opened once finished +exports.closeDB = async db => { + if (!db || env.isTest()) { + return + } + try { + return db.close() + } catch (err) { + // ignore error, already closed + } +} + +// we have to use a callback for this so that we can close +// the DB when we're done, without this manual requests would +// need to close the database when done with it to avoid memory leaks +exports.doWithDB = async (dbName, cb, opts) => { + const db = exports.dangerousGetDB(dbName, opts) + // need this to be async so that we can correctly close DB after all + // async operations have been completed + try { + return await cb(db) + } finally { + await exports.closeDB(db) + } +} + exports.allDbs = () => { checkInitialised() return PouchDB.allDbs() diff --git a/packages/backend-core/src/db/pouch.js b/packages/backend-core/src/db/pouch.js index 0e3315b43c..722913aa67 100644 --- a/packages/backend-core/src/db/pouch.js +++ b/packages/backend-core/src/db/pouch.js @@ -20,16 +20,47 @@ exports.getCouchUrl = () => { return `${protocol}://${env.COUCH_DB_USERNAME}:${env.COUCH_DB_PASSWORD}@${rest}` } +exports.splitCouchUrl = url => { + const [protocol, rest] = url.split("://") + const [auth, host] = rest.split("@") + const [username, password] = auth.split(":") + return { + url: `${protocol}://${host}`, + auth: { + username, + password, + }, + } +} + /** * Return a constructor for PouchDB. * This should be rarely used outside of the main application config. * Exposed for exceptional cases such as in-memory views. */ exports.getPouch = (opts = {}) => { - const COUCH_DB_URL = exports.getCouchUrl() || "http://localhost:4005" + let auth = { + username: env.COUCH_DB_USERNAME, + password: env.COUCH_DB_PASSWORD, + } + let url = exports.getCouchUrl() || "http://localhost:4005" + // need to update security settings + if (!auth.username || !auth.password || url.includes("@")) { + const split = exports.splitCouchUrl(url) + url = split.url + auth = split.auth + } + const authCookie = Buffer.from(`${auth.username}:${auth.password}`).toString( + "base64" + ) let POUCH_DB_DEFAULTS = { - prefix: COUCH_DB_URL, + prefix: url, + fetch: (url, opts) => { + // use a specific authorization cookie - be very explicit about how we authenticate + opts.headers.set("Authorization", `Basic ${authCookie}`) + return PouchDB.fetch(url, opts) + }, } if (opts.inMemory) { diff --git a/packages/backend-core/src/db/tests/index.spec.js b/packages/backend-core/src/db/tests/index.spec.js index fadaa890df..9e555ab92a 100644 --- a/packages/backend-core/src/db/tests/index.spec.js +++ b/packages/backend-core/src/db/tests/index.spec.js @@ -1,11 +1,11 @@ require("../../tests/utilities/TestConfiguration") -const { getDB, allDbs } = require("../") +const { dangerousGetDB, allDbs } = require("../") describe("db", () => { describe("getDB", () => { it("returns a db", async () => { - const db = getDB("test") + const db = dangerousGetDB("test") expect(db).toBeDefined() expect(db._adapter).toBe("memory") expect(db.prefix).toBe("_pouch_") @@ -13,7 +13,7 @@ describe("db", () => { }) it("uses the custom put function", async () => { - const db = getDB("test") + const db = dangerousGetDB("test") let doc = { _id: "test" } await db.put(doc) doc = await db.get(doc._id) @@ -27,9 +27,9 @@ describe("db", () => { it("returns all dbs", async () => { let all = await allDbs() expect(all).toStrictEqual([]) - const db1 = getDB("test1") + const db1 = dangerousGetDB("test1") await db1.put({ _id: "test1" }) - const db2 = getDB("test2") + const db2 = dangerousGetDB("test2") await db2.put({ _id: "test2" }) all = await allDbs() expect(all.length).toBe(2) diff --git a/packages/backend-core/src/db/utils.js b/packages/backend-core/src/db/utils.js index a44d99643e..9e2a06d065 100644 --- a/packages/backend-core/src/db/utils.js +++ b/packages/backend-core/src/db/utils.js @@ -11,7 +11,7 @@ const { } = require("./constants") const { getTenantId, getGlobalDBName } = require("../tenancy") const fetch = require("node-fetch") -const { getDB, allDbs } = require("./index") +const { doWithDB, allDbs } = require("./index") const { getCouchUrl } = require("./pouch") const { getAppMetadata } = require("../cache/appMetadata") const { checkSlashesInUrl } = require("../helpers") @@ -281,17 +281,22 @@ exports.getDevAppIDs = async () => { exports.dbExists = async dbName => { let exists = false - try { - const db = getDB(dbName, { skip_setup: true }) - // check if database exists - const info = await db.info() - if (info && !info.error) { - exists = true - } - } catch (err) { - exists = false - } - return exists + return doWithDB( + dbName, + async db => { + try { + // check if database exists + const info = await db.info() + if (info && !info.error) { + exists = true + } + } catch (err) { + exists = false + } + return exists + }, + { skip_setup: true } + ) } /** @@ -416,3 +421,4 @@ exports.generateConfigID = generateConfigID exports.getConfigParams = getConfigParams exports.getScopedFullConfig = getScopedFullConfig exports.generateDevInfoID = generateDevInfoID +exports.getPlatformUrl = getPlatformUrl diff --git a/packages/backend-core/src/environment.js b/packages/backend-core/src/environment.js index af9bfec2cc..fc7f83eea3 100644 --- a/packages/backend-core/src/environment.js +++ b/packages/backend-core/src/environment.js @@ -22,7 +22,8 @@ module.exports = { MINIO_URL: process.env.MINIO_URL, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, MULTI_TENANCY: process.env.MULTI_TENANCY, - ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL, + ACCOUNT_PORTAL_URL: + process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app", ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED), @@ -31,6 +32,7 @@ module.exports = { POSTHOG_TOKEN: process.env.POSTHOG_TOKEN, ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS, TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS, + USE_COUCH: process.env.USE_COUCH || true, isTest, _set(key, value) { process.env[key] = value diff --git a/packages/backend-core/src/middleware/passport/datasource/google.js b/packages/backend-core/src/middleware/passport/datasource/google.js index 5046815b1f..96c7f99953 100644 --- a/packages/backend-core/src/middleware/passport/datasource/google.js +++ b/packages/backend-core/src/middleware/passport/datasource/google.js @@ -1,8 +1,8 @@ const google = require("../google") const { Cookies, Configs } = require("../../../constants") const { clearCookie, getCookie } = require("../../../utils") -const { getDB } = require("../../../db") -const { getScopedConfig } = require("../../../db/utils") +const { getScopedConfig, getPlatformUrl } = require("../../../db/utils") +const { doWithDB } = require("../../../db") const environment = require("../../../environment") const { getGlobalDB } = require("../../../tenancy") @@ -13,18 +13,28 @@ async function fetchGoogleCreds() { type: Configs.GOOGLE, }) // or fall back to env variables - const config = googleConfig || { - clientID: environment.GOOGLE_CLIENT_ID, - clientSecret: environment.GOOGLE_CLIENT_SECRET, - } + return ( + googleConfig || { + clientID: environment.GOOGLE_CLIENT_ID, + clientSecret: environment.GOOGLE_CLIENT_SECRET, + } + ) +} - return config +async function platformUrl() { + const db = getGlobalDB() + const publicConfig = await getScopedConfig(db, { + type: Configs.SETTINGS, + }) + return getPlatformUrl(publicConfig) } async function preAuth(passport, ctx, next) { // get the relevant config const googleConfig = await fetchGoogleCreds() - let callbackUrl = `${environment.PLATFORM_URL}/api/global/auth/datasource/google/callback` + const platUrl = await platformUrl() + + let callbackUrl = `${platUrl}/api/global/auth/datasource/google/callback` const strategy = await google.strategyFactory(googleConfig, callbackUrl) if (!ctx.query.appId || !ctx.query.datasourceId) { @@ -41,14 +51,15 @@ async function preAuth(passport, ctx, next) { async function postAuth(passport, ctx, next) { // get the relevant config const config = await fetchGoogleCreds() + const platUrl = await platformUrl() - let callbackUrl = `${environment.PLATFORM_URL}/api/global/auth/datasource/google/callback` + let callbackUrl = `${platUrl}/api/global/auth/datasource/google/callback` const strategy = await google.strategyFactory( config, callbackUrl, (accessToken, refreshToken, profile, done) => { clearCookie(ctx, Cookies.DatasourceAuth) - done(null, { accessToken, refreshToken }) + done(null, { refreshToken }) } ) @@ -59,16 +70,17 @@ async function postAuth(passport, ctx, next) { { successRedirect: "/", failureRedirect: "/error" }, async (err, tokens) => { // update the DB for the datasource with all the user info - const db = getDB(authStateCookie.appId) - const datasource = await db.get(authStateCookie.datasourceId) - if (!datasource.config) { - datasource.config = {} - } - datasource.config.auth = { type: "google", ...tokens } - await db.put(datasource) - ctx.redirect( - `/builder/app/${authStateCookie.appId}/data/datasource/${authStateCookie.datasourceId}` - ) + await doWithDB(authStateCookie.appId, async db => { + const datasource = await db.get(authStateCookie.datasourceId) + if (!datasource.config) { + datasource.config = {} + } + datasource.config.auth = { type: "google", ...tokens } + await db.put(datasource) + ctx.redirect( + `/builder/app/${authStateCookie.appId}/data/datasource/${authStateCookie.datasourceId}` + ) + }) } )(ctx, next) } diff --git a/packages/backend-core/src/middleware/passport/google.js b/packages/backend-core/src/middleware/passport/google.js index 5e95a906d8..b12a668327 100644 --- a/packages/backend-core/src/middleware/passport/google.js +++ b/packages/backend-core/src/middleware/passport/google.js @@ -2,7 +2,7 @@ const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const { authenticateThirdParty } = require("./third-party-common") -const buildVerifyFn = async saveUserFn => { +const buildVerifyFn = saveUserFn => { return (accessToken, refreshToken, profile, done) => { const thirdPartyUser = { provider: profile.provider, // should always be 'google' diff --git a/packages/backend-core/src/middleware/passport/tests/third-party-common.spec.js b/packages/backend-core/src/middleware/passport/tests/third-party-common.spec.js index 8caed931ea..04ae46cce5 100644 --- a/packages/backend-core/src/middleware/passport/tests/third-party-common.spec.js +++ b/packages/backend-core/src/middleware/passport/tests/third-party-common.spec.js @@ -1,15 +1,11 @@ require("../../../tests/utilities/TestConfiguration") -const database = require("../../../db") const { authenticateThirdParty } = require("../third-party-common") const { data } = require("./utilities/mock-data") +const { DEFAULT_TENANT_ID } = require("../../../constants") -const { - StaticDatabases, - generateGlobalUserID -} = require("../../../db/utils") +const { generateGlobalUserID } = require("../../../db/utils") const { newid } = require("../../../hashing") - -let db +const { doWithGlobalDB, doInTenant } = require("../../../tenancy") const done = jest.fn() @@ -18,43 +14,52 @@ const getErrorMessage = () => { } const saveUser = async (user) => { - return await db.put(user) + return doWithGlobalDB(DEFAULT_TENANT_ID, async db => { + return await db.put(user) + }) +} + +function authenticate(user, requireLocal, saveFn) { + return doInTenant(DEFAULT_TENANT_ID, () => { + return authenticateThirdParty(user, requireLocal, done, saveFn) + }) } describe("third party common", () => { - describe("authenticateThirdParty", () => { + describe("authenticateThirdParty", () => { let thirdPartyUser - + beforeEach(() => { - db = database.getDB(StaticDatabases.GLOBAL.name) thirdPartyUser = data.buildThirdPartyUser() }) afterEach(async () => { - jest.clearAllMocks() - await db.destroy() + return doWithGlobalDB(DEFAULT_TENANT_ID, async db => { + jest.clearAllMocks() + await db.destroy() + }) }) - + describe("validation", () => { const testValidation = async (message) => { - await authenticateThirdParty(thirdPartyUser, false, done, saveUser) + await authenticate(thirdPartyUser, false, saveUser) expect(done.mock.calls.length).toBe(1) expect(getErrorMessage()).toContain(message) } it("provider fails", async () => { delete thirdPartyUser.provider - testValidation("third party user provider required") + await testValidation("third party user provider required") }) it("user id fails", async () => { delete thirdPartyUser.userId - testValidation("third party user id required") + await testValidation("third party user id required") }) it("email fails", async () => { delete thirdPartyUser.email - testValidation("third party user email required") + await testValidation("third party user email required") }) }) @@ -78,34 +83,37 @@ describe("third party common", () => { describe("when the user doesn't exist", () => { describe("when a local account is required", () => { it("returns an error message", async () => { - await authenticateThirdParty(thirdPartyUser, true, done, saveUser) + await authenticate(thirdPartyUser, true, saveUser) expect(done.mock.calls.length).toBe(1) expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.") }) }) - + describe("when a local account isn't required", () => { it("creates and authenticates the user", async () => { - await authenticateThirdParty(thirdPartyUser, false, done, saveUser) + await authenticate(thirdPartyUser, false, saveUser) const user = expectUserIsAuthenticated() expectUserIsSynced(user, thirdPartyUser) expect(user.roles).toStrictEqual({}) }) }) }) - + describe("when the user exists", () => { let dbUser let id let email const createUser = async () => { - dbUser = { - _id: id, - email: email, - } - const response = await db.put(dbUser) - dbUser._rev = response.rev + return doWithGlobalDB(DEFAULT_TENANT_ID, async db => { + dbUser = { + _id: id, + email: email, + } + const response = await db.put(dbUser) + dbUser._rev = response.rev + return dbUser + }) } const expectUserIsUpdated = (user) => { @@ -123,8 +131,8 @@ describe("third party common", () => { }) it("syncs and authenticates the user", async () => { - await authenticateThirdParty(thirdPartyUser, true, done, saveUser) - + await authenticate(thirdPartyUser, true, saveUser) + const user = expectUserIsAuthenticated() expectUserIsSynced(user, thirdPartyUser) expectUserIsUpdated(user) @@ -139,8 +147,8 @@ describe("third party common", () => { }) it("syncs and authenticates the user", async () => { - await authenticateThirdParty(thirdPartyUser, true, done, saveUser) - + await authenticate(thirdPartyUser, true, saveUser) + const user = expectUserIsAuthenticated() expectUserIsSynced(user, thirdPartyUser) expectUserIsUpdated(user) @@ -157,8 +165,8 @@ describe("third party common", () => { }) it("syncs and authenticates the user", async () => { - await authenticateThirdParty(thirdPartyUser, true, done, saveUser) - + await authenticate(thirdPartyUser, true, saveUser) + const user = expectUserIsAuthenticated() expectUserIsSynced(user, thirdPartyUser) expectUserIsUpdated(user) diff --git a/packages/backend-core/src/middleware/tenancy.js b/packages/backend-core/src/middleware/tenancy.js index 5bb81f8824..f4053d1f5b 100644 --- a/packages/backend-core/src/middleware/tenancy.js +++ b/packages/backend-core/src/middleware/tenancy.js @@ -1,4 +1,5 @@ -const { setTenantId } = require("../tenancy") +const { setTenantId, setGlobalDB, getGlobalDB } = require("../tenancy") +const { closeDB } = require("../db") const ContextFactory = require("../context/FunctionContext") const { buildMatcherRegex, matches } = require("./matchers") @@ -10,10 +11,17 @@ module.exports = ( const allowQsOptions = buildMatcherRegex(allowQueryStringPatterns) const noTenancyOptions = buildMatcherRegex(noTenancyPatterns) - return ContextFactory.getMiddleware(ctx => { + const updateCtxFn = ctx => { const allowNoTenant = opts.noTenancyRequired || !!matches(ctx, noTenancyOptions) const allowQs = !!matches(ctx, allowQsOptions) - setTenantId(ctx, { allowQs, allowNoTenant }) - }) + const tenantId = setTenantId(ctx, { allowQs, allowNoTenant }) + setGlobalDB(tenantId) + } + const destroyFn = async () => { + const db = getGlobalDB() + await closeDB(db) + } + + return ContextFactory.getMiddleware(updateCtxFn, destroyFn) } diff --git a/packages/backend-core/src/migrations/index.js b/packages/backend-core/src/migrations/index.js index 1974a52463..ada1478ace 100644 --- a/packages/backend-core/src/migrations/index.js +++ b/packages/backend-core/src/migrations/index.js @@ -1,5 +1,5 @@ const { DEFAULT_TENANT_ID } = require("../constants") -const { getDB } = require("../db") +const { doWithDB } = require("../db") const { DocumentTypes } = require("../db/constants") const { getAllApps } = require("../db/utils") const environment = require("../environment") @@ -47,45 +47,46 @@ const runMigration = async (migration, options = {}) => { // run the migration against each db for (const dbName of dbNames) { - const db = getDB(dbName) - try { - const doc = await exports.getMigrationsDoc(db) + await doWithDB(dbName, async db => { + try { + const doc = await exports.getMigrationsDoc(db) - // exit if the migration has been performed already - if (doc[migrationName]) { - if ( - options.force && - options.force[migrationType] && - options.force[migrationType].includes(migrationName) - ) { - console.log( - `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Forcing` - ) - } else { - // the migration has already been performed - continue + // exit if the migration has been performed already + if (doc[migrationName]) { + if ( + options.force && + options.force[migrationType] && + options.force[migrationType].includes(migrationName) + ) { + console.log( + `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Forcing` + ) + } else { + // the migration has already been performed + return + } } + + console.log( + `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Running` + ) + // run the migration with tenant context + await migration.fn(db) + console.log( + `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Complete` + ) + + // mark as complete + doc[migrationName] = Date.now() + await db.put(doc) + } catch (err) { + console.error( + `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Error: `, + err + ) + throw err } - - console.log( - `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Running` - ) - // run the migration with tenant context - await migration.fn(db) - console.log( - `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Complete` - ) - - // mark as complete - doc[migrationName] = Date.now() - await db.put(doc) - } catch (err) { - console.error( - `[Tenant: ${tenantId}] [Migration: ${migrationName}] [DB: ${dbName}] Error: `, - err - ) - throw err - } + }) } } diff --git a/packages/backend-core/src/migrations/tests/index.spec.js b/packages/backend-core/src/migrations/tests/index.spec.js index e5587eede1..afdf10502c 100644 --- a/packages/backend-core/src/migrations/tests/index.spec.js +++ b/packages/backend-core/src/migrations/tests/index.spec.js @@ -1,6 +1,6 @@ require("../../tests/utilities/TestConfiguration") const { runMigrations, getMigrationsDoc } = require("../index") -const { getDB } = require("../../db") +const { dangerousGetDB } = require("../../db") const { StaticDatabases, } = require("../../db/utils") @@ -18,7 +18,7 @@ describe("migrations", () => { }] beforeEach(() => { - db = getDB(StaticDatabases.GLOBAL.name) + db = dangerousGetDB(StaticDatabases.GLOBAL.name) }) afterEach(async () => { diff --git a/packages/backend-core/src/security/roles.js b/packages/backend-core/src/security/roles.js index 8535cdc716..7c57cadcbf 100644 --- a/packages/backend-core/src/security/roles.js +++ b/packages/backend-core/src/security/roles.js @@ -7,7 +7,7 @@ const { SEPARATOR, } = require("../db/utils") const { getAppDB } = require("../context") -const { getDB } = require("../db") +const { doWithDB } = require("../db") const BUILTIN_IDS = { ADMIN: "ADMIN", @@ -199,43 +199,49 @@ exports.checkForRoleResourceArray = (rolePerms, resourceId) => { * @return {Promise} An array of the role objects that were found. */ exports.getAllRoles = async appId => { - const db = appId ? getDB(appId) : getAppDB() - const body = await db.allDocs( - getRoleParams(null, { - include_docs: true, - }) - ) - let roles = body.rows.map(row => row.doc) - const builtinRoles = exports.getBuiltinRoles() + if (appId) { + return doWithDB(appId, internal) + } else { + return internal(getAppDB()) + } + async function internal(db) { + const body = await db.allDocs( + getRoleParams(null, { + include_docs: true, + }) + ) + let roles = body.rows.map(row => row.doc) + const builtinRoles = exports.getBuiltinRoles() - // need to combine builtin with any DB record of them (for sake of permissions) - for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) { - const builtinRole = builtinRoles[builtinRoleId] - const dbBuiltin = roles.filter( - dbRole => exports.getExternalRoleID(dbRole._id) === builtinRoleId - )[0] - if (dbBuiltin == null) { - roles.push(builtinRole || builtinRoles.BASIC) - } else { - // remove role and all back after combining with the builtin - roles = roles.filter(role => role._id !== dbBuiltin._id) - dbBuiltin._id = exports.getExternalRoleID(dbBuiltin._id) - roles.push(Object.assign(builtinRole, dbBuiltin)) + // need to combine builtin with any DB record of them (for sake of permissions) + for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) { + const builtinRole = builtinRoles[builtinRoleId] + const dbBuiltin = roles.filter( + dbRole => exports.getExternalRoleID(dbRole._id) === builtinRoleId + )[0] + if (dbBuiltin == null) { + roles.push(builtinRole || builtinRoles.BASIC) + } else { + // remove role and all back after combining with the builtin + roles = roles.filter(role => role._id !== dbBuiltin._id) + dbBuiltin._id = exports.getExternalRoleID(dbBuiltin._id) + roles.push(Object.assign(builtinRole, dbBuiltin)) + } } + // check permissions + for (let role of roles) { + if (!role.permissions) { + continue + } + for (let resourceId of Object.keys(role.permissions)) { + role.permissions = exports.checkForRoleResourceArray( + role.permissions, + resourceId + ) + } + } + return roles } - // check permissions - for (let role of roles) { - if (!role.permissions) { - continue - } - for (let resourceId of Object.keys(role.permissions)) { - role.permissions = exports.checkForRoleResourceArray( - role.permissions, - resourceId - ) - } - } - return roles } /** diff --git a/packages/backend-core/src/tenancy/tenancy.js b/packages/backend-core/src/tenancy/tenancy.js index 24acc16862..b9d5ad7fbe 100644 --- a/packages/backend-core/src/tenancy/tenancy.js +++ b/packages/backend-core/src/tenancy/tenancy.js @@ -1,5 +1,6 @@ -const { getDB } = require("../db") -const { SEPARATOR, StaticDatabases } = require("../db/constants") +const { doWithDB } = require("../db") +const { StaticDatabases } = require("../db/constants") +const { baseGlobalDBName } = require("./utils") const { getTenantId, DEFAULT_TENANT_ID, @@ -23,59 +24,61 @@ exports.addTenantToUrl = url => { } exports.doesTenantExist = async tenantId => { - const db = getDB(PLATFORM_INFO_DB) - let tenants - try { - tenants = await db.get(TENANT_DOC) - } catch (err) { - // if theres an error the doc doesn't exist, no tenants exist - return false - } - return ( - tenants && - Array.isArray(tenants.tenantIds) && - tenants.tenantIds.indexOf(tenantId) !== -1 - ) + return doWithDB(PLATFORM_INFO_DB, async db => { + let tenants + try { + tenants = await db.get(TENANT_DOC) + } catch (err) { + // if theres an error the doc doesn't exist, no tenants exist + return false + } + return ( + tenants && + Array.isArray(tenants.tenantIds) && + tenants.tenantIds.indexOf(tenantId) !== -1 + ) + }) } exports.tryAddTenant = async (tenantId, userId, email) => { - const db = getDB(PLATFORM_INFO_DB) - const getDoc = async id => { - if (!id) { - return null + return doWithDB(PLATFORM_INFO_DB, async db => { + const getDoc = async id => { + if (!id) { + return null + } + try { + return await db.get(id) + } catch (err) { + return { _id: id } + } } - try { - return await db.get(id) - } catch (err) { - return { _id: id } + let [tenants, userIdDoc, emailDoc] = await Promise.all([ + getDoc(TENANT_DOC), + getDoc(userId), + getDoc(email), + ]) + if (!Array.isArray(tenants.tenantIds)) { + tenants = { + _id: TENANT_DOC, + tenantIds: [], + } } - } - let [tenants, userIdDoc, emailDoc] = await Promise.all([ - getDoc(TENANT_DOC), - getDoc(userId), - getDoc(email), - ]) - if (!Array.isArray(tenants.tenantIds)) { - tenants = { - _id: TENANT_DOC, - tenantIds: [], + let promises = [] + if (userIdDoc) { + userIdDoc.tenantId = tenantId + promises.push(db.put(userIdDoc)) } - } - let promises = [] - if (userIdDoc) { - userIdDoc.tenantId = tenantId - promises.push(db.put(userIdDoc)) - } - if (emailDoc) { - emailDoc.tenantId = tenantId - emailDoc.userId = userId - promises.push(db.put(emailDoc)) - } - if (tenants.tenantIds.indexOf(tenantId) === -1) { - tenants.tenantIds.push(tenantId) - promises.push(db.put(tenants)) - } - await Promise.all(promises) + if (emailDoc) { + emailDoc.tenantId = tenantId + emailDoc.userId = userId + promises.push(db.put(emailDoc)) + } + if (tenants.tenantIds.indexOf(tenantId) === -1) { + tenants.tenantIds.push(tenantId) + promises.push(db.put(tenants)) + } + await Promise.all(promises) + }) } exports.getGlobalDBName = (tenantId = null) => { @@ -84,43 +87,37 @@ exports.getGlobalDBName = (tenantId = null) => { if (!tenantId) { tenantId = getTenantId() } - - let dbName - if (tenantId === DEFAULT_TENANT_ID) { - dbName = StaticDatabases.GLOBAL.name - } else { - dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` - } - return dbName + return baseGlobalDBName(tenantId) } -exports.getGlobalDB = (tenantId = null) => { - const dbName = exports.getGlobalDBName(tenantId) - return getDB(dbName) +exports.doWithGlobalDB = (tenantId, cb) => { + return doWithDB(exports.getGlobalDBName(tenantId), cb) } exports.lookupTenantId = async userId => { - const db = getDB(StaticDatabases.PLATFORM_INFO.name) - let tenantId = env.MULTI_TENANCY ? DEFAULT_TENANT_ID : null - try { - const doc = await db.get(userId) - if (doc && doc.tenantId) { - tenantId = doc.tenantId + return doWithDB(StaticDatabases.PLATFORM_INFO.name, async db => { + let tenantId = env.MULTI_TENANCY ? DEFAULT_TENANT_ID : null + try { + const doc = await db.get(userId) + if (doc && doc.tenantId) { + tenantId = doc.tenantId + } + } catch (err) { + // just return the default } - } catch (err) { - // just return the default - } - return tenantId + return tenantId + }) } // lookup, could be email or userId, either will return a doc exports.getTenantUser = async identifier => { - const db = getDB(PLATFORM_INFO_DB) - try { - return await db.get(identifier) - } catch (err) { - return null - } + return doWithDB(PLATFORM_INFO_DB, async db => { + try { + return await db.get(identifier) + } catch (err) { + return null + } + }) } exports.isUserInAppTenant = (appId, user = null) => { @@ -135,13 +132,14 @@ exports.isUserInAppTenant = (appId, user = null) => { } exports.getTenantIds = async () => { - const db = getDB(PLATFORM_INFO_DB) - let tenants - try { - tenants = await db.get(TENANT_DOC) - } catch (err) { - // if theres an error the doc doesn't exist, no tenants exist - return [] - } - return (tenants && tenants.tenantIds) || [] + return doWithDB(PLATFORM_INFO_DB, async db => { + let tenants + try { + tenants = await db.get(TENANT_DOC) + } catch (err) { + // if theres an error the doc doesn't exist, no tenants exist + return [] + } + return (tenants && tenants.tenantIds) || [] + }) } diff --git a/packages/backend-core/src/tenancy/utils.js b/packages/backend-core/src/tenancy/utils.js new file mode 100644 index 0000000000..70a965ddb7 --- /dev/null +++ b/packages/backend-core/src/tenancy/utils.js @@ -0,0 +1,12 @@ +const { DEFAULT_TENANT_ID } = require("../constants") +const { StaticDatabases, SEPARATOR } = require("../db/constants") + +exports.baseGlobalDBName = tenantId => { + let dbName + if (!tenantId || tenantId === DEFAULT_TENANT_ID) { + dbName = StaticDatabases.GLOBAL.name + } else { + dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` + } + return dbName +} diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js index 7f18205c2b..4acccda2a0 100644 --- a/packages/backend-core/src/users.js +++ b/packages/backend-core/src/users.js @@ -17,15 +17,5 @@ exports.getGlobalUserByEmail = async email => { include_docs: true, }) - if (response) { - if (Array.isArray(response)) { - for (let user of response) { - delete user.password - } - } else { - delete response.password - } - } - return response } diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 0118b36f52..bb8d1d4805 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -150,10 +150,24 @@ exports.isClient = ctx => { return ctx.headers[Headers.TYPE] === "client" } -exports.getBuildersCount = async () => { +const getBuilders = async () => { const builders = await queryGlobalView(ViewNames.USER_BY_BUILDERS, { include_docs: false, }) + + if (!builders) { + return [] + } + + if (Array.isArray(builders)) { + return builders + } else { + return [builders] + } +} + +exports.getBuildersCount = async () => { + const builders = await getBuilders() return builders.length } diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index 7c1f7114bd..50003d8b0b 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -259,9 +259,9 @@ "@babel/helper-plugin-utils" "^7.14.5" "@babel/runtime@^7.15.4": - version "7.17.8" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.8.tgz#3e56e4aff81befa55ac3ac6a0967349fd1c5bca2" - integrity sha512-dQpEpK0O9o6lj6oPu0gRDbbnk+4LeHlNcBpspf6Olzt3GIX4P1lWF1gS+pHLDFlaJvbR6q7jCfQ08zA4QJBnmA== + version "7.17.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72" + integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg== dependencies: regenerator-runtime "^0.13.4" @@ -1086,7 +1086,7 @@ buffer-from@1.1.1: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A== -buffer-from@^1.0.0: +buffer-from@1.1.2, buffer-from@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== @@ -1905,6 +1905,13 @@ fetch-cookie@0.10.1: dependencies: tough-cookie "^2.3.3 || ^3.0.1 || ^4.0.0" +fetch-cookie@0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/fetch-cookie/-/fetch-cookie-0.11.0.tgz#e046d2abadd0ded5804ce7e2cae06d4331c15407" + integrity sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA== + dependencies: + tough-cookie "^2.3.3 || ^3.0.1 || ^4.0.0" + fill-range@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" @@ -3710,7 +3717,7 @@ node-fetch@2.6.0: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== -node-fetch@^2.6.1: +node-fetch@2.6.7, node-fetch@^2.6.1: version "2.6.7" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== @@ -4309,17 +4316,17 @@ pouchdb-utils@7.2.2: pouchdb-md5 "7.2.2" uuid "8.1.0" -pouchdb@^7.2.1: - version "7.2.2" - resolved "https://registry.yarnpkg.com/pouchdb/-/pouchdb-7.2.2.tgz#fcae82862db527e4cf7576ed8549d1384961f364" - integrity sha512-5gf5nw5XH/2H/DJj8b0YkvG9fhA/4Jt6kL0Y8QjtztVjb1y4J19Rg4rG+fUbXu96gsUrlyIvZ3XfM0b4mogGmw== +pouchdb@7.3.0: + version "7.3.0" + resolved "https://registry.yarnpkg.com/pouchdb/-/pouchdb-7.3.0.tgz#440fbef12dfd8f9002320802528665e883a3b7f8" + integrity sha512-OwsIQGXsfx3TrU1pLruj6PGSwFH+h5k4hGNxFkZ76Um7/ZI8F5TzUHFrpldVVIhfXYi2vP31q0q7ot1FSLFYOw== dependencies: abort-controller "3.0.0" argsarray "0.0.1" - buffer-from "1.1.1" + buffer-from "1.1.2" clone-buffer "1.0.0" double-ended-queue "2.1.0-0" - fetch-cookie "0.10.1" + fetch-cookie "0.11.0" immediate "3.3.0" inherits "2.0.4" level "6.0.1" @@ -4328,11 +4335,11 @@ pouchdb@^7.2.1: leveldown "5.6.0" levelup "4.4.0" ltgt "2.2.1" - node-fetch "2.6.0" + node-fetch "2.6.7" readable-stream "1.1.14" - spark-md5 "3.0.1" + spark-md5 "3.0.2" through2 "3.0.2" - uuid "8.1.0" + uuid "8.3.2" vuvuzela "1.0.3" prelude-ls@~1.1.2: @@ -4891,6 +4898,11 @@ spark-md5@3.0.1: resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.1.tgz#83a0e255734f2ab4e5c466e5a2cfc9ba2aa2124d" integrity sha512-0tF3AGSD1ppQeuffsLDIOWlKUd3lS92tFxcsrh5Pe3ZphhnoK+oXIBTzOAThZCiuINZLvpiLH/1VS1/ANEJVig== +spark-md5@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/spark-md5/-/spark-md5-3.0.2.tgz#7952c4a30784347abcee73268e473b9c0167e3fc" + integrity sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw== + spdx-correct@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9" @@ -5393,6 +5405,11 @@ uuid@8.1.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.1.0.tgz#6f1536eb43249f473abc6bd58ff983da1ca30d8d" integrity sha512-CI18flHDznR0lq54xBycOVmphdCYnQLKn8abKn7PXUiKUGdEd+/l9LWNJmugXel4hXq7S+RMNl34ecyC9TntWg== +uuid@8.3.2, uuid@^8.3.0, uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + uuid@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" @@ -5403,11 +5420,6 @@ uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== -uuid@^8.3.0, uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - v8-to-istanbul@^7.0.0: version "7.1.2" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz#30898d1a7fa0c84d225a2c1434fb958f290883c1" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index d037ca9a86..bff13a354a 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.0.105-alpha.10", + "version": "1.0.124-alpha.0", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "^1.0.105-alpha.10", + "@budibase/string-templates": "^1.0.124-alpha.0", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/ActionButton/ActionButton.svelte b/packages/bbui/src/ActionButton/ActionButton.svelte index 5ab7a7f047..b518ac3d92 100644 --- a/packages/bbui/src/ActionButton/ActionButton.svelte +++ b/packages/bbui/src/ActionButton/ActionButton.svelte @@ -80,8 +80,4 @@ .active svg { color: var(--spectrum-global-color-blue-600); } - - .spectrum-ActionButton-label { - padding-bottom: 2px; - } diff --git a/packages/bbui/src/ActionMenu/ActionMenu.svelte b/packages/bbui/src/ActionMenu/ActionMenu.svelte index 08425e8f59..c5602d6b0c 100644 --- a/packages/bbui/src/ActionMenu/ActionMenu.svelte +++ b/packages/bbui/src/ActionMenu/ActionMenu.svelte @@ -6,6 +6,7 @@ export let disabled = false export let align = "left" export let portalTarget + export let dataCy let anchor let dropdown @@ -36,7 +37,7 @@
- + diff --git a/packages/bbui/src/Icon/Icon.svelte b/packages/bbui/src/Icon/Icon.svelte index fbaac7098c..eee1d7fbae 100644 --- a/packages/bbui/src/Icon/Icon.svelte +++ b/packages/bbui/src/Icon/Icon.svelte @@ -3,6 +3,9 @@ - (showTooltip = true)} + on:focus={() => (showTooltip = true)} + on:mouseleave={() => (showTooltip = false)} + on:click={() => (showTooltip = false)} > - - + + + + {#if tooltip && showTooltip} +
+ +
+ {/if} + diff --git a/packages/bbui/src/Layout/Layout.svelte b/packages/bbui/src/Layout/Layout.svelte index 0dcb1f46ee..c66a409242 100644 --- a/packages/bbui/src/Layout/Layout.svelte +++ b/packages/bbui/src/Layout/Layout.svelte @@ -36,6 +36,10 @@ padding-left: var(--spacing-l); padding-right: var(--spacing-l); } + .paddingX-XL { + padding-left: var(--spacing-xl); + padding-right: var(--spacing-xl); + } .paddingY-S { padding-top: var(--spacing-s); padding-bottom: var(--spacing-s); @@ -48,6 +52,10 @@ padding-top: var(--spacing-l); padding-bottom: var(--spacing-l); } + .paddingY-XL { + padding-top: var(--spacing-xl); + padding-bottom: var(--spacing-xl); + } .gap-XXS { grid-gap: var(--spacing-xs); } diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index 89c10bb625..10cd4b10ba 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -23,6 +23,7 @@ export let secondaryButtonText = undefined export let secondaryAction = undefined export let secondaryButtonWarning = false + export let dataCy = null const { hide, cancel } = getContext(Context.Modal) let loading = false @@ -63,21 +64,26 @@ role="dialog" tabindex="-1" aria-modal="true" + data-cy={dataCy} >
- {#if title} + {#if title || $$slots.header}

- {title} - + {#if title} + {title} + {:else if $$slots.header} + + {/if}

{#if showDivider} {/if} {/if} +
diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index 6c9c6cc9a3..1017ef71fc 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -10,6 +10,17 @@ export let anchor export let align = "right" export let portalTarget + export let dataCy + + export let direction = "bottom" + export let showTip = false + + let tipSvg = + ' ' + + $: tooltipClasses = showTip + ? `spectrum-Popover--withTip spectrum-Popover--${direction}` + : "" export const show = () => { dispatch("open") @@ -37,9 +48,14 @@ use:positionDropdown={{ anchor, align }} use:clickOutside={hide} on:keydown={handleEscape} - class="spectrum-Popover is-open" + class={"spectrum-Popover is-open " + (tooltipClasses || "")} role="presentation" + data-cy={dataCy} > + {#if showTip} + {@html tipSvg} + {/if} +
@@ -49,4 +65,13 @@ .spectrum-Popover { min-width: var(--spectrum-global-dimension-size-2000) !important; } + .spectrum-Popover.is-open.spectrum-Popover--withTip { + margin-top: var(--spacing-xs); + margin-left: var(--spacing-xl); + } + :global(.spectrum-Popover--bottom .spectrum-Popover-tip), + :global(.spectrum-Popover--top .spectrum-Popover-tip) { + left: 90%; + margin-left: calc(var(--spectrum-global-dimension-size-150) * -1); + } diff --git a/packages/bbui/src/Table/InternalRenderer.svelte b/packages/bbui/src/Table/InternalRenderer.svelte index 7e2dd0b2aa..d38fb9f691 100644 --- a/packages/bbui/src/Table/InternalRenderer.svelte +++ b/packages/bbui/src/Table/InternalRenderer.svelte @@ -1,42 +1,21 @@ diff --git a/packages/bbui/src/Table/Table.svelte b/packages/bbui/src/Table/Table.svelte index 1d957b3f30..863dc84040 100644 --- a/packages/bbui/src/Table/Table.svelte +++ b/packages/bbui/src/Table/Table.svelte @@ -36,6 +36,7 @@ export let disableSorting = false export let autoSortColumns = true export let compact = false + export let customPlaceholder = false const dispatch = createEventDispatcher() @@ -387,13 +388,24 @@ {/each} {:else} -
-
- - - -
No rows found
-
+
+ {#if customPlaceholder} + + {:else} +
+ + + +
No rows found
+
+ {/if}
{/if}
@@ -458,6 +470,13 @@ justify-content: flex-start; align-items: center; user-select: none; + border-top: var(--table-border); + } + .spectrum-Table-headCell:first-of-type { + border-left: var(--table-border); + } + .spectrum-Table-headCell:last-of-type { + border-right: var(--table-border); } .spectrum-Table-headCell--alignCenter { justify-content: center; @@ -576,16 +595,19 @@ border-top: none; grid-column: 1 / -1; background-color: var(--table-bg); + padding: 40px; } .placeholder--no-fields { border-top: var(--table-border); } + .placeholder--custom { + justify-content: flex-start; + } .wrapper--quiet .placeholder { border-left: none; border-right: none; } .placeholder-content { - padding: 40px; display: flex; flex-direction: column; justify-content: center; diff --git a/packages/bbui/src/Tabs/Tabs.svelte b/packages/bbui/src/Tabs/Tabs.svelte index 6930a6cdb5..579c61e28d 100644 --- a/packages/bbui/src/Tabs/Tabs.svelte +++ b/packages/bbui/src/Tabs/Tabs.svelte @@ -108,7 +108,7 @@ padding-left: var(--spacing-xl); padding-right: var(--spacing-xl); position: relative; - border-bottom: var(--border-light); + border-bottom: 1px solid var(--spectrum-global-color-gray-300); } .spectrum-Tabs-content { margin-top: var(--spectrum-global-dimension-static-size-150); diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index cf40e12d74..b02783e0bd 100644 --- a/packages/bbui/src/helpers.js +++ b/packages/bbui/src/helpers.js @@ -106,3 +106,29 @@ export const deepSet = (obj, key, value) => { export const cloneDeep = obj => { return JSON.parse(JSON.stringify(obj)) } + +/** + * Copies a value to the clipboard + * @param value the value to copy + */ +export const copyToClipboard = value => { + return new Promise(res => { + if (navigator.clipboard && window.isSecureContext) { + // Try using the clipboard API first + navigator.clipboard.writeText(value).then(res) + } else { + // Fall back to the textarea hack + let textArea = document.createElement("textarea") + textArea.value = value + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + textArea.style.top = "-9999px" + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + document.execCommand("copy") + textArea.remove() + res() + } + }) +} diff --git a/packages/builder/cypress/integration/appPublishWorkflow.spec.js b/packages/builder/cypress/integration/appPublishWorkflow.spec.js new file mode 100644 index 0000000000..d18233e0e7 --- /dev/null +++ b/packages/builder/cypress/integration/appPublishWorkflow.spec.js @@ -0,0 +1,112 @@ +import filterTests from "../support/filterTests" + +filterTests(['all'], () => { + context("Publish Application Workflow", () => { + before(() => { + cy.login() + cy.createTestApp() + }) + + it("Should reflect the unpublished status correctly", () => { + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(1000) + + cy.get(".appTable .app-status").eq(0) + .within(() => { + cy.contains("Unpublished") + cy.get("svg[aria-label='GlobeStrike']").should("exist") + }) + + cy.get(".appTable .app-row-actions").eq(0) + .within(() => { + cy.get(".spectrum-Button").contains("Preview") + cy.get(".spectrum-Button").contains("Edit").click({ force: true }) + }) + + cy.get(".deployment-top-nav svg[aria-label='GlobeStrike']").should("exist") + cy.get(".deployment-top-nav svg[aria-label='Globe']").should("not.exist") + }) + + it("Should publish an application and correctly reflect that", () => { + //Assuming the previous test was run and the unpublished app is open in edit mode. + cy.get(".toprightnav button.spectrum-Button").contains("Publish").click({ force : true }) + + cy.get(".spectrum-Modal [data-cy='deploy-app-modal']").should("be.visible") + .within(() => { + cy.get(".spectrum-Button").contains("Publish").click({ force : true }) + cy.wait(1000) + }); + + //Verify that the app url is presented correctly to the user + cy.get(".spectrum-Modal [data-cy='deploy-app-success-modal']") + .should("be.visible") + .within(() => { + let appUrl = Cypress.config().baseUrl + '/app/cypress-tests' + cy.get("[data-cy='deployed-app-url'] input").should('have.value', appUrl) + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) + + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(1000) + + cy.get(".appTable .app-status").eq(0) + .within(() => { + cy.contains("Published") + cy.get("svg[aria-label='Globe']").should("exist") + }) + + cy.get(".appTable .app-row-actions").eq(0) + .within(() => { + cy.get(".spectrum-Button").contains("View app") + cy.get(".spectrum-Button").contains("Edit").click({ force: true }) + }) + + cy.get(".deployment-top-nav svg[aria-label='Globe']").should("exist").click({ force: true }) + + cy.get("[data-cy='publish-popover-menu']").should("be.visible") + .within(() => { + cy.get("[data-cy='publish-popover-action']").should("exist") + cy.get("button").contains("View app").should("exist") + cy.get(".publish-popover-message").should("have.text", "Last published a few seconds ago") + }) + }) + + it("Should unpublish an application from the top navigation and reflect the status change", () => { + //Assuming the previous test app exists and is published + + cy.visit(`${Cypress.config().baseUrl}/builder`) + + cy.get(".appTable .app-status").eq(0) + .within(() => { + cy.contains("Published") + cy.get("svg[aria-label='Globe']").should("exist") + }) + + cy.get(".appTable .app-row-actions").eq(0) + .within(() => { + cy.get(".spectrum-Button").contains("View app") + cy.get(".spectrum-Button").contains("Edit").click({ force: true }) + }) + + //The published status + cy.get(".deployment-top-nav svg[aria-label='Globe']").should("exist") + .click({ force: true }) + + cy.get("[data-cy='publish-popover-menu']").should("be.visible") + cy.get("[data-cy='publish-popover-menu'] [data-cy='publish-popover-action']") + .click({ force : true }) + + cy.get("[data-cy='unpublish-modal']").should("be.visible") + .within(() => { + cy.get(".confirm-wrap button").click({ force: true } + )}) + + cy.get(".deployment-top-nav svg[aria-label='GlobeStrike']").should("exist") + + cy.visit(`${Cypress.config().baseUrl}/builder`) + + cy.get(".appTable .app-status").eq(0).contains("Unpublished") + + }) + }) +}) diff --git a/packages/builder/cypress/integration/autoScreensUI.spec.js b/packages/builder/cypress/integration/autoScreensUI.spec.js index ab65e6561f..2c2a43e711 100644 --- a/packages/builder/cypress/integration/autoScreensUI.spec.js +++ b/packages/builder/cypress/integration/autoScreensUI.spec.js @@ -1,51 +1,103 @@ import filterTests from "../support/filterTests" filterTests(['smoke', 'all'], () => { - context("Auto Screens UI", () => { - before(() => { - cy.login() - cy.createTestApp() - }) - - it("should generate internal table screens", () => { - // Create autogenerated screens from the internal table - cy.createAutogeneratedScreens(["Cypress Tests"]) - // Confirm screens have been auto generated - cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) - cy.get(".nav-items-container").should('contain', 'cypress-tests/:id') - .and('contain', 'cypress-tests/new/row') - }) - - it("should generate multiple internal table screens at once", () => { - // Create a second internal table - const initialTable = "Cypress Tests" - const secondTable = "Table Two" - cy.createTable(secondTable) - // Create autogenerated screens from the internal tables - cy.createAutogeneratedScreens([initialTable, secondTable]) - // Confirm screens have been auto generated - cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) - // Previously generated tables are suffixed with numbers - as expected - cy.get(".nav-items-container").should('contain', 'cypress-tests-2/:id') - .and('contain', 'cypress-tests-2/new/row') - cy.get(".nav-items-container").contains("table-two").click() - cy.get(".nav-items-container").should('contain', 'table-two/:id') - .and('contain', 'table-two/new/row') - }) - - if (Cypress.env("TEST_ENV")) { - it("should generate data source screens", () => { - // Using MySQL data source for testing this - const datasource = "MySQL" - // Select & configure MySQL data source - cy.selectExternalDatasource(datasource) - cy.addDatasourceConfig(datasource) - // Create autogenerated screens from a MySQL table - MySQL contains books table - cy.createAutogeneratedScreens(["books"]) - cy.get(".nav-items-container").contains("books").click() - cy.get(".nav-items-container").should('contain', 'books/:id') - .and('contain', 'books/new/row') - }) - } + context("Auto Screens UI", () => { + before(() => { + cy.login() }) + + it("should disable the autogenerated screen options if no sources are available", () => { + cy.createApp("First Test App", false) + + cy.closeModal(); + + cy.contains("Design").click() + cy.get("[aria-label=AddCircle]").click() + cy.get(".spectrum-Modal").within(() => { + cy.get(".item.disabled").contains("Autogenerated screens") + cy.get(".confirm-wrap .spectrum-Button").should('be.disabled') + }) + + cy.deleteAllApps() + }); + + it("should not display incompatible sources", () => { + cy.createApp("Test App") + + cy.selectExternalDatasource("REST") + cy.selectExternalDatasource("S3") + cy.get(".spectrum-Modal").within(() => { + cy.get(".spectrum-Button").contains("Save and continue to query").click({ force : true }) + }) + + cy.navigateToAutogeneratedModal() + + cy.get('.data-source-entry').should('have.length', 1) + cy.get('.data-source-entry') + + cy.deleteAllApps() + }); + + it("should generate internal table screens", () => { + cy.createTestApp() + // Create Autogenerated screens from the internal table + cy.createDatasourceScreen(["Cypress Tests"]) + // Confirm screens have been auto generated + cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) + cy.get(".nav-items-container").should('contain', 'cypress-tests/:id') + .and('contain', 'cypress-tests/new/row') + }) + + it("should generate multiple internal table screens at once", () => { + // Create a second internal table + const initialTable = "Cypress Tests" + const secondTable = "Table Two" + cy.createTable(secondTable) + // Create Autogenerated screens from the internal tables + cy.createDatasourceScreen([initialTable, secondTable]) + // Confirm screens have been auto generated + cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) + // Previously generated tables are suffixed with numbers - as expected + cy.get(".nav-items-container").should('contain', 'cypress-tests-2/:id') + .and('contain', 'cypress-tests-2/new/row') + cy.get(".nav-items-container").contains("table-two").click() + cy.get(".nav-items-container").should('contain', 'table-two/:id') + .and('contain', 'table-two/new/row') + }) + + it("should generate multiple internal table screens with the same screen access level", () => { + //The tables created in the previous step still exist + cy.createTable("Table Three") + cy.createTable("Table Four") + cy.createDatasourceScreen(["Table Three", "Table Four"], "Admin") + + cy.get(".nav-items-container").contains("table-three").click() + cy.get(".nav-items-container").should('contain', 'table-three/:id') + .and('contain', 'table-three/new/row') + + cy.get(".nav-items-container").contains("table-four").click() + cy.get(".nav-items-container").should('contain', 'table-four/:id') + .and('contain', 'table-four/new/row') + + //The access level should now be set to admin. Previous screens should be filtered. + cy.get(".nav-items-container").contains("table-two").should('not.exist') + cy.get(".nav-items-container").contains("cypress-tests").should('not.exist') + }) + + if (Cypress.env("TEST_ENV")) { + it("should generate data source screens", () => { + // Using MySQL data source for testing this + const datasource = "MySQL" + // Select & configure MySQL data source + cy.selectExternalDatasource(datasource) + cy.addDatasourceConfig(datasource) + // Create Autogenerated screens from a MySQL table - MySQL contains books table + cy.createDatasourceScreen(["books"]) + + cy.get(".nav-items-container").contains("books").click() + cy.get(".nav-items-container").should('contain', 'books/:id') + .and('contain', 'books/new/row') + }) + } + }) }) diff --git a/packages/builder/cypress/integration/changeAppIconAndColour.spec.js b/packages/builder/cypress/integration/changeAppIconAndColour.spec.js index 0db2d49e3f..0f623ddb04 100644 --- a/packages/builder/cypress/integration/changeAppIconAndColour.spec.js +++ b/packages/builder/cypress/integration/changeAppIconAndColour.spec.js @@ -11,7 +11,7 @@ filterTests(['all'], () => { cy.applicationInAppTable("Cypress Tests") cy.get(".appTable") .within(() => { - cy.get(".spectrum-Icon").eq(1).click() + cy.get(".app-row-actions-icon").eq(0).click() }) cy.get(".spectrum-Menu").contains("Edit icon").click() // Select random icon @@ -38,6 +38,7 @@ filterTests(['all'], () => { cy.get(".title").children().children() .should('have.attr', 'style').and('contains', 'color') }) + cy.deleteAllApps() }) }) }) diff --git a/packages/builder/cypress/integration/createApp.spec.js b/packages/builder/cypress/integration/createApp.spec.js index 4867534241..13c5979df9 100644 --- a/packages/builder/cypress/integration/createApp.spec.js +++ b/packages/builder/cypress/integration/createApp.spec.js @@ -25,9 +25,13 @@ filterTests(['smoke', 'all'], () => { cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) - if (Cypress.env("TEST_ENV")) { - cy.get(".spectrum-Button").contains("Templates").click({force: true}) - } + cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) + .its("body") + .then(val => { + if (val.length > 0) { + cy.get(".spectrum-Button").contains("Templates").click({force: true}) + } + }) cy.get(".template-category-filters").should("exist") cy.get(".template-categories").should("exist") diff --git a/packages/builder/cypress/integration/createAutomation.spec.js b/packages/builder/cypress/integration/createAutomation.spec.js index b9355f7faf..69ef3f98a3 100644 --- a/packages/builder/cypress/integration/createAutomation.spec.js +++ b/packages/builder/cypress/integration/createAutomation.spec.js @@ -11,7 +11,7 @@ filterTests(['smoke', 'all'], () => { cy.createTestTableWithData() cy.wait(2000) cy.contains("Automate").click() - cy.get("[data-cy='new-screen'] > .spectrum-Icon").click() + cy.get(".add-button .spectrum-Icon").click() cy.get(".modal-inner-wrapper").within(() => { cy.get("input").type("Add Row") cy.contains("Row Created").click({ force: true }) @@ -20,7 +20,6 @@ filterTests(['smoke', 'all'], () => { }) // Setup trigger - cy.contains("Setup").click() cy.get(".spectrum-Picker-label").click() cy.wait(500) cy.contains("dog").click() @@ -32,12 +31,11 @@ filterTests(['smoke', 'all'], () => { cy.contains("Create Row").trigger('mouseover').click().click() cy.get(".spectrum-Button--cta").click() }) - cy.contains("Setup").click() cy.get(".spectrum-Picker-label").eq(1).click() cy.contains("dog").click() cy.get(".spectrum-Textfield-input") - .first() - .type("{{ trigger.row.name }}", { parseSpecialCharSequences: false }) + .first() + .type("{{ trigger.row.name }}", { parseSpecialCharSequences: false }) cy.get(".spectrum-Textfield-input") .eq(1) .type("11") diff --git a/packages/builder/cypress/integration/createBinding.spec.js b/packages/builder/cypress/integration/createBinding.spec.js index 8bf1ec8ea4..57cd0cc5fc 100644 --- a/packages/builder/cypress/integration/createBinding.spec.js +++ b/packages/builder/cypress/integration/createBinding.spec.js @@ -26,7 +26,7 @@ filterTests(['smoke', 'all'], () => { it("should add a URL param binding", () => { const paramName = "foo" - cy.createScreen("Test Param", `/test/:${paramName}`) + cy.createScreen(`/test/:${paramName}`) cy.addComponent("Elements", "Paragraph").then(componentId => { addSettingBinding("text", `URL.${paramName}`) // The builder preview pages don't have a real URL, so all we can do diff --git a/packages/builder/cypress/integration/createScreen.js b/packages/builder/cypress/integration/createScreen.js index ada68d82dc..ae10577ff0 100644 --- a/packages/builder/cypress/integration/createScreen.js +++ b/packages/builder/cypress/integration/createScreen.js @@ -9,17 +9,33 @@ filterTests(["smoke", "all"], () => { }) it("Should successfully create a screen", () => { - cy.createScreen("Test Screen", "/test") + cy.createScreen("/test") cy.get(".nav-items-container").within(() => { cy.contains("/test").should("exist") }) }) it("Should update the url", () => { - cy.createScreen("Test Screen", "test with spaces") + cy.createScreen("test with spaces") cy.get(".nav-items-container").within(() => { cy.contains("/test-with-spaces").should("exist") }) }) + + it("Should create a blank screen with the selected access level", () => { + cy.createScreen("admin only", "Admin") + + cy.get(".nav-items-container").within(() => { + cy.contains("/admin-only").should("exist") + }) + + cy.createScreen("open to all", "Public") + + cy.get(".nav-items-container").within(() => { + cy.contains("/open-to-all").should("exist") + //The access level should now be set to admin. Previous screens should be filtered. + cy.get(".nav-item").contains("/test-screen").should("not.exist") + }) + }) }) }) diff --git a/packages/builder/cypress/integration/createTable.spec.js b/packages/builder/cypress/integration/createTable.spec.js index 81b7c2f045..4600807cbc 100644 --- a/packages/builder/cypress/integration/createTable.spec.js +++ b/packages/builder/cypress/integration/createTable.spec.js @@ -55,13 +55,14 @@ filterTests(["smoke", "all"], () => { if (Cypress.env("TEST_ENV")) { // No Pagination in CI - Test env only for the next two tests - it("Adds 15 rows and checks pagination", () => { + xit("Adds 15 rows and checks pagination", () => { // 10 rows per page, 15 rows should create 2 pages within table const totalRows = 16 for (let i = 1; i < totalRows; i++) { cy.addRow([i]) } - cy.wait(1000) + cy.reload() + cy.wait(2000) cy.get(".spectrum-Pagination").within(() => { cy.get(".spectrum-ActionButton").eq(1).click() }) @@ -70,13 +71,13 @@ filterTests(["smoke", "all"], () => { }) }) - it("Deletes rows and checks pagination", () => { - // Delete rows, removing second page of rows from table - const deleteRows = 5 + xit("Deletes rows and checks pagination", () => { + // Delete rows, removing second page from table cy.get(".spectrum-Checkbox-input").check({ force: true }) - cy.get(".spectrum-Table") - cy.contains("Delete 5 row(s)").click() - cy.get(".spectrum-Modal").contains("Delete").click() + cy.get(".popovers").within(() => { + cy.get(".spectrum-Button").click({ force: true }) + }) + cy.get(".spectrum-Dialog-grid").contains("Delete").click({ force: true }) cy.wait(1000) // Confirm table only has one page diff --git a/packages/builder/cypress/integration/createView.spec.js b/packages/builder/cypress/integration/createView.spec.js index a8c3b03cee..feaf1c3b5f 100644 --- a/packages/builder/cypress/integration/createView.spec.js +++ b/packages/builder/cypress/integration/createView.spec.js @@ -125,7 +125,7 @@ filterTests(['smoke', 'all'], () => { it("renames a view", () => { cy.contains(".nav-item", "Test View") - .find(".actions .icon") + .find(".actions .icon.open-popover") .click({ force: true }) cy.get(".spectrum-Menu-itemLabel").contains("Edit").click() cy.get(".modal-inner-wrapper").within(() => { @@ -138,7 +138,7 @@ filterTests(['smoke', 'all'], () => { it("deletes a view", () => { cy.contains(".nav-item", "Test View Updated") - .find(".actions .icon") + .find(".actions .icon.open-popover") .click({ force: true }) cy.contains("Delete").click() cy.contains("Delete View").click() diff --git a/packages/builder/cypress/integration/datasources/mySql.spec.js b/packages/builder/cypress/integration/datasources/mySql.spec.js index 03f59a6004..98bb2f2acf 100644 --- a/packages/builder/cypress/integration/datasources/mySql.spec.js +++ b/packages/builder/cypress/integration/datasources/mySql.spec.js @@ -19,6 +19,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Button") .contains("Save and fetch tables") .click({ force: true }) + cy.wait(500) // Intercept Request after button click & apply assertions cy.wait("@datasource") cy.get("@datasource") @@ -31,6 +32,7 @@ filterTests(["all"], () => { cy.get("@datasource") .its("response.body") .should("have.property", "status", 500) + cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) it("should add MySQL data source and fetch tables", () => { @@ -72,10 +74,13 @@ filterTests(["all"], () => { cy.get(".spectrum-Popover").contains("COUNTRIES").click() cy.get(".spectrum-Picker").eq(4).click() cy.get(".spectrum-Popover").contains("REGION_ID").click() - // Save relationship & reload page - cy.get(".spectrum-Button").contains("Save").click({ force: true }) - cy.reload() }) + // Save relationship & reload page + cy.get(".spectrum-ButtonGroup").within(() => { + cy.get(".spectrum-Button").contains("Save").click({ force: true }) + }) + cy.reload() + // Confirm table length & column name cy.get(".spectrum-Table") .eq(1) @@ -131,7 +136,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Table") .eq(1) .within(() => { - cy.get(".spectrum-Table-row").eq(0).click() + cy.get(".spectrum-Table-row").eq(0).click({ force: true }) cy.wait(500) }) cy.get(".spectrum-Dialog-grid").within(() => { @@ -175,11 +180,12 @@ filterTests(["all"], () => { }) it("should duplicate a query", () => { - // Get last nav item - The query + /// Get query nav item - QueryName cy.get(".nav-item") - .last() + .contains(queryName) + .parent() .within(() => { - cy.get(".icon").eq(1).click({ force: true }) + cy.get(".spectrum-Icon").eq(1).click({ force: true }) }) // Select and confirm duplication cy.get(".spectrum-Menu").contains("Duplicate").click() @@ -199,23 +205,21 @@ filterTests(["all"], () => { }) it("should delete a query", () => { - // Get last nav item - The query - for (let i = 0; i < 2; i++) { - cy.get(".nav-item") - .last() - .within(() => { - cy.get(".icon").eq(1).click({ force: true }) - }) - // Select Delete - cy.get(".spectrum-Menu").contains("Delete").click() - cy.get(".spectrum-Button") - .contains("Delete Query") - .click({ force: true }) - cy.wait(1000) - } + // Get query nav item - QueryName + cy.get(".nav-item") + .contains(queryName) + .parent() + .within(() => { + cy.get(".spectrum-Icon").eq(1).click({ force: true }) + }) + // Select Delete + cy.get(".spectrum-Menu").contains("Delete").click() + cy.get(".spectrum-Button") + .contains("Delete Query") + .click({ force: true }) + cy.wait(1000) // Confirm deletion cy.get(".nav-item").should("not.contain", queryName) - cy.get(".nav-item").should("not.contain", queryRename) }) } }) diff --git a/packages/builder/cypress/integration/datasources/oracle.spec.js b/packages/builder/cypress/integration/datasources/oracle.spec.js index 73c25001c9..4c4d33d654 100644 --- a/packages/builder/cypress/integration/datasources/oracle.spec.js +++ b/packages/builder/cypress/integration/datasources/oracle.spec.js @@ -46,9 +46,10 @@ filterTests(["all"], () => { cy.get("@datasource") .its("response.body") .should("have.property", "status", 500) + cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - it("should add Oracle data source and fetch tables", () => { + xit("should add Oracle data source and fetch tables", () => { // Add & configure Oracle data source cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") @@ -64,7 +65,7 @@ filterTests(["all"], () => { .should("be.gt", 0) }) - it("should define a One relationship type", () => { + xit("should define a One relationship type", () => { // Select relationship type & configure cy.get(".spectrum-Button") .contains("Define relationship") @@ -93,7 +94,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Table-cell").should("contain", "COUNTRIES to REGIONS") }) - it("should define a Many relationship type", () => { + xit("should define a Many relationship type", () => { // Select relationship type & configure cy.get(".spectrum-Button") .contains("Define relationship") @@ -127,7 +128,7 @@ filterTests(["all"], () => { ) }) - it("should delete relationships", () => { + xit("should delete relationships", () => { // Delete both relationships cy.get(".spectrum-Table") .eq(1) @@ -156,7 +157,7 @@ filterTests(["all"], () => { }) }) - it("should add a query", () => { + xit("should add a query", () => { // Add query cy.get(".spectrum-Button").contains("Add query").click({ force: true }) cy.get(".spectrum-Form-item") @@ -181,7 +182,7 @@ filterTests(["all"], () => { cy.get(".nav-item").should("contain", queryName) }) - it("should duplicate a query", () => { + xit("should duplicate a query", () => { // Get query nav item cy.get(".nav-item") .contains(queryName) @@ -194,7 +195,7 @@ filterTests(["all"], () => { cy.get(".nav-item").should("contain", queryName + " (1)") }) - it("should edit a query name", () => { + xit("should edit a query name", () => { // Rename query cy.get(".spectrum-Form-item") .eq(0) @@ -206,7 +207,7 @@ filterTests(["all"], () => { cy.get(".nav-item").should("contain", queryRename) }) - it("should delete a query", () => { + xit("should delete a query", () => { // Get query nav item - QueryName cy.get(".nav-item") .contains(queryName) diff --git a/packages/builder/cypress/integration/datasources/postgreSql.spec.js b/packages/builder/cypress/integration/datasources/postgreSql.spec.js index 3f55636623..c7413bb7d1 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -21,16 +21,10 @@ filterTests(["all"], () => { .click({ force: true }) // Intercept Request after button click & apply assertions cy.wait("@datasource") - cy.get("@datasource") - .its("response.body") - .should( - "have.property", - "message", - "connect ECONNREFUSED 127.0.0.1:5432" - ) cy.get("@datasource") .its("response.body") .should("have.property", "status", 500) + cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) it("should add PostgreSQL data source and fetch tables", () => { @@ -113,13 +107,13 @@ filterTests(["all"], () => { }) it("should delete a relationship", () => { - cy.get(".hierarchy-items-container").contains(datasource).click() + cy.get(".hierarchy-items-container").contains("PostgreSQL-2").click() cy.reload() // Delete one relationship cy.get(".spectrum-Table") .eq(1) .within(() => { - cy.get(".spectrum-Table-row").eq(0).click() + cy.get(".spectrum-Table-row").eq(0).click({ force: true }) cy.wait(500) }) cy.get(".spectrum-Dialog-grid").within(() => { @@ -161,7 +155,7 @@ filterTests(["all"], () => { it("should switch to schema with no tables", () => { // Switch Schema - To one without any tables - cy.get(".hierarchy-items-container").contains(datasource).click() + cy.get(".hierarchy-items-container").contains("PostgreSQL-2").click() switchSchema("randomText") // No tables displayed @@ -208,11 +202,12 @@ filterTests(["all"], () => { }) it("should duplicate a query", () => { - // Get last nav item - The query + // Locate previously created query cy.get(".nav-item") - .last() + .contains(queryName) + .siblings(".actions") .within(() => { - cy.get(".icon").eq(1).click({ force: true }) + cy.get(".icon").click({ force: true }) }) // Select and confirm duplication cy.get(".spectrum-Menu").contains("Duplicate").click() @@ -240,23 +235,21 @@ filterTests(["all"], () => { }) it("should delete a query", () => { - // Get last nav item - The query - for (let i = 0; i < 2; i++) { - cy.get(".nav-item") - .last() - .within(() => { - cy.get(".icon").eq(1).click({ force: true }) - }) - // Select Delete - cy.get(".spectrum-Menu").contains("Delete").click() - cy.get(".spectrum-Button") - .contains("Delete Query") - .click({ force: true }) - cy.wait(1000) - } + // Get query nav item - QueryName + cy.get(".nav-item") + .contains(queryName) + .parent() + .within(() => { + cy.get(".spectrum-Icon").eq(1).click({ force: true }) + }) + // Select Delete + cy.get(".spectrum-Menu").contains("Delete").click() + cy.get(".spectrum-Button") + .contains("Delete Query") + .click({ force: true }) + cy.wait(1000) // Confirm deletion cy.get(".nav-item").should("not.contain", queryName) - cy.get(".nav-item").should("not.contain", queryRename) }) const switchSchema = schema => { diff --git a/packages/builder/cypress/integration/renameAnApplication.spec.js b/packages/builder/cypress/integration/renameAnApplication.spec.js index f4899f98a0..120c0d54d7 100644 --- a/packages/builder/cypress/integration/renameAnApplication.spec.js +++ b/packages/builder/cypress/integration/renameAnApplication.spec.js @@ -99,30 +99,32 @@ filterTests(['all'], () => { cy.searchForApplication(originalName) cy.get(".appTable") .within(() => { - cy.get(".spectrum-Icon").eq(1).click() - }) - // Check for when an app is published - if (published == true) { - // Should not have Edit as option, will unpublish app - cy.should("not.have.value", "Edit") - cy.get(".spectrum-Menu").contains("Unpublish").click() - cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() - cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() - } - cy.contains("Edit").click() - cy.get(".spectrum-Modal") - .within(() => { - if (noName == true) { - cy.get("input").clear() - cy.get(".spectrum-Dialog-grid").click() - .contains("App name must be letters, numbers and spaces only") - return cy - } + cy.get("[aria-label='More']").eq(0).click() + }) + // Check for when an app is published + if (published == true) { + // Should not have Edit as option, will unpublish app + cy.should("not.have.value", "Edit") + cy.get(".spectrum-Menu").contains("Unpublish").click() + cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() + cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() + } + cy.get("[data-cy='app-row-actions-menu-popover']").eq(0).within(() => { + cy.get(".spectrum-Menu-item").contains("Edit").click({ force: true }) + }) + cy.get(".spectrum-Modal") + .within(() => { + if (noName == true) { cy.get("input").clear() - cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() - cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true }) - cy.wait(500) - }) - } + cy.get(".spectrum-Dialog-grid").click() + .contains("App name must be letters, numbers and spaces only") + return cy + } + cy.get("input").clear() + cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() + cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true }) + cy.wait(500) + }) + } }) }) diff --git a/packages/builder/cypress/integration/revertApp.spec.js b/packages/builder/cypress/integration/revertApp.spec.js index c64d19f230..9d5e4f0f63 100644 --- a/packages/builder/cypress/integration/revertApp.spec.js +++ b/packages/builder/cypress/integration/revertApp.spec.js @@ -10,9 +10,9 @@ filterTests(['smoke', 'all'], () => { it("should try to revert an unpublished app", () => { // Click revert icon cy.get(".toprightnav").within(() => { - cy.get(".spectrum-Icon").eq(1).click() + cy.get("[aria-label='Revert']").click({ force: true }) }) - cy.get(".spectrum-Dialog-grid").within(() => { + cy.get(".spectrum-Modal").within(() => { // Enter app name before revert cy.get("input").type("Cypress Tests") cy.intercept('**/revert').as('revertApp') @@ -33,11 +33,15 @@ filterTests(['smoke', 'all'], () => { cy.get(".spectrum-ButtonGroup").within(() => { cy.get(".spectrum-Button").contains("Publish").click({ force: true }) }) + cy.wait(1000) + cy.get(".spectrum-ButtonGroup").within(() => { + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) // Add second component - Button cy.addComponent("Elements", "Button") // Click Revert cy.get(".toprightnav").within(() => { - cy.get(".spectrum-Icon").eq(1).click() + cy.get("[aria-label='Revert']").click({ force: true }) }) cy.get(".spectrum-Dialog-grid").within(() => { // Click Revert @@ -54,7 +58,7 @@ filterTests(['smoke', 'all'], () => { it("should enter incorrect app name when reverting", () => { // Click Revert cy.get(".toprightnav").within(() => { - cy.get(".spectrum-Icon").eq(1).click({ force: true }) + cy.get("[aria-label='Revert']").click({ force: true }) }) // Enter incorrect app name cy.get(".spectrum-Dialog-grid").within(() => { diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index f4ccdcca24..6c2cac5b31 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -32,7 +32,17 @@ Cypress.Commands.add("login", () => { }) }) -Cypress.Commands.add("createApp", name => { +Cypress.Commands.add("closeModal", () => { + cy.get(".spectrum-Modal").within(() => { + cy.get(".close-icon").click() + cy.wait(500) + }) +}) + +Cypress.Commands.add("createApp", (name, addDefaultTable) => { + const shouldCreateDefaultTable = + typeof addDefaultTable != "boolean" ? true : addDefaultTable + cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) @@ -51,7 +61,9 @@ Cypress.Commands.add("createApp", name => { cy.get(".spectrum-ButtonGroup").contains("Create app").click() cy.wait(10000) }) - cy.createTable("Cypress Tests", true) + if (shouldCreateDefaultTable) { + cy.createTable("Cypress Tests", true) + } }) Cypress.Commands.add("deleteApp", name => { @@ -60,43 +72,48 @@ Cypress.Commands.add("deleteApp", name => { cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) .its("body") .then(val => { - if (val.length > 0) { - if (Cypress.env("TEST_ENV")) { - cy.searchForApplication(name) - cy.get(".appTable").within(() => { - cy.get(".spectrum-Icon").eq(1).click() + const findAppName = val.some(val => val.name == name) + if (findAppName) { + if (val.length > 0) { + if (Cypress.env("TEST_ENV")) { + cy.searchForApplication(name) + cy.get(".appTable").within(() => { + cy.get(".spectrum-Icon").eq(1).click() + }) + } else { + const appId = val.reduce((acc, app) => { + if (name === app.name) { + acc = app.appId + } + return acc + }, "") + + if (appId == "") { + return + } + + const appIdParsed = appId.split("_").pop() + const actionEleId = `[data-cy=row_actions_${appIdParsed}]` + cy.get(actionEleId).within(() => { + cy.get(".spectrum-Icon").eq(0).click() + }) + } + + cy.get(".spectrum-Menu").then($menu => { + if ($menu.text().includes("Unpublish")) { + cy.get(".spectrum-Menu").contains("Unpublish").click() + cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() + } else { + cy.get(".spectrum-Menu").contains("Delete").click() + cy.get(".spectrum-Dialog-grid").within(() => { + cy.get("input").type(name) + }) + cy.get(".spectrum-Button--warning").click() + } }) } else { - const appId = val.reduce((acc, app) => { - if (name === app.name) { - acc = app.appId - } - return acc - }, "") - - if (appId == "") { - return - } - - const appIdParsed = appId.split("_").pop() - const actionEleId = `[data-cy=row_actions_${appIdParsed}]` - cy.get(actionEleId).within(() => { - cy.get(".spectrum-Icon").eq(0).click() - }) + return } - - cy.get(".spectrum-Menu").then($menu => { - if ($menu.text().includes("Unpublish")) { - cy.get(".spectrum-Menu").contains("Unpublish").click() - cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() - } else { - cy.get(".spectrum-Menu").contains("Delete").click() - cy.get(".spectrum-Dialog-grid").within(() => { - cy.get("input").type(name) - }) - cy.get(".spectrum-Button--warning").click() - } - }) } else { return } @@ -130,7 +147,7 @@ Cypress.Commands.add("createTestApp", () => { const appName = "Cypress Tests" cy.deleteApp(appName) cy.createApp(appName, "This app is used for Cypress testing.") - cy.createScreen("home", "home") + cy.createScreen("home") }) Cypress.Commands.add("createTestTableWithData", () => { @@ -270,33 +287,99 @@ Cypress.Commands.add("navigateToDataSection", () => { cy.contains("Data").click() }) -Cypress.Commands.add("createScreen", (screenName, route) => { +//Blank +Cypress.Commands.add("createScreen", (route, accessLevelLabel) => { cy.contains("Design").click() cy.get("[aria-label=AddCircle]").click() cy.get(".spectrum-Modal").within(() => { - cy.get(".item").contains("Blank").click() - cy.get(".spectrum-Button").contains("Add screens").click({ force: true }) + cy.get("[data-cy='blank-screen']").click() + cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.wait(500) }) cy.get(".spectrum-Dialog-grid").within(() => { - cy.get(".spectrum-Form-itemField").eq(0).type(screenName) - cy.get(".spectrum-Form-itemField").eq(1).type(route) + cy.get(".spectrum-Form-itemField").eq(0).type(route) cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.wait(1000) }) + + cy.get(".spectrum-Modal").within(() => { + if (accessLevelLabel) { + cy.get(".spectrum-Picker-label").click() + cy.wait(500) + cy.contains(accessLevelLabel).click() + } + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) }) -Cypress.Commands.add("createAutogeneratedScreens", screenNames => { +Cypress.Commands.add( + "createDatasourceScreen", + (datasourceNames, accessLevelLabel) => { + cy.contains("Design").click() + cy.get("[aria-label=AddCircle]").click() + cy.get(".spectrum-Modal").within(() => { + cy.get(".item").contains("Autogenerated screens").click() + cy.get(".spectrum-Button").contains("Continue").click({ force: true }) + cy.wait(500) + }) + cy.get(".spectrum-Modal [data-cy='data-source-modal']").within(() => { + for (let i = 0; i < datasourceNames.length; i++) { + cy.get(".data-source-entry").contains(datasourceNames[i]).click() + //Ensure the check mark is visible + cy.get(".data-source-entry") + .contains(datasourceNames[i]) + .get(".data-source-check") + .should("exist") + } + + cy.get(".spectrum-Button").contains("Confirm").click({ force: true }) + }) + + cy.get(".spectrum-Modal").within(() => { + if (accessLevelLabel) { + cy.get(".spectrum-Picker-label").click() + cy.wait(500) + cy.contains(accessLevelLabel).click() + } + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) + + cy.contains("Design").click() + } +) + +Cypress.Commands.add("navigateToAutogeneratedModal", () => { // Screen name must already exist within data source cy.contains("Design").click() cy.get("[aria-label=AddCircle]").click() - for (let i = 0; i < screenNames.length; i++) { - cy.get(".item").contains(screenNames[i]).click() - } - cy.get(".spectrum-Button").contains("Add screens").click({ force: true }) - cy.wait(4000) + cy.get(".spectrum-Modal").within(() => { + cy.get(".item").contains("Autogenerated screens").click() + cy.get(".spectrum-Button").contains("Continue").click({ force: true }) + cy.wait(500) + }) }) +Cypress.Commands.add( + "createAutogeneratedScreens", + (screenNames, accessLevelLabel) => { + cy.navigateToAutogeneratedModal() + + for (let i = 0; i < screenNames.length; i++) { + cy.get(".data-source-entry").contains(screenNames[i]).click() + } + + cy.get(".spectrum-Modal").within(() => { + if (accessLevelLabel) { + cy.get(".spectrum-Picker-label").click() + cy.wait(500) + cy.contains(accessLevelLabel).click() + } + cy.get(".spectrum-Button").contains("Confirm").click({ force: true }) + cy.wait(4000) + }) + } +) + Cypress.Commands.add("addRow", values => { cy.contains("Create row").click() cy.get(".spectrum-Modal").within(() => { @@ -390,6 +473,7 @@ Cypress.Commands.add("selectExternalDatasource", datasourceName => { cy.get(".add-button").click() }) // Clicks specified datasource & continue + cy.wait(1000) cy.get(".item-list").contains(datasourceName).click() cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Button").contains("Continue").click({ force: true }) @@ -410,7 +494,9 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => { if (datasource == "Oracle") { cy.get("input").clear().type(Cypress.env("oracle").HOST) } else { - cy.get("input").clear().type(Cypress.env("HOST_IP")) + cy.get("input") + .clear({ force: true }) + .type(Cypress.env("HOST_IP"), { force: true }) } }) }) diff --git a/packages/builder/package.json b/packages/builder/package.json index 6dde63596e..fa64a8acb2 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.0.105-alpha.10", + "version": "1.0.124-alpha.0", "license": "GPL-3.0", "private": true, "scripts": { @@ -65,10 +65,10 @@ } }, "dependencies": { - "@budibase/bbui": "^1.0.105-alpha.10", - "@budibase/client": "^1.0.105-alpha.10", - "@budibase/frontend-core": "^1.0.105-alpha.10", - "@budibase/string-templates": "^1.0.105-alpha.10", + "@budibase/bbui": "^1.0.124-alpha.0", + "@budibase/client": "^1.0.124-alpha.0", + "@budibase/frontend-core": "^1.0.124-alpha.0", + "@budibase/string-templates": "^1.0.124-alpha.0", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/src/analytics/constants.js b/packages/builder/src/analytics/constants.js index 8d6e94acbb..4e7ecc6535 100644 --- a/packages/builder/src/analytics/constants.js +++ b/packages/builder/src/analytics/constants.js @@ -23,6 +23,7 @@ export const Events = { }, SCREEN: { CREATED: "Screen Created", + CREATE_ROLE_UPDATED: "Changed Role On Screen Creation", }, AUTOMATION: { CREATED: "Automation Created", @@ -36,6 +37,7 @@ export const Events = { CREATED: "budibase:app_created", PUBLISHED: "budibase:app_published", UNPUBLISHED: "budibase:app_unpublished", + VIEW_PUBLISHED: "budibase:view_published_app", }, ANALYTICS: { OPT_IN: "budibase:analytics_opt_in", @@ -51,3 +53,9 @@ export const Events = { SAVED: "budibase:sso_saved", }, } + +export const EventSource = { + PORTAL: "portal", + URL: "url", + NOTIFICATION: "notification", +} diff --git a/packages/builder/src/analytics/index.js b/packages/builder/src/analytics/index.js index be77dae8f9..1283bce649 100644 --- a/packages/builder/src/analytics/index.js +++ b/packages/builder/src/analytics/index.js @@ -2,7 +2,7 @@ import { API } from "api" import PosthogClient from "./PosthogClient" import IntercomClient from "./IntercomClient" import SentryClient from "./SentryClient" -import { Events } from "./constants" +import { Events, EventSource } from "./constants" const posthog = new PosthogClient( process.env.POSTHOG_TOKEN, @@ -58,5 +58,5 @@ class AnalyticsHub { const analytics = new AnalyticsHub() -export { Events } +export { Events, EventSource } export default analytics diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 8dff56dd6b..8cbc629291 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -654,7 +654,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => { * Builds a form schema given a form component. * A form schema is a schema of all the fields nested anywhere within a form. */ -const buildFormSchema = component => { +export const buildFormSchema = component => { let schema = {} if (!component) { return schema diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index 4e1e5e1103..caf8835b86 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -39,6 +39,7 @@ if (v.internal) { acc[k] = v } + delete acc.LOOP return acc }, {}) diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte index ca04fed8df..505a0b9aca 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte @@ -72,7 +72,9 @@ animate:flip={{ duration: 500 }} in:fly|local={{ x: 500, duration: 1500 }} > - + {#if block.stepId !== "LOOP"} + + {/if} {/each} diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte index 69dd67724a..0829b85a90 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte @@ -9,8 +9,8 @@ Modal, Button, StatusLight, - ActionButton, Select, + ActionButton, notifications, } from "@budibase/bbui" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" @@ -25,8 +25,8 @@ let webhookModal let actionModal let resultsModal - let setupToggled let blockComplete + let showLooping = false $: rowControl = $automationStore.selectedAutomation.automation.rowControl $: showBindingPicker = @@ -48,12 +48,21 @@ $automationStore.selectedAutomation?.automation?.definition?.steps.length + 1 - $: hasCompletedInputs = Object.keys( - block.schema?.inputs?.properties || {} - ).every(x => block?.inputs[x]) + $: loopingSelected = + $automationStore.selectedAutomation?.automation.definition.steps.find( + x => x.blockToLoop === block.id + ) async function deleteStep() { + let loopBlock = + $automationStore.selectedAutomation?.automation.definition.steps.find( + x => x.blockToLoop === block.id + ) + try { + if (loopBlock) { + automationStore.actions.deleteAutomationBlock(loopBlock) + } automationStore.actions.deleteAutomationBlock(block) await automationStore.actions.save( $automationStore.selectedAutomation?.automation @@ -76,6 +85,23 @@ ) } + async function addLooping() { + loopingSelected = true + const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP + + const loopBlock = $automationStore.selectedAutomation.constructBlock( + "ACTION", + "LOOP", + loopDefinition + ) + loopBlock.blockToLoop = block.id + block.loopBlock = loopBlock.id + automationStore.actions.addBlockToAutomation(loopBlock, blockIdx) + await automationStore.actions.save( + $automationStore.selectedAutomation?.automation + ) + } + async function onSelect(block) { await automationStore.update(state => { state.selectedBlock = block @@ -84,13 +110,68 @@ } -
{ - onSelect(block) - }} -> +
{}}> + {#if loopingSelected} +
+
{ + showLooping = !showLooping + }} + class="splitHeader" + > +
+ + + +
+ Looping +
+
+ +
+
{ + onSelect(block) + }} + > + +
+
+
+
+ + + {#if !showLooping} +
+
+
deleteStep()}> + +
+
+ + x.blockToLoop === block.id + )} + {webhookModal} + /> + +
+ + {/if} + {/if} +
{ @@ -127,65 +208,66 @@ {block?.name?.toUpperCase() || ""}
- {#if testResult && testResult[0]} - resultsModal.show()}> - View response - - {/if} +
+ {#if testResult && testResult[0]} +
resultsModal.show()}> + View response +
+ {/if} +
{ + onSelect(block) + }} + > + +
+
{#if !blockComplete}
-
- { - onSelect(block) - setupToggled = !setupToggled - }} - quiet - icon={setupToggled ? "ChevronDown" : "ChevronRight"} - > - Setup - - {#if !isTrigger} + {#if !isTrigger} +
- {#if showBindingPicker} -
- + {/if} + deleteStep()} + icon="DeleteOutline" + />
- {/if} -
+
+ {/if} - {#if setupToggled} - - {#if lastStep} - - {/if} + + {#if lastStep} + {/if}
@@ -204,13 +286,7 @@
- actionModal.show()} - disabled={!hasCompletedInputs} - hoverable - name="AddCircle" - size="S" -/> + actionModal.show()} hoverable name="AddCircle" size="S" /> {#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
{/if} @@ -220,8 +296,10 @@ padding-left: 30px; } .block-options { - display: flex; + justify-content: flex-end; align-items: center; + display: flex; + gap: var(--spacing-m); } .center-items { display: flex; @@ -256,4 +334,9 @@ /* center horizontally */ align-self: center; } + + .blockTitle { + display: flex; + align-items: center; + } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte index 7dfdff20a7..9662bc8ade 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte @@ -1,5 +1,5 @@ -{#if type === "options"} +{#if type === "options" && meta.constraints.inclusion.length !== 0}
copyToClipboard(value || copyValue)}> diff --git a/packages/builder/src/components/deploy/DeployModal.svelte b/packages/builder/src/components/deploy/DeployModal.svelte index 936bb6cc7e..0018d644ac 100644 --- a/packages/builder/src/components/deploy/DeployModal.svelte +++ b/packages/builder/src/components/deploy/DeployModal.svelte @@ -1,26 +1,62 @@ @@ -32,11 +68,13 @@ showCancelButton={false} /> + The changes you have made will be published to the production version of @@ -44,3 +82,57 @@ > + + + + + + + + + + + + + +
+ + + + App Published! +
+ +
+
+ + diff --git a/packages/builder/src/components/deploy/DeployNavigation.svelte b/packages/builder/src/components/deploy/DeployNavigation.svelte new file mode 100644 index 0000000000..ded75652cc --- /dev/null +++ b/packages/builder/src/components/deploy/DeployNavigation.svelte @@ -0,0 +1,189 @@ + + +
+ {#if isPublished} +
+
+ +
+ + + Your published app + + + {processStringSync( + "Last published {{ duration time 'millisecond' }} ago", + { + time: + new Date().getTime() - + new Date(latestDeployments[0].updatedAt).getTime(), + } + )} + + +
+ + +
+
+
+
+ {/if} + + {#if !isPublished} + + {/if} +
+ + Are you sure you want to unpublish the app {selectedApp?.name}? + + + + + diff --git a/packages/builder/src/components/deploy/DeploymentHistory.svelte b/packages/builder/src/components/deploy/DeploymentHistory.svelte index e933142348..eb5c8953cc 100644 --- a/packages/builder/src/components/deploy/DeploymentHistory.svelte +++ b/packages/builder/src/components/deploy/DeploymentHistory.svelte @@ -7,12 +7,10 @@ import { notifications } from "@budibase/bbui" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" import { store } from "builderStore" - - const DeploymentStatus = { - SUCCESS: "SUCCESS", - PENDING: "PENDING", - FAILURE: "FAILURE", - } + import { + checkIncomingDeploymentStatus, + DeploymentStatus, + } from "components/deploy/utils" const DATE_OPTIONS = { fullDate: { @@ -42,30 +40,17 @@ const formatDate = (date, format) => Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) - // Required to check any updated deployment statuses between polls - function checkIncomingDeploymentStatus(current, incoming) { - for (let incomingDeployment of incoming) { - if (incomingDeployment.status === DeploymentStatus.FAILURE) { - const currentDeployment = current.find( - deployment => deployment._id === incomingDeployment._id - ) - - // We have just been notified of an ongoing deployments failure - if ( - !currentDeployment || - currentDeployment.status === DeploymentStatus.PENDING - ) { - showErrorReasonModal(incomingDeployment.err) - } - } - } - } - async function fetchDeployments() { try { const newDeployments = await API.getAppDeployments() if (deployments.length > 0) { - checkIncomingDeploymentStatus(deployments, newDeployments) + const pendingDeployments = checkIncomingDeploymentStatus( + deployments, + newDeployments + ) + if (pendingDeployments.length) { + showErrorReasonModal(pendingDeployments[0].err) + } } deployments = newDeployments } catch (err) { diff --git a/packages/builder/src/components/deploy/utils.js b/packages/builder/src/components/deploy/utils.js new file mode 100644 index 0000000000..cb254f0dbf --- /dev/null +++ b/packages/builder/src/components/deploy/utils.js @@ -0,0 +1,25 @@ +export const DeploymentStatus = { + SUCCESS: "SUCCESS", + PENDING: "PENDING", + FAILURE: "FAILURE", +} + +// Required to check any updated deployment statuses between polls +export function checkIncomingDeploymentStatus(current, incoming) { + return incoming.reduce((acc, incomingDeployment) => { + if (incomingDeployment.status === DeploymentStatus.FAILURE) { + const currentDeployment = current.find( + deployment => deployment._id === incomingDeployment._id + ) + + //We have just been notified of an ongoing deployments failure + if ( + !currentDeployment || + currentDeployment.status === DeploymentStatus.PENDING + ) { + acc.push(incomingDeployment) + } + } + return acc + }, []) +} diff --git a/packages/builder/src/components/design/NavigationPanel/DatasourceModal.svelte b/packages/builder/src/components/design/NavigationPanel/DatasourceModal.svelte new file mode 100644 index 0000000000..1cb3856165 --- /dev/null +++ b/packages/builder/src/components/design/NavigationPanel/DatasourceModal.svelte @@ -0,0 +1,199 @@ + + + + + + {#each filteredSources as datasource} +
+
+
+ +
+
{datasource.name}
+
+ {#if Array.isArray(datasource.entities)} + {#each datasource.entities.filter(table => table._id !== "ta_users") as table} +
x.table === table.name + )} + on:click={() => toggleScreenSelection(table, datasource)} + > + + + + {table.name} + + {#if selectedScreens.find(x => x.table === table.name)} + + + + {/if} +
+ {/each} + {/if} + {#if datasource["entities"] && !Array.isArray(datasource.entities)} + {#each Object.keys(datasource.entities).filter(table => table._id !== "ta_users") as table_key} +
x.table === datasource.entities[table_key].name + )} + on:click={() => + toggleScreenSelection( + datasource.entities[table_key], + datasource + )} + > + + + + {datasource.entities[table_key].name} + + {#if selectedScreens.find(x => x.table === datasource.entities[table_key].name)} + + + + {/if} +
+ {/each} + {/if} +
+ {/each} +
+
+
+ + diff --git a/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte b/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte index cd83d81235..8fd22926e4 100644 --- a/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte +++ b/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte @@ -1,117 +1,98 @@
- - Please select the screens you would like to add to your application. - Autogenerated screens come with CRUD functionality. - - Blank screen
x.id.includes(blankScreen))} - on:click={() => - toggleScreenSelection(templates.find(t => t.id === blankScreen))} - class:disabled={autoSelected} + class="screen-type item" + class:selected={selectedScreenMode == blankScreenModeKey} + on:click={() => { + selectedScreenMode = blankScreenModeKey + }} > -
-
Blank
+
+ +
+ Blank screen + Add a blank screen +
- {#if selectedScreens.find(x => x.id === blankScreen)} + {#if selectedScreenMode == blankScreenModeKey}
- +
{/if}
- {#if $tables.list.filter(table => table._id !== "ta_users").length > 0} - Autogenerated Screens - {#each $tables.list.filter(table => table._id !== "ta_users") as table} -
x.table === table.name)} - on:click={() => toggleScreenSelection(table)} - class="item" - > -
-
{table.name}
-
-
- {#if selectedScreens.find(x => x.table === table.name)} -
- -
- {/if} -
+
{ + selectedScreenMode = autoCreateModeKey + }} + class:disabled={!$tables.list.filter(table => table._id !== "ta_users") + .length} + > +
+ +
+ Autogenerated screens + + Add autogenerated screens with CRUD functionality to get a working + app quickly! (Requires a data source) +
- {/each} - {/if} +
+
+ {#if selectedScreenMode == autoCreateModeKey} +
+ +
+ {/if} +
+
-
- {#if showProgressCircle} - - {/if} -
diff --git a/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte b/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte index 75977e51a2..4afd182053 100644 --- a/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte +++ b/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte @@ -1,18 +1,23 @@ - + + + + + newScreenModal.show()} + initalScreens={!selectedTemplates ? [] : [...selectedTemplates]} + /> + + + + + Select which level of access you want your screens to have + x._instanceName} + getOptionValue={x => x._id} + /> + + + {#if bindings?.length} + + {#each bindings as binding} + + {/each} + + {:else if category} + There aren't any bindings available in this category. + {/if} + diff --git a/packages/client/src/components/devtools/DevToolsComponentSetting.svelte b/packages/client/src/components/devtools/DevToolsComponentSetting.svelte new file mode 100644 index 0000000000..9bbbf2615c --- /dev/null +++ b/packages/client/src/components/devtools/DevToolsComponentSetting.svelte @@ -0,0 +1,13 @@ + + +{#if prettyName} + +{/if} diff --git a/packages/client/src/components/devtools/DevToolsComponentSettingsTab.svelte b/packages/client/src/components/devtools/DevToolsComponentSettingsTab.svelte new file mode 100644 index 0000000000..63cecab7ef --- /dev/null +++ b/packages/client/src/components/devtools/DevToolsComponentSettingsTab.svelte @@ -0,0 +1,30 @@ + + + + + + {#each settingsDefinition as setting} + + {/each} + + diff --git a/packages/client/src/components/devtools/DevToolsComponentTab.svelte b/packages/client/src/components/devtools/DevToolsComponentTab.svelte new file mode 100644 index 0000000000..a553a78720 --- /dev/null +++ b/packages/client/src/components/devtools/DevToolsComponentTab.svelte @@ -0,0 +1,102 @@ + + +{#if !$builderStore.selectedComponentId} + + Please choose a component + + Press the button below to enable component selection, then click a + component in your app to view its settings and available data bindings. + +
+ +
+
+{:else} + + + {$componentStore.selectedComponent?._instanceName} + + + + + +
+ + +
+ +
+ + + +
+ +
+
+ +
+ +
+
+
+
+
+
+{/if} + + diff --git a/packages/client/src/components/devtools/DevToolsHeader.svelte b/packages/client/src/components/devtools/DevToolsHeader.svelte new file mode 100644 index 0000000000..3ea528be5b --- /dev/null +++ b/packages/client/src/components/devtools/DevToolsHeader.svelte @@ -0,0 +1,74 @@ + + +
+ Budibase App Preview +