Merge branch 'develop' into labday/nginx-error-page

This commit is contained in:
Rory Powell 2022-04-25 16:30:24 +01:00
commit 9e7cec7c01
272 changed files with 8408 additions and 6596 deletions

9
.dockerignore Normal file
View File

@ -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

View File

@ -93,6 +93,8 @@ then `cd ` into your local copy.
#### 3. Install and Build
| **NOTE**: On Windows, all yarn commands must be executed on a bash shell (e.g. git bash)
To develop the Budibase platform you'll need [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) installed.
##### Quick method

View File

@ -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**
A clear and concise description of what the bug is.

3
.github/stale.yml vendored
View File

@ -14,7 +14,6 @@ staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
recent activity.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false

View File

@ -12,6 +12,11 @@ on:
- master
- develop
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
BASE_BRANCH: ${{ github.event.pull_request.base.ref}}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs:
build:
runs-on: ubuntu-latest
@ -27,6 +32,10 @@ jobs:
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
- run: yarn
- run: yarn bootstrap
- run: yarn lint

View File

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

View File

@ -20,6 +20,7 @@ env:
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs:
release:
@ -30,6 +31,10 @@ jobs:
- uses: actions/setup-node@v1
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro master
- run: yarn
- run: yarn bootstrap
- run: yarn lint

View File

@ -28,6 +28,7 @@ jobs:
- name: Cypress run
id: cypress
continue-on-error: true
uses: cypress-io/github-action@v2
with:
install: false

View File

@ -11,7 +11,7 @@ sources:
- https://github.com/Budibase/budibase
- https://budibase.com
type: application
version: 0.2.8
version: 0.2.9
appVersion: 1.0.48
dependencies:
- name: couchdb

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -93,16 +93,15 @@ globals:
logLevel: info
selfHosted: "1" # set to 0 for budibase cloud environment, set to 1 for self-hosted setup
multiTenancy: "0" # set to 0 to disable multiple orgs, set to 1 to enable multiple orgs
useQuotas: "0"
excludeQuotasTenants: "" # comma seperated list of tenants to exclude from quotas
accountPortalUrl: ""
accountPortalApiKey: ""
cookieDomain: ""
platformUrl: ""
httpMigrations: "0"
google:
clientId: ""
clientId: ""
secret: ""
automationMaxIterations: "500"
createSecrets: true # creates an internal API key, JWT secrets and redis password for you
@ -230,6 +229,8 @@ couchdb:
## Optional tolerations
tolerations: []
affinity: {}
service:
# annotations:
enabled: true

View File

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

View File

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

View File

