diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..92bd33894 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +packages/server/node_modules +packages/builder +packages/frontend-core +packages/backend-core +packages/worker/node_modules +packages/cli +packages/client +packages/bbui +packages/string-templates diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2a57d6f38..bd2112370 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 d1ba2bc04..b4f773929 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 5875ed128..2a2c10cb7 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 cb27b30f3..0881d2528 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 4467cd6c8..87e4f0988 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 82848c78e..07df3bd42 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 diff --git a/.github/workflows/smoke_test.yaml b/.github/workflows/smoke_test.yaml index 04c0c7b5e..b26d0386f 100644 --- a/.github/workflows/smoke_test.yaml +++ b/.github/workflows/smoke_test.yaml @@ -28,6 +28,7 @@ jobs: - name: Cypress run id: cypress + continue-on-error: true uses: cypress-io/github-action@v2 with: install: false diff --git a/charts/budibase/Chart.yaml b/charts/budibase/Chart.yaml index 134d29441..694c8c77f 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 86e255d33..c80cfa2ec 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -98,10 +98,6 @@ spec: value: http://worker-service:{{ .Values.services.worker.port }} - name: PLATFORM_URL value: {{ .Values.globals.platformUrl | quote }} - - name: USE_QUOTAS - value: {{ .Values.globals.useQuotas | quote }} - - name: EXCLUDE_QUOTAS_TENANTS - value: {{ .Values.globals.excludeQuotasTenants | quote }} - name: ACCOUNT_PORTAL_URL value: {{ .Values.globals.accountPortalUrl | quote }} - name: ACCOUNT_PORTAL_API_KEY @@ -114,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 1072046c8..ae062475c 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-service-deployment.yaml b/charts/budibase/templates/minio-service-deployment.yaml index a23d0c1d8..901fb61ad 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 05bf4a7f1..bd6a5e311 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-service-deployment.yaml b/charts/budibase/templates/redis-service-deployment.yaml index 9235b0b11..0b6cb1256 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 b6c757cb9..c2180aca2 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 648b1d2de..81fdfb63d 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -93,16 +93,15 @@ globals: logLevel: info selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs - useQuotas: "0" - excludeQuotasTenants: "" # comma seperated list of tenants to exclude from quotas accountPortalUrl: "" accountPortalApiKey: "" cookieDomain: "" platformUrl: "" httpMigrations: "0" google: - clientId: "" + clientId: "" secret: "" + automationMaxIterations: "500" createSecrets: true # creates an internal API key, JWT secrets and redis password for you @@ -230,6 +229,8 @@ couchdb: ## Optional tolerations tolerations: [] + affinity: {} + service: # annotations: enabled: true diff --git a/examples/nextjs-api-sales/yarn.lock b/examples/nextjs-api-sales/yarn.lock index 3f32417ba..52c89967b 100644 --- a/examples/nextjs-api-sales/yarn.lock +++ b/examples/nextjs-api-sales/yarn.lock @@ -1894,9 +1894,9 @@ minimist-options@4.1.0: kind-of "^6.0.3" minimist@^1.2.0: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== minipass-collect@^1.0.2: version "1.0.2" diff --git a/hosting/docker-compose.yaml b/hosting/docker-compose.yaml index f3055b19f..f9d9eaf1c 100644 --- a/hosting/docker-compose.yaml +++ b/hosting/docker-compose.yaml @@ -117,7 +117,6 @@ services: labels: - "com.centurylinklabs.watchtower.enable=false" - volumes: couchdb3_data: driver: local diff --git a/hosting/nginx.prod.conf.hbs b/hosting/nginx.prod.conf.hbs index ad6ffb42c..7ef597051 100644 --- a/hosting/nginx.prod.conf.hbs +++ b/hosting/nginx.prod.conf.hbs @@ -42,7 +42,19 @@ http { client_max_body_size 1000m; ignore_invalid_headers off; proxy_buffering off; - # port_in_redirect off; + + set $csp_default "default-src 'self'"; + set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io"; + set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com"; + set $csp_object "object-src 'none'"; + set $csp_base_uri "base-uri 'self'"; + set $csp_connect "connect-src 'self' https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com"; + set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com"; + set $csp_frame "frame-src 'self' https:"; + set $csp_img "img-src http: https: data: blob:"; + set $csp_manifest "manifest-src 'self'"; + 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 { @@ -54,7 +66,7 @@ http { add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; add_header X-XSS-Protection "1; mode=block" always; - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://jspm.dev; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io ; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com; frame-src 'self' https:; img-src http: https: data:; manifest-src 'self'; media-src 'self'; worker-src 'none';" always; + add_header Content-Security-Policy "${csp_default}; ${csp_script}; ${csp_style}; ${csp_object}; ${csp_base_uri}; ${csp_connect}; ${csp_font}; ${csp_frame}; ${csp_img}; ${csp_manifest}; ${csp_media}; ${csp_worker};" always; # upstreams set $apps {{ apps }}; diff --git a/hosting/portainer/template.json b/hosting/portainer/template.json new file mode 100644 index 000000000..29107b674 --- /dev/null +++ b/hosting/portainer/template.json @@ -0,0 +1,94 @@ +{ + "version": "2", + "templates": [ + { + "type": 3, + "title": "Budibase", + "categories": ["Tools"], + "description": "Build modern business apps in minutes", + "logo": "https://budibase.com/favicon.ico", + "platform": "linux", + "repository": { + "url": "https://github.com/Budibase/budibase", + "stackfile": "hosting/docker-compose.yaml" + }, + "env": [ + { + "name": "MAIN_PORT", + "label": "Main port", + "default": "10000" + }, + { + "name": "JWT_SECRET", + "label": "JWT secret", + "default": "change-me" + }, + { + "name": "MINIO_ACCESS_KEY", + "label": "MinIO access key", + "default": "change-me" + }, + { + "name": "MINIO_SECRET_KEY", + "label": "MinIO secret key", + "default": "change-me" + }, + { + "name": "COUCH_DB_USER", + "default": "budibase", + "preset": true + }, + { + "name": "COUCH_DB_PASSWORD", + "label": "Couch DB password", + "default": "change-me" + }, + { + "name": "REDIS_PASSWORD", + "label": "Redis password", + "default": "change-me" + }, + { + "name": "INTERNAL_API_KEY", + "label": "Internal API key", + "default": "change-me" + }, + { + "name": "APP_PORT", + "default": "4002", + "preset": true + }, + { + "name": "WORKER_PORT", + "default": "4003", + "preset": true + }, + { + "name": "MINIO_PORT", + "default": "4004", + "preset": true + }, + { + "name": "COUCH_DB_PORT", + "default": "4005", + "preset": true + }, + { + "name": "REDIS_PORT", + "default": "6379", + "preset": true + }, + { + "name": "WATCHTOWER_PORT", + "default": "6161", + "preset": true + }, + { + "name": "BUDIBASE_ENVIRONMENT", + "default": "PRODUCTION", + "preset": true + } + ] + } + ] +} diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile new file mode 100644 index 000000000..2123d237b --- /dev/null +++ b/hosting/single/Dockerfile @@ -0,0 +1,97 @@ +FROM couchdb + +ENV COUCHDB_PASSWORD=budibase +ENV COUCHDB_USER=budibase +ENV COUCH_DB_URL=http://budibase:budibase@localhost:5984 +ENV BUDIBASE_ENVIRONMENT=PRODUCTION +ENV MINIO_URL=http://localhost:9000 +ENV REDIS_URL=localhost:6379 +ENV WORKER_URL=http://localhost:4002 +ENV INTERNAL_API_KEY=budibase +ENV JWT_SECRET=testsecret +ENV MINIO_ACCESS_KEY=budibase +ENV MINIO_SECRET_KEY=budibase +ENV SELF_HOSTED=1 +ENV CLUSTER_PORT=10000 +ENV REDIS_PASSWORD=budibase +ENV ARCHITECTURE=amd +ENV APP_PORT=4001 +ENV WORKER_PORT=4002 + +RUN apt-get update +RUN apt-get install software-properties-common wget nginx -y +RUN apt-add-repository 'deb http://security.debian.org/debian-security stretch/updates main' +RUN apt-get update + +# setup nginx +ADD hosting/single/nginx.conf /etc/nginx +RUN mkdir /etc/nginx/logs +RUN useradd www +RUN touch /etc/nginx/logs/error.log +RUN touch /etc/nginx/logs/nginx.pid + +# install java +RUN apt-get install openjdk-8-jdk -y + +# setup nodejs +WORKDIR /nodejs +RUN curl -sL https://deb.nodesource.com/setup_16.x -o /tmp/nodesource_setup.sh +RUN bash /tmp/nodesource_setup.sh +RUN apt-get install nodejs +RUN npm install --global yarn +RUN npm install --global pm2 + +# setup redis +RUN apt install redis-server -y + +# setup server +WORKDIR /app +ADD packages/server . +RUN ls -al +RUN yarn +RUN yarn build +# Install client for oracle datasource +RUN apt-get install unzip libaio1 +RUN /bin/bash -e scripts/integrations/oracle/instantclient/linux/x86-64/install.sh + +# setup worker +WORKDIR /worker +ADD packages/worker . +RUN yarn +RUN yarn build + +# setup clouseau +WORKDIR / +RUN wget https://github.com/cloudant-labs/clouseau/releases/download/2.21.0/clouseau-2.21.0-dist.zip +RUN unzip clouseau-2.21.0-dist.zip +RUN mv clouseau-2.21.0 /opt/clouseau +RUN rm clouseau-2.21.0-dist.zip + +WORKDIR /opt/clouseau +RUN mkdir ./bin +ADD hosting/single/clouseau ./bin/ +ADD hosting/single/log4j.properties . +ADD hosting/single/clouseau.ini . +RUN chmod +x ./bin/clouseau + +# setup CouchDB +WORKDIR /opt/couchdb +ADD hosting/single/vm.args ./etc/ + +# setup minio +WORKDIR /minio +RUN wget https://dl.min.io/server/minio/release/linux-${ARCHITECTURE}64/minio +RUN chmod +x minio + +# setup runner file +WORKDIR / +ADD hosting/single/runner.sh . +RUN chmod +x ./runner.sh + +EXPOSE 10000 +VOLUME /opt/couchdb/data +VOLUME /minio + +# must set this just before running +ENV NODE_ENV=production +CMD ["./runner.sh"] diff --git a/hosting/single/clouseau b/hosting/single/clouseau new file mode 100644 index 000000000..1095ea24c --- /dev/null +++ b/hosting/single/clouseau @@ -0,0 +1,12 @@ +#!/bin/sh +/usr/bin/java -server \ + -Xmx2G \ + -Dsun.net.inetaddr.ttl=30 \ + -Dsun.net.inetaddr.negative.ttl=30 \ + -Dlog4j.configuration=file:/opt/clouseau/log4j.properties \ + -XX:OnOutOfMemoryError="kill -9 %p" \ + -XX:+UseConcMarkSweepGC \ + -XX:+CMSParallelRemarkEnabled \ + -classpath '/opt/clouseau/*' \ + com.cloudant.clouseau.Main \ + /opt/clouseau/clouseau.ini \ No newline at end of file diff --git a/hosting/single/clouseau.ini b/hosting/single/clouseau.ini new file mode 100644 index 000000000..f086cf039 --- /dev/null +++ b/hosting/single/clouseau.ini @@ -0,0 +1,13 @@ +[clouseau] + +; the name of the Erlang node created by the service, leave this unchanged +name=clouseau@127.0.0.1 + +; set this to the same distributed Erlang cookie used by the CouchDB nodes +cookie=monster + +; the path where you would like to store the search index files +dir=/opt/couchdb/data/search + +; the number of search indexes that can be open simultaneously +max_indexes_open=500 diff --git a/hosting/single/log4j.properties b/hosting/single/log4j.properties new file mode 100644 index 000000000..9d4d9311b --- /dev/null +++ b/hosting/single/log4j.properties @@ -0,0 +1,4 @@ +log4j.rootLogger=debug, CONSOLE +log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender +log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout +log4j.appender.CONSOLE.layout.ConversionPattern=%d{ISO8601} %c [%p] %m%n \ No newline at end of file diff --git a/hosting/single/nginx.conf b/hosting/single/nginx.conf new file mode 100644 index 000000000..86938ced4 --- /dev/null +++ b/hosting/single/nginx.conf @@ -0,0 +1,116 @@ +user www www; +error_log /etc/nginx/logs/error.log; +pid /etc/nginx/logs/nginx.pid; +worker_processes auto; +worker_rlimit_nofile 8192; + +events { + worker_connections 1024; +} + +http { + limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=20r/s; + proxy_set_header Host $host; + charset utf-8; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + types_hash_max_size 2048; + + # buffering + client_header_buffer_size 1k; + client_max_body_size 20M; + ignore_invalid_headers off; + proxy_buffering off; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + map $http_upgrade $connection_upgrade { + default "upgrade"; + } + + server { + listen 10000 default_server; + listen [::]:10000 default_server; + server_name _; + client_max_body_size 1000m; + ignore_invalid_headers off; + proxy_buffering off; + # port_in_redirect off; + + location /app { + proxy_pass http://127.0.0.1:4001; + } + + location = / { + proxy_pass http://127.0.0.1:4001; + } + + location ~ ^/(builder|app_) { + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:4001; + } + + location ~ ^/api/(system|admin|global)/ { + proxy_pass http://127.0.0.1:4002; + } + + location /worker/ { + proxy_pass http://127.0.0.1:4002; + rewrite ^/worker/(.*)$ /$1 break; + } + + location /api/ { + # calls to the API are rate limited with bursting + limit_req zone=ratelimit burst=20 nodelay; + + # 120s timeout on API requests + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_pass http://127.0.0.1:4001; + } + + location /db/ { + proxy_pass http://127.0.0.1:5984; + rewrite ^/db/(.*)$ /$1 break; + } + + location / { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 300; + proxy_http_version 1.1; + proxy_set_header Connection ""; + chunked_transfer_encoding off; + proxy_pass http://127.0.0.1:9000; + } + + client_header_timeout 60; + client_body_timeout 60; + keepalive_timeout 60; + + # gzip + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + } +} diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh new file mode 100644 index 000000000..fab843179 --- /dev/null +++ b/hosting/single/runner.sh @@ -0,0 +1,16 @@ +redis-server --requirepass $REDIS_PASSWORD & +/opt/clouseau/bin/clouseau & +/minio/minio server /minio & +/docker-entrypoint.sh /opt/couchdb/bin/couchdb & +/etc/init.d/nginx restart +pushd app +pm2 start --name app "yarn run:docker" +popd +pushd worker +pm2 start --name worker "yarn run:docker" +popd +sleep 10 +URL=http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984 +curl -X PUT ${URL}/_users +curl -X PUT ${URL}/_replicator +sleep infinity \ No newline at end of file diff --git a/hosting/single/vm.args b/hosting/single/vm.args new file mode 100644 index 000000000..e9e441686 --- /dev/null +++ b/hosting/single/vm.args @@ -0,0 +1,32 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# erlang cookie for clouseau security +-name couchdb@127.0.0.1 +-setcookie monster + +# Ensure that the Erlang VM listens on a known port +-kernel inet_dist_listen_min 9100 +-kernel inet_dist_listen_max 9100 + +# Tell kernel and SASL not to log anything +-kernel error_logger silent +-sasl sasl_error_logger false + +# Use kernel poll functionality if supported by emulator ++K true + +# Start a pool of asynchronous IO threads ++A 16 + +# Comment this line out to enable the interactive Erlang shell on startup ++Bd -noinput diff --git a/lerna.json b/lerna.json index 0fe2bc8a8..5432452ba 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.0.104-alpha.0", + "version": "1.0.105-alpha.39", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 88aa681ad..3e6290e40 100644 --- a/package.json +++ b/package.json @@ -21,18 +21,17 @@ }, "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", - "publishdev": "lerna run publishdev", - "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", "nuke:docker": "lerna run --parallel dev:stack:nuke", "clean": "lerna clean", - "kill-port": "kill-port 4001", "kill-builder": "kill-port 3000", "kill-server": "kill-port 4001 4002", "kill-all": "yarn run kill-builder && yarn run kill-server", @@ -58,6 +57,8 @@ "build:docker:develop": "node scripts/pinVersions && lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", + "build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .", + "build:docker:single": "lerna run build && lerna run predocker && npm run build:docker:single:image", "build:docs": "lerna run build:docs", "release:helm": "node scripts/releaseHelmChart", "env:multi:enable": "lerna run env:multi:enable", @@ -72,6 +73,7 @@ "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" } } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 7f27ee0a3..6254eeee7 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.0.104-alpha.0", + "version": "1.0.105-alpha.39", "description": "Budibase backend core libraries used in server and worker", "main": "src/index.js", "author": "Budibase", diff --git a/packages/backend-core/src/constants.js b/packages/backend-core/src/constants.js index 559dc0e6b..172e66e60 100644 --- a/packages/backend-core/src/constants.js +++ b/packages/backend-core/src/constants.js @@ -13,9 +13,11 @@ exports.Cookies = { exports.Headers = { API_KEY: "x-budibase-api-key", + LICENSE_KEY: "x-budibase-license-key", 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/db/constants.js b/packages/backend-core/src/db/constants.js index 5ee8033e0..271d4f412 100644 --- a/packages/backend-core/src/db/constants.js +++ b/packages/backend-core/src/db/constants.js @@ -23,6 +23,7 @@ exports.StaticDatabases = { docs: { apiKeys: "apikeys", usageQuota: "usage_quota", + licenseInfo: "license_info", }, }, // contains information about tenancy and so on diff --git a/packages/backend-core/src/db/utils.js b/packages/backend-core/src/db/utils.js index feb17c412..ac401dea8 100644 --- a/packages/backend-core/src/db/utils.js +++ b/packages/backend-core/src/db/utils.js @@ -27,6 +27,7 @@ const UNICODE_MAX = "\ufff0" exports.ViewNames = { USER_BY_EMAIL: "by_email", BY_API_KEY: "by_api_key", + USER_BY_BUILDERS: "by_builders", } exports.StaticDatabases = StaticDatabases @@ -429,34 +430,9 @@ async function getScopedConfig(db, params) { return configDoc && configDoc.config ? configDoc.config : configDoc } -function generateNewUsageQuotaDoc() { - return { - _id: StaticDatabases.GLOBAL.docs.usageQuota, - quotaReset: Date.now() + 2592000000, - usageQuota: { - automationRuns: 0, - rows: 0, - storage: 0, - apps: 0, - users: 0, - views: 0, - emails: 0, - }, - usageLimits: { - automationRuns: 1000, - rows: 4000, - apps: 4, - storage: 1000, - users: 10, - emails: 50, - }, - } -} - exports.Replication = Replication exports.getScopedConfig = getScopedConfig exports.generateConfigID = generateConfigID exports.getConfigParams = getConfigParams exports.getScopedFullConfig = getScopedFullConfig -exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc exports.generateDevInfoID = generateDevInfoID diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index e5be8e6b4..e0281c658 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -56,10 +56,34 @@ exports.createApiKeyView = async () => { await db.put(designDoc) } +exports.createUserBuildersView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + map: `function(doc) { + if (doc.builder && doc.builder.global === true) { + emit(doc._id, doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewNames.USER_BY_BUILDERS]: view, + } + await db.put(designDoc) +} + exports.queryGlobalView = async (viewName, params, db = null) => { const CreateFuncByName = { [ViewNames.USER_BY_EMAIL]: exports.createUserEmailView, [ViewNames.BY_API_KEY]: exports.createApiKeyView, + [ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView, } // can pass DB in if working with something specific if (!db) { diff --git a/packages/backend-core/src/environment.js b/packages/backend-core/src/environment.js index d112ad859..527760c1c 100644 --- a/packages/backend-core/src/environment.js +++ b/packages/backend-core/src/environment.js @@ -22,12 +22,14 @@ 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), COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, PLATFORM_URL: process.env.PLATFORM_URL, + TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS, isTest, _set(key, value) { process.env[key] = value diff --git a/packages/backend-core/src/errors/base.js b/packages/backend-core/src/errors/base.js new file mode 100644 index 000000000..d31f9838f --- /dev/null +++ b/packages/backend-core/src/errors/base.js @@ -0,0 +1,11 @@ +class BudibaseError extends Error { + constructor(message, type, code) { + super(message) + this.type = type + this.code = code + } +} + +module.exports = { + BudibaseError, +} diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.js new file mode 100644 index 000000000..4f3b4e0c4 --- /dev/null +++ b/packages/backend-core/src/errors/index.js @@ -0,0 +1,41 @@ +const licensing = require("./licensing") + +const codes = { + ...licensing.codes, +} + +const types = { + ...licensing.types, +} + +const context = { + ...licensing.context, +} + +const getPublicError = err => { + let error + if (err.code || err.type) { + // add generic error information + error = { + code: err.code, + type: err.type, + } + + if (err.code && context[err.code]) { + error = { + ...error, + // get any additional context from this error + ...context[err.code](err), + } + } + } + + return error +} + +module.exports = { + codes, + types, + UsageLimitError: licensing.UsageLimitError, + getPublicError, +} diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js new file mode 100644 index 000000000..c05f9c561 --- /dev/null +++ b/packages/backend-core/src/errors/licensing.js @@ -0,0 +1,32 @@ +const { BudibaseError } = require("./base") + +const types = { + LICENSE_ERROR: "license_error", +} + +const codes = { + USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", +} + +const context = { + [codes.USAGE_LIMIT_EXCEEDED]: err => { + return { + limitName: err.limitName, + } + }, +} + +class UsageLimitError extends BudibaseError { + constructor(message, limitName) { + super(message, types.LICENSE_ERROR, codes.USAGE_LIMIT_EXCEEDED) + this.limitName = limitName + this.status = 400 + } +} + +module.exports = { + types, + codes, + context, + UsageLimitError, +} diff --git a/packages/backend-core/src/featureFlags/index.js b/packages/backend-core/src/featureFlags/index.js new file mode 100644 index 000000000..6d3d86978 --- /dev/null +++ b/packages/backend-core/src/featureFlags/index.js @@ -0,0 +1,52 @@ +const env = require("../environment") +const tenancy = require("../tenancy") + +/** + * Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant. + * The env var is formatted as: + * tenant1:feature1:feature2,tenant2:feature1 + */ +const getFeatureFlags = () => { + if (!env.TENANT_FEATURE_FLAGS) { + return + } + + const tenantFeatureFlags = {} + + env.TENANT_FEATURE_FLAGS.split(",").forEach(tenantToFeatures => { + const [tenantId, ...features] = tenantToFeatures.split(":") + + features.forEach(feature => { + if (!tenantFeatureFlags[tenantId]) { + tenantFeatureFlags[tenantId] = [] + } + tenantFeatureFlags[tenantId].push(feature) + }) + }) + + return tenantFeatureFlags +} + +const TENANT_FEATURE_FLAGS = getFeatureFlags() + +exports.isEnabled = featureFlag => { + const tenantId = tenancy.getTenantId() + + return ( + TENANT_FEATURE_FLAGS && + TENANT_FEATURE_FLAGS[tenantId] && + TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag) + ) +} + +exports.getTenantFeatureFlags = tenantId => { + if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) { + return TENANT_FEATURE_FLAGS[tenantId] + } + + return [] +} + +exports.FeatureFlag = { + LICENSING: "LICENSING", +} diff --git a/packages/backend-core/src/index.js b/packages/backend-core/src/index.js index b0bc524d9..8f7158016 100644 --- a/packages/backend-core/src/index.js +++ b/packages/backend-core/src/index.js @@ -15,4 +15,9 @@ module.exports = { auth: require("../auth"), constants: require("../constants"), migrations: require("../migrations"), + errors: require("./errors"), + env: require("./environment"), + accounts: require("./cloud/accounts"), + tenancy: require("./tenancy"), + featureFlags: require("./featureFlags"), } diff --git a/packages/backend-core/src/middleware/passport/google.js b/packages/backend-core/src/middleware/passport/google.js index 8fd0961ea..5e95a906d 100644 --- a/packages/backend-core/src/middleware/passport/google.js +++ b/packages/backend-core/src/middleware/passport/google.js @@ -2,24 +2,27 @@ const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const { authenticateThirdParty } = require("./third-party-common") -async function authenticate(accessToken, refreshToken, profile, done) { - const thirdPartyUser = { - provider: profile.provider, // should always be 'google' - providerType: "google", - userId: profile.id, - profile: profile, - email: profile._json.email, - oauth2: { - accessToken: accessToken, - refreshToken: refreshToken, - }, - } +const buildVerifyFn = async saveUserFn => { + return (accessToken, refreshToken, profile, done) => { + const thirdPartyUser = { + provider: profile.provider, // should always be 'google' + providerType: "google", + userId: profile.id, + profile: profile, + email: profile._json.email, + oauth2: { + accessToken: accessToken, + refreshToken: refreshToken, + }, + } - return authenticateThirdParty( - thirdPartyUser, - true, // require local accounts to exist - done - ) + return authenticateThirdParty( + thirdPartyUser, + true, // require local accounts to exist + done, + saveUserFn + ) + } } /** @@ -27,11 +30,7 @@ async function authenticate(accessToken, refreshToken, profile, done) { * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * @returns Dynamically configured Passport Google Strategy */ -exports.strategyFactory = async function ( - config, - callbackUrl, - verify = authenticate -) { +exports.strategyFactory = async function (config, callbackUrl, saveUserFn) { try { const { clientID, clientSecret } = config @@ -41,6 +40,7 @@ exports.strategyFactory = async function ( ) } + const verify = buildVerifyFn(saveUserFn) return new GoogleStrategy( { clientID: config.clientID, @@ -58,4 +58,4 @@ exports.strategyFactory = async function ( } } // expose for testing -exports.authenticate = authenticate +exports.buildVerifyFn = buildVerifyFn diff --git a/packages/backend-core/src/middleware/passport/oidc.js b/packages/backend-core/src/middleware/passport/oidc.js index 3a75dfcf8..1e93e20b1 100644 --- a/packages/backend-core/src/middleware/passport/oidc.js +++ b/packages/backend-core/src/middleware/passport/oidc.js @@ -2,46 +2,49 @@ const fetch = require("node-fetch") const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy const { authenticateThirdParty } = require("./third-party-common") -/** - * @param {*} issuer The identity provider base URL - * @param {*} sub The user ID - * @param {*} profile The user profile information. Created by passport from the /userinfo response - * @param {*} jwtClaims The parsed id_token claims - * @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT - * @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT - * @param {*} idToken The id_token - always a JWT - * @param {*} params The response body from requesting an access_token - * @param {*} done The passport callback: err, user, info - */ -async function authenticate( - issuer, - sub, - profile, - jwtClaims, - accessToken, - refreshToken, - idToken, - params, - done -) { - const thirdPartyUser = { - // store the issuer info to enable sync in future - provider: issuer, - providerType: "oidc", - userId: profile.id, - profile: profile, - email: getEmail(profile, jwtClaims), - oauth2: { - accessToken: accessToken, - refreshToken: refreshToken, - }, - } - - return authenticateThirdParty( - thirdPartyUser, - false, // don't require local accounts to exist +const buildVerifyFn = saveUserFn => { + /** + * @param {*} issuer The identity provider base URL + * @param {*} sub The user ID + * @param {*} profile The user profile information. Created by passport from the /userinfo response + * @param {*} jwtClaims The parsed id_token claims + * @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT + * @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT + * @param {*} idToken The id_token - always a JWT + * @param {*} params The response body from requesting an access_token + * @param {*} done The passport callback: err, user, info + */ + return async ( + issuer, + sub, + profile, + jwtClaims, + accessToken, + refreshToken, + idToken, + params, done - ) + ) => { + const thirdPartyUser = { + // store the issuer info to enable sync in future + provider: issuer, + providerType: "oidc", + userId: profile.id, + profile: profile, + email: getEmail(profile, jwtClaims), + oauth2: { + accessToken: accessToken, + refreshToken: refreshToken, + }, + } + + return authenticateThirdParty( + thirdPartyUser, + false, // don't require local accounts to exist + done, + saveUserFn + ) + } } /** @@ -86,7 +89,7 @@ function validEmail(value) { * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * @returns Dynamically configured Passport OIDC Strategy */ -exports.strategyFactory = async function (config, callbackUrl) { +exports.strategyFactory = async function (config, callbackUrl, saveUserFn) { try { const { clientID, clientSecret, configUrl } = config @@ -106,6 +109,7 @@ exports.strategyFactory = async function (config, callbackUrl) { const body = await response.json() + const verify = buildVerifyFn(saveUserFn) return new OIDCStrategy( { issuer: body.issuer, @@ -116,7 +120,7 @@ exports.strategyFactory = async function (config, callbackUrl) { clientSecret: clientSecret, callbackURL: callbackUrl, }, - authenticate + verify ) } catch (err) { console.error(err) @@ -125,4 +129,4 @@ exports.strategyFactory = async function (config, callbackUrl) { } // expose for testing -exports.authenticate = authenticate +exports.buildVerifyFn = buildVerifyFn diff --git a/packages/backend-core/src/middleware/passport/tests/google.spec.js b/packages/backend-core/src/middleware/passport/tests/google.spec.js index 9cc878bba..c5580ea30 100644 --- a/packages/backend-core/src/middleware/passport/tests/google.spec.js +++ b/packages/backend-core/src/middleware/passport/tests/google.spec.js @@ -58,8 +58,10 @@ describe("google", () => { it("delegates authentication to third party common", async () => { const google = require("../google") + const mockSaveUserFn = jest.fn() + const authenticate = await google.buildVerifyFn(mockSaveUserFn) - await google.authenticate( + await authenticate( data.accessToken, data.refreshToken, profile, @@ -69,7 +71,8 @@ describe("google", () => { expect(authenticateThirdParty).toHaveBeenCalledWith( user, true, - mockDone) + mockDone, + mockSaveUserFn) }) }) }) diff --git a/packages/backend-core/src/middleware/passport/tests/oidc.spec.js b/packages/backend-core/src/middleware/passport/tests/oidc.spec.js index 44538b913..bfe9f97dc 100644 --- a/packages/backend-core/src/middleware/passport/tests/oidc.spec.js +++ b/packages/backend-core/src/middleware/passport/tests/oidc.spec.js @@ -83,8 +83,10 @@ describe("oidc", () => { async function doAuthenticate() { const oidc = require("../oidc") + const mockSaveUserFn = jest.fn() + const authenticate = await oidc.buildVerifyFn(mockSaveUserFn) - await oidc.authenticate( + await authenticate( issuer, sub, profile, diff --git a/packages/backend-core/src/middleware/passport/third-party-common.js b/packages/backend-core/src/middleware/passport/third-party-common.js index b467c0b10..3fbfb145b 100644 --- a/packages/backend-core/src/middleware/passport/third-party-common.js +++ b/packages/backend-core/src/middleware/passport/third-party-common.js @@ -1,7 +1,6 @@ const env = require("../../environment") const jwt = require("jsonwebtoken") const { generateGlobalUserID } = require("../../db/utils") -const { saveUser } = require("../../utils") const { authError } = require("./utils") const { newid } = require("../../hashing") const { createASession } = require("../../security/sessions") @@ -16,8 +15,11 @@ exports.authenticateThirdParty = async function ( thirdPartyUser, requireLocalAccount = true, done, - saveUserFn = saveUser + saveUserFn ) { + if (!saveUserFn) { + throw new Error("Save user function must be provided") + } if (!thirdPartyUser.provider) { return authError(done, "third party user provider required") } diff --git a/packages/backend-core/src/redis/utils.js b/packages/backend-core/src/redis/utils.js index 4c2b2f5ca..77f64f659 100644 --- a/packages/backend-core/src/redis/utils.js +++ b/packages/backend-core/src/redis/utils.js @@ -17,6 +17,7 @@ exports.Databases = { FLAGS: "flags", APP_METADATA: "appMetadata", QUERY_VARS: "queryVars", + LICENSES: "license", } exports.SEPARATOR = SEPARATOR diff --git a/packages/backend-core/src/security/sessions.js b/packages/backend-core/src/security/sessions.js index bbe6be299..1720eeb82 100644 --- a/packages/backend-core/src/security/sessions.js +++ b/packages/backend-core/src/security/sessions.js @@ -14,22 +14,7 @@ function makeSessionID(userId, sessionId) { return `${userId}/${sessionId}` } -exports.createASession = async (userId, session) => { - const client = await redis.getSessionClient() - const sessionId = session.sessionId - if (!session.csrfToken) { - session.csrfToken = uuidv4() - } - session = { - createdAt: new Date().toISOString(), - lastAccessedAt: new Date().toISOString(), - ...session, - userId, - } - await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) -} - -exports.invalidateSessions = async (userId, sessionIds = null) => { +async function invalidateSessions(userId, sessionIds = null) { let sessions = [] // If no sessionIds, get all the sessions for the user @@ -55,6 +40,24 @@ exports.invalidateSessions = async (userId, sessionIds = null) => { await Promise.all(promises) } +exports.createASession = async (userId, session) => { + // invalidate all other sessions + await invalidateSessions(userId) + + const client = await redis.getSessionClient() + const sessionId = session.sessionId + if (!session.csrfToken) { + session.csrfToken = uuidv4() + } + session = { + createdAt: new Date().toISOString(), + lastAccessedAt: new Date().toISOString(), + ...session, + userId, + } + await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) +} + exports.updateSessionTTL = async session => { const client = await redis.getSessionClient() const key = makeSessionID(session.userId, session.sessionId) @@ -67,8 +70,6 @@ exports.endSession = async (userId, sessionId) => { await client.delete(makeSessionID(userId, sessionId)) } -exports.getUserSessions = getSessionsForUser - exports.getSession = async (userId, sessionId) => { try { const client = await redis.getSessionClient() @@ -84,3 +85,6 @@ exports.getAllSessions = async () => { const sessions = await client.scan() return sessions.map(session => session.value) } + +exports.getUserSessions = getSessionsForUser +exports.invalidateSessions = invalidateSessions diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 8909f6299..e4b358a67 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -176,6 +176,13 @@ exports.getGlobalUserByEmail = async email => { }) } +exports.getBuildersCount = async () => { + const builders = await queryGlobalView(ViewNames.USER_BY_BUILDERS, { + include_docs: false, + }) + return builders ? builders.length : 0 +} + exports.saveUser = async ( user, tenantId, @@ -289,4 +296,5 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => { userId, sessions.map(({ sessionId }) => sessionId) ) + await userCache.invalidateUser(userId) } diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index fc70e3d6a..f4f836b1a 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -3338,9 +3338,9 @@ mimic-fn@^2.1.0: brace-expansion "^1.1.7" minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mixin-deep@^1.2.0: version "1.3.2" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 1fbcd69ca..26bbe94a5 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.104-alpha.0", + "version": "1.0.105-alpha.39", "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.104-alpha.0", + "@budibase/string-templates": "^1.0.105-alpha.39", "@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 5ab7a7f04..b518ac3d9 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/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte index c1c4cc866..fd67fa41b 100644 --- a/packages/bbui/src/Form/Core/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker.svelte @@ -19,18 +19,33 @@ const dispatch = createEventDispatcher() const flatpickrId = `${uuid()}-wrapper` let open = false - let flatpickr, flatpickrOptions, isTimeOnly + let flatpickr, flatpickrOptions + + const resolveTimeStamp = timestamp => { + let maskedDate = new Date(`0-${timestamp}`) + + if (maskedDate instanceof Date && !isNaN(maskedDate.getTime())) { + return maskedDate + } else { + return null + } + } - $: isTimeOnly = !timeOnly && value ? !isNaN(new Date(`0-${value}`)) : timeOnly $: flatpickrOptions = { element: `#${flatpickrId}`, - enableTime: isTimeOnly || enableTime || false, - noCalendar: isTimeOnly || false, + enableTime: timeOnly || enableTime || false, + noCalendar: timeOnly || false, altInput: true, - altFormat: isTimeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y", + altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y", wrap: true, appendTo, disableMobile: "true", + onReady: () => { + let timestamp = resolveTimeStamp(value) + if (timeOnly && timestamp) { + dispatch("change", timestamp.toISOString()) + } + }, } const handleChange = event => { @@ -39,10 +54,9 @@ if (newValue) { newValue = newValue.toISOString() } - // if time only set date component to today + // if time only set date component to 2000-01-01 if (timeOnly) { - const todayDate = new Date().toISOString().split("T")[0] - newValue = `${todayDate}T${newValue.split("T")[1]}` + newValue = `2000-01-01T${newValue.split("T")[1]}` } dispatch("change", newValue) } @@ -76,10 +90,13 @@ return null } let date - let time = new Date(`0-${val}`) + let time + // it is a string like 00:00:00, just time - if (timeOnly || (typeof val === "string" && !isNaN(time))) { - date = time + let ts = resolveTimeStamp(val) + + if (timeOnly && ts) { + date = ts } else if (val instanceof Date) { // Use real date obj if already parsed date = val @@ -101,7 +118,7 @@ } -{#key isTimeOnly} +{#key timeOnly} export let wide = false + export let maxWidth = "80ch" -
+
@@ -12,7 +13,7 @@ flex-direction: column; justify-content: flex-start; align-items: stretch; - max-width: 80ch; + max-width: var(--max-width); margin: 0 auto; padding: calc(var(--spacing-xl) * 2); min-height: calc(100% - var(--spacing-xl) * 4); diff --git a/packages/bbui/src/ProgressBar/ProgressBar.svelte b/packages/bbui/src/ProgressBar/ProgressBar.svelte index 221453d42..0bc50fb45 100644 --- a/packages/bbui/src/ProgressBar/ProgressBar.svelte +++ b/packages/bbui/src/ProgressBar/ProgressBar.svelte @@ -16,11 +16,11 @@ easing: easing, }) - $: if (value) $progress = value + $: if (value || value === 0) $progress = value
{#if $$slots}
{/if} - {#if value} + {#if value || value === 0}
@@ -47,7 +47,7 @@
{/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 6930a6cdb..579c61e28 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/Typography/Body.svelte b/packages/bbui/src/Typography/Body.svelte index 9a1fe266f..469007680 100644 --- a/packages/bbui/src/Typography/Body.svelte +++ b/packages/bbui/src/Typography/Body.svelte @@ -5,12 +5,14 @@ export let serif = false export let weight = null export let textAlign = null + export let color = null

diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index cf40e12d7..b02783e0b 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/bbui/yarn.lock b/packages/bbui/yarn.lock index 5baad3028..0bff3e86d 100644 --- a/packages/bbui/yarn.lock +++ b/packages/bbui/yarn.lock @@ -1574,9 +1574,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp@~0.5.1: version "0.5.5" diff --git a/packages/builder/cypress/integration/autoScreensUI.spec.js b/packages/builder/cypress/integration/autoScreensUI.spec.js index ab65e6561..2c2a43e71 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 bd532aa3f..0db2d49e3 100644 --- a/packages/builder/cypress/integration/changeAppIconAndColour.spec.js +++ b/packages/builder/cypress/integration/changeAppIconAndColour.spec.js @@ -6,10 +6,6 @@ filterTests(['all'], () => { cy.login() }) - after(() => { - cy.deleteAllApps() - }) - it("should change the icon and colour for an application", () => { // Search for test application cy.applicationInAppTable("Cypress Tests") diff --git a/packages/builder/cypress/integration/createApp.spec.js b/packages/builder/cypress/integration/createApp.spec.js index 9507f88be..13c5979df 100644 --- a/packages/builder/cypress/integration/createApp.spec.js +++ b/packages/builder/cypress/integration/createApp.spec.js @@ -3,25 +3,36 @@ import filterTests from '../support/filterTests' filterTests(['smoke', 'all'], () => { context("Create an Application", () => { - beforeEach(() => { + before(() => { cy.login() + cy.deleteApp("Cypress Tests") }) - it("should show the new user UI/UX", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').should("exist") - cy.get(`[data-cy="import-app-btn"]`).should("exist") - - cy.get(".template-category-filters").should("exist") - cy.get(".template-categories").should("exist") - - cy.get(".appTable").should("not.exist") - }) + if (!(Cypress.env("TEST_ENV"))) { + it("should show the new user UI/UX", () => { + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').should("exist") + cy.get(`[data-cy="import-app-btn"]`).should("exist") + + cy.get(".template-category-filters").should("exist") + cy.get(".template-categories").should("exist") + + cy.get(".appTable").should("not.exist") + }) + } it("should provide filterable templates", () => { cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) + 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") @@ -39,14 +50,22 @@ filterTests(['smoke', 'all'], () => { cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) - const appName = "A New App" - - cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').click({force: true}) + // Start create app process. If apps already exist, click second button + cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) + cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) + .its("body") + .then(val => { + if (val.length > 0) { + cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) + } + }) + + const appName = "Cypress Tests" cy.get(".spectrum-Modal").within(() => { //Auto fill cy.get("input").eq(0).type(appName).should("have.value", appName).blur() - cy.get("input").eq(1).should("have.value", "/a-new-app") + cy.get("input").eq(1).should("have.value", "/cypress-tests") cy.get(".spectrum-ButtonGroup").contains("Create app").should('not.be.disabled') //Empty the app url - disabled create @@ -69,8 +88,7 @@ filterTests(['smoke', 'all'], () => { it("should create the first application from scratch", () => { const appName = "Cypress Tests" - cy.deleteApp(appName) - cy.createApp(appName, "This app is used for Cypress testing.") + cy.createApp(appName) cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(1000) @@ -83,10 +101,19 @@ filterTests(['smoke', 'all'], () => { cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) + // Navigate to Create new app section if apps already exist + cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) + .its("body") + .then(val => { + if (val.length > 0) { + cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) + } + }) + cy.get(".template-category-filters").should("exist") cy.get(".template-categories").should("exist") - //### Select nth template and choose to create? + // Select template cy.get('.template-category').eq(0).within(() => { const card = cy.get('.template-card').eq(0).should("exist"); const cardOverlay = card.get('.template-thumbnail-action-overlay').should("exist") @@ -94,7 +121,7 @@ filterTests(['smoke', 'all'], () => { cardOverlay.get("button").contains("Use template").should("exist").click({force: true}) }) - //### CMD Create app from theme card + // CMD Create app from theme card cy.get(".spectrum-Modal").should('be.visible') const templateName = cy.get(".spectrum-Modal .template-thumbnail-text") @@ -111,27 +138,22 @@ filterTests(['smoke', 'all'], () => { cy.wait(1000) cy.applicationInAppTable(templateNameText) - cy.deleteAllApps() + cy.deleteApp(templateNameText) }); }) it("should display a second application and app filtering", () => { + // Create first app const appName = "Cypress Tests" - cy.deleteApp(appName) - cy.createApp(appName, "This app is used for Cypress testing.") + cy.createApp(appName) cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) + // Create second app const secondAppName = "Second App Demo" - cy.deleteApp(secondAppName) - - cy.get(`[data-cy="create-app-btn"]`).contains('Create new app').click({force: true}) - cy.wait(500) - cy.url().should('include', '/builder/portal/apps/create') - - cy.createAppFromScratch(secondAppName) + cy.createApp(secondAppName) cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) @@ -140,7 +162,7 @@ filterTests(['smoke', 'all'], () => { cy.searchForApplication(appName) cy.searchForApplication(secondAppName) - cy.deleteAllApps() + cy.deleteApp(secondAppName) }) }) diff --git a/packages/builder/cypress/integration/createAutomation.spec.js b/packages/builder/cypress/integration/createAutomation.spec.js index b9355f7fa..ff8065f54 100644 --- a/packages/builder/cypress/integration/createAutomation.spec.js +++ b/packages/builder/cypress/integration/createAutomation.spec.js @@ -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 8bf1ec8ea..57cd0cc5f 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 ada68d82d..ae10577ff 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 df6ca5c7c..4600807cb 100644 --- a/packages/builder/cypress/integration/createTable.spec.js +++ b/packages/builder/cypress/integration/createTable.spec.js @@ -7,10 +7,6 @@ filterTests(["smoke", "all"], () => { cy.createTestApp() }) - after(() => { - cy.deleteAllApps() - }) - it("should create a new Table", () => { cy.createTable("dog") cy.wait(1000) @@ -59,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() }) @@ -74,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/createUserAndRoles.spec.js b/packages/builder/cypress/integration/createUserAndRoles.spec.js index 9c12971bd..5cd3697e3 100644 --- a/packages/builder/cypress/integration/createUserAndRoles.spec.js +++ b/packages/builder/cypress/integration/createUserAndRoles.spec.js @@ -4,9 +4,6 @@ filterTests(["smoke", "all"], () => { context("Create a User and Assign Roles", () => { before(() => { cy.login() - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) - cy.createAppFromScratch("Initial App") }) it("should create a user", () => { diff --git a/packages/builder/cypress/integration/createView.spec.js b/packages/builder/cypress/integration/createView.spec.js index ad21d6afa..a8c3b03ce 100644 --- a/packages/builder/cypress/integration/createView.spec.js +++ b/packages/builder/cypress/integration/createView.spec.js @@ -4,7 +4,6 @@ filterTests(['smoke', 'all'], () => { context("Create a View", () => { before(() => { cy.login() - cy.deleteAllApps() cy.createTestApp() cy.createTable("data") diff --git a/packages/builder/cypress/integration/datasources/mySql.spec.js b/packages/builder/cypress/integration/datasources/mySql.spec.js index 03f59a600..98bb2f2ac 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 + }) + // Save relationship & reload page + cy.get(".spectrum-ButtonGroup").within(() => { cy.get(".spectrum-Button").contains("Save").click({ force: true }) - cy.reload() }) + 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 73c25001c..4c4d33d65 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 3f5563662..c7413bb7d 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/datasources/rest.spec.js b/packages/builder/cypress/integration/datasources/rest.spec.js index 7216a1384..a15487c01 100644 --- a/packages/builder/cypress/integration/datasources/rest.spec.js +++ b/packages/builder/cypress/integration/datasources/rest.spec.js @@ -4,7 +4,6 @@ filterTests(["smoke", "all"], () => { context("REST Datasource Testing", () => { before(() => { cy.login() - cy.deleteAllApps() cy.createTestApp() }) diff --git a/packages/builder/cypress/integration/queryLevelTransformers.spec.js b/packages/builder/cypress/integration/queryLevelTransformers.spec.js index 86aea8eaf..bec0825e7 100644 --- a/packages/builder/cypress/integration/queryLevelTransformers.spec.js +++ b/packages/builder/cypress/integration/queryLevelTransformers.spec.js @@ -4,8 +4,7 @@ filterTests(["smoke", "all"], () => { context("Query Level Transformers", () => { before(() => { cy.login() - cy.deleteAllApps() - cy.createApp("Cypress Tests") + cy.createTestApp() }) it("should write a transformer function", () => { diff --git a/packages/builder/cypress/integration/renameAnApplication.spec.js b/packages/builder/cypress/integration/renameAnApplication.spec.js index 258479b62..f4899f98a 100644 --- a/packages/builder/cypress/integration/renameAnApplication.spec.js +++ b/packages/builder/cypress/integration/renameAnApplication.spec.js @@ -11,10 +11,12 @@ filterTests(['all'], () => { const appName = "Cypress Tests" const appRename = "Cypress Renamed" // Rename app, Search for app, Confirm name was changed - cy.get(".home-logo").click() + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(500) renameApp(appName, appRename) cy.reload() cy.wait(1000) + cy.searchForApplication(appRename) cy.get(".appTable").find(".title").should("have.length", 1) cy.applicationInAppTable(appRename) // Set app name back to Cypress Tests @@ -36,7 +38,8 @@ filterTests(['all'], () => { cy.get(".spectrum-Button").contains("Publish").click({ force: true }) }) // Rename app, Search for app, Confirm name was changed - cy.get(".home-logo").click() + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(500) renameApp(appName, appRename, true) cy.get(".appTable").find(".wrapper").should("have.length", 1) cy.applicationInAppTable(appRename) @@ -44,7 +47,8 @@ filterTests(['all'], () => { it("Should try to rename an application to have no name", () => { const appName = "Cypress Tests" - cy.get(".home-logo").click() + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(500) renameApp(appName, " ", false, true) cy.wait(500) // Close modal and confirm name has not been changed @@ -52,8 +56,6 @@ filterTests(['all'], () => { cy.reload() cy.wait(1000) cy.applicationInAppTable(appName) - cy.get(".appTable").find(".title").should("have.length", 1) - }) xit("Should create two applications with the same name", () => { @@ -77,12 +79,12 @@ filterTests(['all'], () => { const appName = "Cypress Tests" const numberName = 12345 const specialCharName = "£$%^" - cy.get(".home-logo").click() + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(500) renameApp(appName, numberName) cy.reload() cy.wait(1000) cy.applicationInAppTable(numberName) - cy.get(".appTable").find(".title").should("have.length", 1) cy.reload() cy.wait(1000) renameApp(numberName, specialCharName) @@ -94,40 +96,33 @@ filterTests(['all'], () => { }) const renameApp = (originalName, changedName, published, noName) => { - cy.applicationInAppTable(originalName) - cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) - .its("body") - .then(val => { - if (val.length > 0) { - 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.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.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("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("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/support/commands.js b/packages/builder/cypress/support/commands.js index c45978e02..e4a7f44ba 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -32,18 +32,38 @@ 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 }) + // If apps already exist + cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) + .its("body") + .then(val => { + if (val.length > 0) { + cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) + } + }) + cy.get(".spectrum-Modal").within(() => { cy.get("input").eq(0).type(name).should("have.value", name).blur() cy.get(".spectrum-ButtonGroup").contains("Create app").click() cy.wait(10000) }) - cy.createTable("Cypress Tests", true) + if (shouldCreateDefaultTable) { + cy.createTable("Cypress Tests", true) + } }) Cypress.Commands.add("deleteApp", name => { @@ -52,36 +72,48 @@ Cypress.Commands.add("deleteApp", name => { cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) .its("body") .then(val => { - if (val.length > 0) { - const appId = val.reduce((acc, app) => { - if (name === app.name) { - acc = app.appId + 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() + }) } - return acc - }, "") - if (appId == "") { + 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 } - - 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 { return } @@ -115,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", () => { @@ -255,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(".item").contains("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(() => { @@ -327,16 +425,25 @@ Cypress.Commands.add("addCustomSourceOptions", totalOptions => { //Filters visible with 1 or more Cypress.Commands.add("searchForApplication", appName => { - cy.wait(1000) - // Searches for the app - cy.get(".filter").then(() => { - cy.get(".spectrum-Textfield").within(() => { - cy.get("input").eq(0).clear() - cy.get("input").eq(0).type(appName) + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(2000) + + // No app filter functionality if only 1 app exists + cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) + .its("body") + .then(val => { + if (val.length < 2) { + return + } else { + // Searches for the app + cy.get(".filter").then(() => { + cy.get(".spectrum-Textfield").within(() => { + cy.get("input").eq(0).clear() + cy.get("input").eq(0).type(appName) + }) + }) + } }) - }) - // Confirms app exists after search - cy.applicationInAppTable(appName) }) //Assumes there are no others @@ -386,7 +493,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("mysql").HOST, { force: true }) } }) }) diff --git a/packages/builder/package.json b/packages/builder/package.json index 55f02f222..fe3510185 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.0.104-alpha.0", + "version": "1.0.105-alpha.39", "license": "GPL-3.0", "private": true, "scripts": { @@ -65,10 +65,10 @@ } }, "dependencies": { - "@budibase/bbui": "^1.0.104-alpha.0", - "@budibase/client": "^1.0.104-alpha.0", - "@budibase/frontend-core": "^1.0.104-alpha.0", - "@budibase/string-templates": "^1.0.104-alpha.0", + "@budibase/bbui": "^1.0.105-alpha.39", + "@budibase/client": "^1.0.105-alpha.39", + "@budibase/frontend-core": "^1.0.105-alpha.39", + "@budibase/string-templates": "^1.0.105-alpha.39", "@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 177d5320a..98408b5cb 100644 --- a/packages/builder/src/analytics/constants.js +++ b/packages/builder/src/analytics/constants.js @@ -22,6 +22,7 @@ export const Events = { }, SCREEN: { CREATED: "Screen Created", + CREATE_ROLE_UPDATED: "Changed Role On Screen Creation", }, AUTOMATION: { CREATED: "Automation Created", diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index c8b4ae8de..8cbc62929 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -7,7 +7,11 @@ import { getComponentSettings, } from "./componentUtils" import { store } from "builderStore" -import { queries as queriesStores, tables as tablesStore } from "stores/backend" +import { + queries as queriesStores, + tables as tablesStore, + roles as rolesStore, +} from "stores/backend" import { makePropSafe, isJSBinding, @@ -33,6 +37,7 @@ export const getBindableProperties = (asset, componentId) => { const deviceBindings = getDeviceBindings() const stateBindings = getStateBindings() const selectedRowsBindings = getSelectedRowsBindings(asset) + const roleBindings = getRoleBindings() return [ ...contextBindings, ...urlBindings, @@ -40,6 +45,7 @@ export const getBindableProperties = (asset, componentId) => { ...userBindings, ...deviceBindings, ...selectedRowsBindings, + ...roleBindings, ] } @@ -391,6 +397,16 @@ const getUrlBindings = asset => { })) } +const getRoleBindings = () => { + return (get(rolesStore) || []).map(role => { + return { + type: "context", + runtimeBinding: `trim "${role._id}"`, + readableBinding: `Role.${role.name}`, + } + }) +} + /** * Gets all bindable properties exposed in a button actions flow up until * the specified action ID, as well as context provided for the action @@ -638,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/builderStore/store/screenTemplates/utils/sanitizeUrl.js b/packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js index 16224a45b..4d00c503f 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js @@ -2,9 +2,15 @@ export default function (url) { return url .split("/") .map(part => { - // if parameter, then use as is - if (part.startsWith(":")) return part - return encodeURIComponent(part.replace(/ /g, "-")) + part = decodeURIComponent(part) + part = part.replace(/ /g, "-") + + // If parameter, then use as is + if (!part.startsWith(":")) { + part = encodeURIComponent(part) + } + + return part }) .join("/") .toLowerCase() diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index 4e1e5e110..caf8835b8 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 ca04fed8d..505a0b9ac 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 69dd67724..ef0a61646 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 = @@ -52,8 +52,21 @@ 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 +89,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 +114,68 @@ } -
{ - onSelect(block) - }} -> +
{}}> + {#if loopingSelected} +
+
{ + showLooping = !showLooping + }} + class="splitHeader" + > +
+ + + +
+ Looping +
+
+ +
+
{ + onSelect(block) + }} + > + +
+
+
+
+ + + {#if !showLooping} +
+
+
deleteStep()}> + +
+
+ + x.blockToLoop === block.id + )} + {webhookModal} + /> + +
+ + {/if} + {/if} +
{ @@ -127,65 +212,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 !loopingSelected} + addLooping()} icon="Reuse" + >Add Looping + {/if} {#if showBindingPicker} -
- {/if} -
deleteStep()}> - -
+ deleteStep()} + icon="DeleteOutline" + />
- {/if} -
+
+ {/if} - {#if setupToggled} - - {#if lastStep} - - {/if} + + {#if lastStep} + {/if}
@@ -220,8 +306,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 +344,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 7dfdff20a..67c7f493e 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} diff --git a/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte b/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte index 1c108f164..ed064f830 100644 --- a/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte +++ b/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte @@ -3,13 +3,14 @@ import PathTree from "./PathTree.svelte" let routes = {} - $: paths = Object.keys(routes || {}).sort() + let paths = [] - $: { - const allRoutes = $store.routes + $: allRoutes = $store.routes + $: selectedScreenId = $store.selectedScreenId + $: updatePaths(allRoutes, $selectedAccessRole, selectedScreenId) + + const updatePaths = (allRoutes, selectedRoleId, selectedScreenId) => { const sortedPaths = Object.keys(allRoutes || {}).sort() - const selectedRoleId = $selectedAccessRole - const selectedScreenId = $store.selectedScreenId let found = false let firstValidScreenId @@ -41,11 +42,15 @@ }) }) }) - routes = filteredRoutes + routes = { ...filteredRoutes } + paths = Object.keys(routes || {}).sort() // Select the correct role for the current screen ID if (!found && screenRoleId) { selectedAccessRole.set(screenRoleId) + if (screenRoleId !== selectedRoleId) { + updatePaths(allRoutes, screenRoleId, selectedScreenId) + } } // If the selected screen isn't in this filtered list, select the first one 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 000000000..1cb385616 --- /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 cd83d8123..8fd22926e 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 75977e51a..4afd18205 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} + /> + + role.name} getOptionValue={role => role._id} + {error} /> diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/SearchFieldSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/SearchFieldSelect.svelte index b3387fdd0..41906d3ba 100644 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/SearchFieldSelect.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/SearchFieldSelect.svelte @@ -15,16 +15,14 @@ const dispatch = createEventDispatcher() $: datasource = getDatasourceForProvider($currentAsset, componentInstance) - $: schema = getSchemaForDatasource($currentAsset, datasource, { - searchableSchema: true, - }).schema + $: schema = getSchemaForDatasource($currentAsset, datasource).schema $: options = getOptions(datasource, schema || {}) $: boundValue = getSelectedOption(value, options) function getOptions(ds, dsSchema) { let base = Object.values(dsSchema) if (!ds?.tableId) { - return base + return base.map(field => field.name) } const currentTable = $tables.list.find(table => table._id === ds.tableId) return getFields(base, { allowLinks: currentTable?.sql }).map( diff --git a/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte b/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte index ded80a7d5..df3ba7f3d 100644 --- a/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte @@ -8,14 +8,50 @@ import { currentAsset, store } from "builderStore" import { FrontendTypes } from "constants" import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl" + import { allScreens, selectedAccessRole } from "builderStore" export let componentInstance export let bindings - function setAssetProps(name, value, parser) { - if (parser && typeof parser === "function") { + let errors = {} + + const routeTaken = url => { + const roleId = get(selectedAccessRole) || "BASIC" + return get(allScreens).some( + screen => + screen.routing.route.toLowerCase() === url.toLowerCase() && + screen.routing.roleId === roleId + ) + } + + const roleTaken = roleId => { + const url = get(currentAsset)?.routing.route + return get(allScreens).some( + screen => + screen.routing.route.toLowerCase() === url.toLowerCase() && + screen.routing.roleId === roleId + ) + } + + const setAssetProps = (name, value, parser, validate) => { + if (parser) { value = parser(value) } + if (validate) { + const error = validate(value) + errors = { + ...errors, + [name]: error, + } + if (error) { + return + } + } else { + errors = { + ...errors, + [name]: null, + } + } const selectedAsset = get(currentAsset) store.update(state => { @@ -38,7 +74,6 @@ } const screenSettings = [ - // { key: "description", label: "Description", control: Input }, { key: "routing.route", label: "Route", @@ -49,8 +84,26 @@ } return sanitizeUrl(val) }, + validate: val => { + const exisingValue = get(currentAsset)?.routing.route + if (val !== exisingValue && routeTaken(val)) { + return "That URL is already in use for this role" + } + return null + }, + }, + { + key: "routing.roleId", + label: "Access", + control: RoleSelect, + validate: val => { + const exisingValue = get(currentAsset)?.routing.roleId + if (val !== exisingValue && roleTaken(val)) { + return "That role is already in use for this URL" + } + return null + }, }, - { key: "routing.roleId", label: "Access", control: RoleSelect }, { key: "layoutId", label: "Layout", control: LayoutSelect }, ] @@ -62,9 +115,11 @@ control={def.control} label={def.label} key={def.key} + error="asdasds" value={deepGet($currentAsset, def.key)} - onChange={val => setAssetProps(def.key, val, def.parser)} + onChange={val => setAssetProps(def.key, val, def.parser, def.validate)} {bindings} + props={{ error: errors[def.key] }} /> {/each} diff --git a/packages/builder/src/components/integration/QueryViewer.svelte b/packages/builder/src/components/integration/QueryViewer.svelte index 19c1165d9..8f6f9eeb5 100644 --- a/packages/builder/src/components/integration/QueryViewer.svelte +++ b/packages/builder/src/components/integration/QueryViewer.svelte @@ -72,7 +72,7 @@ fields = response.schema notifications.success("Query executed successfully") } catch (error) { - notifications.error("Error previewing query") + notifications.error(`Query Error: ${error.message}`) } } diff --git a/packages/builder/src/constants/backend/index.js b/packages/builder/src/constants/backend/index.js index 9a623241c..2f5f26e47 100644 --- a/packages/builder/src/constants/backend/index.js +++ b/packages/builder/src/constants/backend/index.js @@ -179,6 +179,7 @@ export const IntegrationTypes = { INTERNAL: "INTERNAL", GOOGLE_SHEETS: "GOOGLE_SHEETS", FIREBASE: "FIREBASE", + REDIS: "REDIS", } export const IntegrationNames = { @@ -197,6 +198,7 @@ export const IntegrationNames = { [IntegrationTypes.INTERNAL]: "Internal", [IntegrationTypes.GOOGLE_SHEETS]: "Google Sheets", [IntegrationTypes.FIREBASE]: "Firebase", + [IntegrationTypes.REDIS]: "Redis", } export const SchemaTypeOptions = [ diff --git a/packages/builder/src/helpers/featureFlags.js b/packages/builder/src/helpers/featureFlags.js new file mode 100644 index 000000000..9533abed7 --- /dev/null +++ b/packages/builder/src/helpers/featureFlags.js @@ -0,0 +1,14 @@ +import { auth } from "../stores/portal" +import { get } from "svelte/store" + +export const FEATURE_FLAGS = { + LICENSING: "LICENSING", +} + +export const isEnabled = featureFlag => { + const user = get(auth).user + if (user?.featureFlags?.includes(featureFlag)) { + return true + } + return false +} diff --git a/packages/builder/src/pages/builder/invite/index.svelte b/packages/builder/src/pages/builder/invite/index.svelte index c4745d873..8ac35de07 100644 --- a/packages/builder/src/pages/builder/invite/index.svelte +++ b/packages/builder/src/pages/builder/invite/index.svelte @@ -14,7 +14,7 @@ notifications.success("Invitation accepted successfully") $goto("../auth/login") } catch (error) { - notifications.error("Error accepting invitation") + notifications.error(error.message) } } diff --git a/packages/builder/src/pages/builder/portal/_layout.svelte b/packages/builder/src/pages/builder/portal/_layout.svelte index 7aa4671f7..39a5def7d 100644 --- a/packages/builder/src/pages/builder/portal/_layout.svelte +++ b/packages/builder/src/pages/builder/portal/_layout.svelte @@ -20,6 +20,7 @@ import ChangePasswordModal from "components/settings/ChangePasswordModal.svelte" import UpdateAPIKeyModal from "components/settings/UpdateAPIKeyModal.svelte" import Logo from "assets/bb-emblem.svg" + import { isEnabled, FEATURE_FLAGS } from "../../../helpers/featureFlags" let loaded = false let userInfoModal @@ -54,10 +55,17 @@ if (!$adminStore.cloud) { menu = menu.concat([ { - title: "Updates", + title: "Update", href: "/builder/portal/settings/update", }, ]) + + if (isEnabled(FEATURE_FLAGS.LICENSING)) { + menu = menu.concat({ + title: "Upgrade", + href: "/builder/portal/settings/upgrade", + }) + } } } else { menu = menu.concat([ diff --git a/packages/builder/src/pages/builder/portal/apps/create.svelte b/packages/builder/src/pages/builder/portal/apps/create.svelte index 697af7cb2..cd2d47ea4 100644 --- a/packages/builder/src/pages/builder/portal/apps/create.svelte +++ b/packages/builder/src/pages/builder/portal/apps/create.svelte @@ -15,7 +15,7 @@ import { onMount } from "svelte" import { templates } from "stores/portal" - let loaded = false + let loaded = $templates?.length let template let creationModal = false let creatingApp = false @@ -75,8 +75,8 @@
- {createAppTitle} - + {createAppTitle} + {welcomeBody} @@ -84,7 +84,7 @@
+
+ + + + + Plan + + You are currently on the {license.plan.type} plan + + {processStringSync( + "Updated {{ duration time 'millisecond' }} ago", + { + time: + new Date().getTime() - + new Date(license.refreshedAt).getTime(), + } + )} + + + +
+ +
+
+ +{/if} + + diff --git a/packages/builder/yarn.lock b/packages/builder/yarn.lock index 48fd25ac7..29b048107 100644 --- a/packages/builder/yarn.lock +++ b/packages/builder/yarn.lock @@ -4424,9 +4424,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mixin-deep@^1.2.0: version "1.3.2" diff --git a/packages/cli/package.json b/packages/cli/package.json index a6c0d6417..2a3bcf4a4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/cli", - "version": "1.0.104-alpha.0", + "version": "1.0.105-alpha.39", "description": "Budibase CLI, for developers, self hosting and migrations.", "main": "src/index.js", "bin": { diff --git a/packages/cli/yarn.lock b/packages/cli/yarn.lock index 5aba4810c..39fc4ed99 100644 --- a/packages/cli/yarn.lock +++ b/packages/cli/yarn.lock @@ -1123,9 +1123,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" diff --git a/packages/client/.vscode/launch.json b/packages/client/.vscode/launch.json deleted file mode 100644 index ef01de280..000000000 --- a/packages/client/.vscode/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Publish Dev", - "program": "${workspaceFolder}/scripts/publishDev.js" - } - ] -} \ No newline at end of file diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 4c3c0faf1..4190d7f07 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -1834,7 +1834,12 @@ "icon": "Form", "hasChildren": true, "illegalChildren": ["section", "form"], - "actions": ["ValidateForm", "ClearForm", "ChangeFormStep"], + "actions": [ + "ValidateForm", + "ClearForm", + "ChangeFormStep", + "UpdateFieldValue" + ], "styles": ["size"], "settings": [ { @@ -1975,6 +1980,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2049,6 +2065,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2089,6 +2116,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2125,6 +2163,17 @@ "key": "placeholder", "placeholder": "Choose an option" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "select", "label": "Type", @@ -2274,6 +2323,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Autocomplete", @@ -2399,6 +2459,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2439,6 +2510,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "select", "label": "Formatting", @@ -2512,6 +2594,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2657,6 +2750,17 @@ "label": "Extensions", "key": "extensions" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2697,6 +2801,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Autocomplete", @@ -2742,6 +2857,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2750,6 +2876,62 @@ } ] }, + "s3upload": { + "name": "S3 File Upload", + "info": "This component can't be used with S3 datasources that use custom endpoints.", + "icon": "UploadToCloud", + "styles": ["size"], + "editable": true, + "settings": [ + { + "type": "field/attachment", + "label": "Field", + "key": "field" + }, + { + "type": "text", + "label": "Label", + "key": "label" + }, + { + "type": "dataSource/s3", + "label": "S3 Datasource", + "key": "datasourceId" + }, + { + "type": "text", + "label": "Bucket", + "key": "bucket" + }, + { + "type": "text", + "label": "File Name", + "key": "key" + }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, + { + "type": "boolean", + "label": "Disabled", + "key": "disabled", + "defaultValue": false + }, + { + "type": "validation/attachment", + "label": "Validation", + "key": "validation" + } + ] + }, "dataprovider": { "name": "Data Provider", "info": "Pagination is only available for data stored in tables.", @@ -3581,51 +3763,6 @@ } ] }, - "s3upload": { - "name": "S3 File Upload", - "info": "This component can't be used with S3 datasources that use custom endpoints.", - "icon": "UploadToCloud", - "styles": ["size"], - "editable": true, - "settings": [ - { - "type": "field/attachment", - "label": "Field", - "key": "field" - }, - { - "type": "text", - "label": "Label", - "key": "label" - }, - { - "type": "dataSource/s3", - "label": "S3 Datasource", - "key": "datasourceId" - }, - { - "type": "text", - "label": "Bucket", - "key": "bucket" - }, - { - "type": "text", - "label": "File Name", - "key": "key" - }, - { - "type": "boolean", - "label": "Disabled", - "key": "disabled", - "defaultValue": false - }, - { - "type": "validation/attachment", - "label": "Validation", - "key": "validation" - } - ] - }, "markdownviewer": { "name": "Markdown Viewer", "icon": "TaskList", diff --git a/packages/client/package.json b/packages/client/package.json index f2f94255c..07869dba4 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/client", - "version": "1.0.104-alpha.0", + "version": "1.0.105-alpha.39", "license": "MPL-2.0", "module": "dist/budibase-client.js", "main": "dist/budibase-client.js", @@ -19,9 +19,9 @@ "dev:builder": "rollup -cw" }, "dependencies": { - "@budibase/bbui": "^1.0.104-alpha.0", - "@budibase/frontend-core": "^1.0.104-alpha.0", - "@budibase/string-templates": "^1.0.104-alpha.0", + "@budibase/bbui": "^1.0.105-alpha.39", + "@budibase/frontend-core": "^1.0.105-alpha.39", + "@budibase/string-templates": "^1.0.105-alpha.39", "@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "^3.0.3", "@spectrum-css/divider": "^1.0.3", diff --git a/packages/client/src/api/api.js b/packages/client/src/api/api.js index 591d4a678..1d575d9d2 100644 --- a/packages/client/src/api/api.js +++ b/packages/client/src/api/api.js @@ -1,5 +1,5 @@ import { createAPIClient } from "@budibase/frontend-core" -import { notificationStore, authStore } from "../stores" +import { notificationStore, authStore, devToolsStore } from "../stores" import { get } from "svelte/store" export const API = createAPIClient({ @@ -21,6 +21,12 @@ export const API = createAPIClient({ if (auth?.csrfToken) { headers["x-csrf-token"] = auth.csrfToken } + + // Add role header + const role = get(devToolsStore).role + if (role) { + headers["x-budibase-role"] = role + } }, // Show an error notification for all API failures. diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index 27767862a..6bd0313c7 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -14,6 +14,8 @@ routeStore, builderStore, themeStore, + appStore, + devToolsStore, } from "stores" import NotificationDisplay from "components/overlay/NotificationDisplay.svelte" import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte" @@ -28,6 +30,8 @@ import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import DNDHandler from "components/preview/DNDHandler.svelte" import KeyboardManager from "components/preview/KeyboardManager.svelte" + import DevToolsHeader from "components/devtools/DevToolsHeader.svelte" + import DevTools from "components/devtools/DevTools.svelte" // Provide contexts setContext("sdk", SDK) @@ -55,8 +59,22 @@ if ($authStore) { // There is a logged in user, so handle them if ($screenStore.screens.length) { + let firstRoute + + // If using devtools, find the first screen matching our role + if ($devToolsStore.role) { + const roleRoutes = $screenStore.screens.filter( + screen => screen.routing?.roleId === $devToolsStore.role + ) + firstRoute = roleRoutes[0]?.routing?.route || "/" + } + + // Otherwise just use the first route + else { + firstRoute = $screenStore.screens[0]?.routing?.route ?? "/" + } + // Screens exist so navigate back to the home screen - const firstRoute = $screenStore.screens[0].routing?.route ?? "/" routeStore.actions.navigate(firstRoute) } else { // No screens likely means the user has no permissions to view this app @@ -70,6 +88,8 @@ } } } + + $: isDevPreview = $appStore.isDevApp && !$builderStore.inBuilder {#if dataLoaded} @@ -109,39 +129,49 @@ >
- - {#key `${$screenStore.activeLayout._id}-${$builderStore.previewType}`} - - {/key} - - -
- - -