Browse Source

Merge pull request #5562 from Budibase/develop

Develop -> Master
master
Martin McKeaveney 4 years ago
committed by GitHub
parent
commit
ec57efbd18
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      .dockerignore
  2. 2
      .github/CONTRIBUTING.md
  3. 9
      .github/ISSUE_TEMPLATE/bug_report.md
  4. 3
      .github/stale.yml
  5. 9
      .github/workflows/budibase_ci.yml
  6. 11
      .github/workflows/release-develop.yml
  7. 59
      .github/workflows/release.yml
  8. 15
      charts/budibase/templates/app-service-deployment.yaml
  9. 8
      charts/budibase/templates/couchdb-backup.yaml
  10. 7
      charts/budibase/templates/minio-data-persistentvolumeclaim.yaml
  11. 8
      charts/budibase/templates/minio-service-deployment.yaml
  12. 8
      charts/budibase/templates/proxy-service-deployment.yaml
  13. 7
      charts/budibase/templates/redis-data-persistentvolumeclaim.yaml
  14. 8
      charts/budibase/templates/redis-service-deployment.yaml
  15. 8
      charts/budibase/templates/worker-service-deployment.yaml
  16. 17
      charts/budibase/values.yaml
  17. 6
      examples/nextjs-api-sales/yarn.lock
  18. 1
      hosting/docker-compose.dev.yaml
  19. 1
      hosting/docker-compose.yaml
  20. 6
      hosting/nginx.dev.conf.hbs
  21. 6
      hosting/nginx.prod.conf.hbs
  22. 94
      hosting/portainer/template.json
  23. 3
      hosting/proxy/Dockerfile
  24. 175
      hosting/proxy/error.html
  25. 97
      hosting/single/Dockerfile
  26. 12
      hosting/single/clouseau
  27. 13
      hosting/single/clouseau.ini
  28. 4
      hosting/single/log4j.properties
  29. 116
      hosting/single/nginx.conf
  30. 16
      hosting/single/runner.sh
  31. 32
      hosting/single/vm.args
  32. 2
      lerna.json
  33. 14
      package.json
  34. 2
      packages/backend-core/package.json
  35. 2
      packages/backend-core/src/constants.js
  36. 11
      packages/backend-core/src/context/index.js
  37. 1
      packages/backend-core/src/db/constants.js
  38. 26
      packages/backend-core/src/db/utils.js
  39. 24
      packages/backend-core/src/db/views.js
  40. 13
      packages/backend-core/src/environment.js
  41. 11
      packages/backend-core/src/errors/base.js
  42. 41
      packages/backend-core/src/errors/index.js
  43. 32
      packages/backend-core/src/errors/licensing.js
  44. 52
      packages/backend-core/src/featureFlags/index.js
  45. 2
      packages/backend-core/src/index.js
  46. 46
      packages/backend-core/src/middleware/passport/google.js
  47. 88
      packages/backend-core/src/middleware/passport/oidc.js
  48. 7
      packages/backend-core/src/middleware/passport/tests/google.spec.js
  49. 4
      packages/backend-core/src/middleware/passport/tests/oidc.spec.js
  50. 6
      packages/backend-core/src/middleware/passport/third-party-common.js
  51. 1
      packages/backend-core/src/redis/utils.js
  52. 40
      packages/backend-core/src/security/sessions.js
  53. 22
      packages/backend-core/src/utils.js
  54. 6
      packages/backend-core/yarn.lock
  55. 4
      packages/bbui/package.json
  56. 4
      packages/bbui/src/ActionButton/ActionButton.svelte
  57. 3
      packages/bbui/src/ActionMenu/ActionMenu.svelte
  58. 41
      packages/bbui/src/Form/Core/DatePicker.svelte
  59. 62
      packages/bbui/src/Icon/Icon.svelte
  60. 8
      packages/bbui/src/Layout/Layout.svelte
  61. 5
      packages/bbui/src/Layout/Page.svelte
  62. 12
      packages/bbui/src/Modal/ModalContent.svelte
  63. 27
      packages/bbui/src/Popover/Popover.svelte
  64. 10
      packages/bbui/src/ProgressBar/ProgressBar.svelte
  65. 45
      packages/bbui/src/Table/InternalRenderer.svelte
  66. 38
      packages/bbui/src/Table/Table.svelte
  67. 2
      packages/bbui/src/Tabs/Tabs.svelte
  68. 2
      packages/bbui/src/Typography/Body.svelte
  69. 3
      packages/bbui/src/Typography/Heading.svelte
  70. 26
      packages/bbui/src/helpers.js
  71. 6
      packages/bbui/yarn.lock
  72. 112
      packages/builder/cypress/integration/appPublishWorkflow.spec.js
  73. 144
      packages/builder/cypress/integration/autoScreensUI.spec.js
  74. 3
      packages/builder/cypress/integration/changeAppIconAndColour.spec.js
  75. 10
      packages/builder/cypress/integration/createApp.spec.js
  76. 8
      packages/builder/cypress/integration/createAutomation.spec.js
  77. 2
      packages/builder/cypress/integration/createBinding.spec.js
  78. 20
      packages/builder/cypress/integration/createScreen.js
  79. 17
      packages/builder/cypress/integration/createTable.spec.js
  80. 4
      packages/builder/cypress/integration/createView.spec.js
  81. 46
      packages/builder/cypress/integration/datasources/mySql.spec.js
  82. 17
      packages/builder/cypress/integration/datasources/oracle.spec.js
  83. 49
      packages/builder/cypress/integration/datasources/postgreSql.spec.js
  84. 50
      packages/builder/cypress/integration/renameAnApplication.spec.js
  85. 12
      packages/builder/cypress/integration/revertApp.spec.js
  86. 178
      packages/builder/cypress/support/commands.js
  87. 10
      packages/builder/package.json
  88. 8
      packages/builder/src/analytics/constants.js
  89. 4
      packages/builder/src/analytics/index.js
  90. 20
      packages/builder/src/builderStore/dataBinding.js
  91. 12
      packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js
  92. 1
      packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte
  93. 4
      packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte
  94. 219
      packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte
  95. 29
      packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte
  96. 2
      packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte
  97. 66
      packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte
  98. 7
      packages/builder/src/components/automation/SetupPanel/RowSelector.svelte
  99. 1
      packages/builder/src/components/automation/SetupPanel/RowSelectorTypes.svelte
  100. 5
      packages/builder/src/components/automation/SetupPanel/SchemaSetup.svelte

9
.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

2
.github/CONTRIBUTING.md

@ -93,6 +93,8 @@ then `cd ` into your local copy.
#### 3. Install and Build #### 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. To develop the Budibase platform you'll need [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) installed.
##### Quick method ##### Quick method

9
.github/ISSUE_TEMPLATE/bug_report.md

@ -7,6 +7,15 @@ assignees: ''
--- ---
**Hosting**
<!-- Delete as appropriate -->
- Self
- Method: <method> <!-- One of: k8s, docker single image, docker compose, digital ocean: -->
- Budibase Version: <version> <!-- e.g. 1.0.105 -->
- App Version: <version> <!-- Indicate app version if bug is related to an application -->
- Cloud
- Tenant ID: <tenantId> <!-- shown in URL as <tenantID>.budibase.app -->
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.

3
.github/stale.yml

@ -14,7 +14,6 @@ staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable
markComment: > markComment: >
This issue has been automatically marked as stale because it has not had 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 recent activity.
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable # Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false closeComment: false

9
.github/workflows/budibase_ci.yml

@ -12,6 +12,11 @@ on:
- master - master
- develop - 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: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -27,6 +32,10 @@ jobs:
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
- run: yarn - run: yarn
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint

11
.github/workflows/release-develop.yml

@ -19,6 +19,7 @@ env:
POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }} POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }}
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs: jobs:
release: release:
@ -29,6 +30,10 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 14.x
- name: Install Pro
run: yarn install:pro develop
- run: yarn - run: yarn
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint
@ -46,9 +51,9 @@ jobs:
env: env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: | run: |
# setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default # setup the username and email.
git config user.name "Budibase Staging Release Bot" git config --global user.name "Budibase Staging Release Bot"
git config user.email "<>" git config --global user.email "<>"
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release:develop yarn release:develop

59
.github/workflows/release.yml

@ -20,6 +20,7 @@ env:
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }} INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }} POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs: jobs:
release: release:
@ -30,6 +31,10 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 14.x
- name: Install Pro
run: yarn install:pro master
- run: yarn - run: yarn
- run: yarn bootstrap - run: yarn bootstrap
- run: yarn lint - run: yarn lint
@ -66,3 +71,57 @@ jobs:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }} 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 }}

15
charts/budibase/templates/app-service-deployment.yaml