@ -42,7 +42,19 @@ http {
client_max_body_size 1000m;
ignore_invalid_headers off;
proxy_buffering off;
# port_in_redirect off;
set $csp_default "default-src 'self'";
set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io";
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
set $csp_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";
set $csp_connect "connect-src 'self' https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com";
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
set $csp_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";
set $csp_manifest "manifest-src 'self'";
set $csp_media "media-src 'self' https://js.intercomcdn.com";
set $csp_worker "worker-src 'none'";
error_page 502 503 504 /error.html;
location = /error.html {
@ -54,7 +66,7 @@ http {
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://jspm.dev; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://api-iam.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io ; font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com; frame-src 'self' https:; img-src http: https: data:; manifest-src 'self'; media-src 'self'; worker-src 'none';" always;
add_header Content-Security-Policy "${csp_default}; ${csp_script}; ${csp_style}; ${csp_object}; ${csp_base_uri}; ${csp_connect}; ${csp_font}; ${csp_frame}; ${csp_img}; ${csp_manifest}; ${csp_media}; ${csp_worker};" always;
# upstreams
set $apps {{ apps }};

View File

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

97
hosting/single/Dockerfile Normal file
View File

@ -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 Normal file
View File

@ -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

View File

@ -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

View File

@ -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 Normal file
View File

@ -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 Normal file
View File

@ -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 Normal file
View File

@ -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

View File

@ -1,5 +1,5 @@
{
"version": "1.0.104-alpha.0",
"version": "1.0.105-alpha.39",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -21,18 +21,17 @@
},
"scripts": {
"setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev",
"bootstrap": "lerna link && lerna bootstrap",
"bootstrap": "lerna link && lerna bootstrap && ./scripts/link-dependencies.sh",
"build": "lerna run build",
"publishdev": "lerna run publishdev",
"publishnpm": "yarn build && lerna publish --force-publish",
"release": "lerna publish patch --yes --force-publish",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop",
"release": "lerna publish patch --yes --force-publish && yarn release:pro",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop && yarn release:pro:develop",
"release:pro": "bash scripts/pro/release.sh",
"release:pro:develop": "bash scripts/pro/release.sh develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore",
"nuke:docker": "lerna run --parallel dev:stack:nuke",
"clean": "lerna clean",
"kill-port": "kill-port 4001",
"kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server",
@ -58,6 +57,8 @@
"build:docker:develop": "node scripts/pinVersions && lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",
"build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single": "lerna run build && lerna run predocker && npm run build:docker:single:image",
"build:docs": "lerna run build:docs",
"release:helm": "node scripts/releaseHelmChart",
"env:multi:enable": "lerna run env:multi:enable",
@ -72,6 +73,7 @@
"mode:cloud": "yarn env:selfhost:disable && yarn env:multi:enable && yarn env:account:disable",
"mode:account": "yarn mode:cloud && yarn env:account:enable",
"security:audit": "node scripts/audit.js",
"postinstall": "husky install"
"postinstall": "husky install",
"install:pro": "bash scripts/pro/install.sh"
}
}

View File

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

View File

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

View File

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

View File

@ -27,6 +27,7 @@ const UNICODE_MAX = "\ufff0"
exports.ViewNames = {
USER_BY_EMAIL: "by_email",
BY_API_KEY: "by_api_key",
USER_BY_BUILDERS: "by_builders",
}
exports.StaticDatabases = StaticDatabases
@ -429,34 +430,9 @@ async function getScopedConfig(db, params) {
return configDoc && configDoc.config ? configDoc.config : configDoc
}
function generateNewUsageQuotaDoc() {
return {
_id: StaticDatabases.GLOBAL.docs.usageQuota,
quotaReset: Date.now() + 2592000000,
usageQuota: {
automationRuns: 0,
rows: 0,
storage: 0,
apps: 0,
users: 0,
views: 0,
emails: 0,
},
usageLimits: {
automationRuns: 1000,
rows: 4000,
apps: 4,
storage: 1000,
users: 10,
emails: 50,
},
}
}
exports.Replication = Replication
exports.getScopedConfig = getScopedConfig
exports.generateConfigID = generateConfigID
exports.getConfigParams = getConfigParams
exports.getScopedFullConfig = getScopedFullConfig
exports.generateNewUsageQuotaDoc = generateNewUsageQuotaDoc
exports.generateDevInfoID = generateDevInfoID

View File

@ -56,10 +56,34 @@ exports.createApiKeyView = async () => {
await db.put(designDoc)
}
exports.createUserBuildersView = async () => {
const db = getGlobalDB()
let designDoc
try {
designDoc = await db.get("_design/database")
} catch (err) {
// no design doc, make one
designDoc = DesignDoc()
}
const view = {
map: `function(doc) {
if (doc.builder && doc.builder.global === true) {
emit(doc._id, doc._id)
}
}`,
}
designDoc.views = {
...designDoc.views,
[ViewNames.USER_BY_BUILDERS]: view,
}
await db.put(designDoc)
}
exports.queryGlobalView = async (viewName, params, db = null) => {
const CreateFuncByName = {
[ViewNames.USER_BY_EMAIL]: exports.createUserEmailView,
[ViewNames.BY_API_KEY]: exports.createApiKeyView,
[ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView,
}
// can pass DB in if working with something specific
if (!db) {

View File

@ -22,12 +22,14 @@ module.exports = {
MINIO_URL: process.env.MINIO_URL,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
MULTI_TENANCY: process.env.MULTI_TENANCY,
ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL,
ACCOUNT_PORTAL_URL:
process.env.ACCOUNT_PORTAL_URL || "https://account.budibase.app",
ACCOUNT_PORTAL_API_KEY: process.env.ACCOUNT_PORTAL_API_KEY,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED),
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
PLATFORM_URL: process.env.PLATFORM_URL,
TENANT_FEATURE_FLAGS: process.env.TENANT_FEATURE_FLAGS,
isTest,
_set(key, value) {
process.env[key] = value

View File

@ -0,0 +1,11 @@
class BudibaseError extends Error {
constructor(message, type, code) {
super(message)
this.type = type
this.code = code
}
}
module.exports = {
BudibaseError,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,46 +2,49 @@ const fetch = require("node-fetch")
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
const { authenticateThirdParty } = require("./third-party-common")
/**
* @param {*} issuer The identity provider base URL
* @param {*} sub The user ID
* @param {*} profile The user profile information. Created by passport from the /userinfo response
* @param {*} jwtClaims The parsed id_token claims
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @param {*} idToken The id_token - always a JWT
* @param {*} params The response body from requesting an access_token
* @param {*} done The passport callback: err, user, info
*/
async function authenticate(
issuer,
sub,
profile,
jwtClaims,
accessToken,
refreshToken,
idToken,
params,
done
) {
const thirdPartyUser = {
// store the issuer info to enable sync in future
provider: issuer,
providerType: "oidc",
userId: profile.id,
profile: profile,
email: getEmail(profile, jwtClaims),
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
false, // don't require local accounts to exist
const buildVerifyFn = saveUserFn => {
/**
* @param {*} issuer The identity provider base URL
* @param {*} sub The user ID
* @param {*} profile The user profile information. Created by passport from the /userinfo response
* @param {*} jwtClaims The parsed id_token claims
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @param {*} idToken The id_token - always a JWT
* @param {*} params The response body from requesting an access_token
* @param {*} done The passport callback: err, user, info
*/
return async (
issuer,
sub,
profile,
jwtClaims,
accessToken,
refreshToken,
idToken,
params,
done
)
) => {
const thirdPartyUser = {
// store the issuer info to enable sync in future
provider: issuer,
providerType: "oidc",
userId: profile.id,
profile: profile,
email: getEmail(profile, jwtClaims),
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
false, // don't require local accounts to exist
done,
saveUserFn
)
}
}
/**
@ -86,7 +89,7 @@ function validEmail(value) {
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport OIDC Strategy
*/
exports.strategyFactory = async function (config, callbackUrl) {
exports.strategyFactory = async function (config, callbackUrl, saveUserFn) {
try {
const { clientID, clientSecret, configUrl } = config
@ -106,6 +109,7 @@ exports.strategyFactory = async function (config, callbackUrl) {
const body = await response.json()
const verify = buildVerifyFn(saveUserFn)
return new OIDCStrategy(
{
issuer: body.issuer,
@ -116,7 +120,7 @@ exports.strategyFactory = async function (config, callbackUrl) {
clientSecret: clientSecret,
callbackURL: callbackUrl,
},
authenticate
verify
)
} catch (err) {
console.error(err)
@ -125,4 +129,4 @@ exports.strategyFactory = async function (config, callbackUrl) {
}
// expose for testing
exports.authenticate = authenticate
exports.buildVerifyFn = buildVerifyFn

View File

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

View File

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

View File

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

View File

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

View File

@ -14,22 +14,7 @@ function makeSessionID(userId, sessionId) {
return `${userId}/${sessionId}`
}
exports.createASession = async (userId, session) => {
const client = await redis.getSessionClient()
const sessionId = session.sessionId
if (!session.csrfToken) {
session.csrfToken = uuidv4()
}
session = {
createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(),
...session,
userId,
}
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)
}
exports.invalidateSessions = async (userId, sessionIds = null) => {
async function invalidateSessions(userId, sessionIds = null) {
let sessions = []
// If no sessionIds, get all the sessions for the user
@ -55,6 +40,24 @@ exports.invalidateSessions = async (userId, sessionIds = null) => {
await Promise.all(promises)
}
exports.createASession = async (userId, session) => {
// invalidate all other sessions
await invalidateSessions(userId)
const client = await redis.getSessionClient()
const sessionId = session.sessionId
if (!session.csrfToken) {
session.csrfToken = uuidv4()
}
session = {
createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(),
...session,
userId,
}
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)
}
exports.updateSessionTTL = async session => {
const client = await redis.getSessionClient()
const key = makeSessionID(session.userId, session.sessionId)
@ -67,8 +70,6 @@ exports.endSession = async (userId, sessionId) => {
await client.delete(makeSessionID(userId, sessionId))
}
exports.getUserSessions = getSessionsForUser
exports.getSession = async (userId, sessionId) => {
try {
const client = await redis.getSessionClient()
@ -84,3 +85,6 @@ exports.getAllSessions = async () => {
const sessions = await client.scan()
return sessions.map(session => session.value)
}
exports.getUserSessions = getSessionsForUser
exports.invalidateSessions = invalidateSessions

View File

@ -176,6 +176,13 @@ exports.getGlobalUserByEmail = async email => {
})
}
exports.getBuildersCount = async () => {
const builders = await queryGlobalView(ViewNames.USER_BY_BUILDERS, {
include_docs: false,
})
return builders ? builders.length : 0
}
exports.saveUser = async (
user,
tenantId,
@ -289,4 +296,5 @@ exports.platformLogout = async ({ ctx, userId, keepActiveSession }) => {
userId,
sessions.map(({ sessionId }) => sessionId)
)
await userCache.invalidateUser(userId)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,42 +1,21 @@
<script>
import Icon from "../Icon/Icon.svelte"
import { copyToClipboard } from "../helpers"
import { notifications } from "../Stores/notifications"
export let value
const onClick = e => {
const onClick = async e => {
e.stopPropagation()
copyToClipboard(value)
}
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()
}
})
.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)
})
try {
await copyToClipboard(value)
notifications.success("Copied to clipboard")
} catch (error) {
notifications.error(
"Failed to copy to clipboard. Check the dev console for the value."
)
console.warn("Failed to copy the value", value)
}
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

@ -106,3 +106,29 @@ export const deepSet = (obj, key, value) => {
export const cloneDeep = obj => {
return JSON.parse(JSON.stringify(obj))
}
/**
* Copies a value to the clipboard
* @param value the value to copy
*/
export const copyToClipboard = value => {
return new Promise(res => {
if (navigator.clipboard && window.isSecureContext) {
// Try using the clipboard API first
navigator.clipboard.writeText(value).then(res)
} else {
// Fall back to the textarea hack
let textArea = document.createElement("textarea")
textArea.value = value
textArea.style.position = "fixed"
textArea.style.left = "-9999px"
textArea.style.top = "-9999px"
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
document.execCommand("copy")
textArea.remove()
res()
}
})
}

View File

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

View File

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

View File

@ -6,10 +6,6 @@ filterTests(['all'], () => {
cy.login()
})
after(() => {
cy.deleteAllApps()
})
it("should change the icon and colour for an application", () => {
// Search for test application
cy.applicationInAppTable("Cypress Tests")

View File

@ -3,25 +3,36 @@ import filterTests from '../support/filterTests'
filterTests(['smoke', 'all'], () => {
context("Create an Application", () => {
beforeEach(() => {
before(() => {
cy.login()
cy.deleteApp("Cypress Tests")
})
it("should show the new user UI/UX", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').should("exist")
cy.get(`[data-cy="import-app-btn"]`).should("exist")
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
cy.get(".appTable").should("not.exist")
})
if (!(Cypress.env("TEST_ENV"))) {
it("should show the new user UI/UX", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').should("exist")
cy.get(`[data-cy="import-app-btn"]`).should("exist")
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
cy.get(".appTable").should("not.exist")
})
}
it("should provide filterable templates", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
cy.get(".spectrum-Button").contains("Templates").click({force: true})
}
})
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
@ -39,14 +50,22 @@ filterTests(['smoke', 'all'], () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
const appName = "A New App"
cy.get(`[data-cy="create-app-btn"]`).contains('Start from scratch').click({force: true})
// Start create app process. If apps already exist, click second button
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
}
})
const appName = "Cypress Tests"
cy.get(".spectrum-Modal").within(() => {
//Auto fill
cy.get("input").eq(0).type(appName).should("have.value", appName).blur()
cy.get("input").eq(1).should("have.value", "/a-new-app")
cy.get("input").eq(1).should("have.value", "/cypress-tests")
cy.get(".spectrum-ButtonGroup").contains("Create app").should('not.be.disabled')
//Empty the app url - disabled create
@ -69,8 +88,7 @@ filterTests(['smoke', 'all'], () => {
it("should create the first application from scratch", () => {
const appName = "Cypress Tests"
cy.deleteApp(appName)
cy.createApp(appName, "This app is used for Cypress testing.")
cy.createApp(appName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
@ -83,10 +101,19 @@ filterTests(['smoke', 'all'], () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
// Navigate to Create new app section if apps already exist
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
}
})
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
//### Select nth template and choose to create?
// Select template
cy.get('.template-category').eq(0).within(() => {
const card = cy.get('.template-card').eq(0).should("exist");
const cardOverlay = card.get('.template-thumbnail-action-overlay').should("exist")
@ -94,7 +121,7 @@ filterTests(['smoke', 'all'], () => {
cardOverlay.get("button").contains("Use template").should("exist").click({force: true})
})
//### CMD Create app from theme card
// CMD Create app from theme card
cy.get(".spectrum-Modal").should('be.visible')
const templateName = cy.get(".spectrum-Modal .template-thumbnail-text")
@ -111,27 +138,22 @@ filterTests(['smoke', 'all'], () => {
cy.wait(1000)
cy.applicationInAppTable(templateNameText)
cy.deleteAllApps()
cy.deleteApp(templateNameText)
});
})
it("should display a second application and app filtering", () => {
// Create first app
const appName = "Cypress Tests"
cy.deleteApp(appName)
cy.createApp(appName, "This app is used for Cypress testing.")
cy.createApp(appName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
// Create second app
const secondAppName = "Second App Demo"
cy.deleteApp(secondAppName)
cy.get(`[data-cy="create-app-btn"]`).contains('Create new app').click({force: true})
cy.wait(500)
cy.url().should('include', '/builder/portal/apps/create')
cy.createAppFromScratch(secondAppName)
cy.createApp(secondAppName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
@ -140,7 +162,7 @@ filterTests(['smoke', 'all'], () => {
cy.searchForApplication(appName)
cy.searchForApplication(secondAppName)
cy.deleteAllApps()
cy.deleteApp(secondAppName)
})
})

View File

@ -20,7 +20,6 @@ filterTests(['smoke', 'all'], () => {
})
// Setup trigger
cy.contains("Setup").click()
cy.get(".spectrum-Picker-label").click()
cy.wait(500)
cy.contains("dog").click()
@ -32,12 +31,11 @@ filterTests(['smoke', 'all'], () => {
cy.contains("Create Row").trigger('mouseover').click().click()
cy.get(".spectrum-Button--cta").click()
})
cy.contains("Setup").click()
cy.get(".spectrum-Picker-label").eq(1).click()
cy.contains("dog").click()
cy.get(".spectrum-Textfield-input")
.first()
.type("{{ trigger.row.name }}", { parseSpecialCharSequences: false })
.first()
.type("{{ trigger.row.name }}", { parseSpecialCharSequences: false })
cy.get(".spectrum-Textfield-input")
.eq(1)
.type("11")

View File

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

View File

@ -9,17 +9,33 @@ filterTests(["smoke", "all"], () => {
})
it("Should successfully create a screen", () => {
cy.createScreen("Test Screen", "/test")
cy.createScreen("/test")
cy.get(".nav-items-container").within(() => {
cy.contains("/test").should("exist")
})
})
it("Should update the url", () => {
cy.createScreen("Test Screen", "test with spaces")
cy.createScreen("test with spaces")
cy.get(".nav-items-container").within(() => {
cy.contains("/test-with-spaces").should("exist")
})
})
it("Should create a blank screen with the selected access level", () => {
cy.createScreen("admin only", "Admin")
cy.get(".nav-items-container").within(() => {
cy.contains("/admin-only").should("exist")
})
cy.createScreen("open to all", "Public")
cy.get(".nav-items-container").within(() => {
cy.contains("/open-to-all").should("exist")
//The access level should now be set to admin. Previous screens should be filtered.
cy.get(".nav-item").contains("/test-screen").should("not.exist")
})
})
})
})

View File

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

View File

@ -4,9 +4,6 @@ filterTests(["smoke", "all"], () => {
context("Create a User and Assign Roles", () => {
before(() => {
cy.login()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.createAppFromScratch("Initial App")
})
it("should create a user", () => {

View File

@ -4,7 +4,6 @@ filterTests(['smoke', 'all'], () => {
context("Create a View", () => {
before(() => {
cy.login()
cy.deleteAllApps()
cy.createTestApp()
cy.createTable("data")

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ filterTests(["smoke", "all"], () => {
context("REST Datasource Testing", () => {
before(() => {
cy.login()
cy.deleteAllApps()
cy.createTestApp()
})

View File

@ -4,8 +4,7 @@ filterTests(["smoke", "all"], () => {
context("Query Level Transformers", () => {
before(() => {
cy.login()
cy.deleteAllApps()
cy.createApp("Cypress Tests")
cy.createTestApp()
})
it("should write a transformer function", () => {

View File

@ -11,10 +11,12 @@ filterTests(['all'], () => {
const appName = "Cypress Tests"
const appRename = "Cypress Renamed"
// Rename app, Search for app, Confirm name was changed
cy.get(".home-logo").click()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
renameApp(appName, appRename)
cy.reload()
cy.wait(1000)
cy.searchForApplication(appRename)
cy.get(".appTable").find(".title").should("have.length", 1)
cy.applicationInAppTable(appRename)
// Set app name back to Cypress Tests
@ -36,7 +38,8 @@ filterTests(['all'], () => {
cy.get(".spectrum-Button").contains("Publish").click({ force: true })
})
// Rename app, Search for app, Confirm name was changed
cy.get(".home-logo").click()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
renameApp(appName, appRename, true)
cy.get(".appTable").find(".wrapper").should("have.length", 1)
cy.applicationInAppTable(appRename)
@ -44,7 +47,8 @@ filterTests(['all'], () => {
it("Should try to rename an application to have no name", () => {
const appName = "Cypress Tests"
cy.get(".home-logo").click()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
renameApp(appName, " ", false, true)
cy.wait(500)
// Close modal and confirm name has not been changed
@ -52,8 +56,6 @@ filterTests(['all'], () => {
cy.reload()
cy.wait(1000)
cy.applicationInAppTable(appName)
cy.get(".appTable").find(".title").should("have.length", 1)
})
xit("Should create two applications with the same name", () => {
@ -77,12 +79,12 @@ filterTests(['all'], () => {
const appName = "Cypress Tests"
const numberName = 12345
const specialCharName = "£$%^"
cy.get(".home-logo").click()
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
renameApp(appName, numberName)
cy.reload()
cy.wait(1000)
cy.applicationInAppTable(numberName)
cy.get(".appTable").find(".title").should("have.length", 1)
cy.reload()
cy.wait(1000)
renameApp(numberName, specialCharName)
@ -94,40 +96,33 @@ filterTests(['all'], () => {
})
const renameApp = (originalName, changedName, published, noName) => {
cy.applicationInAppTable(originalName)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
cy.get(".appTable")
.within(() => {
cy.get(".spectrum-Icon").eq(1).click()
})
// Check for when an app is published
if (published == true) {
// Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
cy.searchForApplication(originalName)
cy.get(".appTable")
.within(() => {
cy.get(".spectrum-Icon").eq(1).click()
})
// Check for when an app is published
if (published == true) {
// Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
}
cy.contains("Edit").click()
cy.get(".spectrum-Modal")
.within(() => {
if (noName == true) {
cy.get("input").clear()
cy.get(".spectrum-Dialog-grid").click()
.contains("App name must be letters, numbers and spaces only")
return cy
}
cy.contains("Edit").click()
cy.get(".spectrum-Modal")
.within(() => {
if (noName == true) {
cy.get("input").clear()
cy.get(".spectrum-Dialog-grid").click()
.contains("App name must be letters, numbers and spaces only")
return cy
}
cy.get("input").clear()
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur()
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true })
cy.wait(500)
})
}
})
}
})
cy.get("input").clear()
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur()
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true })
cy.wait(500)
})
}
})
})

View File

@ -32,18 +32,38 @@ Cypress.Commands.add("login", () => {
})
})
Cypress.Commands.add("createApp", name => {
Cypress.Commands.add("closeModal", () => {
cy.get(".spectrum-Modal").within(() => {
cy.get(".close-icon").click()
cy.wait(500)
})
})
Cypress.Commands.add("createApp", (name, addDefaultTable) => {
const shouldCreateDefaultTable =
typeof addDefaultTable != "boolean" ? true : addDefaultTable
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
// If apps already exist
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
}
})
cy.get(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click()
cy.wait(10000)
})
cy.createTable("Cypress Tests", true)
if (shouldCreateDefaultTable) {
cy.createTable("Cypress Tests", true)
}
})
Cypress.Commands.add("deleteApp", name => {
@ -52,36 +72,48 @@ Cypress.Commands.add("deleteApp", name => {
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
const appId = val.reduce((acc, app) => {
if (name === app.name) {
acc = app.appId
}
return acc
}, "")
const findAppName = val.some(val => val.name == name)
if (findAppName) {
if (val.length > 0) {
if (Cypress.env("TEST_ENV")) {
cy.searchForApplication(name)
cy.get(".appTable").within(() => {
cy.get(".spectrum-Icon").eq(1).click()
})
} else {
const appId = val.reduce((acc, app) => {
if (name === app.name) {
acc = app.appId
}
return acc
}, "")
if (appId == "") {
if (appId == "") {
return
}
const appIdParsed = appId.split("_").pop()
const actionEleId = `[data-cy=row_actions_${appIdParsed}]`
cy.get(actionEleId).within(() => {
cy.get(".spectrum-Icon").eq(0).click()
})
}
cy.get(".spectrum-Menu").then($menu => {
if ($menu.text().includes("Unpublish")) {
cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
} else {
cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get("input").type(name)
})
cy.get(".spectrum-Button--warning").click()
}
})
} else {
return
}
const appIdParsed = appId.split("_").pop()
const actionEleId = `[data-cy=row_actions_${appIdParsed}]`
cy.get(actionEleId).within(() => {
cy.get(".spectrum-Icon").eq(0).click()
})
cy.get(".spectrum-Menu").then($menu => {
if ($menu.text().includes("Unpublish")) {
cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
} else {
cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get("input").type(name)
})
cy.get(".spectrum-Button--warning").click()
}
})
} else {
return
}
@ -115,7 +147,7 @@ Cypress.Commands.add("createTestApp", () => {
const appName = "Cypress Tests"
cy.deleteApp(appName)
cy.createApp(appName, "This app is used for Cypress testing.")
cy.createScreen("home", "home")
cy.createScreen("home")
})
Cypress.Commands.add("createTestTableWithData", () => {
@ -255,33 +287,99 @@ Cypress.Commands.add("navigateToDataSection", () => {
cy.contains("Data").click()
})
Cypress.Commands.add("createScreen", (screenName, route) => {
//Blank
Cypress.Commands.add("createScreen", (route, accessLevelLabel) => {
cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click()
cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Blank").click()
cy.get(".spectrum-Button").contains("Add screens").click({ force: true })
cy.get(".item").contains("Blank screen").click()
cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(500)
})
cy.get(".spectrum-Dialog-grid").within(() => {
cy.get(".spectrum-Form-itemField").eq(0).type(screenName)
cy.get(".spectrum-Form-itemField").eq(1).type(route)
cy.get(".spectrum-Form-itemField").eq(0).type(route)
cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(1000)
})
cy.get(".spectrum-Modal").within(() => {
if (accessLevelLabel) {
cy.get(".spectrum-Picker-label").click()
cy.wait(500)
cy.contains(accessLevelLabel).click()
}
cy.get(".spectrum-Button").contains("Done").click({ force: true })
})
})
Cypress.Commands.add("createAutogeneratedScreens", screenNames => {
Cypress.Commands.add(
"createDatasourceScreen",
(datasourceNames, accessLevelLabel) => {
cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click()
cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Autogenerated screens").click()
cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(500)
})
cy.get(".spectrum-Modal [data-cy='data-source-modal']").within(() => {
for (let i = 0; i < datasourceNames.length; i++) {
cy.get(".data-source-entry").contains(datasourceNames[i]).click()
//Ensure the check mark is visible
cy.get(".data-source-entry")
.contains(datasourceNames[i])
.get(".data-source-check")
.should("exist")
}
cy.get(".spectrum-Button").contains("Confirm").click({ force: true })
})
cy.get(".spectrum-Modal").within(() => {
if (accessLevelLabel) {
cy.get(".spectrum-Picker-label").click()
cy.wait(500)
cy.contains(accessLevelLabel).click()
}
cy.get(".spectrum-Button").contains("Done").click({ force: true })
})
cy.contains("Design").click()
}
)
Cypress.Commands.add("navigateToAutogeneratedModal", () => {
// Screen name must already exist within data source
cy.contains("Design").click()
cy.get("[aria-label=AddCircle]").click()
for (let i = 0; i < screenNames.length; i++) {
cy.get(".item").contains(screenNames[i]).click()
}
cy.get(".spectrum-Button").contains("Add screens").click({ force: true })
cy.wait(4000)
cy.get(".spectrum-Modal").within(() => {
cy.get(".item").contains("Autogenerated screens").click()
cy.get(".spectrum-Button").contains("Continue").click({ force: true })
cy.wait(500)
})
})
Cypress.Commands.add(
"createAutogeneratedScreens",
(screenNames, accessLevelLabel) => {
cy.navigateToAutogeneratedModal()
for (let i = 0; i < screenNames.length; i++) {
cy.get(".data-source-entry").contains(screenNames[i]).click()
}
cy.get(".spectrum-Modal").within(() => {
if (accessLevelLabel) {
cy.get(".spectrum-Picker-label").click()
cy.wait(500)
cy.contains(accessLevelLabel).click()
}
cy.get(".spectrum-Button").contains("Confirm").click({ force: true })
cy.wait(4000)
})
}
)
Cypress.Commands.add("addRow", values => {
cy.contains("Create row").click()
cy.get(".spectrum-Modal").within(() => {
@ -327,16 +425,25 @@ Cypress.Commands.add("addCustomSourceOptions", totalOptions => {
//Filters visible with 1 or more
Cypress.Commands.add("searchForApplication", appName => {
cy.wait(1000)
// Searches for the app
cy.get(".filter").then(() => {
cy.get(".spectrum-Textfield").within(() => {
cy.get("input").eq(0).clear()
cy.get("input").eq(0).type(appName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(2000)
// No app filter functionality if only 1 app exists
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length < 2) {
return
} else {
// Searches for the app
cy.get(".filter").then(() => {
cy.get(".spectrum-Textfield").within(() => {
cy.get("input").eq(0).clear()
cy.get("input").eq(0).type(appName)
})
})
}
})
})
// Confirms app exists after search
cy.applicationInAppTable(appName)
})
//Assumes there are no others
@ -386,7 +493,9 @@ Cypress.Commands.add("addDatasourceConfig", (datasource, skipFetch) => {
if (datasource == "Oracle") {
cy.get("input").clear().type(Cypress.env("oracle").HOST)
} else {
cy.get("input").clear().type(Cypress.env("HOST_IP"))
cy.get("input")
.clear({ force: true })
.type(Cypress.env("mysql").HOST, { force: true })
}
})
})

View File

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

View File

@ -22,6 +22,7 @@ export const Events = {
},
SCREEN: {
CREATED: "Screen Created",
CREATE_ROLE_UPDATED: "Changed Role On Screen Creation",
},
AUTOMATION: {
CREATED: "Automation Created",

View File

@ -7,7 +7,11 @@ import {
getComponentSettings,
} from "./componentUtils"
import { store } from "builderStore"
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
import {
queries as queriesStores,
tables as tablesStore,
roles as rolesStore,
} from "stores/backend"
import {
makePropSafe,
isJSBinding,
@ -33,6 +37,7 @@ export const getBindableProperties = (asset, componentId) => {
const deviceBindings = getDeviceBindings()
const stateBindings = getStateBindings()
const selectedRowsBindings = getSelectedRowsBindings(asset)
const roleBindings = getRoleBindings()
return [
...contextBindings,
...urlBindings,
@ -40,6 +45,7 @@ export const getBindableProperties = (asset, componentId) => {
...userBindings,
...deviceBindings,
...selectedRowsBindings,
...roleBindings,
]
}
@ -391,6 +397,16 @@ const getUrlBindings = asset => {
}))
}
const getRoleBindings = () => {
return (get(rolesStore) || []).map(role => {
return {
type: "context",
runtimeBinding: `trim "${role._id}"`,
readableBinding: `Role.${role.name}`,
}
})
}
/**
* Gets all bindable properties exposed in a button actions flow up until
* the specified action ID, as well as context provided for the action
@ -638,7 +654,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
* Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form.
*/
const buildFormSchema = component => {
export const buildFormSchema = component => {
let schema = {}
if (!component) {
return schema

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<script>
import { ModalContent, Icon, Detail, TextArea } from "@budibase/bbui"
import { ModalContent, Icon, Detail, TextArea, Label } from "@budibase/bbui"
export let testResult
export let isTrigger
@ -10,7 +10,7 @@
<ModalContent
showCloseIcon={false}
showConfirmButton={false}
title="Test Automation"
title="Test Results"
cancelText="Close"
>
<div slot="header">
@ -26,7 +26,18 @@
{/if}
</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
on:click={() => {
inputToggled = !inputToggled

View File

@ -88,33 +88,65 @@
if (!block || !automation) {
return []
}
// Find previous steps to the selected one
let allSteps = [...automation.steps]
if (automation.trigger) {
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 = []
for (let idx = 0; idx < blockIdx; idx++) {
const outputs = Object.entries(
allSteps[idx].schema?.outputs?.properties ?? {}
)
let wasLoopBlock = allSteps[idx]?.stepId === "LOOP"
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(
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 {
label: runtime,
type: value.type,
description: value.description,
category: idx === 0 ? "Trigger outputs" : `Step ${idx} outputs`,
category:
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx} outputs`,
path: runtime,
}
})
)
}
return bindings
}
@ -261,6 +293,14 @@
value={inputData[key]}
/>
</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"}
{#if isTestModal}
<ModalBindableInput

View File

@ -14,7 +14,7 @@
import Table from "./Table.svelte"
import { TableNames } from "constants"
import CreateEditRow from "./modals/CreateEditRow.svelte"
import { Pagination } from "@budibase/bbui"
import { Pagination, Heading, Body, Layout } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { API } from "api"
@ -27,6 +27,8 @@
$: enrichedSchema = enrichSchema($tables.selected?.schema)
$: id = $tables.selected?._id
$: fetch = createFetch(id)
$: hasCols = checkHasCols(schema)
$: hasRows = !!$fetch.rows?.length
const enrichSchema = schema => {
let tempSchema = { ...schema }
@ -47,6 +49,20 @@
return tempSchema
}
const checkHasCols = schema => {
if (!schema || Object.keys(schema).length === 0) {
return false
}
let fields = Object.values(schema)
for (let field of fields) {
if (!field.autocolumn) {
return true
}
}
return false
}
// Fetches new data whenever the table changes
const createFetch = tableId => {
return fetchData({
@ -104,40 +120,73 @@
disableSorting
on:updatecolumns={onUpdateColumns}
on:updaterows={onUpdateRows}
customPlaceholder
>
<CreateColumnButton on:updatecolumns={onUpdateColumns} />
{#if schema && Object.keys(schema).length > 0}
{#if !isUsersTable}
<CreateRowButton
on:updaterows={onUpdateRows}
title={"Create row"}
modalContentComponent={CreateEditRow}
/>
{/if}
{#if isInternal}
<CreateViewButton />
{/if}
<ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<ExistingRelationshipButton
table={$tables.selected}
<div class="buttons">
<div class="left-buttons">
<CreateColumnButton
highlighted={$fetch.loaded && (!hasCols || !hasRows)}
on:updatecolumns={onUpdateColumns}
/>
{/if}
<HideAutocolumnButton bind:hideAutocolumns />
<!-- always have the export last -->
<ExportButton view={$tables.selected?._id} />
<ImportButton
tableId={$tables.selected?._id}
on:updaterows={onUpdateRows}
/>
{#key id}
<TableFilterButton {schema} on:change={onFilter} />
{/key}
{/if}
{#if !isUsersTable}
<CreateRowButton
on:updaterows={onUpdateRows}
title={"Create row"}
modalContentComponent={CreateEditRow}
disabled={!hasCols}
highlighted={$fetch.loaded && hasCols && !hasRows}
/>
{/if}
{#if isInternal}
<CreateViewButton disabled={!hasCols || !hasRows} />
{/if}
</div>
<div class="right-buttons">
<ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<ExistingRelationshipButton
table={$tables.selected}
on:updatecolumns={onUpdateColumns}
/>
{/if}
<HideAutocolumnButton bind:hideAutocolumns />
<ImportButton
tableId={$tables.selected?._id}
on:updaterows={onUpdateRows}
/>
<ExportButton
disabled={!hasRows || !hasCols}
view={$tables.selected?._id}
/>
{#key id}
<TableFilterButton
{schema}
on:change={onFilter}
disabled={!hasCols || !hasRows}
/>
{/key}
</div>
</div>
<div slot="placeholder">
<Layout gap="S">
{#if !hasCols}
<Heading>Let's create some columns</Heading>
<Body>
Start building out your table structure<br />
by adding some columns
</Body>
{:else}
<Heading>Now let's add a row</Heading>
<Body>
Add some data to your table<br />
by adding some rows
</Body>
{/if}
</Layout>
</div>
</Table>
{#key id}
<div in:fade={{ delay: 200, duration: 100 }}>
@ -162,4 +211,20 @@
align-items: center;
margin-top: var(--spacing-xl);
}
.buttons {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.left-buttons,
.right-buttons {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
</style>

View File

@ -19,13 +19,25 @@
export let value = defaultValue || (meta.type === "boolean" ? false : "")
export let readonly
const resolveTimeStamp = timestamp => {
let maskedDate = new Date(`0-${timestamp}`)
if (maskedDate instanceof Date && !isNaN(maskedDate.getTime())) {
return maskedDate
} else {
return null
}
}
$: stringVal =
typeof value === "object" ? JSON.stringify(value, null, 2) : value
$: type = meta?.type
$: label = meta.name ? capitalise(meta.name) : ""
const timeStamp = resolveTimeStamp(value)
const isTimeStamp = !!timeStamp
</script>
{#if type === "options"}
{#if type === "options" && meta.constraints.inclusion.length !== 0}
<Select
{label}
data-cy="{meta.name}-select"
@ -34,12 +46,12 @@
sort
/>
{:else if type === "datetime"}
<DatePicker {label} bind:value />
<DatePicker {label} timeOnly={isTimeStamp} bind:value />
{:else if type === "attachment"}
<Dropzone {label} bind:value />
{:else if type === "boolean"}
<Toggle text={label} bind:value data-cy="{meta.name}-input" />
{:else if type === "array"}
{:else if type === "array" && meta.constraints.inclusion.length !== 0}
<Multiselect bind:value {label} options={meta.constraints.inclusion} />
{:else if type === "link"}
<LinkedRowSelector bind:linkedRows={value} schema={meta} />

View File

@ -25,6 +25,7 @@
export let rowCount
export let type
export let disableSorting = false
export let customPlaceholder = false
let selectedRows = []
let editableColumn
@ -117,10 +118,10 @@
</script>
<Layout noPadding gap="S">
<div>
<Layout noPadding gap="XS">
{#if title}
<div class="table-title">
<Heading size="S">{title}</Heading>
<Heading size="M">{title}</Heading>
{#if loading}
<div transition:fade|local>
<Spinner size="10" />
@ -134,7 +135,7 @@
<DeleteRowsButton on:updaterows {selectedRows} {deleteRows} />
{/if}
</div>
</div>
</Layout>
{#key tableId}
<div class="table-wrapper">
<Table
@ -144,6 +145,7 @@
{customRenderers}
{rowCount}
{disableSorting}
{customPlaceholder}
bind:selectedRows
allowSelectRows={allowEditing && !isUsersTable}
allowEditRows={allowEditing}
@ -153,7 +155,9 @@
on:editrow={e => editRow(e.detail)}
on:clickrelationship={e => selectRelationship(e.detail)}
on:sort
/>
>
<slot slot="placeholder" name="placeholder" />
</Table>
</div>
{/key}
</Layout>
@ -176,6 +180,7 @@
flex-direction: row;
justify-content: flex-start;
align-items: center;
margin-top: var(--spacing-m);
}
.table-title > div {
margin-left: var(--spacing-xs);

View File

@ -2,10 +2,21 @@
import { ActionButton, Modal } from "@budibase/bbui"
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
export let highlighted = false
export let disabled = false
let modal
</script>
<ActionButton icon="TableColumnAddRight" quiet size="S" on:click={modal.show}>
<ActionButton
{disabled}
selected={highlighted}
emphasized={highlighted}
icon="TableColumnAddRight"
quiet
size="S"
on:click={modal.show}
>
Create column
</ActionButton>
<Modal bind:this={modal}>

View File

@ -4,11 +4,21 @@
export let modalContentComponent = CreateEditRow
export let title = "Create row"
export let disabled = false
export let highlighted = false
let modal
</script>
<ActionButton icon="TableRowAddBottom" size="S" quiet on:click={modal.show}>
<ActionButton
{disabled}
emphasized={highlighted}
selected={highlighted}
icon="TableRowAddBottom"
size="S"
quiet
on:click={modal.show}
>
{title}
</ActionButton>
<Modal bind:this={modal}>

View File

@ -2,10 +2,18 @@
import { Modal, ActionButton } from "@budibase/bbui"
import CreateViewModal from "../modals/CreateViewModal.svelte"
export let disabled = false
let modal
</script>
<ActionButton icon="CollectionAdd" size="S" quiet on:click={modal.show}>
<ActionButton
{disabled}
icon="CollectionAdd"
size="S"
quiet
on:click={modal.show}
>
Create view
</ActionButton>
<Modal bind:this={modal}>

View File

@ -3,11 +3,18 @@
import ExportModal from "../modals/ExportModal.svelte"
export let view
export let disabled = false
let modal
</script>
<ActionButton icon="DataDownload" size="S" quiet on:click={modal.show}>
<ActionButton
{disabled}
icon="DataDownload"
size="S"
quiet
on:click={modal.show}
>
Export
</ActionButton>
<Modal bind:this={modal}>

View File

@ -8,6 +8,12 @@
}
</script>
<ActionButton icon="MagicWand" primary size="S" quiet on:click={hideOrUnhide}>
{#if hideAutocolumns}Show auto columns{:else}Hide auto columns{/if}
<ActionButton
icon={hideAutocolumns ? "VisibilityOff" : "Visibility"}
primary
size="S"
quiet
on:click={hideOrUnhide}
>
Auto columns
</ActionButton>

View File

@ -5,6 +5,7 @@
export let schema
export let filters
export let disabled = false
const dispatch = createEventDispatcher()
let modal
@ -17,6 +18,7 @@
icon="Filter"
size="S"
quiet
{disabled}
on:click={modal.show}
active={tempValue?.length > 0}
>

View File

@ -0,0 +1,49 @@
<script>
export let width = "100"
export let height = "100"
</script>
<svg
{width}
{height}
viewBox="0 0 256 220"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M245.97 168.943C232.308 176.064 161.536 205.163 146.469 213.018C131.402 220.874 123.032 220.798 111.129 215.108C99.227 209.418 23.913 178.996 10.346 172.511C3.566 169.271 0 166.535 0 163.951V138.075C0 138.075 98.05 116.73 113.879 111.051C129.707 105.372 135.199 105.167 148.669 110.101C162.141 115.037 242.687 129.569 256 134.445L255.994 159.955C255.996 162.513 252.924 165.319 245.97 168.943"
fill="#912626"
/>
<path
d="M245.965 143.22C232.304 150.338 161.534 179.438 146.467 187.292C131.401 195.149 123.031 195.072 111.129 189.382C99.226 183.696 23.915 153.269 10.349 146.788C-3.21698 140.303 -3.50098 135.84 9.82502 130.622C23.151 125.402 98.049 96.017 113.88 90.338C129.708 84.661 135.199 84.454 148.669 89.39C162.14 94.324 232.488 122.325 245.799 127.2C259.115 132.081 259.626 136.1 245.965 143.22"
fill="#C6302B"
/>
<path
d="M245.97 127.074C232.308 134.196 161.536 163.294 146.469 171.152C131.402 179.005 123.032 178.929 111.129 173.239C99.226 167.552 23.913 137.127 10.346 130.642C3.566 127.402 0 124.67 0 122.085V96.206C0 96.206 98.05 74.862 113.879 69.183C129.707 63.504 135.199 63.298 148.669 68.233C162.142 73.168 242.688 87.697 256 92.574L255.994 118.087C255.996 120.644 252.924 123.45 245.97 127.074Z"
fill="#912626"
/>
<path
d="M245.965 101.351C232.304 108.471 161.534 137.569 146.467 145.426C131.401 153.28 123.031 153.203 111.129 147.513C99.226 141.827 23.915 111.401 10.349 104.919C-3.21698 98.436 -3.50098 93.972 9.82502 88.752C23.151 83.535 98.05 54.148 113.88 48.47C129.708 42.792 135.199 42.586 148.669 47.521C162.14 52.455 232.488 80.454 245.799 85.331C259.115 90.211 259.626 94.231 245.965 101.351"
fill="#C6302B"
/>
<path
d="M245.97 83.653C232.308 90.773 161.536 119.873 146.469 127.731C131.402 135.585 123.032 135.508 111.129 129.818C99.226 124.131 23.913 93.705 10.346 87.223C3.566 83.98 0 81.247 0 78.665V52.785C0 52.785 98.05 31.442 113.879 25.764C129.707 20.084 135.199 19.88 148.669 24.814C162.142 29.749 242.688 44.278 256 49.155L255.994 74.667C255.996 77.222 252.924 80.028 245.97 83.653Z"
fill="#912626"
/>
<path
d="M245.965 57.93C232.304 65.05 161.534 94.15 146.467 102.004C131.401 109.858 123.031 109.781 111.129 104.094C99.227 98.404 23.915 67.98 10.35 61.497C-3.21699 55.015 -3.49999 50.55 9.82501 45.331C23.151 40.113 98.05 10.73 113.88 5.04999C129.708 -0.629006 135.199 -0.833006 148.669 4.10199C162.14 9.03699 232.488 37.036 245.799 41.913C259.115 46.789 259.626 50.81 245.965 57.93"
fill="#C6302B"
/>
<path
d="M159.283 32.757L137.273 35.042L132.346 46.898L124.388 33.668L98.9729 31.384L117.937 24.545L112.247 14.047L130.002 20.991L146.74 15.511L142.216 26.366L159.283 32.757V32.757ZM131.032 90.275L89.9549 73.238L148.815 64.203L131.032 90.275V90.275ZM74.0819 39.347C91.4569 39.347 105.542 44.807 105.542 51.541C105.542 58.277 91.4569 63.736 74.0819 63.736C56.7069 63.736 42.6219 58.276 42.6219 51.541C42.6219 44.807 56.7069 39.347 74.0819 39.347"
fill="white"
/>
<path
d="M185.295 35.998L220.131 49.764L185.325 63.517L185.295 35.997"
fill="#621B1C"
/>
<path
d="M146.755 51.243L185.295 35.998L185.325 63.517L181.546 64.995L146.755 51.243Z"
fill="#9A2928"
/>
</svg>

View File

@ -13,6 +13,7 @@ import Budibase from "./Budibase.svelte"
import Oracle from "./Oracle.svelte"
import GoogleSheets from "./GoogleSheets.svelte"
import Firebase from "./Firebase.svelte"
import Redis from "./Redis.svelte"
export default {
BUDIBASE: Budibase,
@ -30,4 +31,5 @@ export default {
ORACLE: Oracle,
GOOGLE_SHEETS: GoogleSheets,
FIREBASE: Firebase,
REDIS: Redis,
}

View File

@ -6,15 +6,10 @@
export let overlayEnabled = true
let imageError = false
let imageLoaded = false
const imageRenderError = () => {
imageError = true
}
const imageLoadSuccess = () => {
imageLoaded = true
}
</script>
<div class="template-card" style="background-color:{backgroundColour};">
@ -23,8 +18,7 @@
alt={name}
src={imageSrc}
on:error={imageRenderError}
on:load={imageLoadSuccess}
class={`${imageLoaded ? "loaded" : ""}`}
class:error={imageError}
/>
<div style={`display:${imageError ? "block" : "none"}`}>
<svg
@ -104,15 +98,14 @@
width: 100%;
}
.template-card img.loaded {
display: block;
}
.template-card img {
display: none;
display: block;
max-width: 100%;
border-radius: var(--border-radius-s) 0px var(--border-radius-s) 0px;
}
.template-card img.error {
display: none;
}
.template-card:hover {
background: var(--spectrum-alias-background-color-tertiary);

View File

@ -51,7 +51,7 @@
}
</script>
<div class="control">
<div class="control" class:disabled>
<Combobox
{label}
{disabled}
@ -121,4 +121,8 @@
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.control:not(.disabled) :global(.spectrum-Textfield-input) {
padding-right: 40px;
}
</style>

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