diff --git a/.github/workflows/deploy-single-image.yml b/.github/workflows/deploy-single-image.yml new file mode 100644 index 000000000..4a04bf3f9 --- /dev/null +++ b/.github/workflows/deploy-single-image.yml @@ -0,0 +1,62 @@ +name: Deploy Budibase Single Container Image to DockerHub +on: + push: + branches: + - "omnibus-action" + - "develop" + - "master" + - "main" +env: + BASE_BRANCH: ${{ github.event.pull_request.base.ref}} + BRANCH: ${{ github.event.pull_request.head.ref }} + CI: true + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} + REGISTRY_URL: registry.hub.docker.com +jobs: + build: + name: "build" + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [14.x] + steps: + - name: "Checkout" + uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Setup QEMU + uses: docker/setup-qemu-action@v1 + - name: Setup Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + - name: Install Pro + run: yarn install:pro $BRANCH $BASE_BRANCH + - name: Run Yarn + run: yarn + - name: Run Yarn Bootstrap + run: yarn bootstrap + - name: Runt Yarn Lint + run: yarn lint + - name: Run Yarn Build + run: yarn build + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_API_KEY }} + - name: Get the latest release version + id: version + run: | + release_version=$(cat lerna.json | jq -r '.version') + echo $release_version + echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV + - name: Tag and release Budibase service docker image + uses: docker/build-push-action@v2 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: budibase/budibase,budibase/budibase:v${{ env.RELEASE_VERSION }} + file: ./hosting/single/Dockerfile diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 4502482b2..631308d94 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -21,7 +21,8 @@ env: # Posthog token used by ui at build time POSTHOG_TOKEN: phc_uDYOfnFt6wAbBAXkC6STjcrTpAFiWIhqgFcsC1UVO5F INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} - PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} + PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + FEATURE_PREVIEW_URL: https://budirelease.live jobs: release: @@ -124,4 +125,4 @@ jobs: with: webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }} content: "Release Env Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Release Env." - embed-title: ${{ env.RELEASE_VERSION }} \ No newline at end of file + embed-title: ${{ env.RELEASE_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f33dcc6d5..6c05c4f68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,16 @@ on: - 'package.json' - 'yarn.lock' workflow_dispatch: + inputs: + versioning: + type: choice + description: "Versioning type: patch, minor, major" + default: patch + options: + - patch + - minor + - major + required: true env: # Posthog token used by ui at build time @@ -58,6 +68,7 @@ jobs: - name: Publish budibase packages to NPM env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + RELEASE_VERSION_TYPE: ${{ github.event.inputs.version }} run: | # setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default git config --global user.name "Budibase Release Bot" diff --git a/.vscode/settings.json b/.vscode/settings.json index d471924fe..4838a4fd8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,17 @@ "editor.codeActionsOnSave": { "source.fixAll": true }, - "editor.defaultFormatter": "svelte.svelte-vscode" + "editor.defaultFormatter": "svelte.svelte-vscode", + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "debug.javascript.terminalOptions": { + "skipFiles": [ + "${workspaceFolder}/packages/backend-core/node_modules/**", + "/**" + ] + }, } diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 000000000..21fa517e2 --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +network-timeout 100000 diff --git a/charts/budibase/Chart.yaml b/charts/budibase/Chart.yaml index 227a51543..570aa04d8 100644 --- a/charts/budibase/Chart.yaml +++ b/charts/budibase/Chart.yaml @@ -11,8 +11,8 @@ sources: - https://github.com/Budibase/budibase - https://budibase.com type: application -version: 0.2.10 -appVersion: 1.0.48 +version: 0.2.11 +appVersion: 1.0.214 dependencies: - name: couchdb version: 3.6.1 diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index ddc725d30..7a2c483cc 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -122,6 +122,14 @@ spec: value: {{ .Values.globals.automationMaxIterations | quote }} - name: TENANT_FEATURE_FLAGS value: {{ .Values.globals.tenantFeatureFlags | quote }} + {{ if .Values.globals.bbAdminUserEmail }} + - name: BB_ADMIN_USER_EMAIL + value: { { .Values.globals.bbAdminUserEmail | quote } } + {{ end }} + {{ if .Values.globals.bbAdminUserPassword }} + - name: BB_ADMIN_USER_PASSWORD + value: { { .Values.globals.bbAdminUserPassword | quote } } + {{ end }} image: budibase/apps:{{ .Values.globals.appVersion }} imagePullPolicy: Always diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 455d3251a..2734202ff 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -103,7 +103,7 @@ globals: google: clientId: "" secret: "" - automationMaxIterations: "500" + automationMaxIterations: "200" createSecrets: true # creates an internal API key, JWT secrets and redis password for you diff --git a/hosting/.env b/hosting/.env index 39df76d01..11dd661bf 100644 --- a/hosting/.env +++ b/hosting/.env @@ -18,4 +18,8 @@ MINIO_PORT=4004 COUCH_DB_PORT=4005 REDIS_PORT=6379 WATCHTOWER_PORT=6161 -BUDIBASE_ENVIRONMENT=PRODUCTION \ No newline at end of file +BUDIBASE_ENVIRONMENT=PRODUCTION + +# An admin user can be automatically created initially if these are set +BB_ADMIN_USER_EMAIL= +BB_ADMIN_USER_PASSWORD= \ No newline at end of file diff --git a/hosting/docker-compose.yaml b/hosting/docker-compose.yaml index f9d9eaf1c..57cbf3370 100644 --- a/hosting/docker-compose.yaml +++ b/hosting/docker-compose.yaml @@ -23,6 +23,8 @@ services: ENABLE_ANALYTICS: "true" REDIS_URL: redis-service:6379 REDIS_PASSWORD: ${REDIS_PASSWORD} + BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL} + BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD} depends_on: - worker-service - redis-service diff --git a/hosting/hosting.properties b/hosting/hosting.properties index c8e2f5c60..11dd661bf 100644 --- a/hosting/hosting.properties +++ b/hosting/hosting.properties @@ -19,3 +19,7 @@ COUCH_DB_PORT=4005 REDIS_PORT=6379 WATCHTOWER_PORT=6161 BUDIBASE_ENVIRONMENT=PRODUCTION + +# An admin user can be automatically created initially if these are set +BB_ADMIN_USER_EMAIL= +BB_ADMIN_USER_PASSWORD= \ No newline at end of file diff --git a/hosting/letsencrypt/certificate-request.sh b/hosting/letsencrypt/certificate-request.sh index d029da265..83f314fc8 100644 --- a/hosting/letsencrypt/certificate-request.sh +++ b/hosting/letsencrypt/certificate-request.sh @@ -10,15 +10,14 @@ certbot certonly --webroot --webroot-path="/var/www/html" \ if (($? != 0)); then echo "ERROR: certbot request failed for $CUSTOM_DOMAIN use http on port 80 - exiting" - nginx -s stop exit 1 else cp /app/letsencrypt/options-ssl-nginx.conf /etc/letsencrypt/options-ssl-nginx.conf cp /app/letsencrypt/ssl-dhparams.pem /etc/letsencrypt/ssl-dhparams.pem cp /app/letsencrypt/nginx-ssl.conf /etc/nginx/sites-available/nginx-ssl.conf - sed -i 's/CUSTOM_DOMAIN/$CUSTOM_DOMAIN/g' /etc/nginx/sites-available/nginx-ssl.conf + sed -i "s/CUSTOM_DOMAIN/$CUSTOM_DOMAIN/g" /etc/nginx/sites-available/nginx-ssl.conf ln -s /etc/nginx/sites-available/nginx-ssl.conf /etc/nginx/sites-enabled/nginx-ssl.conf echo "INFO: restart nginx after certbot request" - nginx -s reload + /etc/init.d/nginx restart fi diff --git a/hosting/letsencrypt/nginx-ssl.conf b/hosting/letsencrypt/nginx-ssl.conf index c1a1d9191..50c5e0198 100644 --- a/hosting/letsencrypt/nginx-ssl.conf +++ b/hosting/letsencrypt/nginx-ssl.conf @@ -6,6 +6,7 @@ server { ssl_certificate_key /etc/letsencrypt/live/CUSTOM_DOMAIN/privkey.pem; include /etc/letsencrypt/options-ssl-nginx.conf; ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + client_max_body_size 1000m; ignore_invalid_headers off; proxy_buffering off; @@ -91,4 +92,5 @@ server { 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/scripts/build-target-paths.sh b/hosting/scripts/build-target-paths.sh new file mode 100644 index 000000000..d1c9b5cd0 --- /dev/null +++ b/hosting/scripts/build-target-paths.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +echo ${TARGETBUILD} > /buildtarget.txt +if [[ "${TARGETBUILD}" = "aas" ]]; then + # Azure AppService uses /home for persisent data & SSH on port 2222 + mkdir -p /home/budibase/{minio,couchdb} + mkdir -p /home/budibase/couchdb/data + chown -R couchdb:couchdb /home/budibase/couchdb/ + apt update + apt-get install -y openssh-server + sed -i 's#dir=/opt/couchdb/data/search#dir=/home/budibase/couchdb/data/search#' /opt/clouseau/clouseau.ini + sed -i 's#/minio/minio server /minio &#/minio/minio server /home/budibase/minio &#' /runner.sh + sed -i 's#database_dir = ./data#database_dir = /home/budibase/couchdb/data#' /opt/couchdb/etc/default.ini + sed -i 's#view_index_dir = ./data#view_index_dir = /home/budibase/couchdb/data#' /opt/couchdb/etc/default.ini + sed -i "s/#Port 22/Port 2222/" /etc/ssh/sshd_config + /etc/init.d/ssh restart +fi diff --git a/hosting/scripts/healthcheck.sh b/hosting/scripts/healthcheck.sh index fa6f511eb..80f2ece0b 100644 --- a/hosting/scripts/healthcheck.sh +++ b/hosting/scripts/healthcheck.sh @@ -25,6 +25,13 @@ if [[ $(redis-cli -a $REDIS_PASSWORD --no-auth-warning ping) != 'PONG' ]]; then healthy=false fi # mino, clouseau, +nginx -t -q +NGINX_STATUS=$? + +if [[ $NGINX_STATUS -gt 0 ]]; then + echo 'ERROR: Nginx config problem'; + healthy=false +fi if [ $healthy == true ]; then exit 0 diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index 24e90fc81..772ae2a8a 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -19,8 +19,12 @@ ADD packages/worker . RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh FROM couchdb:3.2.1 - +# TARGETARCH can be amd64 or arm e.g. docker build --build-arg TARGETARCH=amd64 ARG TARGETARCH amd64 +#TARGETBUILD can be set to single (for single docker image) or aas (for azure app service) +# e.g. docker build --build-arg TARGETBUILD=aas .... +ARG TARGETBUILD single +ENV TARGETBUILD $TARGETBUILD COPY --from=build /app /app COPY --from=build /worker /worker @@ -30,26 +34,32 @@ ENV \ ARCHITECTURE=amd \ BUDIBASE_ENVIRONMENT=PRODUCTION \ CLUSTER_PORT=80 \ - COUCHDB_PASSWORD=budibase \ - COUCHDB_USER=budibase \ - COUCH_DB_URL=http://budibase:budibase@localhost:5984 \ - CUSTOM_DOMAIN=budi001.custom.com \ + # CUSTOM_DOMAIN=budi001.custom.com \ DEPLOYMENT_ENVIRONMENT=docker \ - INTERNAL_API_KEY=budibase \ - JWT_SECRET=testsecret \ - MINIO_ACCESS_KEY=budibase \ - MINIO_SECRET_KEY=budibase \ MINIO_URL=http://localhost:9000 \ POSTHOG_TOKEN=phc_fg5I3nDOf6oJVMHSaycEhpPdlgS8rzXG2r6F2IpxCHS \ - REDIS_PASSWORD=budibase \ REDIS_URL=localhost:6379 \ SELF_HOSTED=1 \ + TARGETBUILD=$TARGETBUILD \ WORKER_PORT=4002 \ - WORKER_URL=http://localhost:4002 + WORKER_URL=http://localhost:4002 \ + APPS_URL=http://localhost:4001 + +# These secret env variables are generated by the runner at startup +# their values can be overriden by the user, they will be written +# to the .env file in the /data directory for use later on +# REDIS_PASSWORD=budibase \ +# COUCHDB_PASSWORD=budibase \ +# COUCHDB_USER=budibase \ +# COUCH_DB_URL=http://budibase:budibase@localhost:5984 \ +# INTERNAL_API_KEY=budibase \ +# JWT_SECRET=testsecret \ +# MINIO_ACCESS_KEY=budibase \ +# MINIO_SECRET_KEY=budibase \ # install base dependencies RUN apt-get update && \ - apt-get install -y software-properties-common wget nginx && \ + apt-get install -y software-properties-common wget nginx uuid-runtime && \ apt-add-repository 'deb http://security.debian.org/debian-security stretch/updates main' && \ apt-get update @@ -61,7 +71,8 @@ RUN curl -sL https://deb.nodesource.com/setup_16.x -o /tmp/nodesource_setup.sh & npm install --global yarn pm2 # setup nginx -ADD hosting/single/nginx.conf /etc/nginx +ADD hosting/single/nginx/nginx.conf /etc/nginx +ADD hosting/single/nginx/nginx-default-site.conf /etc/nginx/sites-enabled/default RUN mkdir -p /var/log/nginx && \ touch /var/log/nginx/error.log && \ touch /var/run/nginx.pid @@ -80,13 +91,13 @@ RUN wget https://github.com/cloudant-labs/clouseau/releases/download/2.21.0/clou WORKDIR /opt/clouseau RUN mkdir ./bin -ADD hosting/single/clouseau ./bin/ -ADD hosting/single/log4j.properties hosting/single/clouseau.ini ./ +ADD hosting/single/clouseau/clouseau ./bin/ +ADD hosting/single/clouseau/log4j.properties hosting/single/clouseau/clouseau.ini ./ RUN chmod +x ./bin/clouseau # setup CouchDB WORKDIR /opt/couchdb -ADD hosting/single/vm.args ./etc/ +ADD hosting/single/couch/vm.args hosting/single/couch/local.ini ./etc/ # setup minio WORKDIR /minio @@ -100,6 +111,12 @@ RUN chmod +x ./runner.sh ADD hosting/scripts/healthcheck.sh . RUN chmod +x ./healthcheck.sh +ADD hosting/scripts/build-target-paths.sh . +RUN chmod +x ./build-target-paths.sh + +# For Azure App Service install SSH & point data locations to /home +RUN /build-target-paths.sh + # cleanup cache RUN yarn cache clean -f diff --git a/hosting/single/README.md b/hosting/single/README.md index d62359a62..1147d55c8 100644 --- a/hosting/single/README.md +++ b/hosting/single/README.md @@ -4,7 +4,6 @@ As an alternative to running several docker containers via docker-compose, the files under ./hosting/single can be used to build a docker image containing all of the Budibase components (minio, couch, clouseau etc). We call this the 'single image' container as the Dockerfile adds all the components to a single docker image. - ## Usage - Amend Environment Variables @@ -22,9 +21,9 @@ If you have other arrangements for a proxy in front of the single image containe We would suggest building the image with 6GB of RAM and 20GB of free disk space for build artifacts. The resulting image size will use approx 2GB of disk space. ### Build the Image -The guidance below is based on building the Budibase single image on Debian 11. If you use another distro or OS you will need to amend the commands to suit. -Install Node -Budibase requires a recent version of node (14+) than is in the base Debian repos so: +The guidance below is based on building the Budibase single image on Debian 11 and AlmaLinux 8. If you use another distro or OS you will need to amend the commands to suit. +#### Install Node +Budibase requires a more recent version of node (14+) than is available in the base Debian repos so: ``` curl -sL https://deb.nodesource.com/setup_16.x | sudo bash - @@ -35,25 +34,26 @@ Install yarn and lerna: ``` npm install -g yarn jest lerna ``` -Install Docker +#### Install Docker + ``` apt install -y docker.io -apt install -y python3-pip -pip3 install docker-compose ``` + Check the versions of each installed version. This process was tested with the version numbers below so YMMV using anything else: - Docker: 20.10.5 -- docker-compose: 1.29.2 - node: 16.15.1 - yarn: 1.22.19 - lerna: 5.1.4 +#### Get the Code Clone the Budibase repo ``` git clone https://github.com/Budibase/budibase.git cd budibase ``` +#### Setup Node Node setup: ``` node ./hosting/scripts/setup.js @@ -61,15 +61,20 @@ yarn yarn bootstrap yarn build ``` - -Build the image from the Dockerfile: - +#### Build Image +The following yarn command does some prep and then runs the docker build command: ``` yarn build:docker:single ``` -If the docker build step fails run that step again manually with: +If the docker build step fails try running that step again manually with: ``` -docker build --no-cache -t budibase:latest -f ./hosting/single/Dockerfile . +docker build --build-arg TARGETARCH=amd --no-cache -t budibase:latest -f ./hosting/single/Dockerfile . +``` + +#### Azure App Services +Azure have some specific requirements for running a container in their App Service. Specifically, installation of SSH to port 2222 and data storage under /home. If you would like to build a budibase container for Azure App Service add the build argument shown below setting it to 'aas'. You can remove the CUSTOM_DOMAIN env variable from the Dockerfile too as Azure terminate SSL before requests reach the container. +``` +docker build --build-arg TARGETARCH=amd --build-arg TARGETBUILD=aas -t budibase:latest -f ./hosting/single/Dockerfile . ``` ### Run the Container @@ -85,6 +90,9 @@ When the container runs you should be able to access the container over http at When the Budibase UI appears you will be prompted to create an account to get started. +### Podman +The single image container builds fine when using podman in place of docker. You may be prompted for the registry to use for the CouchDB image and the HEALTHCHECK parameter is not OCI compliant so is ignored. + ### Check There are many things that could go wrong so if your container is not building or running as expected please check the following before opening a support issue. Verify the healthcheck status of the container: @@ -96,7 +104,6 @@ Check the container logs: docker logs budibase ``` - ### Support This single image build is still a work-in-progress so if you open an issue please provide the following information: - The OS and OS version you are building on diff --git a/hosting/single/clouseau b/hosting/single/clouseau/clouseau similarity index 100% rename from hosting/single/clouseau rename to hosting/single/clouseau/clouseau diff --git a/hosting/single/clouseau.ini b/hosting/single/clouseau/clouseau.ini similarity index 92% rename from hosting/single/clouseau.ini rename to hosting/single/clouseau/clouseau.ini index f086cf039..78e43744e 100644 --- a/hosting/single/clouseau.ini +++ b/hosting/single/clouseau/clouseau.ini @@ -7,7 +7,7 @@ name=clouseau@127.0.0.1 cookie=monster ; the path where you would like to store the search index files -dir=/opt/couchdb/data/search +dir=/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/clouseau/log4j.properties similarity index 100% rename from hosting/single/log4j.properties rename to hosting/single/clouseau/log4j.properties diff --git a/hosting/single/couch/local.ini b/hosting/single/couch/local.ini new file mode 100644 index 000000000..72872a60e --- /dev/null +++ b/hosting/single/couch/local.ini @@ -0,0 +1,5 @@ +; CouchDB Configuration Settings + +[couchdb] +database_dir = /data/couch/dbs +view_index_dir = /data/couch/views diff --git a/hosting/single/vm.args b/hosting/single/couch/vm.args similarity index 100% rename from hosting/single/vm.args rename to hosting/single/couch/vm.args diff --git a/hosting/single/nginx.conf b/hosting/single/nginx.conf deleted file mode 100644 index 42d20dd14..000000000 --- a/hosting/single/nginx.conf +++ /dev/null @@ -1,125 +0,0 @@ -user www-data www-data; -error_log /var/log/nginx/error.log; -pid /var/run/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 80 default_server; - listen [::]:80 default_server; - server_name _; - client_max_body_size 1000m; - ignore_invalid_headers off; - proxy_buffering off; - # port_in_redirect off; - - location ^~ /.well-known/acme-challenge/ { - default_type "text/plain"; - root /var/www/html; - break; - } - location = /.well-known/acme-challenge/ { - return 404; - } - - 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/nginx/nginx-default-site.conf b/hosting/single/nginx/nginx-default-site.conf new file mode 100644 index 000000000..c0d80a018 --- /dev/null +++ b/hosting/single/nginx/nginx-default-site.conf @@ -0,0 +1,91 @@ +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + + client_max_body_size 1000m; + ignore_invalid_headers off; + proxy_buffering off; + # port_in_redirect off; + + location ^~ /.well-known/acme-challenge/ { + default_type "text/plain"; + root /var/www/html; + break; + } + location = /.well-known/acme-challenge/ { + return 404; + } + + 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/nginx/nginx.conf b/hosting/single/nginx/nginx.conf new file mode 100644 index 000000000..1e5d1c20d --- /dev/null +++ b/hosting/single/nginx/nginx.conf @@ -0,0 +1,37 @@ +user www-data www-data; +error_log /var/log/nginx/error.log; +pid /var/run/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"; + } + + include /etc/nginx/sites-enabled/*; + +} diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh index 6f3d24784..f8c1fc5e5 100644 --- a/hosting/single/runner.sh +++ b/hosting/single/runner.sh @@ -1,6 +1,34 @@ +#!/bin/bash +declare -a ENV_VARS=("COUCHDB_USER" "COUCHDB_PASSWORD" "MINIO_ACCESS_KEY" "MINIO_SECRET_KEY" "INTERNAL_API_KEY" "JWT_SECRET" "REDIS_PASSWORD") +if [ -f "/data/.env" ]; then + export $(cat /data/.env | xargs) +fi +# first randomise any unset environment variables +for ENV_VAR in "${ENV_VARS[@]}" +do + temp=$(eval "echo \$$ENV_VAR") + if [[ -z "${temp}" ]]; then + eval "export $ENV_VAR=$(uuidgen | sed -e 's/-//g')" + fi +done +if [[ -z "${COUCH_DB_URL}" ]]; then + export COUCH_DB_URL=http://$COUCHDB_USER:$COUCHDB_PASSWORD@localhost:5984 +fi +if [ ! -f "/data/.env" ]; then + touch /data/.env + for ENV_VAR in "${ENV_VARS[@]}" + do + temp=$(eval "echo \$$ENV_VAR") + echo "$ENV_VAR=$temp" >> /data/.env + done +fi + +# make these directories in runner, incase of mount +mkdir -p /data/couch/dbs /data/couch/views +chown couchdb:couchdb /data/couch /data/couch/dbs /data/couch/views redis-server --requirepass $REDIS_PASSWORD & /opt/clouseau/bin/clouseau & -/minio/minio server /minio & +/minio/minio server /data/minio & /docker-entrypoint.sh /opt/couchdb/bin/couchdb & /etc/init.d/nginx restart if [[ ! -z "${CUSTOM_DOMAIN}" ]]; then diff --git a/hosting/single/test.sh b/hosting/single/test.sh index b9f9a4032..8830426a4 100755 --- a/hosting/single/test.sh +++ b/hosting/single/test.sh @@ -1,4 +1,4 @@ #!/bin/bash -id=$(docker run -t -d -p 10000:10000 budibase:latest) +id=$(docker run -t -d -p 8080:80 budibase:latest) docker exec -it $id bash docker kill $id diff --git a/lerna.json b/lerna.json index 66c3adff0..0858860bd 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.0.207-alpha.8", + "version": "1.0.220-alpha.4", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 9c35af497..b787b4aee 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "bootstrap": "lerna bootstrap && lerna link && ./scripts/link-dependencies.sh", "build": "lerna run build", "build:dev": "lerna run prebuild && tsc --build --watch --preserveWatchOutput", - "release": "lerna publish patch --yes --force-publish && yarn release:pro", + "release": "lerna publish ${RELEASE_VERSION_TYPE:-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", @@ -40,7 +40,8 @@ "dev": "yarn run kill-all && lerna link && lerna run --parallel dev:builder --concurrency 1", "dev:noserver": "yarn run kill-builder && lerna link && lerna run dev:stack:up && lerna run --parallel dev:builder --concurrency 1 --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker", "dev:server": "yarn run kill-server && lerna run --parallel dev:builder --concurrency 1 --scope @budibase/backend-core --scope @budibase/worker --scope @budibase/server", - "test": "lerna run test", + "test": "lerna run test && yarn test:pro", + "test:pro": "bash scripts/pro/test.sh", "lint:eslint": "eslint packages", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\"", "lint": "yarn run lint:eslint && yarn run lint:prettier", @@ -83,4 +84,4 @@ "install:pro": "bash scripts/pro/install.sh", "dep:clean": "yarn clean && yarn bootstrap" } -} +} \ No newline at end of file diff --git a/packages/backend-core/cache.js b/packages/backend-core/cache.js index 932fd7b90..c8bd3c9b6 100644 --- a/packages/backend-core/cache.js +++ b/packages/backend-core/cache.js @@ -3,5 +3,7 @@ const generic = require("./src/cache/generic") module.exports = { user: require("./src/cache/user"), app: require("./src/cache/appMetadata"), + writethrough: require("./src/cache/writethrough"), ...generic, + cache: generic, } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index d69d57b88..9b25ab7c5 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.0.207-alpha.8", + "version": "1.0.220-alpha.4", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -20,7 +20,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/types": "^1.0.207-alpha.8", + "@budibase/types": "^1.0.220-alpha.4", "@techpass/passport-openidconnect": "0.3.2", "aws-sdk": "2.1030.0", "bcrypt": "5.0.1", @@ -36,6 +36,7 @@ "passport-google-oauth": "2.0.0", "passport-jwt": "4.0.0", "passport-local": "1.0.0", + "passport-oauth2-refresh": "^2.1.0", "posthog-node": "1.3.0", "pouchdb": "7.3.0", "pouchdb-find": "7.2.2", @@ -58,11 +59,13 @@ ] }, "devDependencies": { + "@budibase/types": "^1.0.219", "@shopify/jest-koa-mocks": "3.1.5", "@types/jest": "27.5.1", "@types/koa": "2.0.52", "@types/node": "14.18.20", "@types/node-fetch": "2.6.1", + "@types/pouchdb": "6.4.0", "@types/redlock": "4.0.3", "@types/semver": "7.3.7", "@types/tar-fs": "2.0.1", diff --git a/packages/backend-core/redis.js b/packages/backend-core/redis.js index badce3be2..1f7a48540 100644 --- a/packages/backend-core/redis.js +++ b/packages/backend-core/redis.js @@ -1,5 +1,5 @@ module.exports = { Client: require("./src/redis"), utils: require("./src/redis/utils"), - clients: require("./src/redis/authRedis"), + clients: require("./src/redis/init"), } diff --git a/packages/backend-core/src/auth.js b/packages/backend-core/src/auth.js index b13cd932c..b60144a0d 100644 --- a/packages/backend-core/src/auth.js +++ b/packages/backend-core/src/auth.js @@ -2,6 +2,9 @@ const passport = require("koa-passport") const LocalStrategy = require("passport-local").Strategy const JwtStrategy = require("passport-jwt").Strategy const { getGlobalDB } = require("./tenancy") +const refresh = require("passport-oauth2-refresh") +const { Configs } = require("./constants") +const { getScopedConfig } = require("./db/utils") const { jwt, local, @@ -12,10 +15,13 @@ const { tenancy, appTenancy, authError, + ssoCallbackUrl, csrf, internalApi, } = require("./middleware") +const { invalidateUser } = require("./cache/user") + // Strategies passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) @@ -34,6 +40,124 @@ passport.deserializeUser(async (user, done) => { } }) +async function refreshOIDCAccessToken(db, chosenConfig, refreshToken) { + const callbackUrl = await oidc.getCallbackUrl(db, chosenConfig) + let enrichedConfig + let strategy + + try { + enrichedConfig = await oidc.fetchStrategyConfig(chosenConfig, callbackUrl) + if (!enrichedConfig) { + throw new Error("OIDC Config contents invalid") + } + strategy = await oidc.strategyFactory(enrichedConfig) + } catch (err) { + console.error(err) + throw new Error("Could not refresh OAuth Token") + } + + refresh.use(strategy, { + setRefreshOAuth2() { + return strategy._getOAuth2Client(enrichedConfig) + }, + }) + + return new Promise(resolve => { + refresh.requestNewAccessToken( + Configs.OIDC, + refreshToken, + (err, accessToken, refreshToken, params) => { + resolve({ err, accessToken, refreshToken, params }) + } + ) + }) +} + +async function refreshGoogleAccessToken(db, config, refreshToken) { + let callbackUrl = await google.getCallbackUrl(db, config) + + let strategy + try { + strategy = await google.strategyFactory(config, callbackUrl) + } catch (err) { + console.error(err) + throw new Error("Error constructing OIDC refresh strategy", err) + } + + refresh.use(strategy) + + return new Promise(resolve => { + refresh.requestNewAccessToken( + Configs.GOOGLE, + refreshToken, + (err, accessToken, refreshToken, params) => { + resolve({ err, accessToken, refreshToken, params }) + } + ) + }) +} + +async function refreshOAuthToken(refreshToken, configType, configId) { + const db = getGlobalDB() + + const config = await getScopedConfig(db, { + type: configType, + group: {}, + }) + + let chosenConfig = {} + let refreshResponse + if (configType === Configs.OIDC) { + // configId - retrieved from cookie. + chosenConfig = config.configs.filter(c => c.uuid === configId)[0] + if (!chosenConfig) { + throw new Error("Invalid OIDC configuration") + } + refreshResponse = await refreshOIDCAccessToken( + db, + chosenConfig, + refreshToken + ) + } else { + chosenConfig = config + refreshResponse = await refreshGoogleAccessToken( + db, + chosenConfig, + refreshToken + ) + } + + return refreshResponse +} + +async function updateUserOAuth(userId, oAuthConfig) { + const details = { + accessToken: oAuthConfig.accessToken, + refreshToken: oAuthConfig.refreshToken, + } + + try { + const db = getGlobalDB() + const dbUser = await db.get(userId) + + //Do not overwrite the refresh token if a valid one is not provided. + if (typeof details.refreshToken !== "string") { + delete details.refreshToken + } + + dbUser.oauth2 = { + ...dbUser.oauth2, + ...details, + } + + await db.put(dbUser) + + await invalidateUser(userId) + } catch (e) { + console.error("Could not update OAuth details for current user", e) + } +} + module.exports = { buildAuthMiddleware: authenticated, passport, @@ -46,4 +170,7 @@ module.exports = { authError, buildCsrfMiddleware: csrf, internalApi, + refreshOAuthToken, + updateUserOAuth, + ssoCallbackUrl, } diff --git a/packages/backend-core/src/cache/appMetadata.js b/packages/backend-core/src/cache/appMetadata.js index effdc886d..b0d9481cb 100644 --- a/packages/backend-core/src/cache/appMetadata.js +++ b/packages/backend-core/src/cache/appMetadata.js @@ -1,4 +1,4 @@ -const redis = require("../redis/authRedis") +const redis = require("../redis/init") const { doWithDB } = require("../db") const { DocumentTypes } = require("../db/constants") diff --git a/packages/backend-core/src/cache/base/index.ts b/packages/backend-core/src/cache/base/index.ts new file mode 100644 index 000000000..f3216531f --- /dev/null +++ b/packages/backend-core/src/cache/base/index.ts @@ -0,0 +1,92 @@ +import { getTenantId } from "../../context" +import redis from "../../redis/init" +import RedisWrapper from "../../redis" + +function generateTenantKey(key: string) { + const tenantId = getTenantId() + return `${key}:${tenantId}` +} + +export = class BaseCache { + client: RedisWrapper | undefined + + constructor(client: RedisWrapper | undefined = undefined) { + this.client = client + } + + async getClient() { + return !this.client ? await redis.getCacheClient() : this.client + } + + async keys(pattern: string) { + const client = await this.getClient() + return client.keys(pattern) + } + + /** + * Read only from the cache. + */ + async get(key: string, opts = { useTenancy: true }) { + key = opts.useTenancy ? generateTenantKey(key) : key + const client = await this.getClient() + return client.get(key) + } + + /** + * Write to the cache. + */ + async store( + key: string, + value: any, + ttl: number | null = null, + opts = { useTenancy: true } + ) { + key = opts.useTenancy ? generateTenantKey(key) : key + const client = await this.getClient() + await client.store(key, value, ttl) + } + + /** + * Remove from cache. + */ + async delete(key: string, opts = { useTenancy: true }) { + key = opts.useTenancy ? generateTenantKey(key) : key + const client = await this.getClient() + return client.delete(key) + } + + /** + * Read from the cache. Write to the cache if not exists. + */ + async withCache( + key: string, + ttl: number, + fetchFn: any, + opts = { useTenancy: true } + ) { + const cachedValue = await this.get(key, opts) + if (cachedValue) { + return cachedValue + } + + try { + const fetchedValue = await fetchFn() + + await this.store(key, fetchedValue, ttl, opts) + return fetchedValue + } catch (err) { + console.error("Error fetching before cache - ", err) + throw err + } + } + + async bustCache(key: string, opts = { client: null }) { + const client = await this.getClient() + try { + await client.delete(generateTenantKey(key)) + } catch (err) { + console.error("Error busting cache - ", err) + throw err + } + } +} diff --git a/packages/backend-core/src/cache/generic.js b/packages/backend-core/src/cache/generic.js index 8f0ef7895..e2f391533 100644 --- a/packages/backend-core/src/cache/generic.js +++ b/packages/backend-core/src/cache/generic.js @@ -1,5 +1,6 @@ -const redis = require("../redis/authRedis") -const { getTenantId } = require("../context") +const BaseCache = require("./base") + +const GENERIC = new BaseCache() exports.CacheKeys = { CHECKLIST: "checklist", @@ -16,67 +17,13 @@ exports.TTL = { ONE_DAY: 86400, } -function generateTenantKey(key) { - const tenantId = getTenantId() - return `${key}:${tenantId}` -} - -exports.keys = async pattern => { - const client = await redis.getCacheClient() - return client.keys(pattern) -} - -/** - * Read only from the cache. - */ -exports.get = async (key, opts = { useTenancy: true }) => { - key = opts.useTenancy ? generateTenantKey(key) : key - const client = await redis.getCacheClient() - const value = await client.get(key) - return value -} - -/** - * Write to the cache. - */ -exports.store = async (key, value, ttl, opts = { useTenancy: true }) => { - key = opts.useTenancy ? generateTenantKey(key) : key - const client = await redis.getCacheClient() - await client.store(key, value, ttl) +function performExport(funcName) { + return (...args) => GENERIC[funcName](...args) } -exports.delete = async (key, opts = { useTenancy: true }) => { - key = opts.useTenancy ? generateTenantKey(key) : key - const client = await redis.getCacheClient() - return client.delete(key) -} - -/** - * Read from the cache. Write to the cache if not exists. - */ -exports.withCache = async (key, ttl, fetchFn, opts = { useTenancy: true }) => { - const cachedValue = await exports.get(key, opts) - if (cachedValue) { - return cachedValue - } - - try { - const fetchedValue = await fetchFn() - - await exports.store(key, fetchedValue, ttl, opts) - return fetchedValue - } catch (err) { - console.error("Error fetching before cache - ", err) - throw err - } -} - -exports.bustCache = async key => { - const client = await redis.getCacheClient() - try { - await client.delete(generateTenantKey(key)) - } catch (err) { - console.error("Error busting cache - ", err) - throw err - } -} +exports.keys = performExport("keys") +exports.get = performExport("get") +exports.store = performExport("store") +exports.delete = performExport("delete") +exports.withCache = performExport("withCache") +exports.bustCache = performExport("bustCache") diff --git a/packages/backend-core/src/cache/tests/writethrough.spec.js b/packages/backend-core/src/cache/tests/writethrough.spec.js new file mode 100644 index 000000000..68db24b32 --- /dev/null +++ b/packages/backend-core/src/cache/tests/writethrough.spec.js @@ -0,0 +1,59 @@ +require("../../../tests/utilities/TestConfiguration") +const { Writethrough } = require("../writethrough") +const { dangerousGetDB } = require("../../db") +const tk = require("timekeeper") + +const START_DATE = Date.now() +tk.freeze(START_DATE) + +const DELAY = 5000 + +const db = dangerousGetDB("test") +const db2 = dangerousGetDB("test2") +const writethrough = new Writethrough(db, DELAY), writethrough2 = new Writethrough(db2, DELAY) + +describe("writethrough", () => { + describe("put", () => { + let first + it("should be able to store, will go to DB", async () => { + const response = await writethrough.put({ _id: "test", value: 1 }) + const output = await db.get(response.id) + first = output + expect(output.value).toBe(1) + }) + + it("second put shouldn't update DB", async () => { + const response = await writethrough.put({ ...first, value: 2 }) + const output = await db.get(response.id) + expect(first._rev).toBe(output._rev) + expect(output.value).toBe(1) + }) + + it("should put it again after delay period", async () => { + tk.freeze(START_DATE + DELAY + 1) + const response = await writethrough.put({ ...first, value: 3 }) + const output = await db.get(response.id) + expect(response.rev).not.toBe(first._rev) + expect(output.value).toBe(3) + }) + }) + + describe("get", () => { + it("should be able to retrieve", async () => { + const response = await writethrough.get("test") + expect(response.value).toBe(3) + }) + }) + + describe("same doc, different databases (tenancy)", () => { + it("should be able to two different databases", async () => { + const resp1 = await writethrough.put({ _id: "db1", value: "first" }) + const resp2 = await writethrough2.put({ _id: "db1", value: "second" }) + expect(resp1.rev).toBeDefined() + expect(resp2.rev).toBeDefined() + expect((await db.get("db1")).value).toBe("first") + expect((await db2.get("db1")).value).toBe("second") + }) + }) +}) + diff --git a/packages/backend-core/src/cache/user.js b/packages/backend-core/src/cache/user.js index faac6de72..130da1915 100644 --- a/packages/backend-core/src/cache/user.js +++ b/packages/backend-core/src/cache/user.js @@ -1,4 +1,4 @@ -const redis = require("../redis/authRedis") +const redis = require("../redis/init") const { getTenantId, lookupTenantId, doWithGlobalDB } = require("../tenancy") const env = require("../environment") const accounts = require("../cloud/accounts") diff --git a/packages/backend-core/src/cache/writethrough.ts b/packages/backend-core/src/cache/writethrough.ts new file mode 100644 index 000000000..e11ca0aca --- /dev/null +++ b/packages/backend-core/src/cache/writethrough.ts @@ -0,0 +1,120 @@ +import BaseCache from "./base" +import { getWritethroughClient } from "../redis/init" + +const DEFAULT_WRITE_RATE_MS = 10000 +let CACHE: BaseCache | null = null + +interface CacheItem { + doc: any + lastWrite: number +} + +async function getCache() { + if (!CACHE) { + const client = await getWritethroughClient() + CACHE = new BaseCache(client) + } + return CACHE +} + +function makeCacheKey(db: PouchDB.Database, key: string) { + return db.name + key +} + +function makeCacheItem(doc: any, lastWrite: number | null = null): CacheItem { + return { doc, lastWrite: lastWrite || Date.now() } +} + +export async function put( + db: PouchDB.Database, + doc: any, + writeRateMs: number = DEFAULT_WRITE_RATE_MS +) { + const cache = await getCache() + const key = doc._id + let cacheItem: CacheItem | undefined = await cache.get(makeCacheKey(db, key)) + const updateDb = !cacheItem || cacheItem.lastWrite < Date.now() - writeRateMs + let output = doc + if (updateDb) { + const writeDb = async (toWrite: any) => { + // doc should contain the _id and _rev + const response = await db.put(toWrite) + output = { + ...doc, + _id: response.id, + _rev: response.rev, + } + } + try { + await writeDb(doc) + } catch (err: any) { + if (err.status !== 409) { + throw err + } else { + // get the rev, update over it - this is risky, may change in future + const readDoc = await db.get(doc._id) + doc._rev = readDoc._rev + await writeDb(doc) + } + } + } + // if we are updating the DB then need to set the lastWrite to now + cacheItem = makeCacheItem(output, updateDb ? null : cacheItem?.lastWrite) + await cache.store(makeCacheKey(db, key), cacheItem) + return { ok: true, id: output._id, rev: output._rev } +} + +export async function get(db: PouchDB.Database, id: string): Promise { + const cache = await getCache() + const cacheKey = makeCacheKey(db, id) + let cacheItem: CacheItem = await cache.get(cacheKey) + if (!cacheItem) { + const doc = await db.get(id) + cacheItem = makeCacheItem(doc) + await cache.store(cacheKey, cacheItem) + } + return cacheItem.doc +} + +export async function remove( + db: PouchDB.Database, + docOrId: any, + rev?: any +): Promise { + const cache = await getCache() + if (!docOrId) { + throw new Error("No ID/Rev provided.") + } + const id = typeof docOrId === "string" ? docOrId : docOrId._id + rev = typeof docOrId === "string" ? rev : docOrId._rev + try { + await cache.delete(makeCacheKey(db, id)) + } finally { + await db.remove(id, rev) + } +} + +export class Writethrough { + db: PouchDB.Database + writeRateMs: number + + constructor( + db: PouchDB.Database, + writeRateMs: number = DEFAULT_WRITE_RATE_MS + ) { + this.db = db + this.writeRateMs = writeRateMs + } + + async put(doc: any) { + return put(this.db, doc, this.writeRateMs) + } + + async get(id: string) { + return get(this.db, id) + } + + async remove(docOrId: any, rev?: any) { + return remove(this.db, docOrId, rev) + } +} diff --git a/packages/backend-core/src/context/index.js b/packages/backend-core/src/context/index.js index a69a381f0..bd4d857ef 100644 --- a/packages/backend-core/src/context/index.js +++ b/packages/backend-core/src/context/index.js @@ -314,6 +314,7 @@ function getContextDB(key, opts) { toUseAppId = getDevelopmentAppID(appId) break } + db = dangerousGetDB(toUseAppId, opts) try { cls.setOnContext(key, db) diff --git a/packages/backend-core/src/db/constants.js b/packages/backend-core/src/db/constants.js deleted file mode 100644 index 10c6e174d..000000000 --- a/packages/backend-core/src/db/constants.js +++ /dev/null @@ -1,41 +0,0 @@ -exports.SEPARATOR = "_" - -const PRE_APP = "app" -const PRE_DEV = "dev" - -exports.DocumentTypes = { - USER: "us", - WORKSPACE: "workspace", - CONFIG: "config", - TEMPLATE: "template", - APP: PRE_APP, - DEV: PRE_DEV, - APP_DEV: `${PRE_APP}${exports.SEPARATOR}${PRE_DEV}`, - APP_METADATA: `${PRE_APP}${exports.SEPARATOR}metadata`, - ROLE: "role", - MIGRATIONS: "migrations", - DEV_INFO: "devinfo", -} - -exports.StaticDatabases = { - GLOBAL: { - name: "global-db", - docs: { - apiKeys: "apikeys", - usageQuota: "usage_quota", - licenseInfo: "license_info", - }, - }, - // contains information about tenancy and so on - PLATFORM_INFO: { - name: "global-info", - docs: { - tenants: "tenants", - install: "install", - }, - }, -} - -exports.APP_PREFIX = exports.DocumentTypes.APP + exports.SEPARATOR -exports.APP_DEV = exports.APP_DEV_PREFIX = - exports.DocumentTypes.APP_DEV + exports.SEPARATOR diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts new file mode 100644 index 000000000..be0e824e6 --- /dev/null +++ b/packages/backend-core/src/db/constants.ts @@ -0,0 +1,58 @@ +export const SEPARATOR = "_" +export const UNICODE_MAX = "\ufff0" + +/** + * Can be used to create a few different forms of querying a view. + */ +export enum AutomationViewModes { + ALL = "all", + AUTOMATION = "automation", + STATUS = "status", +} + +export enum ViewNames { + USER_BY_EMAIL = "by_email", + BY_API_KEY = "by_api_key", + USER_BY_BUILDERS = "by_builders", + LINK = "by_link", + ROUTING = "screen_routes", + AUTOMATION_LOGS = "automation_logs", +} + +export enum DocumentTypes { + USER = "us", + WORKSPACE = "workspace", + CONFIG = "config", + TEMPLATE = "template", + APP = "app", + DEV = "dev", + APP_DEV = "app_dev", + APP_METADATA = "app_metadata", + ROLE = "role", + MIGRATIONS = "migrations", + DEV_INFO = "devinfo", + AUTOMATION_LOG = "log_au", +} + +export const StaticDatabases = { + GLOBAL: { + name: "global-db", + docs: { + apiKeys: "apikeys", + usageQuota: "usage_quota", + licenseInfo: "license_info", + }, + }, + // contains information about tenancy and so on + PLATFORM_INFO: { + name: "global-info", + docs: { + tenants: "tenants", + install: "install", + }, + }, +} + +export const APP_PREFIX = exports.DocumentTypes.APP + exports.SEPARATOR +export const APP_DEV = exports.DocumentTypes.APP_DEV + exports.SEPARATOR +export const APP_DEV_PREFIX = APP_DEV diff --git a/packages/backend-core/src/db/pouch.js b/packages/backend-core/src/db/pouch.js index 76390ac64..59b7ff8ae 100644 --- a/packages/backend-core/src/db/pouch.js +++ b/packages/backend-core/src/db/pouch.js @@ -1,21 +1,42 @@ const PouchDB = require("pouchdb") const env = require("../environment") -function getUrlInfo() { - let url = env.COUCH_DB_URL - let username, password, host - const [protocol, rest] = url.split("://") - if (url.includes("@")) { - const hostParts = rest.split("@") - host = hostParts[1] - const authParts = hostParts[0].split(":") - username = authParts[0] - password = authParts[1] - } else { - host = rest +exports.getUrlInfo = (url = env.COUCH_DB_URL) => { + let cleanUrl, username, password, host + if (url) { + // Ensure the URL starts with a protocol + const protoRegex = /^https?:\/\//i + if (!protoRegex.test(url)) { + url = `http://${url}` + } + + // Split into protocol and remainder + const split = url.split("://") + const protocol = split[0] + const rest = split.slice(1).join("://") + + // Extract auth if specified + if (url.includes("@")) { + // Split into host and remainder + let parts = rest.split("@") + host = parts[parts.length - 1] + let auth = parts.slice(0, -1).join("@") + + // Split auth into username and password + if (auth.includes(":")) { + const authParts = auth.split(":") + username = authParts[0] + password = authParts.slice(1).join(":") + } else { + username = auth + } + } else { + host = rest + } + cleanUrl = `${protocol}://${host}` } return { - url: `${protocol}://${host}`, + url: cleanUrl, auth: { username, password, @@ -24,7 +45,7 @@ function getUrlInfo() { } exports.getCouchInfo = () => { - const urlInfo = getUrlInfo() + const urlInfo = exports.getUrlInfo() let username let password if (env.COUCH_DB_USERNAME) { diff --git a/packages/backend-core/src/db/tests/pouch.spec.js b/packages/backend-core/src/db/tests/pouch.spec.js new file mode 100644 index 000000000..30cdd0f5e --- /dev/null +++ b/packages/backend-core/src/db/tests/pouch.spec.js @@ -0,0 +1,62 @@ +require("../../../tests/utilities/TestConfiguration") +const getUrlInfo = require("../pouch").getUrlInfo + +describe("pouch", () => { + describe("Couch DB URL parsing", () => { + it("should handle a null Couch DB URL", () => { + const info = getUrlInfo(null) + expect(info.url).toBeUndefined() + expect(info.auth.username).toBeUndefined() + }) + it("should be able to parse a basic Couch DB URL", () => { + const info = getUrlInfo("http://host.com") + expect(info.url).toBe("http://host.com") + expect(info.auth.username).toBeUndefined() + }) + it("should be able to parse a Couch DB basic URL with HTTPS", () => { + const info = getUrlInfo("https://host.com") + expect(info.url).toBe("https://host.com") + expect(info.auth.username).toBeUndefined() + }) + it("should be able to parse a basic Couch DB URL with a custom port", () => { + const info = getUrlInfo("https://host.com:1234") + expect(info.url).toBe("https://host.com:1234") + expect(info.auth.username).toBeUndefined() + }) + it("should be able to parse a Couch DB URL with auth", () => { + const info = getUrlInfo("https://user:pass@host.com:1234") + expect(info.url).toBe("https://host.com:1234") + expect(info.auth.username).toBe("user") + expect(info.auth.password).toBe("pass") + }) + it("should be able to parse a Couch DB URL with auth and special chars", () => { + const info = getUrlInfo("https://user:s:p@s://@://:d@;][~s@host.com:1234") + expect(info.url).toBe("https://host.com:1234") + expect(info.auth.username).toBe("user") + expect(info.auth.password).toBe("s:p@s://@://:d@;][~s") + }) + it("should be able to parse a Couch DB URL without a protocol", () => { + const info = getUrlInfo("host.com:1234") + expect(info.url).toBe("http://host.com:1234") + expect(info.auth.username).toBeUndefined() + }) + it("should be able to parse a Couch DB URL with auth and without a protocol", () => { + const info = getUrlInfo("user:s:p@s://@://:d@;][~s@host.com:1234") + expect(info.url).toBe("http://host.com:1234") + expect(info.auth.username).toBe("user") + expect(info.auth.password).toBe("s:p@s://@://:d@;][~s") + }) + it("should be able to parse a Couch DB URL with only username auth", () => { + const info = getUrlInfo("https://user@host.com:1234") + expect(info.url).toBe("https://host.com:1234") + expect(info.auth.username).toBe("user") + expect(info.auth.password).toBeUndefined() + }) + it("should be able to parse a Couch DB URL with only username auth and without a protocol", () => { + const info = getUrlInfo("user@host.com:1234") + expect(info.url).toBe("http://host.com:1234") + expect(info.auth.username).toBe("user") + expect(info.auth.password).toBeUndefined() + }) + }) +}) diff --git a/packages/backend-core/src/db/utils.ts b/packages/backend-core/src/db/utils.ts index dc7a0454c..ba3f1dd3e 100644 --- a/packages/backend-core/src/db/utils.ts +++ b/packages/backend-core/src/db/utils.ts @@ -1,7 +1,7 @@ import { newid } from "../hashing" import { DEFAULT_TENANT_ID, Configs } from "../constants" import env from "../environment" -import { SEPARATOR, DocumentTypes } from "./constants" +import { SEPARATOR, DocumentTypes, UNICODE_MAX, ViewNames } from "./constants" import { getTenantId, getGlobalDBName, getGlobalDB } from "../tenancy" import fetch from "node-fetch" import { doWithDB, allDbs } from "./index" @@ -12,14 +12,6 @@ import { isDevApp, isDevAppID } from "./conversions" import { APP_PREFIX } from "./constants" import * as events from "../events" -const UNICODE_MAX = "\ufff0" - -export const ViewNames = { - USER_BY_EMAIL: "by_email", - BY_API_KEY: "by_api_key", - USER_BY_BUILDERS: "by_builders", -} - export * from "./constants" export * from "./conversions" export { default as Replication } from "./Replication" @@ -63,6 +55,13 @@ export function getDocParams( } } +/** + * Retrieve the correct index for a view based on default design DB. + */ +export function getQueryIndex(viewName: ViewNames) { + return `database/${viewName}` +} + /** * Generates a new workspace ID. * @returns {string} The new workspace ID which the workspace doc can be stored under. @@ -93,13 +92,17 @@ export function generateGlobalUserID(id?: any) { /** * Gets parameters for retrieving users. */ -export function getGlobalUserParams(globalId: any, otherProps = {}) { +export function getGlobalUserParams(globalId: any, otherProps: any = {}) { if (!globalId) { globalId = "" } + const startkey = otherProps?.startkey return { ...otherProps, - startkey: `${DocumentTypes.USER}${SEPARATOR}${globalId}`, + // need to include this incase pagination + startkey: startkey + ? startkey + : `${DocumentTypes.USER}${SEPARATOR}${globalId}`, endkey: `${DocumentTypes.USER}${SEPARATOR}${globalId}${UNICODE_MAX}`, } } @@ -384,7 +387,9 @@ export const getScopedFullConfig = async function ( if (type === Configs.SETTINGS) { if (scopedConfig && scopedConfig.doc) { // overrides affected by environment variables - scopedConfig.doc.config.platformUrl = await getPlatformUrl() + scopedConfig.doc.config.platformUrl = await getPlatformUrl({ + tenantAware: true, + }) scopedConfig.doc.config.analyticsEnabled = await events.analytics.enabled() } else { @@ -393,7 +398,7 @@ export const getScopedFullConfig = async function ( doc: { _id: generateConfigID({ type, user, workspace }), config: { - platformUrl: await getPlatformUrl(), + platformUrl: await getPlatformUrl({ tenantAware: true }), analyticsEnabled: await events.analytics.enabled(), }, }, @@ -434,6 +439,26 @@ export const getPlatformUrl = async (opts = { tenantAware: true }) => { return platformUrl } +export function pagination( + data: any[], + pageSize: number, + { paginate, property } = { paginate: true, property: "_id" } +) { + if (!paginate) { + return { data, hasNextPage: false } + } + const hasNextPage = data.length > pageSize + let nextPage = undefined + if (hasNextPage) { + nextPage = property ? data[pageSize]?.[property] : data[pageSize]?._id + } + return { + data: data.slice(0, pageSize), + hasNextPage, + nextPage, + } +} + export async function getScopedConfig(db: any, params: any) { const configDoc = await getScopedFullConfig(db, params) return configDoc && configDoc.config ? configDoc.config : configDoc diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 1d9febc6f..845504fdc 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -16,7 +16,7 @@ if (!LOADED && isDev() && !isTest()) { LOADED = true } -const env: any = { +const env = { isTest, isDev, JWT_SECRET: process.env.JWT_SECRET, @@ -40,7 +40,7 @@ const env: any = { 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, + PLATFORM_URL: process.env.PLATFORM_URL || "", POSTHOG_TOKEN: process.env.POSTHOG_TOKEN, ENABLE_ANALYTICS: process.env.ENABLE_ANALYTICS, TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS, @@ -66,6 +66,7 @@ const env: any = { for (let [key, value] of Object.entries(env)) { // handle the edge case of "0" to disable an environment variable if (value === "0") { + // @ts-ignore env[key] = 0 } } diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 6eb6b14bc..ab89eed3b 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -13,6 +13,7 @@ import deprovisioning from "./context/deprovision" import auth from "./auth" import constants from "./constants" import * as dbConstants from "./db/constants" +import logging from "./logging" // mimic the outer package exports import * as db from "./pkg/db" @@ -49,6 +50,7 @@ const core = { deprovisioning, installation, errors, + logging, ...errorClasses, } diff --git a/packages/backend-core/src/logging.js b/packages/backend-core/src/logging.ts similarity index 72% rename from packages/backend-core/src/logging.js rename to packages/backend-core/src/logging.ts index 6ab5bff44..68c3307b2 100644 --- a/packages/backend-core/src/logging.js +++ b/packages/backend-core/src/logging.ts @@ -1,10 +1,10 @@ const NonErrors = ["AccountError"] -function isSuppressed(e) { +function isSuppressed(e?: any) { return e && e["suppressAlert"] } -module.exports.logAlert = (message, e) => { +export function logAlert(message: string, e?: any) { if (e && NonErrors.includes(e.name) && isSuppressed(e)) { return } @@ -14,3 +14,7 @@ module.exports.logAlert = (message, e) => { } console.error(`bb-alert: ${message} ${errorJson}`) } + +export default { + logAlert, +} diff --git a/packages/backend-core/src/middleware/authenticated.js b/packages/backend-core/src/middleware/authenticated.js index 9c35336dd..4e6e0b7ba 100644 --- a/packages/backend-core/src/middleware/authenticated.js +++ b/packages/backend-core/src/middleware/authenticated.js @@ -94,7 +94,6 @@ module.exports = ( user = await getUser(userId, session.tenantId) } user.csrfToken = session.csrfToken - delete user.password authenticated = true } catch (err) { error = err @@ -128,6 +127,8 @@ module.exports = ( } if (!user && tenantId) { user = { tenantId } + } else { + delete user.password } // be explicit if (authenticated !== true) { diff --git a/packages/backend-core/src/middleware/index.js b/packages/backend-core/src/middleware/index.js index 6c4c0d888..1721d56a3 100644 --- a/packages/backend-core/src/middleware/index.js +++ b/packages/backend-core/src/middleware/index.js @@ -2,7 +2,7 @@ const jwt = require("./passport/jwt") const local = require("./passport/local") const google = require("./passport/google") const oidc = require("./passport/oidc") -const { authError } = require("./passport/utils") +const { authError, ssoCallbackUrl } = require("./passport/utils") const authenticated = require("./authenticated") const auditLog = require("./auditLog") const tenancy = require("./tenancy") @@ -20,6 +20,7 @@ module.exports = { tenancy, authError, internalApi, + ssoCallbackUrl, datasource: { google: datasourceGoogle, }, diff --git a/packages/backend-core/src/middleware/passport/google.js b/packages/backend-core/src/middleware/passport/google.js index 858029ca8..7419974cd 100644 --- a/packages/backend-core/src/middleware/passport/google.js +++ b/packages/backend-core/src/middleware/passport/google.js @@ -1,6 +1,7 @@ const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy - +const { ssoCallbackUrl } = require("./utils") const { authenticateThirdParty } = require("./third-party-common") +const { Configs } = require("../../../constants") const buildVerifyFn = saveUserFn => { return (accessToken, refreshToken, profile, done) => { @@ -57,5 +58,10 @@ exports.strategyFactory = async function (config, callbackUrl, saveUserFn) { ) } } + +exports.getCallbackUrl = async function (db, config) { + return ssoCallbackUrl(db, config, Configs.GOOGLE) +} + // expose for testing exports.buildVerifyFn = buildVerifyFn diff --git a/packages/backend-core/src/middleware/passport/local.js b/packages/backend-core/src/middleware/passport/local.js index 445893b1d..b955d2910 100644 --- a/packages/backend-core/src/middleware/passport/local.js +++ b/packages/backend-core/src/middleware/passport/local.js @@ -55,6 +55,7 @@ exports.authenticate = async function (ctx, email, password, done) { if (await compare(password, dbUser.password)) { const sessionId = newid() const tenantId = getTenantId() + await createASession(dbUser._id, { sessionId, tenantId }) dbUser.token = jwt.sign( diff --git a/packages/backend-core/src/middleware/passport/oidc.js b/packages/backend-core/src/middleware/passport/oidc.js index 1e93e20b1..20dbd4669 100644 --- a/packages/backend-core/src/middleware/passport/oidc.js +++ b/packages/backend-core/src/middleware/passport/oidc.js @@ -1,6 +1,8 @@ const fetch = require("node-fetch") const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy const { authenticateThirdParty } = require("./third-party-common") +const { ssoCallbackUrl } = require("./utils") +const { Configs } = require("../../../constants") const buildVerifyFn = saveUserFn => { /** @@ -89,11 +91,24 @@ 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, saveUserFn) { +exports.strategyFactory = async function (config, saveUserFn) { try { - const { clientID, clientSecret, configUrl } = config + const verify = buildVerifyFn(saveUserFn) + const strategy = new OIDCStrategy(config, verify) + strategy.name = "oidc" + return strategy + } catch (err) { + console.error(err) + throw new Error("Error constructing OIDC authentication strategy", err) + } +} + +exports.fetchStrategyConfig = async function (enrichedConfig, callbackUrl) { + try { + const { clientID, clientSecret, configUrl } = enrichedConfig if (!clientID || !clientSecret || !callbackUrl || !configUrl) { + //check for remote config and all required elements throw new Error( "Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl" ) @@ -109,24 +124,24 @@ exports.strategyFactory = async function (config, callbackUrl, saveUserFn) { const body = await response.json() - const verify = buildVerifyFn(saveUserFn) - return new OIDCStrategy( - { - issuer: body.issuer, - authorizationURL: body.authorization_endpoint, - tokenURL: body.token_endpoint, - userInfoURL: body.userinfo_endpoint, - clientID: clientID, - clientSecret: clientSecret, - callbackURL: callbackUrl, - }, - verify - ) + return { + issuer: body.issuer, + authorizationURL: body.authorization_endpoint, + tokenURL: body.token_endpoint, + userInfoURL: body.userinfo_endpoint, + clientID: clientID, + clientSecret: clientSecret, + callbackURL: callbackUrl, + } } catch (err) { console.error(err) - throw new Error("Error constructing OIDC authentication strategy", err) + throw new Error("Error constructing OIDC authentication configuration", err) } } +exports.getCallbackUrl = async function (db, config) { + return ssoCallbackUrl(db, config, Configs.OIDC) +} + // expose for testing exports.buildVerifyFn = buildVerifyFn 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 c5e9fe003..c00ab2ea7 100644 --- a/packages/backend-core/src/middleware/passport/tests/oidc.spec.js +++ b/packages/backend-core/src/middleware/passport/tests/oidc.spec.js @@ -48,8 +48,8 @@ describe("oidc", () => { it("should create successfully create an oidc strategy", async () => { const oidc = require("../oidc") - - await oidc.strategyFactory(oidcConfig, callbackUrl) + const enrichedConfig = await oidc.fetchStrategyConfig(oidcConfig, callbackUrl) + await oidc.strategyFactory(enrichedConfig, callbackUrl) expect(mockFetch).toHaveBeenCalledWith(oidcConfig.configUrl) diff --git a/packages/backend-core/src/middleware/passport/utils.js b/packages/backend-core/src/middleware/passport/utils.js index cbb93bfa3..217130cd6 100644 --- a/packages/backend-core/src/middleware/passport/utils.js +++ b/packages/backend-core/src/middleware/passport/utils.js @@ -1,3 +1,7 @@ +const { isMultiTenant, getTenantId } = require("../../tenancy") +const { getScopedConfig } = require("../../db/utils") +const { Configs } = require("../../constants") + /** * Utility to handle authentication errors. * @@ -5,6 +9,7 @@ * @param {*} message Message that will be returned in the response body * @param {*} err (Optional) error that will be logged */ + exports.authError = function (done, message, err = null) { return done( err, @@ -12,3 +17,21 @@ exports.authError = function (done, message, err = null) { { message: message } ) } + +exports.ssoCallbackUrl = async (db, config, type) => { + // incase there is a callback URL from before + if (config && config.callbackURL) { + return config.callbackURL + } + const publicConfig = await getScopedConfig(db, { + type: Configs.SETTINGS, + }) + + let callbackUrl = `/api/global/auth` + if (isMultiTenant()) { + callbackUrl += `/${getTenantId()}` + } + callbackUrl += `/${type}/callback` + + return `${publicConfig.platformUrl}${callbackUrl}` +} diff --git a/packages/backend-core/src/objectStore/index.ts b/packages/backend-core/src/objectStore/index.ts index 9bb0760f5..a7e0b0c13 100644 --- a/packages/backend-core/src/objectStore/index.ts +++ b/packages/backend-core/src/objectStore/index.ts @@ -294,6 +294,16 @@ export const uploadDirectory = async ( await Promise.all(uploads) } +exports.downloadTarballDirect = async (url: string, path: string) => { + path = sanitizeKey(path) + const response = await fetch(url) + if (!response.ok) { + throw new Error(`unexpected response ${response.statusText}`) + } + + await streamPipeline(response.body, zlib.Unzip(), tar.extract(path)) +} + export const downloadTarball = async (url: any, bucketName: any, path: any) => { bucketName = sanitizeBucket(bucketName) path = sanitizeKey(path) diff --git a/packages/backend-core/src/pkg/redis.ts b/packages/backend-core/src/pkg/redis.ts index caa5526e5..65ab186d9 100644 --- a/packages/backend-core/src/pkg/redis.ts +++ b/packages/backend-core/src/pkg/redis.ts @@ -2,7 +2,7 @@ // The outer exports can't be used as they now reference dist directly import Client from "../redis" import utils from "../redis/utils" -import clients from "../redis/authRedis" +import clients from "../redis/init" export = { Client, diff --git a/packages/backend-core/src/redis/index.js b/packages/backend-core/src/redis/index.ts similarity index 55% rename from packages/backend-core/src/redis/index.js rename to packages/backend-core/src/redis/index.ts index bd35f9591..206110366 100644 --- a/packages/backend-core/src/redis/index.js +++ b/packages/backend-core/src/redis/index.ts @@ -1,3 +1,4 @@ +import RedisWrapper from "../redis" const env = require("../environment") // ioredis mock is all in memory const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis") @@ -6,24 +7,34 @@ const { removeDbPrefix, getRedisOptions, SEPARATOR, + SelectableDatabases, } = require("./utils") const RETRY_PERIOD_MS = 2000 const STARTUP_TIMEOUT_MS = 5000 const CLUSTERED = false +const DEFAULT_SELECT_DB = SelectableDatabases.DEFAULT // for testing just generate the client once let CLOSED = false -let CLIENT = env.isTest() ? new Redis(getRedisOptions()) : null +let CLIENTS: { [key: number]: any } = {} // if in test always connected -let CONNECTED = !!env.isTest() +let CONNECTED = env.isTest() -function connectionError(timeout, err) { +function pickClient(selectDb: number): any { + return CLIENTS[selectDb] +} + +function connectionError( + selectDb: number, + timeout: NodeJS.Timeout, + err: Error | string +) { // manually shut down, ignore errors if (CLOSED) { return } - CLIENT.disconnect() + pickClient(selectDb).disconnect() CLOSED = true // always clear this on error clearTimeout(timeout) @@ -38,59 +49,69 @@ function connectionError(timeout, err) { * Inits the system, will error if unable to connect to redis cluster (may take up to 10 seconds) otherwise * will return the ioredis client which will be ready to use. */ -function init() { - let timeout +function init(selectDb = DEFAULT_SELECT_DB) { + let timeout: NodeJS.Timeout CLOSED = false - // testing uses a single in memory client - if (env.isTest() || (CLIENT && CONNECTED)) { + let client = pickClient(selectDb) + // already connected, ignore + if (client && CONNECTED) { return } + // testing uses a single in memory client + if (env.isTest()) { + CLIENTS[selectDb] = new Redis(getRedisOptions()) + } // start the timer - only allowed 5 seconds to connect timeout = setTimeout(() => { if (!CONNECTED) { - connectionError(timeout, "Did not successfully connect in timeout") + connectionError( + selectDb, + timeout, + "Did not successfully connect in timeout" + ) } }, STARTUP_TIMEOUT_MS) // disconnect any lingering client - if (CLIENT) { - CLIENT.disconnect() + if (client) { + client.disconnect() } const { redisProtocolUrl, opts, host, port } = getRedisOptions(CLUSTERED) if (CLUSTERED) { - CLIENT = new Redis.Cluster([{ host, port }], opts) + client = new Redis.Cluster([{ host, port }], opts) } else if (redisProtocolUrl) { - CLIENT = new Redis(redisProtocolUrl) + client = new Redis(redisProtocolUrl) } else { - CLIENT = new Redis(opts) + client = new Redis(opts) } // attach handlers - CLIENT.on("end", err => { - connectionError(timeout, err) + client.on("end", (err: Error) => { + connectionError(selectDb, timeout, err) }) - CLIENT.on("error", err => { - connectionError(timeout, err) + client.on("error", (err: Error) => { + connectionError(selectDb, timeout, err) }) - CLIENT.on("connect", () => { + client.on("connect", () => { clearTimeout(timeout) CONNECTED = true }) + CLIENTS[selectDb] = client } -function waitForConnection() { +function waitForConnection(selectDb: number = DEFAULT_SELECT_DB) { return new Promise(resolve => { - if (CLIENT == null) { + if (pickClient(selectDb) == null) { init() } else if (CONNECTED) { - resolve() + resolve("") return } // check if the connection is ready const interval = setInterval(() => { if (CONNECTED) { clearInterval(interval) - resolve() + resolve("") } }, 500) }) @@ -100,25 +121,26 @@ function waitForConnection() { * Utility function, takes a redis stream and converts it to a promisified response - * this can only be done with redis streams because they will have an end. * @param stream A redis stream, specifically as this type of stream will have an end. + * @param client The client to use for further lookups. * @return {Promise} The final output of the stream */ -function promisifyStream(stream) { +function promisifyStream(stream: any, client: RedisWrapper) { return new Promise((resolve, reject) => { const outputKeys = new Set() - stream.on("data", keys => { + stream.on("data", (keys: string[]) => { keys.forEach(key => { outputKeys.add(key) }) }) - stream.on("error", err => { + stream.on("error", (err: Error) => { reject(err) }) stream.on("end", async () => { - const keysArray = Array.from(outputKeys) + const keysArray: string[] = Array.from(outputKeys) as string[] try { let getPromises = [] for (let key of keysArray) { - getPromises.push(CLIENT.get(key)) + getPromises.push(client.get(key)) } const jsonArray = await Promise.all(getPromises) resolve( @@ -134,48 +156,52 @@ function promisifyStream(stream) { }) } -class RedisWrapper { - constructor(db) { +export = class RedisWrapper { + _db: string + _select: number + + constructor(db: string, selectDb: number | null = null) { this._db = db + this._select = selectDb || DEFAULT_SELECT_DB } getClient() { - return CLIENT + return pickClient(this._select) } async init() { CLOSED = false - init() - await waitForConnection() + init(this._select) + await waitForConnection(this._select) return this } async finish() { CLOSED = true - CLIENT.disconnect() + this.getClient().disconnect() } - async scan(key = "") { + async scan(key = ""): Promise { const db = this._db key = `${db}${SEPARATOR}${key}` let stream if (CLUSTERED) { - let node = CLIENT.nodes("master") + let node = this.getClient().nodes("master") stream = node[0].scanStream({ match: key + "*", count: 100 }) } else { - stream = CLIENT.scanStream({ match: key + "*", count: 100 }) + stream = this.getClient().scanStream({ match: key + "*", count: 100 }) } - return promisifyStream(stream) + return promisifyStream(stream, this.getClient()) } - async keys(pattern) { + async keys(pattern: string) { const db = this._db - return CLIENT.keys(addDbPrefix(db, pattern)) + return this.getClient().keys(addDbPrefix(db, pattern)) } - async get(key) { + async get(key: string) { const db = this._db - let response = await CLIENT.get(addDbPrefix(db, key)) + let response = await this.getClient().get(addDbPrefix(db, key)) // overwrite the prefixed key if (response != null && response.key) { response.key = key @@ -188,39 +214,37 @@ class RedisWrapper { } } - async store(key, value, expirySeconds = null) { + async store(key: string, value: any, expirySeconds: number | null = null) { const db = this._db if (typeof value === "object") { value = JSON.stringify(value) } const prefixedKey = addDbPrefix(db, key) - await CLIENT.set(prefixedKey, value) + await this.getClient().set(prefixedKey, value) if (expirySeconds) { - await CLIENT.expire(prefixedKey, expirySeconds) + await this.getClient().expire(prefixedKey, expirySeconds) } } - async getTTL(key) { + async getTTL(key: string) { const db = this._db const prefixedKey = addDbPrefix(db, key) - return CLIENT.ttl(prefixedKey) + return this.getClient().ttl(prefixedKey) } - async setExpiry(key, expirySeconds) { + async setExpiry(key: string, expirySeconds: number | null) { const db = this._db const prefixedKey = addDbPrefix(db, key) - await CLIENT.expire(prefixedKey, expirySeconds) + await this.getClient().expire(prefixedKey, expirySeconds) } - async delete(key) { + async delete(key: string) { const db = this._db - await CLIENT.del(addDbPrefix(db, key)) + await this.getClient().del(addDbPrefix(db, key)) } async clear() { let items = await this.scan() - await Promise.all(items.map(obj => this.delete(obj.key))) + await Promise.all(items.map((obj: any) => this.delete(obj.key))) } } - -module.exports = RedisWrapper diff --git a/packages/backend-core/src/redis/authRedis.js b/packages/backend-core/src/redis/init.js similarity index 79% rename from packages/backend-core/src/redis/authRedis.js rename to packages/backend-core/src/redis/init.js index b2f686b80..8e5d10f83 100644 --- a/packages/backend-core/src/redis/authRedis.js +++ b/packages/backend-core/src/redis/init.js @@ -2,7 +2,7 @@ const Client = require("./index") const utils = require("./utils") const { getRedlock } = require("./redlock") -let userClient, sessionClient, appClient, cacheClient +let userClient, sessionClient, appClient, cacheClient, writethroughClient let migrationsRedlock // turn retry off so that only one instance can ever hold the lock @@ -13,6 +13,10 @@ async function init() { sessionClient = await new Client(utils.Databases.SESSIONS).init() appClient = await new Client(utils.Databases.APP_METADATA).init() cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init() + writethroughClient = await new Client( + utils.Databases.WRITE_THROUGH, + utils.SelectableDatabases.WRITE_THROUGH + ).init() // pass the underlying ioredis client to redlock migrationsRedlock = getRedlock( cacheClient.getClient(), @@ -25,6 +29,7 @@ process.on("exit", async () => { if (sessionClient) await sessionClient.finish() if (appClient) await appClient.finish() if (cacheClient) await cacheClient.finish() + if (writethroughClient) await writethroughClient.finish() }) module.exports = { @@ -52,6 +57,12 @@ module.exports = { } return cacheClient }, + getWritethroughClient: async () => { + if (!writethroughClient) { + await init() + } + return writethroughClient + }, getMigrationsRedlock: async () => { if (!migrationsRedlock) { await init() diff --git a/packages/backend-core/src/redis/utils.js b/packages/backend-core/src/redis/utils.js index 90ea5c33f..90b3561f3 100644 --- a/packages/backend-core/src/redis/utils.js +++ b/packages/backend-core/src/redis/utils.js @@ -6,6 +6,14 @@ const SEPARATOR = "-" const REDIS_URL = !env.REDIS_URL ? "localhost:6379" : env.REDIS_URL const REDIS_PASSWORD = !env.REDIS_PASSWORD ? "budibase" : env.REDIS_PASSWORD +/** + * These Redis databases help us to segment up a Redis keyspace by prepending the + * specified database name onto the cache key. This means that a single real Redis database + * can be split up a bit; allowing us to use scans on small databases to find some particular + * keys within. + * If writing a very large volume of keys is expected (say 10K+) then it is better to keep these out + * of the default keyspace and use a separate one - the SelectableDatabases can be used for this. + */ exports.Databases = { PW_RESETS: "pwReset", VERIFICATIONS: "verification", @@ -19,6 +27,35 @@ exports.Databases = { QUERY_VARS: "queryVars", LICENSES: "license", GENERIC_CACHE: "data_cache", + WRITE_THROUGH: "writeThrough", +} + +/** + * These define the numeric Redis databases that can be access with the SELECT command - + * (https://redis.io/commands/select/). By default a Redis server/cluster will have 16 selectable + * databases, increasing this count increases the amount of CPU/memory required to run the server. + * Ideally new Redis keyspaces should be used sparingly, only when absolutely necessary for performance + * to be maintained. Generally a keyspace can grow to be very large is scans are not needed or desired, + * but if you need to walk through all values in a database periodically then a separate selectable + * keyspace should be used. + */ +exports.SelectableDatabases = { + DEFAULT: 0, + WRITE_THROUGH: 1, + UNUSED_1: 2, + UNUSED_2: 3, + UNUSED_3: 4, + UNUSED_4: 5, + UNUSED_5: 6, + UNUSED_6: 7, + UNUSED_7: 8, + UNUSED_8: 9, + UNUSED_9: 10, + UNUSED_10: 11, + UNUSED_11: 12, + UNUSED_12: 13, + UNUSED_13: 14, + UNUSED_14: 15, } exports.SEPARATOR = SEPARATOR diff --git a/packages/backend-core/src/security/sessions.js b/packages/backend-core/src/security/sessions.js index 4e6899c24..8874b4746 100644 --- a/packages/backend-core/src/security/sessions.js +++ b/packages/backend-core/src/security/sessions.js @@ -1,4 +1,4 @@ -const redis = require("../redis/authRedis") +const redis = require("../redis/init") const { v4: uuidv4 } = require("uuid") // a week in seconds diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js index 4acccda2a..0c1350a67 100644 --- a/packages/backend-core/src/users.js +++ b/packages/backend-core/src/users.js @@ -1,5 +1,6 @@ const { ViewNames } = require("./db/utils") const { queryGlobalView } = require("./db/views") +const { UNICODE_MAX } = require("./db/constants") /** * Given an email address this will use a view to search through @@ -19,3 +20,24 @@ exports.getGlobalUserByEmail = async email => { return response } + +/** + * Performs a starts with search on the global email view. + */ +exports.searchGlobalUsersByEmail = async (email, opts) => { + if (typeof email !== "string") { + throw new Error("Must provide a string to search by") + } + const lcEmail = email.toLowerCase() + // handle if passing up startkey for pagination + const startkey = opts && opts.startkey ? opts.startkey : lcEmail + let response = await queryGlobalView(ViewNames.USER_BY_EMAIL, { + ...opts, + startkey, + endkey: `${lcEmail}${UNICODE_MAX}`, + }) + if (!response) { + response = [] + } + return Array.isArray(response) ? response : [response] +} diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index 9d2524bbe..cf32539c5 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -197,3 +197,7 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => { await events.auth.logout() await userCache.invalidateUser(userId) } + +exports.timeout = timeMs => { + return new Promise(resolve => setTimeout(resolve, timeMs)) +} diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index 01d1a43b6..77dbc6142 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -656,6 +656,13 @@ "@types/keygrip" "*" "@types/node" "*" +"@types/debug@*": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" + integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg== + dependencies: + "@types/ms" "*" + "@types/express-serve-static-core@^4.17.18": version "4.17.28" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8" @@ -762,6 +769,11 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== +"@types/ms@*": + version "0.7.31" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" + integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== + "@types/node-fetch@2.6.1": version "2.6.1" resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.1.tgz#8f127c50481db65886800ef496f20bbf15518975" @@ -780,6 +792,152 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.20.tgz#268f028b36eaf51181c3300252f605488c4f0650" integrity sha512-Q8KKwm9YqEmUBRsqJ2GWJDtXltBDxTdC4m5vTdXBolu2PeQh8LX+f6BTwU+OuXPu37fLxoN6gidqBmnky36FXA== +"@types/pouchdb-adapter-cordova-sqlite@*": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-cordova-sqlite/-/pouchdb-adapter-cordova-sqlite-1.0.1.tgz#49e5ee6df7cc0c23196fcb340f43a560e74eb1d6" + integrity sha512-nqlXpW1ho3KBg1mUQvZgH2755y3z/rw4UA7ZJCPMRTHofxGMY8izRVw5rHBL4/7P615or0J2udpRYxgkT3D02g== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-fruitdown@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-fruitdown/-/pouchdb-adapter-fruitdown-6.1.3.tgz#9b140ad9645cc56068728acf08ec19ac0046658e" + integrity sha512-Wz1Z1JLOW1hgmFQjqnSkmyyfH7by/iWb4abKn684WMvQfmxx6BxKJpJ4+eulkVPQzzgMMSgU1MpnQOm9FgRkbw== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-http@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-http/-/pouchdb-adapter-http-6.1.3.tgz#6e592d5f48deb6274a21ddac1498dd308096bcf3" + integrity sha512-9Z4TLbF/KJWy/D2sWRPBA+RNU0odQimfdvlDX+EY7rGcd3aVoH8qjD/X0Xcd/0dfBH5pKrNIMFFQgW/TylRCmA== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-idb@*": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-idb/-/pouchdb-adapter-idb-6.1.4.tgz#cb9a18864585d600820cd325f007614c5c3989cd" + integrity sha512-KIAXbkF4uYUz0ZwfNEFLtEkK44mEWopAsD76UhucH92XnJloBysav+TjI4FFfYQyTjoW3S1s6V+Z14CUJZ0F6w== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-leveldb@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-leveldb/-/pouchdb-adapter-leveldb-6.1.3.tgz#17c7e75d75b992050bca15991e97fba575c61bb3" + integrity sha512-ex8NFqQGFwEpFi7AaZ5YofmuemfZNsL3nTFZBUCAKYMBkazQij1pe2ILLStSvJr0XS0qxgXjCEW19T5Wqiiskg== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-localstorage@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-localstorage/-/pouchdb-adapter-localstorage-6.1.3.tgz#0dde02ba6b9d6073a295a20196563942ba9a54bd" + integrity sha512-oor040tye1KKiGLWYtIy7rRT7C2yoyX3Tf6elEJRpjOA7Ja/H8lKc4LaSh9ATbptIcES6MRqZDxtp7ly9hsW3Q== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-memory@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-memory/-/pouchdb-adapter-memory-6.1.3.tgz#9eabdbc890fcf58960ee8b68b8685f837e75c844" + integrity sha512-gVbsIMzDzgZYThFVT4eVNsmuZwVm/4jDxP1sjlgc3qtDIxbtBhGgyNfcskwwz9Zu5Lv1avkDsIWvcxQhnvRlHg== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-node-websql@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-node-websql/-/pouchdb-adapter-node-websql-6.1.3.tgz#aa18bc68af8cf509acd12c400010dcd5fab2243d" + integrity sha512-F/P+os6Jsa7CgHtH64+Z0HfwIcj0hIRB5z8gNhF7L7dxPWoAfkopK5H2gydrP3sQrlGyN4WInF+UJW/Zu1+FKg== + dependencies: + "@types/pouchdb-adapter-websql" "*" + "@types/pouchdb-core" "*" + +"@types/pouchdb-adapter-websql@*": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@types/pouchdb-adapter-websql/-/pouchdb-adapter-websql-6.1.4.tgz#359fbe42ccac0ac90b492ddb8c32fafd0aa96d79" + integrity sha512-zMJQCtXC40hBsIDRn0GhmpeGMK0f9l/OGWfLguvczROzxxcOD7REI+e6SEmX7gJKw5JuMvlfuHzkQwjmvSJbtg== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-browser@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-browser/-/pouchdb-browser-6.1.3.tgz#8f33d6ef58d6817d1f6d36979148a1c7f63244d8" + integrity sha512-EdYowrWxW9SWBMX/rux2eq7dbHi5Zeyzz+FF/IAsgQKnUxgeCO5VO2j4zTzos0SDyJvAQU+EYRc11r7xGn5tvA== + dependencies: + "@types/pouchdb-adapter-http" "*" + "@types/pouchdb-adapter-idb" "*" + "@types/pouchdb-adapter-websql" "*" + "@types/pouchdb-core" "*" + "@types/pouchdb-mapreduce" "*" + "@types/pouchdb-replication" "*" + +"@types/pouchdb-core@*": + version "7.0.10" + resolved "https://registry.yarnpkg.com/@types/pouchdb-core/-/pouchdb-core-7.0.10.tgz#d1ea1549e7fad6cb579f71459b1bc27252e06a5a" + integrity sha512-mKhjLlWWXyV3PTTjDhzDV1kc2dolO7VYFa75IoKM/hr8Er9eo8RIbS7mJLfC8r/C3p6ihZu9yZs1PWC1LQ0SOA== + dependencies: + "@types/debug" "*" + "@types/pouchdb-find" "*" + +"@types/pouchdb-find@*": + version "6.3.7" + resolved "https://registry.yarnpkg.com/@types/pouchdb-find/-/pouchdb-find-6.3.7.tgz#f713534a53c1a7f3fd8fbbfb74131a1b04711ddc" + integrity sha512-b2dr9xoZRK5Mwl8UiRA9l5j9mmCxNfqXuu63H1KZHwJLILjoIIz7BntCvM0hnlnl7Q8P8wORq0IskuaMq5Nnnw== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-http@*": + version "6.1.3" + resolved "https://registry.yarnpkg.com/@types/pouchdb-http/-/pouchdb-http-6.1.3.tgz#09576c0d409da1f8dee34ec5b768415e2472ea52" + integrity sha512-0e9E5SqNOyPl/3FnEIbENssB4FlJsNYuOy131nxrZk36S+y1R/6qO7ZVRypWpGTqBWSuVd7gCsq2UDwO/285+w== + dependencies: + "@types/pouchdb-adapter-http" "*" + "@types/pouchdb-core" "*" + +"@types/pouchdb-mapreduce@*": + version "6.1.7" + resolved "https://registry.yarnpkg.com/@types/pouchdb-mapreduce/-/pouchdb-mapreduce-6.1.7.tgz#9ab32d1e0f234f1bf6d1e4c5d7e216e9e23ac0a3" + integrity sha512-WzBwm7tmO9QhfRzVaWT4v6JQSS/fG2OoUDrWrhX87rPe2Pn6laPvdK5li6myNRxCoI/l5e8Jd+oYBAFnaiFucA== + dependencies: + "@types/pouchdb-core" "*" + +"@types/pouchdb-node@*": + version "6.1.4" + resolved "https://registry.yarnpkg.com/@types/pouchdb-node/-/pouchdb-node-6.1.4.tgz#5214c0169fcfd2237d373380bbd65a934feb5dfb" + integrity sha512-wnTCH8X1JOPpNOfVhz8HW0AvmdHh6pt40MuRj0jQnK7QEHsHS79WujsKTKSOF8QXtPwpvCNSsI7ut7H7tfxxJQ== + dependencies: + "@types/pouchdb-adapter-http" "*" + "@types/pouchdb-adapter-leveldb" "*" + "@types/pouchdb-core" "*" + "@types/pouchdb-mapreduce" "*" + "@types/pouchdb-replication" "*" + +"@types/pouchdb-replication@*": + version "6.4.4" + resolved "https://registry.yarnpkg.com/@types/pouchdb-replication/-/pouchdb-replication-6.4.4.tgz#743406c90f13a988fa3e346ea74ce40acd170d00" + integrity sha512-BsE5LKpjJK4iAf6Fx5kyrMw+33V+Ip7uWldUnU2BYrrvtR+MLD22dcImm7DZN1st2wPPb91i0XEnQzvP0w1C/Q== + dependencies: + "@types/pouchdb-core" "*" + "@types/pouchdb-find" "*" + +"@types/pouchdb@6.4.0": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@types/pouchdb/-/pouchdb-6.4.0.tgz#f9c41ca64b23029f9bf2eb4bf6956e6431cb79f8" + integrity sha512-eGCpX+NXhd5VLJuJMzwe3L79fa9+IDTrAG3CPaf4s/31PD56hOrhDJTSmRELSXuiqXr6+OHzzP0PldSaWsFt7w== + dependencies: + "@types/pouchdb-adapter-cordova-sqlite" "*" + "@types/pouchdb-adapter-fruitdown" "*" + "@types/pouchdb-adapter-http" "*" + "@types/pouchdb-adapter-idb" "*" + "@types/pouchdb-adapter-leveldb" "*" + "@types/pouchdb-adapter-localstorage" "*" + "@types/pouchdb-adapter-memory" "*" + "@types/pouchdb-adapter-node-websql" "*" + "@types/pouchdb-adapter-websql" "*" + "@types/pouchdb-browser" "*" + "@types/pouchdb-core" "*" + "@types/pouchdb-http" "*" + "@types/pouchdb-mapreduce" "*" + "@types/pouchdb-node" "*" + "@types/pouchdb-replication" "*" + "@types/prettier@^2.1.5": version "2.6.3" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.6.3.tgz#68ada76827b0010d0db071f739314fa429943d0a" @@ -3965,6 +4123,11 @@ passport-oauth1@1.x.x: passport-strategy "1.x.x" utils-merge "1.x.x" +passport-oauth2-refresh@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/passport-oauth2-refresh/-/passport-oauth2-refresh-2.1.0.tgz#c31cd133826383f5539d16ad8ab4f35ca73ce4a4" + integrity sha512-4ML7ooCESCqiTgdDBzNUFTBcPR8zQq9iM6eppEUGMMvLdsjqRL93jKwWm4Az3OJcI+Q2eIVyI8sVRcPFvxcF/A== + passport-oauth2@1.x.x: version "1.6.1" resolved "https://registry.yarnpkg.com/passport-oauth2/-/passport-oauth2-1.6.1.tgz#c5aee8f849ce8bd436c7f81d904a3cd1666f181b" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index dc5f87f50..f1f84b1cf 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.207-alpha.8", + "version": "1.0.220-alpha.4", "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.207-alpha.8", + "@budibase/string-templates": "^1.0.220-alpha.4", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", @@ -66,11 +66,12 @@ "@spectrum-css/radio": "^3.0.2", "@spectrum-css/search": "^3.0.2", "@spectrum-css/sidenav": "^3.0.2", + "@spectrum-css/slider": "3.0.1", "@spectrum-css/statuslight": "^3.0.2", "@spectrum-css/stepper": "^3.0.3", "@spectrum-css/switch": "^1.0.2", "@spectrum-css/table": "^3.0.1", - "@spectrum-css/tabs": "^3.0.1", + "@spectrum-css/tabs": "^3.2.12", "@spectrum-css/tags": "^3.0.2", "@spectrum-css/textfield": "^3.0.1", "@spectrum-css/toast": "^3.0.1", diff --git a/packages/bbui/src/ActionButton/ActionButton.svelte b/packages/bbui/src/ActionButton/ActionButton.svelte index b518ac3d9..53ba6c7e5 100644 --- a/packages/bbui/src/ActionButton/ActionButton.svelte +++ b/packages/bbui/src/ActionButton/ActionButton.svelte @@ -13,6 +13,7 @@ export let size = "M" export let active = false export let fullWidth = false + export let noPadding = false function longPress(element) { if (!longPressable) return @@ -41,6 +42,7 @@ class:spectrum-ActionButton--quiet={quiet} class:spectrum-ActionButton--emphasized={emphasized} class:is-selected={selected} + class:noPadding class:fullWidth class="spectrum-ActionButton spectrum-ActionButton--size{size}" class:active @@ -80,4 +82,14 @@ .active svg { color: var(--spectrum-global-color-blue-600); } + :global([dir="ltr"] .spectrum-ActionButton .spectrum-Icon) { + margin-left: 0; + } + .is-selected:not(.spectrum-ActionButton--emphasized) { + background: var(--spectrum-global-color-gray-300); + } + .noPadding { + padding: 0; + min-width: 0; + } diff --git a/packages/bbui/src/Banner/Banner.svelte b/packages/bbui/src/Banner/Banner.svelte index f41fb5f80..3810021a6 100644 --- a/packages/bbui/src/Banner/Banner.svelte +++ b/packages/bbui/src/Banner/Banner.svelte @@ -8,6 +8,7 @@ export let size = "S" export let extraButtonText export let extraButtonAction + export let showCloseButton = true let show = true @@ -39,22 +40,24 @@ {/if} -
- -
+ {#if showCloseButton} +
+ +
+ {/if} {/if} @@ -63,4 +66,7 @@ pointer-events: all; width: 100%; } + .spectrum-Button { + border: 1px solid rgba(255, 255, 255, 0.2); + } diff --git a/packages/bbui/src/Divider/Divider.svelte b/packages/bbui/src/Divider/Divider.svelte index 2b4de9cfb..e4f0f2fb6 100644 --- a/packages/bbui/src/Divider/Divider.svelte +++ b/packages/bbui/src/Divider/Divider.svelte @@ -16,6 +16,9 @@ /> diff --git a/packages/bbui/src/Form/Core/index.js b/packages/bbui/src/Form/Core/index.js index 96d81855e..7c81cfd70 100644 --- a/packages/bbui/src/Form/Core/index.js +++ b/packages/bbui/src/Form/Core/index.js @@ -12,3 +12,4 @@ export { default as CoreDatePicker } from "./DatePicker.svelte" export { default as CoreDropzone } from "./Dropzone.svelte" export { default as CoreStepper } from "./Stepper.svelte" export { default as CoreRichTextField } from "./RichTextField.svelte" +export { default as CoreSlider } from "./Slider.svelte" diff --git a/packages/bbui/src/Form/Search.svelte b/packages/bbui/src/Form/Search.svelte index 25dd98306..74ffeeb22 100644 --- a/packages/bbui/src/Form/Search.svelte +++ b/packages/bbui/src/Form/Search.svelte @@ -10,6 +10,7 @@ export let disabled = false export let updateOnChange = true export let quiet = false + export let inputRef const dispatch = createEventDispatcher() const onChange = e => { @@ -25,6 +26,7 @@ {value} {placeholder} {quiet} + bind:inputRef on:change={onChange} on:click on:input diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index 0df27e2ff..1b68746c5 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -14,6 +14,7 @@ export let getOptionLabel = option => extractProperty(option, "label") export let getOptionValue = option => extractProperty(option, "value") export let getOptionIcon = option => option?.icon + export let getOptionColour = option => option?.colour export let quiet = false export let autoWidth = false export let sort = false @@ -47,6 +48,7 @@ {getOptionLabel} {getOptionValue} {getOptionIcon} + {getOptionColour} on:change={onChange} on:click /> diff --git a/packages/bbui/src/Form/Slider.svelte b/packages/bbui/src/Form/Slider.svelte new file mode 100644 index 000000000..34b2251b3 --- /dev/null +++ b/packages/bbui/src/Form/Slider.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/bbui/src/Icon/Icon.svelte b/packages/bbui/src/Icon/Icon.svelte index eee1d7fba..9c99178fd 100644 --- a/packages/bbui/src/Icon/Icon.svelte +++ b/packages/bbui/src/Icon/Icon.svelte @@ -47,7 +47,7 @@ {#if tooltip && showTooltip}
- +
{/if} diff --git a/packages/bbui/src/IconSideNav/IconSideNav.svelte b/packages/bbui/src/IconSideNav/IconSideNav.svelte new file mode 100644 index 000000000..e8144402e --- /dev/null +++ b/packages/bbui/src/IconSideNav/IconSideNav.svelte @@ -0,0 +1,14 @@ +
+ +
+ + diff --git a/packages/bbui/src/IconSideNav/IconSideNavItem.svelte b/packages/bbui/src/IconSideNav/IconSideNavItem.svelte new file mode 100644 index 000000000..46625c970 --- /dev/null +++ b/packages/bbui/src/IconSideNav/IconSideNavItem.svelte @@ -0,0 +1,56 @@ + + +
(showTooltip = true)} + on:focus={() => (showTooltip = true)} + on:mouseleave={() => (showTooltip = false)} + on:click +> + + {#if tooltip && showTooltip} +
+ +
+ {/if} +
+ + diff --git a/packages/bbui/src/Notification/Notification.svelte b/packages/bbui/src/Notification/Notification.svelte index 1d2113155..53ab06270 100644 --- a/packages/bbui/src/Notification/Notification.svelte +++ b/packages/bbui/src/Notification/Notification.svelte @@ -1,15 +1,20 @@ -
+
{#if icon} {/if} -
+
{message || ""}
+ {#if action} + +
{actionMessage}
+
+ {/if}
{#if dismissable}
@@ -46,4 +56,15 @@ .spectrum-Toast { pointer-events: all; } + + .wide { + width: 100%; + } + + .actionBody { + justify-content: space-between; + display: flex; + width: 100%; + align-items: center; + } diff --git a/packages/bbui/src/Notification/NotificationDisplay.svelte b/packages/bbui/src/Notification/NotificationDisplay.svelte index eb778f3aa..0f7e93eb2 100644 --- a/packages/bbui/src/Notification/NotificationDisplay.svelte +++ b/packages/bbui/src/Notification/NotificationDisplay.svelte @@ -8,13 +8,15 @@
- {#each $notifications as { type, icon, message, id, dismissable } (id)} -
+ {#each $notifications as { type, icon, message, id, dismissable, action, wide } (id)} +
notifications.dismiss(id)} />
@@ -25,7 +27,7 @@ diff --git a/packages/bbui/src/Stores/notifications.js b/packages/bbui/src/Stores/notifications.js index 74eed8628..449d282f2 100644 --- a/packages/bbui/src/Stores/notifications.js +++ b/packages/bbui/src/Stores/notifications.js @@ -20,7 +20,16 @@ export const createNotificationStore = () => { setTimeout(() => (block = false), timeout) } - const send = (message, type = "default", icon = "", autoDismiss = true) => { + const send = ( + message, + { + type = "default", + icon = "", + autoDismiss = true, + action = null, + wide = false, + } + ) => { if (block) { return } @@ -28,7 +37,15 @@ export const createNotificationStore = () => { _notifications.update(state => { return [ ...state, - { id: _id, type, message, icon, dismissable: !autoDismiss }, + { + id: _id, + type, + message, + icon, + dismissable: !autoDismiss, + action, + wide, + }, ] }) if (autoDismiss) { @@ -50,10 +67,11 @@ export const createNotificationStore = () => { return { subscribe, send, - info: msg => send(msg, "info", "Info"), - error: msg => send(msg, "error", "Alert", false), - warning: msg => send(msg, "warning", "Alert"), - success: msg => send(msg, "success", "CheckmarkCircle"), + info: msg => send(msg, { type: "info", icon: "Info" }), + error: msg => + send(msg, { type: "error", icon: "Alert", autoDismiss: false }), + warning: msg => send(msg, { type: "warning", icon: "Alert" }), + success: msg => send(msg, { type: "success", icon: "CheckmarkCircle" }), blockNotifications, dismiss: dismissNotification, } diff --git a/packages/bbui/src/Table/ArrayRenderer.svelte b/packages/bbui/src/Table/ArrayRenderer.svelte index 679973a03..375585066 100644 --- a/packages/bbui/src/Table/ArrayRenderer.svelte +++ b/packages/bbui/src/Table/ArrayRenderer.svelte @@ -5,7 +5,7 @@ const displayLimit = 5 - $: badges = value?.slice(0, displayLimit) ?? [] + $: badges = Array.isArray(value) ? value.slice(0, displayLimit) : [] $: leftover = (value?.length ?? 0) - badges.length diff --git a/packages/bbui/src/Table/CellRenderer.svelte b/packages/bbui/src/Table/CellRenderer.svelte index 4dda31240..246323244 100644 --- a/packages/bbui/src/Table/CellRenderer.svelte +++ b/packages/bbui/src/Table/CellRenderer.svelte @@ -26,12 +26,20 @@ array: ArrayRenderer, internal: InternalRenderer, } - $: type = schema?.type ?? "string" + $: type = getType(schema) $: customRenderer = customRenderers?.find(x => x.column === schema?.name) $: renderer = customRenderer?.component ?? typeMap[type] ?? StringRenderer $: width = schema?.width || "150px" $: cellValue = getCellValue(value, schema.template) + const getType = schema => { + // Use a string renderer for dates if we use a custom template + if (schema?.type === "datetime" && schema?.template) { + return "string" + } + return schema?.type || "string" + } + const getCellValue = (value, template) => { if (!template) { return value diff --git a/packages/bbui/src/Table/Table.svelte b/packages/bbui/src/Table/Table.svelte index baa84c91e..e01d84e12 100644 --- a/packages/bbui/src/Table/Table.svelte +++ b/packages/bbui/src/Table/Table.svelte @@ -37,6 +37,7 @@ export let autoSortColumns = true export let compact = false export let customPlaceholder = false + export let placeholderText = "No rows found" const dispatch = createEventDispatcher() @@ -405,7 +406,7 @@ > -
No rows found
+
{placeholderText}
{/if}
diff --git a/packages/bbui/src/Tabs/Tab.svelte b/packages/bbui/src/Tabs/Tab.svelte index 04791619d..c25be7dbc 100644 --- a/packages/bbui/src/Tabs/Tab.svelte +++ b/packages/bbui/src/Tabs/Tab.svelte @@ -79,4 +79,10 @@ .emphasized { color: var(--spectrum-global-color-blue-600); } + .spectrum-Tabs-item { + color: var(--spectrum-global-color-gray-600); + } + .spectrum-Tabs-item.is-selected { + color: var(--spectrum-global-color-gray-900); + } diff --git a/packages/bbui/src/Tabs/Tabs.svelte b/packages/bbui/src/Tabs/Tabs.svelte index 579c61e28..74edc9cd0 100644 --- a/packages/bbui/src/Tabs/Tabs.svelte +++ b/packages/bbui/src/Tabs/Tabs.svelte @@ -10,8 +10,7 @@ export let noHorizPadding = false export let quiet = false export let emphasized = false - // overlay content from the tab bar onto tabs e.g. for a dropdown - export let onTop = false + export let size = "M" let thisSelected = undefined @@ -74,20 +73,18 @@
{#if $tab.info}
{/if}
@@ -98,26 +95,26 @@ /> diff --git a/packages/bbui/src/Typography/Detail.svelte b/packages/bbui/src/Typography/Detail.svelte index bb5c78c11..76437ffb3 100644 --- a/packages/bbui/src/Typography/Detail.svelte +++ b/packages/bbui/src/Typography/Detail.svelte @@ -1,9 +1,7 @@ diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js index 2b16f32b8..7d5baad47 100644 --- a/packages/bbui/src/index.js +++ b/packages/bbui/src/index.js @@ -64,6 +64,9 @@ export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte" export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte" export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte" export { default as RichTextField } from "./Form/RichTextField.svelte" +export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte" +export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte" +export { default as Slider } from "./Form/Slider.svelte" // Renderers export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" diff --git a/packages/bbui/yarn.lock b/packages/bbui/yarn.lock index 0bff3e86d..d301afea5 100644 --- a/packages/bbui/yarn.lock +++ b/packages/bbui/yarn.lock @@ -206,6 +206,11 @@ resolved "https://registry.yarnpkg.com/@spectrum-css/sidenav/-/sidenav-3.0.2.tgz#9d70f408d588ee79c69857751010333671f32713" integrity sha512-YpIdH/F0jEICYmoduGrnkTmxwJq1kfKxEp0wOs+ZkQOsvKMv1an7nyhsfOKCQqcGNfYzJ9mJAk7/u5+vsxHa8g== +"@spectrum-css/slider@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@spectrum-css/slider/-/slider-3.0.1.tgz#5281e6f47eb5a4fd3d1816c138bf66d01d7f2e49" + integrity sha512-DI2dtMRnQuDM1miVzl3SGyR1khUEKnwdXfO5EHDFwkC3yav43F5QogkfjmjFmWWobMVovdJlAuiaaJ/IHejD0Q== + "@spectrum-css/statuslight@^3.0.2": version "3.0.2" resolved "https://registry.yarnpkg.com/@spectrum-css/statuslight/-/statuslight-3.0.2.tgz#dc54b6cd113413dcdb909c486b5d7bae60db65c5" @@ -226,10 +231,10 @@ resolved "https://registry.yarnpkg.com/@spectrum-css/table/-/table-3.0.2.tgz#c666743d569fef81ddc8810fac8cda53b315f8d7" integrity sha512-nt/QNC7NmUank0wozd4FySEX1UIYXuvuOKDyN1II3sxfwFSpJfp/Df9KVMhrYs4EsmB4XMGcoxp8ND/CrvH3ow== -"@spectrum-css/tabs@^3.0.1": - version "3.0.2" - resolved "https://registry.yarnpkg.com/@spectrum-css/tabs/-/tabs-3.0.2.tgz#822316672e7b0dfba66faa988e638ddae18c700e" - integrity sha512-4RNcmwf0wxLpB7M54H02owlj0mKE8neL1+lytQpxOOhlwTO5zdsD82zjvx9tIc8tRnRKuhCCCwTuBxHYstnBmw== +"@spectrum-css/tabs@^3.2.12": + version "3.2.12" + resolved "https://registry.yarnpkg.com/@spectrum-css/tabs/-/tabs-3.2.12.tgz#9b08f23d5aa881b3441af7757800c7173e5685ff" + integrity sha512-rPFUW9SSW4+3/UJ3UrtY2/l3sQvlqB1fqxHLPDjgykvbfrnMejcCTNV4ZrFNHXpE/6+kGnk+yVViSPtWGwJzkA== "@spectrum-css/tags@^3.0.2": version "3.0.2" diff --git a/packages/builder/cypress.json b/packages/builder/cypress.json index 06bf55894..f1eada481 100644 --- a/packages/builder/cypress.json +++ b/packages/builder/cypress.json @@ -13,7 +13,7 @@ "HOST_IP": "" }, "retries": { - "runMode": 2, + "runMode": 1, "openMode": 0 } -} +} \ No newline at end of file diff --git a/packages/builder/cypress/integration/addMultiOptionDatatype.spec.js b/packages/builder/cypress/integration/addMultiOptionDatatype.spec.js index 38ae881db..32f62efe1 100644 --- a/packages/builder/cypress/integration/addMultiOptionDatatype.spec.js +++ b/packages/builder/cypress/integration/addMultiOptionDatatype.spec.js @@ -16,18 +16,15 @@ filterTests(['all'], () => { it("should add form with multi select picker, containing 5 options", () => { cy.navigateToFrontend() - cy.wait(500) // Add data provider - cy.get(interact.CATEGORY_DATA).click() + cy.get(interact.CATEGORY_DATA, { timeout: 500 }).click() cy.get(interact.COMPONENT_DATA_PROVIDER).click() cy.get(interact.DATASOURCE_PROP_CONTROL).click() cy.get(interact.DROPDOWN).contains("Multi Data").click() - cy.wait(500) // Add Form with schema to match table cy.addComponent("Form", "Form") cy.get(interact.DATASOURCE_PROP_CONTROL).click() cy.get(interact.DROPDOWN).contains("Multi Data").click() - cy.wait(500) // Add multi-select picker to form cy.addComponent("Form", "Multi-select Picker").then(componentId => { cy.get(interact.DATASOURCE_FIELD_CONTROL).type("Test Data").type("{enter}") diff --git a/packages/builder/cypress/integration/addRadioButtons.spec.js b/packages/builder/cypress/integration/addRadioButtons.spec.js index 8f5b1a527..578b51934 100644 --- a/packages/builder/cypress/integration/addRadioButtons.spec.js +++ b/packages/builder/cypress/integration/addRadioButtons.spec.js @@ -10,6 +10,7 @@ filterTests(['all'], () => { it("should add Radio Buttons options picker on form, add data, and confirm", () => { cy.navigateToFrontend() + cy.wait(500) cy.addComponent("Form", "Form") cy.addComponent("Form", "Options Picker").then((componentId) => { // Provide field setting @@ -36,5 +37,9 @@ filterTests(['all'], () => { }) cy.addCustomSourceOptions(totalRadioButtons) } + + after(() => { + cy.deleteAllApps() + }) }) }) diff --git a/packages/builder/cypress/integration/adminAndManagement/accountPortals.spec.js b/packages/builder/cypress/integration/adminAndManagement/accountPortals.spec.js new file mode 100644 index 000000000..491a4abc4 --- /dev/null +++ b/packages/builder/cypress/integration/adminAndManagement/accountPortals.spec.js @@ -0,0 +1,132 @@ +import filterTests from "../../support/filterTests" +const interact = require('../../support/interact') + +filterTests(["smoke", "all"], () => { + context("Account Portals", () => { + + const bbUserEmail = "bbuser@test.com" + + before(() => { + cy.login() + cy.deleteApp("Cypress Tests") + cy.createApp("Cypress Tests", false) + + // Create new user + cy.wait(500) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000}) + cy.createUser(bbUserEmail) + cy.contains("bbuser").click() + cy.wait(500) + + // Reset password + cy.get(".spectrum-ActionButton-label", { timeout: 2000 }).contains("Force password reset").click({ force: true }) + + cy.get(".spectrum-Dialog-grid") + .find(interact.SPECTRUM_TEXTFIELD_INPUT).invoke('val').as('pwd') + + cy.get(interact.SPECTRUM_BUTTON).contains("Reset password").click({ force: true }) + + // Login as new user and set password + cy.logOut() + cy.get('@pwd').then((pwd) => { + cy.login(bbUserEmail, pwd) + }) + + for (let i = 0; i < 2; i++) { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).eq(i).type("test") + } + cy.get(interact.SPECTRUM_BUTTON).contains("Reset your password").click({ force: true }) + cy.logoutNoAppGrid() + }) + + it("should verify Admin Portal", () => { + cy.login() + cy.contains("Users").click() + cy.contains("bbuser").click() + + // Enable Development & Administration access + cy.wait(500) + for (let i = 4; i < 6; i++) { + cy.get(interact.FIELD).eq(i).within(() => { + cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true }) + cy.get(interact.SPECTRUM_SWITCH_INPUT).should('be.enabled') + }) + } + bbUserLogin() + + // Verify available options for Admin portal + cy.get(".spectrum-SideNav") + .should('contain', 'Apps') + //.and('contain', 'Usage') + .and('contain', 'Users') + .and('contain', 'Auth') + .and('contain', 'Email') + .and('contain', 'Organisation') + .and('contain', 'Theming') + .and('contain', 'Update') + //.and('contain', 'Upgrade') + + cy.logOut() + }) + + it("should verify Development Portal", () => { + // Only Development access should be enabled + cy.login() + cy.contains("Users").click() + cy.contains("bbuser").click() + cy.wait(500) + cy.get(interact.FIELD).eq(5).within(() => { + cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true }) + }) + + bbUserLogin() + + // Verify available options for Admin portal + cy.get(interact.SPECTRUM_SIDENAV) + .should('contain', 'Apps') + //.and('contain', 'Usage') + .and('not.contain', 'Users') + .and('not.contain', 'Auth') + .and('not.contain', 'Email') + .and('not.contain', 'Organisation') + .and('contain', 'Theming') + .and('not.contain', 'Update') + .and('not.contain', 'Upgrade') + + cy.logOut() + }) + + it("should verify Standard Portal", () => { + // Development access should be disabled (Admin access is already disabled) + cy.login() + cy.contains("Users").click() + cy.contains("bbuser").click() + cy.wait(500) + cy.get(interact.FIELD).eq(4).within(() => { + cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true }) + }) + + bbUserLogin() + + // Verify Standard Portal + cy.get(interact.SPECTRUM_SIDENAV).should('not.exist') // No config sections + cy.get(interact.CREATE_APP_BUTTON).should('not.exist') // No create app button + cy.get(".app").should('not.exist') // No apps -> no roles assigned to user + cy.get(interact.CONTAINER).should('contain', bbUserEmail) // Message containing users email + + cy.logoutNoAppGrid() + }) + + const bbUserLogin = () => { + // Login as bbuser + cy.logOut() + cy.login(bbUserEmail, "test") + } + + after(() => { + cy.login() + // Delete BB user + cy.deleteUser(bbUserEmail) + }) + }) +}) diff --git a/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js b/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js new file mode 100644 index 000000000..562e1e149 --- /dev/null +++ b/packages/builder/cypress/integration/adminAndManagement/userManagement.spec.js @@ -0,0 +1,237 @@ +import filterTests from "../../support/filterTests" +const interact = require('../../support/interact') + +filterTests(["smoke", "all"], () => { + context("User Management", () => { + before(() => { + cy.login() + cy.deleteApp("Cypress Tests") + cy.createApp("Cypress Tests", false) + }) + + it("should create a user via basic onboarding", () => { + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000}) + cy.createUser("bbuser@test.com") + cy.get(interact.SPECTRUM_TABLE).should("contain", "bbuser") + }) + + it("should confirm basic permission for a New User", () => { + // Basic permission = development & administraton disabled + cy.contains("bbuser").click() + // Confirm development and admin access are disabled + for (let i = 4; i < 6; i++) { + cy.wait(500) + cy.get(interact.FIELD).eq(i).within(() => { + //cy.get(interact.SPECTRUM_SWITCH_INPUT).should('be.disabled') + cy.get(".spectrum-Switch-switch").should('not.be.checked') + }) + } + // Existing apps appear within the No Access table + cy.get(interact.SPECTRUM_TABLE, { timeout: 500 }).eq(1).should("not.contain", "No rows found") + // Configure roles table should not contain apps + cy.get(interact.SPECTRUM_TABLE).eq(0).contains("No rows found") + }) + + if (Cypress.env("TEST_ENV")) { + it("should assign role types", () => { + // 3 apps minimum required - to assign an app to each role type + cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) + .its("body") + .then(val => { + if (val.length < 3) { + for (let i = 1; i < 3; i++) { + const uuid = () => Cypress._.random(0, 1e6) + const name = uuid() + if(i < 1){ + cy.createApp(name, false) + } else { + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000}) + cy.wait(1000) + cy.get(interact.CREATE_APP_BUTTON, { timeout: 2000 }).click({ force: true }) + cy.createAppFromScratch(name) + } + } + } + }) + // Navigate back to the user + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000}) + cy.get(interact.SPECTRUM_SIDENAV).contains("Users").click() + cy.get(interact.SPECTRUM_TABLE, { timeout: 1000 }).contains("bbuser").click() + for (let i = 0; i < 3; i++) { + cy.get(interact.SPECTRUM_TABLE, { timeout: 3000}) + .eq(1) + .find(interact.SPECTRUM_TABLE_ROW) + .eq(0) + .find(interact.SPECTRUM_TABLE_CELL) + .eq(0) + .click() + cy.get(interact.SPECTRUM_DIALOG_GRID, { timeout: 1000 }) + .contains("Choose an option") + .click() + .then(() => { + if (i == 0) { + cy.get(interact.SPECTRUM_MENU, { timeout: 2000 }).contains("Admin").click({ force: true }) + } + else if (i == 1) { + cy.get(interact.SPECTRUM_MENU, { timeout: 2000 }).contains("Power").click({ force: true }) + } + else if (i == 2) { + cy.get(interact.SPECTRUM_MENU, { timeout: 2000 }).contains("Basic").click({ force: true }) + } + cy.get(interact.SPECTRUM_BUTTON, { timeout: 2000 }) + .contains("Update role") + .click({ force: true }) + }) + cy.reload({ timeout: 5000 }) + cy.wait(1000) + } + // Confirm roles exist within Configure roles table + cy.get(interact.SPECTRUM_TABLE, { timeout: 2000 }) + .eq(0) + .within(assginedRoles => { + expect(assginedRoles).to.contain("Admin") + expect(assginedRoles).to.contain("Power") + expect(assginedRoles).to.contain("Basic") + }) + }) + + it("should unassign role types", () => { + // Set each app within Configure roles table to 'No Access' + cy.get(interact.SPECTRUM_TABLE) + .eq(0) + .find(interact.SPECTRUM_TABLE_ROW) + .its("length") + .then(len => { + for (let i = 0; i < len; i++) { + cy.get(interact.SPECTRUM_TABLE) + .eq(0) + .find(interact.SPECTRUM_TABLE_ROW) + .eq(0) + .find(interact.SPECTRUM_TABLE_CELL) + .eq(0) + .click() + .then(() => { + cy.get(interact.SPECTRUM_PICKER).eq(1).click({ force: true }) + cy.get(interact.SPECTRUM_POPOVER, { timeout: 500 }).contains("No Access").click() + }) + cy.get(interact.SPECTRUM_BUTTON) + .contains("Update role") + .click({ force: true }) + } + }) + // Confirm Configure roles table no longer has any apps in it + cy.get(interact.SPECTRUM_TABLE, { timeout: 1000 }).eq(0).contains("No rows found") + }) + } + + it("should enable Developer access and verify application access", () => { + // Enable Developer access + cy.get(interact.FIELD) + .eq(4) + .within(() => { + cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true }) + }) + // No Access table should now be empty + cy.get(interact.CONTAINER) + .contains("No Access") + .parent() + .within(() => { + cy.get(interact.SPECTRUM_TABLE).contains("No rows found") + }) + + // Each app within Configure roles should have Admin access + cy.get(interact.SPECTRUM_TABLE) + .eq(0) + .find(interact.SPECTRUM_TABLE_ROW) + .its("length") + .then(len => { + for (let i = 0; i < len; i++) { + cy.get(interact.SPECTRUM_TABLE) + .eq(0) + .find(interact.SPECTRUM_TABLE_ROW) + .eq(i) + .contains("Admin") + cy.wait(500) + } + }) + }) + + it("should disable Developer access and verify application access", () => { + // Disable Developer access + cy.get(interact.FIELD) + .eq(4) + .within(() => { + cy.get(".spectrum-Switch-input").click({ force: true }) + }) + // Configure roles table should now be empty + cy.get(interact.CONTAINER) + .contains("Configure roles") + .parent() + .within(() => { + cy.get(interact.SPECTRUM_TABLE).contains("No rows found") + }) + }) + + it("Should edit user details within user details page", () => { + // Add First name + cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => { + cy.wait(500) + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).wait(500).clear().click().type("bb") + }) + // Add Last name + cy.get(interact.FIELD, { timeout: 1000 }).eq(3).within(() => { + cy.wait(500) + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).click().wait(500).clear().type("test") + }) + cy.get(interact.FIELD, { timeout: 1000 }).eq(0).click() + // Reload page + cy.reload() + + // Confirm details have been saved + cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', "bb") + }) + cy.get(interact.FIELD, { timeout: 1000 }).eq(3).within(() => { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }).should('have.value', "test") + }) + }) + + it("should reset the users password", () => { + cy.get(interact.REGENERATE, { timeout: 500 }).contains("Force password reset").click({ force: true }) + + // Reset password modal + cy.get(interact.SPECTRUM_DIALOG_GRID) + .find(interact.SPECTRUM_TEXTFIELD_INPUT).invoke('val').as('pwd') + cy.get(interact.SPECTRUM_BUTTON).contains("Reset password").click({ force: true }) + + // Logout, then login with new password + cy.logOut() + cy.get('@pwd').then((pwd) => { + cy.login("bbuser@test.com", pwd) + }) + + // Reset password screen + for (let i = 0; i < 2; i++) { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).eq(i).type("test") + } + cy.get(interact.SPECTRUM_BUTTON).contains("Reset your password").click({ force: true }) + + // Confirm user logged in afer password change + cy.get(".avatar > .icon").click({ force: true }) + + cy.get(".spectrum-Menu-item").contains("Update user information").click({ force: true }) + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT) + .eq(0) + .invoke('val').should('eq', 'bbuser@test.com') + + // Logout and login as previous user + cy.logoutNoAppGrid() + cy.login() + }) + + it("should delete a user", () => { + cy.deleteUser("bbuser@test.com") + cy.get(interact.SPECTRUM_TABLE, { timeout: 4000 }).should("not.have.text", "bbuser") + }) + }) +}) diff --git a/packages/builder/cypress/integration/adminAndManagement/userSettings.spec.js b/packages/builder/cypress/integration/adminAndManagement/userSettings.spec.js new file mode 100644 index 000000000..95af9f784 --- /dev/null +++ b/packages/builder/cypress/integration/adminAndManagement/userSettings.spec.js @@ -0,0 +1,110 @@ +import filterTests from "../../support/filterTests" +const interact = require('../../support/interact') + +filterTests(["smoke", "all"], () => { + context("User Settings Menu", () => { + + before(() => { + cy.login() + }) + + it("should update user information via user settings menu", () => { + const fname = "test" + const lname = "user" + + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.updateUserInformation(fname, lname) + + // Go to user info and confirm name update + cy.contains("Users").click() + cy.contains("test@test.com").click() + + cy.get(interact.FIELD, { timeout: 1000 }).eq(2).within(() => { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', fname) + }) + cy.get(interact.FIELD).eq(3).within(() => { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).should('have.value', lname) + }) + }) + + it("should allow copying of the users API key", () => { + cy.get(".user-dropdown .avatar > .icon", { timeout: 2000 }).click({ force: true }) + cy.get(interact.SPECTRUM_MENU_ITEM).contains("View API key").click({ force: true }) + cy.get(interact.SPECTRUM_DIALOG_CONTENT).within(() => { + cy.get(interact.SPECTRUM_ICON).click({force: true}) + }) + // There may be timing issues with this on the smoke build + cy.wait(500) + cy.get(".spectrum-Toast-content") + .contains("URL copied to clipboard") + .should("be.visible") + }) + + it("should allow API key regeneration", () => { + // Get initial API key value + cy.get(interact.SPECTRUM_DIALOG_CONTENT) + .find(interact.SPECTRUM_TEXTFIELD_INPUT).invoke('val').as('keyOne') + + // Click re-generate key button + cy.get("button").contains("Re-generate key").click({ force: true }) + + // Verify API key was changed + cy.get(interact.SPECTRUM_DIALOG_CONTENT).within(() => { + cy.get('@keyOne').then((keyOne) => { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).invoke('val').should('not.eq', keyOne) + }) + }) + cy.closeModal() + }) + + it("should update password", () => { + // Access Update password modal + cy.get(".user-dropdown .avatar > .icon", { timeout: 2000 }).click({ force: true }) + cy.get(interact.SPECTRUM_MENU_ITEM).contains("Update password").click({ force: true }) + + // Enter new password and update + cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => { + for (let i = 0; i < 2; i++) { + // password set to 'newpwd' + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).eq(i).type("newpwd") + } + cy.get("button").contains("Update password").click({ force: true }) + }) + + // Logout & in with new password + cy.logOut() + cy.login("test@test.com", "newpwd") + }) + + it("should open and close developer mode", () => { + cy.get(".user-dropdown .avatar > .icon", { timeout: 2000 }).click({ force: true }) + + // Close developer mode & verify + cy.get(interact.SPECTRUM_MENU_ITEM).contains("Close developer mode").click({ force: true }) + cy.get(interact.SPECTRUM_SIDENAV).should('not.exist') // No config sections + cy.get(interact.CREATE_APP_BUTTON).should('not.exist') // No create app button + cy.get(".app").should('not.exist') // At least one app should be available + + // Open developer mode & verify + cy.get(".avatar > .icon").click({ force: true }) + cy.get(interact.SPECTRUM_MENU_ITEM).contains("Open developer mode").click({ force: true }) + cy.get(interact.SPECTRUM_SIDENAV).should('exist') // config sections available + cy.get(interact.CREATE_APP_BUTTON).should('exist') // create app button available + cy.get(interact.APP_TABLE).should('exist') // App table available + }) + + after(() => { + // Change password back to original value + cy.get(".user-dropdown .avatar > .icon", { timeout: 2000 }).click({ force: true }) + cy.get(interact.SPECTRUM_MENU_ITEM).contains("Update password").click({ force: true }) + cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => { + for (let i = 0; i < 2; i++) { + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT).eq(i).type("test") + } + cy.get("button").contains("Update password").click({ force: true }) + }) + // Remove users name + cy.updateUserInformation() + }) + }) +}) diff --git a/packages/builder/cypress/integration/appOverview.spec.js b/packages/builder/cypress/integration/appOverview.spec.js index 4b9d07233..dbfce3ce6 100644 --- a/packages/builder/cypress/integration/appOverview.spec.js +++ b/packages/builder/cypress/integration/appOverview.spec.js @@ -5,7 +5,8 @@ filterTests(["all"], () => { context("Application Overview screen", () => { before(() => { cy.login() - cy.createTestApp() + cy.deleteAllApps() + cy.createApp("Cypress Tests") }) it("Should be accessible from the applications list", () => { @@ -81,13 +82,14 @@ filterTests(["all"], () => { }) it("Should reflect the app deployment state", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.get(".appTable .app-row-actions button") .contains("Edit") .eq(0) .click({ force: true }) - cy.get(".toprightnav button.spectrum-Button") + cy.wait(500) + cy.get(".toprightnav button.spectrum-Button", { timeout: 2000 }) .contains("Publish") .click({ force: true }) cy.get(".spectrum-Modal [data-cy='deploy-app-modal']") @@ -136,7 +138,6 @@ filterTests(["all"], () => { .within(() => { cy.get(".confirm-wrap button").click({ force: true }) }) - cy.wait(1000) cy.visit(`${Cypress.config().baseUrl}/builder`) cy.get(".appTable .app-row-actions button") @@ -158,12 +159,9 @@ filterTests(["all"], () => { .contains("Manage") .eq(0) .click({ force: true }) - cy.wait(1000) - cy.get(".app-overview-actions-icon").within(() => { - cy.get(".spectrum-Icon").click({ force: true }) - }) - cy.get(".spectrum-Menu").contains("Edit icon").click() + cy.get(".edit-hover", { timeout: 1000 }).eq(0).click({ force: true }) // Select random icon + cy.wait(400) cy.get(".grid").within(() => { cy.get(".icon-item") .eq(Math.floor(Math.random() * 23) + 1) @@ -182,6 +180,7 @@ filterTests(["all"], () => { cy.get("@iconChange").its("response.statusCode").should("eq", 200) // Confirm icon has changed from default // Confirm colour has been applied + cy.get(".spectrum-ActionButton-label").contains("Back").click({ force: true }) cy.get(".appTable", { timeout: 2000 }).within(() => { cy.get("[aria-label]") .eq(0) @@ -265,10 +264,9 @@ filterTests(["all"], () => { //Downgrade the app for the test cy.alterAppVersion(appId, "0.0.1-alpha.0").then(() => { cy.reload() - cy.wait(1000) cy.log("Current deployment version: " + clientPackage.version) - cy.get(".version-status a").contains("Update").click() + cy.get(".version-status a", { timeout: 1000 }).contains("Update").click() cy.get(".spectrum-Tabs-item.is-selected").contains("Settings") cy.get(".version-section .page-action button") @@ -304,7 +302,7 @@ filterTests(["all"], () => { }) it("Should allow editing of the app details.", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.get(".appTable .app-row-actions button") .contains("Manage") .eq(0) @@ -319,7 +317,8 @@ filterTests(["all"], () => { cy.updateAppName("sample name") //publish and check its disabled - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) + cy.wait(500) cy.get(".appTable .app-row-actions button") .contains("Edit") .eq(0) @@ -335,9 +334,8 @@ filterTests(["all"], () => { cy.wait(1000) }) - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) - cy.get(".appTable .app-row-actions button") + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) + cy.get(".appTable .app-row-actions button", { timeout: 5000 }) .contains("Manage") .eq(0) .click({ force: true }) @@ -345,8 +343,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Tabs-item.is-selected").contains("Settings") cy.get(".details-section .page-action .spectrum-Button").scrollIntoView() - cy.wait(1000) - cy.get(".details-section .page-action .spectrum-Button").should( + cy.get(".details-section .page-action .spectrum-Button", { timeout: 1000 }).should( "be.disabled" ) }) @@ -375,27 +372,21 @@ filterTests(["all"], () => { .contains("Copy App ID") .click({ force: true }) }) - + cy.get(".spectrum-Toast-content") - .contains("App ID copied to clipboard.") - .should("be.visible") + .contains("App ID copied to clipboard.") + .should("be.visible") }) - it("Should allow unpublishing of the application", () => { + it("Should allow unpublishing of the application via the Unpublish link", () => { cy.visit(`${Cypress.config().baseUrl}/builder`) cy.get(".appTable .app-row-actions button") .contains("Manage") .eq(0) .click({ force: true }) - cy.get(".app-overview-actions-icon > .icon").click({ force: true }) - cy.get("[data-cy='app-overview-menu-popover']") - .eq(0) - .within(() => { - cy.get(".spectrum-Menu-item") - .contains("Unpublish") - .click({ force: true }) - cy.wait(500) + cy.get(`[data-cy="app-status"]`).within(() => { + cy.contains("Unpublish").click({ force: true }) }) cy.get("[data-cy='unpublish-modal']") @@ -406,9 +397,8 @@ filterTests(["all"], () => { cy.get(".overview-tab [data-cy='app-status']").within(() => { cy.get(".status-display").contains("Unpublished") - cy.get(".status-display .icon svg[aria-label='GlobeStrike']").should( - "exist" - ) + cy.get(".status-display .icon svg[aria-label='GlobeStrike']") + .should("exist") }) }) diff --git a/packages/builder/cypress/integration/appPublishWorkflow.spec.js b/packages/builder/cypress/integration/appPublishWorkflow.spec.js index f0a7dd791..edca7ee3a 100644 --- a/packages/builder/cypress/integration/appPublishWorkflow.spec.js +++ b/packages/builder/cypress/integration/appPublishWorkflow.spec.js @@ -6,14 +6,14 @@ filterTests(['all'], () => { context("Publish Application Workflow", () => { before(() => { cy.login() - cy.createTestApp() + cy.deleteAllApps() + cy.createApp("Cypress Tests", false) }) it("Should reflect the unpublished status correctly", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) - cy.get(interact.APP_TABLE_STATUS).eq(0) + cy.get(interact.APP_TABLE_STATUS, { timeout: 3000 }).eq(0) .within(() => { cy.contains("Unpublished") cy.get(interact.GLOBESTRIKE).should("exist") @@ -30,16 +30,16 @@ filterTests(['all'], () => { it("Should publish an application and correctly reflect that", () => { //Assuming the previous test was run and the unpublished app is open in edit mode. + cy.closeModal() cy.get(interact.TOPRIGHTNAV_BUTTON_SPECTRUM).contains("Publish").click({ force : true }) cy.get(interact.DEPLOY_APP_MODAL).should("be.visible") .within(() => { cy.get(interact.SPECTRUM_BUTTON).contains("Publish").click({ force : true }) - cy.wait(1000) }); //Verify that the app url is presented correctly to the user - cy.get(interact.DEPLOY_SUCCESS_MODAL) + cy.get(interact.DEPLOY_SUCCESS_MODAL, { timeout: 1000 }) .should("be.visible") .within(() => { let appUrl = Cypress.config().baseUrl + '/app/cypress-tests' @@ -48,9 +48,8 @@ filterTests(['all'], () => { }) cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) - cy.get(interact.APP_TABLE_STATUS).eq(0) + cy.get(interact.APP_TABLE_STATUS, { timeout: 3000 }).eq(0) .within(() => { cy.contains("Published") cy.get(interact.GLOBE).should("exist") @@ -58,7 +57,7 @@ filterTests(['all'], () => { cy.get(interact.APP_TABLE_ROW_ACTION).eq(0) .within(() => { - cy.get(interact.SPECTRUM_BUTTON).contains("View") + cy.get(interact.SPECTRUM_BUTTON).contains("Manage") cy.get(interact.SPECTRUM_BUTTON).contains("Edit").click({ force: true }) }) @@ -75,7 +74,7 @@ filterTests(['all'], () => { it("Should unpublish an application using the link and reflect the status change", () => { //Assuming the previous test app exists and is published - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.get(interact.APP_TABLE_STATUS).eq(0) .within(() => { @@ -83,24 +82,27 @@ filterTests(['all'], () => { cy.get("svg[aria-label='Globe']").should("exist") }) - cy.get(interact.APP_TABLE_ROW_ACTION).eq(0) + cy.get(interact.APP_TABLE).eq(0) .within(() => { - cy.get(interact.SPECTRUM_BUTTON).contains("View") cy.get(interact.APP_TABLE_APP_NAME).click({ force: true }) }) - cy.get(interact.SPECTRUM_LINK).contains('Unpublish').click(); + cy.closeModal() + cy.get(interact.DEPLOYMENT_TOP_GLOBE).should("exist").click({ force: true }) + + cy.get("[data-cy='publish-popover-menu']") + .within(() => { + cy.get(interact.PUBLISH_POPOVER_ACTION).click({ force: true }) + }) cy.get(interact.UNPUBLISH_MODAL).should("be.visible") .within(() => { cy.get(interact.CONFIRM_WRAP_BUTTON).click({ force: true } )}) - cy.get(interact.DEPLOYMENT_TOP_NAV_GLOBESTRIKE).should("exist") - - cy.visit(`${Cypress.config().baseUrl}/builder`) - - cy.get(interact.APP_TABLE_STATUS).eq(0).contains("Unpublished") + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 6000 }) + cy.wait(500) + cy.get(interact.APP_TABLE_STATUS, { timeout: 1000 }).eq(0).contains("Unpublished") }) }) diff --git a/packages/builder/cypress/integration/autoScreensUI.spec.js b/packages/builder/cypress/integration/autoScreensUI.spec.js index eebeac3e7..ca997479a 100644 --- a/packages/builder/cypress/integration/autoScreensUI.spec.js +++ b/packages/builder/cypress/integration/autoScreensUI.spec.js @@ -5,6 +5,7 @@ filterTests(['smoke', 'all'], () => { context("Auto Screens UI", () => { before(() => { cy.login() + cy.deleteAllApps() }) it("should disable the autogenerated screen options if no sources are available", () => { diff --git a/packages/builder/cypress/integration/createApp.spec.js b/packages/builder/cypress/integration/createApp.spec.js index df617e3d9..ca52c66e3 100644 --- a/packages/builder/cypress/integration/createApp.spec.js +++ b/packages/builder/cypress/integration/createApp.spec.js @@ -6,14 +6,14 @@ filterTests(['smoke', 'all'], () => { before(() => { cy.login() - cy.deleteApp("Cypress Tests") + cy.deleteAllApps() }) if (!(Cypress.env("TEST_ENV"))) { it("should show the new user UI/UX", () => { - cy.visit(`${Cypress.config().baseUrl}/builder/portal/apps/create`) //added /portal/apps/create + cy.visit(`${Cypress.config().baseUrl}/builder/portal/apps/create`, { timeout: 5000 }) //added /portal/apps/create + cy.wait(1000) cy.get(interact.CREATE_APP_BUTTON).contains('Start from scratch').should("exist") - cy.get(interact.CREATE_APP_BUTTON).should("exist") cy.get(interact.TEMPLATE_CATEGORY_FILTER).should("exist") cy.get(interact.TEMPLATE_CATEGORY).should("exist") @@ -23,7 +23,7 @@ filterTests(['smoke', 'all'], () => { } it("should provide filterable templates", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.wait(500) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) @@ -48,18 +48,11 @@ filterTests(['smoke', 'all'], () => { }) it("should enforce a valid url before submission", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) // Start create app process. If apps already exist, click second button - cy.get(interact.CREATE_APP_BUTTON).click({ force: true }) - cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) - .its("body") - .then(val => { - if (val.length > 0) { - cy.get(interact.CREATE_APP_BUTTON).click({ force: true }) - } - }) + cy.wait(1000) + cy.get(interact.CREATE_APP_BUTTON, { timeout: 3000 }).click({ force: true }) const appName = "Cypress Tests" cy.get(interact.SPECTRUM_MODAL).within(() => { @@ -92,21 +85,16 @@ filterTests(['smoke', 'all'], () => { it("should create the first application from scratch", () => { const appName = "Cypress Tests" - cy.createApp(appName) + cy.createApp(appName, false) - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.applicationInAppTable(appName) cy.deleteApp(appName) }) it("should create the first application from scratch with a default name", () => { - cy.createApp() - - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) - + cy.createApp("", false) cy.applicationInAppTable("My app") cy.deleteApp("My app") }) @@ -116,26 +104,22 @@ filterTests(['smoke', 'all'], () => { cy.updateUserInformation("Ted", "Userman") - cy.createApp() + cy.createApp("", false) cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) cy.applicationInAppTable("Teds app") cy.deleteApp("Teds app") - cy.wait(2000) //Accomodate names that end in 'S' cy.updateUserInformation("Chris", "Userman") - cy.createApp() + cy.createApp("", false) cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) cy.applicationInAppTable("Chris app") cy.deleteApp("Chris app") - cy.wait(2000) cy.updateUserInformation("", "") }) @@ -145,7 +129,7 @@ filterTests(['smoke', 'all'], () => { cy.importApp(exportedApp, "") - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 2000 }) cy.applicationInAppTable("My app") @@ -224,14 +208,12 @@ filterTests(['smoke', 'all'], () => { cy.createApp(appName) cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) // Create second app const secondAppName = "Second App Demo" cy.createApp(secondAppName) cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) //Both applications should exist and be searchable cy.searchForApplication(appName) diff --git a/packages/builder/cypress/integration/createAutomation.spec.js b/packages/builder/cypress/integration/createAutomation.spec.js index 6a4b70f8d..b5ff40629 100644 --- a/packages/builder/cypress/integration/createAutomation.spec.js +++ b/packages/builder/cypress/integration/createAutomation.spec.js @@ -16,17 +16,15 @@ filterTests(['smoke', 'all'], () => { cy.get(interact.MODAL_INNER_WRAPPER).within(() => { cy.get("input").type("Add Row") cy.contains("Row Created").click({ force: true }) - cy.wait(500) - cy.get(interact.SPECTRUM_BUTTON_CTA).click() + cy.get(interact.SPECTRUM_BUTTON_CTA, { timeout: 500 }).click() }) // Setup trigger cy.get(interact.SPECTRUM_PICKER_LABEL).click() cy.wait(500) cy.contains("dog").click() - cy.wait(2000) // Create action - cy.get('[aria-label="AddCircle"]').eq(1).click() + cy.get('[aria-label="AddCircle"]', { timeout: 2000 }).eq(1).click() cy.get(interact.MODAL_INNER_WRAPPER).within(() => { cy.wait(1000) cy.contains("Create Row").trigger('mouseover').click().click() @@ -43,11 +41,9 @@ filterTests(['smoke', 'all'], () => { cy.contains("Finish and test automation").click() cy.get(interact.MODAL_INNER_WRAPPER).within(() => { - cy.wait(1000) - cy.get(interact.SPECTRUM_PICKER_LABEL).click() + cy.get(interact.SPECTRUM_PICKER_LABEL, { timeout: 1000 }).click() cy.contains("dog").click() - cy.wait(1000) - cy.get(interact.SPECTRUM_TEXTFIELD_INPUT) + cy.get(interact.SPECTRUM_TEXTFIELD_INPUT, { timeout: 1000 }) .first() .type("automationGoodboy") cy.get(interact.SPECTRUM_TEXTFIELD_INPUT) diff --git a/packages/builder/cypress/integration/createScreen.js b/packages/builder/cypress/integration/createScreen.spec.js similarity index 100% rename from packages/builder/cypress/integration/createScreen.js rename to packages/builder/cypress/integration/createScreen.spec.js diff --git a/packages/builder/cypress/integration/createTable.spec.js b/packages/builder/cypress/integration/createTable.spec.js index 7d55a1f03..da73c19fa 100644 --- a/packages/builder/cypress/integration/createTable.spec.js +++ b/packages/builder/cypress/integration/createTable.spec.js @@ -10,9 +10,8 @@ filterTests(["smoke", "all"], () => { it("should create a new Table", () => { cy.createTable("dog") - cy.wait(1000) // Check if Table exists - cy.get(interact.TABLE_TITLE_H1).should("have.text", "dog") + cy.get(interact.TABLE_TITLE_H1, { timeout: 1000 }).should("have.text", "dog") }) it("adds a new column to the table", () => { @@ -40,7 +39,7 @@ filterTests(["smoke", "all"], () => { it("edits a row", () => { cy.contains("button", "Edit").click({ force: true }) - cy.wait(1000) + cy.wait(500) cy.get(interact.SPECTRUM_MODAL_INPUT).clear() cy.get(interact.SPECTRUM_MODAL_INPUT).type("Updated") cy.contains("Save").click() @@ -63,8 +62,7 @@ filterTests(["smoke", "all"], () => { cy.addRow([i]) } cy.reload() - cy.wait(2000) - cy.get(interact.SPECTRUM_PAGINATION).within(() => { + cy.get(interact.SPECTRUM_PAGINATION, { timeout: 2000 }).within(() => { cy.get(interact.SPECTRUM_ACTION_BUTTON).eq(1).click() }) cy.get(interact.SPECTRUM_PAGINATION).within(() => { @@ -79,10 +77,9 @@ filterTests(["smoke", "all"], () => { cy.get(interact.SPECTRUM_BUTTON).click({ force: true }) }) cy.get(interact.SPECTRUM_DIALOG_GRID).contains("Delete").click({ force: true }) - cy.wait(1000) // Confirm table only has one page - cy.get(interact.SPECTRUM_PAGINATION).within(() => { + cy.get(interact.SPECTRUM_PAGINATION, { timeout: 1000 }).within(() => { cy.get(interact.SPECTRUM_ACTION_BUTTON).eq(1).should("not.be.enabled") }) }) diff --git a/packages/builder/cypress/integration/createUserAndRoles.spec.js b/packages/builder/cypress/integration/createUserAndRoles.spec.js deleted file mode 100644 index d47a96ed8..000000000 --- a/packages/builder/cypress/integration/createUserAndRoles.spec.js +++ /dev/null @@ -1,195 +0,0 @@ -import filterTests from "../support/filterTests" -const interact = require('../support/interact') - -filterTests(["smoke", "all"], () => { - context("Create a User and Assign Roles", () => { - before(() => { - cy.login() - cy.deleteApp("Cypress Tests") - cy.createApp("Cypress Tests") - }) - - it("should create a user", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(1000) - cy.createUser("bbuser@test.com") - cy.get(interact.SPECTRUM_TABLE).should("contain", "bbuser") - }) - - it("should confirm there is No Access for a New User", () => { - // Click into the user - cy.contains("bbuser").click() - cy.wait(500) - // Get No Access table - Confirm it has apps in it - cy.get(interact.SPECTRUM_TABLE).eq(1).should("not.contain", "No rows found") - // Get Configure Roles table - Confirm it has no apps - cy.get(interact.SPECTRUM_TABLE).eq(0).contains("No rows found") - }) - - if (Cypress.env("TEST_ENV")) { - it("should assign role types", () => { - // 3 apps minimum required - to assign an app to each role type - cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) - .its("body") - .then(val => { - if (val.length < 3) { - for (let i = 1; i < 3; i++) { - const uuid = () => Cypress._.random(0, 1e6) - const name = uuid() - if(i < 1){ - cy.createApp(name) - } else { - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) - cy.get(interact.CREATE_APP_BUTTON).click({ force: true }) - cy.createAppFromScratch(name) - } - } - } - }) - // Navigate back to the user - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) - cy.get(interact.SPECTRUM_SIDENAV).contains("Users").click() - cy.wait(500) - cy.get(interact.SPECTRUM_TABLE).contains("bbuser").click() - cy.wait(1000) - for (let i = 0; i < 3; i++) { - cy.get(interact.SPECTRUM_TABLE, { timeout: 3000}) - .eq(1) - .find(interact.SPECTRUM_TABLE_ROW) - .eq(0) - .find(interact.SPECTRUM_TABLE_CELL) - .eq(0) - .click() - cy.wait(500) - cy.get(interact.SPECTRUM_DIALOG_GRID) - .contains("Choose an option") - .click() - .then(() => { - cy.wait(1000) - if (i == 0) { - cy.get(interact.SPECTRUM_MENU).contains("Admin").click({ force: true }) - } - else if (i == 1) { - cy.get(interact.SPECTRUM_MENU).contains("Power").click({ force: true }) - } - else if (i == 2) { - cy.get(interact.SPECTRUM_MENU).contains("Basic").click({ force: true }) - } - cy.wait(1000) - cy.get(interact.SPECTRUM_BUTTON) - .contains("Update role") - .click({ force: true }) - }) - cy.reload() - } - // Confirm roles exist within Configure roles table - cy.wait(2000) - cy.get(interact.SPECTRUM_TABLE) - .eq(0) - .within(assginedRoles => { - expect(assginedRoles).to.contain("Admin") - expect(assginedRoles).to.contain("Power") - expect(assginedRoles).to.contain("Basic") - }) - }) - - it("should unassign role types", () => { - // Set each app within Configure roles table to 'No Access' - cy.get(interact.SPECTRUM_TABLE) - .eq(0) - .find(interact.SPECTRUM_TABLE_ROW) - .its("length") - .then(len => { - for (let i = 0; i < len; i++) { - cy.get(interact.SPECTRUM_TABLE) - .eq(0) - .find(interact.SPECTRUM_TABLE_ROW) - .eq(0) - .find(interact.SPECTRUM_TABLE_CELL) - .eq(0) - .click() - .then(() => { - cy.get(interact.SPECTRUM_PICKER).eq(1).click({ force: true }) - cy.wait(500) - cy.get(interact.SPECTRUM_POPOVER).contains("No Access").click() - }) - cy.get(interact.SPECTRUM_BUTTON) - .contains("Update role") - .click({ force: true }) - cy.wait(1000) - } - }) - // Confirm Configure roles table no longer has any apps in it - cy.get(interact.SPECTRUM_TABLE).eq(0).contains("No rows found") - }) - } - - it("should enable Developer access", () => { - // Enable Developer access - cy.get(interact.FIELD) - .eq(4) - .within(() => { - cy.get(interact.SPECTRUM_SWITCH_INPUT).click({ force: true }) - }) - // No Access table should now be empty - cy.get(interact.CONTAINER) - .contains("No Access") - .parent() - .within(() => { - cy.get(interact.SPECTRUM_TABLE).contains("No rows found") - }) - - // Each app within Configure roles should have Admin access - cy.get(interact.SPECTRUM_TABLE) - .eq(0) - .find(interact.SPECTRUM_TABLE_ROW) - .its("length") - .then(len => { - for (let i = 0; i < len; i++) { - cy.get(interact.SPECTRUM_TABLE) - .eq(0) - .find(interact.SPECTRUM_TABLE_ROW) - .eq(i) - .contains("Admin") - cy.wait(500) - } - }) - }) - - it("should disable Developer access", () => { - // Disable Developer access - cy.get(".field") - .eq(4) - .within(() => { - cy.get(".spectrum-Switch-input").click({ force: true }) - }) - // Configure roles table should now be empty - cy.get(".container") - .contains("Configure roles") - .parent() - .within(() => { - cy.get(interact.SPECTRUM_TABLE).contains("No rows found") - }) - }) - - it("should delete a user", () => { - // Click Delete user button - cy.get(interact.SPECTRUM_BUTTON) - .contains("Delete user") - .click({ force: true }) - .then(() => { - // Confirm deletion within modal - cy.wait(500) - cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => { - cy.get(interact.SPECTRUM_BUTTON) - .contains("Delete user") - .click({ force: true }) - cy.wait(4000) - }) - }) - cy.get(interact.SPECTRUM_TABLE).should("not.have.text", "bbuser") - }) - }) -}) diff --git a/packages/builder/cypress/integration/createView.spec.js b/packages/builder/cypress/integration/createView.spec.js index e554f6f86..9adc486f7 100644 --- a/packages/builder/cypress/integration/createView.spec.js +++ b/packages/builder/cypress/integration/createView.spec.js @@ -5,7 +5,6 @@ filterTests(['smoke', 'all'], () => { context("Create a View", () => { before(() => { cy.login() - cy.createTestApp() cy.createTable("data") cy.addColumn("data", "group", "Text") @@ -69,8 +68,8 @@ filterTests(['smoke', 'all'], () => { cy.get(interact.SPECTRUM_BUTTON).contains("Save").click({ force: true }) }) - cy.wait(1000) + cy.wait(1000) cy.get(interact.TITLE).then($headers => { expect($headers).to.have.length(7) const headers = Array.from($headers).map(header => diff --git a/packages/builder/cypress/integration/customThemingProperties.spec.js b/packages/builder/cypress/integration/customThemingProperties.spec.js index ed3478ca6..e9de0985d 100644 --- a/packages/builder/cypress/integration/customThemingProperties.spec.js +++ b/packages/builder/cypress/integration/customThemingProperties.spec.js @@ -34,7 +34,6 @@ filterTests(['all'], () => { Large = 16px */ it("should test button roundness", () => { const buttonRoundnessValues = ["0", "4px", "8px", "16px"] - cy.wait(1000) // Add button, change roundness and confirm value cy.addComponent("Button", null).then((componentId) => { buttonRoundnessValues.forEach(function (item, index){ diff --git a/packages/builder/cypress/integration/datasources/datasourceWizard.spec.js b/packages/builder/cypress/integration/datasources/datasourceWizard.spec.js index 1bee7b5ec..14653d828 100644 --- a/packages/builder/cypress/integration/datasources/datasourceWizard.spec.js +++ b/packages/builder/cypress/integration/datasources/datasourceWizard.spec.js @@ -17,11 +17,10 @@ filterTests(['all'], () => { // Navigate back within datasource wizard cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Button").contains("Back").click({ force: true }) - cy.wait(1000) }) // Select PostgreSQL datasource again - cy.get(".item-list").contains(datasource).click() + cy.get(".item-list", { timeout: 1000 }).contains(datasource).click() cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Button").contains("Continue").click({ force: true }) }) diff --git a/packages/builder/cypress/integration/datasources/mySql.spec.js b/packages/builder/cypress/integration/datasources/mySql.spec.js index 98bb2f2ac..4c24ea928 100644 --- a/packages/builder/cypress/integration/datasources/mySql.spec.js +++ b/packages/builder/cypress/integration/datasources/mySql.spec.js @@ -114,7 +114,7 @@ filterTests(["all"], () => { cy.wait(1000) }) // Confirm table length & relationship name - cy.get(".spectrum-Table") + cy.get(".spectrum-Table", { timeout: 1000 }) .eq(1) .find(".spectrum-Table-row") .its("length") @@ -136,15 +136,15 @@ filterTests(["all"], () => { cy.get(".spectrum-Table") .eq(1) .within(() => { - cy.get(".spectrum-Table-row").eq(0).click({ force: true }) - cy.wait(500) + cy.get(".spectrum-Table-cell").eq(0).click({ force: true }) }) - cy.get(".spectrum-Dialog-grid").within(() => { + cy.get(".spectrum-Dialog-grid", { timeout: 500 }).within(() => { cy.get(".spectrum-Button") .contains("Delete") .click({ force: true }) }) cy.reload() + cy.wait(500) } // Confirm relationships no longer exist cy.get(".spectrum-Body").should( @@ -217,9 +217,8 @@ filterTests(["all"], () => { 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", { timeout: 1000 }).should("not.contain", queryName) }) } }) diff --git a/packages/builder/cypress/integration/datasources/oracle.spec.js b/packages/builder/cypress/integration/datasources/oracle.spec.js index 4c4d33d65..92a5737ff 100644 --- a/packages/builder/cypress/integration/datasources/oracle.spec.js +++ b/packages/builder/cypress/integration/datasources/oracle.spec.js @@ -20,7 +20,7 @@ filterTests(["all"], () => { .click({ force: true }) cy.wait(500) // Confirm config contains localhost - cy.get(".spectrum-Textfield-input") + cy.get(".spectrum-Textfield-input", { timeout: 500 }) .eq(1) .should("have.value", "localhost") // Add another Oracle data source, configure & skip table fetch @@ -140,9 +140,8 @@ filterTests(["all"], () => { .eq(1) .within(() => { cy.get(".spectrum-Table-row").eq(0).click() - cy.wait(500) }) - cy.get(".spectrum-Dialog-grid").within(() => { + cy.get(".spectrum-Dialog-grid", { timeout: 500 }).within(() => { cy.get(".spectrum-Button") .contains("Delete") .click({ force: true }) @@ -221,10 +220,9 @@ filterTests(["all"], () => { 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", { timeout: 1000 }).should("not.contain", queryName) }) } }) diff --git a/packages/builder/cypress/integration/datasources/postgreSql.spec.js b/packages/builder/cypress/integration/datasources/postgreSql.spec.js index 7448e6b27..ccecfbd5d 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -35,6 +35,7 @@ filterTests(["all"], () => { // Check response from datasource after adding configuration cy.wait("@datasource") cy.get("@datasource").its("response.statusCode").should("eq", 200) + cy.wait(2000) // Confirm fetch tables was successful cy.get(".spectrum-Table") .eq(0) @@ -113,13 +114,13 @@ filterTests(["all"], () => { cy.get(".spectrum-Table") .eq(1) .within(() => { - cy.get(".spectrum-Table-row").eq(0).click({ force: true }) - cy.wait(500) + cy.get(".spectrum-Table-cell").eq(0).click({ force: true }) }) - cy.get(".spectrum-Dialog-grid").within(() => { + cy.get(".spectrum-Dialog-grid", { timeout: 500 }).within(() => { cy.get(".spectrum-Button").contains("Delete").click({ force: true }) }) cy.reload() + cy.wait(500) // Confirm relationship was deleted cy.get(".spectrum-Table") .eq(1) @@ -150,7 +151,7 @@ filterTests(["all"], () => { cy.get("@query").its("response.body").should("not.be.empty") // Save query cy.get(".spectrum-Button").contains("Save Query").click({ force: true }) - cy.get(".hierarchy-items-container").should("contain", queryName) + cy.get(".spectrum-Tabs-content", { timeout: 2000 }).should("contain", queryName) }) it("should switch to schema with no tables", () => { @@ -159,7 +160,7 @@ filterTests(["all"], () => { switchSchema("randomText") // No tables displayed - cy.get(".spectrum-Body").eq(2).should("contain", "No tables found") + cy.get(".spectrum-Body", { timeout: 5000 }).eq(2).should("contain", "No tables found") // Previously created query should be visible cy.get(".spectrum-Table").should("contain", queryName) @@ -170,7 +171,7 @@ filterTests(["all"], () => { switchSchema("1") // Confirm tables exist - Check for specific one - cy.get(".spectrum-Table").eq(0).should("contain", "test") + cy.get(".spectrum-Table", { timeout: 5000 }).eq(0).should("contain", "test") cy.get(".spectrum-Table") .eq(0) .find(".spectrum-Table-row") @@ -184,7 +185,7 @@ filterTests(["all"], () => { switchSchema("public") // Confirm tables exist - again - cy.get(".spectrum-Table").eq(0).should("contain", "REGIONS") + cy.get(".spectrum-Table", { timeout: 5000 }).eq(0).should("contain", "REGIONS") cy.get(".spectrum-Table") .eq(0) .find(".spectrum-Table-row") @@ -216,22 +217,24 @@ filterTests(["all"], () => { it("should edit a query name", () => { // Access query - cy.get(".hierarchy-items-container") + cy.get(".hierarchy-items-container", { timeout: 2000 }) .contains(queryName + " (1)") .click() // Rename query - cy.get(".spectrum-Form-item") + cy.wait(1000) + cy.get(".spectrum-Form-item", { timeout: 2000 }) .eq(0) .within(() => { cy.get("input").clear().type(queryRename) }) // Run and Save query - cy.get(".spectrum-Button").contains("Run Query").click({ force: true }) - cy.wait(500) - cy.get(".spectrum-Button").contains("Save Query").click({ force: true }) - cy.get(".nav-item").should("contain", queryRename) + cy.get(".spectrum-Button", { timeout: 2000 }).contains("Run Query").click({ force: true }) + cy.wait(1000) + cy.get(".spectrum-Button", { timeout: 2000 }).contains("Save Query").click({ force: true }) + cy.reload({ timeout: 5000 }) + cy.get(".nav-item", { timeout: 2000 }).should("contain", queryRename) }) it("should delete a query", () => { @@ -247,9 +250,9 @@ filterTests(["all"], () => { cy.get(".spectrum-Button") .contains("Delete Query") .click({ force: true }) - cy.wait(1000) // Confirm deletion - cy.get(".nav-item").should("not.contain", queryName) + cy.reload({ timeout: 5000 }) + cy.get(".nav-item", { timeout: 1000 }).should("not.contain", queryName) }) const switchSchema = schema => { @@ -271,7 +274,7 @@ filterTests(["all"], () => { .click({ force: true }) }) cy.reload() - cy.wait(5000) + cy.wait(1000) } } }) diff --git a/packages/builder/cypress/integration/datasources/rest.spec.js b/packages/builder/cypress/integration/datasources/rest.spec.js index a15487c01..488c30c0c 100644 --- a/packages/builder/cypress/integration/datasources/rest.spec.js +++ b/packages/builder/cypress/integration/datasources/rest.spec.js @@ -14,8 +14,7 @@ filterTests(["smoke", "all"], () => { // Select REST data source cy.selectExternalDatasource(datasource) // Enter incorrect api & attempt to send query - cy.wait(500) - cy.get(".spectrum-Button").contains("Add query").click({ force: true }) + cy.get(".spectrum-Button", { timeout: 500 }).contains("Add query").click({ force: true }) cy.intercept("**/preview").as("queryError") cy.get("input").clear().type("random text") cy.get(".spectrum-Button").contains("Send").click({ force: true }) @@ -36,8 +35,7 @@ filterTests(["smoke", "all"], () => { // createRestQuery confirms query creation cy.createRestQuery("GET", restUrl, "/breweries") // Confirm status code response within REST datasource - cy.wait(1000) - cy.get(".stats").within(() => { + cy.get(".stats", { timeout: 1000 }).within(() => { cy.get(".spectrum-FieldLabel") .eq(0) .should("contain", 200) diff --git a/packages/builder/cypress/integration/renameAnApplication.spec.js b/packages/builder/cypress/integration/renameAnApplication.spec.js index 703535a50..4460750b0 100644 --- a/packages/builder/cypress/integration/renameAnApplication.spec.js +++ b/packages/builder/cypress/integration/renameAnApplication.spec.js @@ -12,17 +12,14 @@ filterTests(["all"], () => { const appName = "Cypress Tests" const appRename = "Cypress Renamed" // Rename app, Search for app, Confirm name was changed - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) renameApp(appName, appRename) cy.reload() - cy.wait(1000) cy.searchForApplication(appRename) cy.get(interact.APP_TABLE).find(interact.TITLE).should("have.length", 1) cy.applicationInAppTable(appRename) // Set app name back to Cypress Tests cy.reload() - cy.wait(1000) renameApp(appRename, appName) }) @@ -42,8 +39,7 @@ filterTests(["all"], () => { .click({ force: true }) }) // Rename app, Search for app, Confirm name was changed - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) renameApp(appName, appRename, true) cy.get(interact.APP_TABLE).find(interact.WRAPPER).should("have.length", 1) cy.applicationInAppTable(appRename) @@ -51,23 +47,18 @@ filterTests(["all"], () => { it("Should try to rename an application to have no name", () => { const appName = "Cypress Tests" - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) renameApp(appName, " ", false, true) - cy.wait(500) // Close modal and confirm name has not been changed - cy.get(interact.SPECTRUM_DIALOG_GRID).contains("Cancel").click() - cy.reload() - cy.wait(1000) + cy.get(interact.SPECTRUM_DIALOG_GRID, { timeout: 1000 }).contains("Cancel").click() cy.applicationInAppTable(appName) }) xit("Should create two applications with the same name", () => { // It is not possible to have applications with the same name const appName = "Cypress Tests" - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) - cy.get(interact.SPECTRUM_BUTTON) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) + cy.get(interact.SPECTRUM_BUTTON), { timeout: 500 } .contains("Create app") .click({ force: true }) cy.contains(/Start from scratch/).click() @@ -89,28 +80,21 @@ filterTests(["all"], () => { const appName = "Cypress Tests" const numberName = 12345 const specialCharName = "£$%^" - cy.visit(`${Cypress.config().baseUrl}/builder`) - cy.wait(500) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) renameApp(appName, numberName) - cy.reload() - cy.wait(1000) cy.applicationInAppTable(numberName) - cy.reload() - cy.wait(1000) renameApp(numberName, specialCharName) cy.get(interact.ERROR).should( "have.text", "App name must be letters, numbers and spaces only" ) // Set app name back to Cypress Tests - cy.reload() - cy.wait(1000) renameApp(numberName, appName) }) const renameApp = (originalName, changedName, published, noName) => { cy.searchForApplication(originalName) - cy.get(interact.APP_TABLE).within(() => { + cy.get(interact.APP_TABLE, { timeout: 1000 }).within(() => { cy.get(".app-row-actions button") .contains("Manage") .eq(0) diff --git a/packages/builder/cypress/integration/revertApp.spec.js b/packages/builder/cypress/integration/revertApp.spec.js index 93501ab97..4c6f245b7 100644 --- a/packages/builder/cypress/integration/revertApp.spec.js +++ b/packages/builder/cypress/integration/revertApp.spec.js @@ -36,8 +36,8 @@ filterTests(['smoke', 'all'], () => { cy.get(interact.SPECTRUM_BUTTON_GROUP).within(() => { cy.get(interact.SPECTRUM_BUTTON).contains("Publish").click({ force: true }) }) - cy.wait(1000) - cy.get(interact.SPECTRUM_BUTTON_GROUP).within(() => { + cy.wait(1000) // Wait for next modal to finish loading + cy.get(interact.SPECTRUM_BUTTON_GROUP, { timeout: 1000 }).within(() => { cy.get(interact.SPECTRUM_BUTTON).contains("Done").click({ force: true }) }) @@ -50,18 +50,17 @@ filterTests(['smoke', 'all'], () => { cy.get(interact.SPECTRUM_DIALOG_GRID).within(() => { // Click Revert cy.get(interact.SPECTRUM_BUTTON).contains("Revert").click({ force: true }) - cy.wait(1000) + cy.wait(2000) // Wait for app to finish reverting }) // Confirm Paragraph component is still visible - cy.get(interact.ROOT).contains("New Paragraph") + cy.get(interact.ROOT, { timeout: 1000 }).contains("New Paragraph") // Confirm Button component is not visible - cy.get(interact.ROOT).should("not.have.text", "New Button") - cy.wait(500) + cy.get(interact.ROOT, { timeout: 1000 }).should("not.have.text", "New Button") }) it("should enter incorrect app name when reverting", () => { // Click Revert - cy.get(interact.TOP_RIGHT_NAV).within(() => { + cy.get(interact.TOP_RIGHT_NAV, { timeout: 1000 }).within(() => { cy.get(interact.AREA_LABEL_REVERT).click({ force: true }) }) // Enter incorrect app name diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index e638eb6cb..b2ab8e678 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -3,7 +3,7 @@ Cypress.on("uncaught:exception", () => { }) // ACCOUNTS & USERS -Cypress.Commands.add("login", () => { +Cypress.Commands.add("login", (email, password) => { cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(2000) cy.url().then(url => { @@ -17,8 +17,13 @@ Cypress.Commands.add("login", () => { if (url.includes("builder/auth/login") || url.includes("builder/admin")) { // login cy.contains("Sign in to Budibase").then(() => { - cy.get("input").first().type("test@test.com") - cy.get('input[type="password"]').type("test") + if (email == null) { + cy.get("input").first().type("test@test.com") + cy.get('input[type="password"]').type("test") + } else { + cy.get("input").first().type(email) + cy.get('input[type="password"]').type(password) + } cy.get("button").first().click({ force: true }) cy.wait(1000) }) @@ -27,7 +32,7 @@ Cypress.Commands.add("login", () => { }) Cypress.Commands.add("logOut", () => { - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 2000 }) cy.get(".user-dropdown .avatar > .icon").click({ force: true }) cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => { cy.get("li[data-cy='user-logout']").click({ force: true }) @@ -35,24 +40,58 @@ Cypress.Commands.add("logOut", () => { cy.wait(2000) }) +Cypress.Commands.add("logoutNoAppGrid", () => { + // Logs user out when app grid is not present + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.get(".avatar > .icon").click({ force: true }) + cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => { + cy.get(".spectrum-Menu-item").contains("Log out").click({ force: true }) + }) + cy.wait(2000) +}) + Cypress.Commands.add("createUser", email => { // quick hacky recorded way to create a user cy.contains("Users").click() cy.get(`[data-cy="add-user"]`).click() - cy.get(".spectrum-Picker-label").click() - cy.get(".spectrum-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel").click() + cy.get(".spectrum-Dialog-grid").within(() => { + cy.get(".spectrum-Picker-label").click() + cy.get( + ".spectrum-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel" + ).click() - //Onboarding type selector - cy.get( - ":nth-child(2) > .spectrum-Form-itemField > .spectrum-Textfield > .spectrum-Textfield-input" - ) - .first() - .type(email, { force: true }) - cy.get(".spectrum-Button--cta").click({ force: true }) + // Onboarding type selector + cy.get(".spectrum-Textfield-input") + .eq(0) + .first() + .type(email, { force: true }) + cy.get(".spectrum-Button--cta").click({ force: true }) + }) +}) + +Cypress.Commands.add("deleteUser", email => { + // Assumes user has access to Users section + cy.contains("Users", { timeout: 2000 }).click() + cy.contains(email).click() + + // Click Delete user button + cy.get(".spectrum-Button") + .contains("Delete user") + .click({ force: true }) + .then(() => { + // Confirm deletion within modal + cy.get(".spectrum-Dialog-grid", { timeout: 500 }).within(() => { + cy.get(".spectrum-Button") + .contains("Delete user") + .click({ force: true }) + }) + }) }) Cypress.Commands.add("updateUserInformation", (firstName, lastName) => { - cy.get(".user-dropdown .avatar > .icon").click({ force: true }) + cy.get(".user-dropdown .avatar > .icon", { timeout: 2000 }).click({ + force: true, + }) cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => { cy.get("li[data-cy='user-info']").click({ force: true }) @@ -95,16 +134,18 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => { const shouldCreateDefaultTable = typeof addDefaultTable != "boolean" ? true : addDefaultTable - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) cy.wait(1000) - cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) + cy.get(`[data-cy="create-app-btn"]`, { timeout: 5000 }).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(`[data-cy="create-app-btn"]`, { timeout: 5000 }).click({ + force: true, + }) } }) @@ -117,7 +158,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => { cy.get(".spectrum-ButtonGroup") .contains("Create app") .click({ force: true }) - cy.wait(10000) + cy.wait(2000) }) if (shouldCreateDefaultTable) { cy.createTable("Cypress Tests", true) @@ -125,7 +166,7 @@ Cypress.Commands.add("createApp", (name, addDefaultTable) => { }) Cypress.Commands.add("deleteApp", name => { - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) cy.wait(2000) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) .its("body") @@ -150,14 +191,15 @@ Cypress.Commands.add("deleteApp", name => { cy.get(actionEleId).within(() => { cy.contains("Manage").click({ force: true }) }) - cy.wait(1000) + cy.wait(500) // Unpublish first if needed cy.get(`[data-cy="app-status"]`).then($status => { - if ($status.text().includes("Last published")) { - cy.contains("Unpublish").click() + if ($status.text().includes("- Unpublish")) { + // Exact match for Unpublish + cy.contains("Unpublish").click({ force: true }) cy.get(".spectrum-Modal").within(() => { - cy.contains("Unpublish app").click() + cy.contains("Unpublish app").click({ force: true }) }) } }) @@ -276,16 +318,18 @@ Cypress.Commands.add("alterAppVersion", (appId, version) => { }) Cypress.Commands.add("importApp", (exportFilePath, name) => { - cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 5000 }) 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.wait(500) } - cy.get(`[data-cy="import-app-btn"]`).click({ force: true }) + cy.wait(500) + cy.get(`[data-cy="import-app-btn"]`).click({ + force: true, + }) }) cy.get(".spectrum-Modal").within(() => { @@ -303,7 +347,7 @@ Cypress.Commands.add("importApp", (exportFilePath, name) => { cy.get(".confirm-wrap button") .should("not.be.disabled") .click({ force: true }) - cy.wait(5000) + cy.wait(3000) }) }) @@ -332,7 +376,8 @@ Cypress.Commands.add("searchForApplication", appName => { // Assumes there are no others Cypress.Commands.add("applicationInAppTable", appName => { - cy.get(".appTable").within(() => { + cy.visit(`${Cypress.config().baseUrl}/builder`, { timeout: 10000 }) + cy.get(".appTable", { timeout: 2000 }).within(() => { cy.get(".title").contains(appName).should("exist") }) }) @@ -358,18 +403,19 @@ Cypress.Commands.add("createAppFromScratch", appName => { Cypress.Commands.add("createTable", (tableName, initialTable) => { if (!initialTable) { cy.navigateToDataSection() - cy.get(`[data-cy="new-table"]`).click() + cy.get(`[data-cy="new-table"]`, { timeout: 2000 }).click() } - cy.wait(5000) - cy.get(".spectrum-Dialog-grid") + cy.wait(2000) + cy.get(".item", { timeout: 2000 }) .contains("Budibase DB") .click({ force: true }) .then(() => { - cy.get(".spectrum-Button").contains("Continue").click({ force: true }) + cy.get(".spectrum-Button", { timeout: 2000 }) + .contains("Continue") + .click({ force: true }) }) - cy.get(".spectrum-Modal").within(() => { - cy.wait(1000) - cy.get("input").first().type(tableName).blur() + cy.get(".spectrum-Modal", { timeout: 2000 }).within(() => { + cy.get("input", { timeout: 2000 }).first().type(tableName).blur() cy.get(".spectrum-ButtonGroup").contains("Create").click() }) cy.contains(tableName).should("be.visible") @@ -451,8 +497,7 @@ Cypress.Commands.add("addCustomSourceOptions", totalOptions => { .contains("Add Option") .click({ force: true }) .then(() => { - cy.wait(500) - cy.get("[placeholder='Label']").eq(i).type(i) + cy.get("[placeholder='Label']", { timeout: 500 }).eq(i).type(i) cy.get("[placeholder='Value']").eq(i).type(i) }) } @@ -464,16 +509,21 @@ Cypress.Commands.add("addCustomSourceOptions", totalOptions => { // DESIGN AREA Cypress.Commands.add("addComponent", (category, component) => { if (category) { - cy.get(`[data-cy="category-${category}"]`).click({ force: true }) + cy.get(`[data-cy="category-${category}"]`, { timeout: 3000 }).click({ + force: true, + }) } + cy.wait(500) if (component) { - cy.get(`[data-cy="component-${component}"]`).click({ force: true }) + cy.get(`[data-cy="component-${component}"]`, { timeout: 3000 }).click({ + force: true, + }) } cy.wait(1000) cy.location().then(loc => { const params = loc.pathname.split("/") const componentId = params[params.length - 1] - cy.getComponent(componentId).should("exist") + cy.getComponent(componentId, { timeout: 3000 }).should("exist") return cy.wrap(componentId) }) }) @@ -496,15 +546,14 @@ Cypress.Commands.add("createScreen", (route, accessLevelLabel) => { cy.get(".spectrum-Modal").within(() => { cy.get("[data-cy='blank-screen']").click() cy.get(".spectrum-Button").contains("Continue").click({ force: true }) - cy.wait(500) }) - cy.get(".spectrum-Dialog-grid").within(() => { + cy.wait(500) + cy.get(".spectrum-Dialog-grid", { timeout: 500 }).within(() => { cy.get(".spectrum-Form-itemField").eq(0).type(route) - cy.get(".spectrum-Button").contains("Continue").click({ force: true }) - cy.wait(1000) + cy.get(".confirm-wrap").contains("Continue").click({ force: true }) }) - cy.get(".spectrum-Modal").within(() => { + cy.get(".spectrum-Modal", { timeout: 1000 }).within(() => { if (accessLevelLabel) { cy.get(".spectrum-Picker-label").click() cy.wait(500) @@ -522,10 +571,12 @@ Cypress.Commands.add( 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(() => { + cy.get(".spectrum-Modal [data-cy='data-source-modal']", { + timeout: 500, + }).within(() => { for (let i = 0; i < datasourceNames.length; i++) { + cy.wait(500) cy.get(".data-source-entry").contains(datasourceNames[i]).click() //Ensure the check mark is visible cy.get(".data-source-entry") @@ -574,10 +625,10 @@ Cypress.Commands.add( // NAVIGATION Cypress.Commands.add("navigateToFrontend", () => { // Clicks on Design tab and then the Home nav item - cy.wait(1000) + cy.wait(500) cy.contains("Design").click() - cy.get(".spectrum-Search").type("/") - cy.get(".nav-item").contains("home").click() + cy.get(".spectrum-Search", { timeout: 2000 }).type("/") + cy.get(".nav-item", { timeout: 2000 }).contains("home").click() }) Cypress.Commands.add("navigateToDataSection", () => { @@ -606,11 +657,11 @@ Cypress.Commands.add("selectExternalDatasource", datasourceName => { cy.get(".add-button").click() }) // Clicks specified datasource & continue - cy.wait(1000) - cy.get(".item-list").contains(datasourceName).click() + cy.get(".item-list", { timeout: 1000 }).contains(datasourceName).click() cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Button").contains("Continue").click({ force: true }) }) + cy.wait(500) }) Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => { @@ -618,8 +669,7 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => { // Adds the config for specified datasource & fetches tables // Currently supports MySQL, PostgreSQL, Oracle // Host IP Address - cy.wait(500) - cy.get(".spectrum-Dialog-grid").within(() => { + cy.get(".spectrum-Dialog-grid", { timeout: 500 }).within(() => { cy.get(".form-row") .eq(0) .within(() => { @@ -719,16 +769,18 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => { Cypress.Commands.add("createRestQuery", (method, restUrl, queryPrettyName) => { // addExternalDatasource should be called prior to this // Configures REST datasource & sends query - cy.wait(1000) - cy.get(".spectrum-Button").contains("Add query").click({ force: true }) + cy.get(".spectrum-Button", { timeout: 1000 }) + .contains("Add query") + .click({ force: true }) // Select Method & add Rest URL cy.get(".spectrum-Picker-label").eq(1).click() cy.get(".spectrum-Menu").contains(method).click() cy.get("input").clear().type(restUrl) // Send query cy.get(".spectrum-Button").contains("Send").click({ force: true }) - cy.wait(500) - cy.get(".spectrum-Button").contains("Save").click({ force: true }) + cy.get(".spectrum-Button", { timeout: 500 }) + .contains("Save") + .click({ force: true }) cy.get(".hierarchy-items-container") .should("contain", method) .and("contain", queryPrettyName) @@ -736,7 +788,7 @@ Cypress.Commands.add("createRestQuery", (method, restUrl, queryPrettyName) => { // MISC Cypress.Commands.add("closeModal", () => { - cy.get(".spectrum-Modal").within(() => { + cy.get(".spectrum-Modal", { timeout: 2000 }).within(() => { cy.get(".close-icon").click() cy.wait(1000) // Wait for modal to close }) diff --git a/packages/builder/cypress/support/interact.js b/packages/builder/cypress/support/interact.js index f0fae6166..0b31d8a8c 100644 --- a/packages/builder/cypress/support/interact.js +++ b/packages/builder/cypress/support/interact.js @@ -62,6 +62,7 @@ export const GLOBESTRIKE = "svg[aria-label=GlobeStrike]" export const GLOBE = "svg[aria-label=Globe]" export const UNPUBLISH_MODAL = "[data-cy=unpublish-modal]" export const CONFIRM_WRAP_BUTTON = ".confirm-wrap button" +export const DEPLOYMENT_TOP_NAV = ".deployment-top-nav" //changeAppiconAndColour export const APP_ROW_ACTION = ".app-row-actions-icon" @@ -97,13 +98,16 @@ export const ACTION_SPECTRUM_ICON = ".actions .spectrum-Icon" export const SPECTRUM_MENU_CHILD2 = ".spectrum-Menu > :nth-child(2)" export const DELETE_TABLE_CONFIRM = '[data-cy="delete-table-confirm"]' -//createUSerAndRoles +//adminAndManagement Folder export const SPECTRUM_TABLE = ".spectrum-Table" export const SPECTRUM_SIDENAV = ".spectrum-SideNav" export const SPECTRUM_TABLE_ROW = ".spectrum-Table-row" export const SPECTRUM_TABLE_CELL = ".spectrum-Table-cell" export const FIELD = ".field" export const CONTAINER = ".container" +export const REGENERATE = ".regenerate" +export const SPECTRUM_DIALOG_CONTENT = ".spectrum-Dialog-content" +export const SPECTRUM_ICON = ".spectrum-Icon" //createView export const SPECTRUM_MENU_ITEM_LABEL = ".spectrum-Menu-itemLabel" @@ -113,14 +117,17 @@ export const TOP_RIGHT_NAV = ".toprightnav" export const AREA_LABEL_REVERT = "[aria-label=Revert]" export const ROOT = ".root" -//quertLevelTransformers +//queryLevelTransformers export const SPECTRUM_TABS_ITEM = ".spectrum-Tabs-itemLabel" export const CODEMIRROR_TEXTAREA = ".CodeMirror textarea" -//renemaApplication +//renameApplication export const WRAPPER = ".wrapper" export const ERROR = ".error" export const AREA_LABEL_MORE = "[aria-label=More]" export const APP_ROW_ACTION_MENU_POPOVER = '[data-cy="app-row-actions-menu-popover"]' -export const SPECTRUM_MENU_ITEMM = ".spectrum-Menu-item" +export const SPECTRUM_MENU_ITEM = ".spectrum-Menu-item" + +//commands +export const HOME_LOGO = ".home-logo" diff --git a/packages/builder/package.json b/packages/builder/package.json index dea257c12..e22d58f25 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.0.207-alpha.8", + "version": "1.0.220-alpha.4", "license": "GPL-3.0", "private": true, "scripts": { @@ -69,14 +69,15 @@ } }, "dependencies": { - "@budibase/bbui": "^1.0.207-alpha.8", - "@budibase/client": "^1.0.207-alpha.8", - "@budibase/frontend-core": "^1.0.207-alpha.8", - "@budibase/string-templates": "^1.0.207-alpha.8", + "@budibase/bbui": "^1.0.220-alpha.4", + "@budibase/client": "^1.0.220-alpha.4", + "@budibase/frontend-core": "^1.0.220-alpha.4", + "@budibase/string-templates": "^1.0.220-alpha.4", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", "codemirror": "^5.59.0", + "dayjs": "^1.11.2", "downloadjs": "1.4.7", "lodash": "4.17.21", "posthog-js": "1.4.5", @@ -94,7 +95,7 @@ "@babel/preset-env": "^7.13.12", "@babel/runtime": "^7.13.10", "@rollup/plugin-replace": "^2.4.2", - "@roxi/routify": "2.18.0", + "@roxi/routify": "2.18.5", "@sveltejs/vite-plugin-svelte": "1.0.0-next.19", "@testing-library/jest-dom": "^5.11.10", "@testing-library/svelte": "^3.0.0", @@ -112,7 +113,7 @@ "rollup": "^2.44.0", "rollup-plugin-copy": "^3.4.0", "start-server-and-test": "^1.12.1", - "svelte": "^3.38.2", + "svelte": "^3.48.0", "svelte-jester": "^1.3.2", "ts-node": "^10.4.0", "tsconfig-paths": "4.0.0", diff --git a/packages/builder/src/analytics/IntercomClient.js b/packages/builder/src/analytics/IntercomClient.js index d4835d979..6d5bf9e93 100644 --- a/packages/builder/src/analytics/IntercomClient.js +++ b/packages/builder/src/analytics/IntercomClient.js @@ -53,7 +53,7 @@ export default class IntercomClient { * @returns Intercom global object */ show(user = {}) { - if (!this.initialised) return + if (!this.initialised || !user?.admin) return return window.Intercom("boot", { app_id: this.token, diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 8cbc62929..bebd06c6d 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -20,7 +20,7 @@ import { } from "@budibase/string-templates" import { TableNames } from "../constants" import { JSONUtils } from "@budibase/frontend-core" -import ActionDefinitions from "components/design/PropertiesPanel/PropertyControls/ButtonActionEditor/manifest.json" +import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json" // Regex to match all instances of template strings const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g @@ -49,6 +49,95 @@ export const getBindableProperties = (asset, componentId) => { ] } +/** + * Gets all rest bindable data fields + */ +export const getRestBindings = () => { + const userBindings = getUserBindings() + return [...userBindings, ...getAuthBindings()] +} + +/** + * Gets all rest bindable auth fields + */ +export const getAuthBindings = () => { + let bindings = [] + const safeUser = makePropSafe("user") + const safeOAuth2 = makePropSafe("oauth2") + const safeAccessToken = makePropSafe("accessToken") + + const authBindings = [ + { + runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`, + readable: `Current User.OAuthToken`, + key: "accessToken", + }, + ] + + bindings = Object.keys(authBindings).map(key => { + const fieldBinding = authBindings[key] + return { + type: "context", + runtimeBinding: fieldBinding.runtime, + readableBinding: fieldBinding.readable, + fieldSchema: { type: "string", name: fieldBinding.key }, + providerId: "user", + } + }) + return bindings +} + +/** + * Utility - convert a key/value map to an array of custom 'context' bindings + * @param {object} valueMap Key/value pairings + * @param {string} prefix A contextual string prefix/path for a user readable binding + * @return {object[]} An array containing readable/runtime binding objects + */ +export const toBindingsArray = (valueMap, prefix) => { + if (!valueMap) { + return [] + } + return Object.keys(valueMap).reduce((acc, binding) => { + if (!binding || !valueMap[binding]) { + return acc + } + acc.push({ + type: "context", + runtimeBinding: binding, + readableBinding: `${prefix}.${binding}`, + }) + return acc + }, []) +} + +/** + * Utility - coverting a map of readable bindings to runtime + */ +export const readableToRuntimeMap = (bindings, ctx) => { + if (!bindings || !ctx) { + return {} + } + return Object.keys(ctx).reduce((acc, key) => { + let parsedQuery = readableToRuntimeBinding(bindings, ctx[key]) + acc[key] = parsedQuery + return acc + }, {}) +} + +/** + * Utility - coverting a map of runtime bindings to readable + */ +export const runtimeToReadableMap = (bindings, ctx) => { + if (!bindings || !ctx) { + return {} + } + return Object.keys(ctx).reduce((acc, key) => { + let parsedQuery = runtimeToReadableBinding(bindings, ctx[key]) + acc[key] = parsedQuery + return acc + }, {}) +} + /** * Gets the bindable properties exposed by a certain component. */ @@ -298,7 +387,6 @@ const getUserBindings = () => { providerId: "user", }) }) - return bindings } @@ -390,11 +478,17 @@ const getUrlBindings = asset => { } }) const safeURL = makePropSafe("url") - return params.map(param => ({ + const urlParamBindings = params.map(param => ({ type: "context", runtimeBinding: `${safeURL}.${makePropSafe(param)}`, readableBinding: `URL.${param}`, })) + const queryParamsBinding = { + type: "context", + runtimeBinding: makePropSafe("query"), + readableBinding: "Query params", + } + return urlParamBindings.concat([queryParamsBinding]) } const getRoleBindings = () => { @@ -694,6 +788,13 @@ export const getAllStateVariables = () => { }) }) + // Add on load settings from screens + get(store).screens.forEach(screen => { + if (screen.onLoad) { + eventSettings.push(screen.onLoad) + } + }) + // Extract all state keys from any "update state" actions in each setting let bindingSet = new Set() eventSettings.forEach(setting => { diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index 619bdd94a..28ef1f437 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -1,65 +1,77 @@ import { getFrontendStore } from "./store/frontend" import { getAutomationStore } from "./store/automation" import { getThemeStore } from "./store/theme" -import { derived, writable } from "svelte/store" -import { FrontendTypes, LAYOUT_NAMES } from "../constants" +import { derived } from "svelte/store" +import { LAYOUT_NAMES } from "../constants" import { findComponent, findComponentPath } from "./componentUtils" +import { RoleUtils } from "@budibase/frontend-core" export const store = getFrontendStore() export const automationStore = getAutomationStore() export const themeStore = getThemeStore() -export const currentAsset = derived(store, $store => { - const type = $store.currentFrontEndType - if (type === FrontendTypes.SCREEN) { - return $store.screens.find(screen => screen._id === $store.selectedScreenId) - } else if (type === FrontendTypes.LAYOUT) { - return $store.layouts.find(layout => layout._id === $store.selectedLayoutId) - } - return null +export const selectedScreen = derived(store, $store => { + return $store.screens.find(screen => screen._id === $store.selectedScreenId) +}) + +export const selectedLayout = derived(store, $store => { + return $store.layouts?.find(layout => layout._id === $store.selectedLayoutId) }) export const selectedComponent = derived( - [store, currentAsset], - ([$store, $currentAsset]) => { - if (!$currentAsset || !$store.selectedComponentId) { + [store, selectedScreen], + ([$store, $selectedScreen]) => { + if (!$selectedScreen || !$store.selectedComponentId) { return null } - return findComponent($currentAsset?.props, $store.selectedComponentId) + return findComponent($selectedScreen?.props, $store.selectedComponentId) } ) +export const sortedScreens = derived(store, $store => { + return $store.screens.slice().sort((a, b) => { + // Sort by role first + const roleA = RoleUtils.getRolePriority(a.routing.roleId) + const roleB = RoleUtils.getRolePriority(b.routing.roleId) + if (roleA !== roleB) { + return roleA > roleB ? -1 : 1 + } + // Then put home screens first + const homeA = !!a.routing.homeScreen + const homeB = !!b.routing.homeScreen + if (homeA !== homeB) { + return homeA ? -1 : 1 + } + // Then sort alphabetically by each URL param + const aParams = a.routing.route.split("/") + const bParams = b.routing.route.split("/") + let minParams = Math.min(aParams.length, bParams.length) + for (let i = 0; i < minParams; i++) { + if (aParams[i] === bParams[i]) { + continue + } + return aParams[i] < bParams[i] ? -1 : 1 + } + // Then sort by the fewest amount of URL params + return aParams.length < bParams.length ? -1 : 1 + }) +}) + export const selectedComponentPath = derived( - [store, currentAsset], - ([$store, $currentAsset]) => { + [store, selectedScreen], + ([$store, $selectedScreen]) => { return findComponentPath( - $currentAsset?.props, + $selectedScreen?.props, $store.selectedComponentId ).map(component => component._id) } ) -export const currentAssetId = derived(store, $store => { - return $store.currentFrontEndType === FrontendTypes.SCREEN - ? $store.selectedScreenId - : $store.selectedLayoutId -}) - -export const currentAssetName = derived(currentAsset, $currentAsset => { - return $currentAsset?.name -}) - -// leave this as before for consistency -export const allScreens = derived(store, $store => { - return $store.screens -}) - export const mainLayout = derived(store, $store => { return $store.layouts?.find( layout => layout._id === LAYOUT_NAMES.MASTER.PRIVATE ) }) -export const selectedAccessRole = writable("BASIC") - -export const screenSearchString = writable(null) +// For compatibility +export const currentAsset = selectedScreen diff --git a/packages/builder/src/builderStore/store/automation/index.js b/packages/builder/src/builderStore/store/automation/index.js index cf42492c0..af102ab69 100644 --- a/packages/builder/src/builderStore/store/automation/index.js +++ b/packages/builder/src/builderStore/store/automation/index.js @@ -5,6 +5,7 @@ import { cloneDeep } from "lodash/fp" const initialAutomationState = { automations: [], + showTestPanel: false, blockDefinitions: { TRIGGER: [], ACTION: [], @@ -19,6 +20,17 @@ export const getAutomationStore = () => { } const automationActions = store => ({ + definitions: async () => { + const response = await API.getAutomationDefinitions() + store.update(state => { + state.blockDefinitions = { + TRIGGER: response.trigger, + ACTION: response.action, + } + return state + }) + return response + }, fetch: async () => { const responses = await Promise.all([ API.getAutomations(), @@ -56,7 +68,19 @@ const automationActions = store => ({ return state }) }, - + duplicate: async automation => { + const response = await API.createAutomation({ + ...automation, + name: `${automation.name} - copy`, + _id: undefined, + _ref: undefined, + }) + store.update(state => { + state.automations = [...state.automations, response.automation] + store.actions.select(response.automation) + return state + }) + }, save: async automation => { const response = await API.updateAutomation(automation) store.update(state => { @@ -109,6 +133,20 @@ const automationActions = store => ({ return state }) }, + getLogs: async ({ automationId, startDate, status, page } = {}) => { + return await API.getAutomationLogs({ + automationId, + startDate, + status, + page, + }) + }, + clearLogErrors: async ({ automationId, appId } = {}) => { + return await API.clearAutomationLogErrors({ + automationId, + appId, + }) + }, addTestDataToAutomation: data => { store.update(state => { state.selectedAutomation.addTestData(data) @@ -117,11 +155,10 @@ const automationActions = store => ({ }, addBlockToAutomation: (block, blockIdx) => { store.update(state => { - const newBlock = state.selectedAutomation.addBlock( + state.selectedBlock = state.selectedAutomation.addBlock( cloneDeep(block), blockIdx ) - state.selectedBlock = newBlock return state }) }, diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index ec810e5c3..16ae5ce21 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -1,12 +1,6 @@ import { get, writable } from "svelte/store" import { cloneDeep } from "lodash/fp" -import { - allScreens, - currentAsset, - mainLayout, - selectedComponent, - selectedAccessRole, -} from "builderStore" +import { currentAsset, mainLayout, selectedComponent } from "builderStore" import { datasources, integrations, @@ -15,7 +9,6 @@ import { tables, } from "stores/backend" import { API } from "api" -import { FrontendTypes } from "constants" import analytics, { Events } from "analytics" import { findComponentType, @@ -27,6 +20,7 @@ import { makeComponentUnique, } from "../componentUtils" import { Helpers } from "@budibase/bbui" +import { DefaultAppTheme, LAYOUT_NAMES } from "../../constants" const INITIAL_FRONTEND_STATE = { apps: [], @@ -47,10 +41,6 @@ const INITIAL_FRONTEND_STATE = { messagePassing: false, continueIfAction: false, }, - currentFrontEndType: "none", - selectedScreenId: "", - selectedLayoutId: "", - selectedComponentId: "", errors: [], hasAppPackage: false, libraries: null, @@ -61,6 +51,11 @@ const INITIAL_FRONTEND_STATE = { customTheme: {}, previewDevice: "desktop", highlightedSettingKey: null, + + // URL params + selectedScreenId: null, + selectedComponentId: null, + selectedLayoutId: null, } export const getFrontendStore = () => { @@ -100,6 +95,7 @@ export const getFrontendStore = () => { previousTopNavPath: {}, version: application.version, revertableVersion: application.revertableVersion, + navigation: application.navigation || {}, })) // Initialise backend stores @@ -108,6 +104,35 @@ export const getFrontendStore = () => { await integrations.init() await queries.init() await tables.init() + + // Add navigation settings to old apps + if (!application.navigation) { + const layout = layouts.find(x => x._id === LAYOUT_NAMES.MASTER.PRIVATE) + const customTheme = application.customTheme + let navigationSettings = { + navigation: "Top", + title: application.name, + navWidth: "Large", + navBackground: + customTheme?.navBackground || DefaultAppTheme.navBackground, + navTextColor: + customTheme?.navTextColor || DefaultAppTheme.navTextColor, + } + if (layout) { + navigationSettings.hideLogo = layout.props.hideLogo + navigationSettings.hideTitle = layout.props.hideTitle + navigationSettings.title = layout.props.title || application.name + navigationSettings.logoUrl = layout.props.logoUrl + navigationSettings.links = layout.props.links + navigationSettings.navigation = layout.props.navigation || "Top" + navigationSettings.sticky = layout.props.sticky + navigationSettings.navWidth = layout.props.width || "Large" + if (navigationSettings.navigation === "None") { + navigationSettings.navigation = "Top" + } + } + await store.actions.navigation.save(navigationSettings) + } }, theme: { save: async theme => { @@ -135,6 +160,19 @@ export const getFrontendStore = () => { }) }, }, + navigation: { + save: async navigation => { + const appId = get(store).appId + await API.saveAppMetadata({ + appId, + metadata: { navigation }, + }) + store.update(state => { + state.navigation = navigation + return state + }) + }, + }, routing: { fetch: async () => { const response = await API.fetchAppRoutes() @@ -147,18 +185,12 @@ export const getFrontendStore = () => { screens: { select: screenId => { store.update(state => { - let screens = get(allScreens) + let screens = state.screens let screen = screens.find(screen => screen._id === screenId) || screens[0] if (!screen) return state - // Update role to the screen's role setting so that it will always - // be visible - selectedAccessRole.set(screen.routing.roleId) - - state.currentFrontEndType = FrontendTypes.SCREEN state.selectedScreenId = screen._id - state.currentView = "detail" state.selectedComponentId = screen.props?._id return state }) @@ -221,16 +253,44 @@ export const getFrontendStore = () => { // Refresh routes await store.actions.routing.fetch() }, + updateHomeScreen: async (screen, makeHomeScreen = true) => { + let promises = [] + + // Find any existing home screen for this role so we can remove it, + // if we are setting this to be the new home screen + if (makeHomeScreen) { + const roleId = screen.routing.roleId + let existingHomeScreen = get(store).screens.find(s => { + return ( + s.routing.roleId === roleId && + s.routing.homeScreen && + s._id !== screen._id + ) + }) + if (existingHomeScreen) { + existingHomeScreen.routing.homeScreen = false + promises.push(store.actions.screens.save(existingHomeScreen)) + } + } + + // Update the passed in screen + screen.routing.homeScreen = makeHomeScreen + promises.push(store.actions.screens.save(screen)) + return await Promise.all(promises) + }, + removeCustomLayout: async screen => { + // Pull relevant settings from old layout, if required + const layout = get(store).layouts.find(x => x._id === screen.layoutId) + screen.layoutId = null + screen.showNavigation = layout?.props.navigation !== "None" + screen.width = layout?.props.width || "Large" + await store.actions.screens.save(screen) + }, }, preview: { saveSelected: async () => { - const state = get(store) const selectedAsset = get(currentAsset) - if (state.currentFrontEndType !== FrontendTypes.LAYOUT) { - return await store.actions.screens.save(selectedAsset) - } else { - return await store.actions.layouts.save(selectedAsset) - } + return await store.actions.screens.save(selectedAsset) }, setDevice: device => { store.update(state => { @@ -245,8 +305,6 @@ export const getFrontendStore = () => { const layout = store.actions.layouts.find(layoutId) || get(store).layouts[0] if (!layout) return - state.currentFrontEndType = FrontendTypes.LAYOUT - state.currentView = "detail" state.selectedLayoutId = layout._id state.selectedComponentId = layout.props?._id return state @@ -297,32 +355,6 @@ export const getFrontendStore = () => { }, }, components: { - select: component => { - const asset = get(currentAsset) - if (!asset || !component) { - return - } - - // If this is the root component, select the asset instead - const parent = findComponentParent(asset.props, component._id) - if (parent == null) { - const state = get(store) - const isLayout = state.currentFrontEndType === FrontendTypes.LAYOUT - if (isLayout) { - store.actions.layouts.select(asset._id) - } else { - store.actions.screens.select(asset._id) - } - return - } - - // Otherwise select the component - store.update(state => { - state.selectedComponentId = component._id - state.currentView = "component" - return state - }) - }, getDefinition: componentName => { if (!componentName) { return null @@ -418,7 +450,6 @@ export const getFrontendStore = () => { // Save components and update UI await store.actions.preview.saveSelected() store.update(state => { - state.currentView = "component" state.selectedComponentId = componentInstance._id return state }) @@ -461,11 +492,14 @@ export const getFrontendStore = () => { parent._children = parent._children.filter( child => child._id !== component._id ) - store.actions.components.select(parent) + store.update(state => { + state.selectedComponentId = parent._id + return state + }) } await store.actions.preview.saveSelected() }, - copy: (component, cut = false) => { + copy: (component, cut = false, selectParent = true) => { const selectedAsset = get(currentAsset) if (!selectedAsset) { return null @@ -485,7 +519,12 @@ export const getFrontendStore = () => { parent._children = parent._children.filter( child => child._id !== component._id ) - store.actions.components.select(parent) + if (selectParent) { + store.update(state => { + state.selectedComponentId = parent._id + return state + }) + } } } }, @@ -536,7 +575,7 @@ export const getFrontendStore = () => { // Save and select the new component promises.push(store.actions.preview.saveSelected()) - store.actions.components.select(componentToPaste) + state.selectedComponentId = componentToPaste._id return state }) await Promise.all(promises) @@ -578,35 +617,38 @@ export const getFrontendStore = () => { }, links: { save: async (url, title) => { - const layout = get(mainLayout) - if (!layout) { + const navigation = get(store).navigation + let links = [...navigation?.links] + + // Skip if we have an identical link + if (links.find(link => link.url === url && link.text === title)) { return } - // Add link setting to main layout - if (!layout.props.links) { - layout.props.links = [] - } - layout.props.links.push({ + links.push({ text: title, url, }) - - await store.actions.layouts.save(layout) + await store.actions.navigation.save({ + ...navigation, + links: [...links], + }) }, delete: async urls => { - const layout = get(mainLayout) - if (!layout?.props.links?.length) { + const navigation = get(store).navigation + let links = navigation?.links + if (!links?.length) { return } // Filter out the URLs to delete urls = Array.isArray(urls) ? urls : [urls] - layout.props.links = layout.props.links.filter( - link => !urls.includes(link.url) - ) + links = links.filter(link => !urls.includes(link.url)) - await store.actions.layouts.save(layout) + await store.actions.navigation.save({ + ...navigation, + links, + }) }, }, settings: { diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js index 272f62716..6fc79e53b 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js @@ -5,7 +5,8 @@ export class Screen extends BaseStructure { constructor() { super(true) this._json = { - layoutId: "layout_private_master", + showNavigation: true, + width: "Large", props: { _id: Helpers.uuid(), _component: "@budibase/standard-components/container", @@ -26,6 +27,7 @@ export class Screen extends BaseStructure { routing: { route: "", roleId: "BASIC", + homeScreen: false, }, name: "screen-id", } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte index 3e58b25ff..9c987c89d 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte @@ -65,7 +65,7 @@ { - $automationStore.selectedAutomation.automation.showTestPanel = true + $automationStore.showTestPanel = true }} size="M">Test Details diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte index a4c41c694..291575f3f 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte @@ -1,6 +1,4 @@
@@ -60,16 +80,13 @@
- {#if showTestStatus && testResult && testResult[0]} + {#if showTestStatus && testResult}
{testResult[0].outputs?.success || isTrigger - ? "Success" - : "Error"}{status?.message}
{/if} diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte index fecd0fcc7..b86cffb1f 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/TestDataModal.svelte @@ -51,7 +51,7 @@ $automationStore.selectedAutomation?.automation, testData ) - $automationStore.selectedAutomation.automation.showTestPanel = true + $automationStore.showTestPanel = true } catch (error) { notifications.error("Error testing notification") } diff --git a/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte new file mode 100644 index 000000000..a2eb904c9 --- /dev/null +++ b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte @@ -0,0 +1,137 @@ + + +
+ {#each blocks as block, idx} +
+ {#if block.stepId !== "LOOP"} + + {#if showParameters && showParameters[block.id]} + + {#if filteredResults?.[idx]?.outputs.iterations} +
+ +
+ +
+
+ {/if} + +
+ + +
+