@ -98,10 +98,6 @@ spec:
value: http://worker-service:{{ .Values.services.worker.port }} value: http://worker-service:{{ .Values.services.worker.port }}
- name: PLATFORM_URL - name: PLATFORM_URL
value: {{ .Values.globals.platformUrl | quote }} 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 - name: ACCOUNT_PORTAL_URL
value: {{ .Values.globals.accountPortalUrl | quote }} value: {{ .Values.globals.accountPortalUrl | quote }}
- name: ACCOUNT_PORTAL_API_KEY - name: ACCOUNT_PORTAL_API_KEY
@ -114,12 +110,23 @@ spec:
value: {{ .Values.globals.google.clientId | quote }} value: {{ .Values.globals.google.clientId | quote }}
- name: GOOGLE_CLIENT_SECRET - name: GOOGLE_CLIENT_SECRET
value: {{ .Values.globals.google.secret | quote }} value: {{ .Values.globals.google.secret | quote }}
- name: AUTOMATION_MAX_ITERATIONS
value: {{ .Values.globals.automationMaxIterations | quote }}
image: budibase/apps:{{ .Values.globals.appVersion }} image: budibase/apps:{{ .Values.globals.appVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: bbapps name: bbapps
ports: ports:
- containerPort: {{ .Values.services.apps.port }} - containerPort: {{ .Values.services.apps.port }}
resources: {} resources: {}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
status: {} status: {}

8
charts/budibase/templates/couchdb-backup.yaml

@ -39,5 +39,13 @@ spec:
imagePullPolicy: Always imagePullPolicy: Always
name: couchdb-backup name: couchdb-backup
resources: {} resources: {}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
status: {} status: {}
{{- end }} {{- end }}

7
charts/budibase/templates/minio-data-persistentvolumeclaim.yaml

@ -12,5 +12,10 @@ spec:
resources: resources:
requests: requests:
storage: {{ .Values.services.objectStore.storage }} storage: {{ .Values.services.objectStore.storage }}
{{- if (eq "-" .Values.services.objectStore.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.services.objectStore.storageClass }}"
{{- end }}
status: {} status: {}
{{- end }} {{- end }}

8
charts/budibase/templates/minio-service-deployment.yaml

@ -60,6 +60,14 @@ spec:
volumeMounts: volumeMounts:
- mountPath: /data - mountPath: /data
name: minio-data name: minio-data
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
volumes: volumes:

8
charts/budibase/templates/proxy-service-deployment.yaml

@ -32,6 +32,14 @@ spec:
- containerPort: {{ .Values.services.proxy.port }} - containerPort: {{ .Values.services.proxy.port }}
resources: {} resources: {}
volumeMounts: volumeMounts:
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
volumes: volumes:

7
charts/budibase/templates/redis-data-persistentvolumeclaim.yaml

@ -12,5 +12,10 @@ spec:
resources: resources:
requests: requests:
storage: {{ .Values.services.redis.storage }} storage: {{ .Values.services.redis.storage }}
{{- if (eq "-" .Values.services.redis.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.services.redis.storageClass }}"
{{- end }}
status: {} status: {}
{{- end }} {{- end }}

8
charts/budibase/templates/redis-service-deployment.yaml

@ -39,6 +39,14 @@ spec:
volumeMounts: volumeMounts:
- mountPath: /data - mountPath: /data
name: redis-data name: redis-data
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
volumes: volumes:

8
charts/budibase/templates/worker-service-deployment.yaml

@ -121,6 +121,14 @@ spec:
ports: ports:
- containerPort: {{ .Values.services.worker.port }} - containerPort: {{ .Values.services.worker.port }}
resources: {} resources: {}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
restartPolicy: Always restartPolicy: Always
serviceAccountName: "" serviceAccountName: ""
status: {} status: {}

17
charts/budibase/values.yaml

@ -95,16 +95,15 @@ globals:
logLevel: info logLevel: info
selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup 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 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: "" accountPortalUrl: ""
accountPortalApiKey: "" accountPortalApiKey: ""
cookieDomain: "" cookieDomain: ""
platformUrl: "" platformUrl: ""
httpMigrations: "0" httpMigrations: "0"
google: google:
clientId: "" clientId: ""
secret: "" secret: ""
automationMaxIterations: "500"
createSecrets: true # creates an internal API key, JWT secrets and redis password for you 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 url: "" # only change if pointing to existing redis cluster and enabled: false
password: "budibase" # recommended to override if using built-in redis password: "budibase" # recommended to override if using built-in redis
storage: 100Mi storage: 100Mi
## If defined, storageClassName: <storageClass>
## 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: objectStore:
minio: true minio: true
@ -163,6 +167,11 @@ services:
region: "" # AWS_REGION if using S3 or existing minio secret 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 url: "http://minio-service:9000" # only change if pointing to existing minio cluster or S3 and minio: false
storage: 100Mi storage: 100Mi
## If defined, storageClassName: <storageClass>
## 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 # Override values in couchDB subchart
couchdb: couchdb:
@ -232,6 +241,8 @@ couchdb:
## Optional tolerations ## Optional tolerations
tolerations: [] tolerations: []
affinity: {}
service: service:
# annotations: # annotations:
enabled: true enabled: true

6
examples/nextjs-api-sales/yarn.lock

@ -1894,9 +1894,9 @@ minimist-options@4.1.0:
kind-of "^6.0.3" kind-of "^6.0.3"
minimist@^1.2.0: minimist@^1.2.0:
version "1.2.5" version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
minipass-collect@^1.0.2: minipass-collect@^1.0.2:
version "1.0.2" version "1.0.2"

1
hosting/docker-compose.dev.yaml

@ -27,6 +27,7 @@ services:
image: nginx:latest image: nginx:latest
volumes: volumes:
- ./.generated-nginx.dev.conf:/etc/nginx/nginx.conf - ./.generated-nginx.dev.conf:/etc/nginx/nginx.conf
- ./proxy/error.html:/usr/share/nginx/html/error.html
ports: ports:
- "${MAIN_PORT}:10000" - "${MAIN_PORT}:10000"
depends_on: depends_on:

1
hosting/docker-compose.yaml

@ -117,7 +117,6 @@ services:
labels: labels:
- "com.centurylinklabs.watchtower.enable=false" - "com.centurylinklabs.watchtower.enable=false"
volumes: volumes:
couchdb3_data: couchdb3_data:
driver: local driver: local

6
hosting/nginx.dev.conf.hbs

@ -28,6 +28,12 @@ http {
ignore_invalid_headers off; ignore_invalid_headers off;
proxy_buffering off; proxy_buffering off;
error_page 502 503 504 /error.html;
location = /error.html {
root /usr/share/nginx/html;
internal;
}
location /db/ { location /db/ {
proxy_pass http://couchdb-service:5984; proxy_pass http://couchdb-service:5984;
rewrite ^/db/(.*)$ /$1 break; rewrite ^/db/(.*)$ /$1 break;

6
hosting/nginx.prod.conf.hbs

@ -56,6 +56,12 @@ http {
set $csp_media "media-src 'self' https://js.intercomcdn.com"; set $csp_media "media-src 'self' https://js.intercomcdn.com";
set $csp_worker "worker-src 'none'"; set $csp_worker "worker-src 'none'";
error_page 502 503 504 /error.html;
location = /error.html {
root /usr/share/nginx/html;
internal;
}
# Security Headers # Security Headers
add_header X-Frame-Options SAMEORIGIN always; add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;

94
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
}
]
}
]
}

3
hosting/proxy/Dockerfile

@ -1,2 +1,3 @@
FROM nginx:latest FROM nginx:latest
COPY .generated-nginx.prod.conf /etc/nginx/nginx.conf COPY .generated-nginx.prod.conf /etc/nginx/nginx.conf
COPY error.html /usr/share/nginx/html/error.html

175
hosting/proxy/error.html

@ -0,0 +1,175 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Budibase</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
</head>
<script>
function checkStatusButton() {
if (window.location.href.includes("budibase.app")) {
var button = document.getElementById("statusButton")
button.removeAttribute("hidden")
}
}
function goToStatus() {
window.location.href = "https://status.budibase.com";
}
function goHome() {
window.location.href = window.location.origin;
}
function getStatus() {
var http = new XMLHttpRequest()
var url = window.location.href
http.open('GET', url, true)
http.send()
http.onreadystatechange = (e) => {
var status = http.status
document.getElementById("status").innerHTML = status
var message
if (status === 502) {
message = "Bad gateway. Please try again later."
} else if (status === 503) {
message = "Service Unavailable. Please try again later."
} else if (status === 504) {
message = "Gateway timeout. Please try again later."
} else {
message = "Please try again later."
}
document.getElementById("message").innerHTML = message
}
}
window.onload = function() {
checkStatusButton()
getStatus()
};
</script>
<style>
:root {
--spectrum-global-color-gray-600: rgb(144,144,144);
--spectrum-global-color-gray-900: rgb(255,255,255);
--spectrum-global-color-gray-800: rgb(227,227,227);
--spectrum-global-color-static-blue-600: rgb(20,115,230);
--spectrum-global-color-static-blue-hover: rgb( 18, 103, 207);
}
html, body {
background-color: #1a1a1a;
padding: 0;
margin: 0;
overflow: hidden;
color: #e7e7e7;
font-family: 'Roboto', sans-serif;
}
button {
color: #e7e7e7;
font-family: 'Roboto', sans-serif;
border: none;
font-size: 15px;
border-radius: 15px;
padding: 8px 22px;
}
button:hover {
cursor: pointer;
}
.main {
height: 100vh;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.info {
display: flex;
flex-direction: column;
align-items: left;
}
@media only screen and (max-width: 600px) {
.info {
align-items: center;
}
}
.status {
color: var(--spectrum-global-color-gray-600)
}
.title {
font-weight: 400;
color: var(--spectrum-global-color-gray-900)
}
.message {
font-weight: 200;
color: var(--spectrum-global-color-gray-800)
}
.buttons {
display: flex;
flex-direction: row;
margin-top: 15px;
}
.homeButton {
background-color: var(--spectrum-global-color-static-blue-600);
}
.homeButton:hover {
background-color: var(--spectrum-global-color-static-blue-hover);
}
.statusButton {
background-color: transparent;
margin-left: 20px;
border: none;
}
.hero {
height: 160px;
width: 160px;
margin-right: 80px;
}
.content {
display: flex;
flex-direction: row;
align-items: flex-end;
justify-content: center;
}
@media only screen and (max-width: 600px) {
.content {
flex-direction: column;
}
}
</style>
<script src="">
</script>
<body>
<div class="main">
<div class="content">
<div class="hero">
<img src="https://raw.githubusercontent.com/Budibase/budibase/master/packages/builder/assets/bb-space-man.svg" alt="Budibase Logo">
</div>
<div class="info">
<div>
<h4 id="status" class="status"></h4>
<h1 class="title">
Houston we have a problem!
</h1>
<h3 id="message" class="message">
</h3>
</div>
<div class="buttons">
<button class="homeButton" onclick=goHome()>Return home</button>
<button id="statusButton" class="statusButton" hidden="true" onclick=goToStatus()>Check out status</button>
</div>
</div>
</div>
</div>
</body>
</html>

97
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"]

12
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

13
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

4
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

116
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;
}
}

16
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

32
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

2
lerna.json

@ -1,5 +1,5 @@
{ {
"version": "1.0.123", "version": "1.0.124-alpha.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

14
package.json

@ -21,18 +21,17 @@
}, },
"scripts": { "scripts": {
"setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev", "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", "build": "lerna run build",
"publishdev": "lerna run publishdev", "release": "lerna publish patch --yes --force-publish && yarn release:pro",
"publishnpm": "yarn build && lerna publish --force-publish", "release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop && yarn release:pro:develop",
"release": "lerna publish patch --yes --force-publish", "release:pro": "bash scripts/pro/release.sh",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop", "release:pro:develop": "bash scripts/pro/release.sh develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build", "restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "nuke:packages": "yarn run restore",
"nuke:docker": "lerna run --parallel dev:stack:nuke", "nuke:docker": "lerna run --parallel dev:stack:nuke",
"clean": "lerna clean", "clean": "lerna clean",
"kill-port": "kill-port 4001",
"kill-builder": "kill-port 3000", "kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002", "kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server", "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: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:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", "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", "build:docs": "lerna run build:docs",
"release:helm": "node scripts/releaseHelmChart", "release:helm": "node scripts/releaseHelmChart",
"env:multi:enable": "lerna run env:multi:enable", "env:multi:enable": "lerna run env:multi:enable",
@ -73,6 +74,7 @@
"mode:account": "yarn mode:cloud && yarn env:account:enable", "mode:account": "yarn mode:cloud && yarn env:account:enable",
"security:audit": "node scripts/audit.js", "security:audit": "node scripts/audit.js",
"postinstall": "husky install", "postinstall": "husky install",
"install:pro": "bash scripts/pro/install.sh",
"dep:clean": "yarn clean && yarn bootstrap" "dep:clean": "yarn clean && yarn bootstrap"
} }
} }

2
packages/backend-core/package.json

