diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..92bd33894e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +packages/server/node_modules +packages/builder +packages/frontend-core +packages/backend-core +packages/worker/node_modules +packages/cli +packages/client +packages/bbui +packages/string-templates diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 2a57d6f388..bd21123709 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -93,6 +93,8 @@ then `cd ` into your local copy. #### 3. Install and Build +| **NOTE**: On Windows, all yarn commands must be executed on a bash shell (e.g. git bash) + To develop the Budibase platform you'll need [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) installed. ##### Quick method diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d1ba2bc046..b4f7739293 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -7,6 +7,15 @@ assignees: '' --- +**Hosting** + +- Self + - Method: + - Budibase Version: + - App Version: +- Cloud + - Tenant ID: + **Describe the bug** A clear and concise description of what the bug is. diff --git a/.github/stale.yml b/.github/stale.yml index 5875ed1282..2a2c10cb7d 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -14,7 +14,6 @@ staleLabel: stale # Comment to post when marking an issue as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. + recent activity. # Comment to post when closing a stale issue. Set to `false` to disable closeComment: false diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index cb27b30f3f..0881d2528d 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -12,6 +12,11 @@ on: - master - develop +env: + BRANCH: ${{ github.event.pull_request.head.ref }} + BASE_BRANCH: ${{ github.event.pull_request.base.ref}} + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} + jobs: build: runs-on: ubuntu-latest @@ -27,6 +32,10 @@ jobs: uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} + + - name: Install Pro + run: yarn install:pro $BRANCH $BASE_BRANCH + - run: yarn - run: yarn bootstrap - run: yarn lint diff --git a/.github/workflows/release-develop.yml b/.github/workflows/release-develop.yml index 4467cd6c81..87e4f0988a 100644 --- a/.github/workflows/release-develop.yml +++ b/.github/workflows/release-develop.yml @@ -19,6 +19,7 @@ env: POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }} INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }} + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} jobs: release: @@ -29,6 +30,10 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 14.x + + - name: Install Pro + run: yarn install:pro develop + - run: yarn - run: yarn bootstrap - run: yarn lint @@ -46,9 +51,9 @@ jobs: env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - # setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default - git config user.name "Budibase Staging Release Bot" - git config user.email "<>" + # setup the username and email. + git config --global user.name "Budibase Staging Release Bot" + git config --global user.email "<>" echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc yarn release:develop diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 82848c78e4..b3b2b01316 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,7 @@ env: INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }} jobs: release: @@ -30,6 +31,10 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 14.x + + - name: Install Pro + run: yarn install:pro master + - run: yarn - run: yarn bootstrap - run: yarn lint @@ -66,3 +71,57 @@ jobs: DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }} + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: eu-west-1 + + - name: Tag and release Proxy service docker image + run: | + docker login -u $DOCKER_USER -p $DOCKER_PASSWORD + yarn build:docker:proxy:preprod + docker tag proxy-service budibase/proxy:$PREPROD_TAG + docker push budibase/proxy:$PREPROD_TAG + env: + DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} + PREPROD_TAG: k8s-preprod + + - name: Pull values.yaml from budibase-infra + run: | + curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \ + -H 'Accept: application/vnd.github.v3.raw' \ + -o values.preprod.yaml \ + -L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/budibase-preprod/values.yaml + wc -l values.preprod.yaml + + - name: Deploy to Preprod Environment + uses: glopezep/helm@v1.7.1 + with: + release: budibase-preprod + namespace: budibase + chart: charts/budibase + token: ${{ github.token }} + helm: helm3 + values: | + globals: + appVersion: ${{ steps.previoustag.outputs.tag }} + ingress: + enabled: true + nginx: true + value-files: >- + [ + "values.preprod.yaml" + ] + env: + KUBECONFIG_FILE: '${{ secrets.PREPROD_KUBECONFIG }}' + + - name: Discord Webhook Action + uses: tsickert/discord-webhook@v4.0.0 + with: + webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }} + content: "Preprod Deployment Complete: ${{ steps.previoustag.outputs.tag }} deployed to Budibase Pre-prod." + embed-title: ${{ steps.previoustag.outputs.tag }} diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index 86e255d331..c80cfa2ecc 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -98,10 +98,6 @@ spec: value: http://worker-service:{{ .Values.services.worker.port }} - name: PLATFORM_URL value: {{ .Values.globals.platformUrl | quote }} - - name: USE_QUOTAS - value: {{ .Values.globals.useQuotas | quote }} - - name: EXCLUDE_QUOTAS_TENANTS - value: {{ .Values.globals.excludeQuotasTenants | quote }} - name: ACCOUNT_PORTAL_URL value: {{ .Values.globals.accountPortalUrl | quote }} - name: ACCOUNT_PORTAL_API_KEY @@ -114,12 +110,23 @@ spec: value: {{ .Values.globals.google.clientId | quote }} - name: GOOGLE_CLIENT_SECRET value: {{ .Values.globals.google.secret | quote }} + - name: AUTOMATION_MAX_ITERATIONS + value: {{ .Values.globals.automationMaxIterations | quote }} + image: budibase/apps:{{ .Values.globals.appVersion }} imagePullPolicy: Always name: bbapps ports: - containerPort: {{ .Values.services.apps.port }} resources: {} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" status: {} diff --git a/charts/budibase/templates/couchdb-backup.yaml b/charts/budibase/templates/couchdb-backup.yaml index 1072046c8c..ae062475ce 100644 --- a/charts/budibase/templates/couchdb-backup.yaml +++ b/charts/budibase/templates/couchdb-backup.yaml @@ -39,5 +39,13 @@ spec: imagePullPolicy: Always name: couchdb-backup resources: {} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} status: {} {{- end }} diff --git a/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml b/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml index d122ad0a3e..7a6e05a66a 100644 --- a/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml +++ b/charts/budibase/templates/minio-data-persistentvolumeclaim.yaml @@ -12,5 +12,10 @@ spec: resources: requests: storage: {{ .Values.services.objectStore.storage }} + {{- if (eq "-" .Values.services.objectStore.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: "{{ .Values.services.objectStore.storageClass }}" + {{- end }} status: {} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/budibase/templates/minio-service-deployment.yaml b/charts/budibase/templates/minio-service-deployment.yaml index a23d0c1d89..901fb61ad9 100644 --- a/charts/budibase/templates/minio-service-deployment.yaml +++ b/charts/budibase/templates/minio-service-deployment.yaml @@ -60,6 +60,14 @@ spec: volumeMounts: - mountPath: /data name: minio-data + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" volumes: diff --git a/charts/budibase/templates/proxy-service-deployment.yaml b/charts/budibase/templates/proxy-service-deployment.yaml index 05bf4a7f1a..bd6a5e311f 100644 --- a/charts/budibase/templates/proxy-service-deployment.yaml +++ b/charts/budibase/templates/proxy-service-deployment.yaml @@ -32,6 +32,14 @@ spec: - containerPort: {{ .Values.services.proxy.port }} resources: {} volumeMounts: + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" volumes: diff --git a/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml b/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml index 2cb5ee8eab..5f063dc664 100644 --- a/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml +++ b/charts/budibase/templates/redis-data-persistentvolumeclaim.yaml @@ -12,5 +12,10 @@ spec: resources: requests: storage: {{ .Values.services.redis.storage }} + {{- if (eq "-" .Values.services.redis.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: "{{ .Values.services.redis.storageClass }}" + {{- end }} status: {} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/charts/budibase/templates/redis-service-deployment.yaml b/charts/budibase/templates/redis-service-deployment.yaml index 9235b0b11d..0b6cb12562 100644 --- a/charts/budibase/templates/redis-service-deployment.yaml +++ b/charts/budibase/templates/redis-service-deployment.yaml @@ -39,6 +39,14 @@ spec: volumeMounts: - mountPath: /data name: redis-data + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" volumes: diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index b6c757cb9f..c2180aca2b 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -121,6 +121,14 @@ spec: ports: - containerPort: {{ .Values.services.worker.port }} resources: {} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} restartPolicy: Always serviceAccountName: "" status: {} diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index ce9fb13907..5ada89de6c 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -95,16 +95,15 @@ globals: logLevel: info selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs - useQuotas: "0" - excludeQuotasTenants: "" # comma seperated list of tenants to exclude from quotas accountPortalUrl: "" accountPortalApiKey: "" cookieDomain: "" platformUrl: "" httpMigrations: "0" google: - clientId: "" + clientId: "" secret: "" + automationMaxIterations: "500" createSecrets: true # creates an internal API key, JWT secrets and redis password for you @@ -152,6 +151,11 @@ services: url: "" # only change if pointing to existing redis cluster and enabled: false password: "budibase" # recommended to override if using built-in redis storage: 100Mi + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. + storageClass: "-" objectStore: minio: true @@ -163,6 +167,11 @@ services: region: "" # AWS_REGION if using S3 or existing minio secret url: "http://minio-service:9000" # only change if pointing to existing minio cluster or S3 and minio: false storage: 100Mi + ## If defined, storageClassName: + ## If set to "-", storageClassName: "", which disables dynamic provisioning + ## If undefined (the default) or set to null, no storageClassName spec is + ## set, choosing the default provisioner. + storageClass: "-" # Override values in couchDB subchart couchdb: @@ -232,6 +241,8 @@ couchdb: ## Optional tolerations tolerations: [] + affinity: {} + service: # annotations: enabled: true diff --git a/examples/nextjs-api-sales/yarn.lock b/examples/nextjs-api-sales/yarn.lock index 3f32417ba8..52c89967b2 100644 --- a/examples/nextjs-api-sales/yarn.lock +++ b/examples/nextjs-api-sales/yarn.lock @@ -1894,9 +1894,9 @@ minimist-options@4.1.0: kind-of "^6.0.3" minimist@^1.2.0: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== minipass-collect@^1.0.2: version "1.0.2" diff --git a/hosting/docker-compose.dev.yaml b/hosting/docker-compose.dev.yaml index 43b8526e9e..be0bc74a26 100644 --- a/hosting/docker-compose.dev.yaml +++ b/hosting/docker-compose.dev.yaml @@ -27,6 +27,7 @@ services: image: nginx:latest volumes: - ./.generated-nginx.dev.conf:/etc/nginx/nginx.conf + - ./proxy/error.html:/usr/share/nginx/html/error.html ports: - "${MAIN_PORT}:10000" depends_on: diff --git a/hosting/docker-compose.yaml b/hosting/docker-compose.yaml index f3055b19fa..f9d9eaf1c5 100644 --- a/hosting/docker-compose.yaml +++ b/hosting/docker-compose.yaml @@ -117,7 +117,6 @@ services: labels: - "com.centurylinklabs.watchtower.enable=false" - volumes: couchdb3_data: driver: local diff --git a/hosting/nginx.dev.conf.hbs b/hosting/nginx.dev.conf.hbs index 9fc2345fb2..9398b7e719 100644 --- a/hosting/nginx.dev.conf.hbs +++ b/hosting/nginx.dev.conf.hbs @@ -28,6 +28,12 @@ http { ignore_invalid_headers off; proxy_buffering off; + error_page 502 503 504 /error.html; + location = /error.html { + root /usr/share/nginx/html; + internal; + } + location /db/ { proxy_pass http://couchdb-service:5984; rewrite ^/db/(.*)$ /$1 break; diff --git a/hosting/nginx.prod.conf.hbs b/hosting/nginx.prod.conf.hbs index 88570a4a2d..7ef597051b 100644 --- a/hosting/nginx.prod.conf.hbs +++ b/hosting/nginx.prod.conf.hbs @@ -56,6 +56,12 @@ http { set $csp_media "media-src 'self' https://js.intercomcdn.com"; set $csp_worker "worker-src 'none'"; + error_page 502 503 504 /error.html; + location = /error.html { + root /usr/share/nginx/html; + internal; + } + # Security Headers add_header X-Frame-Options SAMEORIGIN always; add_header X-Content-Type-Options nosniff always; diff --git a/hosting/portainer/template.json b/hosting/portainer/template.json new file mode 100644 index 0000000000..29107b674e --- /dev/null +++ b/hosting/portainer/template.json @@ -0,0 +1,94 @@ +{ + "version": "2", + "templates": [ + { + "type": 3, + "title": "Budibase", + "categories": ["Tools"], + "description": "Build modern business apps in minutes", + "logo": "https://budibase.com/favicon.ico", + "platform": "linux", + "repository": { + "url": "https://github.com/Budibase/budibase", + "stackfile": "hosting/docker-compose.yaml" + }, + "env": [ + { + "name": "MAIN_PORT", + "label": "Main port", + "default": "10000" + }, + { + "name": "JWT_SECRET", + "label": "JWT secret", + "default": "change-me" + }, + { + "name": "MINIO_ACCESS_KEY", + "label": "MinIO access key", + "default": "change-me" + }, + { + "name": "MINIO_SECRET_KEY", + "label": "MinIO secret key", + "default": "change-me" + }, + { + "name": "COUCH_DB_USER", + "default": "budibase", + "preset": true + }, + { + "name": "COUCH_DB_PASSWORD", + "label": "Couch DB password", + "default": "change-me" + }, + { + "name": "REDIS_PASSWORD", + "label": "Redis password", + "default": "change-me" + }, + { + "name": "INTERNAL_API_KEY", + "label": "Internal API key", + "default": "change-me" + }, + { + "name": "APP_PORT", + "default": "4002", + "preset": true + }, + { + "name": "WORKER_PORT", + "default": "4003", + "preset": true + }, + { + "name": "MINIO_PORT", + "default": "4004", + "preset": true + }, + { + "name": "COUCH_DB_PORT", + "default": "4005", + "preset": true + }, + { + "name": "REDIS_PORT", + "default": "6379", + "preset": true + }, + { + "name": "WATCHTOWER_PORT", + "default": "6161", + "preset": true + }, + { + "name": "BUDIBASE_ENVIRONMENT", + "default": "PRODUCTION", + "preset": true + } + ] + } + ] +} diff --git a/hosting/proxy/Dockerfile b/hosting/proxy/Dockerfile index b577e3e40f..a2b17d3333 100644 --- a/hosting/proxy/Dockerfile +++ b/hosting/proxy/Dockerfile @@ -1,2 +1,3 @@ FROM nginx:latest -COPY .generated-nginx.prod.conf /etc/nginx/nginx.conf \ No newline at end of file +COPY .generated-nginx.prod.conf /etc/nginx/nginx.conf +COPY error.html /usr/share/nginx/html/error.html \ No newline at end of file diff --git a/hosting/proxy/error.html b/hosting/proxy/error.html new file mode 100644 index 0000000000..023c1ebaff --- /dev/null +++ b/hosting/proxy/error.html @@ -0,0 +1,175 @@ + + + + + Budibase + + + + + + + + + + +
+
+
+ Budibase Logo +
+
+
+

+

+ Houston we have a problem! +

+

+

