Merge branch 'feature/licensing' into feature/posthog-v2

This commit is contained in:
Rory Powell 2022-04-12 17:12:46 +01:00
commit 33719fcb09
149 changed files with 3239 additions and 2399 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

@ -24,6 +24,22 @@
{ {
"files": ["*.svelte"], "files": ["*.svelte"],
"processor": "svelte3/svelte3" "processor": "svelte3/svelte3"
},
{
"files": ["**/*.ts"],
"parser": "@typescript-eslint/parser",
"plugins": [],
"extends": [
"eslint:recommended"
],
"rules": {
"no-unused-vars": "off",
"no-inner-declarations": "off",
"no-case-declarations": "off",
"no-useless-escape": "off",
"no-undef": "off",
"no-prototype-builtins": "off"
}
} }
], ],
"rules": { "rules": {

View File

@ -2,6 +2,8 @@ name: Budibase Smoke Test
on: on:
workflow_dispatch: workflow_dispatch:
schedule:
- cron: "0 5 * * *" # every day at 5AM
jobs: jobs:
release: release:
@ -23,10 +25,14 @@ jobs:
-o packages/builder/cypress.env.json \ -o packages/builder/cypress.env.json \
-L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json -L https://api.github.com/repos/budibase/budibase-infra/contents/test/cypress.env.json
wc -l packages/builder/cypress.env.json wc -l packages/builder/cypress.env.json
- run: yarn test:e2e:ci
env: - name: Cypress run
CI: true id: cypress
name: Budibase CI continue-on-error: true
uses: cypress-io/github-action@v2
with:
install: false
command: yarn test:e2e:ci
# TODO: upload recordings to s3 # TODO: upload recordings to s3
# - name: Configure AWS Credentials # - name: Configure AWS Credentials
@ -36,11 +42,11 @@ jobs:
# aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} # aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# aws-region: eu-west-1 # aws-region: eu-west-1
# TODO look at cypress reporters - name: Discord Webhook Action
# - name: Discord Webhook Action uses: tsickert/discord-webhook@v4.0.0
# uses: tsickert/discord-webhook@v4.0.0 with:
# with: webhook-url: ${{ secrets.BUDI_QA_WEBHOOK }}
# webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }} content: "Smoke test run completed with ${{ steps.cypress.outcome }}. See results at ${{ steps.cypress.dashboardUrl }}"
# content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud." embed-title: ${{ steps.cypress.outcome }}
# embed-title: ${{ env.RELEASE_VERSION }} embed-color: ${{ steps.cypress.outcome == 'success' && '3066993' || '15548997' }}

11
.vscode/launch.json vendored
View File

@ -22,9 +22,16 @@
"name": "Budibase Worker", "name": "Budibase Worker",
"type": "node", "type": "node",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/packages/worker/src/index.js", "runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register/transpile-only"
],
"args": [
"${workspaceFolder}/packages/worker/src/index.ts"
],
"cwd": "${workspaceFolder}/packages/worker" "cwd": "${workspaceFolder}/packages/worker"
} },
], ],
"compounds": [ "compounds": [
{ {

View File

@ -15,7 +15,7 @@ version: 0.2.8
appVersion: 1.0.48 appVersion: 1.0.48
dependencies: dependencies:
- name: couchdb - name: couchdb
version: 3.3.4 version: 3.6.1
repository: https://apache.github.io/couchdb-helm repository: https://apache.github.io/couchdb-helm
condition: services.couchdb.enabled condition: services.couchdb.enabled
- name: ingress-nginx - name: ingress-nginx

View File

@ -106,6 +106,10 @@ spec:
value: {{ .Values.globals.cookieDomain | quote }} value: {{ .Values.globals.cookieDomain | quote }}
- name: HTTP_MIGRATIONS - name: HTTP_MIGRATIONS
value: {{ .Values.globals.httpMigrations | quote }} value: {{ .Values.globals.httpMigrations | quote }}
- name: GOOGLE_CLIENT_ID
value: {{ .Values.globals.google.clientId | quote }}
- name: GOOGLE_CLIENT_SECRET
value: {{ .Values.globals.google.secret | quote }}
image: budibase/apps:{{ .Values.globals.appVersion }} image: budibase/apps:{{ .Values.globals.appVersion }}
imagePullPolicy: Always imagePullPolicy: Always
name: bbapps name: bbapps

View File

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

View File

@ -5,7 +5,7 @@ version: "3"
services: services:
minio-service: minio-service:
container_name: budi-minio-dev container_name: budi-minio-dev
restart: always restart: on-failure
image: minio/minio image: minio/minio
volumes: volumes:
- minio_data:/data - minio_data:/data
@ -23,7 +23,7 @@ services:
proxy-service: proxy-service:
container_name: budi-nginx-dev container_name: budi-nginx-dev
restart: always restart: on-failure
image: nginx:latest image: nginx:latest
volumes: volumes:
- ./.generated-nginx.dev.conf:/etc/nginx/nginx.conf - ./.generated-nginx.dev.conf:/etc/nginx/nginx.conf
@ -38,7 +38,7 @@ services:
couchdb-service: couchdb-service:
# platform: linux/amd64 # platform: linux/amd64
container_name: budi-couchdb-dev container_name: budi-couchdb-dev
restart: always restart: on-failure
image: ibmcom/couchdb3 image: ibmcom/couchdb3
environment: environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
@ -59,7 +59,7 @@ services:
redis-service: redis-service:
container_name: budi-redis-dev container_name: budi-redis-dev
restart: always restart: on-failure
image: redis image: redis
command: redis-server --requirepass ${REDIS_PASSWORD} command: redis-server --requirepass ${REDIS_PASSWORD}
ports: ports:

View File

@ -4,7 +4,7 @@ version: "3"
services: services:
app-service: app-service:
restart: always restart: unless-stopped
image: budibase.docker.scarf.sh/budibase/apps image: budibase.docker.scarf.sh/budibase/apps
container_name: bbapps container_name: bbapps
environment: environment:
@ -28,7 +28,7 @@ services:
- redis-service - redis-service
worker-service: worker-service:
restart: always restart: unless-stopped
image: budibase.docker.scarf.sh/budibase/worker image: budibase.docker.scarf.sh/budibase/worker
container_name: bbworker container_name: bbworker
environment: environment:
@ -53,7 +53,7 @@ services:
- couch-init - couch-init
minio-service: minio-service:
restart: always restart: unless-stopped
image: minio/minio image: minio/minio
volumes: volumes:
- minio_data:/data - minio_data:/data
@ -69,7 +69,7 @@ services:
retries: 3 retries: 3
proxy-service: proxy-service:
restart: always restart: unless-stopped
ports: ports:
- "${MAIN_PORT}:10000" - "${MAIN_PORT}:10000"
container_name: bbproxy container_name: bbproxy
@ -81,7 +81,7 @@ services:
- couchdb-service - couchdb-service
couchdb-service: couchdb-service:
restart: always restart: unless-stopped
image: ibmcom/couchdb3 image: ibmcom/couchdb3
environment: environment:
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD} - COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
@ -98,13 +98,14 @@ services:
command: ["sh","-c","sleep 10 && $${PUT_CALL}/_users && $${PUT_CALL}/_replicator; fg;"] command: ["sh","-c","sleep 10 && $${PUT_CALL}/_users && $${PUT_CALL}/_replicator; fg;"]
redis-service: redis-service:
restart: always restart: unless-stopped
image: redis image: redis
command: redis-server --requirepass ${REDIS_PASSWORD} command: redis-server --requirepass ${REDIS_PASSWORD}
volumes: volumes:
- redis_data:/data - redis_data:/data
watchtower-service: watchtower-service:
restart: always
image: containrrr/watchtower image: containrrr/watchtower
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
@ -116,7 +117,6 @@ services:
labels: labels:
- "com.centurylinklabs.watchtower.enable=false" - "com.centurylinklabs.watchtower.enable=false"
volumes: volumes:
couchdb3_data: couchdb3_data:
driver: local driver: local

View File

@ -52,9 +52,8 @@ http {
proxy_pass http://{{ address }}:4001; proxy_pass http://{{ address }}:4001;
} }
location /app/ { location /app {
proxy_pass http://{{ address }}:4001; proxy_pass http://{{ address }}:4001;
rewrite ^/app/(.*)$ /$1 break;
} }
location /builder { location /builder {

View File

@ -22,9 +22,8 @@ http {
resolver {{ resolver }} valid=10s ipv6=off; resolver {{ resolver }} valid=10s ipv6=off;
# buffering # buffering
client_body_buffer_size 1K;
client_header_buffer_size 1k; client_header_buffer_size 1k;
client_max_body_size 10M; client_max_body_size 20M;
ignore_invalid_headers off; ignore_invalid_headers off;
proxy_buffering off; proxy_buffering off;
@ -43,13 +42,25 @@ http {
client_max_body_size 1000m; client_max_body_size 1000m;
ignore_invalid_headers off; ignore_invalid_headers off;
proxy_buffering 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'";
# Security Headers # Security Headers
add_header X-Frame-Options SAMEORIGIN always; add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" 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; 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 # upstreams
set $apps {{ apps }}; set $apps {{ apps }};
@ -62,7 +73,6 @@ http {
location /app { location /app {
proxy_pass http://$apps:4002; proxy_pass http://$apps:4002;
rewrite ^/app/(.*)$ /$1 break;
} }
location = / { location = / {

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.91-alpha.17", "version": "1.0.105-alpha.10",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -15,7 +15,9 @@
"prettier-plugin-svelte": "^2.3.0", "prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0", "rollup-plugin-replace": "^2.2.0",
"svelte": "^3.38.2" "svelte": "^3.38.2",
"@typescript-eslint/parser": "4.28.0",
"typescript": "4.5.5"
}, },
"scripts": { "scripts": {
"setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev", "setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev",
@ -41,8 +43,7 @@
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
"lint:fix:eslint": "eslint --fix packages", "lint:fix:eslint": "eslint --fix packages",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"",
"lint:fix:ts": "lerna run lint:fix", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"test:e2e": "lerna run cy:test --stream", "test:e2e": "lerna run cy:test --stream",
"test:e2e:ci": "lerna run cy:ci --stream", "test:e2e:ci": "lerna run cy:ci --stream",
"build:specs": "lerna run specs", "build:specs": "lerna run specs",
@ -55,6 +56,8 @@
"build:docker:develop": "node scripts/pinVersions && lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:develop": "node scripts/pinVersions && lerna run build:docker && npm run build:docker:proxy:compose && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",
"build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single": "lerna run build && lerna run predocker && npm run build:docker:single:image",
"build:docs": "lerna run build:docs", "build:docs": "lerna run build:docs",
"release:helm": "node scripts/releaseHelmChart", "release:helm": "node scripts/releaseHelmChart",
"env:multi:enable": "lerna run env:multi:enable", "env:multi:enable": "lerna run env:multi:enable",

View File

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

View File

@ -1,5 +1,6 @@
const env = require("../environment") const env = require("../environment")
const { Headers } = require("../../constants") const { Headers } = require("../../constants")
const { SEPARATOR, DocumentTypes } = require("../db/constants")
const cls = require("./FunctionContext") const cls = require("./FunctionContext")
const { getDB } = require("../db") const { getDB } = require("../db")
const { getProdAppID, getDevelopmentAppID } = require("../db/conversions") const { getProdAppID, getDevelopmentAppID } = require("../db/conversions")
@ -42,8 +43,39 @@ exports.doInTenant = (tenantId, task) => {
}) })
} }
/**
* Given an app ID this will attempt to retrieve the tenant ID from it.
* @return {null|string} The tenant ID found within the app ID.
*/
exports.getTenantIDFromAppID = appId => {
if (!appId) {
return null
}
const split = appId.split(SEPARATOR)
const hasDev = split[1] === DocumentTypes.DEV
if ((hasDev && split.length === 3) || (!hasDev && split.length === 2)) {
return null
}
if (hasDev) {
return split[2]
} else {
return split[1]
}
}
const setAppTenantId = appId => {
const appTenantId = this.getTenantIDFromAppID(appId) || this.DEFAULT_TENANT_ID
this.updateTenantId(appTenantId)
}
exports.doInAppContext = (appId, task) => { exports.doInAppContext = (appId, task) => {
if (!appId) {
throw new Error("appId is required")
}
return cls.run(() => { return cls.run(() => {
// set the app tenant id
setAppTenantId(appId)
// set the app ID // set the app ID
cls.setOnContext(ContextKeys.APP_ID, appId) cls.setOnContext(ContextKeys.APP_ID, appId)

View File

@ -9,11 +9,7 @@ const {
APP_PREFIX, APP_PREFIX,
APP_DEV, APP_DEV,
} = require("./constants") } = require("./constants")
const { const { getTenantId, getGlobalDBName } = require("../tenancy")
getTenantId,
getTenantIDFromAppID,
getGlobalDBName,
} = require("../tenancy")
const fetch = require("node-fetch") const fetch = require("node-fetch")
const { getDB, allDbs } = require("./index") const { getDB, allDbs } = require("./index")
const { getCouchUrl } = require("./pouch") const { getCouchUrl } = require("./pouch")
@ -41,7 +37,6 @@ exports.DocumentTypes = DocumentTypes
exports.APP_PREFIX = APP_PREFIX exports.APP_PREFIX = APP_PREFIX
exports.APP_DEV = exports.APP_DEV_PREFIX = APP_DEV exports.APP_DEV = exports.APP_DEV_PREFIX = APP_DEV
exports.SEPARATOR = SEPARATOR exports.SEPARATOR = SEPARATOR
exports.getTenantIDFromAppID = getTenantIDFromAppID
exports.isDevApp = isDevApp exports.isDevApp = isDevApp
exports.isProdAppID = isProdAppID exports.isProdAppID = isProdAppID
exports.isDevAppID = isDevAppID exports.isDevAppID = isDevAppID

View File

@ -1,27 +0,0 @@
const {
isMultiTenant,
updateTenantId,
isTenantIdSet,
DEFAULT_TENANT_ID,
updateAppId,
} = require("../tenancy")
const ContextFactory = require("../context/FunctionContext")
const { getTenantIDFromAppID } = require("../db/utils")
module.exports = () => {
return ContextFactory.getMiddleware(ctx => {
// if not in multi-tenancy mode make sure its default and exit
if (!isMultiTenant()) {
updateTenantId(DEFAULT_TENANT_ID)
return
}
// if tenant ID already set no need to continue
if (isTenantIdSet()) {
return
}
const appId = ctx.appId ? ctx.appId : ctx.user ? ctx.user.appId : null
const tenantId = getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID
updateTenantId(tenantId)
updateAppId(appId)
})
}

View File

@ -6,7 +6,6 @@ const { authError } = require("./passport/utils")
const authenticated = require("./authenticated") const authenticated = require("./authenticated")
const auditLog = require("./auditLog") const auditLog = require("./auditLog")
const tenancy = require("./tenancy") const tenancy = require("./tenancy")
const appTenancy = require("./appTenancy")
const internalApi = require("./internalApi") const internalApi = require("./internalApi")
const datasourceGoogle = require("./passport/datasource/google") const datasourceGoogle = require("./passport/datasource/google")
const csrf = require("./csrf") const csrf = require("./csrf")
@ -19,7 +18,6 @@ module.exports = {
authenticated, authenticated,
auditLog, auditLog,
tenancy, tenancy,
appTenancy,
authError, authError,
internalApi, internalApi,
datasource: { datasource: {

View File

@ -1,15 +1,29 @@
const google = require("../google") const google = require("../google")
const { Cookies } = require("../../../constants") const { Cookies, Configs } = require("../../../constants")
const { clearCookie, getCookie } = require("../../../utils") const { clearCookie, getCookie } = require("../../../utils")
const { getDB } = require("../../../db") const { getDB } = require("../../../db")
const { getScopedConfig } = require("../../../db/utils")
const environment = require("../../../environment") const environment = require("../../../environment")
const { getGlobalDB } = require("../../../tenancy")
async function preAuth(passport, ctx, next) { async function fetchGoogleCreds() {
// get the relevant config // try and get the config from the tenant
const googleConfig = { const db = getGlobalDB()
const googleConfig = await getScopedConfig(db, {
type: Configs.GOOGLE,
})
// or fall back to env variables
const config = googleConfig || {
clientID: environment.GOOGLE_CLIENT_ID, clientID: environment.GOOGLE_CLIENT_ID,
clientSecret: environment.GOOGLE_CLIENT_SECRET, clientSecret: environment.GOOGLE_CLIENT_SECRET,
} }
return config
}
async function preAuth(passport, ctx, next) {
// get the relevant config
const googleConfig = await fetchGoogleCreds()
let callbackUrl = `${environment.PLATFORM_URL}/api/global/auth/datasource/google/callback` let callbackUrl = `${environment.PLATFORM_URL}/api/global/auth/datasource/google/callback`
const strategy = await google.strategyFactory(googleConfig, callbackUrl) const strategy = await google.strategyFactory(googleConfig, callbackUrl)
@ -26,10 +40,7 @@ async function preAuth(passport, ctx, next) {
async function postAuth(passport, ctx, next) { async function postAuth(passport, ctx, next) {
// get the relevant config // get the relevant config
const config = { const config = await fetchGoogleCreds()
clientID: environment.GOOGLE_CLIENT_ID,
clientSecret: environment.GOOGLE_CLIENT_SECRET,
}
let callbackUrl = `${environment.PLATFORM_URL}/api/global/auth/datasource/google/callback` let callbackUrl = `${environment.PLATFORM_URL}/api/global/auth/datasource/google/callback`
const strategy = await google.strategyFactory( const strategy = await google.strategyFactory(

View File

@ -51,7 +51,10 @@ exports.strategyFactory = async function (config, callbackUrl, saveUserFn) {
) )
} catch (err) { } catch (err) {
console.error(err) console.error(err)
throw new Error("Error constructing google authentication strategy", err) throw new Error(
`Error constructing google authentication strategy: ${err}`,
err
)
} }
} }
// expose for testing // expose for testing

View File

@ -1,5 +1,5 @@
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { BUILTIN_PERMISSION_IDS } = require("./permissions") const { BUILTIN_PERMISSION_IDS, PermissionLevels } = require("./permissions")
const { const {
generateRoleID, generateRoleID,
getRoleParams, getRoleParams,
@ -180,6 +180,20 @@ exports.getUserRoleHierarchy = async (userRoleId, opts = { idOnly: true }) => {
return opts.idOnly ? roles.map(role => role._id) : roles return opts.idOnly ? roles.map(role => role._id) : roles
} }
// this function checks that the provided permissions are in an array format
// some templates/older apps will use a simple string instead of array for roles
// convert the string to an array using the theory that write is higher than read
exports.checkForRoleResourceArray = (rolePerms, resourceId) => {
if (rolePerms && !Array.isArray(rolePerms[resourceId])) {
const permLevel = rolePerms[resourceId]
rolePerms[resourceId] = [permLevel]
if (permLevel === PermissionLevels.WRITE) {
rolePerms[resourceId].push(PermissionLevels.READ)
}
}
return rolePerms
}
/** /**
* Given an app ID this will retrieve all of the roles that are currently within that app. * Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found. * @return {Promise<object[]>} An array of the role objects that were found.
@ -209,15 +223,27 @@ exports.getAllRoles = async appId => {
roles.push(Object.assign(builtinRole, dbBuiltin)) roles.push(Object.assign(builtinRole, dbBuiltin))
} }
} }
// check permissions
for (let role of roles) {
if (!role.permissions) {
continue
}
for (let resourceId of Object.keys(role.permissions)) {
role.permissions = exports.checkForRoleResourceArray(
role.permissions,
resourceId
)
}
}
return roles return roles
} }
/** /**
* This retrieves the required role * This retrieves the required role for a resource
* @param permLevel * @param permLevel The level of request
* @param resourceId * @param resourceId The resource being requested
* @param subResourceId * @param subResourceId The sub resource being requested
* @return {Promise<{permissions}|Object>} * @return {Promise<{permissions}|Object>} returns the permissions required to access.
*/ */
exports.getRequiredResourceRole = async ( exports.getRequiredResourceRole = async (
permLevel, permLevel,

View File

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

View File

@ -1,6 +1,11 @@
const { getDB } = require("../db") const { getDB } = require("../db")
const { SEPARATOR, StaticDatabases, DocumentTypes } = require("../db/constants") const { SEPARATOR, StaticDatabases } = require("../db/constants")
const { getTenantId, DEFAULT_TENANT_ID, isMultiTenant } = require("../context") const {
getTenantId,
DEFAULT_TENANT_ID,
isMultiTenant,
getTenantIDFromAppID,
} = require("../context")
const env = require("../environment") const env = require("../environment")
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
@ -118,26 +123,6 @@ exports.getTenantUser = async identifier => {
} }
} }
/**
* Given an app ID this will attempt to retrieve the tenant ID from it.
* @return {null|string} The tenant ID found within the app ID.
*/
exports.getTenantIDFromAppID = appId => {
if (!appId) {
return null
}
const split = appId.split(SEPARATOR)
const hasDev = split[1] === DocumentTypes.DEV
if ((hasDev && split.length === 3) || (!hasDev && split.length === 2)) {
return null
}
if (hasDev) {
return split[2]
} else {
return split[1]
}
}
exports.isUserInAppTenant = (appId, user = null) => { exports.isUserInAppTenant = (appId, user = null) => {
let userTenantId let userTenantId
if (user) { if (user) {
@ -145,7 +130,7 @@ exports.isUserInAppTenant = (appId, user = null) => {
} else { } else {
userTenantId = getTenantId() userTenantId = getTenantId()
} }
const tenantId = exports.getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID const tenantId = getTenantIDFromAppID(appId) || DEFAULT_TENANT_ID
return tenantId === userTenantId return tenantId === userTenantId
} }

View File

@ -1,4 +1,9 @@
const { DocumentTypes, SEPARATOR, ViewNames } = require("./db/utils") const {
DocumentTypes,
SEPARATOR,
ViewNames,
getAllApps,
} = require("./db/utils")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const { options } = require("./middleware/passport/jwt") const { options } = require("./middleware/passport/jwt")
const { queryGlobalView } = require("./db/views") const { queryGlobalView } = require("./db/views")
@ -7,8 +12,10 @@ const env = require("./environment")
const userCache = require("./cache/user") const userCache = require("./cache/user")
const { getUserSessions, invalidateSessions } = require("./security/sessions") const { getUserSessions, invalidateSessions } = require("./security/sessions")
const events = require("./events") const events = require("./events")
const tenancy = require("./tenancy")
const APP_PREFIX = DocumentTypes.APP + SEPARATOR const APP_PREFIX = DocumentTypes.APP + SEPARATOR
const PROD_APP_PREFIX = "/app/"
function confirmAppId(possibleAppId) { function confirmAppId(possibleAppId) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX) return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
@ -16,16 +23,35 @@ function confirmAppId(possibleAppId) {
: undefined : undefined
} }
async function resolveAppUrl(ctx) {
const appUrl = ctx.path.split("/")[2]
let possibleAppUrl = `/${appUrl.toLowerCase()}`
let tenantId = tenancy.getTenantId()
if (!env.SELF_HOSTED && ctx.subdomains.length) {
// always use the tenant id from the url in cloud
tenantId = ctx.subdomains[0]
}
// search prod apps for a url that matches
const apps = await tenancy.doInTenant(tenantId, () =>
getAllApps({ dev: false })
)
const app = apps.filter(
a => a.url && a.url.toLowerCase() === possibleAppUrl
)[0]
return app && app.appId ? app.appId : undefined
}
/** /**
* Given a request tries to find the appId, which can be located in various places * Given a request tries to find the appId, which can be located in various places
* @param {object} ctx The main request body to look through. * @param {object} ctx The main request body to look through.
* @returns {string|undefined} If an appId was found it will be returned. * @returns {string|undefined} If an appId was found it will be returned.
*/ */
exports.getAppId = ctx => { exports.getAppIdFromCtx = async ctx => {
const options = [ctx.headers[Headers.APP_ID], ctx.params.appId] // look in headers
if (ctx.subdomains) { const options = [ctx.headers[Headers.APP_ID]]
options.push(ctx.subdomains[1])
}
let appId let appId
for (let option of options) { for (let option of options) {
appId = confirmAppId(option) appId = confirmAppId(option)
@ -34,16 +60,24 @@ exports.getAppId = ctx => {
} }
} }
// look in body if can't find it in subdomain // look in body
if (!appId && ctx.request.body && ctx.request.body.appId) { if (!appId && ctx.request.body && ctx.request.body.appId) {
appId = confirmAppId(ctx.request.body.appId) appId = confirmAppId(ctx.request.body.appId)
} }
// look in the url - dev app
let appPath = let appPath =
ctx.request.headers.referrer || ctx.request.headers.referrer ||
ctx.path.split("/").filter(subPath => subPath.startsWith(APP_PREFIX)) ctx.path.split("/").filter(subPath => subPath.startsWith(APP_PREFIX))
if (!appId && appPath.length !== 0) { if (!appId && appPath.length) {
appId = confirmAppId(appPath[0]) appId = confirmAppId(appPath[0])
} }
// look in the url - prod app
if (!appId && ctx.path.startsWith(PROD_APP_PREFIX)) {
appId = confirmAppId(await resolveAppUrl(ctx))
}
return appId return appId
} }

View File

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

View File

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

View File

@ -13,6 +13,7 @@
export let icon = undefined export let icon = undefined
export let active = false export let active = false
export let tooltip = undefined export let tooltip = undefined
export let dataCy
let showTooltip = false let showTooltip = false
</script> </script>
@ -27,6 +28,7 @@
class:active class:active
class="spectrum-Button spectrum-Button--size{size.toUpperCase()}" class="spectrum-Button spectrum-Button--size{size.toUpperCase()}"
{disabled} {disabled}
data-cy={dataCy}
on:click|preventDefault on:click|preventDefault
on:mouseover={() => (showTooltip = true)} on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)} on:focus={() => (showTooltip = true)}

View File

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

View File

@ -5,6 +5,7 @@
import Divider from "../Divider/Divider.svelte" import Divider from "../Divider/Divider.svelte"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import Context from "../context" import Context from "../context"
import ProgressCircle from "../ProgressCircle/ProgressCircle.svelte"
export let title = undefined export let title = undefined
export let size = "S" export let size = "S"
@ -102,6 +103,7 @@
<Button group secondary on:click={close}>{cancelText}</Button> <Button group secondary on:click={close}>{cancelText}</Button>
{/if} {/if}
{#if showConfirmButton} {#if showConfirmButton}
<span class="confirm-wrap">
<Button <Button
group group
cta cta
@ -109,8 +111,14 @@
disabled={confirmDisabled} disabled={confirmDisabled}
on:click={confirm} on:click={confirm}
> >
{#if loading}
<ProgressCircle overBackground={true} size="S" />
{/if}
{#if !loading}
{confirmText} {confirmText}
{/if}
</Button> </Button>
</span>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -169,4 +177,8 @@
.spectrum-Dialog-buttonGroup { .spectrum-Dialog-buttonGroup {
padding-left: 0; padding-left: 0;
} }
.confirm-wrap :global(.spectrum-Button-label) {
display: contents;
}
</style> </style>

View File

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

View File

@ -8,7 +8,7 @@ filterTests(['all'], () => {
it("should change the icon and colour for an application", () => { it("should change the icon and colour for an application", () => {
// Search for test application // Search for test application
cy.searchForApplication("Cypress Tests") cy.applicationInAppTable("Cypress Tests")
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get(".spectrum-Icon").eq(1).click()

View File

@ -2,11 +2,164 @@ import filterTests from '../support/filterTests'
filterTests(['smoke', 'all'], () => { filterTests(['smoke', 'all'], () => {
context("Create an Application", () => { context("Create an Application", () => {
it("should create a new application", () => {
before(() => {
cy.login() cy.login()
cy.createTestApp() cy.deleteApp("Cypress Tests")
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.contains("Cypress Tests").should("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)
if (Cypress.env("TEST_ENV")) {
cy.get(".spectrum-Button").contains("Templates").click({force: true})
}
cy.get(".template-category-filters").should("exist")
cy.get(".template-categories").should("exist")
cy.get(".template-category").its('length').should('be.gt', 1)
cy.get(".template-category-filters .spectrum-ActionButton").its('length').should('be.gt', 2)
cy.get(".template-category-filters .spectrum-ActionButton").eq(1).click()
cy.get(".template-category").should('have.length', 1)
cy.get(".template-category-filters .spectrum-ActionButton").eq(0).click()
cy.get(".template-category").its('length').should('be.gt', 1)
})
it("should enforce a valid url before submission", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
// 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", "/cypress-tests")
cy.get(".spectrum-ButtonGroup").contains("Create app").should('not.be.disabled')
//Empty the app url - disabled create
cy.get("input").eq(1).clear().blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").should('be.disabled')
//Invalid url
cy.get("input").eq(1).type("/new app-url").blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").should('be.disabled')
//Specifically alter the url
cy.get("input").eq(1).clear()
cy.get("input").eq(1).type("another-app-name").blur()
cy.get("input").eq(1).should("have.value", "/another-app-name")
cy.get("input").eq(0).should("have.value", appName)
cy.get(".spectrum-ButtonGroup").contains("Create app").should('not.be.disabled')
})
})
it("should create the first application from scratch", () => {
const appName = "Cypress Tests"
cy.createApp(appName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.applicationInAppTable(appName)
cy.deleteApp(appName)
})
it("should generate the first application from a template", () => {
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 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")
cardOverlay.invoke("show")
cardOverlay.get("button").contains("Use template").should("exist").click({force: true})
})
// CMD Create app from theme card
cy.get(".spectrum-Modal").should('be.visible')
const templateName = cy.get(".spectrum-Modal .template-thumbnail-text")
templateName.invoke('text')
.then(templateNameText => {
const templateNameParsed = "/"+templateNameText.toLowerCase().replace(/\s+/g, "-")
cy.get(".spectrum-Modal input").eq(0).should("have.value", templateNameText)
cy.get(".spectrum-Modal input").eq(1).should("have.value", templateNameParsed)
cy.get(".spectrum-Modal .spectrum-ButtonGroup").contains("Create app").click()
cy.wait(5000)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.applicationInAppTable(templateNameText)
cy.deleteApp(templateNameText)
});
})
it("should display a second application and app filtering", () => {
// Create first app
const appName = "Cypress Tests"
cy.createApp(appName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
// Create second app
const secondAppName = "Second App Demo"
cy.createApp(secondAppName)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
//Both applications should exist and be searchable
cy.searchForApplication(appName)
cy.searchForApplication(secondAppName)
cy.deleteApp(secondAppName)
})
}) })
}) })

View File

@ -7,6 +7,8 @@ filterTests(["smoke", "all"], () => {
}) })
it("should create a user", () => { it("should create a user", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.createUser("bbuser@test.com") cy.createUser("bbuser@test.com")
cy.get(".spectrum-Table").should("contain", "bbuser") cy.get(".spectrum-Table").should("contain", "bbuser")
}) })
@ -21,6 +23,7 @@ filterTests(["smoke", "all"], () => {
cy.get(".spectrum-Table").eq(0).contains("No rows found") cy.get(".spectrum-Table").eq(0).contains("No rows found")
}) })
if (Cypress.env("TEST_ENV")) {
it("should assign role types", () => { it("should assign role types", () => {
// 3 apps minimum required - to assign an app to each role type // 3 apps minimum required - to assign an app to each role type
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`) cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
@ -30,7 +33,14 @@ filterTests(["smoke", "all"], () => {
for (let i = 1; i < 3; i++) { for (let i = 1; i < 3; i++) {
const uuid = () => Cypress._.random(0, 1e6) const uuid = () => Cypress._.random(0, 1e6)
const name = uuid() const name = uuid()
if(i < 1){
cy.createApp(name) cy.createApp(name)
} else {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
cy.get(`[data-cy="create-app-btn"]`).click({ force: true })
cy.createAppFromScratch(name)
}
} }
} }
}) })
@ -110,6 +120,7 @@ filterTests(["smoke", "all"], () => {
// Confirm Configure roles table no longer has any apps in it // Confirm Configure roles table no longer has any apps in it
cy.get(".spectrum-Table").eq(0).contains("No rows found") cy.get(".spectrum-Table").eq(0).contains("No rows found")
}) })
}
it("should enable Developer access", () => { it("should enable Developer access", () => {
// Enable Developer access // Enable Developer access

View File

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

View File

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

View File

@ -11,12 +11,14 @@ filterTests(['all'], () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
const appRename = "Cypress Renamed" const appRename = "Cypress Renamed"
// Rename app, Search for app, Confirm name was changed // 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) renameApp(appName, appRename)
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
cy.searchForApplication(appRename) cy.searchForApplication(appRename)
cy.get(".appTable").find(".title").should("have.length", 1) cy.get(".appTable").find(".title").should("have.length", 1)
cy.applicationInAppTable(appRename)
// Set app name back to Cypress Tests // Set app name back to Cypress Tests
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
@ -29,31 +31,31 @@ filterTests(['all'], () => {
const appRename = "Cypress Renamed" const appRename = "Cypress Renamed"
// Publish the app // Publish the app
cy.get(".toprightnav") cy.get(".toprightnav")
cy.get(".spectrum-Button").contains("Publish").click({force: true}) cy.get(".spectrum-Button").contains("Publish").click({ force: true })
cy.get(".spectrum-Dialog-grid") cy.get(".spectrum-Dialog-grid")
.within(() => { .within(() => {
// Click publish again within the modal // Click publish again within the modal
cy.get(".spectrum-Button").contains("Publish").click({force: true}) cy.get(".spectrum-Button").contains("Publish").click({ force: true })
}) })
// Rename app, Search for app, Confirm name was changed // 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) renameApp(appName, appRename, true)
cy.searchForApplication(appRename)
cy.get(".appTable").find(".wrapper").should("have.length", 1) cy.get(".appTable").find(".wrapper").should("have.length", 1)
cy.applicationInAppTable(appRename)
}) })
it("Should try to rename an application to have no name", () => { it("Should try to rename an application to have no name", () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.get(".home-logo").click() cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
renameApp(appName, " ", false, true) renameApp(appName, " ", false, true)
cy.wait(500) cy.wait(500)
// Close modal and confirm name has not been changed // Close modal and confirm name has not been changed
cy.get(".spectrum-Dialog-grid").contains("Cancel").click() cy.get(".spectrum-Dialog-grid").contains("Cancel").click()
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
cy.searchForApplication(appName) cy.applicationInAppTable(appName)
cy.get(".appTable").find(".title").should("have.length", 1)
}) })
xit("Should create two applications with the same name", () => { xit("Should create two applications with the same name", () => {
@ -61,12 +63,12 @@ filterTests(['all'], () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.visit(`${Cypress.config().baseUrl}/builder`) cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500) cy.wait(500)
cy.get(".spectrum-Button").contains("Create app").click({force: true}) cy.get(".spectrum-Button").contains("Create app").click({ force: true })
cy.contains(/Start from scratch/).click() cy.contains(/Start from scratch/).click()
cy.get(".spectrum-Modal") cy.get(".spectrum-Modal")
.within(() => { .within(() => {
cy.get("input").eq(0).type(appName) cy.get("input").eq(0).type(appName)
cy.get(".spectrum-ButtonGroup").contains("Create app").click({force: true}) cy.get(".spectrum-ButtonGroup").contains("Create app").click({ force: true })
cy.get(".error").should("have.text", "Another app with the same name already exists") cy.get(".error").should("have.text", "Another app with the same name already exists")
}) })
}) })
@ -77,12 +79,12 @@ filterTests(['all'], () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
const numberName = 12345 const numberName = 12345
const specialCharName = "£$%^" const specialCharName = "£$%^"
cy.get(".home-logo").click() cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500)
renameApp(appName, numberName) renameApp(appName, numberName)
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
cy.searchForApplication(numberName) cy.applicationInAppTable(numberName)
cy.get(".appTable").find(".title").should("have.length", 1)
cy.reload() cy.reload()
cy.wait(1000) cy.wait(1000)
renameApp(numberName, specialCharName) renameApp(numberName, specialCharName)
@ -95,16 +97,12 @@ filterTests(['all'], () => {
const renameApp = (originalName, changedName, published, noName) => { const renameApp = (originalName, changedName, published, noName) => {
cy.searchForApplication(originalName) cy.searchForApplication(originalName)
cy.request(`${Cypress.config().baseUrl}/api/applications?status=all`)
.its("body")
.then(val => {
if (val.length > 0) {
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get(".spectrum-Icon").eq(1).click()
}) })
// Check for when an app is published // Check for when an app is published
if (published == true){ if (published == true) {
// Should not have Edit as option, will unpublish app // Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit") cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click() cy.get(".spectrum-Menu").contains("Unpublish").click()
@ -114,7 +112,7 @@ filterTests(['all'], () => {
cy.contains("Edit").click() cy.contains("Edit").click()
cy.get(".spectrum-Modal") cy.get(".spectrum-Modal")
.within(() => { .within(() => {
if (noName == true){ if (noName == true) {
cy.get("input").clear() cy.get("input").clear()
cy.get(".spectrum-Dialog-grid").click() cy.get(".spectrum-Dialog-grid").click()
.contains("App name must be letters, numbers and spaces only") .contains("App name must be letters, numbers and spaces only")
@ -122,12 +120,9 @@ filterTests(['all'], () => {
} }
cy.get("input").clear() cy.get("input").clear()
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur() cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur()
cy.get(".spectrum-ButtonGroup").contains("Save").click({force: true}) cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true })
cy.wait(500) cy.wait(500)
}) })
} }
}) })
}
})
}) })

View File

@ -35,7 +35,17 @@ Cypress.Commands.add("login", () => {
Cypress.Commands.add("createApp", name => { Cypress.Commands.add("createApp", name => {
cy.visit(`${Cypress.config().baseUrl}/builder`) cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(500) cy.wait(500)
cy.get(".spectrum-Button").contains("Create app").click({ force: true }) 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(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(name).should("have.value", name).blur() cy.get("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click() cy.get(".spectrum-ButtonGroup").contains("Create app").click()
@ -51,10 +61,30 @@ Cypress.Commands.add("deleteApp", name => {
.its("body") .its("body")
.then(val => { .then(val => {
if (val.length > 0) { if (val.length > 0) {
if (Cypress.env("TEST_ENV")) {
cy.searchForApplication(name) cy.searchForApplication(name)
cy.get(".appTable").within(() => { cy.get(".appTable").within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get(".spectrum-Icon").eq(1).click()
}) })
} else {
const appId = val.reduce((acc, app) => {
if (name === app.name) {
acc = app.appId
}
return acc
}, "")
if (appId == "") {
return
}
const appIdParsed = appId.split("_").pop()
const actionEleId = `[data-cy=row_actions_${appIdParsed}]`
cy.get(actionEleId).within(() => {
cy.get(".spectrum-Icon").eq(0).click()
})
}
cy.get(".spectrum-Menu").then($menu => { cy.get(".spectrum-Menu").then($menu => {
if ($menu.text().includes("Unpublish")) { if ($menu.text().includes("Unpublish")) {
cy.get(".spectrum-Menu").contains("Unpublish").click() cy.get(".spectrum-Menu").contains("Unpublish").click()
@ -80,22 +110,18 @@ Cypress.Commands.add("deleteAllApps", () => {
.its("body") .its("body")
.then(val => { .then(val => {
for (let i = 0; i < val.length; i++) { for (let i = 0; i < val.length; i++) {
cy.get(".spectrum-Heading") const appIdParsed = val[i].appId.split("_").pop()
.eq(1) const actionEleId = `[data-cy=row_actions_${appIdParsed}]`
.then(app => { cy.get(actionEleId).within(() => {
const name = app.text()
cy.get(".title")
.children()
.within(() => {
cy.get(".spectrum-Icon").eq(0).click() cy.get(".spectrum-Icon").eq(0).click()
}) })
cy.get(".spectrum-Menu").contains("Delete").click() cy.get(".spectrum-Menu").contains("Delete").click()
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
cy.get("input").type(name) cy.get("input").type(val[i].name)
cy.get(".spectrum-Button--warning").click() cy.get(".spectrum-Button--warning").click()
}) })
cy.reload() cy.reload()
})
} }
}) })
}) })
@ -190,9 +216,11 @@ Cypress.Commands.add("addRowMultiValue", values => {
Cypress.Commands.add("createUser", email => { Cypress.Commands.add("createUser", email => {
// quick hacky recorded way to create a user // quick hacky recorded way to create a user
cy.contains("Users").click() cy.contains("Users").click()
cy.get(".spectrum-Button--primary").click() cy.get(`[data-cy="add-user"]`).click()
cy.get(".spectrum-Picker-label").click() cy.get(".spectrum-Picker-label").click()
cy.get(".spectrum-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel").click() cy.get(".spectrum-Menu-item:nth-child(2) > .spectrum-Menu-itemLabel").click()
//Onboarding type selector
cy.get( cy.get(
":nth-child(2) > .spectrum-Form-itemField > .spectrum-Textfield > .spectrum-Textfield-input" ":nth-child(2) > .spectrum-Form-itemField > .spectrum-Textfield > .spectrum-Textfield-input"
) )
@ -312,16 +340,46 @@ Cypress.Commands.add("addCustomSourceOptions", totalOptions => {
}) })
}) })
//Filters visible with 1 or more
Cypress.Commands.add("searchForApplication", appName => { Cypress.Commands.add("searchForApplication", appName => {
cy.wait(1000) 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 // Searches for the app
cy.get(".filter").then(() => { cy.get(".filter").then(() => {
cy.get(".spectrum-Textfield").within(() => { cy.get(".spectrum-Textfield").within(() => {
cy.get("input").eq(0).clear()
cy.get("input").eq(0).type(appName) cy.get("input").eq(0).type(appName)
}) })
}) })
// Confirms app exists after search }
cy.get(".appTable").contains(appName) })
})
//Assumes there are no others
Cypress.Commands.add("applicationInAppTable", appName => {
cy.get(".appTable").within(() => {
cy.get(".title").contains(appName).should("exist")
})
})
Cypress.Commands.add("createAppFromScratch", appName => {
cy.get(`[data-cy="create-app-btn"]`)
.contains("Start from scratch")
.click({ force: true })
cy.get(".spectrum-Modal").within(() => {
cy.get("input").eq(0).type(appName).should("have.value", appName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click()
cy.wait(10000)
})
cy.createTable("Cypress Tests", true)
}) })
Cypress.Commands.add("selectExternalDatasource", datasourceName => { Cypress.Commands.add("selectExternalDatasource", datasourceName => {

View File

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

View File

@ -7,7 +7,11 @@ import {
getComponentSettings, getComponentSettings,
} from "./componentUtils" } from "./componentUtils"
import { store } from "builderStore" import { store } from "builderStore"
import { queries as queriesStores, tables as tablesStore } from "stores/backend" import {
queries as queriesStores,
tables as tablesStore,
roles as rolesStore,
} from "stores/backend"
import { import {
makePropSafe, makePropSafe,
isJSBinding, isJSBinding,
@ -33,6 +37,7 @@ export const getBindableProperties = (asset, componentId) => {
const deviceBindings = getDeviceBindings() const deviceBindings = getDeviceBindings()
const stateBindings = getStateBindings() const stateBindings = getStateBindings()
const selectedRowsBindings = getSelectedRowsBindings(asset) const selectedRowsBindings = getSelectedRowsBindings(asset)
const roleBindings = getRoleBindings()
return [ return [
...contextBindings, ...contextBindings,
...urlBindings, ...urlBindings,
@ -40,6 +45,7 @@ export const getBindableProperties = (asset, componentId) => {
...userBindings, ...userBindings,
...deviceBindings, ...deviceBindings,
...selectedRowsBindings, ...selectedRowsBindings,
...roleBindings,
] ]
} }
@ -391,20 +397,57 @@ const getUrlBindings = asset => {
})) }))
} }
const getRoleBindings = () => {
return (get(rolesStore) || []).map(role => {
return {
type: "context",
runtimeBinding: `trim "${role._id}"`,
readableBinding: `Role.${role.name}`,
}
})
}
/** /**
* Gets all bindable properties exposed in a button actions flow up until * Gets all bindable properties exposed in a button actions flow up until
* the specified action ID. * the specified action ID, as well as context provided for the action
* setting as a whole by the component.
*/ */
export const getButtonContextBindings = (actions, actionId) => { export const getButtonContextBindings = (
asset,
componentId,
settingKey,
actions,
actionId
) => {
let bindings = []
// Check if any context bindings are provided by the component for this
// setting
const component = findComponent(asset.props, componentId)
const settings = getComponentSettings(component?._component)
const eventSetting = settings.find(setting => setting.key === settingKey)
if (!eventSetting) {
return bindings
}
if (eventSetting.context?.length) {
eventSetting.context.forEach(contextEntry => {
bindings.push({
readableBinding: contextEntry.label,
runtimeBinding: `${makePropSafe("eventContext")}.${makePropSafe(
contextEntry.key
)}`,
})
})
}
// Get the steps leading up to this value // Get the steps leading up to this value
const index = actions?.findIndex(action => action.id === actionId) const index = actions?.findIndex(action => action.id === actionId)
if (index == null || index === -1) { if (index == null || index === -1) {
return [] return bindings
} }
const prevActions = actions.slice(0, index) const prevActions = actions.slice(0, index)
// Generate bindings for any steps which provide context // Generate bindings for any steps which provide context
let bindings = []
prevActions.forEach((action, idx) => { prevActions.forEach((action, idx) => {
const def = ActionDefinitions.actions.find( const def = ActionDefinitions.actions.find(
x => x.name === action["##eventHandlerType"] x => x.name === action["##eventHandlerType"]
@ -418,6 +461,7 @@ export const getButtonContextBindings = (actions, actionId) => {
}) })
} }
}) })
return bindings return bindings
} }

View File

@ -45,6 +45,7 @@ const INITIAL_FRONTEND_STATE = {
customThemes: false, customThemes: false,
devicePreview: false, devicePreview: false,
messagePassing: false, messagePassing: false,
continueIfAction: false,
}, },
currentFrontEndType: "none", currentFrontEndType: "none",
selectedScreenId: "", selectedScreenId: "",

View File

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

View File

@ -2,9 +2,10 @@ import { createLocalStorageStore } from "@budibase/frontend-core"
export const getThemeStore = () => { export const getThemeStore = () => {
const themeElement = document.documentElement const themeElement = document.documentElement
const initialValue = { const initialValue = {
theme: "darkest", theme: "darkest",
options: ["lightest", "light", "dark", "darkest"], options: ["lightest", "light", "dark", "darkest", "nord"],
} }
const store = createLocalStorageStore("bb-theme", initialValue) const store = createLocalStorageStore("bb-theme", initialValue)
@ -21,6 +22,7 @@ export const getThemeStore = () => {
`spectrum--${option}`, `spectrum--${option}`,
option === state.theme option === state.theme
) )
themeElement.classList.add("spectrum--darkest")
}) })
}) })

View File

@ -162,7 +162,7 @@
<Select <Select
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
value={inputData[key]} value={inputData[key]}
options={Object.keys(table.schema)} options={Object.keys(table?.schema || {})}
/> />
{:else if value.customType === "filters"} {:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton> <ActionButton on:click={drawer.show}>Define filters</ActionButton>

View File

@ -1,5 +1,5 @@
<script> <script>
import { Input, Select } from "@budibase/bbui" import { Input, Select, Button } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -62,9 +62,6 @@
</script> </script>
<div class="root"> <div class="root">
<div class="add-field">
<i class="ri-add-line" on:click={addField} />
</div>
<div class="spacer" /> <div class="spacer" />
{#each fieldsArray as field} {#each fieldsArray as field}
<div class="field"> <div class="field">
@ -88,6 +85,7 @@
/> />
</div> </div>
{/each} {/each}
<Button quiet secondary icon="Add" on:click={addField}>Add field</Button>
</div> </div>
<style> <style>
@ -103,52 +101,11 @@
.field { .field {
max-width: 100%; max-width: 100%;
background-color: var(--grey-2);
margin-bottom: var(--spacing-m); margin-bottom: var(--spacing-m);
border-style: solid;
border-width: 1px;
border-color: var(--grey-4);
display: grid; display: grid;
/*grid-template-rows: auto auto; grid-template-columns: 1fr 1fr auto;
grid-template-columns: auto;*/
position: relative; position: relative;
} align-items: center;
gap: var(--spacing-m);
.field :global(select) {
padding: var(--spacing-xs) 2rem var(--spacing-m) var(--spacing-s) !important;
font-size: var(--font-size-xs);
color: var(--grey-7);
}
.field :global(.pointer) {
padding-bottom: var(--spacing-m) !important;
color: var(--grey-2);
}
.field :global(input) {
padding: var(--spacing-m) var(--spacing-xl) var(--spacing-xs)
var(--spacing-m);
font-size: var(--font-size-s);
font-weight: bold;
}
.remove-field {
cursor: pointer;
color: var(--grey-6);
position: absolute;
top: var(--spacing-m);
right: 3px;
}
.remove-field:hover {
color: var(--black);
}
.add-field {
text-align: right;
}
.add-field > i {
cursor: pointer;
} }
</style> </style>

View File

@ -19,10 +19,22 @@
export let value = defaultValue || (meta.type === "boolean" ? false : "") export let value = defaultValue || (meta.type === "boolean" ? false : "")
export let readonly 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 = $: stringVal =
typeof value === "object" ? JSON.stringify(value, null, 2) : value typeof value === "object" ? JSON.stringify(value, null, 2) : value
$: type = meta?.type $: type = meta?.type
$: label = meta.name ? capitalise(meta.name) : "" $: label = meta.name ? capitalise(meta.name) : ""
const timeStamp = resolveTimeStamp(value)
const isTimeStamp = timeStamp ? true : false
</script> </script>
{#if type === "options"} {#if type === "options"}
@ -34,7 +46,7 @@
sort sort
/> />
{:else if type === "datetime"} {:else if type === "datetime"}
<DatePicker {label} bind:value /> <DatePicker {label} timeOnly={isTimeStamp} bind:value />
{:else if type === "attachment"} {:else if type === "attachment"}
<Dropzone {label} bind:value /> <Dropzone {label} bind:value />
{:else if type === "boolean"} {:else if type === "boolean"}

View File

@ -15,6 +15,7 @@
import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte" import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import GoogleButton from "../_components/GoogleButton.svelte"
export let datasource export let datasource
export let save export let save
@ -160,6 +161,11 @@
Fetch tables Fetch tables
</Button> </Button>
<Button cta icon="Add" on:click={createNewTable}>New table</Button> <Button cta icon="Add" on:click={createNewTable}>New table</Button>
{#if integration.auth}
{#if integration.auth.type === "google"}
<GoogleButton {datasource} />
{/if}
{/if}
</div> </div>
</div> </div>
<Body> <Body>

View File

@ -0,0 +1,118 @@
<script>
export let backgroundColour
export let imageSrc
export let name
export let icon
export let overlayEnabled = true
let imageError = false
const imageRenderError = () => {
imageError = true
}
</script>
<div class="template-card" style="background-color:{backgroundColour};">
<div class="template-thumbnail card-body">
<img
alt={name}
src={imageSrc}
on:error={imageRenderError}
class:error={imageError}
/>
<div style={`display:${imageError ? "block" : "none"}`}>
<svg
width="26px"
height="26px"
class="spectrum-Icon"
style="color: white"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
</div>
<div class={overlayEnabled ? "template-thumbnail-action-overlay" : ""}>
<slot />
</div>
</div>
<div class="template-thumbnail-text">
<div>{name}</div>
</div>
</div>
<style>
.template-thumbnail {
position: relative;
}
.template-card:hover .template-thumbnail-action-overlay {
opacity: 1;
}
.template-thumbnail-action-overlay {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 70%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.7);
opacity: 0;
transition: opacity var(--spectrum-global-animation-duration-100) ease;
border-top-right-radius: inherit;
border-top-left-radius: inherit;
}
.template-thumbnail-text {
position: absolute;
bottom: 0px;
display: flex;
align-items: center;
height: 30%;
width: 100%;
color: var(
--spectrum-heading-xs-text-color,
var(--spectrum-alias-heading-text-color)
);
background-color: var(--spectrum-global-color-gray-50);
}
.template-thumbnail-text > div {
padding-left: 1rem;
padding-right: 1rem;
}
.template-card {
position: relative;
display: flex;
border-radius: var(--border-radius-s);
border: 1px solid var(--spectrum-global-color-gray-300);
overflow: hidden;
min-height: 200px;
}
.template-card > * {
width: 100%;
}
.template-card img {
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);
}
.card-body {
padding-left: 1rem;
padding-top: 1rem;
}
</style>

View File

@ -0,0 +1,152 @@
<script>
import {
Layout,
Detail,
Heading,
Button,
Modal,
ActionGroup,
ActionButton,
} from "@budibase/bbui"
import TemplateCard from "components/common/TemplateCard.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
export let templates
let selectedTemplateCategory
let creationModal
let template
const groupTemplatesByCategory = (templates, categoryFilter) => {
let grouped = templates.reduce((acc, template) => {
if (
typeof categoryFilter === "string" &&
[categoryFilter].indexOf(template.category) < 0
) {
return acc
}
acc[template.category] = !acc[template.category]
? []
: acc[template.category]
acc[template.category].push(template)
return acc
}, {})
return grouped
}
$: filteredTemplates = groupTemplatesByCategory(
templates,
selectedTemplateCategory
)
$: filteredTemplateCategories = filteredTemplates
? Object.keys(filteredTemplates).sort()
: []
$: templateCategories = templates
? Object.keys(groupTemplatesByCategory(templates)).sort()
: []
const stopAppCreation = () => {
template = null
}
</script>
<div class="template-header">
<Layout noPadding gap="S">
<Heading size="S">Templates</Heading>
<div class="template-category-filters spectrum-ActionGroup">
<ActionGroup>
<ActionButton
selected={!selectedTemplateCategory}
on:click={() => {
selectedTemplateCategory = null
}}
>
All
</ActionButton>
{#each templateCategories as templateCategoryKey}
<ActionButton
dataCy={templateCategoryKey}
selected={templateCategoryKey == selectedTemplateCategory}
on:click={() => {
selectedTemplateCategory = templateCategoryKey
}}
>
{templateCategoryKey}
</ActionButton>
{/each}
</ActionGroup>
</div>
</Layout>
</div>
<div class="template-categories">
<Layout gap="XL" noPadding>
{#each filteredTemplateCategories as templateCategoryKey}
<div class="template-category" data-cy={templateCategoryKey}>
<Detail size="M">{templateCategoryKey}</Detail>
<div class="template-grid">
{#each filteredTemplates[templateCategoryKey] as templateEntry}
<TemplateCard
name={templateEntry.name}
imageSrc={templateEntry.image}
backgroundColour={templateEntry.background}
icon={templateEntry.icon}
>
<Button
cta
on:click={() => {
template = templateEntry
creationModal.show()
}}
>
Use template
</Button>
<a
href={templateEntry.url}
target="_blank"
class="overlay-preview-link spectrum-Button spectrum-Button--sizeM spectrum-Button--secondary"
on:click|stopPropagation
>
Details
</a>
</TemplateCard>
{/each}
</div>
</div>
{/each}
</Layout>
</div>
<Modal
bind:this={creationModal}
padding={false}
width="600px"
on:hide={stopAppCreation}
>
<CreateAppModal {template} />
</Modal>
<style>
.template-grid {
padding-top: 10px;
display: grid;
grid-gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
a:hover.spectrum-Button.spectrum-Button--secondary.overlay-preview-link {
background-color: #c8c8c8;
border-color: #c8c8c8;
color: #505050;
}
a.spectrum-Button--secondary.overlay-preview-link {
margin-top: 20px;
border-color: #c8c8c8;
color: #c8c8c8;
}
</style>

View File

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

View File

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

View File

@ -12,8 +12,8 @@
const enrichBindings = bindings => { const enrichBindings = bindings => {
return bindings?.map(binding => ({ return bindings?.map(binding => ({
...binding, ...binding,
readableBinding: binding.label || binding.readableBinding, readableBinding: binding.readableBinding || binding.label,
runtimeBinding: binding.path || binding.runtimeBinding, runtimeBinding: binding.runtimeBinding || binding.path,
})) }))
} }
</script> </script>

View File

@ -3,13 +3,14 @@
import PathTree from "./PathTree.svelte" import PathTree from "./PathTree.svelte"
let routes = {} let routes = {}
$: paths = Object.keys(routes || {}).sort() let paths = []
$: { $: allRoutes = $store.routes
const allRoutes = $store.routes $: selectedScreenId = $store.selectedScreenId
$: updatePaths(allRoutes, $selectedAccessRole, selectedScreenId)
const updatePaths = (allRoutes, selectedRoleId, selectedScreenId) => {
const sortedPaths = Object.keys(allRoutes || {}).sort() const sortedPaths = Object.keys(allRoutes || {}).sort()
const selectedRoleId = $selectedAccessRole
const selectedScreenId = $store.selectedScreenId
let found = false let found = false
let firstValidScreenId let firstValidScreenId
@ -41,11 +42,15 @@
}) })
}) })
}) })
routes = filteredRoutes routes = { ...filteredRoutes }
paths = Object.keys(routes || {}).sort()
// Select the correct role for the current screen ID // Select the correct role for the current screen ID
if (!found && screenRoleId) { if (!found && screenRoleId) {
selectedAccessRole.set(screenRoleId) selectedAccessRole.set(screenRoleId)
if (screenRoleId !== selectedRoleId) {
updatePaths(allRoutes, screenRoleId, selectedScreenId)
}
} }
// If the selected screen isn't in this filtered list, select the first one // If the selected screen isn't in this filtered list, select the first one

View File

@ -12,11 +12,13 @@
import { getAvailableActions } from "./index" import { getAvailableActions } from "./index"
import { generate } from "shortid" import { generate } from "shortid"
import { getButtonContextBindings } from "builderStore/dataBinding" import { getButtonContextBindings } from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
const flipDurationMs = 150 const flipDurationMs = 150
const EVENT_TYPE_KEY = "##eventHandlerType" const EVENT_TYPE_KEY = "##eventHandlerType"
const actionTypes = getAvailableActions() const actionTypes = getAvailableActions()
export let key
export let actions export let actions
export let bindings = [] export let bindings = []
@ -24,6 +26,9 @@
// These are ephemeral bindings which only exist while executing actions // These are ephemeral bindings which only exist while executing actions
$: buttonContextBindings = getButtonContextBindings( $: buttonContextBindings = getButtonContextBindings(
$currentAsset,
$store.selectedComponentId,
key,
actions, actions,
selectedAction?.id selectedAction?.id
) )

View File

@ -8,6 +8,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let key
export let value = [] export let value = []
export let name export let name
export let bindings export let bindings
@ -81,5 +82,6 @@
bind:actions={tmpValue} bind:actions={tmpValue}
eventType={name} eventType={name}
{bindings} {bindings}
{key}
/> />
</Drawer> </Drawer>

View File

@ -0,0 +1,78 @@
<script>
import { Select, Body } from "@budibase/bbui"
import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters
export let bindings
const typeOptions = [
{
label: "Continue if",
value: "continue",
},
{
label: "Stop if",
value: "stop",
},
]
const operatorOptions = [
{
label: "Equals",
value: "equal",
},
{
label: "Not equals",
value: "notEqual",
},
]
onMount(() => {
if (!parameters.type) {
parameters.type = "continue"
}
if (!parameters.operator) {
parameters.operator = "equal"
}
})
</script>
<div class="root">
<Body size="S">
Configure a condition to be evaluated which can stop further actions from
being executed.
</Body>
<Select
bind:value={parameters.type}
options={typeOptions}
placeholder={null}
/>
<DrawerBindableInput
placeholder="Value"
value={parameters.value}
on:change={e => (parameters.value = e.detail)}
{bindings}
/>
<Select
bind:value={parameters.operator}
options={operatorOptions}
placeholder={null}
/>
<DrawerBindableInput
placeholder="Reference value"
bind:value={parameters.referenceValue}
on:change={e => (parameters.referenceValue = e.detail)}
{bindings}
/>
</div>
<style>
.root {
display: flex;
flex-direction: column;
gap: var(--spacing-l);
justify-content: flex-start;
align-items: stretch;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -19,7 +19,7 @@
.filter(a => a.definition.trigger?.stepId === "APP") .filter(a => a.definition.trigger?.stepId === "APP")
.map(automation => { .map(automation => {
const schema = Object.entries( const schema = Object.entries(
automation.definition.trigger.inputs.fields automation.definition.trigger.inputs.fields || {}
).map(([name, type]) => ({ name, type })) ).map(([name, type]) => ({ name, type }))
return { return {

View File

@ -13,3 +13,4 @@ export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte"
export { default as DuplicateRow } from "./DuplicateRow.svelte" export { default as DuplicateRow } from "./DuplicateRow.svelte"
export { default as S3Upload } from "./S3Upload.svelte" export { default as S3Upload } from "./S3Upload.svelte"
export { default as ExportData } from "./ExportData.svelte" export { default as ExportData } from "./ExportData.svelte"
export { default as ContinueIf } from "./ContinueIf.svelte"

View File

@ -84,6 +84,11 @@
{ {
"name": "Export Data", "name": "Export Data",
"component": "ExportData" "component": "ExportData"
},
{
"name": "Continue if / Stop if",
"component": "ContinueIf",
"dependsOnFeature": "continueIfAction"
} }
] ]
} }

View File

@ -79,6 +79,7 @@
bindings={allBindings} bindings={allBindings}
name={key} name={key}
text={label} text={label}
{key}
{type} {type}
{...props} {...props}
/> />

View File

@ -3,6 +3,7 @@
import { roles } from "stores/backend" import { roles } from "stores/backend"
export let value export let value
export let error
</script> </script>
<Select <Select
@ -11,4 +12,5 @@
options={$roles} options={$roles}
getOptionLabel={role => role.name} getOptionLabel={role => role.name}
getOptionValue={role => role._id} getOptionValue={role => role._id}
{error}
/> />

View File

@ -8,14 +8,50 @@
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
import { FrontendTypes } from "constants" import { FrontendTypes } from "constants"
import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl" import sanitizeUrl from "builderStore/store/screenTemplates/utils/sanitizeUrl"
import { allScreens, selectedAccessRole } from "builderStore"
export let componentInstance export let componentInstance
export let bindings export let bindings
function setAssetProps(name, value, parser) { let errors = {}
if (parser && typeof parser === "function") {
const routeTaken = url => {
const roleId = get(selectedAccessRole) || "BASIC"
return get(allScreens).some(
screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() &&
screen.routing.roleId === roleId
)
}
const roleTaken = roleId => {
const url = get(currentAsset)?.routing.route
return get(allScreens).some(
screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() &&
screen.routing.roleId === roleId
)
}
const setAssetProps = (name, value, parser, validate) => {
if (parser) {
value = parser(value) value = parser(value)
} }
if (validate) {
const error = validate(value)
errors = {
...errors,
[name]: error,
}
if (error) {
return
}
} else {
errors = {
...errors,
[name]: null,
}
}
const selectedAsset = get(currentAsset) const selectedAsset = get(currentAsset)
store.update(state => { store.update(state => {
@ -38,7 +74,6 @@
} }
const screenSettings = [ const screenSettings = [
// { key: "description", label: "Description", control: Input },
{ {
key: "routing.route", key: "routing.route",
label: "Route", label: "Route",
@ -49,8 +84,26 @@
} }
return sanitizeUrl(val) return sanitizeUrl(val)
}, },
validate: val => {
const exisingValue = get(currentAsset)?.routing.route
if (val !== exisingValue && routeTaken(val)) {
return "That URL is already in use for this role"
}
return null
},
},
{
key: "routing.roleId",
label: "Access",
control: RoleSelect,
validate: val => {
const exisingValue = get(currentAsset)?.routing.roleId
if (val !== exisingValue && roleTaken(val)) {
return "That role is already in use for this URL"
}
return null
},
}, },
{ key: "routing.roleId", label: "Access", control: RoleSelect },
{ key: "layoutId", label: "Layout", control: LayoutSelect }, { key: "layoutId", label: "Layout", control: LayoutSelect },
] ]
</script> </script>
@ -62,9 +115,11 @@
control={def.control} control={def.control}
label={def.label} label={def.label}
key={def.key} key={def.key}
error="asdasds"
value={deepGet($currentAsset, def.key)} value={deepGet($currentAsset, def.key)}
onChange={val => setAssetProps(def.key, val, def.parser)} onChange={val => setAssetProps(def.key, val, def.parser, def.validate)}
{bindings} {bindings}
props={{ error: errors[def.key] }}
/> />
{/each} {/each}
</DetailSummary> </DetailSummary>

View File

@ -61,7 +61,7 @@
{#if app.deployed}Published{:else}Unpublished{/if} {#if app.deployed}Published{:else}Unpublished{/if}
</StatusLight> </StatusLight>
</div> </div>
<div> <div data-cy={`row_actions_${app.appId}`}>
<Button <Button
size="S" size="S"
disabled={app.lockedOther} disabled={app.lockedOther}

View File

@ -9,17 +9,57 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
import TemplateCard from "components/common/TemplateCard.svelte"
export let template export let template
let creating = false
const values = writable({ name: "", url: null }) const values = writable({ name: "", url: null })
const validation = createValidationStore() const validation = createValidationStore()
$: validation.check($values) $: validation.check($values)
onMount(async () => { onMount(async () => {
$values.name = resolveAppName(template, $values.name)
nameToUrl($values.name)
await setupValidation() await setupValidation()
}) })
const appPrefix = "/app"
$: appUrl = `${window.location.origin}${
$values.url
? `${appPrefix}${$values.url}`
: `${appPrefix}${resolveAppUrl(template, $values.name)}`
}`
const resolveAppUrl = (template, name) => {
let parsedName
const resolvedName = resolveAppName(template, name)
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const resolveAppName = (template, name) => {
if (template && !name) {
return template.name
}
return name ? name.trim() : null
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(template, appName)
tidyUrl(resolvedUrl)
}
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(apps)
appValidation.name(validation, { apps: applications }) appValidation.name(validation, { apps: applications })
@ -30,6 +70,8 @@
} }
async function createNewApp() { async function createNewApp() {
creating = true
try { try {
// Create form data to create app // Create form data to create app
let data = new FormData() let data = new FormData()
@ -65,17 +107,11 @@
await auth.setInitInfo({}) await auth.setInitInfo({})
$goto(`/builder/app/${createdApp.instance._id}`) $goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) { } catch (error) {
creating = false
console.error(error) console.error(error)
notifications.error("Error creating app") notifications.error("Error creating app")
} }
} }
// auto add slash to url
$: {
if ($values.url && !$values.url.startsWith("/")) {
$values.url = `/${$values.url}`
}
}
</script> </script>
<ModalContent <ModalContent
@ -84,6 +120,15 @@
onConfirm={createNewApp} onConfirm={createNewApp}
disabled={!$validation.valid} disabled={!$validation.valid}
> >
{#if template && !template?.fromFile}
<TemplateCard
name={template.name}
imageSrc={template.image}
backgroundColour={template.background}
overlayEnabled={false}
icon={template.icon}
/>
{/if}
{#if template?.fromFile} {#if template?.fromFile}
<Dropzone <Dropzone
error={$validation.touched.file && $validation.errors.file} error={$validation.touched.file && $validation.errors.file}
@ -98,20 +143,42 @@
{/if} {/if}
<Input <Input
bind:value={$values.name} bind:value={$values.name}
disabled={creating}
error={$validation.touched.name && $validation.errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)} on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name" label="Name"
placeholder={$auth.user.firstName placeholder={$auth.user?.firstName
? `${$auth.user.firstName}s app` ? `${$auth.user.firstName}s app`
: "My app"} : "My app"}
/> />
<span>
<Input <Input
bind:value={$values.url} bind:value={$values.url}
disabled={creating}
error={$validation.touched.url && $validation.errors.url} error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)} on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL" label="URL"
placeholder={$values.name placeholder={$values.url
? "/" + encodeURIComponent($values.name).toLowerCase() ? $values.url
: "/"} : `/${resolveAppUrl(template, $values.name)}`}
/> />
{#if $values.url && $values.url !== "" && !$validation.errors.url}
<div class="app-server" title={appUrl}>
{appUrl}
</div>
{/if}
</span>
</ModalContent> </ModalContent>
<style>
.app-server {
color: var(--spectrum-global-color-gray-600);
margin-top: 10px;
width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -42,11 +42,31 @@
} }
} }
// auto add slash to url const resolveAppUrl = (template, name) => {
$: { let parsedName
if ($values.url && !$values.url.startsWith("/")) { const resolvedName = resolveAppName(null, name)
$values.url = `/${$values.url}` parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
} }
const resolveAppName = (template, name) => {
if (template && !name) {
return template.name
}
return name ? name.trim() : null
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(null, appName)
tidyUrl(resolvedUrl)
} }
</script> </script>
@ -61,15 +81,17 @@
bind:value={$values.name} bind:value={$values.name}
error={$validation.touched.name && $validation.errors.name} error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)} on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name" label="Name"
/> />
<Input <Input
bind:value={$values.url} bind:value={$values.url}
error={$validation.touched.url && $validation.errors.url} error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)} on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL" label="URL"
placeholder={$values.name placeholder={$values.url
? "/" + encodeURIComponent($values.name).toLowerCase() ? $values.url
: "/"} : `/${resolveAppUrl(null, $values.name)}`}
/> />
</ModalContent> </ModalContent>

View File

@ -35,13 +35,14 @@ export const url = (validation, { apps, currentApp } = { apps: [] }) => {
validation.addValidator( validation.addValidator(
"url", "url",
string() string()
.trim()
.nullable() .nullable()
.matches(APP_URL_REGEX, "App URL must not contain spaces") .required("Your application must have a url")
.matches(APP_URL_REGEX, "Please enter a valid url")
.test( .test(
"non-existing-app-url", "non-existing-app-url",
"Another app with the same URL already exists", "Another app with the same URL already exists",
value => { value => {
// url is nullable
if (!value) { if (!value) {
return true return true
} }

View File

@ -0,0 +1,151 @@
<script>
import { goto } from "@roxi/routify"
import {
Layout,
Page,
notifications,
Button,
Heading,
Body,
Modal,
Divider,
} from "@budibase/bbui"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import { onMount } from "svelte"
import { templates } from "stores/portal"
let loaded = $templates?.length
let template
let creationModal = false
let creatingApp = false
const welcomeBody =
"Start from scratch or get a head start with one of our templates"
const createAppTitle = "Create new app"
const createAppButtonText = "Start from scratch"
onMount(async () => {
try {
await templates.load()
if ($templates?.length === 0) {
notifications.error(
"There was a problem loading quick start templates."
)
}
} catch (error) {
notifications.error("Error loading apps and templates")
}
loaded = true
})
const initiateAppCreation = () => {
template = null
creationModal.show()
creatingApp = true
}
const stopAppCreation = () => {
template = null
creatingApp = false
}
const initiateAppImport = () => {
template = { fromFile: true }
creationModal.show()
creatingApp = true
}
</script>
<Page wide>
<Layout noPadding gap="XL">
<span>
<Button
quiet
secondary
icon={"ChevronLeft"}
on:click={() => {
$goto("../")
}}
>
Back
</Button>
</span>
<div class="title">
<div class="welcome">
<Layout noPadding gap="XS">
<Heading size="M">{createAppTitle}</Heading>
<Body size="S">
{welcomeBody}
</Body>
</Layout>
<div class="buttons">
<Button
dataCy="create-app-btn"
size="L"
icon="Add"
cta
on:click={initiateAppCreation}
>
{createAppButtonText}
</Button>
<Button
dataCy="import-app-btn"
icon="Import"
size="L"
quiet
secondary
on:click={initiateAppImport}
>
Import app
</Button>
</div>
</div>
</div>
<Divider size="S" />
{#if loaded && $templates?.length}
<TemplateDisplay templates={$templates} />
{/if}
</Layout>
</Page>
<Modal
bind:this={creationModal}
padding={false}
width="600px"
on:hide={stopAppCreation}
>
<CreateAppModal {template} />
</Modal>
<style>
.title .welcome > .buttons {
padding-top: 30px;
}
.title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--spacing-xl);
flex-wrap: wrap;
}
.buttons {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xl);
flex-wrap: wrap;
}
@media (max-width: 640px) {
.buttons {
flex-direction: row-reverse;
justify-content: flex-end;
}
}
</style>

View File

@ -11,8 +11,9 @@
notifications, notifications,
Body, Body,
Search, Search,
Icon, Divider,
} from "@budibase/bbui" } from "@budibase/bbui"
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
@ -39,12 +40,27 @@
let unpublishModal let unpublishModal
let iconModal let iconModal
let creatingApp = false let creatingApp = false
let loaded = false let loaded = $apps?.length || $templates?.length
let searchTerm = "" let searchTerm = ""
let cloud = $admin.cloud let cloud = $admin.cloud
let appName = "" let appName = ""
let creatingFromTemplate = false let creatingFromTemplate = false
const resolveWelcomeMessage = (auth, apps) => {
const userWelcome = auth?.user?.firstName
? `Welcome ${auth?.user?.firstName}!`
: "Welcome back!"
return apps?.length ? userWelcome : "Let's create your first app!"
}
$: welcomeHeader = resolveWelcomeMessage($auth, $apps)
$: welcomeBody = $apps?.length
? "Manage your apps and get a head start with templates"
: "Start from scratch or get a head start with one of our templates"
$: createAppButtonText = $apps?.length
? "Create new app"
: "Start from scratch"
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: enrichedApps = enrichApps($apps, $auth.user, sortBy)
$: filteredApps = enrichedApps.filter(app => $: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
@ -79,10 +95,14 @@
} }
const initiateAppCreation = () => { const initiateAppCreation = () => {
if ($apps?.length) {
$goto("/builder/portal/apps/create")
} else {
template = null template = null
creationModal.show() creationModal.show()
creatingApp = true creatingApp = true
} }
}
const initiateAppsExport = () => { const initiateAppsExport = () => {
try { try {
@ -269,27 +289,40 @@
<Page wide> <Page wide>
<Layout noPadding gap="XL"> <Layout noPadding gap="XL">
{#if loaded}
<div class="title"> <div class="title">
<div class="welcome">
<Layout noPadding gap="XS"> <Layout noPadding gap="XS">
<Heading size="M">Welcome to Budibase</Heading> <Heading size="L">{welcomeHeader}</Heading>
<Body size="S"> <Body size="M">
Manage your apps and get a head start with templates {welcomeBody}
</Body> </Body>
</Layout> </Layout>
<div class="buttons"> <div class="buttons">
{#if cloud}
<Button <Button
size="L" dataCy="create-app-btn"
icon="Export" size="M"
icon="Add"
cta
on:click={initiateAppCreation}
>
{createAppButtonText}
</Button>
{#if $apps?.length > 0}
<Button
icon="Experience"
size="M"
quiet quiet
secondary secondary
on:click={initiateAppsExport} on:click={$goto("/builder/portal/apps/templates")}
> >
Export apps Templates
</Button> </Button>
{/if} {/if}
{#if !$apps?.length}
<Button <Button
dataCy="import-app-btn"
icon="Import" icon="Import"
size="L" size="L"
quiet quiet
@ -298,60 +331,38 @@
> >
Import app Import app
</Button> </Button>
<Button size="L" icon="Add" cta on:click={initiateAppCreation}> {/if}
Create app
</Button>
</div> </div>
</div> </div>
<div>
<Layout noPadding gap="S"> <Layout gap="S" justifyItems="center">
<Detail size="L">Quick start templates</Detail> <img class="img-logo img-size" alt="logo" src={Logo} />
<div class="grid">
{#each $templates as item}
<div
on:click={() => {
template = item
creationModal.show()
creatingApp = true
}}
class="template-card"
>
<a
href={item.url}
target="_blank"
class="external-link"
on:click|stopPropagation
>
<Icon name="LinkOut" size="S" />
</a>
<div class="card-body">
<div style="color: {item.background}" class="iconAlign">
<svg
width="26px"
height="26px"
class="spectrum-Icon"
style="color:{item.background};"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-{item.icon}" />
</svg>
</div>
<div class="iconAlign">
<Body weight="900" size="S">{item.name}</Body>
<div style="font-size: 10px;">
<Body size="S">{item.category.toUpperCase()}</Body>
</div>
</div>
</div>
</div>
{/each}
</div>
</Layout> </Layout>
</div>
<Divider size="S" />
</div>
{#if loaded && enrichedApps.length} {#if !$apps?.length && $templates?.length}
<TemplateDisplay templates={$templates} />
{/if}
{#if enrichedApps.length}
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div class="title"> <div class="title">
<Detail size="L">My apps</Detail> <Detail size="L">Apps</Detail>
{#if enrichedApps.length > 1}
<div class="app-actions">
{#if cloud}
<Button
size="M"
icon="Export"
quiet
secondary
on:click={initiateAppsExport}
>
Export apps
</Button>
{/if}
<div class="filter"> <div class="filter">
<Select <Select
quiet quiet
@ -367,6 +378,8 @@
<Search placeholder="Search" bind:value={searchTerm} /> <Search placeholder="Search" bind:value={searchTerm} />
</div> </div>
</div> </div>
{/if}
</div>
<div class="appTable"> <div class="appTable">
{#each filteredApps as app (app.appId)} {#each filteredApps as app (app.appId)}
@ -385,32 +398,11 @@
</div> </div>
</Layout> </Layout>
{/if} {/if}
{#if !enrichedApps.length && !creatingApp && loaded}
<div class="empty-wrapper">
<div class="centered">
<div class="main">
<Layout gap="S" justifyItems="center">
<img class="img-size" alt="logo" src={Logo} />
<div class="new-screen-text">
<Detail size="M">Create a business app in minutes!</Detail>
</div>
<Button on:click={() => initiateAppCreation()} size="M" cta>
<div class="new-screen-button">
<div class="background-icon" style="color: white;">
<Icon name="Add" />
</div>
Create App
</div></Button
>
</Layout>
</div>
</div>
</div>
{/if} {/if}
{#if creatingFromTemplate} {#if creatingFromTemplate}
<div class="empty-wrapper"> <div class="empty-wrapper">
<img class="img-logo img-size" alt="logo" src={Logo} />
<p>Creating your Budibase app from your selected template...</p> <p>Creating your Budibase app from your selected template...</p>
<Spinner size="10" /> <Spinner size="10" />
</div> </div>
@ -460,6 +452,15 @@
<ChooseIconModal app={selectedApp} bind:this={iconModal} /> <ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style> <style>
.app-actions {
display: flex;
}
.app-actions :global(> button) {
margin-right: 10px;
}
.title .welcome > .buttons {
padding-top: 30px;
}
.title { .title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -476,13 +477,11 @@
gap: var(--spacing-xl); gap: var(--spacing-xl);
flex-wrap: wrap; flex-wrap: wrap;
} }
@media (max-width: 640px) { @media (max-width: 1000px) {
.buttons { .img-logo {
flex-direction: row-reverse; display: none;
justify-content: flex-end;
} }
} }
.filter { .filter {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -490,49 +489,6 @@
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.grid {
height: 200px;
display: grid;
overflow: hidden;
grid-gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
grid-template-rows: minmax(70px, 1fr) minmax(100px, 1fr) minmax(0px, 0);
}
.template-card {
height: 70px;
border-radius: var(--border-radius-s);
border: 1px solid var(--spectrum-global-color-gray-300);
cursor: pointer;
display: flex;
position: relative;
}
.template-card:hover {
background: var(--spectrum-alias-background-color-tertiary);
}
.card-body {
display: flex;
align-items: center;
padding: 12px;
}
.external-link {
position: absolute;
top: 5px;
right: 5px;
color: var(--spectrum-global-color-gray-300);
z-index: 99;
}
.external-link:hover {
color: var(--spectrum-global-color-gray-500);
}
.iconAlign {
padding: 0 0 0 var(--spacing-m);
display: inline-block;
}
.appTable { .appTable {
display: grid; display: grid;
grid-template-rows: auto; grid-template-rows: auto;
@ -558,7 +514,6 @@
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
} }
} }
.empty-wrapper { .empty-wrapper {
flex: 1 1 auto; flex: 1 1 auto;
height: 100%; height: 100%;
@ -567,42 +522,8 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.centered {
width: calc(100% - 350px);
height: calc(100% - 100px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 300px;
}
.new-screen-text {
width: 160px;
text-align: center;
color: #2c2c2c;
font-weight: 600;
}
.new-screen-button {
margin-left: 5px;
height: 20px;
width: 100px;
display: flex;
align-items: center;
}
.img-size { .img-size {
width: 160px; width: 160px;
height: 160px; height: 160px;
} }
.background-icon {
margin-top: 4px;
margin-right: 4px;
}
</style> </style>

View File

@ -0,0 +1,43 @@
<script>
import { goto } from "@roxi/routify"
import { Layout, Page, notifications, Button } from "@budibase/bbui"
import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import { onMount } from "svelte"
import { templates } from "stores/portal"
let loaded = $templates?.length
onMount(async () => {
try {
await templates.load()
if ($templates?.length === 0) {
notifications.error(
"There was a problem loading quick start templates."
)
}
} catch (error) {
notifications.error("Error loading apps and templates")
}
loaded = true
})
</script>
<Page wide>
<Layout noPadding gap="XL">
<span>
<Button
quiet
secondary
icon={"ChevronLeft"}
on:click={() => {
$goto("../")
}}
>
Back
</Button>
</span>
{#if loaded && $templates?.length}
<TemplateDisplay templates={$templates} />
{/if}
</Layout>
</Page>

View File

@ -71,7 +71,9 @@
<Heading size="S">Users</Heading> <Heading size="S">Users</Heading>
<ButtonGroup> <ButtonGroup>
<Button disabled secondary>Import users</Button> <Button disabled secondary>Import users</Button>
<Button primary on:click={createUserModal.show}>Add user</Button> <Button primary dataCy="add-user" on:click={createUserModal.show}
>Add user</Button
>
</ButtonGroup> </ButtonGroup>
</div> </div>
<div class="field"> <div class="field">

View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "1.0.91-alpha.17", "version": "1.0.105-alpha.10",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {

View File

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

View File

@ -7,7 +7,8 @@
"customThemes": true, "customThemes": true,
"devicePreview": true, "devicePreview": true,
"messagePassing": true, "messagePassing": true,
"rowSelection": true "rowSelection": true,
"continueIfAction": true
}, },
"layout": { "layout": {
"name": "Layout", "name": "Layout",
@ -2528,69 +2529,103 @@
"name": "Embedded Map", "name": "Embedded Map",
"icon": "Location", "icon": "Location",
"styles": ["size"], "styles": ["size"],
"editable": true,
"draggable": false, "draggable": false,
"illegalChildren": [ "illegalChildren": ["section"],
"section"
],
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
"label": "Provider", "label": "Provider",
"key": "dataProvider" "key": "dataProvider"
}, },
{
"type": "field",
"label": "Latitude Key",
"key": "latitudeKey",
"dependsOn": "dataProvider"
},
{
"type": "field",
"label": "Longitude Key",
"key": "longitudeKey",
"dependsOn": "dataProvider"
},
{
"type": "field",
"label": "Title Key",
"key": "titleKey",
"dependsOn": "dataProvider"
},
{
"type": "event",
"label": "On Click Marker",
"key": "onClickMarker",
"context": [
{
"label": "Clicked marker",
"key": "marker"
}
]
},
{ {
"type": "boolean", "type": "boolean",
"label": "Enable Fullscreen", "label": "Enable creating markers",
"key": "creationEnabled",
"defaultValue": false
},
{
"type": "event",
"label": "On Create Marker",
"key": "onCreateMarker",
"dependsOn": "creationEnabled",
"context": [
{
"label": "New marker latitude",
"key": "lat"
},
{
"label": "New marker longitude",
"key": "lng"
}
]
},
{
"type": "boolean",
"label": "Enable fullscreen",
"key": "fullScreenEnabled", "key": "fullScreenEnabled",
"defaultValue": true "defaultValue": true
}, },
{ {
"type": "boolean", "type": "boolean",
"label": "Enable Location", "label": "Enable location",
"key": "locationEnabled", "key": "locationEnabled",
"defaultValue": true "defaultValue": true
}, },
{ {
"type": "boolean", "type": "boolean",
"label": "Enable Zoom", "label": "Enable zoom",
"key": "zoomEnabled", "key": "zoomEnabled",
"defaultValue": true "defaultValue": true
}, },
{
"type": "number",
"label": "Zoom Level (0-100)",
"key": "zoomLevel",
"defaultValue": 72,
"max" : 100,
"min" : 0
},
{
"type": "field",
"label": "Latitude Key",
"key": "latitudeKey"
},
{
"type": "field",
"label": "Longitude Key",
"key": "longitudeKey"
},
{
"type": "field",
"label": "Title Key",
"key": "titleKey"
},
{ {
"type": "text", "type": "text",
"label": "Tile URL", "label": "Tile URL",
"key": "tileURL", "key": "tileURL",
"defaultValue": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" "defaultValue": "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
}, },
{ {
"type": "text", "type": "text",
"label": "Default Location (lat,lng)", "label": "Default Location (when empty)",
"key": "defaultLocation", "key": "defaultLocation",
"defaultValue": "51.5072,-0.1276" "placeholder": "51.5072,-0.1276"
},
{
"type": "number",
"label": "Default Location Zoom Level (0-100)",
"key": "zoomLevel",
"placeholder": 50,
"max": 100,
"min": 0
}, },
{ {
"type": "text", "type": "text",
@ -3595,7 +3630,6 @@
"name": "Markdown Viewer", "name": "Markdown Viewer",
"icon": "TaskList", "icon": "TaskList",
"styles": ["size"], "styles": ["size"],
"editable": true,
"settings": [ "settings": [
{ {
"type": "text", "type": "text",

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "1.0.91-alpha.17", "version": "1.0.105-alpha.10",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.0.91-alpha.17", "@budibase/bbui": "^1.0.105-alpha.10",
"@budibase/frontend-core": "^1.0.91-alpha.17", "@budibase/frontend-core": "^1.0.105-alpha.10",
"@budibase/string-templates": "^1.0.91-alpha.17", "@budibase/string-templates": "^1.0.105-alpha.10",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/button": "^3.0.3",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3", "@spectrum-css/divider": "^1.0.3",

View File

@ -1,6 +1,7 @@
<script context="module"> <script context="module">
// Cache the definition of settings for each component type // Cache the definition of settings for each component type
let SettingsDefinitionCache = {} let SettingsDefinitionCache = {}
let SettingsDefinitionMapCache = {}
// Cache the settings of each component ID. // Cache the settings of each component ID.
// This speeds up remounting as well as repeaters. // This speeds up remounting as well as repeaters.
@ -74,6 +75,8 @@
// Component information derived during initialisation // Component information derived during initialisation
let constructor let constructor
let definition let definition
let settingsDefinition
let settingsDefinitionMap
// Set up initial state for each new component instance // Set up initial state for each new component instance
$: initialise(instance) $: initialise(instance)
@ -118,7 +121,7 @@
$: emptyState = empty && showEmptyState $: emptyState = empty && showEmptyState
// Enrich component settings // Enrich component settings
$: enrichComponentSettings($context) $: enrichComponentSettings($context, settingsDefinitionMap)
// Evaluate conditional UI settings and store any component setting changes // Evaluate conditional UI settings and store any component setting changes
// which need to be made. This is broken into 2 lines to avoid svelte // which need to be made. This is broken into 2 lines to avoid svelte
@ -168,12 +171,14 @@
} }
// Get the settings definition for this component, and cache it // Get the settings definition for this component, and cache it
let settingsDefinition
if (SettingsDefinitionCache[definition.name]) { if (SettingsDefinitionCache[definition.name]) {
settingsDefinition = SettingsDefinitionCache[definition.name] settingsDefinition = SettingsDefinitionCache[definition.name]
settingsDefinitionMap = SettingsDefinitionMapCache[definition.name]
} else { } else {
settingsDefinition = getSettingsDefinition(definition) settingsDefinition = getSettingsDefinition(definition)
settingsDefinitionMap = getSettingsDefinitionMap(settingsDefinition)
SettingsDefinitionCache[definition.name] = settingsDefinition SettingsDefinitionCache[definition.name] = settingsDefinition
SettingsDefinitionMapCache[definition.name] = settingsDefinitionMap
} }
// Parse the instance settings, and cache them // Parse the instance settings, and cache them
@ -190,7 +195,9 @@
dynamicSettings = instanceSettings.dynamicSettings dynamicSettings = instanceSettings.dynamicSettings
// Force an initial enrichment of the new settings // Force an initial enrichment of the new settings
enrichComponentSettings(get(context), { force: true }) enrichComponentSettings(get(context), settingsDefinitionMap, {
force: true,
})
} }
// Gets the component constructor for the specified component // Gets the component constructor for the specified component
@ -226,6 +233,14 @@
return settings return settings
} }
const getSettingsDefinitionMap = settingsDefinition => {
let map = {}
settingsDefinition?.forEach(setting => {
map[setting.key] = setting
})
return map
}
const getInstanceSettings = (instance, settingsDefinition) => { const getInstanceSettings = (instance, settingsDefinition) => {
// Get raw settings // Get raw settings
let settings = {} let settings = {}
@ -248,7 +263,7 @@
} else if (typeof value === "string" && value.includes("{{")) { } else if (typeof value === "string" && value.includes("{{")) {
// Strings can be trivially checked // Strings can be trivially checked
delete newStaticSettings[setting.key] delete newStaticSettings[setting.key]
} else if (value[0]?.["##eventHandlerType"] != null) { } else if (setting.type === "event") {
// Always treat button actions as dynamic // Always treat button actions as dynamic
delete newStaticSettings[setting.key] delete newStaticSettings[setting.key]
} else if (typeof value === "object") { } else if (typeof value === "object") {
@ -273,7 +288,11 @@
} }
// Enriches any string component props using handlebars // Enriches any string component props using handlebars
const enrichComponentSettings = (context, options = { force: false }) => { const enrichComponentSettings = (
context,
settingsDefinitionMap,
options = { force: false }
) => {
const contextChanged = context.key !== lastContextKey const contextChanged = context.key !== lastContextKey
if (!contextChanged && !options?.force) { if (!contextChanged && !options?.force) {
return return
@ -285,7 +304,11 @@
const enrichmentTime = latestUpdateTime const enrichmentTime = latestUpdateTime
// Enrich settings with context // Enrich settings with context
const newEnrichedSettings = enrichProps(dynamicSettings, context) const newEnrichedSettings = enrichProps(
dynamicSettings,
context,
settingsDefinitionMap
)
// Abandon this update if a newer update has started // Abandon this update if a newer update has started
if (enrichmentTime !== latestUpdateTime) { if (enrichmentTime !== latestUpdateTime) {

View File

@ -22,18 +22,19 @@
$: componentText = getComponentText(text, $builderStore, $component) $: componentText = getComponentText(text, $builderStore, $component)
const getComponentText = (text, builderState, componentState) => { const getComponentText = (text, builderState, componentState) => {
if (!builderState.inBuilder || componentState.editing) { if (componentState.editing) {
return text || " " return text || " "
} }
return text || componentState.name || "Placeholder text" return text || componentState.name || "Placeholder text"
} }
const updateText = e => { const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent.trim()) builderStore.actions.updateProp("text", e.target.textContent)
} }
</script> </script>
<button {#key $component.editing}
<button
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`} class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`}
class:spectrum-Button--quiet={quiet} class:spectrum-Button--quiet={quiet}
{disabled} {disabled}
@ -43,7 +44,7 @@
on:blur={$component.editing ? updateText : null} on:blur={$component.editing ? updateText : null}
bind:this={node} bind:this={node}
class:active class:active
> >
{#if icon} {#if icon}
<svg <svg
class:hasText={componentText?.length > 0} class:hasText={componentText?.length > 0}
@ -56,7 +57,8 @@
</svg> </svg>
{/if} {/if}
{componentText} {componentText}
</button> </button>
{/key}
<style> <style>
button { button {

View File

@ -47,12 +47,12 @@
// Convert contenteditable HTML to text and save // Convert contenteditable HTML to text and save
const updateText = e => { const updateText = e => {
const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n").trim() builderStore.actions.updateProp("text", e.target.textContent)
builderStore.actions.updateProp("text", sanitized)
} }
</script> </script>
<h1 {#key $component.editing}
<h1
bind:this={node} bind:this={node}
contenteditable={$component.editing} contenteditable={$component.editing}
use:styleable={styles} use:styleable={styles}
@ -62,9 +62,10 @@
class:underline class:underline
class="spectrum-Heading {sizeClass} {alignClass}" class="spectrum-Heading {sizeClass} {alignClass}"
on:blur={$component.editing ? updateText : null} on:blur={$component.editing ? updateText : null}
> >
{componentText} {componentText}
</h1> </h1>
{/key}
<style> <style>
h1 { h1 {

View File

@ -61,7 +61,7 @@
} }
const updateText = e => { const updateText = e => {
builderStore.actions.updateProp("text", e.target.textContent.trim()) builderStore.actions.updateProp("text", e.target.textContent)
} }
</script> </script>

View File

@ -102,7 +102,7 @@
white-space: nowrap; white-space: nowrap;
} }
.spectrum-Card-footer { .spectrum-Card-footer {
word-wrap: anywhere; word-wrap: break-word;
white-space: pre-wrap; white-space: pre-wrap;
} }
.horizontal .spectrum-Card-coverPhoto { .horizontal .spectrum-Card-coverPhoto {

View File

@ -46,12 +46,12 @@
// Convert contenteditable HTML to text and save // Convert contenteditable HTML to text and save
const updateText = e => { const updateText = e => {
const sanitized = e.target.innerHTML.replace(/<br>/gi, "\n").trim() builderStore.actions.updateProp("text", e.target.textContent)
builderStore.actions.updateProp("text", sanitized)
} }
</script> </script>
<p {#key $component.editing}
<p
bind:this={node} bind:this={node}
contenteditable={$component.editing} contenteditable={$component.editing}
use:styleable={styles} use:styleable={styles}
@ -61,9 +61,10 @@
class:underline class:underline
class="spectrum-Body {sizeClass} {alignClass}" class="spectrum-Body {sizeClass} {alignClass}"
on:blur={$component.editing ? updateText : null} on:blur={$component.editing ? updateText : null}
> >
{componentText} {componentText}
</p> </p>
{/key}
<style> <style>
p { p {

View File

@ -125,7 +125,11 @@
{#if schemaLoaded} {#if schemaLoaded}
<Block> <Block>
<div class="card-list" use:styleable={$component.styles}> <div class="card-list" use:styleable={$component.styles}>
<BlockComponent type="form" bind:id={formId} props={{ dataSource }}> <BlockComponent
type="form"
bind:id={formId}
props={{ dataSource, disableValidation: true }}
>
{#if title || enrichedSearchColumns?.length || showTitleButton} {#if title || enrichedSearchColumns?.length || showTitleButton}
<div class="header" class:mobile={$context.device.mobile}> <div class="header" class:mobile={$context.device.mobile}>
<div class="title"> <div class="title">

View File

@ -106,7 +106,11 @@
{#if schemaLoaded} {#if schemaLoaded}
<Block> <Block>
<div class={size} use:styleable={$component.styles}> <div class={size} use:styleable={$component.styles}>
<BlockComponent type="form" bind:id={formId} props={{ dataSource }}> <BlockComponent
type="form"
bind:id={formId}
props={{ dataSource, disableValidation: true }}
>
{#if title || enrichedSearchColumns?.length || showTitleButton} {#if title || enrichedSearchColumns?.length || showTitleButton}
<div class="header" class:mobile={$context.device.mobile}> <div class="header" class:mobile={$context.device.mobile}>
<div class="title"> <div class="title">

View File

@ -34,7 +34,7 @@
color: var(--spectrum-global-color-gray-700) !important; color: var(--spectrum-global-color-gray-700) !important;
} }
div :global(.apexcharts-datalabel) { div :global(.apexcharts-datalabel) {
fill: var(--spectrum-global-color-gray-800); fill: white;
} }
div :global(.apexcharts-tooltip) { div :global(.apexcharts-tooltip) {
background-color: var(--spectrum-global-color-gray-200) !important; background-color: var(--spectrum-global-color-gray-200) !important;
@ -45,4 +45,12 @@
background-color: var(--spectrum-global-color-gray-100) !important; background-color: var(--spectrum-global-color-gray-100) !important;
border-color: var(--spectrum-global-color-gray-300) !important; border-color: var(--spectrum-global-color-gray-300) !important;
} }
div :global(.apexcharts-theme-dark .apexcharts-tooltip-text) {
color: white;
}
div
:global(.apexcharts-theme-dark
.apexcharts-tooltip-series-group.apexcharts-active) {
padding-bottom: 0;
}
</style> </style>

View File

@ -2,8 +2,8 @@
import L from "leaflet" import L from "leaflet"
import sanitizeHtml from "sanitize-html" import sanitizeHtml from "sanitize-html"
import "leaflet/dist/leaflet.css" import "leaflet/dist/leaflet.css"
import { Helpers } from "@budibase/bbui" import { Helpers, Button } from "@budibase/bbui"
import { getContext } from "svelte" import { onMount, getContext } from "svelte"
import { import {
FullScreenControl, FullScreenControl,
LocationControl, LocationControl,
@ -24,86 +24,16 @@
export let defaultLocation export let defaultLocation
export let tileURL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" export let tileURL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
export let mapAttribution export let mapAttribution
export let creationEnabled = false
export let onClickMarker
export let onCreateMarker
const { styleable, notificationStore } = getContext("sdk") const { styleable, notificationStore } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
const embeddedMapId = `${Helpers.uuid()}-wrapper` const embeddedMapId = `${Helpers.uuid()}-wrapper`
let cachedDeviceCoordinates
const fallbackCoordinates = [51.5072, -0.1276] //London
let mapInstance
let mapMarkerGroup = new L.FeatureGroup()
let mapMarkers = []
let minZoomLevel = 0
let maxZoomLevel = 18
let adjustedZoomLevel = !Number.isInteger(zoomLevel)
? 72
: Math.round(zoomLevel * (maxZoomLevel / 100))
$: zoomControlUpdated(mapInstance, zoomEnabled)
$: locationControlUpdated(mapInstance, locationEnabled)
$: fullScreenControlUpdated(mapInstance, fullScreenEnabled)
$: updateMapDimensions(
mapInstance,
$component.styles.normal.width,
$component.styles.normal.height
)
$: addMapMarkers(
mapInstance,
dataProvider?.rows,
latitudeKey,
longitudeKey,
titleKey
)
$: if (typeof mapInstance === "object" && mapMarkers.length > 0) {
mapInstance.setZoom(0)
mapInstance.fitBounds(mapMarkerGroup.getBounds(), {
paddingTopLeft: [0, 24],
})
}
const updateMapDimensions = mapInstance => {
if (typeof mapInstance !== "object") {
return
}
mapInstance.invalidateSize()
}
let isValidLatitude = value => {
return !isNaN(value) && value > -90 && value < 90
}
let isValidLongitude = value => {
return !isNaN(value) && value > -180 && value < 180
}
const parseDefaultLocation = defaultLocation => {
if (typeof defaultLocation !== "string") {
return fallbackCoordinates
}
let defaultLocationParts = defaultLocation.split(",")
if (defaultLocationParts.length !== 2) {
return fallbackCoordinates
}
let parsedDefaultLatitude = parseFloat(defaultLocationParts[0].trim())
let parsedDefaultLongitude = parseFloat(defaultLocationParts[1].trim())
return isValidLatitude(parsedDefaultLatitude) === true &&
isValidLongitude(parsedDefaultLongitude) === true
? [parsedDefaultLatitude, parsedDefaultLongitude]
: fallbackCoordinates
}
$: defaultCoordinates =
mapMarkers.length > 0
? parseDefaultLocation(defaultLocation)
: fallbackCoordinates
// Map Button Controls // Map Button Controls
let locationControl = new LocationControl({ const locationControl = new LocationControl({
position: "bottomright", position: "bottomright",
onLocationFail: err => { onLocationFail: err => {
if (err.code === GeolocationPositionError.PERMISSION_DENIED) { if (err.code === GeolocationPositionError.PERMISSION_DENIED) {
@ -129,13 +59,135 @@
} }
}, },
}) })
let fullScreenControl = new FullScreenControl({ const fullScreenControl = new FullScreenControl({
position: "topright", position: "topright",
}) })
let zoomControl = L.control.zoom({ const zoomControl = L.control.zoom({
position: "bottomright", position: "bottomright",
}) })
// Map and marker configuration
const defaultMarkerOptions = {
html:
'<div><svg width="26px" height="26px" class="spectrum-Icon" focusable="false" stroke="#b12b27" stroke-width="1%">' +
'<use xlink:href="#spectrum-icon-18-Location" /></svg></div>',
className: "embedded-map-marker",
iconSize: [26, 26],
iconAnchor: [13, 26],
popupAnchor: [0, -13],
}
const mapMarkerOptions = {
icon: L.divIcon(defaultMarkerOptions),
draggable: false,
alt: "Location Marker",
}
const candidateMarkerOptions = {
icon: L.divIcon({
...defaultMarkerOptions,
className: "embedded-map-marker--candidate",
}),
draggable: false,
alt: "Location Marker",
}
const mapOptions = {
fullScreen: false,
zoomControl: false,
scrollWheelZoom: zoomEnabled,
minZoomLevel,
maxZoomLevel,
}
const fallbackCoordinates = [51.5072, -0.1276] //London
let mapInstance
let mapMarkerGroup = new L.FeatureGroup()
let candidateMarkerGroup = new L.FeatureGroup()
let candidateMarkerPosition
let mounted = false
let initialMarkerZoomCompleted = false
let minZoomLevel = 0
let maxZoomLevel = 18
let cachedDeviceCoordinates
$: validRows = getValidRows(dataProvider?.rows, latitudeKey, longitudeKey)
$: safeZoomLevel = parseZoomLevel(zoomLevel)
$: defaultCoordinates = parseDefaultLocation(defaultLocation)
$: initMap(tileURL, mapAttribution, safeZoomLevel)
$: zoomControlUpdated(mapInstance, zoomEnabled)
$: locationControlUpdated(mapInstance, locationEnabled)
$: fullScreenControlUpdated(mapInstance, fullScreenEnabled)
$: width = $component.styles.normal.width
$: height = $component.styles.normal.height
$: width, height, mapInstance?.invalidateSize()
$: defaultCoordinates, resetView()
$: addMapMarkers(
mapInstance,
validRows,
latitudeKey,
longitudeKey,
titleKey,
onClickMarker
)
const isValidLatitude = value => {
return !isNaN(value) && value > -90 && value < 90
}
const isValidLongitude = value => {
return !isNaN(value) && value > -180 && value < 180
}
const getValidRows = (rows, latKey, lngKey) => {
if (!rows?.length || !latKey || !lngKey) {
return []
}
return rows.filter(row => {
return isValidLatitude(row[latKey]) && isValidLongitude(row[lngKey])
})
}
const parseZoomLevel = zoomLevel => {
let zoom = zoomLevel
if (zoom == null || isNaN(zoom)) {
zoom = 50
} else {
zoom = parseFloat(zoom)
zoom = Math.max(0, Math.min(100, zoom))
}
return Math.round((zoom * maxZoomLevel) / 100)
}
const parseDefaultLocation = defaultLocation => {
if (typeof defaultLocation !== "string") {
return fallbackCoordinates
}
let defaultLocationParts = defaultLocation.split(",")
if (defaultLocationParts.length !== 2) {
return fallbackCoordinates
}
let parsedDefaultLatitude = parseFloat(defaultLocationParts[0].trim())
let parsedDefaultLongitude = parseFloat(defaultLocationParts[1].trim())
return isValidLatitude(parsedDefaultLatitude) === true &&
isValidLongitude(parsedDefaultLongitude) === true
? [parsedDefaultLatitude, parsedDefaultLongitude]
: fallbackCoordinates
}
const resetView = () => {
if (!mapInstance) {
return
}
if (mapMarkerGroup.getLayers().length) {
mapInstance.setZoom(0)
mapInstance.fitBounds(mapMarkerGroup.getBounds(), {
paddingTopLeft: [0, 24],
})
} else {
mapInstance.setView(defaultCoordinates, safeZoomLevel)
}
}
const locationControlUpdated = (mapInstance, locationEnabled) => { const locationControlUpdated = (mapInstance, locationEnabled) => {
if (typeof mapInstance !== "object") { if (typeof mapInstance !== "object") {
return return
@ -171,44 +223,25 @@
} }
} }
//Map icon and marker configuration const addMapMarkers = (
const mapIconMarkup = mapInstance,
'<div><svg width="26px" height="26px" class="spectrum-Icon" focusable="false" stroke="#b12b27" stroke-width="1%">' + validRows,
'<use xlink:href="#spectrum-icon-18-Location" /></svg></div>' latKey,
const mapIcon = L.divIcon({ lngKey,
html: mapIconMarkup, titleKey,
className: "embedded-map-marker", onClick
iconSize: [26, 26], ) => {
iconAnchor: [13, 26], if (!mapInstance) {
popupAnchor: [0, -13],
})
const mapMarkerOptions = {
icon: mapIcon,
draggable: false,
alt: "Location Marker",
}
let mapOptions = {
fullScreen: false,
zoomControl: false,
scrollWheelZoom: zoomEnabled,
minZoomLevel,
maxZoomLevel,
}
const addMapMarkers = (mapInstance, rows, latKey, lngKey, titleKey) => {
if (typeof mapInstance !== "object" || !rows || !latKey || !lngKey) {
return return
} }
mapMarkerGroup.clearLayers() mapMarkerGroup.clearLayers()
if (!validRows?.length) {
const validRows = rows.filter(row => { return
return isValidLatitude(row[latKey]) && isValidLongitude(row[lngKey]) }
})
validRows.forEach(row => { validRows.forEach(row => {
let markerCoords = [row[latKey], row[lngKey]] let markerCoords = [row[latKey], row[lngKey]]
let marker = L.marker(markerCoords, mapMarkerOptions).addTo(mapInstance) let marker = L.marker(markerCoords, mapMarkerOptions).addTo(mapInstance)
let markerContent = generateMarkerPopupContent( let markerContent = generateMarkerPopupContent(
row[latKey], row[latKey],
@ -216,52 +249,105 @@
row[titleKey] row[titleKey]
) )
marker.bindPopup(markerContent).addTo(mapMarkerGroup) marker
.bindTooltip(markerContent, {
direction: "top",
offset: [0, -25],
})
.addTo(mapMarkerGroup)
//https://github.com/Leaflet/Leaflet/issues/7331 if (onClick) {
marker.on("click", function () { marker.on("click", () => {
this.openPopup() onClick({
marker: row,
})
})
}
}) })
mapMarkers = [...mapMarkers, marker] // Zoom to markers if this is the first time
}) if (!initialMarkerZoomCompleted) {
resetView()
initialMarkerZoomCompleted = true
}
} }
const generateMarkerPopupContent = (latitude, longitude, text) => { const generateMarkerPopupContent = (latitude, longitude, text) => {
return text || latitude + "," + longitude return text || latitude + "," + longitude
} }
const initMap = () => { const initMap = (tileURL, attribution, zoom) => {
const initCoords = defaultCoordinates if (!mounted) {
return
}
if (mapInstance) {
mapInstance.remove()
}
mapInstance = L.map(embeddedMapId, mapOptions) mapInstance = L.map(embeddedMapId, mapOptions)
mapMarkerGroup.addTo(mapInstance) mapMarkerGroup.addTo(mapInstance)
candidateMarkerGroup.addTo(mapInstance)
const cleanAttribution = sanitizeHtml(mapAttribution, { // Add attribution
const cleanAttribution = sanitizeHtml(attribution, {
allowedTags: ["a"], allowedTags: ["a"],
allowedAttributes: { allowedAttributes: {
a: ["href", "target"], a: ["href", "target"],
}, },
}) })
L.tileLayer(tileURL, { L.tileLayer(tileURL, {
attribution: "&copy; " + cleanAttribution, attribution: "&copy; " + cleanAttribution,
zoom: adjustedZoomLevel, zoom,
}).addTo(mapInstance) }).addTo(mapInstance)
//Initialise the map // Add click handler
mapInstance.setView(initCoords, adjustedZoomLevel) mapInstance.on("click", handleMapClick)
// Reset view
resetView()
} }
const mapAction = () => { const handleMapClick = e => {
initMap() if (!creationEnabled) {
return { return
destroy() { }
mapInstance.remove() candidateMarkerGroup.clearLayers()
mapInstance = undefined candidateMarkerPosition = [e.latlng.lat, e.latlng.lng]
}, let candidateMarker = L.marker(
candidateMarkerPosition,
candidateMarkerOptions
)
candidateMarker
.bindTooltip("New marker", {
permanent: true,
direction: "top",
offset: [0, -25],
})
.addTo(candidateMarkerGroup)
.on("click", clearCandidateMarker)
}
const createMarker = async () => {
if (!onCreateMarker) {
return
}
const res = await onCreateMarker({
lat: candidateMarkerPosition[0],
lng: candidateMarkerPosition[1],
})
if (res !== false) {
clearCandidateMarker()
} }
} }
const clearCandidateMarker = () => {
candidateMarkerGroup.clearLayers()
candidateMarkerPosition = null
}
onMount(() => {
mounted = true
initMap(tileURL, mapAttribution, safeZoomLevel)
})
</script> </script>
<div class="embedded-map-wrapper map-default" use:styleable={$component.styles}> <div class="embedded-map-wrapper map-default" use:styleable={$component.styles}>
@ -269,12 +355,20 @@
<div>{error}</div> <div>{error}</div>
{/if} {/if}
<div id={embeddedMapId} class="embedded embedded-map" use:mapAction /> <div id={embeddedMapId} class="embedded embedded-map" />
{#if candidateMarkerPosition}
<div class="button-container">
<Button secondary quiet on:click={clearCandidateMarker}>Cancel</Button>
<Button cta on:click={createMarker}>Create marker</Button>
</div>
{/if}
</div> </div>
<style> <style>
.embedded-map-wrapper { .embedded-map-wrapper {
background-color: #f1f1f1; background-color: #f1f1f1;
height: 320px;
} }
.map-default { .map-default {
min-height: 180px; min-height: 180px;
@ -287,6 +381,9 @@
.embedded-map :global(.embedded-map-marker) { .embedded-map :global(.embedded-map-marker) {
color: #ee3b35; color: #ee3b35;
} }
.embedded-map :global(.embedded-map-marker--candidate) {
color: var(--primaryColor);
}
.embedded-map :global(.embedded-map-control) { .embedded-map :global(.embedded-map-control) {
font-size: 22px; font-size: 22px;
} }
@ -294,4 +391,12 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.button-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
}
</style> </style>

View File

@ -50,12 +50,13 @@
$: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}` $: labelClass = labelPos === "above" ? "" : `spectrum-FieldLabel--${labelPos}`
const updateLabel = e => { const updateLabel = e => {
builderStore.actions.updateProp("label", e.target.textContent.trim()) builderStore.actions.updateProp("label", e.target.textContent)
} }
</script> </script>
<FieldGroupFallback> <FieldGroupFallback>
<div class="spectrum-Form-item" use:styleable={$component.styles}> <div class="spectrum-Form-item" use:styleable={$component.styles}>
{#key $component.editing}
<label <label
bind:this={labelNode} bind:this={labelNode}
contenteditable={$component.editing} contenteditable={$component.editing}
@ -66,6 +67,7 @@
> >
{label || " "} {label || " "}
</label> </label>
{/key}
<div class="spectrum-Form-itemField"> <div class="spectrum-Form-itemField">
{#if !formContext} {#if !formContext}
<Placeholder text="Form components need to be wrapped in a form" /> <Placeholder text="Form components need to be wrapped in a form" />

View File

@ -9,6 +9,10 @@
export let disabled = false export let disabled = false
export let actionType = "Create" export let actionType = "Create"
// Not exposed as a builder setting. Used internally to disable validation
// for fields rendered in things like search blocks.
export let disableValidation = false
const context = getContext("context") const context = getContext("context")
const { API, fetchDatasourceSchema } = getContext("sdk") const { API, fetchDatasourceSchema } = getContext("sdk")
@ -102,6 +106,7 @@
{schema} {schema}
{table} {table}
{initialValues} {initialValues}
{disableValidation}
> >
<slot /> <slot />
</InnerForm> </InnerForm>

View File

@ -10,6 +10,7 @@
export let size export let size
export let schema export let schema
export let table export let table
export let disableValidation = false
const component = getContext("component") const component = getContext("component")
const { styleable, Provider, ActionTypes } = getContext("sdk") const { styleable, Provider, ActionTypes } = getContext("sdk")
@ -141,7 +142,9 @@
// Create validation function based on field schema // Create validation function based on field schema
const schemaConstraints = schema?.[field]?.constraints const schemaConstraints = schema?.[field]?.constraints
const validator = createValidatorFromConstraints( const validator = disableValidation
? null
: createValidatorFromConstraints(
schemaConstraints, schemaConstraints,
validationRules, validationRules,
field, field,
@ -164,7 +167,7 @@
// If this field has already been registered and we previously had an // If this field has already been registered and we previously had an
// error set, then re-run the validator to see if we can unset it // error set, then re-run the validator to see if we can unset it
if (fieldState.error) { if (fieldState.error) {
initialError = validator(initialValue) initialError = validator?.(initialValue)
} }
} }
@ -254,7 +257,7 @@
} }
// Update field state // Update field state
const error = validator ? validator(value) : null const error = validator?.(value)
fieldInfo.update(state => { fieldInfo.update(state => {
state.fieldState.value = value state.fieldState.value = value
state.fieldState.error = error state.fieldState.error = error
@ -288,7 +291,9 @@
// Create new validator // Create new validator
const schemaConstraints = schema?.[field]?.constraints const schemaConstraints = schema?.[field]?.constraints
const validator = createValidatorFromConstraints( const validator = disableValidation
? null
: createValidatorFromConstraints(
schemaConstraints, schemaConstraints,
validationRules, validationRules,
field, field,

View File

@ -20,8 +20,8 @@
let listenersAttached = false let listenersAttached = false
const proxyInvalidation = event => { const proxyInvalidation = event => {
const { dataSourceId } = event.detail const { dataSourceId, options } = event.detail
dataSourceStore.actions.invalidateDataSource(dataSourceId) dataSourceStore.actions.invalidateDataSource(dataSourceId, options)
} }
const proxyNotification = event => { const proxyNotification = event => {

View File

@ -66,6 +66,11 @@
newTop = deviceBottom - 44 newTop = deviceBottom - 44
} }
//If element is at the very top of the screen, put the bar below the element
if (elBounds.top < elBounds.height) {
newTop = elBounds.bottom + verticalOffset
}
// Horizontally, try to center first. // Horizontally, try to center first.
// Failing that, render to left edge of component. // Failing that, render to left edge of component.
// Failing that, render to right edge of component, // Failing that, render to right edge of component,

View File

@ -4,30 +4,36 @@ const initialState = {
showConfirmation: false, showConfirmation: false,
title: null, title: null,
text: null, text: null,
callback: null, onConfirm: null,
onCancel: null,
} }
const createConfirmationStore = () => { const createConfirmationStore = () => {
const store = writable(initialState) const store = writable(initialState)
const showConfirmation = (title, text, callback) => { const showConfirmation = (title, text, onConfirm, onCancel) => {
store.set({ store.set({
showConfirmation: true, showConfirmation: true,
title, title,
text, text,
callback, onConfirm,
onCancel,
}) })
} }
const confirm = async () => { const confirm = async () => {
const state = get(store) const state = get(store)
if (!state.showConfirmation || !state.callback) { if (!state.showConfirmation || !state.onConfirm) {
return return
} }
store.set(initialState) store.set(initialState)
await state.callback() await state.onConfirm()
} }
const cancel = () => { const cancel = () => {
const state = get(store)
store.set(initialState) store.set(initialState)
if (state.onCancel) {
state.onCancel()
}
} }
return { return {

View File

@ -54,18 +54,24 @@ export const createDataSourceStore = () => {
// Invalidates a specific dataSource ID by refreshing all instances // Invalidates a specific dataSource ID by refreshing all instances
// which depend on data from that dataSource // which depend on data from that dataSource
const invalidateDataSource = async dataSourceId => { const invalidateDataSource = async (dataSourceId, options) => {
if (!dataSourceId) { if (!dataSourceId) {
return return
} }
// Merge default options
options = {
invalidateRelationships: false,
...options,
}
// Emit this as a window event, so parent screens which are iframing us in // Emit this as a window event, so parent screens which are iframing us in
// can also invalidate the same datasource // can also invalidate the same datasource
const inModal = get(routeStore).queryParams?.peek const inModal = get(routeStore).queryParams?.peek
if (inModal) { if (inModal) {
window.parent.postMessage({ window.parent.postMessage({
type: "invalidate-datasource", type: "invalidate-datasource",
detail: { dataSourceId }, detail: { dataSourceId, options },
}) })
} }
@ -73,13 +79,14 @@ export const createDataSourceStore = () => {
// Fetch related table IDs from table schema // Fetch related table IDs from table schema
let schema let schema
if (options.invalidateRelationships) {
try { try {
const definition = await API.fetchTableDefinition(dataSourceId) const definition = await API.fetchTableDefinition(dataSourceId)
schema = definition?.schema schema = definition?.schema
} catch (error) { } catch (error) {
schema = null schema = null
} }
}
if (schema) { if (schema) {
Object.values(schema).forEach(fieldSchema => { Object.values(schema).forEach(fieldSchema => {
if ( if (

View File

@ -37,7 +37,9 @@ const saveRowHandler = async (action, context) => {
notificationStore.actions.success("Row saved") notificationStore.actions.success("Row saved")
// Refresh related datasources // Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(row.tableId) await dataSourceStore.actions.invalidateDataSource(row.tableId, {
invalidateRelationships: true,
})
return { row } return { row }
} catch (error) { } catch (error) {
@ -65,7 +67,9 @@ const duplicateRowHandler = async (action, context) => {
notificationStore.actions.success("Row saved") notificationStore.actions.success("Row saved")
// Refresh related datasources // Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(row.tableId) await dataSourceStore.actions.invalidateDataSource(row.tableId, {
invalidateRelationships: true,
})
return { row } return { row }
} catch (error) { } catch (error) {
@ -83,7 +87,9 @@ const deleteRowHandler = async action => {
notificationStore.actions.success("Row deleted") notificationStore.actions.success("Row deleted")
// Refresh related datasources // Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(tableId) await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true,
})
} catch (error) { } catch (error) {
// Abort next actions // Abort next actions
return false return false
@ -261,6 +267,26 @@ const exportDataHandler = async action => {
} }
} }
const continueIfHandler = action => {
const { type, value, operator, referenceValue } = action.parameters
if (!type || !operator) {
return
}
let match = false
if (value == null && referenceValue == null) {
match = true
} else if (value === referenceValue) {
match = true
} else {
match = JSON.stringify(value) === JSON.stringify(referenceValue)
}
if (type === "continue") {
return operator === "equal" ? match : !match
} else {
return operator === "equal" ? !match : match
}
}
const handlerMap = { const handlerMap = {
["Save Row"]: saveRowHandler, ["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler, ["Duplicate Row"]: duplicateRowHandler,
@ -277,6 +303,7 @@ const handlerMap = {
["Update State"]: updateStateHandler, ["Update State"]: updateStateHandler,
["Upload File to S3"]: s3UploadHandler, ["Upload File to S3"]: s3UploadHandler,
["Export Data"]: exportDataHandler, ["Export Data"]: exportDataHandler,
["Continue if / Stop if"]: continueIfHandler,
} }
const confirmTextMap = { const confirmTextMap = {
@ -302,13 +329,13 @@ export const enrichButtonActions = (actions, context) => {
return actions return actions
} }
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async eventContext => {
// Button context is built up as actions are executed. // Button context is built up as actions are executed.
// Inherit any previous button context which may have come from actions // Inherit any previous button context which may have come from actions
// before a confirmable action since this breaks the chain. // before a confirmable action since this breaks the chain.
let buttonContext = context.actions || [] let buttonContext = context.actions || []
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => {
for (let i = 0; i < handlers.length; i++) { for (let i = 0; i < handlers.length; i++) {
try { try {
// Skip any non-existent action definitions // Skip any non-existent action definitions
@ -317,7 +344,12 @@ export const enrichButtonActions = (actions, context) => {
} }
// Built total context for this action // Built total context for this action
const totalContext = { ...context, actions: buttonContext } const totalContext = {
...context,
state: get(stateStore),
actions: buttonContext,
eventContext,
}
// Get and enrich this button action with the total context // Get and enrich this button action with the total context
let action = actions[i] let action = actions[i]
@ -327,6 +359,7 @@ export const enrichButtonActions = (actions, context) => {
// If this action is confirmable, show confirmation and await a // If this action is confirmable, show confirmation and await a
// callback to execute further actions // callback to execute further actions
if (action.parameters?.confirm) { if (action.parameters?.confirm) {
return new Promise(resolve => {
const defaultText = confirmTextMap[action["##eventHandlerType"]] const defaultText = confirmTextMap[action["##eventHandlerType"]]
const confirmText = action.parameters?.confirmText || defaultText const confirmText = action.parameters?.confirmText || defaultText
confirmationStore.actions.showConfirmation( confirmationStore.actions.showConfirmation(
@ -346,14 +379,16 @@ export const enrichButtonActions = (actions, context) => {
actions.slice(i + 1), actions.slice(i + 1),
newContext newContext
) )
await next() resolve(await next())
} else {
resolve(false)
} }
},
() => {
resolve(false)
} }
) )
})
// Stop enriching actions when encountering a confirmable action,
// as the callback continues the action chain
return
} }
// For non-confirmable actions, execute the handler immediately // For non-confirmable actions, execute the handler immediately

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