@ -1,6 +1,6 @@
{ {
"name": "@budibase/backend-core", "name": "@budibase/backend-core",
"version": "1.0.123", "version": "1.0.124-alpha.0",
"description": "Budibase backend core libraries used in server and worker", "description": "Budibase backend core libraries used in server and worker",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",

2
packages/backend-core/src/constants.js

@ -13,9 +13,11 @@ exports.Cookies = {
exports.Headers = { exports.Headers = {
API_KEY: "x-budibase-api-key", API_KEY: "x-budibase-api-key",
LICENSE_KEY: "x-budibase-license-key",
API_VER: "x-budibase-api-version", API_VER: "x-budibase-api-version",
APP_ID: "x-budibase-app-id", APP_ID: "x-budibase-app-id",
TYPE: "x-budibase-type", TYPE: "x-budibase-type",
PREVIEW_ROLE: "x-budibase-role",
TENANT_ID: "x-budibase-tenant-id", TENANT_ID: "x-budibase-tenant-id",
TOKEN: "x-budibase-token", TOKEN: "x-budibase-token",
CSRF_TOKEN: "x-csrf-token", CSRF_TOKEN: "x-csrf-token",

11
packages/backend-core/src/context/index.js

@ -71,7 +71,9 @@ exports.doInTenant = (tenantId, task) => {
// set the tenant id // set the tenant id
if (!opts.existing) { if (!opts.existing) {
cls.setOnContext(ContextKeys.TENANT_ID, tenantId) cls.setOnContext(ContextKeys.TENANT_ID, tenantId)
exports.setGlobalDB(tenantId) if (env.USE_COUCH) {
exports.setGlobalDB(tenantId)
}
} }
try { try {
@ -80,7 +82,9 @@ exports.doInTenant = (tenantId, task) => {
} finally { } finally {
const using = cls.getFromContext(ContextKeys.IN_USE) const using = cls.getFromContext(ContextKeys.IN_USE)
if (!using || using <= 1) { 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 // clear from context now that database is closed/task is finished
cls.setOnContext(ContextKeys.TENANT_ID, null) cls.setOnContext(ContextKeys.TENANT_ID, null)
cls.setOnContext(ContextKeys.GLOBAL_DB, null) cls.setOnContext(ContextKeys.GLOBAL_DB, null)
@ -167,6 +171,7 @@ exports.doInAppContext = (appId, task) => {
exports.updateTenantId = tenantId => { exports.updateTenantId = tenantId => {
cls.setOnContext(ContextKeys.TENANT_ID, tenantId) cls.setOnContext(ContextKeys.TENANT_ID, tenantId)
exports.setGlobalDB(tenantId)
} }
exports.updateAppId = async appId => { exports.updateAppId = async appId => {
@ -269,8 +274,10 @@ function getContextDB(key, opts) {
if (db && isEqual(opts, storedOpts)) { if (db && isEqual(opts, storedOpts)) {
return db return db
} }
const appId = exports.getAppId() const appId = exports.getAppId()
let toUseAppId let toUseAppId
switch (key) { switch (key) {
case ContextKeys.CURRENT_DB: case ContextKeys.CURRENT_DB:
toUseAppId = appId toUseAppId = appId

1
packages/backend-core/src/db/constants.js

@ -23,6 +23,7 @@ exports.StaticDatabases = {
docs: { docs: {
apiKeys: "apikeys", apiKeys: "apikeys",
usageQuota: "usage_quota", usageQuota: "usage_quota",
licenseInfo: "license_info",
}, },
}, },
// contains information about tenancy and so on // contains information about tenancy and so on

26
packages/backend-core/src/db/utils.js

@ -28,6 +28,7 @@ const UNICODE_MAX = "\ufff0"
exports.ViewNames = { exports.ViewNames = {
USER_BY_EMAIL: "by_email", USER_BY_EMAIL: "by_email",
BY_API_KEY: "by_api_key", BY_API_KEY: "by_api_key",
USER_BY_BUILDERS: "by_builders",
} }
exports.StaticDatabases = StaticDatabases exports.StaticDatabases = StaticDatabases
@ -414,35 +415,10 @@ async function getScopedConfig(db, params) {
return configDoc && configDoc.config ? configDoc.config : configDoc 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.Replication = Replication
exports.getScopedConfig = getScopedConfig exports.getScopedConfig = getScopedConfig
exports.generateConfigID = generateConfigID exports.generateConfigID = generateConfigID
exports.getConfigParams = getConfigParams exports.getConfigParams = getConfigParams
exports.getScopedFullConfig = getScopedFullConfig exports.getScopedFullConfig = getScopedFullConfig
exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc
exports.generateDevInfoID = generateDevInfoID exports.generateDevInfoID = generateDevInfoID
exports.getPlatformUrl = getPlatformUrl exports.getPlatformUrl = getPlatformUrl

24
packages/backend-core/src/db/views.js

@ -56,10 +56,34 @@ exports.createApiKeyView = async () => {
await db.put(designDoc) 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) => { exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = { const CreateFuncByName = {
[ViewNames.USER_BY_EMAIL]: exports.createUserEmailView, [ViewNames.USER_BY_EMAIL]: exports.createUserEmailView,
[ViewNames.BY_API_KEY]: exports.createApiKeyView, [ViewNames.BY_API_KEY]: exports.createApiKeyView,
[ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView,
} }
// can pass DB in if working with something specific // can pass DB in if working with something specific
if (!db) { if (!db) {

13
packages/backend-core/src/environment.js

@ -22,15 +22,26 @@ module.exports = {
MINIO_URL: process.env.MINIO_URL, MINIO_URL: process.env.MINIO_URL,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
MULTI_TENANCY: process.env.MULTI_TENANCY, 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, ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL, DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED), SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED),
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
PLATFORM_URL: process.env.PLATFORM_URL, PLATFORM_URL: process.env.PLATFORM_URL,
TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS,
USE_COUCH: process.env.USE_COUCH || true,
isTest, isTest,
_set(key, value) { _set(key, value) {
process.env[key] = value process.env[key] = value
module.exports[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
}
}

11
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,
}

41
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,
}

32
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,
}

52
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",
}

2
packages/backend-core/src/index.js

@ -15,7 +15,9 @@ module.exports = {
auth: require("../auth"), auth: require("../auth"),
constants: require("../constants"), constants: require("../constants"),
migrations: require("../migrations"), migrations: require("../migrations"),
errors: require("./errors"),
env: require("./environment"), env: require("./environment"),
accounts: require("./cloud/accounts"), accounts: require("./cloud/accounts"),
tenancy: require("./tenancy"), tenancy: require("./tenancy"),
featureFlags: require("./featureFlags"),
} }

46
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") const { authenticateThirdParty } = require("./third-party-common")
async function authenticate(accessToken, refreshToken, profile, done) { const buildVerifyFn = saveUserFn => {
const thirdPartyUser = { return (accessToken, refreshToken, profile, done) => {
provider: profile.provider, // should always be 'google' const thirdPartyUser = {
providerType: "google", provider: profile.provider, // should always be 'google'
userId: profile.id, providerType: "google",
profile: profile, userId: profile.id,
email: profile._json.email, profile: profile,
oauth2: { email: profile._json.email,
accessToken: accessToken, oauth2: {
refreshToken: refreshToken, accessToken: accessToken,
}, refreshToken: refreshToken,
} },
}
return authenticateThirdParty( return authenticateThirdParty(
thirdPartyUser, thirdPartyUser,
true, // require local accounts to exist true, // require local accounts to exist
done 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. * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport Google Strategy * @returns Dynamically configured Passport Google Strategy
*/ */
exports.strategyFactory = async function ( exports.strategyFactory = async function (config, callbackUrl, saveUserFn) {
config,
callbackUrl,
verify = authenticate
) {
try { try {
const { clientID, clientSecret } = config const { clientID, clientSecret } = config
@ -41,6 +40,7 @@ exports.strategyFactory = async function (
) )
} }
const verify = buildVerifyFn(saveUserFn)
return new GoogleStrategy( return new GoogleStrategy(
{ {
clientID: config.clientID, clientID: config.clientID,
@ -58,4 +58,4 @@ exports.strategyFactory = async function (
} }
} }
// expose for testing // expose for testing
exports.authenticate = authenticate exports.buildVerifyFn = buildVerifyFn

88
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 OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
const { authenticateThirdParty } = require("./third-party-common") const { authenticateThirdParty } = require("./third-party-common")
/** const buildVerifyFn = saveUserFn => {
* @param {*} issuer The identity provider base URL /**
* @param {*} sub The user ID * @param {*} issuer The identity provider base URL
* @param {*} profile The user profile information. Created by passport from the /userinfo response * @param {*} sub The user ID
* @param {*} jwtClaims The parsed id_token claims * @param {*} profile The user profile information. Created by passport from the /userinfo response
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT * @param {*} jwtClaims The parsed id_token claims
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT * @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param {*} idToken The id_token - always a JWT * @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @param {*} params The response body from requesting an access_token * @param {*} idToken The id_token - always a JWT
* @param {*} done The passport callback: err, user, info * @param {*} params The response body from requesting an access_token
*/ * @param {*} done The passport callback: err, user, info
async function authenticate( */
issuer, return async (
sub, issuer,
profile, sub,
jwtClaims, profile,
accessToken, jwtClaims,
refreshToken, accessToken,
idToken, refreshToken,
params, idToken,
done params,
) {
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 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. * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport OIDC Strategy * @returns Dynamically configured Passport OIDC Strategy
*/ */
exports.strategyFactory = async function (config, callbackUrl) { exports.strategyFactory = async function (config, callbackUrl, saveUserFn) {
try { try {
const { clientID, clientSecret, configUrl } = config const { clientID, clientSecret, configUrl } = config
@ -106,6 +109,7 @@ exports.strategyFactory = async function (config, callbackUrl) {
const body = await response.json() const body = await response.json()
const verify = buildVerifyFn(saveUserFn)
return new OIDCStrategy( return new OIDCStrategy(
{ {
issuer: body.issuer, issuer: body.issuer,
@ -116,7 +120,7 @@ exports.strategyFactory = async function (config, callbackUrl) {
clientSecret: clientSecret, clientSecret: clientSecret,
callbackURL: callbackUrl, callbackURL: callbackUrl,
}, },
authenticate verify
) )
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -125,4 +129,4 @@ exports.strategyFactory = async function (config, callbackUrl) {
} }
// expose for testing // expose for testing
exports.authenticate = authenticate exports.buildVerifyFn = buildVerifyFn

7
packages/backend-core/src/middleware/passport/tests/google.spec.js

@ -58,8 +58,10 @@ describe("google", () => {
it("delegates authentication to third party common", async () => { it("delegates authentication to third party common", async () => {
const google = require("../google") const google = require("../google")
const mockSaveUserFn = jest.fn()
const authenticate = await google.buildVerifyFn(mockSaveUserFn)
await google.authenticate( await authenticate(
data.accessToken, data.accessToken,
data.refreshToken, data.refreshToken,
profile, profile,
@ -69,7 +71,8 @@ describe("google", () => {
expect(authenticateThirdParty).toHaveBeenCalledWith( expect(authenticateThirdParty).toHaveBeenCalledWith(
user, user,
true, true,
mockDone) mockDone,
mockSaveUserFn)
}) })
}) })
}) })

4
packages/backend-core/src/middleware/passport/tests/oidc.spec.js

@ -83,8 +83,10 @@ describe("oidc", () => {
async function doAuthenticate() { async function doAuthenticate() {
const oidc = require("../oidc") const oidc = require("../oidc")
const mockSaveUserFn = jest.fn()
const authenticate = await oidc.buildVerifyFn(mockSaveUserFn)
await oidc.authenticate( await authenticate(
issuer, issuer,
sub, sub,
profile, profile,

6
packages/backend-core/src/middleware/passport/third-party-common.js

@ -1,7 +1,6 @@
const env = require("../../environment") const env = require("../../environment")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const { generateGlobalUserID } = require("../../db/utils") const { generateGlobalUserID } = require("../../db/utils")
const { saveUser } = require("../../utils")
const { authError } = require("./utils") const { authError } = require("./utils")
const { newid } = require("../../hashing") const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions") const { createASession } = require("../../security/sessions")
@ -16,8 +15,11 @@ exports.authenticateThirdParty = async function (
thirdPartyUser, thirdPartyUser,
requireLocalAccount = true, requireLocalAccount = true,
done, done,
saveUserFn = saveUser saveUserFn
) { ) {
if (!saveUserFn) {
throw new Error("Save user function must be provided")
}
if (!thirdPartyUser.provider) { if (!thirdPartyUser.provider) {
return authError(done, "third party user provider required") return authError(done, "third party user provider required")
} }

1
packages/backend-core/src/redis/utils.js

@ -17,6 +17,7 @@ exports.Databases = {
FLAGS: "flags", FLAGS: "flags",
APP_METADATA: "appMetadata", APP_METADATA: "appMetadata",
QUERY_VARS: "queryVars", QUERY_VARS: "queryVars",
LICENSES: "license",
} }
exports.SEPARATOR = SEPARATOR exports.SEPARATOR = SEPARATOR

40
packages/backend-core/src/security/sessions.js

@ -14,22 +14,7 @@ function makeSessionID(userId, sessionId) {
return `${userId}/${sessionId}` return `${userId}/${sessionId}`
} }
exports.createASession = async (userId, session) => { async function invalidateSessions(userId, sessionIds = null) {
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) => {
let sessions = [] let sessions = []
// If no sessionIds, get all the sessions for the user // If no sessionIds, get all the sessions for the user
@ -55,6 +40,24 @@ exports.invalidateSessions = async (userId, sessionIds = null) => {
await Promise.all(promises) 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 => { exports.updateSessionTTL = async session => {
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const key = makeSessionID(session.userId, session.sessionId) const key = makeSessionID(session.userId, session.sessionId)
@ -67,8 +70,6 @@ exports.endSession = async (userId, sessionId) => {
await client.delete(makeSessionID(userId, sessionId)) await client.delete(makeSessionID(userId, sessionId))
} }
exports.getUserSessions = getSessionsForUser
exports.getSession = async (userId, sessionId) => { exports.getSession = async (userId, sessionId) => {
try { try {
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
@ -84,3 +85,6 @@ exports.getAllSessions = async () => {
const sessions = await client.scan() const sessions = await client.scan()
return sessions.map(session => session.value) return sessions.map(session => session.value)
} }
exports.getUserSessions = getSessionsForUser
exports.invalidateSessions = invalidateSessions

22
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 ( exports.saveUser = async (
user, user,
tenantId, tenantId,
@ -290,4 +311,5 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => {
userId, userId,
sessions.map(({ sessionId }) => sessionId) sessions.map(({ sessionId }) => sessionId)
) )
await userCache.invalidateUser(userId)
} }

6
packages/backend-core/yarn.lock

@ -3411,9 +3411,9 @@ mimic-fn@^2.1.0:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"
minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5: minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
version "1.2.5" version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
mixin-deep@^1.2.0: mixin-deep@^1.2.0:
version "1.3.2" version "1.3.2"

4
packages/bbui/package.json

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "1.0.123", "version": "1.0.124-alpha.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",
@ -38,7 +38,7 @@
], ],
"dependencies": { "dependencies": {
"@adobe/spectrum-css-workflow-icons": "^1.2.1", "@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/actionbutton": "^1.0.1",
"@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1",
"@spectrum-css/avatar": "^3.0.2", "@spectrum-css/avatar": "^3.0.2",

4
packages/bbui/src/ActionButton/ActionButton.svelte

@ -80,8 +80,4 @@
.active svg { .active svg {
color: var(--spectrum-global-color-blue-600); color: var(--spectrum-global-color-blue-600);
} }
.spectrum-ActionButton-label {
padding-bottom: 2px;
}
</style> </style>

3
packages/bbui/src/ActionMenu/ActionMenu.svelte

@ -6,6 +6,7 @@
export let disabled = false export let disabled = false
export let align = "left" export let align = "left"
export let portalTarget export let portalTarget
export let dataCy
let anchor let anchor
let dropdown let dropdown
@ -36,7 +37,7 @@
<div use:getAnchor on:click={openMenu}> <div use:getAnchor on:click={openMenu}>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}> <Popover bind:this={dropdown} {anchor} {align} {portalTarget} {dataCy}>
<Menu> <Menu>
<slot /> <slot />
</Menu> </Menu>

41
packages/bbui/src/Form/Core/DatePicker.svelte

@ -19,18 +19,33 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper` const flatpickrId = `${uuid()}-wrapper`
let open = false 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 = { $: flatpickrOptions = {
element: `#${flatpickrId}`, element: `#${flatpickrId}`,
enableTime: isTimeOnly || enableTime || false, enableTime: timeOnly || enableTime || false,
noCalendar: isTimeOnly || false, noCalendar: timeOnly || false,
altInput: true, 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, wrap: true,
appendTo, appendTo,
disableMobile: "true", disableMobile: "true",
onReady: () => {
let timestamp = resolveTimeStamp(value)
if (timeOnly && timestamp) {
dispatch("change", timestamp.toISOString())
}
},
} }
const handleChange = event => { const handleChange = event => {
@ -39,10 +54,9 @@
if (newValue) { if (newValue) {
newValue = newValue.toISOString() newValue = newValue.toISOString()
} }
// if time only set date component to today // if time only set date component to 2000-01-01
if (timeOnly) { if (timeOnly) {
const todayDate = new Date().toISOString().split("T")[0] newValue = `2000-01-01T${newValue.split("T")[1]}`
newValue = `${todayDate}T${newValue.split("T")[1]}`
} }
dispatch("change", newValue) dispatch("change", newValue)
} }
@ -76,10 +90,13 @@
return null return null
} }
let date let date
let time = new Date(`0-${val}`) let time
// it is a string like 00:00:00, just time // it is a string like 00:00:00, just time
if (timeOnly || (typeof val === "string" && !isNaN(time))) { let ts = resolveTimeStamp(val)
date = time
if (timeOnly && ts) {
date = ts
} else if (val instanceof Date) { } else if (val instanceof Date) {
// Use real date obj if already parsed // Use real date obj if already parsed
date = val date = val
@ -101,7 +118,7 @@
} }
</script> </script>
{#key isTimeOnly} {#key timeOnly}
<Flatpickr <Flatpickr
bind:flatpickr bind:flatpickr
value={parseDate(value)} value={parseDate(value)}

62
packages/bbui/src/Icon/Icon.svelte

@ -3,6 +3,9 @@
</script> </script>
<script> <script>
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
export let direction = "n" export let direction = "n"
export let name = "Add" export let name = "Add"
export let hidden = false export let hidden = false
@ -10,30 +13,52 @@
export let hoverable = false export let hoverable = false
export let disabled = false export let disabled = false
export let color export let color
export let tooltip
$: rotation = getRotation(direction) $: rotation = getRotation(direction)
let showTooltip = false
const getRotation = direction => { const getRotation = direction => {
return directions.indexOf(direction) * 45 return directions.indexOf(direction) * 45
} }
</script> </script>
<svg <div
on:click class="icon"
class:hoverable on:mouseover={() => (showTooltip = true)}
class:disabled on:focus={() => (showTooltip = true)}
class="spectrum-Icon spectrum-Icon--size{size}" on:mouseleave={() => (showTooltip = false)}
focusable="false" on:click={() => (showTooltip = false)}
aria-hidden={hidden}
aria-label={name}
style={`transform: rotate(${rotation}deg); ${
color ? `color: ${color};` : ""
}`}
> >
<use xlink:href="#spectrum-icon-18-{name}" /> <svg
</svg> on:click
class:hoverable
class:disabled
class="spectrum-Icon spectrum-Icon--size{size}"
focusable="false"
aria-hidden={hidden}
aria-label={name}
style={`transform: rotate(${rotation}deg); ${
color ? `color: ${color};` : ""
}`}
>
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-{name}" />
</svg>
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction={"bottom"} text={tooltip} />
</div>
{/if}
</div>
<style> <style>
.icon {
position: relative;
display: grid;
place-items: center;
}
svg.hoverable { svg.hoverable {
pointer-events: all; pointer-events: all;
transition: color var(--spectrum-global-animation-duration-100, 130ms); transition: color var(--spectrum-global-animation-duration-100, 130ms);
@ -47,4 +72,15 @@
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important; pointer-events: none !important;
} }
.tooltip {
position: absolute;
pointer-events: none;
left: 50%;
top: calc(100% + 4px);
width: 100vw;
max-width: 150px;
transform: translateX(-50%);
text-align: center;
}
</style> </style>

8
packages/bbui/src/Layout/Layout.svelte

@ -36,6 +36,10 @@
padding-left: var(--spacing-l); padding-left: var(--spacing-l);
padding-right: var(--spacing-l); padding-right: var(--spacing-l);
} }
.paddingX-XL {
padding-left: var(--spacing-xl);
padding-right: var(--spacing-xl);
}
.paddingY-S { .paddingY-S {
padding-top: var(--spacing-s); padding-top: var(--spacing-s);
padding-bottom: var(--spacing-s); padding-bottom: var(--spacing-s);
@ -48,6 +52,10 @@
padding-top: var(--spacing-l); padding-top: var(--spacing-l);
padding-bottom: var(--spacing-l); padding-bottom: var(--spacing-l);
} }
.paddingY-XL {
padding-top: var(--spacing-xl);
padding-bottom: var(--spacing-xl);
}
.gap-XXS { .gap-XXS {
grid-gap: var(--spacing-xs); grid-gap: var(--spacing-xs);
} }

5
packages/bbui/src/Layout/Page.svelte

@ -1,8 +1,9 @@
<script> <script>
export let wide = false export let wide = false
export let maxWidth = "80ch"
</script> </script>
<div class:wide> <div style="--max-width: {maxWidth}" class:wide>
<slot /> <slot />
</div> </div>
@ -12,7 +13,7 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
max-width: 80ch; max-width: var(--max-width);
margin: 0 auto; margin: 0 auto;
padding: calc(var(--spacing-xl) * 2); padding: calc(var(--spacing-xl) * 2);
min-height: calc(100% - var(--spacing-xl) * 4); min-height: calc(100% - var(--spacing-xl) * 4);

12
packages/bbui/src/Modal/ModalContent.svelte

@ -23,6 +23,7 @@
export let secondaryButtonText = undefined export let secondaryButtonText = undefined
export let secondaryAction = undefined export let secondaryAction = undefined
export let secondaryButtonWarning = false export let secondaryButtonWarning = false
export let dataCy = null
const { hide, cancel } = getContext(Context.Modal) const { hide, cancel } = getContext(Context.Modal)
let loading = false let loading = false
@ -63,21 +64,26 @@
role="dialog" role="dialog"
tabindex="-1" tabindex="-1"
aria-modal="true" aria-modal="true"
data-cy={dataCy}
> >
<div class="spectrum-Dialog-grid"> <div class="spectrum-Dialog-grid">
{#if title} {#if title || $$slots.header}
<h1 <h1
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader" class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
class:noDivider={!showDivider} class:noDivider={!showDivider}
class:header-spacing={$$slots.header} class:header-spacing={$$slots.header}
> >
{title} {#if title}
<slot name="header" /> {title}
{:else if $$slots.header}
<slot name="header" />
{/if}
</h1> </h1>
{#if showDivider} {#if showDivider}
<Divider size="M" /> <Divider size="M" />
{/if} {/if}
{/if} {/if}
<!-- TODO: Remove content-grid class once Layout components are in bbui --> <!-- TODO: Remove content-grid class once Layout components are in bbui -->
<section class="spectrum-Dialog-content content-grid"> <section class="spectrum-Dialog-content content-grid">
<slot /> <slot />

27
packages/bbui/src/Popover/Popover.svelte

@ -10,6 +10,17 @@
export let anchor export let anchor
export let align = "right" export let align = "right"
export let portalTarget export let portalTarget
export let dataCy
export let direction = "bottom"
export let showTip = false
let tipSvg =
'<svg xmlns="http://www.w3.org/svg/2000" width="23" height="12" class="spectrum-Popover-tip" > <path class="spectrum-Popover-tip-triangle" d="M 0.7071067811865476 0 L 11.414213562373096 10.707106781186548 L 22.121320343559645 0" /> </svg>'
$: tooltipClasses = showTip
? `spectrum-Popover--withTip spectrum-Popover--${direction}`
: ""
export const show = () => { export const show = () => {
dispatch("open") dispatch("open")
@ -37,9 +48,14 @@
use:positionDropdown={{ anchor, align }} use:positionDropdown={{ anchor, align }}
use:clickOutside={hide} use:clickOutside={hide}
on:keydown={handleEscape} on:keydown={handleEscape}
class="spectrum-Popover is-open" class={"spectrum-Popover is-open " + (tooltipClasses || "")}
role="presentation" role="presentation"
data-cy={dataCy}
> >
{#if showTip}
{@html tipSvg}
{/if}
<slot /> <slot />
</div> </div>
</Portal> </Portal>
@ -49,4 +65,13 @@
.spectrum-Popover { .spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000) !important; 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);
}
</style> </style>

10
packages/bbui/src/ProgressBar/ProgressBar.svelte

@ -16,11 +16,11 @@
easing: easing, easing: easing,
}) })
$: if (value) $progress = value $: if (value || value === 0) $progress = value
</script> </script>
<div <div
class:spectrum-ProgressBar--indeterminate={!value} class:spectrum-ProgressBar--indeterminate={!value && value !== 0}
class:spectrum-ProgressBar--sideLabel={sideLabel} class:spectrum-ProgressBar--sideLabel={sideLabel}
class="spectrum-ProgressBar spectrum-ProgressBar--size{size}" class="spectrum-ProgressBar spectrum-ProgressBar--size{size}"
value={$progress} value={$progress}
@ -28,7 +28,7 @@
aria-valuenow={$progress} aria-valuenow={$progress}
aria-valuemin="0" aria-valuemin="0"
aria-valuemax="100" aria-valuemax="100"
style={width ? `width: ${width}px;` : ""} style={width ? `width: ${width};` : ""}
> >
{#if $$slots} {#if $$slots}
<div <div
@ -37,7 +37,7 @@
<slot /> <slot />
</div> </div>
{/if} {/if}
{#if value} {#if value || value === 0}
<div <div
class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}" class="spectrum-FieldLabel spectrum-ProgressBar-percentage spectrum-FieldLabel--size{size}"
> >
@ -47,7 +47,7 @@
<div class="spectrum-ProgressBar-track"> <div class="spectrum-ProgressBar-track">
<div <div
class="spectrum-ProgressBar-fill" class="spectrum-ProgressBar-fill"
style={value ? `width: ${$progress}%` : ""} style={value || value === 0 ? `width: ${$progress}%` : ""}
/> />
</div> </div>
<div class="spectrum-ProgressBar-label" hidden="" /> <div class="spectrum-ProgressBar-label" hidden="" />

45
packages/bbui/src/Table/InternalRenderer.svelte

@ -1,42 +1,21 @@
<script> <script>
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import { copyToClipboard } from "../helpers"
import { notifications } from "../Stores/notifications" import { notifications } from "../Stores/notifications"
export let value export let value
const onClick = e => { const onClick = async e => {
e.stopPropagation() e.stopPropagation()
copyToClipboard(value) try {
} await copyToClipboard(value)
notifications.success("Copied to clipboard")
const copyToClipboard = value => { } catch (error) {
return new Promise(res => { notifications.error(
if (navigator.clipboard && window.isSecureContext) { "Failed to copy to clipboard. Check the dev console for the value."
// Try using the clipboard API first )
navigator.clipboard.writeText(value).then(res) console.warn("Failed to copy the value", value)
} 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()
}
})
.then(() => {
notifications.success("Copied to clipboard")
})
.catch(() => {
notifications.error(
"Failed to copy to clipboard. Check the dev console for the value."
)
console.warn("Failed to copy the value", value)
})
} }
</script> </script>

38
packages/bbui/src/Table/Table.svelte

@ -36,6 +36,7 @@
export let disableSorting = false export let disableSorting = false
export let autoSortColumns = true export let autoSortColumns = true
export let compact = false export let compact = false
export let customPlaceholder = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -387,13 +388,24 @@
</div> </div>
{/each} {/each}
{:else} {:else}
<div class="placeholder" class:placeholder--no-fields={!fields?.length}> <div
<div class="placeholder-content"> class="placeholder"
<svg class="spectrum-Icon spectrum-Icon--sizeXXL" focusable="false"> class:placeholder--custom={customPlaceholder}
<use xlink:href="#spectrum-icon-18-Table" /> class:placeholder--no-fields={!fields?.length}
</svg> >
<div>No rows found</div> {#if customPlaceholder}
</div> <slot name="placeholder" />
{:else}
<div class="placeholder-content">
<svg
class="spectrum-Icon spectrum-Icon--sizeXXL"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Table" />
</svg>
<div>No rows found</div>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@ -458,6 +470,13 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
user-select: none; 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 { .spectrum-Table-headCell--alignCenter {
justify-content: center; justify-content: center;
@ -576,16 +595,19 @@
border-top: none; border-top: none;
grid-column: 1 / -1; grid-column: 1 / -1;
background-color: var(--table-bg); background-color: var(--table-bg);
padding: 40px;
} }
.placeholder--no-fields { .placeholder--no-fields {
border-top: var(--table-border); border-top: var(--table-border);
} }
.placeholder--custom {
justify-content: flex-start;
}
.wrapper--quiet .placeholder { .wrapper--quiet .placeholder {
border-left: none; border-left: none;
border-right: none; border-right: none;
} }
.placeholder-content { .placeholder-content {
padding: 40px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;

2
packages/bbui/src/Tabs/Tabs.svelte

@ -108,7 +108,7 @@
padding-left: var(--spacing-xl); padding-left: var(--spacing-xl);
padding-right: var(--spacing-xl); padding-right: var(--spacing-xl);
position: relative; position: relative;
border-bottom: var(--border-light); border-bottom: 1px solid var(--spectrum-global-color-gray-300);
} }
.spectrum-Tabs-content { .spectrum-Tabs-content {
margin-top: var(--spectrum-global-dimension-static-size-150); margin-top: var(--spectrum-global-dimension-static-size-150);

2
packages/bbui/src/Typography/Body.svelte

@ -5,12 +5,14 @@
export let serif = false export let serif = false
export let weight = null export let weight = null
export let textAlign = null export let textAlign = null
export let color = null
</script> </script>
<p <p
style={` style={`
${weight ? `font-weight:${weight};` : ""} ${weight ? `font-weight:${weight};` : ""}
${textAlign ? `text-align:${textAlign};` : ""} ${textAlign ? `text-align:${textAlign};` : ""}
${color ? `color:${color};` : ""}
`} `}
class="spectrum-Body spectrum-Body--size{size}" class="spectrum-Body spectrum-Body--size{size}"
class:spectrum-Body--serif={serif} class:spectrum-Body--serif={serif}

3
packages/bbui/src/Typography/Heading.svelte

@ -5,12 +5,13 @@
export let size = "M" export let size = "M"
export let textAlign export let textAlign
export let noPadding = false export let noPadding = false
export let weight = "default" // light, heavy, default
</script> </script>
<h1 <h1
style={textAlign ? `text-align:${textAlign}` : ``} style={textAlign ? `text-align:${textAlign}` : ``}
class:noPadding class:noPadding
class="spectrum-Heading spectrum-Heading--size{size}" class="spectrum-Heading spectrum-Heading--size{size} spectrum-Heading--{weight}"
> >
<slot /> <slot />
</h1> </h1>

26
packages/bbui/src/helpers.js

@ -106,3 +106,29 @@ export const deepSet = (obj, key, value) => {
export const cloneDeep = obj => { export const cloneDeep = obj => {
return JSON.parse(JSON.stringify(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()
}
})
}

6
packages/bbui/yarn.lock

@ -1574,9 +1574,9 @@ minimatch@^3.0.4:
brace-expansion "^1.1.7" brace-expansion "^1.1.7"
minimist@^1.2.0, minimist@^1.2.5: minimist@^1.2.0, minimist@^1.2.5:
version "1.2.5" version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
mkdirp@~0.5.1: mkdirp@~0.5.1:
version "0.5.5" version "0.5.5"

112
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")
})
})
})

144
packages/builder/cypress/integration/autoScreensUI.spec.js

@ -1,51 +1,103 @@
import filterTests from "../support/filterTests" import filterTests from "../support/filterTests"
filterTests(['smoke', 'all'], () => { filterTests(['smoke', 'all'], () => {
context("Auto Screens UI", () => { context("Auto Screens UI", () => {
before(() => { before(() => {
cy.login() cy.login()
cy.createTestApp() })
})
it("should disable the autogenerated screen options if no sources are available", () => {
it("should generate internal table screens", () => { cy.createApp("First Test App", false)
// Create autogenerated screens from the internal table
cy.createAutogeneratedScreens(["Cypress Tests"]) cy.closeModal();
// Confirm screens have been auto generated
cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) cy.contains("Design").click()
cy.get(".nav-items-container").should('contain', 'cypress-tests/:id') cy.get("[aria-label=AddCircle]").click()
.and('contain', 'cypress-tests/new/row') cy.get(".spectrum-Modal").within(() => {
}) cy.get(".item.disabled").contains("Autogenerated screens")
cy.get(".confirm-wrap .spectrum-Button").should('be.disabled')
it("should generate multiple internal table screens at once", () => { })
// Create a second internal table
const initialTable = "Cypress Tests" cy.deleteAllApps()
const secondTable = "Table Two" });
cy.createTable(secondTable)
// Create autogenerated screens from the internal tables it("should not display incompatible sources", () => {
cy.createAutogeneratedScreens([initialTable, secondTable]) cy.createApp("Test App")
// Confirm screens have been auto generated
cy.get(".nav-items-container").contains("cypress-tests").click({ force: true }) cy.selectExternalDatasource("REST")
// Previously generated tables are suffixed with numbers - as expected cy.selectExternalDatasource("S3")
cy.get(".nav-items-container").should('contain', 'cypress-tests-2/:id') cy.get(".spectrum-Modal").within(() => {
.and('contain', 'cypress-tests-2/new/row') cy.get(".spectrum-Button").contains("Save and continue to query").click({ force : true })
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') cy.navigateToAutogeneratedModal()
})
cy.get('.data-source-entry').should('have.length', 1)
if (Cypress.env("TEST_ENV")) { cy.get('.data-source-entry')
it("should generate data source screens", () => {
// Using MySQL data source for testing this cy.deleteAllApps()
const datasource = "MySQL" });
// Select & configure MySQL data source
cy.selectExternalDatasource(datasource) it("should generate internal table screens", () => {
cy.addDatasourceConfig(datasource) cy.createTestApp()
// Create autogenerated screens from a MySQL table - MySQL contains books table // Create Autogenerated screens from the internal table
cy.createAutogeneratedScreens(["books"]) cy.createDatasourceScreen(["Cypress Tests"])
cy.get(".nav-items-container").contains("books").click() // Confirm screens have been auto generated
cy.get(".nav-items-container").should('contain', 'books/:id') cy.get(".nav-items-container").contains("cypress-tests").click({ force: true })
.and('contain', 'books/new/row') 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')
})
}
})
}) })

3
packages/builder/cypress/integration/changeAppIconAndColour.spec.js

@ -11,7 +11,7 @@ filterTests(['all'], () => {
cy.applicationInAppTable("Cypress Tests") cy.applicationInAppTable("Cypress Tests")
cy.get(".appTable") cy.get(".appTable")
.within(() => { .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() cy.get(".spectrum-Menu").contains("Edit icon").click()
// Select random icon // Select random icon
@ -38,6 +38,7 @@ filterTests(['all'], () => {
cy.get(".title").children().children() cy.get(".title").children().children()
.should('have.attr', 'style').and('contains', 'color') .should('have.attr', 'style').and('contains', 'color')
}) })
cy.deleteAllApps()
}) })
}) })
}) })

10
packages/builder/cypress/integration/createApp.spec.js

@ -25,9 +25,13 @@ filterTests(['smoke', 'all'], () => {
cy.visit(`${Cypress.config().baseUrl}/builder`) cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500) cy.wait(500)
if (Cypress.env("TEST_ENV")) { cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
cy.get(".spectrum-Button").contains("Templates").click({force: true}) .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-category-filters").should("exist")
cy.get(".template-categories").should("exist") cy.get(".template-categories").should("exist")

8
packages/builder/cypress/integration/createAutomation.spec.js

@ -11,7 +11,7 @@ filterTests(['smoke', 'all'], () => {
cy.createTestTableWithData() cy.createTestTableWithData()
cy.wait(2000) cy.wait(2000)
cy.contains("Automate").click() 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(".modal-inner-wrapper").within(() => {
cy.get("input").type("Add Row") cy.get("input").type("Add Row")
cy.contains("Row Created").click({ force: true }) cy.contains("Row Created").click({ force: true })
@ -20,7 +20,6 @@ filterTests(['smoke', 'all'], () => {
}) })
// Setup trigger // Setup trigger
cy.contains("Setup").click()
cy.get(".spectrum-Picker-label").click() cy.get(".spectrum-Picker-label").click()
cy.wait(500) cy.wait(500)
cy.contains("dog").click() cy.contains("dog").click()
@ -32,12 +31,11 @@ filterTests(['smoke', 'all'], () => {
cy.contains("Create Row").trigger('mouseover').click().click() cy.contains("Create Row").trigger('mouseover').click().click()
cy.get(".spectrum-Button--cta").click() cy.get(".spectrum-Button--cta").click()
}) })
cy.contains("Setup").click()
cy.get(".spectrum-Picker-label").eq(1).click() cy.get(".spectrum-Picker-label").eq(1).click()
cy.contains("dog").click() cy.contains("dog").click()
cy.get(".spectrum-Textfield-input") cy.get(".spectrum-Textfield-input")
.first() .first()
.type("{{ trigger.row.name }}", { parseSpecialCharSequences: false }) .type("{{ trigger.row.name }}", { parseSpecialCharSequences: false })
cy.get(".spectrum-Textfield-input") cy.get(".spectrum-Textfield-input")
.eq(1) .eq(1)
.type("11") .type("11")

2
packages/builder/cypress/integration/createBinding.spec.js

@ -26,7 +26,7 @@ filterTests(['smoke', 'all'], () => {
it("should add a URL param binding", () => { it("should add a URL param binding", () => {
const paramName = "foo" const paramName = "foo"
cy.createScreen("Test Param", `/test/:${paramName}`) cy.createScreen(`/test/:${paramName}`)
cy.addComponent("Elements", "Paragraph").then(componentId => { cy.addComponent("Elements", "Paragraph").then(componentId => {
addSettingBinding("text", `URL.${paramName}`) addSettingBinding("text", `URL.${paramName}`)
// The builder preview pages don't have a real URL, so all we can do // The builder preview pages don't have a real URL, so all we can do

20
packages/builder/cypress/integration/createScreen.js

@ -9,17 +9,33 @@ filterTests(["smoke", "all"], () => {
}) })
it("Should successfully create a screen", () => { it("Should successfully create a screen", () => {
cy.createScreen("Test Screen", "/test") cy.createScreen("/test")
cy.get(".nav-items-container").within(() => { cy.get(".nav-items-container").within(() => {
cy.contains("/test").should("exist") cy.contains("/test").should("exist")
}) })
}) })
it("Should update the url", () => { it("Should update the url", () => {
cy.createScreen("Test Screen", "test with spaces") cy.createScreen("test with spaces")
cy.get(".nav-items-container").within(() => { cy.get(".nav-items-container").within(() => {
cy.contains("/test-with-spaces").should("exist") 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")
})
})
}) })
}) })

17
packages/builder/cypress/integration/createTable.spec.js

@ -55,13 +55,14 @@ filterTests(["smoke", "all"], () => {
if (Cypress.env("TEST_ENV")) { if (Cypress.env("TEST_ENV")) {
// No Pagination in CI - Test env only for the next two tests // 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 // 10 rows per page, 15 rows should create 2 pages within table
const totalRows = 16 const totalRows = 16
for (let i = 1; i < totalRows; i++) { for (let i = 1; i < totalRows; i++) {
cy.addRow([i]) cy.addRow([i])
} }
cy.wait(1000) cy.reload()
cy.wait(2000)
cy.get(".spectrum-Pagination").within(() => { cy.get(".spectrum-Pagination").within(() => {
cy.get(".spectrum-ActionButton").eq(1).click() cy.get(".spectrum-ActionButton").eq(1).click()
}) })
@ -70,13 +71,13 @@ filterTests(["smoke", "all"], () => {
}) })
}) })
it("Deletes rows and checks pagination", () => { xit("Deletes rows and checks pagination", () => {
// Delete rows, removing second page of rows from table // Delete rows, removing second page from table
const deleteRows = 5
cy.get(".spectrum-Checkbox-input").check({ force: true }) cy.get(".spectrum-Checkbox-input").check({ force: true })
cy.get(".spectrum-Table") cy.get(".popovers").within(() => {
cy.contains("Delete 5 row(s)").click() cy.get(".spectrum-Button").click({ force: true })
cy.get(".spectrum-Modal").contains("Delete").click() })
cy.get(".spectrum-Dialog-grid").contains("Delete").click({ force: true })
cy.wait(1000) cy.wait(1000)
// Confirm table only has one page // Confirm table only has one page

4
packages/builder/cypress/integration/createView.spec.js

@ -125,7 +125,7 @@ filterTests(['smoke', 'all'], () => {
it("renames a view", () => { it("renames a view", () => {
cy.contains(".nav-item", "Test View") cy.contains(".nav-item", "Test View")
.find(".actions .icon") .find(".actions .icon.open-popover")
.click({ force: true }) .click({ force: true })
cy.get(".spectrum-Menu-itemLabel").contains("Edit").click() cy.get(".spectrum-Menu-itemLabel").contains("Edit").click()
cy.get(".modal-inner-wrapper").within(() => { cy.get(".modal-inner-wrapper").within(() => {
@ -138,7 +138,7 @@ filterTests(['smoke', 'all'], () => {
it("deletes a view", () => { it("deletes a view", () => {
cy.contains(".nav-item", "Test View Updated") cy.contains(".nav-item", "Test View Updated")
.find(".actions .icon") .find(".actions .icon.open-popover")
.click({ force: true }) .click({ force: true })
cy.contains("Delete").click() cy.contains("Delete").click()
cy.contains("Delete View").click() cy.contains("Delete View").click()

46
packages/builder/cypress/integration/datasources/mySql.spec.js

@ -19,6 +19,7 @@ filterTests(["all"], () => {
cy.get(".spectrum-Button") cy.get(".spectrum-Button")
.contains("Save and fetch tables") .contains("Save and fetch tables")
.click({ force: true }) .click({ force: true })
cy.wait(500)
// Intercept Request after button click & apply assertions // Intercept Request after button click & apply assertions
cy.wait("@datasource") cy.wait("@datasource")
cy.get("@datasource") cy.get("@datasource")
@ -31,6 +32,7 @@ filterTests(["all"], () => {
cy.get("@datasource") cy.get("@datasource")
.its("response.body") .its("response.body")
.should("have.property", "status", 500) .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", () => { 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-Popover").contains("COUNTRIES").click()
cy.get(".spectrum-Picker").eq(4).click() cy.get(".spectrum-Picker").eq(4).click()
cy.get(".spectrum-Popover").contains("REGION_ID").click() cy.get(".spectrum-Popover").contains("REGION_ID").click()
// Save relationship & reload page })
// Save relationship & reload page
cy.get(".spectrum-ButtonGroup").within(() => {
cy.get(".spectrum-Button").contains("Save").click({ force: true }) cy.get(".spectrum-Button").contains("Save").click({ force: true })
cy.reload()
}) })
cy.reload()
// Confirm table length & column name // Confirm table length & column name
cy.get(".spectrum-Table") cy.get(".spectrum-Table")
.eq(1) .eq(1)
@ -131,7 +136,7 @@ filterTests(["all"], () => {
cy.get(".spectrum-Table") cy.get(".spectrum-Table")
.eq(1) .eq(1)
.within(() => { .within(() => {
cy.get(".spectrum-Table-row").eq(0).click() cy.get(".spectrum-Table-row").eq(0).click({ force: true })
cy.wait(500) cy.wait(500)
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
@ -175,11 +180,12 @@ filterTests(["all"], () => {
}) })
it("should duplicate a query", () => { it("should duplicate a query", () => {
// Get last nav item - The query /// Get query nav item - QueryName
cy.get(".nav-item") cy.get(".nav-item")
.last() .contains(queryName)
.parent()
.within(() => { .within(() => {
cy.get(".icon").eq(1).click({ force: true }) cy.get(".spectrum-Icon").eq(1).click({ force: true })
}) })
// Select and confirm duplication // Select and confirm duplication
cy.get(".spectrum-Menu").contains("Duplicate").click() cy.get(".spectrum-Menu").contains("Duplicate").click()
@ -199,23 +205,21 @@ filterTests(["all"], () => {
}) })
it("should delete a query", () => { it("should delete a query", () => {
// Get last nav item - The query // Get query nav item - QueryName
for (let i = 0; i < 2; i++) { cy.get(".nav-item")
cy.get(".nav-item") .contains(queryName)
.last() .parent()
.within(() => { .within(() => {
cy.get(".icon").eq(1).click({ force: true }) cy.get(".spectrum-Icon").eq(1).click({ force: true })
}) })
// Select Delete // Select Delete
cy.get(".spectrum-Menu").contains("Delete").click() cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Button") cy.get(".spectrum-Button")
.contains("Delete Query") .contains("Delete Query")
.click({ force: true }) .click({ force: true })
cy.wait(1000) cy.wait(1000)
}
// Confirm deletion // Confirm deletion
cy.get(".nav-item").should("not.contain", queryName) cy.get(".nav-item").should("not.contain", queryName)
cy.get(".nav-item").should("not.contain", queryRename)
}) })
} }
}) })

17
packages/builder/cypress/integration/datasources/oracle.spec.js

@ -46,9 +46,10 @@ filterTests(["all"], () => {
cy.get("@datasource") cy.get("@datasource")
.its("response.body") .its("response.body")
.should("have.property", "status", 500) .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 // Add & configure Oracle data source
cy.selectExternalDatasource(datasource) cy.selectExternalDatasource(datasource)
cy.intercept("**/datasources").as("datasource") cy.intercept("**/datasources").as("datasource")
@ -64,7 +65,7 @@ filterTests(["all"], () => {
.should("be.gt", 0) .should("be.gt", 0)
}) })
it("should define a One relationship type", () => { xit("should define a One relationship type", () => {
// Select relationship type & configure // Select relationship type & configure
cy.get(".spectrum-Button") cy.get(".spectrum-Button")
.contains("Define relationship") .contains("Define relationship")
@ -93,7 +94,7 @@ filterTests(["all"], () => {
cy.get(".spectrum-Table-cell").should("contain", "COUNTRIES to REGIONS") 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 // Select relationship type & configure
cy.get(".spectrum-Button") cy.get(".spectrum-Button")
.contains("Define relationship") .contains("Define relationship")
@ -127,7 +128,7 @@ filterTests(["all"], () => {
) )
}) })
it("should delete relationships", () => { xit("should delete relationships", () => {
// Delete both relationships // Delete both relationships
cy.get(".spectrum-Table") cy.get(".spectrum-Table")
.eq(1) .eq(1)
@ -156,7 +157,7 @@ filterTests(["all"], () => {
}) })
}) })
it("should add a query", () => { xit("should add a query", () => {
// Add query // Add query
cy.get(".spectrum-Button").contains("Add query").click({ force: true }) cy.get(".spectrum-Button").contains("Add query").click({ force: true })
cy.get(".spectrum-Form-item") cy.get(".spectrum-Form-item")
@ -181,7 +182,7 @@ filterTests(["all"], () => {
cy.get(".nav-item").should("contain", queryName) cy.get(".nav-item").should("contain", queryName)
}) })
it("should duplicate a query", () => { xit("should duplicate a query", () => {
// Get query nav item // Get query nav item
cy.get(".nav-item") cy.get(".nav-item")
.contains(queryName) .contains(queryName)
@ -194,7 +195,7 @@ filterTests(["all"], () => {
cy.get(".nav-item").should("contain", queryName + " (1)") cy.get(".nav-item").should("contain", queryName + " (1)")
}) })
it("should edit a query name", () => { xit("should edit a query name", () => {
// Rename query // Rename query
cy.get(".spectrum-Form-item") cy.get(".spectrum-Form-item")
.eq(0) .eq(0)
@ -206,7 +207,7 @@ filterTests(["all"], () => {
cy.get(".nav-item").should("contain", queryRename) cy.get(".nav-item").should("contain", queryRename)
}) })
it("should delete a query", () => { xit("should delete a query", () => {
// Get query nav item - QueryName // Get query nav item - QueryName
cy.get(".nav-item") cy.get(".nav-item")
.contains(queryName) .contains(queryName)

49
packages/builder/cypress/integration/datasources/postgreSql.spec.js

@ -21,16 +21,10 @@ filterTests(["all"], () => {
.click({ force: true }) .click({ force: true })
// Intercept Request after button click & apply assertions // Intercept Request after button click & apply assertions
cy.wait("@datasource") cy.wait("@datasource")
cy.get("@datasource")
.its("response.body")
.should(
"have.property",
"message",
"connect ECONNREFUSED 127.0.0.1:5432"
)
cy.get("@datasource") cy.get("@datasource")
.its("response.body") .its("response.body")
.should("have.property", "status", 500) .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", () => { it("should add PostgreSQL data source and fetch tables", () => {
@ -113,13 +107,13 @@ filterTests(["all"], () => {
}) })
it("should delete a relationship", () => { it("should delete a relationship", () => {
cy.get(".hierarchy-items-container").contains(datasource).click() cy.get(".hierarchy-items-container").contains("PostgreSQL-2").click()
cy.reload() cy.reload()
// Delete one relationship // Delete one relationship
cy.get(".spectrum-Table") cy.get(".spectrum-Table")
.eq(1) .eq(1)
.within(() => { .within(() => {
cy.get(".spectrum-Table-row").eq(0).click() cy.get(".spectrum-Table-row").eq(0).click({ force: true })
cy.wait(500) cy.wait(500)
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
@ -161,7 +155,7 @@ filterTests(["all"], () => {
it("should switch to schema with no tables", () => { it("should switch to schema with no tables", () => {
// Switch Schema - To one without any 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") switchSchema("randomText")
// No tables displayed // No tables displayed
@ -208,11 +202,12 @@ filterTests(["all"], () => {
}) })
it("should duplicate a query", () => { it("should duplicate a query", () => {
// Get last nav item - The query // Locate previously created query
cy.get(".nav-item") cy.get(".nav-item")
.last() .contains(queryName)
.siblings(".actions")
.within(() => { .within(() => {
cy.get(".icon").eq(1).click({ force: true }) cy.get(".icon").click({ force: true })
}) })
// Select and confirm duplication // Select and confirm duplication
cy.get(".spectrum-Menu").contains("Duplicate").click() cy.get(".spectrum-Menu").contains("Duplicate").click()
@ -240,23 +235,21 @@ filterTests(["all"], () => {
}) })
it("should delete a query", () => { it("should delete a query", () => {
// Get last nav item - The query // Get query nav item - QueryName
for (let i = 0; i < 2; i++) { cy.get(".nav-item")
cy.get(".nav-item") .contains(queryName)
.last() .parent()
.within(() => { .within(() => {
cy.get(".icon").eq(1).click({ force: true }) cy.get(".spectrum-Icon").eq(1).click({ force: true })
}) })
// Select Delete // Select Delete
cy.get(".spectrum-Menu").contains("Delete").click() cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Button") cy.get(".spectrum-Button")
.contains("Delete Query") .contains("Delete Query")
.click({ force: true }) .click({ force: true })
cy.wait(1000) cy.wait(1000)
}
// Confirm deletion // Confirm deletion
cy.get(".nav-item").should("not.contain", queryName) cy.get(".nav-item").should("not.contain", queryName)
cy.get(".nav-item").should("not.contain", queryRename)
}) })
const switchSchema = schema => { const switchSchema = schema => {

50
packages/builder/cypress/integration/renameAnApplication.spec.js

@ -99,30 +99,32 @@ filterTests(['all'], () => {
cy.searchForApplication(originalName) cy.searchForApplication(originalName)
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[aria-label='More']").eq(0).click()
}) })
// Check for when an app is published // Check for when an app is published
if (published == true) { if (published == true) {
// Should not have Edit as option, will unpublish app // Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit") cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click() cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
} }
cy.contains("Edit").click() cy.get("[data-cy='app-row-actions-menu-popover']").eq(0).within(() => {
cy.get(".spectrum-Modal") cy.get(".spectrum-Menu-item").contains("Edit").click({ force: true })
.within(() => { })
if (noName == true) { cy.get(".spectrum-Modal")
cy.get("input").clear() .within(() => {
cy.get(".spectrum-Dialog-grid").click() if (noName == true) {
.contains("App name must be letters, numbers and spaces only")
return cy
}
cy.get("input").clear() cy.get("input").clear()
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() cy.get(".spectrum-Dialog-grid").click()
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true }) .contains("App name must be letters, numbers and spaces only")
cy.wait(500) 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)
})
}
}) })
}) })

12
packages/builder/cypress/integration/revertApp.spec.js

@ -10,9 +10,9 @@ filterTests(['smoke', 'all'], () => {
it("should try to revert an unpublished app", () => { it("should try to revert an unpublished app", () => {
// Click revert icon // Click revert icon
cy.get(".toprightnav").within(() => { 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 // Enter app name before revert
cy.get("input").type("Cypress Tests") cy.get("input").type("Cypress Tests")
cy.intercept('**/revert').as('revertApp') cy.intercept('**/revert').as('revertApp')
@ -33,11 +33,15 @@ filterTests(['smoke', 'all'], () => {
cy.get(".spectrum-ButtonGroup").within(() => { cy.get(".spectrum-ButtonGroup").within(() => {
cy.get(".spectrum-Button").contains("Publish").click({ force: true }) 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 // Add second component - Button
cy.addComponent("Elements", "Button") cy.addComponent("Elements", "Button")
// Click Revert // Click Revert
cy.get(".toprightnav").within(() => { 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-Dialog-grid").within(() => {
// Click Revert // Click Revert
@ -54,7 +58,7 @@ filterTests(['smoke', 'all'], () => {
it("should enter incorrect app name when reverting", () => { it("should enter incorrect app name when reverting", () => {
// Click Revert // Click Revert
cy.get(".toprightnav").within(() => { 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 // Enter incorrect app name
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {

178
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.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500) cy.wait(500)
cy.get(`[data-cy="create-app-btn"]`).click({ force: true }) 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.get(".spectrum-ButtonGroup").contains("Create app").click()
cy.wait(10000) cy.wait(10000)
}) })
cy.createTable("Cypress Tests", true) if (shouldCreateDefaultTable) {
cy.createTable("Cypress Tests", true)
}
}) })
Cypress.Commands.add("deleteApp", name => { Cypress.Commands.add("deleteApp", name => {
@ -60,43 +72,48 @@ Cypress.Commands.add("deleteApp", name => {
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body") .its("body")
.then(val => { .then(val => {
if (val.length > 0) { const findAppName = val.some(val => val.name == name)
if (Cypress.env("TEST_ENV")) { if (findAppName) {
cy.searchForApplication(name) if (val.length > 0) {
cy.get(".appTable").within(() => { if (Cypress.env("TEST_ENV")) {
cy.get(".spectrum-Icon").eq(1).click() cy.searchForApplication(name)
}) cy.get(".appTable").within(() => {
} else { cy.get(".spectrum-Icon").eq(1).click()
const appId = val.reduce((acc, app) => { })
if (name === app.name) { } else {
acc = app.appId const appId = val.reduce((acc, app) => {
if (name === app.name) {
acc = app.appId
}
return acc
}, "")
if (appId == "") {
return
} }
return acc
}, "")
if (appId == "") { const appIdParsed = appId.split("_").pop()
return const actionEleId = `[data-cy=row_actions_${appIdParsed}]`
cy.get(actionEleId).within(() => {
cy.get(".spectrum-Icon").eq(0).click()
})
} }
const appIdParsed = appId.split("_").pop() cy.get(".spectrum-Menu").then($menu => {
const actionEleId = `[data-cy=row_actions_${appIdParsed}]` if ($menu.text().includes("Unpublish")) {
cy.get(actionEleId).within(() => { cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Icon").eq(0).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
} }
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 { } else {
return return
} }
@ -130,7 +147,7 @@ Cypress.Commands.add("createTestApp", () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.deleteApp(appName) cy.deleteApp(appName)
cy.createApp(appName, "This app is used for Cypress testing.") cy.createApp(appName, "This app is used for Cypress testing.")
cy.createScreen("home", "home") cy.createScreen("home")
}) })
Cypress.Commands.add("createTestTableWithData", () => { Cypress.Commands.add("createTestTableWithData", () => {
@ -270,33 +287,99 @@ Cypress.Commands.add("navigateToDataSection", () => {
cy.contains("Data").click() cy.contains("Data").click()
}) })
Cypress.Commands.add("createScreen", (screenName, route) => { //Blank
Cypress.Commands.add("createScreen", (route, accessLevelLabel) => {
cy.contains("Design").click() cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click() cy.get("[aria-label=AddCircle]").click()
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Blank").click() cy.get("[data-cy='blank-screen']").click()
cy.get(".spectrum-Button").contains("Add screens").click({ force: true }) cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(500) cy.wait(500)
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".spectrum-Form-itemField").eq(0).type(screenName) cy.get(".spectrum-Form-itemField").eq(0).type(route)
cy.get(".spectrum-Form-itemField").eq(1).type(route)
cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(1000) 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 // Screen name must already exist within data source
cy.contains("Design").click() cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click() cy.get("[aria-label=AddCircle]").click()
for (let i = 0; i < screenNames.length; i++) { cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains(screenNames[i]).click() cy.get(".item").contains("Autogenerated screens").click()
} cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.get(".spectrum-Button").contains("Add screens").click({ force: true }) cy.wait(500)
cy.wait(4000) })
}) })
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 => { Cypress.Commands.add("addRow", values => {
cy.contains("Create row").click() cy.contains("Create row").click()
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
@ -390,6 +473,7 @@ Cypress.Commands.add("selectExternalDatasource", datasourceName => {
cy.get(".add-button").click() cy.get(".add-button").click()
}) })
// Clicks specified datasource & continue // Clicks specified datasource & continue
cy.wait(1000)
cy.get(".item-list").contains(datasourceName).click() cy.get(".item-list").contains(datasourceName).click()
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".spectrum-Button").contains("Continue").click({ force: true }) cy.get(".spectrum-Button").contains("Continue").click({ force: true })
@ -410,7 +494,9 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => {
if (datasource == "Oracle") { if (datasource == "Oracle") {
cy.get("input").clear().type(Cypress.env("oracle").HOST) cy.get("input").clear().type(Cypress.env("oracle").HOST)
} else { } else {
cy.get("input").clear().type(Cypress.env("HOST_IP")) cy.get("input")
.clear({ force: true })
.type(Cypress.env("HOST_IP"), { force: true })
} }
}) })
}) })

10
packages/builder/package.json

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "1.0.123", "version": "1.0.124-alpha.0",
"license": "GPL-3.0", "license": "GPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.123", "@budibase/bbui": "^1.0.124-alpha.0",
"@budibase/client": "^1.0.123", "@budibase/client": "^1.0.124-alpha.0",
"@budibase/frontend-core": "^1.0.123", "@budibase/frontend-core": "^1.0.124-alpha.0",
"@budibase/string-templates": "^1.0.123", "@budibase/string-templates": "^1.0.124-alpha.0",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

8
packages/builder/src/analytics/constants.js

@ -22,6 +22,7 @@ export const Events = {
}, },
SCREEN: { SCREEN: {
CREATED: "Screen Created", CREATED: "Screen Created",
CREATE_ROLE_UPDATED: "Changed Role On Screen Creation",
}, },
AUTOMATION: { AUTOMATION: {
CREATED: "Automation Created", CREATED: "Automation Created",
@ -35,6 +36,7 @@ export const Events = {
CREATED: "budibase:app_created", CREATED: "budibase:app_created",
PUBLISHED: "budibase:app_published", PUBLISHED: "budibase:app_published",
UNPUBLISHED: "budibase:app_unpublished", UNPUBLISHED: "budibase:app_unpublished",
VIEW_PUBLISHED: "budibase:view_published_app",
}, },
ANALYTICS: { ANALYTICS: {
OPT_IN: "budibase:analytics_opt_in", OPT_IN: "budibase:analytics_opt_in",
@ -50,3 +52,9 @@ export const Events = {
SAVED: "budibase:sso_saved", SAVED: "budibase:sso_saved",
}, },
} }
export const EventSource = {
PORTAL: "portal",
URL: "url",
NOTIFICATION: "notification",
}

4
packages/builder/src/analytics/index.js

@ -2,7 +2,7 @@ import { API } from "api"
import PosthogClient from "./PosthogClient" import PosthogClient from "./PosthogClient"
import IntercomClient from "./IntercomClient" import IntercomClient from "./IntercomClient"
import SentryClient from "./SentryClient" import SentryClient from "./SentryClient"
import { Events } from "./constants" import { Events, EventSource } from "./constants"
const posthog = new PosthogClient( const posthog = new PosthogClient(
process.env.POSTHOG_TOKEN, process.env.POSTHOG_TOKEN,
@ -57,5 +57,5 @@ class AnalyticsHub {
const analytics = new AnalyticsHub() const analytics = new AnalyticsHub()
export { Events } export { Events, EventSource }
export default analytics export default analytics

20
packages/builder/src/builderStore/dataBinding.js

@ -7,7 +7,11 @@ import {
getComponentSettings, getComponentSettings,
} from "./componentUtils" } from "./componentUtils"
import { store } from "builderStore" 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 { import {
makePropSafe, makePropSafe,
isJSBinding, isJSBinding,
@ -33,6 +37,7 @@ export const getBindableProperties = (asset, componentId) => {
const deviceBindings = getDeviceBindings() const deviceBindings = getDeviceBindings()
const stateBindings = getStateBindings() const stateBindings = getStateBindings()
const selectedRowsBindings = getSelectedRowsBindings(asset) const selectedRowsBindings = getSelectedRowsBindings(asset)
const roleBindings = getRoleBindings()
return [ return [
...contextBindings, ...contextBindings,
...urlBindings, ...urlBindings,
@ -40,6 +45,7 @@ export const getBindableProperties = (asset, componentId) => {
...userBindings, ...userBindings,
...deviceBindings, ...deviceBindings,
...selectedRowsBindings, ...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 * Gets all bindable properties exposed in a button actions flow up until
* the specified action ID, as well as context provided for the action * 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. * Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form. * A form schema is a schema of all the fields nested anywhere within a form.
*/ */
const buildFormSchema = component => { export const buildFormSchema = component => {
let schema = {} let schema = {}
if (!component) { if (!component) {
return schema return schema

12
packages/builder/src/builderStore/store/screenTemplates/utils/sanitizeUrl.js

@ -2,9 +2,15 @@ export default function (url) {
return url return url
.split("/") .split("/")
.map(part => { .map(part => {
// if parameter, then use as is part = decodeURIComponent(part)
if (part.startsWith(":")) return part part = part.replace(/ /g, "-")
return encodeURIComponent(part.replace(/ /g, "-"))
// If parameter, then use as is
if (!part.startsWith(":")) {
part = encodeURIComponent(part)
}
return part
}) })
.join("/") .join("/")
.toLowerCase() .toLowerCase()

1
packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte

@ -39,6 +39,7 @@
if (v.internal) { if (v.internal) {
acc[k] = v acc[k] = v
} }
delete acc.LOOP
return acc return acc
}, {}) }, {})

4
packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowChart.svelte

@ -72,7 +72,9 @@
animate:flip={{ duration: 500 }} animate:flip={{ duration: 500 }}
in:fly|local={{ x: 500, duration: 1500 }} in:fly|local={{ x: 500, duration: 1500 }}
> >
<FlowItem {testDataModal} {block} /> {#if block.stepId !== "LOOP"}
<FlowItem {testDataModal} {block} />
{/if}
</div> </div>
{/each} {/each}
</div> </div>

219
packages/builder/src/components/automation/AutomationBuilder/FlowChart/FlowItem.svelte

@ -9,8 +9,8 @@
Modal, Modal,
Button, Button,
StatusLight, StatusLight,
ActionButton,
Select, Select,
ActionButton,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte" import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
@ -25,8 +25,8 @@
let webhookModal let webhookModal
let actionModal let actionModal
let resultsModal let resultsModal
let setupToggled
let blockComplete let blockComplete
let showLooping = false
$: rowControl = $automationStore.selectedAutomation.automation.rowControl $: rowControl = $automationStore.selectedAutomation.automation.rowControl
$: showBindingPicker = $: showBindingPicker =
@ -48,12 +48,21 @@
$automationStore.selectedAutomation?.automation?.definition?.steps.length + $automationStore.selectedAutomation?.automation?.definition?.steps.length +
1 1
$: hasCompletedInputs = Object.keys( $: loopingSelected =
block.schema?.inputs?.properties || {} $automationStore.selectedAutomation?.automation.definition.steps.find(
).every(x => block?.inputs[x]) x => x.blockToLoop === block.id
)
async function deleteStep() { async function deleteStep() {
let loopBlock =
$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)
try { try {
if (loopBlock) {
automationStore.actions.deleteAutomationBlock(loopBlock)
}
automationStore.actions.deleteAutomationBlock(block) automationStore.actions.deleteAutomationBlock(block)
await automationStore.actions.save( await automationStore.actions.save(
$automationStore.selectedAutomation?.automation $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) { async function onSelect(block) {
await automationStore.update(state => { await automationStore.update(state => {
state.selectedBlock = block state.selectedBlock = block
@ -84,13 +110,68 @@
} }
</script> </script>
<div <div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}>
class={`block ${block.type} hoverable`} {#if loopingSelected}
class:selected <div class="blockSection">
on:click={() => { <div
onSelect(block) on:click={() => {
}} showLooping = !showLooping
> }}
class="splitHeader"
>
<div class="center-items">
<svg
width="28px"
height="28px"
class="spectrum-Icon"
style="color:grey;"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Reuse" />
</svg>
<div class="iconAlign">
<Detail size="S">Looping</Detail>
</div>
</div>
<div class="blockTitle">
<div
style="margin-left: 10px;"
on:click={() => {
onSelect(block)
}}
>
<Icon name={showLooping ? "ChevronDown" : "ChevronUp"} />
</div>
</div>
</div>
</div>
<Divider noMargin />
{#if !showLooping}
<div class="blockSection">
<div class="block-options">
<div class="delete-padding" on:click={() => deleteStep()}>
<Icon name="DeleteOutline" />
</div>
</div>
<Layout noPadding gap="S">
<AutomationBlockSetup
schemaProperties={Object.entries(
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs
.properties
)}
block={$automationStore.selectedAutomation?.automation.definition.steps.find(
x => x.blockToLoop === block.id
)}
{webhookModal}
/>
</Layout>
</div>
<Divider noMargin />
{/if}
{/if}
<div class="blockSection"> <div class="blockSection">
<div <div
on:click={() => { on:click={() => {
@ -127,65 +208,66 @@
<Detail size="S">{block?.name?.toUpperCase() || ""}</Detail> <Detail size="S">{block?.name?.toUpperCase() || ""}</Detail>
</div> </div>
</div> </div>
{#if testResult && testResult[0]} <div class="blockTitle">
<span on:click={() => resultsModal.show()}> {#if testResult && testResult[0]}
<StatusLight <div style="float: right;" on:click={() => resultsModal.show()}>
positive={isTrigger || testResult[0].outputs?.success} <StatusLight
negative={!testResult[0].outputs?.success} positive={isTrigger || testResult[0].outputs?.success}
><Body size="XS">View response</Body></StatusLight negative={!testResult[0].outputs?.success}
> ><Body size="XS">View response</Body></StatusLight
</span> >
{/if} </div>
{/if}
<div
style="margin-left: 10px;"
on:click={() => {
onSelect(block)
}}
>
<Icon name={blockComplete ? "ChevronDown" : "ChevronUp"} />
</div>
</div>
</div> </div>
</div> </div>
{#if !blockComplete} {#if !blockComplete}
<Divider noMargin /> <Divider noMargin />
<div class="blockSection"> <div class="blockSection">
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div class="splitHeader"> {#if !isTrigger}
<ActionButton <div>
on:click={() => {
onSelect(block)
setupToggled = !setupToggled
}}
quiet
icon={setupToggled ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Setup</Detail>
</ActionButton>
{#if !isTrigger}
<div class="block-options"> <div class="block-options">
{#if !loopingSelected}
<ActionButton on:click={() => addLooping()} icon="Reuse"
>Add Looping</ActionButton
>
{/if}
{#if showBindingPicker} {#if showBindingPicker}
<div> <Select
<Select on:change={toggleFieldControl}
on:change={toggleFieldControl} defaultValue="Use values"
quiet autoWidth
defaultValue="Use values" value={rowControl ? "Use bindings" : "Use values"}
autoWidth options={["Use values", "Use bindings"]}
value={rowControl ? "Use bindings" : "Use values"} placeholder={null}
options={["Use values", "Use bindings"]} />
placeholder={null}
/>
</div>
{/if} {/if}
<div class="delete-padding" on:click={() => deleteStep()}> <ActionButton
<Icon name="DeleteOutline" /> on:click={() => deleteStep()}
</div> icon="DeleteOutline"
/>
</div> </div>
{/if} </div>
</div> {/if}
{#if setupToggled} <AutomationBlockSetup
<AutomationBlockSetup schemaProperties={Object.entries(block.schema.inputs.properties)}
schemaProperties={Object.entries(block.schema.inputs.properties)} {block}
{block} {webhookModal}
{webhookModal} />
/> {#if lastStep}
{#if lastStep} <Button on:click={() => testDataModal.show()} cta
<Button on:click={() => testDataModal.show()} cta >Finish and test automation</Button
>Finish and test automation</Button >
>
{/if}
{/if} {/if}
</Layout> </Layout>
</div> </div>
@ -204,13 +286,7 @@
</Modal> </Modal>
</div> </div>
<div class="separator" /> <div class="separator" />
<Icon <Icon on:click={() => actionModal.show()} hoverable name="AddCircle" size="S" />
on:click={() => actionModal.show()}
disabled={!hasCompletedInputs}
hoverable
name="AddCircle"
size="S"
/>
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2} {#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
<div class="separator" /> <div class="separator" />
{/if} {/if}
@ -220,8 +296,10 @@
padding-left: 30px; padding-left: 30px;
} }
.block-options { .block-options {
display: flex; justify-content: flex-end;
align-items: center; align-items: center;
display: flex;
gap: var(--spacing-m);
} }
.center-items { .center-items {
display: flex; display: flex;
@ -256,4 +334,9 @@
/* center horizontally */ /* center horizontally */
align-self: center; align-self: center;
} }
.blockTitle {
display: flex;
align-items: center;
}
</style> </style>

29
packages/builder/src/components/automation/AutomationBuilder/FlowChart/ResultsModal.svelte

@ -1,5 +1,5 @@
<script> <script>
import { ModalContent, Icon, Detail, TextArea } from "@budibase/bbui" import { ModalContent, Icon, Detail, TextArea, Label } from "@budibase/bbui"
export let testResult export let testResult
export let isTrigger export let isTrigger
@ -10,11 +10,11 @@
<ModalContent <ModalContent
showCloseIcon={false} showCloseIcon={false}
showConfirmButton={false} showConfirmButton={false}
title="Test Automation"
cancelText="Close" cancelText="Close"
> >
<div slot="header"> <div slot="header" class="result-modal-header">
<div style="float: right;"> <span>Test Results</span>
<div>
{#if isTrigger || testResult[0].outputs.success} {#if isTrigger || testResult[0].outputs.success}
<div class="iconSuccess"> <div class="iconSuccess">
<Icon size="S" name="CheckmarkCircle" /> <Icon size="S" name="CheckmarkCircle" />
@ -26,7 +26,18 @@
{/if} {/if}
</div> </div>
</div> </div>
<span>
{#if testResult[0].outputs.iterations}
<div style="display: flex;">
<Icon name="Reuse" />
<div style="margin-left: 10px;">
<Label>
This loop ran {testResult[0].outputs.iterations} times.</Label
>
</div>
</div>
{/if}
</span>
<div <div
on:click={() => { on:click={() => {
inputToggled = !inputToggled inputToggled = !inputToggled
@ -89,6 +100,14 @@
</ModalContent> </ModalContent>
<style> <style>
.result-modal-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.iconSuccess { .iconSuccess {
color: var(--spectrum-global-color-green-600); color: var(--spectrum-global-color-green-600);
} }

2
packages/builder/src/components/automation/AutomationPanel/AutomationPanel.svelte

@ -16,7 +16,7 @@
</Modal> </Modal>
</Tab> </Tab>
</Tabs> </Tabs>
<div class="add-button" data-cy="new-screen"> <div class="add-button">
<Icon hoverable name="AddCircle" on:click={modal.show} /> <Icon hoverable name="AddCircle" on:click={modal.show} />
</div> </div>
</div> </div>

66
packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte

@ -25,11 +25,11 @@
import QueryParamSelector from "./QueryParamSelector.svelte" import QueryParamSelector from "./QueryParamSelector.svelte"
import CronBuilder from "./CronBuilder.svelte" import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import { debounce } from "lodash"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import FilterDrawer from "components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte" import FilterDrawer from "components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte"
import { LuceneUtils } from "@budibase/frontend-core" import { LuceneUtils } from "@budibase/frontend-core"
import { getSchemaForTable } from "builderStore/dataBinding" import { getSchemaForTable } from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core"
export let block export let block
export let testData export let testData
@ -54,7 +54,7 @@
$: schema = getSchemaForTable(tableId, { searchableSchema: true }).schema $: schema = getSchemaForTable(tableId, { searchableSchema: true }).schema
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
const onChange = debounce(async function (e, key) { const onChange = Utils.sequential(async (e, key) => {
try { try {
if (isTestModal) { if (isTestModal) {
// Special case for webhook, as it requires a body, but the schema already brings back the body's contents // Special case for webhook, as it requires a body, but the schema already brings back the body's contents
@ -82,39 +82,71 @@
} catch (error) { } catch (error) {
notifications.error("Error saving automation") notifications.error("Error saving automation")
} }
}, 800) })
function getAvailableBindings(block, automation) { function getAvailableBindings(block, automation) {
if (!block || !automation) { if (!block || !automation) {
return [] return []
} }
// Find previous steps to the selected one // Find previous steps to the selected one
let allSteps = [...automation.steps] let allSteps = [...automation.steps]
if (automation.trigger) { if (automation.trigger) {
allSteps = [automation.trigger, ...allSteps] allSteps = [automation.trigger, ...allSteps]
} }
const blockIdx = allSteps.findIndex(step => step.id === block.id) let blockIdx = allSteps.findIndex(step => step.id === block.id)
// Extract all outputs from all previous steps as available bindings // Extract all outputs from all previous steps as available bindins
let bindings = [] let bindings = []
for (let idx = 0; idx < blockIdx; idx++) { for (let idx = 0; idx < blockIdx; idx++) {
const outputs = Object.entries( let wasLoopBlock = allSteps[idx]?.stepId === "LOOP"
allSteps[idx].schema?.outputs?.properties ?? {} let isLoopBlock =
) allSteps[idx]?.stepId === "LOOP" &&
allSteps.find(x => x.blockToLoop === block.id)
// If the previous block was a loop block, decerement the index so the following
// steps are in the correct order
if (wasLoopBlock) {
blockIdx--
}
let schema = allSteps[idx]?.schema?.outputs?.properties ?? {}
// If its a Loop Block, we need to add this custom schema
if (isLoopBlock) {
schema = {
currentItem: {
type: "string",
description: "the item currently being executed",
},
}
}
const outputs = Object.entries(schema)
bindings = bindings.concat( bindings = bindings.concat(
outputs.map(([name, value]) => { outputs.map(([name, value]) => {
const runtime = idx === 0 ? `trigger.${name}` : `steps.${idx}.${name}` let runtimeName = isLoopBlock
? `loop.${name}`
: block.name.startsWith("JS")
? `steps[${idx}].${name}`
: `steps.${idx}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : runtimeName
return { return {
label: runtime, label: runtime,
type: value.type, type: value.type,
description: value.description, description: value.description,
category: idx === 0 ? "Trigger outputs" : `Step ${idx} outputs`, category:
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx} outputs`,
path: runtime, path: runtime,
} }
}) })
) )
} }
return bindings return bindings
} }
@ -194,6 +226,7 @@
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
fillWidth fillWidth
updateOnChange={false}
/> />
{:else} {:else}
<DrawerBindableInput <DrawerBindableInput
@ -205,6 +238,7 @@
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
allowJS={false} allowJS={false}
updateOnChange={false}
/> />
{/if} {/if}
{:else if value.customType === "query"} {:else if value.customType === "query"}
@ -261,6 +295,14 @@
value={inputData[key]} value={inputData[key]}
/> />
</CodeEditorModal> </CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
on:change={e => onChange(e, key)}
autoWidth
value={inputData[key]}
options={["Array", "String"]}
defaultValue={"Array"}
/>
{:else if value.type === "string" || value.type === "number" || value.type === "integer"} {:else if value.type === "string" || value.type === "number" || value.type === "integer"}
{#if isTestModal} {#if isTestModal}
<ModalBindableInput <ModalBindableInput
@ -270,6 +312,7 @@
type={value.customType} type={value.customType}
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
updateOnChange={false}
/> />
{:else} {:else}
<div class="test"> <div class="test">
@ -281,6 +324,7 @@
value={inputData[key]} value={inputData[key]}
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
{bindings} {bindings}
updateOnChange={false}
/> />
</div> </div>
{/if} {/if}

7
packages/builder/src/components/automation/SetupPanel/RowSelector.svelte

@ -43,6 +43,11 @@
} }
const coerce = (value, type) => { const coerce = (value, type) => {
const re = new RegExp(/{{([^{].*?)}}/g)
if (re.test(value)) {
return value
}
if (type === "boolean") { if (type === "boolean") {
if (typeof value === "boolean") { if (typeof value === "boolean") {
return value return value
@ -120,6 +125,7 @@
{bindings} {bindings}
fillWidth={true} fillWidth={true}
allowJS={true} allowJS={true}
updateOnChange={false}
/> />
{/if} {/if}
{:else if !rowControl} {:else if !rowControl}
@ -137,6 +143,7 @@
{bindings} {bindings}
fillWidth={true} fillWidth={true}
allowJS={true} allowJS={true}
updateOnChange={false}
/> />
{/if} {/if}
{/if} {/if}

1
packages/builder/src/components/automation/SetupPanel/RowSelectorTypes.svelte

@ -60,5 +60,6 @@
{bindings} {bindings}
fillWidth={true} fillWidth={true}
allowJS={true} allowJS={true}
updateOnChange={false}
/> />
{/if} {/if}

5
packages/builder/src/components/automation/SetupPanel/SchemaSetup.svelte

@ -30,6 +30,10 @@
label: "DateTime", label: "DateTime",
value: "datetime", value: "datetime",
}, },
{
label: "Array",
value: "array",
},
] ]
function addField() { function addField() {
@ -70,6 +74,7 @@
secondary secondary
placeholder="Enter field name" placeholder="Enter field name"
on:change={fieldNameChanged(field.name)} on:change={fieldNameChanged(field.name)}
updateOnChange={false}
/> />
<Select <Select
value={field.type} value={field.type}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save