+
+
+ + +
+
+
+
+ + + \ No newline at end of file diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile new file mode 100644 index 0000000000..2123d237b5 --- /dev/null +++ b/hosting/single/Dockerfile @@ -0,0 +1,97 @@ +FROM couchdb + +ENV COUCHDB_PASSWORD=budibase +ENV COUCHDB_USER=budibase +ENV COUCH_DB_URL=http://budibase:budibase@localhost:5984 +ENV BUDIBASE_ENVIRONMENT=PRODUCTION +ENV MINIO_URL=http://localhost:9000 +ENV REDIS_URL=localhost:6379 +ENV WORKER_URL=http://localhost:4002 +ENV INTERNAL_API_KEY=budibase +ENV JWT_SECRET=testsecret +ENV MINIO_ACCESS_KEY=budibase +ENV MINIO_SECRET_KEY=budibase +ENV SELF_HOSTED=1 +ENV CLUSTER_PORT=10000 +ENV REDIS_PASSWORD=budibase +ENV ARCHITECTURE=amd +ENV APP_PORT=4001 +ENV WORKER_PORT=4002 + +RUN apt-get update +RUN apt-get install software-properties-common wget nginx -y +RUN apt-add-repository 'deb http://security.debian.org/debian-security stretch/updates main' +RUN apt-get update + +# setup nginx +ADD hosting/single/nginx.conf /etc/nginx +RUN mkdir /etc/nginx/logs +RUN useradd www +RUN touch /etc/nginx/logs/error.log +RUN touch /etc/nginx/logs/nginx.pid + +# install java +RUN apt-get install openjdk-8-jdk -y + +# setup nodejs +WORKDIR /nodejs +RUN curl -sL https://deb.nodesource.com/setup_16.x -o /tmp/nodesource_setup.sh +RUN bash /tmp/nodesource_setup.sh +RUN apt-get install nodejs +RUN npm install --global yarn +RUN npm install --global pm2 + +# setup redis +RUN apt install redis-server -y + +# setup server +WORKDIR /app +ADD packages/server . +RUN ls -al +RUN yarn +RUN yarn build +# Install client for oracle datasource +RUN apt-get install unzip libaio1 +RUN /bin/bash -e scripts/integrations/oracle/instantclient/linux/x86-64/install.sh + +# setup worker +WORKDIR /worker +ADD packages/worker . +RUN yarn +RUN yarn build + +# setup clouseau +WORKDIR / +RUN wget https://github.com/cloudant-labs/clouseau/releases/download/2.21.0/clouseau-2.21.0-dist.zip +RUN unzip clouseau-2.21.0-dist.zip +RUN mv clouseau-2.21.0 /opt/clouseau +RUN rm clouseau-2.21.0-dist.zip + +WORKDIR /opt/clouseau +RUN mkdir ./bin +ADD hosting/single/clouseau ./bin/ +ADD hosting/single/log4j.properties . +ADD hosting/single/clouseau.ini . +RUN chmod +x ./bin/clouseau + +# setup CouchDB +WORKDIR /opt/couchdb +ADD hosting/single/vm.args ./etc/ + +# setup minio +WORKDIR /minio +RUN wget https://dl.min.io/server/minio/release/linux-${ARCHITECTURE}64/minio +RUN chmod +x minio + +# setup runner file +WORKDIR / +ADD hosting/single/runner.sh . +RUN chmod +x ./runner.sh + +EXPOSE 10000 +VOLUME /opt/couchdb/data +VOLUME /minio + +# must set this just before running +ENV NODE_ENV=production +CMD ["./runner.sh"] diff --git a/hosting/single/clouseau b/hosting/single/clouseau new file mode 100644 index 0000000000..1095ea24cb --- /dev/null +++ b/hosting/single/clouseau @@ -0,0 +1,12 @@ +#!/bin/sh +/usr/bin/java -server \ + -Xmx2G \ + -Dsun.net.inetaddr.ttl=30 \ + -Dsun.net.inetaddr.negative.ttl=30 \ + -Dlog4j.configuration=file:/opt/clouseau/log4j.properties \ + -XX:OnOutOfMemoryError="kill -9 %p" \ + -XX:+UseConcMarkSweepGC \ + -XX:+CMSParallelRemarkEnabled \ + -classpath '/opt/clouseau/*' \ + com.cloudant.clouseau.Main \ + /opt/clouseau/clouseau.ini \ No newline at end of file diff --git a/hosting/single/clouseau.ini b/hosting/single/clouseau.ini new file mode 100644 index 0000000000..f086cf0398 --- /dev/null +++ b/hosting/single/clouseau.ini @@ -0,0 +1,13 @@ +[clouseau] + +; the name of the Erlang node created by the service, leave this unchanged +name=clouseau@127.0.0.1 + +; set this to the same distributed Erlang cookie used by the CouchDB nodes +cookie=monster + +; the path where you would like to store the search index files +dir=/opt/couchdb/data/search + +; the number of search indexes that can be open simultaneously +max_indexes_open=500 diff --git a/hosting/single/log4j.properties b/hosting/single/log4j.properties new file mode 100644 index 0000000000..9d4d9311bc --- /dev/null +++ b/hosting/single/log4j.properties @@ -0,0 +1,4 @@ +log4j.rootLogger=debug, CONSOLE +log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender +log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout +log4j.appender.CONSOLE.layout.ConversionPattern=%d{ISO8601} %c [%p] %m%n \ No newline at end of file diff --git a/hosting/single/nginx.conf b/hosting/single/nginx.conf new file mode 100644 index 0000000000..86938ced4e --- /dev/null +++ b/hosting/single/nginx.conf @@ -0,0 +1,116 @@ +user www www; +error_log /etc/nginx/logs/error.log; +pid /etc/nginx/logs/nginx.pid; +worker_processes auto; +worker_rlimit_nofile 8192; + +events { + worker_connections 1024; +} + +http { + limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=20r/s; + proxy_set_header Host $host; + charset utf-8; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + server_tokens off; + types_hash_max_size 2048; + + # buffering + client_header_buffer_size 1k; + client_max_body_size 20M; + ignore_invalid_headers off; + proxy_buffering off; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + map $http_upgrade $connection_upgrade { + default "upgrade"; + } + + server { + listen 10000 default_server; + listen [::]:10000 default_server; + server_name _; + client_max_body_size 1000m; + ignore_invalid_headers off; + proxy_buffering off; + # port_in_redirect off; + + location /app { + proxy_pass http://127.0.0.1:4001; + } + + location = / { + proxy_pass http://127.0.0.1:4001; + } + + location ~ ^/(builder|app_) { + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_pass http://127.0.0.1:4001; + } + + location ~ ^/api/(system|admin|global)/ { + proxy_pass http://127.0.0.1:4002; + } + + location /worker/ { + proxy_pass http://127.0.0.1:4002; + rewrite ^/worker/(.*)$ /$1 break; + } + + location /api/ { + # calls to the API are rate limited with bursting + limit_req zone=ratelimit burst=20 nodelay; + + # 120s timeout on API requests + proxy_read_timeout 120s; + proxy_connect_timeout 120s; + proxy_send_timeout 120s; + + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + proxy_pass http://127.0.0.1:4001; + } + + location /db/ { + proxy_pass http://127.0.0.1:5984; + rewrite ^/db/(.*)$ /$1 break; + } + + location / { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 300; + proxy_http_version 1.1; + proxy_set_header Connection ""; + chunked_transfer_encoding off; + proxy_pass http://127.0.0.1:9000; + } + + client_header_timeout 60; + client_body_timeout 60; + keepalive_timeout 60; + + # gzip + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + } +} diff --git a/hosting/single/runner.sh b/hosting/single/runner.sh new file mode 100644 index 0000000000..fab8431796 --- /dev/null +++ b/hosting/single/runner.sh @@ -0,0 +1,16 @@ +redis-server --requirepass $REDIS_PASSWORD & +/opt/clouseau/bin/clouseau & +/minio/minio server /minio & +/docker-entrypoint.sh /opt/couchdb/bin/couchdb & +/etc/init.d/nginx restart +pushd app +pm2 start --name app "yarn run:docker" +popd +pushd worker +pm2 start --name worker "yarn run:docker" +popd +sleep 10 +URL=http://${COUCHDB_USER}:${COUCHDB_PASSWORD}@localhost:5984 +curl -X PUT ${URL}/_users +curl -X PUT ${URL}/_replicator +sleep infinity \ No newline at end of file diff --git a/hosting/single/vm.args b/hosting/single/vm.args new file mode 100644 index 0000000000..e9e4416863 --- /dev/null +++ b/hosting/single/vm.args @@ -0,0 +1,32 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy of +# the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations under +# the License. + +# erlang cookie for clouseau security +-name couchdb@127.0.0.1 +-setcookie monster + +# Ensure that the Erlang VM listens on a known port +-kernel inet_dist_listen_min 9100 +-kernel inet_dist_listen_max 9100 + +# Tell kernel and SASL not to log anything +-kernel error_logger silent +-sasl sasl_error_logger false + +# Use kernel poll functionality if supported by emulator ++K true + +# Start a pool of asynchronous IO threads ++A 16 + +# Comment this line out to enable the interactive Erlang shell on startup ++Bd -noinput diff --git a/lerna.json b/lerna.json index 36e23d9ab3..3f69ccefda 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.0.123", + "version": "1.0.124-alpha.0", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 6009abff3e..727104d830 100644 --- a/package.json +++ b/package.json @@ -21,18 +21,17 @@ }, "scripts": { "setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev", - "bootstrap": "lerna link && lerna bootstrap", + "bootstrap": "lerna link && lerna bootstrap && ./scripts/link-dependencies.sh", "build": "lerna run build", - "publishdev": "lerna run publishdev", - "publishnpm": "yarn build && lerna publish --force-publish", - "release": "lerna publish patch --yes --force-publish", - "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop", + "release": "lerna publish patch --yes --force-publish && yarn release:pro", + "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop && yarn release:pro:develop", + "release:pro": "bash scripts/pro/release.sh", + "release:pro:develop": "bash scripts/pro/release.sh develop", "restore": "yarn run clean && yarn run bootstrap && yarn run build", "nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke:packages": "yarn run restore", "nuke:docker": "lerna run --parallel dev:stack:nuke", "clean": "lerna clean", - "kill-port": "kill-port 4001", "kill-builder": "kill-port 3000", "kill-server": "kill-port 4001 4002", "kill-all": "yarn run kill-builder && yarn run kill-server", @@ -58,6 +57,8 @@ "build:docker:develop": "node scripts/pinVersions && lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", + "build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .", + "build:docker:single": "lerna run build && lerna run predocker && npm run build:docker:single:image", "build:docs": "lerna run build:docs", "release:helm": "node scripts/releaseHelmChart", "env:multi:enable": "lerna run env:multi:enable", @@ -73,6 +74,7 @@ "mode:account": "yarn mode:cloud && yarn env:account:enable", "security:audit": "node scripts/audit.js", "postinstall": "husky install", + "install:pro": "bash scripts/pro/install.sh", "dep:clean": "yarn clean && yarn bootstrap" } } diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 117d74ed1d..b0ff91ab98 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.0.123", + "version": "1.0.124-alpha.0", "description": "Budibase backend core libraries used in server and worker", "main": "src/index.js", "author": "Budibase", diff --git a/packages/backend-core/src/constants.js b/packages/backend-core/src/constants.js index 559dc0e6b2..172e66e603 100644 --- a/packages/backend-core/src/constants.js +++ b/packages/backend-core/src/constants.js @@ -13,9 +13,11 @@ exports.Cookies = { exports.Headers = { API_KEY: "x-budibase-api-key", + LICENSE_KEY: "x-budibase-license-key", API_VER: "x-budibase-api-version", APP_ID: "x-budibase-app-id", TYPE: "x-budibase-type", + PREVIEW_ROLE: "x-budibase-role", TENANT_ID: "x-budibase-tenant-id", TOKEN: "x-budibase-token", CSRF_TOKEN: "x-csrf-token", diff --git a/packages/backend-core/src/context/index.js b/packages/backend-core/src/context/index.js index 906ae9cce5..b6b6f2380c 100644 --- a/packages/backend-core/src/context/index.js +++ b/packages/backend-core/src/context/index.js @@ -71,7 +71,9 @@ exports.doInTenant = (tenantId, task) => { // set the tenant id if (!opts.existing) { cls.setOnContext(ContextKeys.TENANT_ID, tenantId) - exports.setGlobalDB(tenantId) + if (env.USE_COUCH) { + exports.setGlobalDB(tenantId) + } } try { @@ -80,7 +82,9 @@ exports.doInTenant = (tenantId, task) => { } finally { const using = cls.getFromContext(ContextKeys.IN_USE) if (!using || using <= 1) { - await closeDB(exports.getGlobalDB()) + if (env.USE_COUCH) { + await closeDB(exports.getGlobalDB()) + } // clear from context now that database is closed/task is finished cls.setOnContext(ContextKeys.TENANT_ID, null) cls.setOnContext(ContextKeys.GLOBAL_DB, null) @@ -167,6 +171,7 @@ exports.doInAppContext = (appId, task) => { exports.updateTenantId = tenantId => { cls.setOnContext(ContextKeys.TENANT_ID, tenantId) + exports.setGlobalDB(tenantId) } exports.updateAppId = async appId => { @@ -269,8 +274,10 @@ function getContextDB(key, opts) { if (db && isEqual(opts, storedOpts)) { return db } + const appId = exports.getAppId() let toUseAppId + switch (key) { case ContextKeys.CURRENT_DB: toUseAppId = appId diff --git a/packages/backend-core/src/db/constants.js b/packages/backend-core/src/db/constants.js index 5ee8033e05..271d4f412d 100644 --- a/packages/backend-core/src/db/constants.js +++ b/packages/backend-core/src/db/constants.js @@ -23,6 +23,7 @@ exports.StaticDatabases = { docs: { apiKeys: "apikeys", usageQuota: "usage_quota", + licenseInfo: "license_info", }, }, // contains information about tenancy and so on diff --git a/packages/backend-core/src/db/utils.js b/packages/backend-core/src/db/utils.js index 4632b709c0..9e2a06d065 100644 --- a/packages/backend-core/src/db/utils.js +++ b/packages/backend-core/src/db/utils.js @@ -28,6 +28,7 @@ const UNICODE_MAX = "\ufff0" exports.ViewNames = { USER_BY_EMAIL: "by_email", BY_API_KEY: "by_api_key", + USER_BY_BUILDERS: "by_builders", } exports.StaticDatabases = StaticDatabases @@ -414,35 +415,10 @@ async function getScopedConfig(db, params) { return configDoc && configDoc.config ? configDoc.config : configDoc } -function generateNewUsageQuotaDoc() { - return { - _id: StaticDatabases.GLOBAL.docs.usageQuota, - quotaReset: Date.now() + 2592000000, - usageQuota: { - automationRuns: 0, - rows: 0, - storage: 0, - apps: 0, - users: 0, - views: 0, - emails: 0, - }, - usageLimits: { - automationRuns: 1000, - rows: 4000, - apps: 4, - storage: 1000, - users: 10, - emails: 50, - }, - } -} - exports.Replication = Replication exports.getScopedConfig = getScopedConfig exports.generateConfigID = generateConfigID exports.getConfigParams = getConfigParams exports.getScopedFullConfig = getScopedFullConfig -exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc exports.generateDevInfoID = generateDevInfoID exports.getPlatformUrl = getPlatformUrl diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index e5be8e6b40..e0281c6584 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -56,10 +56,34 @@ exports.createApiKeyView = async () => { await db.put(designDoc) } +exports.createUserBuildersView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + map: `function(doc) { + if (doc.builder && doc.builder.global === true) { + emit(doc._id, doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewNames.USER_BY_BUILDERS]: view, + } + await db.put(designDoc) +} + exports.queryGlobalView = async (viewName, params, db = null) => { const CreateFuncByName = { [ViewNames.USER_BY_EMAIL]: exports.createUserEmailView, [ViewNames.BY_API_KEY]: exports.createApiKeyView, + [ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView, } // can pass DB in if working with something specific if (!db) { diff --git a/packages/backend-core/src/environment.js b/packages/backend-core/src/environment.js index d112ad8599..8a92e39469 100644 --- a/packages/backend-core/src/environment.js +++ b/packages/backend-core/src/environment.js @@ -22,15 +22,26 @@ module.exports = { MINIO_URL: process.env.MINIO_URL, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, MULTI_TENANCY: process.env.MULTI_TENANCY, - ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL, + ACCOUNT_PORTAL_URL: + process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app", ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED), COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, PLATFORM_URL: process.env.PLATFORM_URL, + TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS, + USE_COUCH: process.env.USE_COUCH || true, isTest, _set(key, value) { process.env[key] = value module.exports[key] = value }, } + +// clean up any environment variable edge cases +for (let [key, value] of Object.entries(module.exports)) { + // handle the edge case of "0" to disable an environment variable + if (value === "0") { + module.exports[key] = 0 + } +} diff --git a/packages/backend-core/src/errors/base.js b/packages/backend-core/src/errors/base.js new file mode 100644 index 0000000000..d31f9838f4 --- /dev/null +++ b/packages/backend-core/src/errors/base.js @@ -0,0 +1,11 @@ +class BudibaseError extends Error { + constructor(message, type, code) { + super(message) + this.type = type + this.code = code + } +} + +module.exports = { + BudibaseError, +} diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.js new file mode 100644 index 0000000000..4f3b4e0c41 --- /dev/null +++ b/packages/backend-core/src/errors/index.js @@ -0,0 +1,41 @@ +const licensing = require("./licensing") + +const codes = { + ...licensing.codes, +} + +const types = { + ...licensing.types, +} + +const context = { + ...licensing.context, +} + +const getPublicError = err => { + let error + if (err.code || err.type) { + // add generic error information + error = { + code: err.code, + type: err.type, + } + + if (err.code && context[err.code]) { + error = { + ...error, + // get any additional context from this error + ...context[err.code](err), + } + } + } + + return error +} + +module.exports = { + codes, + types, + UsageLimitError: licensing.UsageLimitError, + getPublicError, +} diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js new file mode 100644 index 0000000000..c05f9c561e --- /dev/null +++ b/packages/backend-core/src/errors/licensing.js @@ -0,0 +1,32 @@ +const { BudibaseError } = require("./base") + +const types = { + LICENSE_ERROR: "license_error", +} + +const codes = { + USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", +} + +const context = { + [codes.USAGE_LIMIT_EXCEEDED]: err => { + return { + limitName: err.limitName, + } + }, +} + +class UsageLimitError extends BudibaseError { + constructor(message, limitName) { + super(message, types.LICENSE_ERROR, codes.USAGE_LIMIT_EXCEEDED) + this.limitName = limitName + this.status = 400 + } +} + +module.exports = { + types, + codes, + context, + UsageLimitError, +} diff --git a/packages/backend-core/src/featureFlags/index.js b/packages/backend-core/src/featureFlags/index.js new file mode 100644 index 0000000000..6d3d86978a --- /dev/null +++ b/packages/backend-core/src/featureFlags/index.js @@ -0,0 +1,52 @@ +const env = require("../environment") +const tenancy = require("../tenancy") + +/** + * Read the TENANT_FEATURE_FLAGS env var and return an array of features flags for each tenant. + * The env var is formatted as: + * tenant1:feature1:feature2,tenant2:feature1 + */ +const getFeatureFlags = () => { + if (!env.TENANT_FEATURE_FLAGS) { + return + } + + const tenantFeatureFlags = {} + + env.TENANT_FEATURE_FLAGS.split(",").forEach(tenantToFeatures => { + const [tenantId, ...features] = tenantToFeatures.split(":") + + features.forEach(feature => { + if (!tenantFeatureFlags[tenantId]) { + tenantFeatureFlags[tenantId] = [] + } + tenantFeatureFlags[tenantId].push(feature) + }) + }) + + return tenantFeatureFlags +} + +const TENANT_FEATURE_FLAGS = getFeatureFlags() + +exports.isEnabled = featureFlag => { + const tenantId = tenancy.getTenantId() + + return ( + TENANT_FEATURE_FLAGS && + TENANT_FEATURE_FLAGS[tenantId] && + TENANT_FEATURE_FLAGS[tenantId].includes(featureFlag) + ) +} + +exports.getTenantFeatureFlags = tenantId => { + if (TENANT_FEATURE_FLAGS && TENANT_FEATURE_FLAGS[tenantId]) { + return TENANT_FEATURE_FLAGS[tenantId] + } + + return [] +} + +exports.FeatureFlag = { + LICENSING: "LICENSING", +} diff --git a/packages/backend-core/src/index.js b/packages/backend-core/src/index.js index 446e3fa1e9..3868d9bffa 100644 --- a/packages/backend-core/src/index.js +++ b/packages/backend-core/src/index.js @@ -15,7 +15,9 @@ module.exports = { auth: require("../auth"), constants: require("../constants"), migrations: require("../migrations"), + errors: require("./errors"), env: require("./environment"), accounts: require("./cloud/accounts"), tenancy: require("./tenancy"), + featureFlags: require("./featureFlags"), } diff --git a/packages/backend-core/src/middleware/passport/google.js b/packages/backend-core/src/middleware/passport/google.js index 8fd0961ea1..b12a668327 100644 --- a/packages/backend-core/src/middleware/passport/google.js +++ b/packages/backend-core/src/middleware/passport/google.js @@ -2,24 +2,27 @@ const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const { authenticateThirdParty } = require("./third-party-common") -async function authenticate(accessToken, refreshToken, profile, done) { - const thirdPartyUser = { - provider: profile.provider, // should always be 'google' - providerType: "google", - userId: profile.id, - profile: profile, - email: profile._json.email, - oauth2: { - accessToken: accessToken, - refreshToken: refreshToken, - }, - } +const buildVerifyFn = saveUserFn => { + return (accessToken, refreshToken, profile, done) => { + const thirdPartyUser = { + provider: profile.provider, // should always be 'google' + providerType: "google", + userId: profile.id, + profile: profile, + email: profile._json.email, + oauth2: { + accessToken: accessToken, + refreshToken: refreshToken, + }, + } - return authenticateThirdParty( - thirdPartyUser, - true, // require local accounts to exist - done - ) + return authenticateThirdParty( + thirdPartyUser, + true, // require local accounts to exist + done, + saveUserFn + ) + } } /** @@ -27,11 +30,7 @@ async function authenticate(accessToken, refreshToken, profile, done) { * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * @returns Dynamically configured Passport Google Strategy */ -exports.strategyFactory = async function ( - config, - callbackUrl, - verify = authenticate -) { +exports.strategyFactory = async function (config, callbackUrl, saveUserFn) { try { const { clientID, clientSecret } = config @@ -41,6 +40,7 @@ exports.strategyFactory = async function ( ) } + const verify = buildVerifyFn(saveUserFn) return new GoogleStrategy( { clientID: config.clientID, @@ -58,4 +58,4 @@ exports.strategyFactory = async function ( } } // expose for testing -exports.authenticate = authenticate +exports.buildVerifyFn = buildVerifyFn diff --git a/packages/backend-core/src/middleware/passport/oidc.js b/packages/backend-core/src/middleware/passport/oidc.js index 3a75dfcf8e..1e93e20b1c 100644 --- a/packages/backend-core/src/middleware/passport/oidc.js +++ b/packages/backend-core/src/middleware/passport/oidc.js @@ -2,46 +2,49 @@ const fetch = require("node-fetch") const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy const { authenticateThirdParty } = require("./third-party-common") -/** - * @param {*} issuer The identity provider base URL - * @param {*} sub The user ID - * @param {*} profile The user profile information. Created by passport from the /userinfo response - * @param {*} jwtClaims The parsed id_token claims - * @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT - * @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT - * @param {*} idToken The id_token - always a JWT - * @param {*} params The response body from requesting an access_token - * @param {*} done The passport callback: err, user, info - */ -async function authenticate( - issuer, - sub, - profile, - jwtClaims, - accessToken, - refreshToken, - idToken, - params, - done -) { - const thirdPartyUser = { - // store the issuer info to enable sync in future - provider: issuer, - providerType: "oidc", - userId: profile.id, - profile: profile, - email: getEmail(profile, jwtClaims), - oauth2: { - accessToken: accessToken, - refreshToken: refreshToken, - }, - } - - return authenticateThirdParty( - thirdPartyUser, - false, // don't require local accounts to exist +const buildVerifyFn = saveUserFn => { + /** + * @param {*} issuer The identity provider base URL + * @param {*} sub The user ID + * @param {*} profile The user profile information. Created by passport from the /userinfo response + * @param {*} jwtClaims The parsed id_token claims + * @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT + * @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT + * @param {*} idToken The id_token - always a JWT + * @param {*} params The response body from requesting an access_token + * @param {*} done The passport callback: err, user, info + */ + return async ( + issuer, + sub, + profile, + jwtClaims, + accessToken, + refreshToken, + idToken, + params, done - ) + ) => { + const thirdPartyUser = { + // store the issuer info to enable sync in future + provider: issuer, + providerType: "oidc", + userId: profile.id, + profile: profile, + email: getEmail(profile, jwtClaims), + oauth2: { + accessToken: accessToken, + refreshToken: refreshToken, + }, + } + + return authenticateThirdParty( + thirdPartyUser, + false, // don't require local accounts to exist + done, + saveUserFn + ) + } } /** @@ -86,7 +89,7 @@ function validEmail(value) { * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * @returns Dynamically configured Passport OIDC Strategy */ -exports.strategyFactory = async function (config, callbackUrl) { +exports.strategyFactory = async function (config, callbackUrl, saveUserFn) { try { const { clientID, clientSecret, configUrl } = config @@ -106,6 +109,7 @@ exports.strategyFactory = async function (config, callbackUrl) { const body = await response.json() + const verify = buildVerifyFn(saveUserFn) return new OIDCStrategy( { issuer: body.issuer, @@ -116,7 +120,7 @@ exports.strategyFactory = async function (config, callbackUrl) { clientSecret: clientSecret, callbackURL: callbackUrl, }, - authenticate + verify ) } catch (err) { console.error(err) @@ -125,4 +129,4 @@ exports.strategyFactory = async function (config, callbackUrl) { } // expose for testing -exports.authenticate = authenticate +exports.buildVerifyFn = buildVerifyFn diff --git a/packages/backend-core/src/middleware/passport/tests/google.spec.js b/packages/backend-core/src/middleware/passport/tests/google.spec.js index 9cc878bba9..c5580ea309 100644 --- a/packages/backend-core/src/middleware/passport/tests/google.spec.js +++ b/packages/backend-core/src/middleware/passport/tests/google.spec.js @@ -58,8 +58,10 @@ describe("google", () => { it("delegates authentication to third party common", async () => { const google = require("../google") + const mockSaveUserFn = jest.fn() + const authenticate = await google.buildVerifyFn(mockSaveUserFn) - await google.authenticate( + await authenticate( data.accessToken, data.refreshToken, profile, @@ -69,7 +71,8 @@ describe("google", () => { expect(authenticateThirdParty).toHaveBeenCalledWith( user, true, - mockDone) + mockDone, + mockSaveUserFn) }) }) }) diff --git a/packages/backend-core/src/middleware/passport/tests/oidc.spec.js b/packages/backend-core/src/middleware/passport/tests/oidc.spec.js index 44538b9135..bfe9f97dc0 100644 --- a/packages/backend-core/src/middleware/passport/tests/oidc.spec.js +++ b/packages/backend-core/src/middleware/passport/tests/oidc.spec.js @@ -83,8 +83,10 @@ describe("oidc", () => { async function doAuthenticate() { const oidc = require("../oidc") + const mockSaveUserFn = jest.fn() + const authenticate = await oidc.buildVerifyFn(mockSaveUserFn) - await oidc.authenticate( + await authenticate( issuer, sub, profile, diff --git a/packages/backend-core/src/middleware/passport/third-party-common.js b/packages/backend-core/src/middleware/passport/third-party-common.js index b467c0b10b..3fbfb145bc 100644 --- a/packages/backend-core/src/middleware/passport/third-party-common.js +++ b/packages/backend-core/src/middleware/passport/third-party-common.js @@ -1,7 +1,6 @@ const env = require("../../environment") const jwt = require("jsonwebtoken") const { generateGlobalUserID } = require("../../db/utils") -const { saveUser } = require("../../utils") const { authError } = require("./utils") const { newid } = require("../../hashing") const { createASession } = require("../../security/sessions") @@ -16,8 +15,11 @@ exports.authenticateThirdParty = async function ( thirdPartyUser, requireLocalAccount = true, done, - saveUserFn = saveUser + saveUserFn ) { + if (!saveUserFn) { + throw new Error("Save user function must be provided") + } if (!thirdPartyUser.provider) { return authError(done, "third party user provider required") } diff --git a/packages/backend-core/src/redis/utils.js b/packages/backend-core/src/redis/utils.js index 4c2b2f5cae..77f64f6593 100644 --- a/packages/backend-core/src/redis/utils.js +++ b/packages/backend-core/src/redis/utils.js @@ -17,6 +17,7 @@ exports.Databases = { FLAGS: "flags", APP_METADATA: "appMetadata", QUERY_VARS: "queryVars", + LICENSES: "license", } exports.SEPARATOR = SEPARATOR diff --git a/packages/backend-core/src/security/sessions.js b/packages/backend-core/src/security/sessions.js index bbe6be299d..1720eeb820 100644 --- a/packages/backend-core/src/security/sessions.js +++ b/packages/backend-core/src/security/sessions.js @@ -14,22 +14,7 @@ function makeSessionID(userId, sessionId) { return `${userId}/${sessionId}` } -exports.createASession = async (userId, session) => { - const client = await redis.getSessionClient() - const sessionId = session.sessionId - if (!session.csrfToken) { - session.csrfToken = uuidv4() - } - session = { - createdAt: new Date().toISOString(), - lastAccessedAt: new Date().toISOString(), - ...session, - userId, - } - await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) -} - -exports.invalidateSessions = async (userId, sessionIds = null) => { +async function invalidateSessions(userId, sessionIds = null) { let sessions = [] // If no sessionIds, get all the sessions for the user @@ -55,6 +40,24 @@ exports.invalidateSessions = async (userId, sessionIds = null) => { await Promise.all(promises) } +exports.createASession = async (userId, session) => { + // invalidate all other sessions + await invalidateSessions(userId) + + const client = await redis.getSessionClient() + const sessionId = session.sessionId + if (!session.csrfToken) { + session.csrfToken = uuidv4() + } + session = { + createdAt: new Date().toISOString(), + lastAccessedAt: new Date().toISOString(), + ...session, + userId, + } + await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) +} + exports.updateSessionTTL = async session => { const client = await redis.getSessionClient() const key = makeSessionID(session.userId, session.sessionId) @@ -67,8 +70,6 @@ exports.endSession = async (userId, sessionId) => { await client.delete(makeSessionID(userId, sessionId)) } -exports.getUserSessions = getSessionsForUser - exports.getSession = async (userId, sessionId) => { try { const client = await redis.getSessionClient() @@ -84,3 +85,6 @@ exports.getAllSessions = async () => { const sessions = await client.scan() return sessions.map(session => session.value) } + +exports.getUserSessions = getSessionsForUser +exports.invalidateSessions = invalidateSessions diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index b69bba5be0..5c922c42ad 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -176,6 +176,27 @@ exports.getGlobalUserByEmail = async email => { }) } +const getBuilders = async () => { + const builders = await queryGlobalView(ViewNames.USER_BY_BUILDERS, { + include_docs: false, + }) + + if (!builders) { + return [] + } + + if (Array.isArray(builders)) { + return builders + } else { + return [builders] + } +} + +exports.getBuildersCount = async () => { + const builders = await getBuilders() + return builders.length +} + exports.saveUser = async ( user, tenantId, @@ -290,4 +311,5 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => { userId, sessions.map(({ sessionId }) => sessionId) ) + await userCache.invalidateUser(userId) } diff --git a/packages/backend-core/yarn.lock b/packages/backend-core/yarn.lock index f75e472f8c..87db3761bc 100644 --- a/packages/backend-core/yarn.lock +++ b/packages/backend-core/yarn.lock @@ -3411,9 +3411,9 @@ mimic-fn@^2.1.0: brace-expansion "^1.1.7" minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mixin-deep@^1.2.0: version "1.3.2" diff --git a/packages/bbui/package.json b/packages/bbui/package.json index c081e6bac3..bff13a354a 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.0.123", + "version": "1.0.124-alpha.0", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "^1.0.123", + "@budibase/string-templates": "^1.0.124-alpha.0", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/ActionButton/ActionButton.svelte b/packages/bbui/src/ActionButton/ActionButton.svelte index 5ab7a7f047..b518ac3d92 100644 --- a/packages/bbui/src/ActionButton/ActionButton.svelte +++ b/packages/bbui/src/ActionButton/ActionButton.svelte @@ -80,8 +80,4 @@ .active svg { color: var(--spectrum-global-color-blue-600); } - - .spectrum-ActionButton-label { - padding-bottom: 2px; - } diff --git a/packages/bbui/src/ActionMenu/ActionMenu.svelte b/packages/bbui/src/ActionMenu/ActionMenu.svelte index 08425e8f59..c5602d6b0c 100644 --- a/packages/bbui/src/ActionMenu/ActionMenu.svelte +++ b/packages/bbui/src/ActionMenu/ActionMenu.svelte @@ -6,6 +6,7 @@ export let disabled = false export let align = "left" export let portalTarget + export let dataCy let anchor let dropdown @@ -36,7 +37,7 @@
- + diff --git a/packages/bbui/src/Form/Core/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker.svelte index c1c4cc866f..fd67fa41bb 100644 --- a/packages/bbui/src/Form/Core/DatePicker.svelte +++ b/packages/bbui/src/Form/Core/DatePicker.svelte @@ -19,18 +19,33 @@ const dispatch = createEventDispatcher() const flatpickrId = `${uuid()}-wrapper` let open = false - let flatpickr, flatpickrOptions, isTimeOnly + let flatpickr, flatpickrOptions + + const resolveTimeStamp = timestamp => { + let maskedDate = new Date(`0-${timestamp}`) + + if (maskedDate instanceof Date && !isNaN(maskedDate.getTime())) { + return maskedDate + } else { + return null + } + } - $: isTimeOnly = !timeOnly && value ? !isNaN(new Date(`0-${value}`)) : timeOnly $: flatpickrOptions = { element: `#${flatpickrId}`, - enableTime: isTimeOnly || enableTime || false, - noCalendar: isTimeOnly || false, + enableTime: timeOnly || enableTime || false, + noCalendar: timeOnly || false, altInput: true, - altFormat: isTimeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y", + altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y", wrap: true, appendTo, disableMobile: "true", + onReady: () => { + let timestamp = resolveTimeStamp(value) + if (timeOnly && timestamp) { + dispatch("change", timestamp.toISOString()) + } + }, } const handleChange = event => { @@ -39,10 +54,9 @@ if (newValue) { newValue = newValue.toISOString() } - // if time only set date component to today + // if time only set date component to 2000-01-01 if (timeOnly) { - const todayDate = new Date().toISOString().split("T")[0] - newValue = `${todayDate}T${newValue.split("T")[1]}` + newValue = `2000-01-01T${newValue.split("T")[1]}` } dispatch("change", newValue) } @@ -76,10 +90,13 @@ return null } let date - let time = new Date(`0-${val}`) + let time + // it is a string like 00:00:00, just time - if (timeOnly || (typeof val === "string" && !isNaN(time))) { - date = time + let ts = resolveTimeStamp(val) + + if (timeOnly && ts) { + date = ts } else if (val instanceof Date) { // Use real date obj if already parsed date = val @@ -101,7 +118,7 @@ } -{#key isTimeOnly} +{#key timeOnly} - (showTooltip = true)} + on:focus={() => (showTooltip = true)} + on:mouseleave={() => (showTooltip = false)} + on:click={() => (showTooltip = false)} > - - + + + + {#if tooltip && showTooltip} +
+ +
+ {/if} + diff --git a/packages/bbui/src/Layout/Layout.svelte b/packages/bbui/src/Layout/Layout.svelte index 0dcb1f46ee..c66a409242 100644 --- a/packages/bbui/src/Layout/Layout.svelte +++ b/packages/bbui/src/Layout/Layout.svelte @@ -36,6 +36,10 @@ padding-left: var(--spacing-l); padding-right: var(--spacing-l); } + .paddingX-XL { + padding-left: var(--spacing-xl); + padding-right: var(--spacing-xl); + } .paddingY-S { padding-top: var(--spacing-s); padding-bottom: var(--spacing-s); @@ -48,6 +52,10 @@ padding-top: var(--spacing-l); padding-bottom: var(--spacing-l); } + .paddingY-XL { + padding-top: var(--spacing-xl); + padding-bottom: var(--spacing-xl); + } .gap-XXS { grid-gap: var(--spacing-xs); } diff --git a/packages/bbui/src/Layout/Page.svelte b/packages/bbui/src/Layout/Page.svelte index 9658f9b9f1..c12d54787b 100644 --- a/packages/bbui/src/Layout/Page.svelte +++ b/packages/bbui/src/Layout/Page.svelte @@ -1,8 +1,9 @@ -
+
@@ -12,7 +13,7 @@ flex-direction: column; justify-content: flex-start; align-items: stretch; - max-width: 80ch; + max-width: var(--max-width); margin: 0 auto; padding: calc(var(--spacing-xl) * 2); min-height: calc(100% - var(--spacing-xl) * 4); diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index 89c10bb625..10cd4b10ba 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -23,6 +23,7 @@ export let secondaryButtonText = undefined export let secondaryAction = undefined export let secondaryButtonWarning = false + export let dataCy = null const { hide, cancel } = getContext(Context.Modal) let loading = false @@ -63,21 +64,26 @@ role="dialog" tabindex="-1" aria-modal="true" + data-cy={dataCy} >
- {#if title} + {#if title || $$slots.header}

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

{#if showDivider} {/if} {/if} +
diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index 6c9c6cc9a3..1017ef71fc 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -10,6 +10,17 @@ export let anchor export let align = "right" export let portalTarget + export let dataCy + + export let direction = "bottom" + export let showTip = false + + let tipSvg = + ' ' + + $: tooltipClasses = showTip + ? `spectrum-Popover--withTip spectrum-Popover--${direction}` + : "" export const show = () => { dispatch("open") @@ -37,9 +48,14 @@ use:positionDropdown={{ anchor, align }} use:clickOutside={hide} on:keydown={handleEscape} - class="spectrum-Popover is-open" + class={"spectrum-Popover is-open " + (tooltipClasses || "")} role="presentation" + data-cy={dataCy} > + {#if showTip} + {@html tipSvg} + {/if} +
@@ -49,4 +65,13 @@ .spectrum-Popover { min-width: var(--spectrum-global-dimension-size-2000) !important; } + .spectrum-Popover.is-open.spectrum-Popover--withTip { + margin-top: var(--spacing-xs); + margin-left: var(--spacing-xl); + } + :global(.spectrum-Popover--bottom .spectrum-Popover-tip), + :global(.spectrum-Popover--top .spectrum-Popover-tip) { + left: 90%; + margin-left: calc(var(--spectrum-global-dimension-size-150) * -1); + } diff --git a/packages/bbui/src/ProgressBar/ProgressBar.svelte b/packages/bbui/src/ProgressBar/ProgressBar.svelte index 221453d428..0bc50fb452 100644 --- a/packages/bbui/src/ProgressBar/ProgressBar.svelte +++ b/packages/bbui/src/ProgressBar/ProgressBar.svelte @@ -16,11 +16,11 @@ easing: easing, }) - $: if (value) $progress = value + $: if (value || value === 0) $progress = value
{#if $$slots}
{/if} - {#if value} + {#if value || value === 0}
@@ -47,7 +47,7 @@
{/each} {:else} -
-
- - - -
No rows found
-
+
+ {#if customPlaceholder} + + {:else} +
+ + + +
No rows found
+
+ {/if}
{/if}
@@ -458,6 +470,13 @@ justify-content: flex-start; align-items: center; user-select: none; + border-top: var(--table-border); + } + .spectrum-Table-headCell:first-of-type { + border-left: var(--table-border); + } + .spectrum-Table-headCell:last-of-type { + border-right: var(--table-border); } .spectrum-Table-headCell--alignCenter { justify-content: center; @@ -576,16 +595,19 @@ border-top: none; grid-column: 1 / -1; background-color: var(--table-bg); + padding: 40px; } .placeholder--no-fields { border-top: var(--table-border); } + .placeholder--custom { + justify-content: flex-start; + } .wrapper--quiet .placeholder { border-left: none; border-right: none; } .placeholder-content { - padding: 40px; display: flex; flex-direction: column; justify-content: center; diff --git a/packages/bbui/src/Tabs/Tabs.svelte b/packages/bbui/src/Tabs/Tabs.svelte index 6930a6cdb5..579c61e28d 100644 --- a/packages/bbui/src/Tabs/Tabs.svelte +++ b/packages/bbui/src/Tabs/Tabs.svelte @@ -108,7 +108,7 @@ padding-left: var(--spacing-xl); padding-right: var(--spacing-xl); position: relative; - border-bottom: var(--border-light); + border-bottom: 1px solid var(--spectrum-global-color-gray-300); } .spectrum-Tabs-content { margin-top: var(--spectrum-global-dimension-static-size-150); diff --git a/packages/bbui/src/Typography/Body.svelte b/packages/bbui/src/Typography/Body.svelte index 9a1fe266fc..4690076809 100644 --- a/packages/bbui/src/Typography/Body.svelte +++ b/packages/bbui/src/Typography/Body.svelte @@ -5,12 +5,14 @@ export let serif = false export let weight = null export let textAlign = null + export let color = null

diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index cf40e12d74..b02783e0bd 100644 --- a/packages/bbui/src/helpers.js +++ b/packages/bbui/src/helpers.js @@ -106,3 +106,29 @@ export const deepSet = (obj, key, value) => { export const cloneDeep = obj => { return JSON.parse(JSON.stringify(obj)) } + +/** + * Copies a value to the clipboard + * @param value the value to copy + */ +export const copyToClipboard = value => { + return new Promise(res => { + if (navigator.clipboard && window.isSecureContext) { + // Try using the clipboard API first + navigator.clipboard.writeText(value).then(res) + } else { + // Fall back to the textarea hack + let textArea = document.createElement("textarea") + textArea.value = value + textArea.style.position = "fixed" + textArea.style.left = "-9999px" + textArea.style.top = "-9999px" + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + document.execCommand("copy") + textArea.remove() + res() + } + }) +} diff --git a/packages/bbui/yarn.lock b/packages/bbui/yarn.lock index 5baad30282..0bff3e86d9 100644 --- a/packages/bbui/yarn.lock +++ b/packages/bbui/yarn.lock @@ -1574,9 +1574,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp@~0.5.1: version "0.5.5" diff --git a/packages/builder/cypress/integration/appPublishWorkflow.spec.js b/packages/builder/cypress/integration/appPublishWorkflow.spec.js new file mode 100644 index 0000000000..d18233e0e7 --- /dev/null +++ b/packages/builder/cypress/integration/appPublishWorkflow.spec.js @@ -0,0 +1,112 @@ +import filterTests from "../support/filterTests" + +filterTests(['all'], () => { + context("Publish Application Workflow", () => { + before(() => { + cy.login() + cy.createTestApp() + }) + + it("Should reflect the unpublished status correctly", () => { + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(1000) + + cy.get(".appTable .app-status").eq(0) + .within(() => { + cy.contains("Unpublished") + cy.get("svg[aria-label='GlobeStrike']").should("exist") + }) + + cy.get(".appTable .app-row-actions").eq(0) + .within(() => { + cy.get(".spectrum-Button").contains("Preview") + cy.get(".spectrum-Button").contains("Edit").click({ force: true }) + }) + + cy.get(".deployment-top-nav svg[aria-label='GlobeStrike']").should("exist") + cy.get(".deployment-top-nav svg[aria-label='Globe']").should("not.exist") + }) + + it("Should publish an application and correctly reflect that", () => { + //Assuming the previous test was run and the unpublished app is open in edit mode. + cy.get(".toprightnav button.spectrum-Button").contains("Publish").click({ force : true }) + + cy.get(".spectrum-Modal [data-cy='deploy-app-modal']").should("be.visible") + .within(() => { + cy.get(".spectrum-Button").contains("Publish").click({ force : true }) + cy.wait(1000) + }); + + //Verify that the app url is presented correctly to the user + cy.get(".spectrum-Modal [data-cy='deploy-app-success-modal']") + .should("be.visible") + .within(() => { + let appUrl = Cypress.config().baseUrl + '/app/cypress-tests' + cy.get("[data-cy='deployed-app-url'] input").should('have.value', appUrl) + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) + + cy.visit(`${Cypress.config().baseUrl}/builder`) + cy.wait(1000) + + cy.get(".appTable .app-status").eq(0) + .within(() => { + cy.contains("Published") + cy.get("svg[aria-label='Globe']").should("exist") + }) + + cy.get(".appTable .app-row-actions").eq(0) + .within(() => { + cy.get(".spectrum-Button").contains("View app") + cy.get(".spectrum-Button").contains("Edit").click({ force: true }) + }) + + cy.get(".deployment-top-nav svg[aria-label='Globe']").should("exist").click({ force: true }) + + cy.get("[data-cy='publish-popover-menu']").should("be.visible") + .within(() => { + cy.get("[data-cy='publish-popover-action']").should("exist") + cy.get("button").contains("View app").should("exist") + cy.get(".publish-popover-message").should("have.text", "Last published a few seconds ago") + }) + }) + + it("Should unpublish an application from the top navigation and reflect the status change", () => { + //Assuming the previous test app exists and is published + + cy.visit(`${Cypress.config().baseUrl}/builder`) + + cy.get(".appTable .app-status").eq(0) + .within(() => { + cy.contains("Published") + cy.get("svg[aria-label='Globe']").should("exist") + }) + + cy.get(".appTable .app-row-actions").eq(0) + .within(() => { + cy.get(".spectrum-Button").contains("View app") + cy.get(".spectrum-Button").contains("Edit").click({ force: true }) + }) + + //The published status + cy.get(".deployment-top-nav svg[aria-label='Globe']").should("exist") + .click({ force: true }) + + cy.get("[data-cy='publish-popover-menu']").should("be.visible") + cy.get("[data-cy='publish-popover-menu'] [data-cy='publish-popover-action']") + .click({ force : true }) + + cy.get("[data-cy='unpublish-modal']").should("be.visible") + .within(() => { + cy.get(".confirm-wrap button").click({ force: true } + )}) + + cy.get(".deployment-top-nav svg[aria-label='GlobeStrike']").should("exist") + + cy.visit(`${Cypress.config().baseUrl}/builder`) + + cy.get(".appTable .app-status").eq(0).contains("Unpublished") + + }) + }) +}) diff --git a/packages/builder/cypress/integration/autoScreensUI.spec.js b/packages/builder/cypress/integration/autoScreensUI.spec.js index ab65e6561f..2c2a43e711 100644 --- a/packages/builder/cypress/integration/autoScreensUI.spec.js +++ b/packages/builder/cypress/integration/autoScreensUI.spec.js @@ -1,51 +1,103 @@ import filterTests from "../support/filterTests" filterTests(['smoke', 'all'], () => { - context("Auto Screens UI", () => { - before(() => { - cy.login() - cy.createTestApp() - }) - - it("should generate internal table screens", () => { - // Create autogenerated screens from the internal table - cy.createAutogeneratedScreens(["Cypress Tests"]) - // Confirm screens have been auto generated - cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) - cy.get(".nav-items-container").should('contain', 'cypress-tests/:id') - .and('contain', 'cypress-tests/new/row') - }) - - it("should generate multiple internal table screens at once", () => { - // Create a second internal table - const initialTable = "Cypress Tests" - const secondTable = "Table Two" - cy.createTable(secondTable) - // Create autogenerated screens from the internal tables - cy.createAutogeneratedScreens([initialTable, secondTable]) - // Confirm screens have been auto generated - cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) - // Previously generated tables are suffixed with numbers - as expected - cy.get(".nav-items-container").should('contain', 'cypress-tests-2/:id') - .and('contain', 'cypress-tests-2/new/row') - cy.get(".nav-items-container").contains("table-two").click() - cy.get(".nav-items-container").should('contain', 'table-two/:id') - .and('contain', 'table-two/new/row') - }) - - if (Cypress.env("TEST_ENV")) { - it("should generate data source screens", () => { - // Using MySQL data source for testing this - const datasource = "MySQL" - // Select & configure MySQL data source - cy.selectExternalDatasource(datasource) - cy.addDatasourceConfig(datasource) - // Create autogenerated screens from a MySQL table - MySQL contains books table - cy.createAutogeneratedScreens(["books"]) - cy.get(".nav-items-container").contains("books").click() - cy.get(".nav-items-container").should('contain', 'books/:id') - .and('contain', 'books/new/row') - }) - } + context("Auto Screens UI", () => { + before(() => { + cy.login() }) + + it("should disable the autogenerated screen options if no sources are available", () => { + cy.createApp("First Test App", false) + + cy.closeModal(); + + cy.contains("Design").click() + cy.get("[aria-label=AddCircle]").click() + cy.get(".spectrum-Modal").within(() => { + cy.get(".item.disabled").contains("Autogenerated screens") + cy.get(".confirm-wrap .spectrum-Button").should('be.disabled') + }) + + cy.deleteAllApps() + }); + + it("should not display incompatible sources", () => { + cy.createApp("Test App") + + cy.selectExternalDatasource("REST") + cy.selectExternalDatasource("S3") + cy.get(".spectrum-Modal").within(() => { + cy.get(".spectrum-Button").contains("Save and continue to query").click({ force : true }) + }) + + cy.navigateToAutogeneratedModal() + + cy.get('.data-source-entry').should('have.length', 1) + cy.get('.data-source-entry') + + cy.deleteAllApps() + }); + + it("should generate internal table screens", () => { + cy.createTestApp() + // Create Autogenerated screens from the internal table + cy.createDatasourceScreen(["Cypress Tests"]) + // Confirm screens have been auto generated + cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) + cy.get(".nav-items-container").should('contain', 'cypress-tests/:id') + .and('contain', 'cypress-tests/new/row') + }) + + it("should generate multiple internal table screens at once", () => { + // Create a second internal table + const initialTable = "Cypress Tests" + const secondTable = "Table Two" + cy.createTable(secondTable) + // Create Autogenerated screens from the internal tables + cy.createDatasourceScreen([initialTable, secondTable]) + // Confirm screens have been auto generated + cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) + // Previously generated tables are suffixed with numbers - as expected + cy.get(".nav-items-container").should('contain', 'cypress-tests-2/:id') + .and('contain', 'cypress-tests-2/new/row') + cy.get(".nav-items-container").contains("table-two").click() + cy.get(".nav-items-container").should('contain', 'table-two/:id') + .and('contain', 'table-two/new/row') + }) + + it("should generate multiple internal table screens with the same screen access level", () => { + //The tables created in the previous step still exist + cy.createTable("Table Three") + cy.createTable("Table Four") + cy.createDatasourceScreen(["Table Three", "Table Four"], "Admin") + + cy.get(".nav-items-container").contains("table-three").click() + cy.get(".nav-items-container").should('contain', 'table-three/:id') + .and('contain', 'table-three/new/row') + + cy.get(".nav-items-container").contains("table-four").click() + cy.get(".nav-items-container").should('contain', 'table-four/:id') + .and('contain', 'table-four/new/row') + + //The access level should now be set to admin. Previous screens should be filtered. + cy.get(".nav-items-container").contains("table-two").should('not.exist') + cy.get(".nav-items-container").contains("cypress-tests").should('not.exist') + }) + + if (Cypress.env("TEST_ENV")) { + it("should generate data source screens", () => { + // Using MySQL data source for testing this + const datasource = "MySQL" + // Select & configure MySQL data source + cy.selectExternalDatasource(datasource) + cy.addDatasourceConfig(datasource) + // Create Autogenerated screens from a MySQL table - MySQL contains books table + cy.createDatasourceScreen(["books"]) + + cy.get(".nav-items-container").contains("books").click() + cy.get(".nav-items-container").should('contain', 'books/:id') + .and('contain', 'books/new/row') + }) + } + }) }) diff --git a/packages/builder/cypress/integration/changeAppIconAndColour.spec.js b/packages/builder/cypress/integration/changeAppIconAndColour.spec.js index 0db2d49e3f..0f623ddb04 100644 --- a/packages/builder/cypress/integration/changeAppIconAndColour.spec.js +++ b/packages/builder/cypress/integration/changeAppIconAndColour.spec.js @@ -11,7 +11,7 @@ filterTests(['all'], () => { cy.applicationInAppTable("Cypress Tests") cy.get(".appTable") .within(() => { - cy.get(".spectrum-Icon").eq(1).click() + cy.get(".app-row-actions-icon").eq(0).click() }) cy.get(".spectrum-Menu").contains("Edit icon").click() // Select random icon @@ -38,6 +38,7 @@ filterTests(['all'], () => { cy.get(".title").children().children() .should('have.attr', 'style').and('contains', 'color') }) + cy.deleteAllApps() }) }) }) diff --git a/packages/builder/cypress/integration/createApp.spec.js b/packages/builder/cypress/integration/createApp.spec.js index 4867534241..13c5979df9 100644 --- a/packages/builder/cypress/integration/createApp.spec.js +++ b/packages/builder/cypress/integration/createApp.spec.js @@ -25,9 +25,13 @@ filterTests(['smoke', 'all'], () => { cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) - if (Cypress.env("TEST_ENV")) { - cy.get(".spectrum-Button").contains("Templates").click({force: true}) - } + cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) + .its("body") + .then(val => { + if (val.length > 0) { + cy.get(".spectrum-Button").contains("Templates").click({force: true}) + } + }) cy.get(".template-category-filters").should("exist") cy.get(".template-categories").should("exist") diff --git a/packages/builder/cypress/integration/createAutomation.spec.js b/packages/builder/cypress/integration/createAutomation.spec.js index b9355f7faf..69ef3f98a3 100644 --- a/packages/builder/cypress/integration/createAutomation.spec.js +++ b/packages/builder/cypress/integration/createAutomation.spec.js @@ -11,7 +11,7 @@ filterTests(['smoke', 'all'], () => { cy.createTestTableWithData() cy.wait(2000) cy.contains("Automate").click() - cy.get("[data-cy='new-screen'] > .spectrum-Icon").click() + cy.get(".add-button .spectrum-Icon").click() cy.get(".modal-inner-wrapper").within(() => { cy.get("input").type("Add Row") cy.contains("Row Created").click({ force: true }) @@ -20,7 +20,6 @@ filterTests(['smoke', 'all'], () => { }) // Setup trigger - cy.contains("Setup").click() cy.get(".spectrum-Picker-label").click() cy.wait(500) cy.contains("dog").click() @@ -32,12 +31,11 @@ filterTests(['smoke', 'all'], () => { cy.contains("Create Row").trigger('mouseover').click().click() cy.get(".spectrum-Button--cta").click() }) - cy.contains("Setup").click() cy.get(".spectrum-Picker-label").eq(1).click() cy.contains("dog").click() cy.get(".spectrum-Textfield-input") - .first() - .type("{{ trigger.row.name }}", { parseSpecialCharSequences: false }) + .first() + .type("{{ trigger.row.name }}", { parseSpecialCharSequences: false }) cy.get(".spectrum-Textfield-input") .eq(1) .type("11") diff --git a/packages/builder/cypress/integration/createBinding.spec.js b/packages/builder/cypress/integration/createBinding.spec.js index 8bf1ec8ea4..57cd0cc5fc 100644 --- a/packages/builder/cypress/integration/createBinding.spec.js +++ b/packages/builder/cypress/integration/createBinding.spec.js @@ -26,7 +26,7 @@ filterTests(['smoke', 'all'], () => { it("should add a URL param binding", () => { const paramName = "foo" - cy.createScreen("Test Param", `/test/:${paramName}`) + cy.createScreen(`/test/:${paramName}`) cy.addComponent("Elements", "Paragraph").then(componentId => { addSettingBinding("text", `URL.${paramName}`) // The builder preview pages don't have a real URL, so all we can do diff --git a/packages/builder/cypress/integration/createScreen.js b/packages/builder/cypress/integration/createScreen.js index ada68d82dc..ae10577ff0 100644 --- a/packages/builder/cypress/integration/createScreen.js +++ b/packages/builder/cypress/integration/createScreen.js @@ -9,17 +9,33 @@ filterTests(["smoke", "all"], () => { }) it("Should successfully create a screen", () => { - cy.createScreen("Test Screen", "/test") + cy.createScreen("/test") cy.get(".nav-items-container").within(() => { cy.contains("/test").should("exist") }) }) it("Should update the url", () => { - cy.createScreen("Test Screen", "test with spaces") + cy.createScreen("test with spaces") cy.get(".nav-items-container").within(() => { cy.contains("/test-with-spaces").should("exist") }) }) + + it("Should create a blank screen with the selected access level", () => { + cy.createScreen("admin only", "Admin") + + cy.get(".nav-items-container").within(() => { + cy.contains("/admin-only").should("exist") + }) + + cy.createScreen("open to all", "Public") + + cy.get(".nav-items-container").within(() => { + cy.contains("/open-to-all").should("exist") + //The access level should now be set to admin. Previous screens should be filtered. + cy.get(".nav-item").contains("/test-screen").should("not.exist") + }) + }) }) }) diff --git a/packages/builder/cypress/integration/createTable.spec.js b/packages/builder/cypress/integration/createTable.spec.js index 81b7c2f045..4600807cbc 100644 --- a/packages/builder/cypress/integration/createTable.spec.js +++ b/packages/builder/cypress/integration/createTable.spec.js @@ -55,13 +55,14 @@ filterTests(["smoke", "all"], () => { if (Cypress.env("TEST_ENV")) { // No Pagination in CI - Test env only for the next two tests - it("Adds 15 rows and checks pagination", () => { + xit("Adds 15 rows and checks pagination", () => { // 10 rows per page, 15 rows should create 2 pages within table const totalRows = 16 for (let i = 1; i < totalRows; i++) { cy.addRow([i]) } - cy.wait(1000) + cy.reload() + cy.wait(2000) cy.get(".spectrum-Pagination").within(() => { cy.get(".spectrum-ActionButton").eq(1).click() }) @@ -70,13 +71,13 @@ filterTests(["smoke", "all"], () => { }) }) - it("Deletes rows and checks pagination", () => { - // Delete rows, removing second page of rows from table - const deleteRows = 5 + xit("Deletes rows and checks pagination", () => { + // Delete rows, removing second page from table cy.get(".spectrum-Checkbox-input").check({ force: true }) - cy.get(".spectrum-Table") - cy.contains("Delete 5 row(s)").click() - cy.get(".spectrum-Modal").contains("Delete").click() + cy.get(".popovers").within(() => { + cy.get(".spectrum-Button").click({ force: true }) + }) + cy.get(".spectrum-Dialog-grid").contains("Delete").click({ force: true }) cy.wait(1000) // Confirm table only has one page diff --git a/packages/builder/cypress/integration/createView.spec.js b/packages/builder/cypress/integration/createView.spec.js index a8c3b03cee..feaf1c3b5f 100644 --- a/packages/builder/cypress/integration/createView.spec.js +++ b/packages/builder/cypress/integration/createView.spec.js @@ -125,7 +125,7 @@ filterTests(['smoke', 'all'], () => { it("renames a view", () => { cy.contains(".nav-item", "Test View") - .find(".actions .icon") + .find(".actions .icon.open-popover") .click({ force: true }) cy.get(".spectrum-Menu-itemLabel").contains("Edit").click() cy.get(".modal-inner-wrapper").within(() => { @@ -138,7 +138,7 @@ filterTests(['smoke', 'all'], () => { it("deletes a view", () => { cy.contains(".nav-item", "Test View Updated") - .find(".actions .icon") + .find(".actions .icon.open-popover") .click({ force: true }) cy.contains("Delete").click() cy.contains("Delete View").click() diff --git a/packages/builder/cypress/integration/datasources/mySql.spec.js b/packages/builder/cypress/integration/datasources/mySql.spec.js index 03f59a6004..98bb2f2acf 100644 --- a/packages/builder/cypress/integration/datasources/mySql.spec.js +++ b/packages/builder/cypress/integration/datasources/mySql.spec.js @@ -19,6 +19,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Button") .contains("Save and fetch tables") .click({ force: true }) + cy.wait(500) // Intercept Request after button click & apply assertions cy.wait("@datasource") cy.get("@datasource") @@ -31,6 +32,7 @@ filterTests(["all"], () => { cy.get("@datasource") .its("response.body") .should("have.property", "status", 500) + cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) it("should add MySQL data source and fetch tables", () => { @@ -72,10 +74,13 @@ filterTests(["all"], () => { cy.get(".spectrum-Popover").contains("COUNTRIES").click() cy.get(".spectrum-Picker").eq(4).click() cy.get(".spectrum-Popover").contains("REGION_ID").click() - // Save relationship & reload page - cy.get(".spectrum-Button").contains("Save").click({ force: true }) - cy.reload() }) + // Save relationship & reload page + cy.get(".spectrum-ButtonGroup").within(() => { + cy.get(".spectrum-Button").contains("Save").click({ force: true }) + }) + cy.reload() + // Confirm table length & column name cy.get(".spectrum-Table") .eq(1) @@ -131,7 +136,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Table") .eq(1) .within(() => { - cy.get(".spectrum-Table-row").eq(0).click() + cy.get(".spectrum-Table-row").eq(0).click({ force: true }) cy.wait(500) }) cy.get(".spectrum-Dialog-grid").within(() => { @@ -175,11 +180,12 @@ filterTests(["all"], () => { }) it("should duplicate a query", () => { - // Get last nav item - The query + /// Get query nav item - QueryName cy.get(".nav-item") - .last() + .contains(queryName) + .parent() .within(() => { - cy.get(".icon").eq(1).click({ force: true }) + cy.get(".spectrum-Icon").eq(1).click({ force: true }) }) // Select and confirm duplication cy.get(".spectrum-Menu").contains("Duplicate").click() @@ -199,23 +205,21 @@ filterTests(["all"], () => { }) it("should delete a query", () => { - // Get last nav item - The query - for (let i = 0; i < 2; i++) { - cy.get(".nav-item") - .last() - .within(() => { - cy.get(".icon").eq(1).click({ force: true }) - }) - // Select Delete - cy.get(".spectrum-Menu").contains("Delete").click() - cy.get(".spectrum-Button") - .contains("Delete Query") - .click({ force: true }) - cy.wait(1000) - } + // Get query nav item - QueryName + cy.get(".nav-item") + .contains(queryName) + .parent() + .within(() => { + cy.get(".spectrum-Icon").eq(1).click({ force: true }) + }) + // Select Delete + cy.get(".spectrum-Menu").contains("Delete").click() + cy.get(".spectrum-Button") + .contains("Delete Query") + .click({ force: true }) + cy.wait(1000) // Confirm deletion cy.get(".nav-item").should("not.contain", queryName) - cy.get(".nav-item").should("not.contain", queryRename) }) } }) diff --git a/packages/builder/cypress/integration/datasources/oracle.spec.js b/packages/builder/cypress/integration/datasources/oracle.spec.js index 73c25001c9..4c4d33d654 100644 --- a/packages/builder/cypress/integration/datasources/oracle.spec.js +++ b/packages/builder/cypress/integration/datasources/oracle.spec.js @@ -46,9 +46,10 @@ filterTests(["all"], () => { cy.get("@datasource") .its("response.body") .should("have.property", "status", 500) + cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) - it("should add Oracle data source and fetch tables", () => { + xit("should add Oracle data source and fetch tables", () => { // Add & configure Oracle data source cy.selectExternalDatasource(datasource) cy.intercept("**/datasources").as("datasource") @@ -64,7 +65,7 @@ filterTests(["all"], () => { .should("be.gt", 0) }) - it("should define a One relationship type", () => { + xit("should define a One relationship type", () => { // Select relationship type & configure cy.get(".spectrum-Button") .contains("Define relationship") @@ -93,7 +94,7 @@ filterTests(["all"], () => { cy.get(".spectrum-Table-cell").should("contain", "COUNTRIES to REGIONS") }) - it("should define a Many relationship type", () => { + xit("should define a Many relationship type", () => { // Select relationship type & configure cy.get(".spectrum-Button") .contains("Define relationship") @@ -127,7 +128,7 @@ filterTests(["all"], () => { ) }) - it("should delete relationships", () => { + xit("should delete relationships", () => { // Delete both relationships cy.get(".spectrum-Table") .eq(1) @@ -156,7 +157,7 @@ filterTests(["all"], () => { }) }) - it("should add a query", () => { + xit("should add a query", () => { // Add query cy.get(".spectrum-Button").contains("Add query").click({ force: true }) cy.get(".spectrum-Form-item") @@ -181,7 +182,7 @@ filterTests(["all"], () => { cy.get(".nav-item").should("contain", queryName) }) - it("should duplicate a query", () => { + xit("should duplicate a query", () => { // Get query nav item cy.get(".nav-item") .contains(queryName) @@ -194,7 +195,7 @@ filterTests(["all"], () => { cy.get(".nav-item").should("contain", queryName + " (1)") }) - it("should edit a query name", () => { + xit("should edit a query name", () => { // Rename query cy.get(".spectrum-Form-item") .eq(0) @@ -206,7 +207,7 @@ filterTests(["all"], () => { cy.get(".nav-item").should("contain", queryRename) }) - it("should delete a query", () => { + xit("should delete a query", () => { // Get query nav item - QueryName cy.get(".nav-item") .contains(queryName) diff --git a/packages/builder/cypress/integration/datasources/postgreSql.spec.js b/packages/builder/cypress/integration/datasources/postgreSql.spec.js index 3f55636623..c7413bb7d1 100644 --- a/packages/builder/cypress/integration/datasources/postgreSql.spec.js +++ b/packages/builder/cypress/integration/datasources/postgreSql.spec.js @@ -21,16 +21,10 @@ filterTests(["all"], () => { .click({ force: true }) // Intercept Request after button click & apply assertions cy.wait("@datasource") - cy.get("@datasource") - .its("response.body") - .should( - "have.property", - "message", - "connect ECONNREFUSED 127.0.0.1:5432" - ) cy.get("@datasource") .its("response.body") .should("have.property", "status", 500) + cy.get(".spectrum-Button").contains("Skip table fetch").click({ force: true }) }) it("should add PostgreSQL data source and fetch tables", () => { @@ -113,13 +107,13 @@ filterTests(["all"], () => { }) it("should delete a relationship", () => { - cy.get(".hierarchy-items-container").contains(datasource).click() + cy.get(".hierarchy-items-container").contains("PostgreSQL-2").click() cy.reload() // Delete one relationship cy.get(".spectrum-Table") .eq(1) .within(() => { - cy.get(".spectrum-Table-row").eq(0).click() + cy.get(".spectrum-Table-row").eq(0).click({ force: true }) cy.wait(500) }) cy.get(".spectrum-Dialog-grid").within(() => { @@ -161,7 +155,7 @@ filterTests(["all"], () => { it("should switch to schema with no tables", () => { // Switch Schema - To one without any tables - cy.get(".hierarchy-items-container").contains(datasource).click() + cy.get(".hierarchy-items-container").contains("PostgreSQL-2").click() switchSchema("randomText") // No tables displayed @@ -208,11 +202,12 @@ filterTests(["all"], () => { }) it("should duplicate a query", () => { - // Get last nav item - The query + // Locate previously created query cy.get(".nav-item") - .last() + .contains(queryName) + .siblings(".actions") .within(() => { - cy.get(".icon").eq(1).click({ force: true }) + cy.get(".icon").click({ force: true }) }) // Select and confirm duplication cy.get(".spectrum-Menu").contains("Duplicate").click() @@ -240,23 +235,21 @@ filterTests(["all"], () => { }) it("should delete a query", () => { - // Get last nav item - The query - for (let i = 0; i < 2; i++) { - cy.get(".nav-item") - .last() - .within(() => { - cy.get(".icon").eq(1).click({ force: true }) - }) - // Select Delete - cy.get(".spectrum-Menu").contains("Delete").click() - cy.get(".spectrum-Button") - .contains("Delete Query") - .click({ force: true }) - cy.wait(1000) - } + // Get query nav item - QueryName + cy.get(".nav-item") + .contains(queryName) + .parent() + .within(() => { + cy.get(".spectrum-Icon").eq(1).click({ force: true }) + }) + // Select Delete + cy.get(".spectrum-Menu").contains("Delete").click() + cy.get(".spectrum-Button") + .contains("Delete Query") + .click({ force: true }) + cy.wait(1000) // Confirm deletion cy.get(".nav-item").should("not.contain", queryName) - cy.get(".nav-item").should("not.contain", queryRename) }) const switchSchema = schema => { diff --git a/packages/builder/cypress/integration/renameAnApplication.spec.js b/packages/builder/cypress/integration/renameAnApplication.spec.js index f4899f98a0..120c0d54d7 100644 --- a/packages/builder/cypress/integration/renameAnApplication.spec.js +++ b/packages/builder/cypress/integration/renameAnApplication.spec.js @@ -99,30 +99,32 @@ filterTests(['all'], () => { cy.searchForApplication(originalName) cy.get(".appTable") .within(() => { - cy.get(".spectrum-Icon").eq(1).click() - }) - // Check for when an app is published - if (published == true) { - // Should not have Edit as option, will unpublish app - cy.should("not.have.value", "Edit") - cy.get(".spectrum-Menu").contains("Unpublish").click() - cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() - cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() - } - cy.contains("Edit").click() - cy.get(".spectrum-Modal") - .within(() => { - if (noName == true) { - cy.get("input").clear() - cy.get(".spectrum-Dialog-grid").click() - .contains("App name must be letters, numbers and spaces only") - return cy - } + cy.get("[aria-label='More']").eq(0).click() + }) + // Check for when an app is published + if (published == true) { + // Should not have Edit as option, will unpublish app + cy.should("not.have.value", "Edit") + cy.get(".spectrum-Menu").contains("Unpublish").click() + cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() + cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() + } + cy.get("[data-cy='app-row-actions-menu-popover']").eq(0).within(() => { + cy.get(".spectrum-Menu-item").contains("Edit").click({ force: true }) + }) + cy.get(".spectrum-Modal") + .within(() => { + if (noName == true) { cy.get("input").clear() - cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() - cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true }) - cy.wait(500) - }) - } + cy.get(".spectrum-Dialog-grid").click() + .contains("App name must be letters, numbers and spaces only") + return cy + } + cy.get("input").clear() + cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() + cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true }) + cy.wait(500) + }) + } }) }) diff --git a/packages/builder/cypress/integration/revertApp.spec.js b/packages/builder/cypress/integration/revertApp.spec.js index c64d19f230..9d5e4f0f63 100644 --- a/packages/builder/cypress/integration/revertApp.spec.js +++ b/packages/builder/cypress/integration/revertApp.spec.js @@ -10,9 +10,9 @@ filterTests(['smoke', 'all'], () => { it("should try to revert an unpublished app", () => { // Click revert icon cy.get(".toprightnav").within(() => { - cy.get(".spectrum-Icon").eq(1).click() + cy.get("[aria-label='Revert']").click({ force: true }) }) - cy.get(".spectrum-Dialog-grid").within(() => { + cy.get(".spectrum-Modal").within(() => { // Enter app name before revert cy.get("input").type("Cypress Tests") cy.intercept('**/revert').as('revertApp') @@ -33,11 +33,15 @@ filterTests(['smoke', 'all'], () => { cy.get(".spectrum-ButtonGroup").within(() => { cy.get(".spectrum-Button").contains("Publish").click({ force: true }) }) + cy.wait(1000) + cy.get(".spectrum-ButtonGroup").within(() => { + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) // Add second component - Button cy.addComponent("Elements", "Button") // Click Revert cy.get(".toprightnav").within(() => { - cy.get(".spectrum-Icon").eq(1).click() + cy.get("[aria-label='Revert']").click({ force: true }) }) cy.get(".spectrum-Dialog-grid").within(() => { // Click Revert @@ -54,7 +58,7 @@ filterTests(['smoke', 'all'], () => { it("should enter incorrect app name when reverting", () => { // Click Revert cy.get(".toprightnav").within(() => { - cy.get(".spectrum-Icon").eq(1).click({ force: true }) + cy.get("[aria-label='Revert']").click({ force: true }) }) // Enter incorrect app name cy.get(".spectrum-Dialog-grid").within(() => { diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index f4ccdcca24..6c2cac5b31 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -32,7 +32,17 @@ Cypress.Commands.add("login", () => { }) }) -Cypress.Commands.add("createApp", name => { +Cypress.Commands.add("closeModal", () => { + cy.get(".spectrum-Modal").within(() => { + cy.get(".close-icon").click() + cy.wait(500) + }) +}) + +Cypress.Commands.add("createApp", (name, addDefaultTable) => { + const shouldCreateDefaultTable = + typeof addDefaultTable != "boolean" ? true : addDefaultTable + cy.visit(`${Cypress.config().baseUrl}/builder`) cy.wait(500) cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) @@ -51,7 +61,9 @@ Cypress.Commands.add("createApp", name => { cy.get(".spectrum-ButtonGroup").contains("Create app").click() cy.wait(10000) }) - cy.createTable("Cypress Tests", true) + if (shouldCreateDefaultTable) { + cy.createTable("Cypress Tests", true) + } }) Cypress.Commands.add("deleteApp", name => { @@ -60,43 +72,48 @@ Cypress.Commands.add("deleteApp", name => { cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) .its("body") .then(val => { - if (val.length > 0) { - if (Cypress.env("TEST_ENV")) { - cy.searchForApplication(name) - cy.get(".appTable").within(() => { - cy.get(".spectrum-Icon").eq(1).click() + const findAppName = val.some(val => val.name == name) + if (findAppName) { + if (val.length > 0) { + if (Cypress.env("TEST_ENV")) { + cy.searchForApplication(name) + cy.get(".appTable").within(() => { + cy.get(".spectrum-Icon").eq(1).click() + }) + } else { + const appId = val.reduce((acc, app) => { + if (name === app.name) { + acc = app.appId + } + return acc + }, "") + + if (appId == "") { + return + } + + const appIdParsed = appId.split("_").pop() + const actionEleId = `[data-cy=row_actions_${appIdParsed}]` + cy.get(actionEleId).within(() => { + cy.get(".spectrum-Icon").eq(0).click() + }) + } + + cy.get(".spectrum-Menu").then($menu => { + if ($menu.text().includes("Unpublish")) { + cy.get(".spectrum-Menu").contains("Unpublish").click() + cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() + } else { + cy.get(".spectrum-Menu").contains("Delete").click() + cy.get(".spectrum-Dialog-grid").within(() => { + cy.get("input").type(name) + }) + cy.get(".spectrum-Button--warning").click() + } }) } else { - const appId = val.reduce((acc, app) => { - if (name === app.name) { - acc = app.appId - } - return acc - }, "") - - if (appId == "") { - return - } - - const appIdParsed = appId.split("_").pop() - const actionEleId = `[data-cy=row_actions_${appIdParsed}]` - cy.get(actionEleId).within(() => { - cy.get(".spectrum-Icon").eq(0).click() - }) + return } - - cy.get(".spectrum-Menu").then($menu => { - if ($menu.text().includes("Unpublish")) { - cy.get(".spectrum-Menu").contains("Unpublish").click() - cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() - } else { - cy.get(".spectrum-Menu").contains("Delete").click() - cy.get(".spectrum-Dialog-grid").within(() => { - cy.get("input").type(name) - }) - cy.get(".spectrum-Button--warning").click() - } - }) } else { return } @@ -130,7 +147,7 @@ Cypress.Commands.add("createTestApp", () => { const appName = "Cypress Tests" cy.deleteApp(appName) cy.createApp(appName, "This app is used for Cypress testing.") - cy.createScreen("home", "home") + cy.createScreen("home") }) Cypress.Commands.add("createTestTableWithData", () => { @@ -270,33 +287,99 @@ Cypress.Commands.add("navigateToDataSection", () => { cy.contains("Data").click() }) -Cypress.Commands.add("createScreen", (screenName, route) => { +//Blank +Cypress.Commands.add("createScreen", (route, accessLevelLabel) => { cy.contains("Design").click() cy.get("[aria-label=AddCircle]").click() cy.get(".spectrum-Modal").within(() => { - cy.get(".item").contains("Blank").click() - cy.get(".spectrum-Button").contains("Add screens").click({ force: true }) + cy.get("[data-cy='blank-screen']").click() + cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.wait(500) }) cy.get(".spectrum-Dialog-grid").within(() => { - cy.get(".spectrum-Form-itemField").eq(0).type(screenName) - cy.get(".spectrum-Form-itemField").eq(1).type(route) + cy.get(".spectrum-Form-itemField").eq(0).type(route) cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.wait(1000) }) + + cy.get(".spectrum-Modal").within(() => { + if (accessLevelLabel) { + cy.get(".spectrum-Picker-label").click() + cy.wait(500) + cy.contains(accessLevelLabel).click() + } + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) }) -Cypress.Commands.add("createAutogeneratedScreens", screenNames => { +Cypress.Commands.add( + "createDatasourceScreen", + (datasourceNames, accessLevelLabel) => { + cy.contains("Design").click() + cy.get("[aria-label=AddCircle]").click() + cy.get(".spectrum-Modal").within(() => { + cy.get(".item").contains("Autogenerated screens").click() + cy.get(".spectrum-Button").contains("Continue").click({ force: true }) + cy.wait(500) + }) + cy.get(".spectrum-Modal [data-cy='data-source-modal']").within(() => { + for (let i = 0; i < datasourceNames.length; i++) { + cy.get(".data-source-entry").contains(datasourceNames[i]).click() + //Ensure the check mark is visible + cy.get(".data-source-entry") + .contains(datasourceNames[i]) + .get(".data-source-check") + .should("exist") + } + + cy.get(".spectrum-Button").contains("Confirm").click({ force: true }) + }) + + cy.get(".spectrum-Modal").within(() => { + if (accessLevelLabel) { + cy.get(".spectrum-Picker-label").click() + cy.wait(500) + cy.contains(accessLevelLabel).click() + } + cy.get(".spectrum-Button").contains("Done").click({ force: true }) + }) + + cy.contains("Design").click() + } +) + +Cypress.Commands.add("navigateToAutogeneratedModal", () => { // Screen name must already exist within data source cy.contains("Design").click() cy.get("[aria-label=AddCircle]").click() - for (let i = 0; i < screenNames.length; i++) { - cy.get(".item").contains(screenNames[i]).click() - } - cy.get(".spectrum-Button").contains("Add screens").click({ force: true }) - cy.wait(4000) + cy.get(".spectrum-Modal").within(() => { + cy.get(".item").contains("Autogenerated screens").click() + cy.get(".spectrum-Button").contains("Continue").click({ force: true }) + cy.wait(500) + }) }) +Cypress.Commands.add( + "createAutogeneratedScreens", + (screenNames, accessLevelLabel) => { + cy.navigateToAutogeneratedModal() + + for (let i = 0; i < screenNames.length; i++) { + cy.get(".data-source-entry").contains(screenNames[i]).click() + } + + cy.get(".spectrum-Modal").within(() => { + if (accessLevelLabel) { + cy.get(".spectrum-Picker-label").click() + cy.wait(500) + cy.contains(accessLevelLabel).click() + } + cy.get(".spectrum-Button").contains("Confirm").click({ force: true }) + cy.wait(4000) + }) + } +) + Cypress.Commands.add("addRow", values => { cy.contains("Create row").click() cy.get(".spectrum-Modal").within(() => { @@ -390,6 +473,7 @@ Cypress.Commands.add("selectExternalDatasource", datasourceName => { cy.get(".add-button").click() }) // Clicks specified datasource & continue + cy.wait(1000) cy.get(".item-list").contains(datasourceName).click() cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Button").contains("Continue").click({ force: true }) @@ -410,7 +494,9 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => { if (datasource == "Oracle") { cy.get("input").clear().type(Cypress.env("oracle").HOST) } else { - cy.get("input").clear().type(Cypress.env("HOST_IP")) + cy.get("input") + .clear({ force: true }) + .type(Cypress.env("HOST_IP"), { force: true }) } }) }) diff --git a/packages/builder/package.json b/packages/builder/package.json index 2c0a13a6d9..fa64a8acb2 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.0.123", + "version": "1.0.124-alpha.0", "license": "GPL-3.0", "private": true, "scripts": { @@ -65,10 +65,10 @@ } }, "dependencies": { - "@budibase/bbui": "^1.0.123", - "@budibase/client": "^1.0.123", - "@budibase/frontend-core": "^1.0.123", - "@budibase/string-templates": "^1.0.123", + "@budibase/bbui": "^1.0.124-alpha.0", + "@budibase/client": "^1.0.124-alpha.0", + "@budibase/frontend-core": "^1.0.124-alpha.0", + "@budibase/string-templates": "^1.0.124-alpha.0", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/builder/src/analytics/constants.js b/packages/builder/src/analytics/constants.js index 177d5320a5..300b1e058d 100644 --- a/packages/builder/src/analytics/constants.js +++ b/packages/builder/src/analytics/constants.js @@ -22,6 +22,7 @@ export const Events = { }, SCREEN: { CREATED: "Screen Created", + CREATE_ROLE_UPDATED: "Changed Role On Screen Creation", }, AUTOMATION: { CREATED: "Automation Created", @@ -35,6 +36,7 @@ export const Events = { CREATED: "budibase:app_created", PUBLISHED: "budibase:app_published", UNPUBLISHED: "budibase:app_unpublished", + VIEW_PUBLISHED: "budibase:view_published_app", }, ANALYTICS: { OPT_IN: "budibase:analytics_opt_in", @@ -50,3 +52,9 @@ export const Events = { SAVED: "budibase:sso_saved", }, } + +export const EventSource = { + PORTAL: "portal", + URL: "url", + NOTIFICATION: "notification", +} diff --git a/packages/builder/src/analytics/index.js b/packages/builder/src/analytics/index.js index 3a4118347d..aa1ebf7e0e 100644 --- a/packages/builder/src/analytics/index.js +++ b/packages/builder/src/analytics/index.js @@ -2,7 +2,7 @@ import { API } from "api" import PosthogClient from "./PosthogClient" import IntercomClient from "./IntercomClient" import SentryClient from "./SentryClient" -import { Events } from "./constants" +import { Events, EventSource } from "./constants" const posthog = new PosthogClient( process.env.POSTHOG_TOKEN, @@ -57,5 +57,5 @@ class AnalyticsHub { const analytics = new AnalyticsHub() -export { Events } +export { Events, EventSource } export default analytics diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index c8b4ae8de9..8cbc629291 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -7,7 +7,11 @@ import { getComponentSettings, } from "./componentUtils" import { store } from "builderStore" -import { queries as queriesStores, tables as tablesStore } from "stores/backend" +import { + queries as queriesStores, + tables as tablesStore, + roles as rolesStore, +} from "stores/backend" import { makePropSafe, isJSBinding, @@ -33,6 +37,7 @@ export const getBindableProperties = (asset, componentId) => { const deviceBindings = getDeviceBindings() const stateBindings = getStateBindings() const selectedRowsBindings = getSelectedRowsBindings(asset) + const roleBindings = getRoleBindings() return [ ...contextBindings, ...urlBindings, @@ -40,6 +45,7 @@ export const getBindableProperties = (asset, componentId) => { ...userBindings, ...deviceBindings, ...selectedRowsBindings, + ...roleBindings, ] } @@ -391,6 +397,16 @@ const getUrlBindings = asset => { })) } +const getRoleBindings = () => { + return (get(rolesStore) || []).map(role => { + return { + type: "context", + runtimeBinding: `trim "${role._id}"`, + readableBinding: `Role.${role.name}`, + } + }) +} + /** * Gets all bindable properties exposed in a button actions flow up until * the specified action ID, as well as context provided for the action @@ -638,7 +654,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => { * Builds a form schema given a form component. * A form schema is a schema of all the fields nested anywhere within a form. */ -const buildFormSchema = component => { +export const buildFormSchema = component => { let schema = {} if (!component) { return schema diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js b/packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js index 16224a45bc..4d00c503fb 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js @@ -2,9 +2,15 @@ export default function (url) { return url .split("/") .map(part => { - // if parameter, then use as is - if (part.startsWith(":")) return part - return encodeURIComponent(part.replace(/ /g, "-")) + part = decodeURIComponent(part) + part = part.replace(/ /g, "-") + + // If parameter, then use as is + if (!part.startsWith(":")) { + part = encodeURIComponent(part) + } + + return part }) .join("/") .toLowerCase() diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index 4e1e5e1103..caf8835b86 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -39,6 +39,7 @@ if (v.internal) { acc[k] = v } + delete acc.LOOP return acc }, {}) diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte index ca04fed8df..505a0b9aca 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte @@ -72,7 +72,9 @@ animate:flip={{ duration: 500 }} in:fly|local={{ x: 500, duration: 1500 }} > - + {#if block.stepId !== "LOOP"} + + {/if}
{/each}
diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte index 69dd67724a..0829b85a90 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte @@ -9,8 +9,8 @@ Modal, Button, StatusLight, - ActionButton, Select, + ActionButton, notifications, } from "@budibase/bbui" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" @@ -25,8 +25,8 @@ let webhookModal let actionModal let resultsModal - let setupToggled let blockComplete + let showLooping = false $: rowControl = $automationStore.selectedAutomation.automation.rowControl $: showBindingPicker = @@ -48,12 +48,21 @@ $automationStore.selectedAutomation?.automation?.definition?.steps.length + 1 - $: hasCompletedInputs = Object.keys( - block.schema?.inputs?.properties || {} - ).every(x => block?.inputs[x]) + $: loopingSelected = + $automationStore.selectedAutomation?.automation.definition.steps.find( + x => x.blockToLoop === block.id + ) async function deleteStep() { + let loopBlock = + $automationStore.selectedAutomation?.automation.definition.steps.find( + x => x.blockToLoop === block.id + ) + try { + if (loopBlock) { + automationStore.actions.deleteAutomationBlock(loopBlock) + } automationStore.actions.deleteAutomationBlock(block) await automationStore.actions.save( $automationStore.selectedAutomation?.automation @@ -76,6 +85,23 @@ ) } + async function addLooping() { + loopingSelected = true + const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP + + const loopBlock = $automationStore.selectedAutomation.constructBlock( + "ACTION", + "LOOP", + loopDefinition + ) + loopBlock.blockToLoop = block.id + block.loopBlock = loopBlock.id + automationStore.actions.addBlockToAutomation(loopBlock, blockIdx) + await automationStore.actions.save( + $automationStore.selectedAutomation?.automation + ) + } + async function onSelect(block) { await automationStore.update(state => { state.selectedBlock = block @@ -84,13 +110,68 @@ } -
{ - onSelect(block) - }} -> +
{}}> + {#if loopingSelected} +
+
{ + showLooping = !showLooping + }} + class="splitHeader" + > +
+ + + +
+ Looping +
+
+ +
+
{ + onSelect(block) + }} + > + +
+
+
+
+ + + {#if !showLooping} +
+
+
deleteStep()}> + +
+
+ + x.blockToLoop === block.id + )} + {webhookModal} + /> + +
+ + {/if} + {/if} +
{ @@ -127,65 +208,66 @@ {block?.name?.toUpperCase() || ""}
- {#if testResult && testResult[0]} - resultsModal.show()}> - View response - - {/if} +
+ {#if testResult && testResult[0]} +
resultsModal.show()}> + View response +
+ {/if} +
{ + onSelect(block) + }} + > + +
+
{#if !blockComplete}
-
- { - onSelect(block) - setupToggled = !setupToggled - }} - quiet - icon={setupToggled ? "ChevronDown" : "ChevronRight"} - > - Setup - - {#if !isTrigger} + {#if !isTrigger} +
- {#if showBindingPicker} -
- + {/if} + deleteStep()} + icon="DeleteOutline" + />
- {/if} -
+
+ {/if} - {#if setupToggled} - - {#if lastStep} - - {/if} + + {#if lastStep} + {/if}
@@ -204,13 +286,7 @@
- actionModal.show()} - disabled={!hasCompletedInputs} - hoverable - name="AddCircle" - size="S" -/> + actionModal.show()} hoverable name="AddCircle" size="S" /> {#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
{/if} @@ -220,8 +296,10 @@ padding-left: 30px; } .block-options { - display: flex; + justify-content: flex-end; align-items: center; + display: flex; + gap: var(--spacing-m); } .center-items { display: flex; @@ -256,4 +334,9 @@ /* center horizontally */ align-self: center; } + + .blockTitle { + display: flex; + align-items: center; + } diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte index 7dfdff20a7..9662bc8ade 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte @@ -1,5 +1,5 @@ -{#if type === "options"} +{#if type === "options" && meta.constraints.inclusion.length !== 0} onChange(event.detail)} {placeholder} + {updateOnChange} /> {#if !disabled}
@@ -103,4 +105,8 @@ background-color: var(--spectrum-global-color-gray-50); border-color: var(--spectrum-alias-border-color-hover); } + + .control:not(.disabled) :global(.spectrum-Textfield-input) { + padding-right: 40px; + } diff --git a/packages/builder/src/components/common/bindings/ModalBindableInput.svelte b/packages/builder/src/components/common/bindings/ModalBindableInput.svelte index 3776f1dc84..a3fddac3a5 100644 --- a/packages/builder/src/components/common/bindings/ModalBindableInput.svelte +++ b/packages/builder/src/components/common/bindings/ModalBindableInput.svelte @@ -15,6 +15,7 @@ export let placeholder export let label export let allowJS = false + export let updateOnChange = true const dispatch = createEventDispatcher() let bindingModal @@ -41,6 +42,7 @@ value={isJS ? "(JavaScript function)" : readableValue} on:change={event => onChange(event.detail)} {placeholder} + {updateOnChange} />
diff --git a/packages/builder/src/components/common/inputs/CopyInput.svelte b/packages/builder/src/components/common/inputs/CopyInput.svelte index 68974fb63a..102fd5682a 100644 --- a/packages/builder/src/components/common/inputs/CopyInput.svelte +++ b/packages/builder/src/components/common/inputs/CopyInput.svelte @@ -4,6 +4,7 @@ export let label = null export let value export let copyValue + export let dataCy = null const copyToClipboard = val => { const dummy = document.createElement("textarea") @@ -16,7 +17,7 @@ } -
+
copyToClipboard(value || copyValue)}> diff --git a/packages/builder/src/components/deploy/DeployModal.svelte b/packages/builder/src/components/deploy/DeployModal.svelte index 9f2aae56c5..0d9bfb7539 100644 --- a/packages/builder/src/components/deploy/DeployModal.svelte +++ b/packages/builder/src/components/deploy/DeployModal.svelte @@ -1,24 +1,61 @@ @@ -30,11 +67,13 @@ showCancelButton={false} /> + The changes you have made will be published to the production version of @@ -42,3 +81,57 @@ > + + + + + + + + + + + + + +
+ + + + App Published! +
+ +
+
+ + diff --git a/packages/builder/src/components/deploy/DeployNavigation.svelte b/packages/builder/src/components/deploy/DeployNavigation.svelte new file mode 100644 index 0000000000..ded75652cc --- /dev/null +++ b/packages/builder/src/components/deploy/DeployNavigation.svelte @@ -0,0 +1,189 @@ + + +
+ {#if isPublished} +
+
+ +
+ + + Your published app + + + {processStringSync( + "Last published {{ duration time 'millisecond' }} ago", + { + time: + new Date().getTime() - + new Date(latestDeployments[0].updatedAt).getTime(), + } + )} + + +
+ + +
+
+
+
+ {/if} + + {#if !isPublished} + + {/if} +
+ + Are you sure you want to unpublish the app {selectedApp?.name}? + + + + + diff --git a/packages/builder/src/components/deploy/DeploymentHistory.svelte b/packages/builder/src/components/deploy/DeploymentHistory.svelte index e933142348..eb5c8953cc 100644 --- a/packages/builder/src/components/deploy/DeploymentHistory.svelte +++ b/packages/builder/src/components/deploy/DeploymentHistory.svelte @@ -7,12 +7,10 @@ import { notifications } from "@budibase/bbui" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" import { store } from "builderStore" - - const DeploymentStatus = { - SUCCESS: "SUCCESS", - PENDING: "PENDING", - FAILURE: "FAILURE", - } + import { + checkIncomingDeploymentStatus, + DeploymentStatus, + } from "components/deploy/utils" const DATE_OPTIONS = { fullDate: { @@ -42,30 +40,17 @@ const formatDate = (date, format) => Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) - // Required to check any updated deployment statuses between polls - function checkIncomingDeploymentStatus(current, incoming) { - for (let incomingDeployment of incoming) { - if (incomingDeployment.status === DeploymentStatus.FAILURE) { - const currentDeployment = current.find( - deployment => deployment._id === incomingDeployment._id - ) - - // We have just been notified of an ongoing deployments failure - if ( - !currentDeployment || - currentDeployment.status === DeploymentStatus.PENDING - ) { - showErrorReasonModal(incomingDeployment.err) - } - } - } - } - async function fetchDeployments() { try { const newDeployments = await API.getAppDeployments() if (deployments.length > 0) { - checkIncomingDeploymentStatus(deployments, newDeployments) + const pendingDeployments = checkIncomingDeploymentStatus( + deployments, + newDeployments + ) + if (pendingDeployments.length) { + showErrorReasonModal(pendingDeployments[0].err) + } } deployments = newDeployments } catch (err) { diff --git a/packages/builder/src/components/deploy/utils.js b/packages/builder/src/components/deploy/utils.js new file mode 100644 index 0000000000..cb254f0dbf --- /dev/null +++ b/packages/builder/src/components/deploy/utils.js @@ -0,0 +1,25 @@ +export const DeploymentStatus = { + SUCCESS: "SUCCESS", + PENDING: "PENDING", + FAILURE: "FAILURE", +} + +// Required to check any updated deployment statuses between polls +export function checkIncomingDeploymentStatus(current, incoming) { + return incoming.reduce((acc, incomingDeployment) => { + if (incomingDeployment.status === DeploymentStatus.FAILURE) { + const currentDeployment = current.find( + deployment => deployment._id === incomingDeployment._id + ) + + //We have just been notified of an ongoing deployments failure + if ( + !currentDeployment || + currentDeployment.status === DeploymentStatus.PENDING + ) { + acc.push(incomingDeployment) + } + } + return acc + }, []) +} diff --git a/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte b/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte index 1c108f164e..ed064f8307 100644 --- a/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte +++ b/packages/builder/src/components/design/NavigationPanel/ComponentNavigationTree/index.svelte @@ -3,13 +3,14 @@ import PathTree from "./PathTree.svelte" let routes = {} - $: paths = Object.keys(routes || {}).sort() + let paths = [] - $: { - const allRoutes = $store.routes + $: allRoutes = $store.routes + $: selectedScreenId = $store.selectedScreenId + $: updatePaths(allRoutes, $selectedAccessRole, selectedScreenId) + + const updatePaths = (allRoutes, selectedRoleId, selectedScreenId) => { const sortedPaths = Object.keys(allRoutes || {}).sort() - const selectedRoleId = $selectedAccessRole - const selectedScreenId = $store.selectedScreenId let found = false let firstValidScreenId @@ -41,11 +42,15 @@ }) }) }) - routes = filteredRoutes + routes = { ...filteredRoutes } + paths = Object.keys(routes || {}).sort() // Select the correct role for the current screen ID if (!found && screenRoleId) { selectedAccessRole.set(screenRoleId) + if (screenRoleId !== selectedRoleId) { + updatePaths(allRoutes, screenRoleId, selectedScreenId) + } } // If the selected screen isn't in this filtered list, select the first one diff --git a/packages/builder/src/components/design/NavigationPanel/DatasourceModal.svelte b/packages/builder/src/components/design/NavigationPanel/DatasourceModal.svelte new file mode 100644 index 0000000000..1cb3856165 --- /dev/null +++ b/packages/builder/src/components/design/NavigationPanel/DatasourceModal.svelte @@ -0,0 +1,199 @@ + + + + + + {#each filteredSources as datasource} +
+
+
+ +
+
{datasource.name}
+
+ {#if Array.isArray(datasource.entities)} + {#each datasource.entities.filter(table => table._id !== "ta_users") as table} +
x.table === table.name + )} + on:click={() => toggleScreenSelection(table, datasource)} + > + + + + {table.name} + + {#if selectedScreens.find(x => x.table === table.name)} + + + + {/if} +
+ {/each} + {/if} + {#if datasource["entities"] && !Array.isArray(datasource.entities)} + {#each Object.keys(datasource.entities).filter(table => table._id !== "ta_users") as table_key} +
x.table === datasource.entities[table_key].name + )} + on:click={() => + toggleScreenSelection( + datasource.entities[table_key], + datasource + )} + > + + + + {datasource.entities[table_key].name} + + {#if selectedScreens.find(x => x.table === datasource.entities[table_key].name)} + + + + {/if} +
+ {/each} + {/if} +
+ {/each} +
+
+
+ + diff --git a/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte b/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte index cd83d81235..8fd22926e4 100644 --- a/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte +++ b/packages/builder/src/components/design/NavigationPanel/NewScreenModal.svelte @@ -1,117 +1,98 @@
- - Please select the screens you would like to add to your application. - Autogenerated screens come with CRUD functionality. - - Blank screen
x.id.includes(blankScreen))} - on:click={() => - toggleScreenSelection(templates.find(t => t.id === blankScreen))} - class:disabled={autoSelected} + class="screen-type item" + class:selected={selectedScreenMode == blankScreenModeKey} + on:click={() => { + selectedScreenMode = blankScreenModeKey + }} > -
-
Blank
+
+ +
+ Blank screen + Add a blank screen +
- {#if selectedScreens.find(x => x.id === blankScreen)} + {#if selectedScreenMode == blankScreenModeKey}
- +
{/if}
- {#if $tables.list.filter(table => table._id !== "ta_users").length > 0} - Autogenerated Screens - {#each $tables.list.filter(table => table._id !== "ta_users") as table} -
x.table === table.name)} - on:click={() => toggleScreenSelection(table)} - class="item" - > -
-
{table.name}
-
-
- {#if selectedScreens.find(x => x.table === table.name)} -
- -
- {/if} -
+
{ + selectedScreenMode = autoCreateModeKey + }} + class:disabled={!$tables.list.filter(table => table._id !== "ta_users") + .length} + > +
+ +
+ Autogenerated screens + + Add autogenerated screens with CRUD functionality to get a working + app quickly! (Requires a data source) +
- {/each} - {/if} +
+
+ {#if selectedScreenMode == autoCreateModeKey} +
+ +
+ {/if} +
+
-
- {#if showProgressCircle} - - {/if} -
diff --git a/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte b/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte index 75977e51a2..4afd182053 100644 --- a/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte +++ b/packages/builder/src/components/design/NavigationPanel/ScreenDetailsModal.svelte @@ -1,18 +1,23 @@ - + + + + + newScreenModal.show()} + initalScreens={!selectedTemplates ? [] : [...selectedTemplates]} + /> + + + + + Select which level of access you want your screens to have + x._instanceName} + getOptionValue={x => x._id} + /> + + role.name} getOptionValue={role => role._id} + {error} /> diff --git a/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte b/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte index ded80a7d5c..df3ba7f3d3 100644 --- a/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte @@ -8,14 +8,50 @@ import { currentAsset, store } from "builderStore" import { FrontendTypes } from "constants" import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl" + import { allScreens, selectedAccessRole } from "builderStore" export let componentInstance export let bindings - function setAssetProps(name, value, parser) { - if (parser && typeof parser === "function") { + let errors = {} + + const routeTaken = url => { + const roleId = get(selectedAccessRole) || "BASIC" + return get(allScreens).some( + screen => + screen.routing.route.toLowerCase() === url.toLowerCase() && + screen.routing.roleId === roleId + ) + } + + const roleTaken = roleId => { + const url = get(currentAsset)?.routing.route + return get(allScreens).some( + screen => + screen.routing.route.toLowerCase() === url.toLowerCase() && + screen.routing.roleId === roleId + ) + } + + const setAssetProps = (name, value, parser, validate) => { + if (parser) { value = parser(value) } + if (validate) { + const error = validate(value) + errors = { + ...errors, + [name]: error, + } + if (error) { + return + } + } else { + errors = { + ...errors, + [name]: null, + } + } const selectedAsset = get(currentAsset) store.update(state => { @@ -38,7 +74,6 @@ } const screenSettings = [ - // { key: "description", label: "Description", control: Input }, { key: "routing.route", label: "Route", @@ -49,8 +84,26 @@ } return sanitizeUrl(val) }, + validate: val => { + const exisingValue = get(currentAsset)?.routing.route + if (val !== exisingValue && routeTaken(val)) { + return "That URL is already in use for this role" + } + return null + }, + }, + { + key: "routing.roleId", + label: "Access", + control: RoleSelect, + validate: val => { + const exisingValue = get(currentAsset)?.routing.roleId + if (val !== exisingValue && roleTaken(val)) { + return "That role is already in use for this URL" + } + return null + }, }, - { key: "routing.roleId", label: "Access", control: RoleSelect }, { key: "layoutId", label: "Layout", control: LayoutSelect }, ] @@ -62,9 +115,11 @@ control={def.control} label={def.label} key={def.key} + error="asdasds" value={deepGet($currentAsset, def.key)} - onChange={val => setAssetProps(def.key, val, def.parser)} + onChange={val => setAssetProps(def.key, val, def.parser, def.validate)} {bindings} + props={{ error: errors[def.key] }} /> {/each} diff --git a/packages/builder/src/components/start/AppRow.svelte b/packages/builder/src/components/start/AppRow.svelte index d6dc4e1800..ea2f005216 100644 --- a/packages/builder/src/components/start/AppRow.svelte +++ b/packages/builder/src/components/start/AppRow.svelte @@ -15,6 +15,7 @@ export let editApp export let updateApp export let deleteApp + export let previewApp export let unpublishApp export let releaseLock export let editIcon @@ -22,7 +23,7 @@
-
+
editApp(app)}> @@ -57,26 +58,40 @@
- - {#if app.deployed}Published{:else}Unpublished{/if} - +
+ {#if app.deployed} + + Published + {:else} + + Unpublished + {/if} +
- - - +
{#if app.deployed} - viewApp(app)} icon="GlobeOutline"> - View published app - + + {:else} + {/if} + +
+ + + + {#if app.lockedYou} releaseLock(app)} icon="LockOpen"> Release lock @@ -97,6 +112,18 @@
diff --git a/packages/builder/yarn.lock b/packages/builder/yarn.lock index 48fd25ac71..29b0481072 100644 --- a/packages/builder/yarn.lock +++ b/packages/builder/yarn.lock @@ -4424,9 +4424,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mixin-deep@^1.2.0: version "1.3.2" diff --git a/packages/cli/package.json b/packages/cli/package.json index b564638f1a..00a76555b5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/cli", - "version": "1.0.123", + "version": "1.0.124-alpha.0", "description": "Budibase CLI, for developers, self hosting and migrations.", "main": "src/index.js", "bin": { diff --git a/packages/cli/yarn.lock b/packages/cli/yarn.lock index 5aba4810ca..39fc4ed990 100644 --- a/packages/cli/yarn.lock +++ b/packages/cli/yarn.lock @@ -1123,9 +1123,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: version "0.5.3" diff --git a/packages/client/.vscode/launch.json b/packages/client/.vscode/launch.json deleted file mode 100644 index ef01de280f..0000000000 --- a/packages/client/.vscode/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Publish Dev", - "program": "${workspaceFolder}/scripts/publishDev.js" - } - ] -} \ No newline at end of file diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 4c3c0faf1f..4190d7f076 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -1834,7 +1834,12 @@ "icon": "Form", "hasChildren": true, "illegalChildren": ["section", "form"], - "actions": ["ValidateForm", "ClearForm", "ChangeFormStep"], + "actions": [ + "ValidateForm", + "ClearForm", + "ChangeFormStep", + "UpdateFieldValue" + ], "styles": ["size"], "settings": [ { @@ -1975,6 +1980,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2049,6 +2065,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2089,6 +2116,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2125,6 +2163,17 @@ "key": "placeholder", "placeholder": "Choose an option" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "select", "label": "Type", @@ -2274,6 +2323,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Autocomplete", @@ -2399,6 +2459,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2439,6 +2510,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "select", "label": "Formatting", @@ -2512,6 +2594,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2657,6 +2750,17 @@ "label": "Extensions", "key": "extensions" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2697,6 +2801,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Autocomplete", @@ -2742,6 +2857,17 @@ "label": "Default value", "key": "defaultValue" }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, { "type": "boolean", "label": "Disabled", @@ -2750,6 +2876,62 @@ } ] }, + "s3upload": { + "name": "S3 File Upload", + "info": "This component can't be used with S3 datasources that use custom endpoints.", + "icon": "UploadToCloud", + "styles": ["size"], + "editable": true, + "settings": [ + { + "type": "field/attachment", + "label": "Field", + "key": "field" + }, + { + "type": "text", + "label": "Label", + "key": "label" + }, + { + "type": "dataSource/s3", + "label": "S3 Datasource", + "key": "datasourceId" + }, + { + "type": "text", + "label": "Bucket", + "key": "bucket" + }, + { + "type": "text", + "label": "File Name", + "key": "key" + }, + { + "type": "event", + "label": "On Change", + "key": "onChange", + "context": [ + { + "label": "Field Value", + "key": "value" + } + ] + }, + { + "type": "boolean", + "label": "Disabled", + "key": "disabled", + "defaultValue": false + }, + { + "type": "validation/attachment", + "label": "Validation", + "key": "validation" + } + ] + }, "dataprovider": { "name": "Data Provider", "info": "Pagination is only available for data stored in tables.", @@ -3581,51 +3763,6 @@ } ] }, - "s3upload": { - "name": "S3 File Upload", - "info": "This component can't be used with S3 datasources that use custom endpoints.", - "icon": "UploadToCloud", - "styles": ["size"], - "editable": true, - "settings": [ - { - "type": "field/attachment", - "label": "Field", - "key": "field" - }, - { - "type": "text", - "label": "Label", - "key": "label" - }, - { - "type": "dataSource/s3", - "label": "S3 Datasource", - "key": "datasourceId" - }, - { - "type": "text", - "label": "Bucket", - "key": "bucket" - }, - { - "type": "text", - "label": "File Name", - "key": "key" - }, - { - "type": "boolean", - "label": "Disabled", - "key": "disabled", - "defaultValue": false - }, - { - "type": "validation/attachment", - "label": "Validation", - "key": "validation" - } - ] - }, "markdownviewer": { "name": "Markdown Viewer", "icon": "TaskList", diff --git a/packages/client/package.json b/packages/client/package.json index 1720e53b1b..d3b0a62bd3 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/client", - "version": "1.0.123", + "version": "1.0.124-alpha.0", "license": "MPL-2.0", "module": "dist/budibase-client.js", "main": "dist/budibase-client.js", @@ -19,9 +19,9 @@ "dev:builder": "rollup -cw" }, "dependencies": { - "@budibase/bbui": "^1.0.123", - "@budibase/frontend-core": "^1.0.123", - "@budibase/string-templates": "^1.0.123", + "@budibase/bbui": "^1.0.124-alpha.0", + "@budibase/frontend-core": "^1.0.124-alpha.0", + "@budibase/string-templates": "^1.0.124-alpha.0", "@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "^3.0.3", "@spectrum-css/divider": "^1.0.3", diff --git a/packages/client/src/api/api.js b/packages/client/src/api/api.js index 51a9b82ee6..c3cc679220 100644 --- a/packages/client/src/api/api.js +++ b/packages/client/src/api/api.js @@ -1,5 +1,5 @@ import { createAPIClient } from "@budibase/frontend-core" -import { notificationStore, authStore } from "../stores" +import { notificationStore, authStore, devToolsStore } from "../stores" import { get } from "svelte/store" export const API = createAPIClient({ @@ -21,6 +21,12 @@ export const API = createAPIClient({ if (auth?.csrfToken) { headers["x-csrf-token"] = auth.csrfToken } + + // Add role header + const role = get(devToolsStore).role + if (role) { + headers["x-budibase-role"] = role + } }, // Show an error notification for all API failures. diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index 27767862ab..6bd0313c75 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -14,6 +14,8 @@ routeStore, builderStore, themeStore, + appStore, + devToolsStore, } from "stores" import NotificationDisplay from "components/overlay/NotificationDisplay.svelte" import ConfirmationDisplay from "components/overlay/ConfirmationDisplay.svelte" @@ -28,6 +30,8 @@ import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import DNDHandler from "components/preview/DNDHandler.svelte" import KeyboardManager from "components/preview/KeyboardManager.svelte" + import DevToolsHeader from "components/devtools/DevToolsHeader.svelte" + import DevTools from "components/devtools/DevTools.svelte" // Provide contexts setContext("sdk", SDK) @@ -55,8 +59,22 @@ if ($authStore) { // There is a logged in user, so handle them if ($screenStore.screens.length) { + let firstRoute + + // If using devtools, find the first screen matching our role + if ($devToolsStore.role) { + const roleRoutes = $screenStore.screens.filter( + screen => screen.routing?.roleId === $devToolsStore.role + ) + firstRoute = roleRoutes[0]?.routing?.route || "/" + } + + // Otherwise just use the first route + else { + firstRoute = $screenStore.screens[0]?.routing?.route ?? "/" + } + // Screens exist so navigate back to the home screen - const firstRoute = $screenStore.screens[0].routing?.route ?? "/" routeStore.actions.navigate(firstRoute) } else { // No screens likely means the user has no permissions to view this app @@ -70,6 +88,8 @@ } } } + + $: isDevPreview = $appStore.isDevApp && !$builderStore.inBuilder {#if dataLoaded} @@ -109,39 +129,49 @@ >
- - {#key `${$screenStore.activeLayout._id}-${$builderStore.previewType}`} - - {/key} + {#if isDevPreview} + + {/if} - -
+
+ + {#key `${$screenStore.activeLayout._id}-${$builderStore.previewType}`} + + {/key} - - @@ -167,6 +197,7 @@ justify-content: center; align-items: center; } + #clip-root { max-width: 100%; max-height: 100%; @@ -176,10 +207,24 @@ overflow: hidden; background-color: transparent; } + #app-root { overflow: hidden; height: 100%; width: 100%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: stretch; + } + + #app-body { + flex: 1 1 auto; + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: stretch; + overflow: hidden; } .error { @@ -192,19 +237,23 @@ text-align: center; padding: 20px; } + .error :global(svg) { fill: var(--spectrum-global-color-gray-500); width: 80px; height: 80px; } + .error :global(h1), .error :global(p) { color: var(--spectrum-global-color-gray-800); } + .error :global(p) { font-style: italic; margin-top: -0.5em; } + .error :global(h1) { font-weight: 400; } @@ -214,14 +263,17 @@ #clip-root.preview { padding: 2px; } + #clip-root.tablet-preview { width: calc(1024px + 6px); height: calc(768px + 6px); } + #clip-root.mobile-preview { width: calc(390px + 6px); height: calc(844px + 6px); } + .preview #app-root { border: 1px solid var(--spectrum-global-color-gray-300); border-radius: 4px; diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index d9af295108..3c29cb875b 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -9,12 +9,16 @@ {#if constructor && initialSettings && (visible || inSelectedPath)} @@ -419,12 +431,15 @@ .component { display: contents; } + .interactive :global(*:hover) { cursor: pointer; } + .draggable :global(*:hover) { cursor: grab; } + .editing :global(*:hover) { cursor: auto; } diff --git a/packages/client/src/components/app/Layout.svelte b/packages/client/src/components/app/Layout.svelte index a801ea4b46..4df9087904 100644 --- a/packages/client/src/components/app/Layout.svelte +++ b/packages/client/src/components/app/Layout.svelte @@ -179,6 +179,7 @@ justify-content: flex-start; align-items: stretch; height: 100%; + flex: 1 1 auto; overflow: auto; overflow-x: hidden; position: relative; diff --git a/packages/client/src/components/app/SpectrumCard.svelte b/packages/client/src/components/app/SpectrumCard.svelte index 24cda52598..4b4f9d62f2 100644 --- a/packages/client/src/components/app/SpectrumCard.svelte +++ b/packages/client/src/components/app/SpectrumCard.svelte @@ -102,7 +102,7 @@ white-space: nowrap; } .spectrum-Card-footer { - word-wrap: anywhere; + word-wrap: break-word; white-space: pre-wrap; } .horizontal .spectrum-Card-coverPhoto { diff --git a/packages/client/src/components/app/blocks/CardsBlock.svelte b/packages/client/src/components/app/blocks/CardsBlock.svelte index 51924f9be2..392af5411e 100644 --- a/packages/client/src/components/app/blocks/CardsBlock.svelte +++ b/packages/client/src/components/app/blocks/CardsBlock.svelte @@ -125,7 +125,11 @@ {#if schemaLoaded}
- + {#if title || enrichedSearchColumns?.length || showTitleButton}
diff --git a/packages/client/src/components/app/blocks/TableBlock.svelte b/packages/client/src/components/app/blocks/TableBlock.svelte index 70980669b6..4f5e0e1114 100644 --- a/packages/client/src/components/app/blocks/TableBlock.svelte +++ b/packages/client/src/components/app/blocks/TableBlock.svelte @@ -106,7 +106,11 @@ {#if schemaLoaded}
- + {#if title || enrichedSearchColumns?.length || showTitleButton}
diff --git a/packages/client/src/components/app/charts/ApexChart.svelte b/packages/client/src/components/app/charts/ApexChart.svelte index eecf82d2ea..329fb1ce75 100644 --- a/packages/client/src/components/app/charts/ApexChart.svelte +++ b/packages/client/src/components/app/charts/ApexChart.svelte @@ -34,7 +34,7 @@ color: var(--spectrum-global-color-gray-700) !important; } div :global(.apexcharts-datalabel) { - fill: var(--spectrum-global-color-gray-800); + fill: white; } div :global(.apexcharts-tooltip) { background-color: var(--spectrum-global-color-gray-200) !important; @@ -45,4 +45,12 @@ background-color: var(--spectrum-global-color-gray-100) !important; border-color: var(--spectrum-global-color-gray-300) !important; } + div :global(.apexcharts-theme-dark .apexcharts-tooltip-text) { + color: white; + } + div + :global(.apexcharts-theme-dark + .apexcharts-tooltip-series-group.apexcharts-active) { + padding-bottom: 0; + } diff --git a/packages/client/src/components/app/forms/AttachmentField.svelte b/packages/client/src/components/app/forms/AttachmentField.svelte index 679dfa9702..5b2eab0c42 100644 --- a/packages/client/src/components/app/forms/AttachmentField.svelte +++ b/packages/client/src/components/app/forms/AttachmentField.svelte @@ -8,6 +8,7 @@ export let disabled = false export let validation export let extensions + export let onChange let fieldState let fieldApi @@ -38,6 +39,13 @@ return [] } } + + const handleChange = e => { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } { - fieldApi.setValue(e.detail) - }} + on:change={handleChange} {processFiles} {handleFileTooLarge} {extensions} diff --git a/packages/client/src/components/app/forms/BooleanField.svelte b/packages/client/src/components/app/forms/BooleanField.svelte index 1e3bc2fc8c..b9c69ce684 100644 --- a/packages/client/src/components/app/forms/BooleanField.svelte +++ b/packages/client/src/components/app/forms/BooleanField.svelte @@ -9,6 +9,7 @@ export let size export let validation export let defaultValue + export let onChange let fieldState let fieldApi @@ -25,6 +26,13 @@ } return false } + + const handleChange = e => { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } fieldApi.setValue(e.detail)} {text} + on:change={handleChange} /> {/if} diff --git a/packages/client/src/components/app/forms/DateTimeField.svelte b/packages/client/src/components/app/forms/DateTimeField.svelte index 022a634bc5..1010883cad 100644 --- a/packages/client/src/components/app/forms/DateTimeField.svelte +++ b/packages/client/src/components/app/forms/DateTimeField.svelte @@ -10,9 +10,17 @@ export let timeOnly = false export let validation export let defaultValue + export let onChange let fieldState let fieldApi + + const handleChange = e => { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } fieldApi.setValue(e.detail)} + on:change={handleChange} disabled={fieldState.disabled} error={fieldState.error} id={fieldState.fieldId} diff --git a/packages/client/src/components/app/forms/Form.svelte b/packages/client/src/components/app/forms/Form.svelte index 8de729ae0a..a274fb24f0 100644 --- a/packages/client/src/components/app/forms/Form.svelte +++ b/packages/client/src/components/app/forms/Form.svelte @@ -9,6 +9,10 @@ export let disabled = false export let actionType = "Create" + // Not exposed as a builder setting. Used internally to disable validation + // for fields rendered in things like search blocks. + export let disableValidation = false + const context = getContext("context") const { API, fetchDatasourceSchema } = getContext("sdk") @@ -102,6 +106,7 @@ {schema} {table} {initialValues} + {disableValidation} > diff --git a/packages/client/src/components/app/forms/FormStep.svelte b/packages/client/src/components/app/forms/FormStep.svelte index 58300287a8..4441f515ee 100644 --- a/packages/client/src/components/app/forms/FormStep.svelte +++ b/packages/client/src/components/app/forms/FormStep.svelte @@ -5,7 +5,7 @@ export let step = 1 - const { styleable, builderStore } = getContext("sdk") + const { styleable, builderStore, componentStore } = getContext("sdk") const component = getContext("component") const formContext = getContext("form") @@ -22,7 +22,7 @@ if ( formContext && $builderStore.inBuilder && - $builderStore.selectedComponentPath?.includes($component.id) + $componentStore.selectedComponentPath?.includes($component.id) ) { formContext.formApi.setStep(step) } diff --git a/packages/client/src/components/app/forms/InnerForm.svelte b/packages/client/src/components/app/forms/InnerForm.svelte index b82070681b..99dcbf4d5e 100644 --- a/packages/client/src/components/app/forms/InnerForm.svelte +++ b/packages/client/src/components/app/forms/InnerForm.svelte @@ -10,6 +10,7 @@ export let size export let schema export let table + export let disableValidation = false const component = getContext("component") const { styleable, Provider, ActionTypes } = getContext("sdk") @@ -141,12 +142,14 @@ // Create validation function based on field schema const schemaConstraints = schema?.[field]?.constraints - const validator = createValidatorFromConstraints( - schemaConstraints, - validationRules, - field, - table - ) + const validator = disableValidation + ? null + : createValidatorFromConstraints( + schemaConstraints, + validationRules, + field, + table + ) // If we've already registered this field then keep some existing state let initialValue = Helpers.deepGet(initialValues, field) ?? defaultValue @@ -164,7 +167,7 @@ // If this field has already been registered and we previously had an // error set, then re-run the validator to see if we can unset it if (fieldState.error) { - initialError = validator(initialValue) + initialError = validator?.(initialValue) } } @@ -216,10 +219,10 @@ }) return valid }, - clear: () => { - // Clear the form by clearing each individual field + reset: () => { + // Reset the form by resetting each individual field fields.forEach(field => { - get(field).fieldApi.clearValue() + get(field).fieldApi.reset() }) }, changeStep: ({ type, number }) => { @@ -238,6 +241,22 @@ currentStep.set(step) } }, + setFieldValue: (fieldName, value) => { + const field = getField(fieldName) + if (!field) { + return + } + const { fieldApi } = get(field) + fieldApi.setValue(value) + }, + resetField: fieldName => { + const field = getField(fieldName) + if (!field) { + return + } + const { fieldApi } = get(field) + fieldApi.reset() + }, } // Creates an API for a specific field @@ -254,7 +273,7 @@ } // Update field state - const error = validator ? validator(value) : null + const error = validator?.(value) fieldInfo.update(state => { state.fieldState.value = value state.fieldState.error = error @@ -265,11 +284,11 @@ return !error } - // Clears the value of a certain field back to the initial value - const clearValue = () => { + // Clears the value of a certain field back to the default value + const reset = () => { const fieldInfo = getField(field) const { fieldState } = get(fieldInfo) - const newValue = initialValues[field] ?? fieldState.defaultValue + const newValue = fieldState.defaultValue // Update field state fieldInfo.update(state => { @@ -288,12 +307,14 @@ // Create new validator const schemaConstraints = schema?.[field]?.constraints - const validator = createValidatorFromConstraints( - schemaConstraints, - validationRules, - field, - table - ) + const validator = disableValidation + ? null + : createValidatorFromConstraints( + schemaConstraints, + validationRules, + field, + table + ) // Update validator fieldInfo.update(state => { @@ -324,7 +345,7 @@ return { setValue, - clearValue, + reset, updateValidation, setDisabled, validate: () => { @@ -349,11 +370,20 @@ // register their fields to step 1 setContext("form-step", writable(1)) + const handleUpdateFieldValue = ({ type, field, value }) => { + if (type === "set") { + formApi.setFieldValue(field, value) + } else { + formApi.resetField(field) + } + } + // Action context to pass to children const actions = [ { type: ActionTypes.ValidateForm, callback: formApi.validate }, - { type: ActionTypes.ClearForm, callback: formApi.clear }, + { type: ActionTypes.ClearForm, callback: formApi.reset }, { type: ActionTypes.ChangeFormStep, callback: formApi.changeStep }, + { type: ActionTypes.UpdateFieldValue, callback: handleUpdateFieldValue }, ] diff --git a/packages/client/src/components/app/forms/JSONField.svelte b/packages/client/src/components/app/forms/JSONField.svelte index d38a41b430..4bb5ee542c 100644 --- a/packages/client/src/components/app/forms/JSONField.svelte +++ b/packages/client/src/components/app/forms/JSONField.svelte @@ -8,6 +8,7 @@ export let placeholder export let disabled = false export let defaultValue = "" + export let onChange const component = getContext("component") const validation = [ @@ -33,6 +34,14 @@ return value } } + + const handleChange = e => { + const value = parseValue(e.detail) + fieldApi.setValue(value) + if (onChange) { + onChange({ value }) + } + } fieldApi.setValue(parseValue(e.detail))} + on:change={handleChange} disabled={fieldState.disabled} error={fieldState.error} id={fieldState.fieldId} diff --git a/packages/client/src/components/app/forms/LongFormField.svelte b/packages/client/src/components/app/forms/LongFormField.svelte index 15a10827b2..200e55af41 100644 --- a/packages/client/src/components/app/forms/LongFormField.svelte +++ b/packages/client/src/components/app/forms/LongFormField.svelte @@ -11,6 +11,7 @@ export let validation export let defaultValue = "" export let format = "auto" + export let onChange let fieldState let fieldApi @@ -44,6 +45,13 @@ }, }) } + + const handleChange = e => { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } fieldApi.setValue(e.detail)} + on:change={handleChange} disabled={fieldState.disabled} error={fieldState.error} id={fieldState.fieldId} @@ -78,7 +86,7 @@ {:else} fieldApi.setValue(e.detail)} + on:change={handleChange} disabled={fieldState.disabled} error={fieldState.error} id={fieldState.fieldId} diff --git a/packages/client/src/components/app/forms/MultiFieldSelect.svelte b/packages/client/src/components/app/forms/MultiFieldSelect.svelte index 686198dfe1..6bc0970051 100644 --- a/packages/client/src/components/app/forms/MultiFieldSelect.svelte +++ b/packages/client/src/components/app/forms/MultiFieldSelect.svelte @@ -14,6 +14,7 @@ export let valueColumn export let customOptions export let autocomplete = false + export let onChange let fieldState let fieldApi @@ -34,13 +35,18 @@ if (!values) { return [] } - if (Array.isArray(values)) { return values } - return values.split(",").map(value => value.trim()) } + + const handleChange = e => { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } x : x => x.value} id={fieldState.fieldId} disabled={fieldState.disabled} - on:change={e => fieldApi.setValue(e.detail)} + on:change={handleChange} {placeholder} {options} {autocomplete} diff --git a/packages/client/src/components/app/forms/OptionsField.svelte b/packages/client/src/components/app/forms/OptionsField.svelte index 8140600e7e..c0f98ed827 100644 --- a/packages/client/src/components/app/forms/OptionsField.svelte +++ b/packages/client/src/components/app/forms/OptionsField.svelte @@ -16,6 +16,7 @@ export let customOptions export let autocomplete = false export let direction = "vertical" + export let onChange let fieldState let fieldApi @@ -30,6 +31,13 @@ valueColumn, customOptions ) + + const handleChange = e => { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } fieldApi.setValue(e.detail)} + on:change={handleChange} getOptionLabel={flatOptions ? x => x : x => x.label} getOptionValue={flatOptions ? x => x : x => x.value} {autocomplete} @@ -66,7 +74,7 @@ error={fieldState.error} {options} {direction} - on:change={e => fieldApi.setValue(e.detail)} + on:change={handleChange} getOptionLabel={flatOptions ? x => x : x => x.label} getOptionValue={flatOptions ? x => x : x => x.value} /> diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 6089939dcd..2c1136bea3 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -13,6 +13,7 @@ export let validation export let autocomplete = false export let defaultValue + export let onChange let fieldState let fieldApi @@ -62,11 +63,11 @@ } const singleHandler = e => { - fieldApi.setValue(e.detail == null ? [] : [e.detail]) + handleChange(e.detail == null ? [] : [e.detail]) } const multiHandler = e => { - fieldApi.setValue(e.detail) + handleChange(e.detail) } const expand = values => { @@ -78,6 +79,13 @@ } return values.split(",").map(value => value.trim()) } + + const handleChange = value => { + fieldApi.setValue(value) + if (onChange) { + onChange({ value }) + } + } { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } + onMount(() => { uploadStore.actions.registerFileUpload($component.id, upload) }) @@ -113,9 +121,7 @@ value={fieldState.value} disabled={loading || fieldState.disabled} error={fieldState.error} - on:change={e => { - fieldApi.setValue(e.detail) - }} + on:change={handleChange} {processFiles} {handleFileTooLarge} maximum={1} diff --git a/packages/client/src/components/app/forms/StringField.svelte b/packages/client/src/components/app/forms/StringField.svelte index bb598bb1e0..fcdcfdc099 100644 --- a/packages/client/src/components/app/forms/StringField.svelte +++ b/packages/client/src/components/app/forms/StringField.svelte @@ -10,9 +10,17 @@ export let validation export let defaultValue = "" export let align + export let onChange let fieldState let fieldApi + + const handleChange = e => { + fieldApi.setValue(e.detail) + if (onChange) { + onChange({ value: e.detail }) + } + } fieldApi.setValue(e.detail)} + on:change={handleChange} disabled={fieldState.disabled} error={fieldState.error} id={fieldState.fieldId} diff --git a/packages/client/src/components/context/UserBindingsProvider.svelte b/packages/client/src/components/context/UserBindingsProvider.svelte index e788d80dc4..f7605122ae 100644 --- a/packages/client/src/components/context/UserBindingsProvider.svelte +++ b/packages/client/src/components/context/UserBindingsProvider.svelte @@ -1,6 +1,6 @@ - + diff --git a/packages/client/src/components/devtools/DevTools.svelte b/packages/client/src/components/devtools/DevTools.svelte new file mode 100644 index 0000000000..4bb332da2f --- /dev/null +++ b/packages/client/src/components/devtools/DevTools.svelte @@ -0,0 +1,69 @@ + + +
+ {#if $devToolsStore.visible} + +
+ Budibase DevTools + devToolsStore.actions.setVisible(false)} + /> +
+ + +
+ +
+
+ +
+ +
+
+
+
+ {/if} +
+ + diff --git a/packages/client/src/components/devtools/DevToolsComponentContextTab.svelte b/packages/client/src/components/devtools/DevToolsComponentContextTab.svelte new file mode 100644 index 0000000000..3b4c426851 --- /dev/null +++ b/packages/client/src/components/devtools/DevToolsComponentContextTab.svelte @@ -0,0 +1,113 @@ + + + + + Choose a category to see the value of all its available bindings. + + devToolsStore.actions.changeRole(e.detail)} + /> + {#if !$context.device.mobile} + + {/if} +
+ + diff --git a/packages/client/src/components/devtools/DevToolsStat.svelte b/packages/client/src/components/devtools/DevToolsStat.svelte new file mode 100644 index 0000000000..30600737d2 --- /dev/null +++ b/packages/client/src/components/devtools/DevToolsStat.svelte @@ -0,0 +1,75 @@ + + +
+
{prettyLabel}
+
+ {prettyValue} +
+
+ + diff --git a/packages/client/src/components/devtools/DevToolsStatsTab.svelte b/packages/client/src/components/devtools/DevToolsStatsTab.svelte new file mode 100644 index 0000000000..ab029db815 --- /dev/null +++ b/packages/client/src/components/devtools/DevToolsStatsTab.svelte @@ -0,0 +1,27 @@ + + + + + + + {#if $appStore.clientLoadTime} + + {/if} + + + + + + + + diff --git a/packages/client/src/components/preview/IndicatorSet.svelte b/packages/client/src/components/preview/IndicatorSet.svelte index 012aa7e470..6fcf552d21 100644 --- a/packages/client/src/components/preview/IndicatorSet.svelte +++ b/packages/client/src/components/preview/IndicatorSet.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from "svelte" import Indicator from "./Indicator.svelte" import { domDebounce } from "utils/domDebounce" + import { builderStore } from "stores" export let componentId export let color @@ -13,6 +14,7 @@ let interval let text $: visibleIndicators = indicators.filter(x => x.visible) + $: offset = $builderStore.inBuilder ? 0 : 2 let updating = false let observers = [] @@ -88,8 +90,8 @@ const elBounds = child.getBoundingClientRect() nextIndicators.push({ - top: elBounds.top + scrollY - deviceBounds.top, - left: elBounds.left + scrollX - deviceBounds.left, + top: elBounds.top + scrollY - deviceBounds.top - offset, + left: elBounds.left + scrollX - deviceBounds.left - offset, width: elBounds.width + 4, height: elBounds.height + 4, visible: false, diff --git a/packages/client/src/components/preview/SettingsBar.svelte b/packages/client/src/components/preview/SettingsBar.svelte index c5ad8bef6c..bf0b48250a 100644 --- a/packages/client/src/components/preview/SettingsBar.svelte +++ b/packages/client/src/components/preview/SettingsBar.svelte @@ -3,7 +3,7 @@ import SettingsButton from "./SettingsButton.svelte" import SettingsColorPicker from "./SettingsColorPicker.svelte" import SettingsPicker from "./SettingsPicker.svelte" - import { builderStore } from "stores" + import { builderStore, componentStore } from "stores" import { domDebounce } from "utils/domDebounce" const verticalOffset = 28 @@ -15,7 +15,7 @@ let self let measured = false - $: definition = $builderStore.selectedComponentDefinition + $: definition = $componentStore.selectedComponentDefinition $: showBar = definition?.showSettingsBar && !$builderStore.isDragging $: settings = getBarSettings(definition) @@ -66,6 +66,11 @@ newTop = deviceBottom - 44 } + //If element is at the very top of the screen, put the bar below the element + if (elBounds.top < elBounds.height && elBounds.height < 80) { + newTop = elBounds.bottom + verticalOffset + } + // Horizontally, try to center first. // Failing that, render to left edge of component. // Failing that, render to right edge of component, @@ -158,9 +163,7 @@ { - builderStore.actions.deleteComponent( - $builderStore.selectedComponent._id - ) + builderStore.actions.deleteComponent($builderStore.selectedComponentId) }} title="Delete component" /> diff --git a/packages/client/src/components/preview/SettingsButton.svelte b/packages/client/src/components/preview/SettingsButton.svelte index 1490b2c9b7..6f7d95f5ae 100644 --- a/packages/client/src/components/preview/SettingsButton.svelte +++ b/packages/client/src/components/preview/SettingsButton.svelte @@ -1,6 +1,6 @@ diff --git a/packages/client/src/components/preview/SettingsColorPicker.svelte b/packages/client/src/components/preview/SettingsColorPicker.svelte index 68061e0163..b078d048d2 100644 --- a/packages/client/src/components/preview/SettingsColorPicker.svelte +++ b/packages/client/src/components/preview/SettingsColorPicker.svelte @@ -1,10 +1,10 @@
diff --git a/packages/client/src/components/preview/SettingsPicker.svelte b/packages/client/src/components/preview/SettingsPicker.svelte index 8d7129b812..8b83729fde 100644 --- a/packages/client/src/components/preview/SettingsPicker.svelte +++ b/packages/client/src/components/preview/SettingsPicker.svelte @@ -1,12 +1,12 @@
diff --git a/packages/client/src/constants.js b/packages/client/src/constants.js index 965ca788e1..51ef3fd124 100644 --- a/packages/client/src/constants.js +++ b/packages/client/src/constants.js @@ -21,6 +21,7 @@ export const UnsortableTypes = [ export const ActionTypes = { ValidateForm: "ValidateForm", + UpdateFieldValue: "UpdateFieldValue", RefreshDatasource: "RefreshDatasource", AddDataProviderQueryExtension: "AddDataProviderQueryExtension", RemoveDataProviderQueryExtension: "RemoveDataProviderQueryExtension", diff --git a/packages/client/src/stores/app.js b/packages/client/src/stores/app.js index a28a4cd9eb..2c2ead66c4 100644 --- a/packages/client/src/stores/app.js +++ b/packages/client/src/stores/app.js @@ -1,8 +1,14 @@ import { API } from "api" import { get, writable } from "svelte/store" +const initialState = { + appId: null, + isDevApp: false, + clientLoadTime: window.INIT_TIME ? Date.now() - window.INIT_TIME : null, +} + const createAppStore = () => { - const store = writable(null) + const store = writable(initialState) // Fetches the app definition including screens, layouts and theme const fetchAppDefinition = async () => { @@ -13,11 +19,13 @@ const createAppStore = () => { try { const appDefinition = await API.fetchAppPackage(appId) store.set({ + ...initialState, ...appDefinition, appId: appDefinition?.application?.appId, + isDevApp: appId.startsWith("app_dev"), }) } catch (error) { - store.set(null) + store.set(initialState) } } diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index 27c8bbe2a2..6d57ce7762 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -1,7 +1,6 @@ -import { writable, derived, get } from "svelte/store" -import Manifest from "manifest.json" -import { findComponentById, findComponentPathById } from "../utils/components" +import { writable, get } from "svelte/store" import { API } from "api" +import { devToolsStore } from "./devTools.js" const dispatchEvent = (type, data = {}) => { window.parent.postMessage({ type, data }) @@ -22,38 +21,18 @@ const createBuilderStore = () => { previewDevice: "desktop", isDragging: false, } - const writableStore = writable(initialState) - const derivedStore = derived(writableStore, $state => { - // Avoid any of this logic if we aren't in the builder preview - if (!$state.inBuilder) { - return $state - } - - // Derive the selected component instance and definition - const { layout, screen, previewType, selectedComponentId } = $state - const asset = previewType === "layout" ? layout : screen - const component = findComponentById(asset?.props, selectedComponentId) - const prefix = "@budibase/standard-components/" - const type = component?._component?.replace(prefix, "") - const definition = type ? Manifest[type] : null - - // Derive the selected component path - const path = findComponentPathById(asset.props, selectedComponentId) || [] - - return { - ...$state, - selectedComponent: component, - selectedComponentDefinition: definition, - selectedComponentPath: path?.map(component => component._id), - } - }) - + const store = writable(initialState) const actions = { selectComponent: id => { - if (id === get(writableStore).selectedComponentId) { + if (id === get(store).selectedComponentId) { return } - writableStore.update(state => ({ ...state, editMode: false })) + store.update(state => ({ + ...state, + editMode: false, + selectedComponentId: id, + })) + devToolsStore.actions.setAllowSelection(false) dispatchEvent("select-component", { id }) }, updateProp: (prop, value) => { @@ -76,7 +55,7 @@ const createBuilderStore = () => { } }, setSelectedPath: path => { - writableStore.update(state => ({ ...state, selectedPath: path })) + store.update(state => ({ ...state, selectedPath: path })) }, moveComponent: (componentId, destinationComponentId, mode) => { dispatchEvent("move-component", { @@ -86,22 +65,21 @@ const createBuilderStore = () => { }) }, setDragging: dragging => { - if (dragging === get(writableStore).isDragging) { + if (dragging === get(store).isDragging) { return } - writableStore.update(state => ({ ...state, isDragging: dragging })) + store.update(state => ({ ...state, isDragging: dragging })) }, setEditMode: enabled => { - if (enabled === get(writableStore).editMode) { + if (enabled === get(store).editMode) { return } - writableStore.update(state => ({ ...state, editMode: enabled })) + store.update(state => ({ ...state, editMode: enabled })) }, } return { - ...writableStore, - set: state => writableStore.set({ ...initialState, ...state }), - subscribe: derivedStore.subscribe, + ...store, + set: state => store.set({ ...initialState, ...state }), actions, } } diff --git a/packages/client/src/stores/components.js b/packages/client/src/stores/components.js new file mode 100644 index 0000000000..4f972b23c7 --- /dev/null +++ b/packages/client/src/stores/components.js @@ -0,0 +1,81 @@ +import { get, writable, derived } from "svelte/store" +import Manifest from "manifest.json" +import { findComponentById, findComponentPathById } from "../utils/components" +import { devToolsStore } from "./devTools" +import { screenStore } from "./screens" +import { builderStore } from "./builder" + +const createComponentStore = () => { + const store = writable({}) + + const derivedStore = derived( + [store, builderStore, devToolsStore, screenStore], + ([$store, $builderState, $devToolsState, $screenState]) => { + // Avoid any of this logic if we aren't in the builder preview + if (!$builderState.inBuilder && !$devToolsState.visible) { + return {} + } + + // Derive the selected component instance and definition + let asset + const { layout, screen, previewType, selectedComponentId } = $builderState + if ($builderState.inBuilder) { + asset = previewType === "layout" ? layout : screen + } else { + asset = $screenState.activeScreen + } + const component = findComponentById(asset?.props, selectedComponentId) + const prefix = "@budibase/standard-components/" + const type = component?._component?.replace(prefix, "") + const definition = type ? Manifest[type] : null + + // Derive the selected component path + const path = + findComponentPathById(asset?.props, selectedComponentId) || [] + + return { + selectedComponentInstance: $store[selectedComponentId], + selectedComponent: component, + selectedComponentDefinition: definition, + selectedComponentPath: path?.map(component => component._id), + mountedComponents: Object.keys($store).length, + currentAsset: asset, + } + } + ) + + const registerInstance = (id, instance) => { + store.update(state => ({ + ...state, + [id]: instance, + })) + } + + const unregisterInstance = id => { + store.update(state => { + delete state[id] + return state + }) + } + + const isComponentRegistered = id => { + return get(store)[id] != null + } + + const getComponentById = id => { + const asset = get(derivedStore).currentAsset + return findComponentById(asset?.props, id) + } + + return { + ...derivedStore, + actions: { + registerInstance, + unregisterInstance, + isComponentRegistered, + getComponentById, + }, + } +} + +export const componentStore = createComponentStore() diff --git a/packages/client/src/stores/devTools.js b/packages/client/src/stores/devTools.js new file mode 100644 index 0000000000..6d631080ab --- /dev/null +++ b/packages/client/src/stores/devTools.js @@ -0,0 +1,47 @@ +import { get } from "svelte/store" +import { createLocalStorageStore } from "@budibase/frontend-core" +import { appStore } from "./app" +import { initialise } from "./initialise" +import { authStore } from "./auth" + +const initialState = { + visible: false, + allowSelection: false, + role: null, +} + +const createDevToolStore = () => { + const localStorageKey = `${get(appStore).appId}.devTools` + const store = createLocalStorageStore(localStorageKey, initialState) + + const setVisible = visible => { + store.update(state => ({ + ...state, + visible: visible, + })) + } + + const setAllowSelection = allowSelection => { + store.update(state => ({ + ...state, + allowSelection, + })) + } + + const changeRole = async role => { + store.update(state => ({ + ...state, + role: role === "self" ? null : role, + })) + // location.reload() + await authStore.actions.fetchUser() + await initialise() + } + + return { + subscribe: store.subscribe, + actions: { setVisible, setAllowSelection, changeRole }, + } +} + +export const devToolsStore = createDevToolStore() diff --git a/packages/client/src/stores/index.js b/packages/client/src/stores/index.js index ddd052fb4e..280a32e069 100644 --- a/packages/client/src/stores/index.js +++ b/packages/client/src/stores/index.js @@ -9,6 +9,8 @@ export { confirmationStore } from "./confirmation" export { peekStore } from "./peek" export { stateStore } from "./state" export { themeStore } from "./theme" +export { devToolsStore } from "./devTools" +export { componentStore } from "./components" export { uploadStore } from "./uploads.js" export { rowSelectionStore } from "./rowSelection.js" // Context stores are layered and duplicated, so it is not a singleton diff --git a/packages/client/src/stores/screens.js b/packages/client/src/stores/screens.js index 702f662f8a..9635f2b5a0 100644 --- a/packages/client/src/stores/screens.js +++ b/packages/client/src/stores/screens.js @@ -66,7 +66,6 @@ const createScreenStore = () => { } let children = [] findChildrenByType(component, type, children) - console.log(children) return children }, } diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index ecc85eabf2..473b563bb1 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -81,7 +81,7 @@ const duplicateRowHandler = async (action, context) => { const deleteRowHandler = async action => { const { tableId, revId, rowId } = action.parameters - if (tableId && revId && rowId) { + if (tableId && rowId) { try { await API.deleteRow({ tableId, rowId, revId }) notificationStore.actions.success("Row deleted") @@ -162,6 +162,19 @@ const executeActionHandler = async ( } } +const updateFieldValueHandler = async (action, context) => { + return await executeActionHandler( + context, + action.parameters.componentId, + ActionTypes.UpdateFieldValue, + { + type: action.parameters.type, + field: action.parameters.field, + value: action.parameters.value, + } + ) +} + const validateFormHandler = async (action, context) => { return await executeActionHandler( context, @@ -295,6 +308,7 @@ const handlerMap = { ["Execute Query"]: queryExecutionHandler, ["Trigger Automation"]: triggerAutomationHandler, ["Validate Form"]: validateFormHandler, + ["Update Field Value"]: updateFieldValueHandler, ["Refresh Data Provider"]: refreshDataProviderHandler, ["Log Out"]: logoutHandler, ["Clear Form"]: clearFormHandler, @@ -329,13 +343,13 @@ export const enrichButtonActions = (actions, context) => { return actions } - // Button context is built up as actions are executed. - // Inherit any previous button context which may have come from actions - // before a confirmable action since this breaks the chain. - let buttonContext = context.actions || [] - const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]]) return async eventContext => { + // Button context is built up as actions are executed. + // Inherit any previous button context which may have come from actions + // before a confirmable action since this breaks the chain. + let buttonContext = context.actions || [] + for (let i = 0; i < handlers.length; i++) { try { // Skip any non-existent action definitions @@ -346,6 +360,7 @@ export const enrichButtonActions = (actions, context) => { // Built total context for this action const totalContext = { ...context, + state: get(stateStore), actions: buttonContext, eventContext, } diff --git a/packages/client/src/utils/componentProps.js b/packages/client/src/utils/componentProps.js index 14760252a9..38eaf77885 100644 --- a/packages/client/src/utils/componentProps.js +++ b/packages/client/src/utils/componentProps.js @@ -107,3 +107,21 @@ export const propsUseBinding = (props, bindingKey) => { } return false } + +/** + * Gets the definition of this component's settings from the manifest + */ +export const getSettingsDefinition = definition => { + if (!definition) { + return [] + } + let settings = [] + definition.settings?.forEach(setting => { + if (setting.section) { + settings = settings.concat(setting.settings || []) + } else { + settings.push(setting) + } + }) + return settings +} diff --git a/packages/frontend-core/package.json b/packages/frontend-core/package.json index 785d609462..fd651db17b 100644 --- a/packages/frontend-core/package.json +++ b/packages/frontend-core/package.json @@ -1,12 +1,12 @@ { "name": "@budibase/frontend-core", - "version": "1.0.123", + "version": "1.0.124-alpha.0", "description": "Budibase frontend core libraries used in builder and client", "author": "Budibase", "license": "MPL-2.0", "svelte": "src/index.js", "dependencies": { - "@budibase/bbui": "^1.0.123", + "@budibase/bbui": "^1.0.124-alpha.0", "lodash": "^4.17.21", "svelte": "^3.46.2" } diff --git a/packages/frontend-core/src/api/index.js b/packages/frontend-core/src/api/index.js index 20c3c4f686..164f51aae5 100644 --- a/packages/frontend-core/src/api/index.js +++ b/packages/frontend-core/src/api/index.js @@ -22,6 +22,7 @@ import { buildTemplateEndpoints } from "./templates" import { buildUserEndpoints } from "./user" import { buildSelfEndpoints } from "./self" import { buildViewEndpoints } from "./views" +import { buildLicensingEndpoints } from "./licensing" const defaultAPIClientConfig = { /** @@ -233,5 +234,6 @@ export const createAPIClient = config => { ...buildUserEndpoints(API), ...buildViewEndpoints(API), ...buildSelfEndpoints(API), + ...buildLicensingEndpoints(API), } } diff --git a/packages/frontend-core/src/api/licensing.js b/packages/frontend-core/src/api/licensing.js new file mode 100644 index 0000000000..a3e5583325 --- /dev/null +++ b/packages/frontend-core/src/api/licensing.js @@ -0,0 +1,30 @@ +export const buildLicensingEndpoints = API => ({ + /** + * Activates a self hosted license key + */ + activateLicenseKey: async data => { + return API.post({ + url: `/api/global/license/activate`, + body: data, + }) + }, + + /** + * Get the license info - metadata about the license including the + * obfuscated license key. + */ + getLicenseInfo: async () => { + return API.get({ + url: "/api/global/license/info", + }) + }, + + /** + * Refreshes the license cache + */ + refreshLicense: async () => { + return API.post({ + url: "/api/global/license/refresh", + }) + }, +}) diff --git a/packages/frontend-core/src/api/rows.js b/packages/frontend-core/src/api/rows.js index 6a0d278cf7..9f980678c5 100644 --- a/packages/frontend-core/src/api/rows.js +++ b/packages/frontend-core/src/api/rows.js @@ -35,7 +35,7 @@ export const buildRowEndpoints = API => ({ * @param revId the rev of the row to delete */ deleteRow: async ({ tableId, rowId, revId }) => { - if (!tableId || !rowId || !revId) { + if (!tableId || !rowId) { return } return await API.delete({ diff --git a/packages/server/nodemon.json b/packages/server/nodemon.json index 3c0f052aa0..a979dfb1cb 100644 --- a/packages/server/nodemon.json +++ b/packages/server/nodemon.json @@ -1,5 +1,5 @@ { - "watch": ["src", "../backend-core"], + "watch": ["src", "../backend-core", "../../../budibase-pro/packages/pro"], "ext": "js,ts,json", "ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], "exec": "ts-node src/index.ts" diff --git a/packages/server/package.json b/packages/server/package.json index 6f39cab8fb..1eb3b3f900 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/server", "email": "hi@budibase.com", - "version": "1.0.123", + "version": "1.0.124-alpha.0", "description": "Budibase Web Server", "main": "src/index.ts", "repository": { @@ -9,7 +9,7 @@ "url": "https://github.com/Budibase/budibase.git" }, "scripts": { - "build": "rimraf dist/ && tsc && mv dist/src/* dist/ && rmdir dist/src/ && yarn postbuild", + "build": "rimraf dist/ && tsc -p tsconfig.build.json && mv dist/src/* dist/ && rimraf dist/src/ && yarn postbuild", "postbuild": "copyfiles -u 1 src/**/*.svelte dist/ && copyfiles -u 1 src/**/*.hbs dist/ && copyfiles -u 1 src/**/*.json dist/", "test": "jest --coverage --maxWorkers=2", "test:watch": "jest --watch", @@ -68,16 +68,19 @@ "license": "GPL-3.0", "dependencies": { "@apidevtools/swagger-parser": "^10.0.3", - "@budibase/backend-core": "^1.0.123", - "@budibase/client": "^1.0.123", - "@budibase/string-templates": "^1.0.123", + "@budibase/backend-core": "^1.0.124-alpha.0", + "@budibase/client": "^1.0.124-alpha.0", + "@budibase/pro": "1.0.124-alpha.0", + "@budibase/string-templates": "^1.0.124-alpha.0", "@bull-board/api": "^3.7.0", "@bull-board/koa": "^3.7.0", "@elastic/elasticsearch": "7.10.0", "@google-cloud/firestore": "^5.0.2", "@koa/router": "8.0.0", "@sendgrid/mail": "7.1.1", - "@sentry/node": "^6.0.0", + "@sentry/node": "6.17.7", + "@types/global-agent": "^2.1.1", + "@types/koa__router": "^8.0.11", "airtable": "0.10.1", "arangojs": "7.2.0", "aws-sdk": "^2.767.0", @@ -91,6 +94,7 @@ "fix-path": "3.0.0", "form-data": "^4.0.0", "fs-extra": "8.1.0", + "global-agent": "^3.0.0", "google-auth-library": "^7.11.0", "google-spreadsheet": "^3.2.0", "jimp": "0.16.1", @@ -143,18 +147,20 @@ "@types/apidoc": "^0.50.0", "@types/bull": "^3.15.1", "@types/google-spreadsheet": "^3.1.5", - "@types/jest": "^26.0.23", + "@types/jest": "^27.4.1", "@types/koa": "^2.13.3", "@types/koa-router": "^7.4.2", "@types/lodash": "4.14.180", "@types/node": "^15.12.4", "@types/oracledb": "^5.2.1", "@types/redis": "^4.0.11", + "@typescript-eslint/parser": "5.12.0", "apidoc": "^0.50.2", "babel-jest": "^27.0.2", "copyfiles": "^2.4.1", "docker-compose": "^0.23.6", "eslint": "^6.8.0", + "ioredis-mock": "^7.2.0", "is-wsl": "^2.2.0", "jest": "^27.0.5", "jest-openapi": "^0.14.2", @@ -168,7 +174,7 @@ "swagger-jsdoc": "^6.1.0", "ts-jest": "^27.0.3", "ts-node": "^10.0.0", - "typescript": "^4.3.5", + "typescript": "^4.5.5", "update-dotenv": "^1.1.1" }, "optionalDependencies": { diff --git a/packages/server/scripts/dev/manage.js b/packages/server/scripts/dev/manage.js index 03b53c8855..9c1f0eb775 100644 --- a/packages/server/scripts/dev/manage.js +++ b/packages/server/scripts/dev/manage.js @@ -42,6 +42,8 @@ async function init() { REDIS_URL: "localhost:6379", WORKER_URL: "http://localhost:4002", INTERNAL_API_KEY: "budibase", + ACCOUNT_PORTAL_URL: "http://localhost:10001", + ACCOUNT_PORTAL_API_KEY: "budibase", JWT_SECRET: "testsecret", REDIS_PASSWORD: "budibase", MINIO_ACCESS_KEY: "budibase", diff --git a/packages/server/src/api/controllers/application.js b/packages/server/src/api/controllers/application.ts similarity index 71% rename from packages/server/src/api/controllers/application.js rename to packages/server/src/api/controllers/application.ts index ae1071124d..4e6d37765f 100644 --- a/packages/server/src/api/controllers/application.js +++ b/packages/server/src/api/controllers/application.ts @@ -1,29 +1,29 @@ -const env = require("../../environment") -const packageJson = require("../../../package.json") -const { +import env from "../../environment" +import packageJson from "../../../package.json" +import { createLinkView, createRoutingView, createAllSearchIndex, -} = require("../../db/views/staticViews") -const { +} from "../../db/views/staticViews" +import { getTemplateStream, createApp, deleteApp, -} = require("../../utilities/fileSystem") -const { +} from "../../utilities/fileSystem" +import { generateAppID, getLayoutParams, getScreenParams, generateDevAppID, DocumentTypes, AppStatus, -} = require("../../db/utils") +} from "../../db/utils" const { BUILTIN_ROLE_IDS, AccessController, } = require("@budibase/backend-core/roles") -const { BASE_LAYOUTS } = require("../../constants/layouts") -const { cloneDeep } = require("lodash/fp") +import { BASE_LAYOUTS } from "../../constants/layouts" +import { cloneDeep } from "lodash/fp" const { processObject } = require("@budibase/string-templates") const { getAllApps, @@ -31,24 +31,27 @@ const { getProdAppID, Replication, } = require("@budibase/backend-core/db") -const { USERS_TABLE_SCHEMA } = require("../../constants") -const { removeAppFromUserRoles } = require("../../utilities/workerRequests") -const { clientLibraryPath, stringToReadStream } = require("../../utilities") -const { getAllLocks } = require("../../utilities/redis") -const { +import { USERS_TABLE_SCHEMA } from "../../constants" +import { removeAppFromUserRoles } from "../../utilities/workerRequests" +import { clientLibraryPath, stringToReadStream } from "../../utilities" +import { getAllLocks } from "../../utilities/redis" +import { updateClientLibrary, backupClientLibrary, revertClientLibrary, -} = require("../../utilities/fileSystem/clientLibrary") +} from "../../utilities/fileSystem/clientLibrary" const { getTenantId, isMultiTenant } = require("@budibase/backend-core/tenancy") -const { syncGlobalUsers } = require("./user") +import { syncGlobalUsers } from "./user" const { app: appCache } = require("@budibase/backend-core/cache") -const { cleanupAutomations } = require("../../automations/utils") +import { cleanupAutomations } from "../../automations/utils" const { getAppDB, getProdAppDB, updateAppId, } = require("@budibase/backend-core/context") +import { getUniqueRows } from "../../utilities/usageQuota/rows" +import { quotas } from "@budibase/pro" +import { errors } from "@budibase/backend-core" const URL_REGEX_SLASH = /\/|\\/g @@ -61,7 +64,7 @@ async function getLayouts() { include_docs: true, }) ) - ).rows.map(row => row.doc) + ).rows.map((row: any) => row.doc) } async function getScreens() { @@ -72,16 +75,16 @@ async function getScreens() { include_docs: true, }) ) - ).rows.map(row => row.doc) + ).rows.map((row: any) => row.doc) } -function getUserRoleId(ctx) { +function getUserRoleId(ctx: any) { return !ctx.user.role || !ctx.user.role._id ? BUILTIN_ROLE_IDS.PUBLIC : ctx.user.role._id } -exports.getAppUrl = ctx => { +export const getAppUrl = (ctx: any) => { // construct the url let url if (ctx.request.body.url) { @@ -97,29 +100,34 @@ exports.getAppUrl = ctx => { return url } -const checkAppUrl = (ctx, apps, url, currentAppId) => { +const checkAppUrl = (ctx: any, apps: any, url: any, currentAppId?: string) => { if (currentAppId) { - apps = apps.filter(app => app.appId !== currentAppId) + apps = apps.filter((app: any) => app.appId !== currentAppId) } - if (apps.some(app => app.url === url)) { + if (apps.some((app: any) => app.url === url)) { ctx.throw(400, "App URL is already in use.") } } -const checkAppName = (ctx, apps, name, currentAppId) => { +const checkAppName = ( + ctx: any, + apps: any, + name: any, + currentAppId?: string +) => { // TODO: Replace with Joi if (!name) { ctx.throw(400, "Name is required") } if (currentAppId) { - apps = apps.filter(app => app.appId !== currentAppId) + apps = apps.filter((app: any) => app.appId !== currentAppId) } - if (apps.some(app => app.name === name)) { + if (apps.some((app: any) => app.name === name)) { ctx.throw(400, "App name is already in use.") } } -async function createInstance(template) { +async function createInstance(template: any) { const tenantId = isMultiTenant() ? getTenantId() : null const baseAppId = generateAppID(tenantId) const appId = generateDevAppID(baseAppId) @@ -160,7 +168,7 @@ async function createInstance(template) { return { _id: appId } } -exports.fetch = async ctx => { +export const fetch = async (ctx: any) => { const dev = ctx.query && ctx.query.status === AppStatus.DEV const all = ctx.query && ctx.query.status === AppStatus.ALL const apps = await getAllApps({ dev, all }) @@ -172,7 +180,7 @@ exports.fetch = async ctx => { if (app.status !== "development") { continue } - const lock = locks.find(lock => lock.appId === app.appId) + const lock = locks.find((lock: any) => lock.appId === app.appId) if (lock) { app.lockedBy = lock.user } else { @@ -185,7 +193,7 @@ exports.fetch = async ctx => { ctx.body = apps } -exports.fetchAppDefinition = async ctx => { +export const fetchAppDefinition = async (ctx: any) => { const layouts = await getLayouts() const userRoleId = getUserRoleId(ctx) const accessController = new AccessController() @@ -200,7 +208,7 @@ exports.fetchAppDefinition = async ctx => { } } -exports.fetchAppPackage = async ctx => { +export const fetchAppPackage = async (ctx: any) => { const db = getAppDB() const application = await db.get(DocumentTypes.APP_METADATA) const layouts = await getLayouts() @@ -221,7 +229,7 @@ exports.fetchAppPackage = async ctx => { } } -exports.create = async ctx => { +const performAppCreate = async (ctx: any) => { const apps = await getAllApps({ dev: true }) const name = ctx.request.body.name checkAppName(ctx, apps, name) @@ -229,7 +237,7 @@ exports.create = async ctx => { checkAppUrl(ctx, apps, url) const { useTemplate, templateKey, templateString } = ctx.request.body - const instanceConfig = { + const instanceConfig: any = { useTemplate, key: templateKey, templateString, @@ -280,13 +288,41 @@ exports.create = async ctx => { } await appCache.invalidateAppMetadata(appId, newApplication) - ctx.status = 200 + return newApplication +} + +const appPostCreate = async (ctx: any, appId: string) => { + // app import & template creation + if (ctx.request.body.useTemplate === "true") { + const rows = await getUniqueRows([appId]) + const rowCount = rows ? rows.length : 0 + if (rowCount) { + try { + await quotas.addRows(rowCount) + } catch (err: any) { + if (err.code && err.code === errors.codes.USAGE_LIMIT_EXCEEDED) { + // this import resulted in row usage exceeding the quota + // delete the app + // skip pre and post steps as no rows have been added to quotas yet + ctx.params.appId = appId + await destroyApp(ctx) + } + throw err + } + } + } +} + +export const create = async (ctx: any) => { + const newApplication = await quotas.addApp(() => performAppCreate(ctx)) + await appPostCreate(ctx, newApplication.appId) ctx.body = newApplication + ctx.status = 200 } // This endpoint currently operates as a PATCH rather than a PUT // Thus name and url fields are handled only if present -exports.update = async ctx => { +export const update = async (ctx: any) => { const apps = await getAllApps({ dev: true }) // validation const name = ctx.request.body.name @@ -304,7 +340,7 @@ exports.update = async ctx => { ctx.body = data } -exports.updateClient = async ctx => { +export const updateClient = async (ctx: any) => { // Get current app version const db = getAppDB() const application = await db.get(DocumentTypes.APP_METADATA) @@ -326,7 +362,7 @@ exports.updateClient = async ctx => { ctx.body = data } -exports.revertClient = async ctx => { +export const revertClient = async (ctx: any) => { // Check app can be reverted const db = getAppDB() const application = await db.get(DocumentTypes.APP_METADATA) @@ -349,26 +385,57 @@ exports.revertClient = async ctx => { ctx.body = data } -exports.delete = async ctx => { - const db = getAppDB() +const destroyApp = async (ctx: any) => { + let appId = ctx.params.appId + let isUnpublish = ctx.query && ctx.query.unpublish - const result = await db.destroy() - /* istanbul ignore next */ - if (!env.isTest() && !ctx.query.unpublish) { - await deleteApp(ctx.params.appId) + if (isUnpublish) { + appId = getProdAppID(appId) } - if (ctx.query && ctx.query.unpublish) { - await cleanupAutomations(ctx.params.appId) + + const db = isUnpublish ? getProdAppDB() : getAppDB() + const result = await db.destroy() + + if (isUnpublish) { + await quotas.removePublishedApp() + } else { + await quotas.removeApp() + } + + /* istanbul ignore next */ + if (!env.isTest() && !isUnpublish) { + await deleteApp(appId) + } + if (isUnpublish) { + await cleanupAutomations(appId) } // make sure the app/role doesn't stick around after the app has been deleted - await removeAppFromUserRoles(ctx, ctx.params.appId) - await appCache.invalidateAppMetadata(ctx.params.appId) + await removeAppFromUserRoles(ctx, appId) + await appCache.invalidateAppMetadata(appId) + return result +} +const preDestroyApp = async (ctx: any) => { + const rows = await getUniqueRows([ctx.params.appId]) + ctx.rowCount = rows.length +} + +const postDestroyApp = async (ctx: any) => { + const rowCount = ctx.rowCount + if (rowCount) { + await quotas.removeRows(rowCount) + } +} + +export const destroy = async (ctx: any) => { + await preDestroyApp(ctx) + const result = await destroyApp(ctx) + await postDestroyApp(ctx) ctx.status = 200 ctx.body = result } -exports.sync = async (ctx, next) => { +export const sync = async (ctx: any, next: any) => { const appId = ctx.params.appId if (!isDevAppID(appId)) { ctx.throw(400, "This action cannot be performed for production apps") @@ -398,7 +465,7 @@ exports.sync = async (ctx, next) => { let error try { await replication.replicate({ - filter: function (doc) { + filter: function (doc: any) { return doc._id !== DocumentTypes.APP_METADATA }, }) @@ -420,7 +487,7 @@ exports.sync = async (ctx, next) => { } } -const updateAppPackage = async (appPackage, appId) => { +const updateAppPackage = async (appPackage: any, appId: any) => { const db = getAppDB() const application = await db.get(DocumentTypes.APP_METADATA) @@ -439,7 +506,7 @@ const updateAppPackage = async (appPackage, appId) => { return response } -const createEmptyAppPackage = async (ctx, app) => { +const createEmptyAppPackage = async (ctx: any, app: any) => { const db = getAppDB() let screensAndLayouts = [] diff --git a/packages/server/src/api/controllers/deploy/index.js b/packages/server/src/api/controllers/deploy/index.ts similarity index 72% rename from packages/server/src/api/controllers/deploy/index.js rename to packages/server/src/api/controllers/deploy/index.ts index 791d04e8c4..cd0145e2d6 100644 --- a/packages/server/src/api/controllers/deploy/index.js +++ b/packages/server/src/api/controllers/deploy/index.ts @@ -1,20 +1,18 @@ -const Deployment = require("./Deployment") -const { +import Deployment from "./Deployment" +import { Replication, getProdAppID, getDevelopmentAppID, -} = require("@budibase/backend-core/db") -const { DocumentTypes, getAutomationParams } = require("../../../db/utils") -const { - disableAllCrons, - enableCronTrigger, -} = require("../../../automations/utils") -const { app: appCache } = require("@budibase/backend-core/cache") -const { +} from "@budibase/backend-core/db" +import { DocumentTypes, getAutomationParams } from "../../../db/utils" +import { disableAllCrons, enableCronTrigger } from "../../../automations/utils" +import { app as appCache } from "@budibase/backend-core/cache" +import { getAppId, getAppDB, getProdAppDB, -} = require("@budibase/backend-core/context") +} from "@budibase/backend-core/context" +import { quotas } from "@budibase/pro" // the max time we can wait for an invalidation to complete before considering it failed const MAX_PENDING_TIME_MS = 30 * 60000 @@ -25,9 +23,10 @@ const DeploymentStatus = { } // checks that deployments are in a good state, any pending will be updated -async function checkAllDeployments(deployments) { +async function checkAllDeployments(deployments: any) { let updated = false - for (let deployment of Object.values(deployments.history)) { + let deployment: any + for (deployment of Object.values(deployments.history)) { // check that no deployments have crashed etc and are now stuck if ( deployment.status === DeploymentStatus.PENDING && @@ -41,7 +40,7 @@ async function checkAllDeployments(deployments) { return { updated, deployments } } -async function storeDeploymentHistory(deployment) { +async function storeDeploymentHistory(deployment: any) { const deploymentJSON = deployment.getJSON() const db = getAppDB() @@ -70,7 +69,7 @@ async function storeDeploymentHistory(deployment) { return deployment } -async function initDeployedApp(prodAppId) { +async function initDeployedApp(prodAppId: any) { const db = getProdAppDB() console.log("Reading automation docs") const automations = ( @@ -79,7 +78,7 @@ async function initDeployedApp(prodAppId) { include_docs: true, }) ) - ).rows.map(row => row.doc) + ).rows.map((row: any) => row.doc) console.log("You have " + automations.length + " automations") const promises = [] console.log("Disabling prod crons..") @@ -93,21 +92,27 @@ async function initDeployedApp(prodAppId) { console.log("Enabled cron triggers for deployed app..") } -async function deployApp(deployment) { +async function deployApp(deployment: any) { let replication try { const appId = getAppId() const devAppId = getDevelopmentAppID(appId) const productionAppId = getProdAppID(appId) - replication = new Replication({ + + const config: any = { source: devAppId, target: productionAppId, - }) + } + replication = new Replication(config) + console.log("Replication object created") await replication.replicate() console.log("replication complete.. replacing app meta doc") const db = getProdAppDB() const appDoc = await db.get(DocumentTypes.APP_METADATA) + + deployment.appUrl = appDoc.url + appDoc.appId = productionAppId appDoc.instance._id = productionAppId await db.put(appDoc) @@ -117,7 +122,7 @@ async function deployApp(deployment) { console.log("Deployed app initialised, setting deployment to successful") deployment.setStatus(DeploymentStatus.SUCCESS) await storeDeploymentHistory(deployment) - } catch (err) { + } catch (err: any) { deployment.setStatus(DeploymentStatus.FAILURE, err.message) await storeDeploymentHistory(deployment) throw { @@ -125,18 +130,17 @@ async function deployApp(deployment) { message: `Deployment Failed: ${err.message}`, } } finally { - await replication.close() + if (replication) { + await replication.close() + } } } -exports.fetchDeployments = async function (ctx) { +export async function fetchDeployments(ctx: any) { try { const db = getAppDB() const deploymentDoc = await db.get(DocumentTypes.DEPLOYMENTS) - const { updated, deployments } = await checkAllDeployments( - deploymentDoc, - ctx.user - ) + const { updated, deployments } = await checkAllDeployments(deploymentDoc) if (updated) { await db.put(deployments) } @@ -146,7 +150,7 @@ exports.fetchDeployments = async function (ctx) { } } -exports.deploymentProgress = async function (ctx) { +export async function deploymentProgress(ctx: any) { try { const db = getAppDB() const deploymentDoc = await db.get(DocumentTypes.DEPLOYMENTS) @@ -159,7 +163,20 @@ exports.deploymentProgress = async function (ctx) { } } -exports.deployApp = async function (ctx) { +const isFirstDeploy = async () => { + try { + const db = getProdAppDB() + await db.get(DocumentTypes.APP_METADATA) + } catch (e: any) { + if (e.status === 404) { + return true + } + throw e + } + return false +} + +const _deployApp = async function (ctx: any) { let deployment = new Deployment() console.log("Deployment object created") deployment.setStatus(DeploymentStatus.PENDING) @@ -168,7 +185,14 @@ exports.deployApp = async function (ctx) { console.log("Stored deployment history") console.log("Deploying app...") - await deployApp(deployment) + + if (await isFirstDeploy()) { + await quotas.addPublishedApp(() => deployApp(deployment)) + } else { + await deployApp(deployment) + } ctx.body = deployment } + +export { _deployApp as deployApp } diff --git a/packages/server/src/api/controllers/public/applications.ts b/packages/server/src/api/controllers/public/applications.ts index c756a6df7c..0bb847be22 100644 --- a/packages/server/src/api/controllers/public/applications.ts +++ b/packages/server/src/api/controllers/public/applications.ts @@ -1,7 +1,7 @@ const { getAllApps } = require("@budibase/backend-core/db") const { updateAppId } = require("@budibase/backend-core/context") import { search as stringSearch } from "./utils" -import { default as controller } from "../application" +import * as controller from "../application" import { Application } from "../../../definitions/common" function fixAppID(app: Application, params: any) { @@ -59,7 +59,7 @@ export async function destroy(ctx: any, next: any) { // get the app before deleting it await setResponseApp(ctx) const body = ctx.body - await controller.delete(ctx) + await controller.destroy(ctx) // overwrite the body again ctx.body = body await next() diff --git a/packages/server/src/api/controllers/public/queries.ts b/packages/server/src/api/controllers/public/queries.ts index efef60594b..57ec608379 100644 --- a/packages/server/src/api/controllers/public/queries.ts +++ b/packages/server/src/api/controllers/public/queries.ts @@ -1,5 +1,5 @@ import { search as stringSearch } from "./utils" -import { default as queryController } from "../query" +import * as queryController from "../query" export async function search(ctx: any, next: any) { await queryController.fetch(ctx) diff --git a/packages/server/src/api/controllers/public/rows.ts b/packages/server/src/api/controllers/public/rows.ts index c69ad27314..4daccd9542 100644 --- a/packages/server/src/api/controllers/public/rows.ts +++ b/packages/server/src/api/controllers/public/rows.ts @@ -1,4 +1,4 @@ -import { default as rowController } from "../row" +import * as rowController from "../row" import { addRev } from "./utils" import { Row } from "../../../definitions/common" import { convertBookmark } from "../../../utilities" diff --git a/packages/server/src/api/controllers/query/index.js b/packages/server/src/api/controllers/query/index.ts similarity index 66% rename from packages/server/src/api/controllers/query/index.js rename to packages/server/src/api/controllers/query/index.ts index 7a179bab35..3f9d0275b4 100644 --- a/packages/server/src/api/controllers/query/index.js +++ b/packages/server/src/api/controllers/query/index.ts @@ -1,22 +1,19 @@ -const { - generateQueryID, - getQueryParams, - isProdAppID, -} = require("../../../db/utils") -const { BaseQueryVerbs } = require("../../../constants") -const { Thread, ThreadType } = require("../../../threads") -const { save: saveDatasource } = require("../datasource") -const { RestImporter } = require("./import") -const { invalidateDynamicVariables } = require("../../../threads/utils") -const environment = require("../../../environment") -const { getAppDB } = require("@budibase/backend-core/context") +import { generateQueryID, getQueryParams, isProdAppID } from "../../../db/utils" +import { BaseQueryVerbs } from "../../../constants" +import { Thread, ThreadType } from "../../../threads" +import { save as saveDatasource } from "../datasource" +import { RestImporter } from "./import" +import { invalidateDynamicVariables } from "../../../threads/utils" +import { QUERY_THREAD_TIMEOUT } from "../../../environment" +import { getAppDB } from "@budibase/backend-core/context" +import { quotas } from "@budibase/pro" const Runner = new Thread(ThreadType.QUERY, { - timeoutMs: environment.QUERY_THREAD_TIMEOUT || 10000, + timeoutMs: QUERY_THREAD_TIMEOUT || 10000, }) // simple function to append "readable" to all read queries -function enrichQueries(input) { +function enrichQueries(input: any) { const wasArray = Array.isArray(input) const queries = wasArray ? input : [input] for (let query of queries) { @@ -27,7 +24,7 @@ function enrichQueries(input) { return wasArray ? queries : queries[0] } -exports.fetch = async function (ctx) { +export async function fetch(ctx: any) { const db = getAppDB() const body = await db.allDocs( @@ -36,10 +33,10 @@ exports.fetch = async function (ctx) { }) ) - ctx.body = enrichQueries(body.rows.map(row => row.doc)) + ctx.body = enrichQueries(body.rows.map((row: any) => row.doc)) } -exports.import = async ctx => { +const _import = async (ctx: any) => { const body = ctx.request.body const data = body.data @@ -49,7 +46,7 @@ exports.import = async ctx => { let datasourceId if (!body.datasourceId) { // construct new datasource - const info = await importer.getInfo() + const info: any = await importer.getInfo() let datasource = { type: "datasource", source: "REST", @@ -77,8 +74,9 @@ exports.import = async ctx => { } ctx.status = 200 } +export { _import as import } -exports.save = async function (ctx) { +export async function save(ctx: any) { const db = getAppDB() const query = ctx.request.body @@ -93,7 +91,7 @@ exports.save = async function (ctx) { ctx.message = `Query ${query.name} saved successfully.` } -exports.find = async function (ctx) { +export async function find(ctx: any) { const db = getAppDB() const query = enrichQueries(await db.get(ctx.params.queryId)) // remove properties that could be dangerous in real app @@ -104,7 +102,7 @@ exports.find = async function (ctx) { ctx.body = query } -exports.preview = async function (ctx) { +export async function preview(ctx: any) { const db = getAppDB() const datasource = await db.get(ctx.request.body.datasourceId) @@ -114,16 +112,18 @@ exports.preview = async function (ctx) { ctx.request.body try { - const { rows, keys, info, extra } = await Runner.run({ - appId: ctx.appId, - datasource, - queryVerb, - fields, - parameters, - transformer, - queryId, - }) + const runFn = () => + Runner.run({ + appId: ctx.appId, + datasource, + queryVerb, + fields, + parameters, + transformer, + queryId, + }) + const { rows, keys, info, extra } = await quotas.addQuery(runFn) ctx.body = { rows, schemaFields: [...new Set(keys)], @@ -135,7 +135,7 @@ exports.preview = async function (ctx) { } } -async function execute(ctx, opts = { rowsOnly: false }) { +async function execute(ctx: any, opts = { rowsOnly: false }) { const db = getAppDB() const query = await db.get(ctx.params.queryId) @@ -153,16 +153,19 @@ async function execute(ctx, opts = { rowsOnly: false }) { // call the relevant CRUD method on the integration class try { - const { rows, pagination, extra } = await Runner.run({ - appId: ctx.appId, - datasource, - queryVerb: query.queryVerb, - fields: query.fields, - pagination: ctx.request.body.pagination, - parameters: enrichedParameters, - transformer: query.transformer, - queryId: ctx.params.queryId, - }) + const runFn = () => + Runner.run({ + appId: ctx.appId, + datasource, + queryVerb: query.queryVerb, + fields: query.fields, + pagination: ctx.request.body.pagination, + parameters: enrichedParameters, + transformer: query.transformer, + queryId: ctx.params.queryId, + }) + + const { rows, pagination, extra } = await quotas.addQuery(runFn) if (opts && opts.rowsOnly) { ctx.body = rows } else { @@ -173,15 +176,15 @@ async function execute(ctx, opts = { rowsOnly: false }) { } } -exports.executeV1 = async function (ctx) { +export async function executeV1(ctx: any) { return execute(ctx, { rowsOnly: true }) } -exports.executeV2 = async function (ctx) { +export async function executeV2(ctx: any) { return execute(ctx, { rowsOnly: false }) } -const removeDynamicVariables = async queryId => { +const removeDynamicVariables = async (queryId: any) => { const db = getAppDB() const query = await db.get(queryId) const datasource = await db.get(query.datasourceId) @@ -190,19 +193,19 @@ const removeDynamicVariables = async queryId => { if (dynamicVariables) { // delete dynamic variables from the datasource datasource.config.dynamicVariables = dynamicVariables.filter( - dv => dv.queryId !== queryId + (dv: any) => dv.queryId !== queryId ) await db.put(datasource) // invalidate the deleted variables const variablesToDelete = dynamicVariables.filter( - dv => dv.queryId === queryId + (dv: any) => dv.queryId === queryId ) await invalidateDynamicVariables(variablesToDelete) } } -exports.destroy = async function (ctx) { +export async function destroy(ctx: any) { const db = getAppDB() await removeDynamicVariables(ctx.params.queryId) await db.remove(ctx.params.queryId, ctx.params.revId) diff --git a/packages/server/src/api/controllers/row/ExternalRequest.ts b/packages/server/src/api/controllers/row/ExternalRequest.ts index f7db52d0b2..d7063e590b 100644 --- a/packages/server/src/api/controllers/row/ExternalRequest.ts +++ b/packages/server/src/api/controllers/row/ExternalRequest.ts @@ -52,7 +52,7 @@ interface RunConfig { module External { function buildFilters( - id: string | undefined, + id: string | undefined | string[], filters: SearchFilters, table: Table ) { diff --git a/packages/server/src/api/controllers/row/index.js b/packages/server/src/api/controllers/row/index.ts similarity index 77% rename from packages/server/src/api/controllers/row/index.js rename to packages/server/src/api/controllers/row/index.ts index c4a9ac8f06..c50fef496e 100644 --- a/packages/server/src/api/controllers/row/index.js +++ b/packages/server/src/api/controllers/row/index.ts @@ -1,15 +1,16 @@ -const internal = require("./internal") -const external = require("./external") -const { isExternalTable } = require("../../../integrations/utils") +import { quotas } from "@budibase/pro" +import internal from "./internal" +import external from "./external" +import { isExternalTable } from "../../../integrations/utils" -function pickApi(tableId) { +function pickApi(tableId: any) { if (isExternalTable(tableId)) { return external } return internal } -function getTableId(ctx) { +function getTableId(ctx: any) { if (ctx.request.body && ctx.request.body.tableId) { return ctx.request.body.tableId } @@ -21,13 +22,13 @@ function getTableId(ctx) { } } -exports.patch = async ctx => { +export async function patch(ctx: any): Promise { const appId = ctx.appId const tableId = getTableId(ctx) const body = ctx.request.body // if it doesn't have an _id then its save if (body && !body._id) { - return exports.save(ctx) + return save(ctx) } try { const { row, table } = await pickApi(tableId).patch(ctx) @@ -41,13 +42,13 @@ exports.patch = async ctx => { } } -exports.save = async function (ctx) { +const saveRow = async (ctx: any) => { const appId = ctx.appId const tableId = getTableId(ctx) const body = ctx.request.body // if it has an ID already then its a patch if (body && body._id) { - return exports.patch(ctx) + return patch(ctx) } try { const { row, table } = await pickApi(tableId).save(ctx) @@ -60,7 +61,11 @@ exports.save = async function (ctx) { } } -exports.fetchView = async function (ctx) { +export async function save(ctx: any) { + await quotas.addRow(() => saveRow(ctx)) +} + +export async function fetchView(ctx: any) { const tableId = getTableId(ctx) try { ctx.body = await pickApi(tableId).fetchView(ctx) @@ -69,7 +74,7 @@ exports.fetchView = async function (ctx) { } } -exports.fetch = async function (ctx) { +export async function fetch(ctx: any) { const tableId = getTableId(ctx) try { ctx.body = await pickApi(tableId).fetch(ctx) @@ -78,7 +83,7 @@ exports.fetch = async function (ctx) { } } -exports.find = async function (ctx) { +export async function find(ctx: any) { const tableId = getTableId(ctx) try { ctx.body = await pickApi(tableId).find(ctx) @@ -87,19 +92,21 @@ exports.find = async function (ctx) { } } -exports.destroy = async function (ctx) { +export async function destroy(ctx: any) { const appId = ctx.appId const inputs = ctx.request.body const tableId = getTableId(ctx) let response, row if (inputs.rows) { let { rows } = await pickApi(tableId).bulkDestroy(ctx) + await quotas.removeRows(rows.length) response = rows for (let row of rows) { ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) } } else { let resp = await pickApi(tableId).destroy(ctx) + await quotas.removeRow() response = resp.response row = resp.row ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row) @@ -110,7 +117,7 @@ exports.destroy = async function (ctx) { ctx.body = response } -exports.search = async ctx => { +export async function search(ctx: any) { const tableId = getTableId(ctx) try { ctx.status = 200 @@ -120,7 +127,7 @@ exports.search = async ctx => { } } -exports.validate = async function (ctx) { +export async function validate(ctx: any) { const tableId = getTableId(ctx) try { ctx.body = await pickApi(tableId).validate(ctx) @@ -129,7 +136,7 @@ exports.validate = async function (ctx) { } } -exports.fetchEnrichedRow = async function (ctx) { +export async function fetchEnrichedRow(ctx: any) { const tableId = getTableId(ctx) try { ctx.body = await pickApi(tableId).fetchEnrichedRow(ctx) @@ -138,7 +145,7 @@ exports.fetchEnrichedRow = async function (ctx) { } } -exports.export = async function (ctx) { +export const exportRows = async (ctx: any) => { const tableId = getTableId(ctx) try { ctx.body = await pickApi(tableId).exportRows(ctx) diff --git a/packages/server/src/api/controllers/row/internal.js b/packages/server/src/api/controllers/row/internal.js index 8083b10584..b2b932a69a 100644 --- a/packages/server/src/api/controllers/row/internal.js +++ b/packages/server/src/api/controllers/row/internal.js @@ -260,8 +260,9 @@ exports.find = async ctx => { exports.destroy = async function (ctx) { const db = getAppDB() - const { _id, _rev } = ctx.request.body + const { _id } = ctx.request.body let row = await db.get(_id) + let _rev = ctx.request.body._rev || row._rev if (row.tableId !== ctx.params.tableId) { throw "Supplied tableId doesn't match the row's tableId" diff --git a/packages/server/src/api/controllers/row/utils.js b/packages/server/src/api/controllers/row/utils.js index 9ff6b085af..9bea800d63 100644 --- a/packages/server/src/api/controllers/row/utils.js +++ b/packages/server/src/api/controllers/row/utils.js @@ -65,7 +65,10 @@ exports.validate = async ({ tableId, row, table }) => { if (type === FieldTypes.ARRAY && row[fieldName]) { if (row[fieldName].length) { row[fieldName].map(val => { - if (!constraints.inclusion.includes(val)) { + if ( + !constraints.inclusion.includes(val) && + constraints.inclusion.length !== 0 + ) { errors[fieldName] = "Field not in list" } }) diff --git a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte index 761e953ff7..35379ba6d8 100644 --- a/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte +++ b/packages/server/src/api/controllers/static/templates/BudibaseApp.svelte @@ -78,6 +78,9 @@ app.
+