Merge branch 'master' into BUDI-8270/validation-for-search-api
This commit is contained in:
commit
0d5bac67db
|
@ -9,8 +9,5 @@ packages/server/client
|
|||
packages/server/coverage
|
||||
packages/builder/.routify
|
||||
packages/sdk/sdk
|
||||
packages/account-portal/packages/server/build
|
||||
packages/account-portal/packages/ui/.routify
|
||||
packages/account-portal/packages/ui/build
|
||||
**/*.ivm.bundle.js
|
||||
packages/server/build/oldClientVersions/**/**
|
||||
|
|
|
@ -64,18 +64,15 @@ jobs:
|
|||
- run: yarn --frozen-lockfile
|
||||
|
||||
# Run build all the projects
|
||||
- name: Build OSS
|
||||
run: yarn build:oss
|
||||
- name: Build account portal
|
||||
run: yarn build:account-portal
|
||||
if: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
|
||||
- name: Build
|
||||
run: yarn build
|
||||
# Check the types of the projects built via esbuild
|
||||
- name: Check types
|
||||
run: |
|
||||
if ${{ env.ONLY_AFFECTED_TASKS }}; then
|
||||
yarn check:types --since=${{ env.NX_BASE_BRANCH }} --ignore @budibase/account-portal-server
|
||||
yarn check:types --since=${{ env.NX_BASE_BRANCH }}
|
||||
else
|
||||
yarn check:types --ignore @budibase/account-portal-server
|
||||
yarn check:types
|
||||
fi
|
||||
|
||||
helm-lint:
|
||||
|
@ -147,7 +144,10 @@ jobs:
|
|||
fi
|
||||
|
||||
test-server:
|
||||
runs-on: budi-tubby-tornado-quad-core-300gb
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
datasource: [mssql, mysql, postgres, mongodb, mariadb, oracle, none]
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
@ -170,12 +170,19 @@ jobs:
|
|||
|
||||
- name: Pull testcontainers images
|
||||
run: |
|
||||
docker pull mcr.microsoft.com/mssql/server@${{ steps.dotenv.outputs.MSSQL_SHA }} &
|
||||
docker pull mysql@${{ steps.dotenv.outputs.MYSQL_SHA }} &
|
||||
docker pull postgres@${{ steps.dotenv.outputs.POSTGRES_SHA }} &
|
||||
docker pull mongo@${{ steps.dotenv.outputs.MONGODB_SHA }} &
|
||||
docker pull mariadb@${{ steps.dotenv.outputs.MARIADB_SHA }} &
|
||||
docker pull budibase/oracle-database:23.2-slim-faststart &
|
||||
if [ "${{ matrix.datasource }}" == "mssql" ]; then
|
||||
docker pull mcr.microsoft.com/mssql/server@${{ steps.dotenv.outputs.MSSQL_SHA }}
|
||||
elif [ "${{ matrix.datasource }}" == "mysql" ]; then
|
||||
docker pull mysql@${{ steps.dotenv.outputs.MYSQL_SHA }}
|
||||
elif [ "${{ matrix.datasource }}" == "postgres" ]; then
|
||||
docker pull postgres@${{ steps.dotenv.outputs.POSTGRES_SHA }}
|
||||
elif [ "${{ matrix.datasource }}" == "mongodb" ]; then
|
||||
docker pull mongo@${{ steps.dotenv.outputs.MONGODB_SHA }}
|
||||
elif [ "${{ matrix.datasource }}" == "mariadb" ]; then
|
||||
docker pull mariadb@${{ steps.dotenv.outputs.MARIADB_SHA }}
|
||||
elif [ "${{ matrix.datasource }}" == "oracle" ]; then
|
||||
docker pull budibase/oracle-database:23.2-slim-faststart
|
||||
fi
|
||||
docker pull minio/minio &
|
||||
docker pull redis &
|
||||
docker pull testcontainers/ryuk:0.5.1 &
|
||||
|
@ -186,13 +193,25 @@ jobs:
|
|||
- run: yarn --frozen-lockfile
|
||||
|
||||
- name: Test server
|
||||
env:
|
||||
DATASOURCE: ${{ matrix.datasource }}
|
||||
run: |
|
||||
if ${{ env.ONLY_AFFECTED_TASKS }}; then
|
||||
node scripts/run-affected.js --task=test --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
|
||||
else
|
||||
yarn test --scope=@budibase/server
|
||||
AFFECTED=$(yarn --silent nx show projects --affected -t test --base=${{ env.NX_BASE_BRANCH }} -p @budibase/server)
|
||||
if [ -z "$AFFECTED" ]; then
|
||||
echo "No affected tests to run"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
FILTER="./src/tests/filters/datasource-tests.js"
|
||||
if [ "${{ matrix.datasource }}" == "none" ]; then
|
||||
FILTER="./src/tests/filters/non-datasource-tests.js"
|
||||
fi
|
||||
|
||||
cd packages/server
|
||||
yarn test --filter $FILTER --passWithNoTests
|
||||
|
||||
check-pro-submodule:
|
||||
runs-on: ubuntu-latest
|
||||
if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase')
|
||||
|
@ -252,60 +271,26 @@ jobs:
|
|||
echo 'All good, the submodule had been merged and setup correctly!'
|
||||
fi
|
||||
|
||||
check-accountportal-submodule:
|
||||
check-lockfile:
|
||||
runs-on: ubuntu-latest
|
||||
if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase')
|
||||
steps:
|
||||
- name: Checkout repo and submodules
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: changes
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- packages/account-portal/**
|
||||
|
||||
- if: steps.changes.outputs.src == 'true'
|
||||
name: Check account portal commit
|
||||
id: get_accountportal_commits
|
||||
node-version: 20.x
|
||||
cache: yarn
|
||||
- run: yarn install
|
||||
- name: Check for yarn.lock changes
|
||||
run: |
|
||||
cd packages/account-portal
|
||||
accountportal_commit=$(git rev-parse HEAD)
|
||||
|
||||
branch="${{ github.base_ref || github.ref_name }}"
|
||||
echo "Running on branch '$branch' (base_ref=${{ github.base_ref }}, ref_name=${{ github.head_ref }})"
|
||||
|
||||
base_commit=$(git rev-parse origin/master)
|
||||
|
||||
if [[ ! -z $base_commit ]]; then
|
||||
echo "target_branch=$branch"
|
||||
echo "target_branch=$branch" >> "$GITHUB_OUTPUT"
|
||||
echo "accountportal_commit=$accountportal_commit"
|
||||
echo "accountportal_commit=$accountportal_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "base_commit=$base_commit"
|
||||
echo "base_commit=$base_commit" >> "$GITHUB_OUTPUT"
|
||||
if [[ $(git status --porcelain) == *"yarn.lock"* ]]; then
|
||||
echo "yarn.lock file needs to be modified. Please update it locally and commit the changes."
|
||||
exit 1
|
||||
else
|
||||
echo "Nothing to do - branch to branch merge."
|
||||
echo "yarn.lock file is unchanged."
|
||||
fi
|
||||
|
||||
- name: Check submodule merged to base branch
|
||||
if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }}
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const submoduleCommit = '${{ steps.get_accountportal_commits.outputs.accountportal_commit }}';
|
||||
const baseCommit = '${{ steps.get_accountportal_commits.outputs.base_commit }}';
|
||||
|
||||
if (submoduleCommit !== baseCommit) {
|
||||
console.error('Submodule commit does not match the latest commit on the "${{ steps.get_accountportal_commits.outputs.target_branch }}" branch.');
|
||||
console.error('Refer to the account portal repo to merge your changes: https://github.com/Budibase/account-portal/blob/master/docs/index.md')
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('All good, the submodule had been merged and setup correctly!')
|
||||
}
|
||||
|
|
|
@ -2,27 +2,39 @@ name: deploy-featurebranch
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
types: [
|
||||
labeled,
|
||||
# default types below (https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request)
|
||||
opened,
|
||||
synchronize,
|
||||
reopened,
|
||||
]
|
||||
types:
|
||||
- labeled
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: |
|
||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch')
|
||||
(
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-pro') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-team') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-business') ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PAYLOAD_BRANCH: ${{ github.head_ref }}
|
||||
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PAYLOAD_LICENSE_TYPE: |
|
||||
${{
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch') && 'free' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-pro') && 'pro' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-team') && 'team' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-business') && 'business' ||
|
||||
contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise') && 'enterprise' || 'free'
|
||||
}}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: passeidireto/trigger-external-workflow-action@main
|
||||
env:
|
||||
PAYLOAD_BRANCH: ${{ github.head_ref }}
|
||||
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PAYLOAD_LICENSE_TYPE: "free"
|
||||
with:
|
||||
repository: budibase/budibase-deploys
|
||||
event: featurebranch-qa-deploy
|
||||
|
|
|
@ -13,7 +13,6 @@ on:
|
|||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
|
|
|
@ -4,11 +4,11 @@ packages/server/runtime_apps/
|
|||
.idea/
|
||||
bb-airgapped.tar.gz
|
||||
*.iml
|
||||
|
||||
packages/server/build/oldClientVersions/**/*
|
||||
packages/builder/src/components/deploy/clientVersions.json
|
||||
|
||||
packages/server/src/integrations/tests/utils/*.lock
|
||||
packages/builder/vite.config.mjs.timestamp*
|
||||
packages/account-portal
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
@ -111,4 +111,4 @@ budibase-component
|
|||
budibase-datasource
|
||||
|
||||
*.iml
|
||||
.nx
|
||||
.nx
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
[submodule "packages/pro"]
|
||||
path = packages/pro
|
||||
url = git@github.com:Budibase/budibase-pro.git
|
||||
[submodule "packages/account-portal"]
|
||||
path = packages/account-portal
|
||||
url = git@github.com:Budibase/account-portal.git
|
||||
|
|
|
@ -9,8 +9,4 @@ packages/backend-core/coverage
|
|||
packages/builder/.routify
|
||||
packages/sdk/sdk
|
||||
packages/pro/coverage
|
||||
packages/account-portal/packages/ui/build
|
||||
packages/account-portal/packages/ui/.routify
|
||||
packages/account-portal/packages/server/build
|
||||
packages/account-portal/packages/server/coverage
|
||||
**/*.ivm.bundle.js
|
|
@ -42,14 +42,12 @@ spec:
|
|||
{{ else }}
|
||||
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
|
||||
{{ end }}
|
||||
{{ if .Values.globals.sqs.enabled }}
|
||||
- name: COUCH_DB_SQL_URL
|
||||
{{ if .Values.globals.sqs.url }}
|
||||
value: {{ .Values.globals.sqs.url }}
|
||||
{{ else }}
|
||||
{{ if .Values.globals.sqs.url }}
|
||||
value: {{ .Values.globals.sqs.url | quote }}
|
||||
{{ else }}
|
||||
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ if .Values.services.couchdb.enabled }}
|
||||
- name: COUCH_DB_USER
|
||||
valueFrom:
|
||||
|
|
|
@ -43,6 +43,12 @@ spec:
|
|||
{{ else }}
|
||||
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
|
||||
{{ end }}
|
||||
- name: COUCH_DB_SQL_URL
|
||||
{{ if .Values.globals.sqs.url }}
|
||||
value: {{ .Values.globals.sqs.url | quote }}
|
||||
{{ else }}
|
||||
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
|
||||
{{ end }}
|
||||
{{ if .Values.services.couchdb.enabled }}
|
||||
- name: COUCH_DB_USER
|
||||
valueFrom:
|
||||
|
|
|
@ -56,14 +56,12 @@ spec:
|
|||
{{ else }}
|
||||
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.services.couchdb.port }}
|
||||
{{ end }}
|
||||
{{ if .Values.globals.sqs.enabled }}
|
||||
- name: COUCH_DB_SQL_URL
|
||||
{{ if .Values.globals.sqs.url }}
|
||||
value: {{ .Values.globals.sqs.url }}
|
||||
{{ else }}
|
||||
{{ if .Values.globals.sqs.url }}
|
||||
value: {{ .Values.globals.sqs.url | quote }}
|
||||
{{ else }}
|
||||
value: http://{{ .Release.Name }}-svc-couchdb:{{ .Values.globals.sqs.port }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
- name: API_ENCRYPTION_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
|
|
@ -139,9 +139,6 @@ globals:
|
|||
password: ""
|
||||
|
||||
sqs:
|
||||
# -- Whether to use the CouchDB "structured query service" or not. This is disabled by
|
||||
# default for now, but will become the default in a future release.
|
||||
enabled: false
|
||||
# @ignore
|
||||
url: ""
|
||||
# @ignore
|
||||
|
|
|
@ -423,9 +423,9 @@ core-js-pure@^3.20.2:
|
|||
integrity sha512-12VZfFIu+wyVbBebyHmRTuEE/tZrB4tJToWcwAMcsp3h4+sHR+fMJWbKpYiCRWlhFBq+KNyO8rIV9rTkeVmznQ==
|
||||
|
||||
cross-spawn@^7.0.2:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
||||
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
|
||||
version "7.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
|
||||
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
|
||||
dependencies:
|
||||
path-key "^3.1.0"
|
||||
shebang-command "^2.0.0"
|
||||
|
|
|
@ -62,6 +62,7 @@ export default async function setup() {
|
|||
},
|
||||
])
|
||||
.withLabels({ "com.budibase": "true" })
|
||||
.withTmpFs({ "/data": "rw" })
|
||||
.withReuse()
|
||||
.withWaitStrategy(
|
||||
Wait.forSuccessfulCommand(
|
||||
|
@ -72,6 +73,7 @@ export default async function setup() {
|
|||
const minio = new GenericContainer("minio/minio")
|
||||
.withExposedPorts(9000)
|
||||
.withCommand(["server", "/data"])
|
||||
.withTmpFs({ "/data": "rw" })
|
||||
.withEnvironment({
|
||||
MINIO_ACCESS_KEY: "budibase",
|
||||
MINIO_SECRET_KEY: "budibase",
|
||||
|
|
|
@ -19,7 +19,6 @@ MINIO_PORT=4004
|
|||
COUCH_DB_PORT=4005
|
||||
COUCH_DB_SQS_PORT=4006
|
||||
REDIS_PORT=6379
|
||||
WATCHTOWER_PORT=6161
|
||||
BUDIBASE_ENVIRONMENT=PRODUCTION
|
||||
SQL_MAX_ROWS=
|
||||
|
||||
|
|
|
@ -74,7 +74,6 @@ services:
|
|||
- WORKER_UPSTREAM_URL=http://worker-service:4003
|
||||
- MINIO_UPSTREAM_URL=http://minio-service:9000
|
||||
- COUCHDB_UPSTREAM_URL=http://couchdb-service:5984
|
||||
- WATCHTOWER_UPSTREAM_URL=http://watchtower-service:8080
|
||||
- RESOLVER=127.0.0.11
|
||||
depends_on:
|
||||
- minio-service
|
||||
|
|
|
@ -5,7 +5,7 @@ version: "3"
|
|||
services:
|
||||
app-service:
|
||||
restart: unless-stopped
|
||||
image: budibase.docker.scarf.sh/budibase/apps
|
||||
image: budibase/apps
|
||||
container_name: bbapps
|
||||
environment:
|
||||
SELF_HOSTED: 1
|
||||
|
@ -35,7 +35,7 @@ services:
|
|||
|
||||
worker-service:
|
||||
restart: unless-stopped
|
||||
image: budibase.docker.scarf.sh/budibase/worker
|
||||
image: budibase/worker
|
||||
container_name: bbworker
|
||||
environment:
|
||||
SELF_HOSTED: 1
|
||||
|
@ -87,7 +87,6 @@ services:
|
|||
- WORKER_UPSTREAM_URL=http://worker-service:4003
|
||||
- MINIO_UPSTREAM_URL=http://minio-service:9000
|
||||
- COUCHDB_UPSTREAM_URL=http://couchdb-service:5984
|
||||
- WATCHTOWER_UPSTREAM_URL=http://watchtower-service:8080
|
||||
- RESOLVER=127.0.0.11
|
||||
depends_on:
|
||||
- minio-service
|
||||
|
@ -97,7 +96,7 @@ services:
|
|||
|
||||
couchdb-service:
|
||||
restart: unless-stopped
|
||||
image: budibase/couchdb
|
||||
image: budibase/couchdb:v3.3.3-sqs-v2.1.1
|
||||
environment:
|
||||
- COUCHDB_PASSWORD=${COUCH_DB_PASSWORD}
|
||||
- COUCHDB_USER=${COUCH_DB_USER}
|
||||
|
@ -112,19 +111,6 @@ services:
|
|||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
watchtower-service:
|
||||
restart: always
|
||||
image: containrrr/watchtower
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: --debug --http-api-update bbapps bbworker bbproxy
|
||||
environment:
|
||||
- WATCHTOWER_HTTP_API=true
|
||||
- WATCHTOWER_HTTP_API_TOKEN=budibase
|
||||
- WATCHTOWER_CLEANUP=true
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=false"
|
||||
|
||||
volumes:
|
||||
couchdb3_data:
|
||||
driver: local
|
||||
|
|
|
@ -1,152 +0,0 @@
|
|||
static_resources:
|
||||
listeners:
|
||||
- name: main_listener
|
||||
address:
|
||||
socket_address: { address: 0.0.0.0, port_value: 10000 }
|
||||
filter_chains:
|
||||
- filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: ingress
|
||||
codec_type: auto
|
||||
route_config:
|
||||
name: local_route
|
||||
virtual_hosts:
|
||||
- name: local_services
|
||||
domains: ["*"]
|
||||
routes:
|
||||
- match: { prefix: "/app/" }
|
||||
route:
|
||||
cluster: app-service
|
||||
prefix_rewrite: "/"
|
||||
|
||||
- match: { path: "/v1/update" }
|
||||
route:
|
||||
cluster: watchtower-service
|
||||
|
||||
- match: { prefix: "/builder/" }
|
||||
route:
|
||||
cluster: app-service
|
||||
|
||||
- match: { prefix: "/builder" }
|
||||
route:
|
||||
cluster: app-service
|
||||
|
||||
- match: { prefix: "/app_" }
|
||||
route:
|
||||
cluster: app-service
|
||||
|
||||
# special cases for worker admin (deprecated), global and system API
|
||||
- match: { prefix: "/api/global/" }
|
||||
route:
|
||||
cluster: worker-service
|
||||
|
||||
- match: { prefix: "/api/admin/" }
|
||||
route:
|
||||
cluster: worker-service
|
||||
|
||||
- match: { prefix: "/api/system/" }
|
||||
route:
|
||||
cluster: worker-service
|
||||
|
||||
- match: { path: "/" }
|
||||
route:
|
||||
cluster: app-service
|
||||
|
||||
# special case for when API requests are made, can just forward, not to minio
|
||||
- match: { prefix: "/api/" }
|
||||
route:
|
||||
cluster: app-service
|
||||
timeout: 120s
|
||||
|
||||
- match: { prefix: "/worker/" }
|
||||
route:
|
||||
cluster: worker-service
|
||||
prefix_rewrite: "/"
|
||||
|
||||
- match: { prefix: "/db/" }
|
||||
route:
|
||||
cluster: couchdb-service
|
||||
prefix_rewrite: "/"
|
||||
|
||||
# minio is on the default route because this works
|
||||
# best, minio + AWS SDK doesn't handle path proxy
|
||||
- match: { prefix: "/" }
|
||||
route:
|
||||
cluster: minio-service
|
||||
|
||||
http_filters:
|
||||
- name: envoy.filters.http.router
|
||||
|
||||
clusters:
|
||||
- name: app-service
|
||||
connect_timeout: 0.25s
|
||||
type: strict_dns
|
||||
lb_policy: round_robin
|
||||
load_assignment:
|
||||
cluster_name: app-service
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: app-service
|
||||
port_value: 4002
|
||||
|
||||
- name: minio-service
|
||||
connect_timeout: 0.25s
|
||||
type: strict_dns
|
||||
lb_policy: round_robin
|
||||
load_assignment:
|
||||
cluster_name: minio-service
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: minio-service
|
||||
port_value: 9000
|
||||
|
||||
- name: worker-service
|
||||
connect_timeout: 0.25s
|
||||
type: strict_dns
|
||||
lb_policy: round_robin
|
||||
load_assignment:
|
||||
cluster_name: worker-service
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: worker-service
|
||||
port_value: 4003
|
||||
|
||||
- name: couchdb-service
|
||||
connect_timeout: 0.25s
|
||||
type: strict_dns
|
||||
lb_policy: round_robin
|
||||
load_assignment:
|
||||
cluster_name: couchdb-service
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: couchdb-service
|
||||
port_value: 5984
|
||||
|
||||
- name: watchtower-service
|
||||
connect_timeout: 0.25s
|
||||
type: strict_dns
|
||||
lb_policy: round_robin
|
||||
load_assignment:
|
||||
cluster_name: watchtower-service
|
||||
endpoints:
|
||||
- lb_endpoints:
|
||||
- endpoint:
|
||||
address:
|
||||
socket_address:
|
||||
address: watchtower-service
|
||||
port_value: 8080
|
||||
|
|
@ -18,7 +18,6 @@ WORKER_PORT=4003
|
|||
MINIO_PORT=4004
|
||||
COUCH_DB_PORT=4005
|
||||
REDIS_PORT=6379
|
||||
WATCHTOWER_PORT=6161
|
||||
BUDIBASE_ENVIRONMENT=PRODUCTION
|
||||
|
||||
# An admin user can be automatically created initially if these are set
|
||||
|
@ -26,4 +25,4 @@ BB_ADMIN_USER_EMAIL=
|
|||
BB_ADMIN_USER_PASSWORD=
|
||||
|
||||
# A path that is watched for plugin bundles. Any bundles found are imported automatically/
|
||||
PLUGINS_DIR=
|
||||
PLUGINS_DIR=
|
||||
|
|
|
@ -78,11 +78,6 @@
|
|||
"default": "6379",
|
||||
"preset": true
|
||||
},
|
||||
{
|
||||
"name": "WATCHTOWER_PORT",
|
||||
"default": "6161",
|
||||
"preset": true
|
||||
},
|
||||
{
|
||||
"name": "BUDIBASE_ENVIRONMENT",
|
||||
"default": "PRODUCTION",
|
||||
|
|
|
@ -22,5 +22,4 @@ ENV APPS_UPSTREAM_URL=http://app-service:4002
|
|||
ENV WORKER_UPSTREAM_URL=http://worker-service:4003
|
||||
ENV MINIO_UPSTREAM_URL=http://minio-service:9000
|
||||
ENV COUCHDB_UPSTREAM_URL=http://couchdb-service:5984
|
||||
ENV WATCHTOWER_UPSTREAM_URL=http://watchtower-service:8080
|
||||
ENV RESOLVER=127.0.0.11
|
||||
|
|
|
@ -50,19 +50,6 @@ http {
|
|||
ignore_invalid_headers off;
|
||||
proxy_buffering off;
|
||||
|
||||
set $csp_default "default-src 'self'";
|
||||
set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com";
|
||||
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://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com https://us.i.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 https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.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 https://cdn.budi.live";
|
||||
set $csp_worker "worker-src blob:";
|
||||
|
||||
error_page 502 503 504 /error.html;
|
||||
location = /error.html {
|
||||
root /usr/share/nginx/html;
|
||||
|
@ -73,7 +60,6 @@ http {
|
|||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Content-Security-Policy "${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;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
# upstreams
|
||||
|
@ -81,7 +67,6 @@ http {
|
|||
set $worker ${WORKER_UPSTREAM_URL};
|
||||
set $minio ${MINIO_UPSTREAM_URL};
|
||||
set $couchdb ${COUCHDB_UPSTREAM_URL};
|
||||
set $watchtower ${WATCHTOWER_UPSTREAM_URL};
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
|
@ -107,10 +92,6 @@ http {
|
|||
proxy_pass $apps;
|
||||
}
|
||||
|
||||
location = /v1/update {
|
||||
proxy_pass $watchtower;
|
||||
}
|
||||
|
||||
location ~ ^/(builder|app_) {
|
||||
proxy_http_version 1.1;
|
||||
|
||||
|
@ -125,6 +106,12 @@ http {
|
|||
|
||||
location ~ ^/api/(system|admin|global)/ {
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# Enable buffering for potentially large OIDC configs
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 16k;
|
||||
proxy_buffers 4 32k;
|
||||
|
||||
proxy_pass $worker;
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ let IMAGES = {
|
|||
couch: "ibmcom/couchdb3",
|
||||
curl: "curlimages/curl",
|
||||
redis: "redis",
|
||||
watchtower: "containrrr/watchtower",
|
||||
}
|
||||
|
||||
if (IS_SINGLE_IMAGE) {
|
||||
|
@ -53,4 +52,4 @@ if (!IS_SINGLE_IMAGE) {
|
|||
copyFile(FILES.ENV)
|
||||
|
||||
// compress
|
||||
execSync(`tar -czf bb-airgapped.tar.gz hosting/scripts/bb-airgapped`)
|
||||
execSync(`tar -czf bb-airgapped.tar.gz hosting/scripts/bb-airgapped`)
|
||||
|
|
|
@ -22,7 +22,8 @@ RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
|
|||
|
||||
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
|
||||
RUN ./scripts/removeWorkspaceDependencies.sh package.json
|
||||
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile
|
||||
ARG TARGETPLATFORM
|
||||
RUN --mount=type=cache,target=/root/.yarn/${TARGETPLATFORM} YARN_CACHE_FOLDER=/root/.yarn/${TARGETPLATFORM} yarn install --production --frozen-lockfile
|
||||
|
||||
# copy the actual code
|
||||
COPY packages/server/dist packages/server/dist
|
||||
|
@ -69,6 +70,9 @@ WORKDIR /minio
|
|||
COPY scripts/install-minio.sh ./install.sh
|
||||
RUN chmod +x install.sh && ./install.sh
|
||||
|
||||
# setup redis
|
||||
COPY hosting/single/redis.conf /etc/redis/redis.conf
|
||||
|
||||
# setup runner file
|
||||
WORKDIR /
|
||||
COPY hosting/single/runner.sh .
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
dir "DATA_DIR/redis"
|
||||
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
|
@ -75,13 +75,23 @@ fi
|
|||
for LINE in $(cat ${DATA_DIR}/.env); do export $LINE; done
|
||||
ln -s ${DATA_DIR}/.env /app/.env
|
||||
ln -s ${DATA_DIR}/.env /worker/.env
|
||||
|
||||
# make these directories in runner, incase of mount
|
||||
mkdir -p ${DATA_DIR}/minio
|
||||
mkdir -p ${DATA_DIR}/redis
|
||||
chown -R couchdb:couchdb ${DATA_DIR}/couch
|
||||
|
||||
REDIS_CONFIG="/etc/redis/redis.conf"
|
||||
sed -i "s#DATA_DIR#${DATA_DIR}#g" "${REDIS_CONFIG}"
|
||||
|
||||
if [[ -n "${USE_DEFAULT_REDIS_CONFIG}" ]]; then
|
||||
REDIS_CONFIG=""
|
||||
fi
|
||||
|
||||
if [[ -n "${REDIS_PASSWORD}" ]]; then
|
||||
redis-server --requirepass $REDIS_PASSWORD > /dev/stdout 2>&1 &
|
||||
redis-server "${REDIS_CONFIG}" --requirepass $REDIS_PASSWORD > /dev/stdout 2>&1 &
|
||||
else
|
||||
redis-server > /dev/stdout 2>&1 &
|
||||
redis-server "${REDIS_CONFIG}" > /dev/stdout 2>&1 &
|
||||
fi
|
||||
/bbcouch-runner.sh &
|
||||
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "2.32.15",
|
||||
"version": "3.2.9",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"!packages/account-portal",
|
||||
"packages/account-portal/packages/*"
|
||||
],
|
||||
"concurrency": 20,
|
||||
"command": {
|
||||
"publish": {
|
||||
|
|
1
nx.json
1
nx.json
|
@ -2,7 +2,6 @@
|
|||
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"runner": "nx-cloud",
|
||||
"options": {
|
||||
"cacheableOperations": ["build", "test", "check:types"]
|
||||
}
|
||||
|
|
17
package.json
17
package.json
|
@ -9,6 +9,7 @@
|
|||
"@types/node": "20.10.0",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@typescript-eslint/parser": "6.9.0",
|
||||
"depcheck": "^1.4.7",
|
||||
"esbuild": "^0.18.17",
|
||||
"esbuild-node-externals": "^1.14.0",
|
||||
"eslint": "^8.52.0",
|
||||
|
@ -35,11 +36,10 @@
|
|||
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
|
||||
"build": "DISABLE_V8_COMPILE_CACHE=1 NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream",
|
||||
"build:apps": "DISABLE_V8_COMPILE_CACHE=1 yarn build --scope @budibase/server --scope @budibase/worker",
|
||||
"build:oss": "DISABLE_V8_COMPILE_CACHE=1 NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --ignore @budibase/account-portal-server --ignore @budibase/account-portal-ui",
|
||||
"build:cli": "yarn build --scope @budibase/cli",
|
||||
"build:account-portal": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream --scope @budibase/account-portal-server --scope @budibase/account-portal-ui",
|
||||
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
|
||||
"check:types": "lerna run --concurrency 2 check:types --ignore @budibase/account-portal-server",
|
||||
"check:types": "yarn check:dependencies && lerna run --concurrency 2 check:types",
|
||||
"check:dependencies": "lerna run --concurrency 2 check:dependencies",
|
||||
"build:sdk": "lerna run --stream build:sdk",
|
||||
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
|
||||
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
|
||||
|
@ -52,12 +52,9 @@
|
|||
"kill-server": "kill-port 4001 4002",
|
||||
"kill-accountportal": "kill-port 3001 4003",
|
||||
"kill-all": "yarn run kill-builder && yarn run kill-server && yarn kill-accountportal",
|
||||
"dev": "yarn run kill-all && lerna run --parallel prebuild && lerna run --stream dev --ignore=@budibase/account-portal-ui --ignore @budibase/account-portal-server",
|
||||
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up --ignore @budibase/account-portal-server && lerna run --stream dev --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker --ignore=@budibase/account-portal-ui --ignore @budibase/account-portal-server",
|
||||
"dev": "yarn run kill-all && lerna run --parallel prebuild && lerna run --stream dev",
|
||||
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
|
||||
"dev:server": "yarn run kill-server && lerna run --stream dev --scope @budibase/worker --scope @budibase/server",
|
||||
"dev:accountportal": "yarn kill-accountportal && lerna run dev --stream --scope @budibase/account-portal-ui --scope @budibase/account-portal-server",
|
||||
"dev:camunda": "./scripts/deploy-camunda.sh",
|
||||
"dev:all": "yarn run kill-all && lerna run --stream dev",
|
||||
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
|
||||
"dev:docker": "./scripts/devDocker.sh",
|
||||
"test": "lerna run --concurrency 1 --stream test --stream",
|
||||
|
@ -98,9 +95,7 @@
|
|||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"!packages/account-portal",
|
||||
"packages/account-portal/packages/*"
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 8cd052ce8288f343812a514d06c5a9459b3ba1a8
|
|
@ -1,6 +1,4 @@
|
|||
*
|
||||
!dist/**/*
|
||||
dist/tsconfig.build.tsbuildinfo
|
||||
!package.json
|
||||
!src/**
|
||||
!tests/**
|
||||
!package.json
|
|
@ -9,6 +9,13 @@
|
|||
"./tests": "./dist/tests/index.js",
|
||||
"./*": "./dist/*.js"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"tests": [
|
||||
"dist/tests/index.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"author": "Budibase",
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
|
@ -17,6 +24,7 @@
|
|||
"build": "tsc -p tsconfig.build.json --paths null && node ./scripts/build.js",
|
||||
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
|
||||
"check:types": "tsc -p tsconfig.json --noEmit --paths null --target es2020",
|
||||
"check:dependencies": "node ../../scripts/depcheck.js",
|
||||
"test": "bash scripts/test.sh",
|
||||
"test:watch": "jest --watchAll"
|
||||
},
|
||||
|
@ -36,6 +44,7 @@
|
|||
"ioredis": "5.3.2",
|
||||
"joi": "17.6.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"knex": "2.4.2",
|
||||
"koa-passport": "^6.0.0",
|
||||
"koa-pino-logger": "4.0.0",
|
||||
"lodash": "4.17.21",
|
||||
|
@ -54,9 +63,12 @@
|
|||
"semver": "^7.5.4",
|
||||
"tar-fs": "2.1.1",
|
||||
"uuid": "^8.3.2",
|
||||
"knex": "2.4.2"
|
||||
"@techpass/passport-openidconnect": "0.3.3",
|
||||
"google-auth-library": "^8.0.1",
|
||||
"google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/types": "^29.6.3",
|
||||
"@shopify/jest-koa-mocks": "5.1.1",
|
||||
"@swc/core": "1.3.71",
|
||||
"@swc/jest": "0.2.27",
|
||||
|
@ -64,6 +76,7 @@
|
|||
"@types/cookies": "0.7.8",
|
||||
"@types/jest": "29.5.5",
|
||||
"@types/lodash": "4.14.200",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/pouchdb": "6.4.0",
|
||||
"@types/redlock": "4.0.7",
|
||||
|
@ -74,6 +87,7 @@
|
|||
"ioredis-mock": "8.9.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-serial-runner": "1.2.1",
|
||||
"nock": "^13.5.6",
|
||||
"pino-pretty": "10.0.0",
|
||||
"pouchdb-adapter-memory": "7.2.2",
|
||||
"testcontainers": "^10.7.2",
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
import tk from "timekeeper"
|
||||
|
||||
import _ from "lodash"
|
||||
import { DBTestConfiguration, generator, structures } from "../../../tests"
|
||||
import {
|
||||
DBTestConfiguration,
|
||||
generator,
|
||||
structures,
|
||||
utils,
|
||||
} from "../../../tests"
|
||||
import { getDB } from "../../db"
|
||||
|
||||
import {
|
||||
|
@ -10,15 +15,14 @@ import {
|
|||
init,
|
||||
} from "../docWritethrough"
|
||||
|
||||
import InMemoryQueue from "../../queue/inMemoryQueue"
|
||||
|
||||
const initialTime = Date.now()
|
||||
|
||||
async function waitForQueueCompletion() {
|
||||
const queue: InMemoryQueue = DocWritethroughProcessor.queue as never
|
||||
await queue.waitForCompletion()
|
||||
await utils.queue.processMessages(DocWritethroughProcessor.queue)
|
||||
}
|
||||
|
||||
beforeAll(() => utils.queue.useRealQueues())
|
||||
|
||||
describe("docWritethrough", () => {
|
||||
beforeAll(() => {
|
||||
init()
|
||||
|
@ -67,7 +71,7 @@ describe("docWritethrough", () => {
|
|||
const patch3 = generatePatchObject(3)
|
||||
await docWritethrough.patch(patch3)
|
||||
|
||||
expect(await db.get(documentId)).toEqual({
|
||||
expect(await db.tryGet(documentId)).toEqual({
|
||||
_id: documentId,
|
||||
...patch1,
|
||||
...patch2,
|
||||
|
@ -92,7 +96,7 @@ describe("docWritethrough", () => {
|
|||
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
_id: documentId,
|
||||
...patch1,
|
||||
|
@ -117,7 +121,7 @@ describe("docWritethrough", () => {
|
|||
await waitForQueueCompletion()
|
||||
|
||||
expect(date1).not.toEqual(date2)
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
createdAt: date1.toISOString(),
|
||||
updatedAt: date2.toISOString(),
|
||||
|
@ -135,7 +139,7 @@ describe("docWritethrough", () => {
|
|||
await docWritethrough.patch(patch2)
|
||||
|
||||
const keyToOverride = _.sample(Object.keys(patch1))!
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
[keyToOverride]: patch1[keyToOverride],
|
||||
})
|
||||
|
@ -150,7 +154,7 @@ describe("docWritethrough", () => {
|
|||
await docWritethrough.patch(patch3)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
...patch1,
|
||||
...patch2,
|
||||
|
@ -180,14 +184,14 @@ describe("docWritethrough", () => {
|
|||
await secondDocWritethrough.patch(doc2Patch2)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(docWritethrough.docId)).toEqual(
|
||||
expect(await db.tryGet(docWritethrough.docId)).toEqual(
|
||||
expect.objectContaining({
|
||||
...doc1Patch,
|
||||
...doc1Patch2,
|
||||
})
|
||||
)
|
||||
|
||||
expect(await db.get(secondDocWritethrough.docId)).toEqual(
|
||||
expect(await db.tryGet(secondDocWritethrough.docId)).toEqual(
|
||||
expect.objectContaining({
|
||||
...doc2Patch,
|
||||
...doc2Patch2,
|
||||
|
@ -203,7 +207,7 @@ describe("docWritethrough", () => {
|
|||
await docWritethrough.patch(initialPatch)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining(initialPatch)
|
||||
)
|
||||
|
||||
|
@ -214,10 +218,10 @@ describe("docWritethrough", () => {
|
|||
await docWritethrough.patch(extraPatch)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining(extraPatch)
|
||||
)
|
||||
expect(await db.get(documentId)).not.toEqual(
|
||||
expect(await db.tryGet(documentId)).not.toEqual(
|
||||
expect.objectContaining(initialPatch)
|
||||
)
|
||||
})
|
||||
|
@ -242,7 +246,7 @@ describe("docWritethrough", () => {
|
|||
expect(queueMessageSpy).toHaveBeenCalledTimes(5)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining(patches)
|
||||
)
|
||||
|
||||
|
@ -250,7 +254,7 @@ describe("docWritethrough", () => {
|
|||
expect(queueMessageSpy).toHaveBeenCalledTimes(45)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining(patches)
|
||||
)
|
||||
|
||||
|
@ -258,20 +262,18 @@ describe("docWritethrough", () => {
|
|||
expect(queueMessageSpy).toHaveBeenCalledTimes(55)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining(patches)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// This is not yet supported
|
||||
// eslint-disable-next-line jest/no-disabled-tests
|
||||
it.skip("patches will execute in order", async () => {
|
||||
it("patches will execute in order", async () => {
|
||||
let incrementalValue = 0
|
||||
const keyToOverride = generator.word()
|
||||
async function incrementalPatches(count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await docWritethrough.patch({ [keyToOverride]: incrementalValue++ })
|
||||
await docWritethrough.patch({ [keyToOverride]: ++incrementalValue })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -279,13 +281,13 @@ describe("docWritethrough", () => {
|
|||
await incrementalPatches(5)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining({ [keyToOverride]: 5 })
|
||||
)
|
||||
|
||||
await incrementalPatches(40)
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect(await db.tryGet(documentId)).toEqual(
|
||||
expect.objectContaining({ [keyToOverride]: 45 })
|
||||
)
|
||||
})
|
||||
|
|
|
@ -28,6 +28,7 @@ export enum Config {
|
|||
OIDC = "oidc",
|
||||
OIDC_LOGOS = "logos_oidc",
|
||||
SCIM = "scim",
|
||||
AI = "AI",
|
||||
}
|
||||
|
||||
export const MIN_VALID_DATE = new Date(-2147483647000)
|
||||
|
|
|
@ -27,7 +27,7 @@ export function doInUserContext(user: User, ctx: Ctx, task: any) {
|
|||
hostInfo: {
|
||||
ipAddress: ctx.request.ip,
|
||||
// filled in by koa-useragent package
|
||||
userAgent: ctx.userAgent._agent.source,
|
||||
userAgent: ctx.userAgent.source,
|
||||
},
|
||||
}
|
||||
return doInIdentityContext(userContext, task)
|
||||
|
|
|
@ -26,7 +26,6 @@ import { SQLITE_DESIGN_DOC_ID } from "../../constants"
|
|||
import { DDInstrumentedDatabase } from "../instrumentation"
|
||||
import { checkSlashesInUrl } from "../../helpers"
|
||||
import { sqlLog } from "../../sql/utils"
|
||||
import { flags } from "../../features"
|
||||
|
||||
const DATABASE_NOT_FOUND = "Database does not exist."
|
||||
|
||||
|
@ -211,6 +210,17 @@ export class DatabaseImpl implements Database {
|
|||
})
|
||||
}
|
||||
|
||||
async tryGet<T extends Document>(id?: string): Promise<T | undefined> {
|
||||
try {
|
||||
return await this.get<T>(id)
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404) {
|
||||
return undefined
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async getMultiple<T extends Document>(
|
||||
ids: string[],
|
||||
opts?: { allowMissing?: boolean; excludeDocs?: boolean }
|
||||
|
@ -444,10 +454,7 @@ export class DatabaseImpl implements Database {
|
|||
}
|
||||
|
||||
async destroy() {
|
||||
if (
|
||||
(await flags.isEnabled("SQS")) &&
|
||||
(await this.exists(SQLITE_DESIGN_DOC_ID))
|
||||
) {
|
||||
if (await this.exists(SQLITE_DESIGN_DOC_ID)) {
|
||||
// delete the design document, then run the cleanup operation
|
||||
const definition = await this.get<SQLiteDefinition>(SQLITE_DESIGN_DOC_ID)
|
||||
// remove all tables - save the definition then trigger a cleanup
|
||||
|
|
|
@ -42,6 +42,13 @@ export class DDInstrumentedDatabase implements Database {
|
|||
})
|
||||
}
|
||||
|
||||
tryGet<T extends Document>(id?: string | undefined): Promise<T | undefined> {
|
||||
return tracer.trace("db.tryGet", span => {
|
||||
span?.addTags({ db_name: this.name, doc_id: id })
|
||||
return this.db.tryGet(id)
|
||||
})
|
||||
}
|
||||
|
||||
getMultiple<T extends Document>(
|
||||
ids: string[],
|
||||
opts?: { allowMissing?: boolean | undefined } | undefined
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { existsSync, readFileSync } from "fs"
|
||||
import { ServiceType } from "@budibase/types"
|
||||
import { cloneDeep } from "lodash"
|
||||
import { createSecretKey } from "crypto"
|
||||
|
||||
function isTest() {
|
||||
return isJest()
|
||||
|
@ -18,6 +19,12 @@ function isDev() {
|
|||
return process.env.NODE_ENV !== "production"
|
||||
}
|
||||
|
||||
function parseIntSafe(number?: string) {
|
||||
if (number) {
|
||||
return parseInt(number)
|
||||
}
|
||||
}
|
||||
|
||||
let LOADED = false
|
||||
if (!LOADED && isDev() && !isTest()) {
|
||||
require("dotenv").config()
|
||||
|
@ -54,30 +61,46 @@ function getPackageJsonFields(): {
|
|||
VERSION: string
|
||||
SERVICE_NAME: string
|
||||
} {
|
||||
function findFileInAncestors(
|
||||
fileName: string,
|
||||
currentDir: string
|
||||
): string | null {
|
||||
const filePath = `${currentDir}/${fileName}`
|
||||
if (existsSync(filePath)) {
|
||||
return filePath
|
||||
function getParentFile(file: string) {
|
||||
function findFileInAncestors(
|
||||
fileName: string,
|
||||
currentDir: string
|
||||
): string | null {
|
||||
const filePath = `${currentDir}/${fileName}`
|
||||
if (existsSync(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
const parentDir = `${currentDir}/..`
|
||||
if (parentDir === currentDir) {
|
||||
// reached root directory
|
||||
return null
|
||||
}
|
||||
|
||||
return findFileInAncestors(fileName, parentDir)
|
||||
}
|
||||
|
||||
const parentDir = `${currentDir}/..`
|
||||
if (parentDir === currentDir) {
|
||||
// reached root directory
|
||||
return null
|
||||
}
|
||||
const packageJsonFile = findFileInAncestors(file, process.cwd())
|
||||
const content = readFileSync(packageJsonFile!, "utf-8")
|
||||
const parsedContent = JSON.parse(content)
|
||||
return parsedContent
|
||||
}
|
||||
|
||||
return findFileInAncestors(fileName, parentDir)
|
||||
let localVersion: string | undefined
|
||||
if (isDev() && !isTest()) {
|
||||
try {
|
||||
const lerna = getParentFile("lerna.json")
|
||||
localVersion = `${lerna.version}+local`
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
|
||||
const content = readFileSync(packageJsonFile!, "utf-8")
|
||||
const parsedContent = JSON.parse(content)
|
||||
const parsedContent = getParentFile("package.json")
|
||||
return {
|
||||
VERSION: process.env.BUDIBASE_VERSION || parsedContent.version,
|
||||
VERSION:
|
||||
localVersion || process.env.BUDIBASE_VERSION || parsedContent.version,
|
||||
SERVICE_NAME: parsedContent.name,
|
||||
}
|
||||
} catch {
|
||||
|
@ -110,8 +133,12 @@ const environment = {
|
|||
},
|
||||
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
|
||||
JS_BCRYPT: process.env.JS_BCRYPT,
|
||||
JWT_SECRET: process.env.JWT_SECRET,
|
||||
JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK,
|
||||
JWT_SECRET: process.env.JWT_SECRET
|
||||
? createSecretKey(Buffer.from(process.env.JWT_SECRET))
|
||||
: undefined,
|
||||
JWT_SECRET_FALLBACK: process.env.JWT_SECRET_FALLBACK
|
||||
? createSecretKey(Buffer.from(process.env.JWT_SECRET_FALLBACK))
|
||||
: undefined,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
API_ENCRYPTION_KEY: getAPIEncryptionKey(),
|
||||
COUCH_DB_URL: process.env.COUCH_DB_URL || "http://localhost:4005",
|
||||
|
@ -207,6 +234,10 @@ const environment = {
|
|||
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
|
||||
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
MIN_VERSION_WITHOUT_POWER_ROLE:
|
||||
process.env.MIN_VERSION_WITHOUT_POWER_ROLE || "3.0.0",
|
||||
DISABLE_CONTENT_SECURITY_POLICY: process.env.DISABLE_CONTENT_SECURITY_POLICY,
|
||||
BSON_BUFFER_SIZE: parseIntSafe(process.env.BSON_BUFFER_SIZE),
|
||||
}
|
||||
|
||||
export function setEnv(newEnvVars: Partial<typeof environment>): () => void {
|
||||
|
|
|
@ -171,9 +171,9 @@ const identifyUser = async (
|
|||
if (isSSOUser(user)) {
|
||||
providerType = user.providerType
|
||||
}
|
||||
const accountHolder = account?.budibaseUserId === user._id || false
|
||||
const verified =
|
||||
account && account?.budibaseUserId === user._id ? account.verified : false
|
||||
const accountHolder = await users.getExistingAccounts([user.email])
|
||||
const isAccountHolder = accountHolder.length > 0
|
||||
const verified = !!account && isAccountHolder && account.verified
|
||||
const installationId = await getInstallationId()
|
||||
const hosting = account ? account.hosting : getHostingFromEnv()
|
||||
const environment = getDeploymentEnvironment()
|
||||
|
@ -185,7 +185,7 @@ const identifyUser = async (
|
|||
installationId,
|
||||
tenantId,
|
||||
verified,
|
||||
accountHolder,
|
||||
accountHolder: isAccountHolder,
|
||||
providerType,
|
||||
builder,
|
||||
admin,
|
||||
|
@ -207,9 +207,10 @@ const identifyAccount = async (account: Account) => {
|
|||
const environment = getDeploymentEnvironment()
|
||||
|
||||
if (isCloudAccount(account)) {
|
||||
if (account.budibaseUserId) {
|
||||
const user = await users.getGlobalUserByEmail(account.email)
|
||||
if (user?._id) {
|
||||
// use the budibase user as the id if set
|
||||
id = account.budibaseUserId
|
||||
id = user._id
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -267,12 +267,10 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
|
|||
// All of the machinery in this file is to make sure that flags have their
|
||||
// default values set correctly and their types flow through the system.
|
||||
export const flags = new FlagSet({
|
||||
DEFAULT_VALUES: Flag.boolean(env.isDev()),
|
||||
AUTOMATION_BRANCHING: Flag.boolean(env.isDev()),
|
||||
SQS: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.TABLES_DEFAULT_ADMIN]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.DEFAULT_VALUES]: Flag.boolean(true),
|
||||
[FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(true),
|
||||
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(true),
|
||||
[FeatureFlag.BUDIBASE_AI]: Flag.boolean(true),
|
||||
})
|
||||
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
|
||||
|
|
|
@ -10,6 +10,7 @@ const schema = {
|
|||
TEST_BOOLEAN: Flag.boolean(false),
|
||||
TEST_STRING: Flag.string("default value"),
|
||||
TEST_NUMBER: Flag.number(0),
|
||||
TEST_BOOLEAN_DEFAULT_TRUE: Flag.boolean(true),
|
||||
}
|
||||
const flags = new FlagSet(schema)
|
||||
|
||||
|
@ -123,6 +124,11 @@ describe("feature flags", () => {
|
|||
},
|
||||
expected: flags.defaults(),
|
||||
},
|
||||
{
|
||||
it: "should be possible to override a default true flag to false",
|
||||
environmentFlags: "default:!TEST_BOOLEAN_DEFAULT_TRUE",
|
||||
expected: { TEST_BOOLEAN_DEFAULT_TRUE: false },
|
||||
},
|
||||
])(
|
||||
"$it",
|
||||
async ({
|
||||
|
|
|
@ -1,20 +1,26 @@
|
|||
import { Cookie, Header } from "../constants"
|
||||
import {
|
||||
getCookie,
|
||||
clearCookie,
|
||||
openJwt,
|
||||
getCookie,
|
||||
isValidInternalAPIKey,
|
||||
openJwt,
|
||||
} from "../utils"
|
||||
import { getUser } from "../cache/user"
|
||||
import { getSession, updateSessionTTL } from "../security/sessions"
|
||||
import { buildMatcherRegex, matches } from "./matchers"
|
||||
import { SEPARATOR, queryGlobalView, ViewName } from "../db"
|
||||
import { getGlobalDB, doInTenant } from "../context"
|
||||
import { queryGlobalView, SEPARATOR, ViewName } from "../db"
|
||||
import { doInTenant, getGlobalDB } from "../context"
|
||||
import { decrypt } from "../security/encryption"
|
||||
import * as identity from "../context/identity"
|
||||
import env from "../environment"
|
||||
import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types"
|
||||
import { InvalidAPIKeyError, ErrorCode } from "../errors"
|
||||
import {
|
||||
Ctx,
|
||||
EndpointMatcher,
|
||||
LoginMethod,
|
||||
SessionCookie,
|
||||
User,
|
||||
} from "@budibase/types"
|
||||
import { ErrorCode, InvalidAPIKeyError } from "../errors"
|
||||
import tracer from "dd-trace"
|
||||
|
||||
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
|
||||
|
@ -26,16 +32,18 @@ interface FinaliseOpts {
|
|||
internal?: boolean
|
||||
publicEndpoint?: boolean
|
||||
version?: string
|
||||
user?: any
|
||||
user?: User | { tenantId: string }
|
||||
loginMethod?: LoginMethod
|
||||
}
|
||||
|
||||
function timeMinusOneMinute() {
|
||||
return new Date(Date.now() - ONE_MINUTE).toISOString()
|
||||
}
|
||||
|
||||
function finalise(ctx: any, opts: FinaliseOpts = {}) {
|
||||
function finalise(ctx: Ctx, opts: FinaliseOpts = {}) {
|
||||
ctx.publicEndpoint = opts.publicEndpoint || false
|
||||
ctx.isAuthenticated = opts.authenticated || false
|
||||
ctx.loginMethod = opts.loginMethod
|
||||
ctx.user = opts.user
|
||||
ctx.internal = opts.internal || false
|
||||
ctx.version = opts.version
|
||||
|
@ -120,9 +128,10 @@ export default function (
|
|||
}
|
||||
|
||||
const tenantId = ctx.request.headers[Header.TENANT_ID]
|
||||
let authenticated = false,
|
||||
user = null,
|
||||
internal = false
|
||||
let authenticated: boolean = false,
|
||||
user: User | { tenantId: string } | undefined = undefined,
|
||||
internal: boolean = false,
|
||||
loginMethod: LoginMethod | undefined = undefined
|
||||
if (authCookie && !apiKey) {
|
||||
const sessionId = authCookie.sessionId
|
||||
const userId = authCookie.userId
|
||||
|
@ -146,6 +155,7 @@ export default function (
|
|||
}
|
||||
// @ts-ignore
|
||||
user.csrfToken = session.csrfToken
|
||||
loginMethod = LoginMethod.COOKIE
|
||||
|
||||
if (session?.lastAccessedAt < timeMinusOneMinute()) {
|
||||
// make sure we denote that the session is still in use
|
||||
|
@ -170,17 +180,16 @@ export default function (
|
|||
apiKey,
|
||||
populateUser
|
||||
)
|
||||
if (valid && foundUser) {
|
||||
if (valid) {
|
||||
authenticated = true
|
||||
loginMethod = LoginMethod.API_KEY
|
||||
user = foundUser
|
||||
} else if (valid) {
|
||||
authenticated = true
|
||||
internal = true
|
||||
internal = !foundUser
|
||||
}
|
||||
}
|
||||
if (!user && tenantId) {
|
||||
user = { tenantId }
|
||||
} else if (user) {
|
||||
} else if (user && "password" in user) {
|
||||
delete user.password
|
||||
}
|
||||
// be explicit
|
||||
|
@ -204,7 +213,14 @@ export default function (
|
|||
}
|
||||
|
||||
// isAuthenticated is a function, so use a variable to be able to check authed state
|
||||
finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
|
||||
finalise(ctx, {
|
||||
authenticated,
|
||||
user,
|
||||
internal,
|
||||
version,
|
||||
publicEndpoint,
|
||||
loginMethod,
|
||||
})
|
||||
|
||||
if (isUser(user)) {
|
||||
return identity.doInUserContext(user, ctx, next)
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import crypto from "crypto"
|
||||
|
||||
const CSP_DIRECTIVES = {
|
||||
"default-src": ["'self'"],
|
||||
"script-src": [
|
||||
"'self'",
|
||||
"'unsafe-eval'",
|
||||
"https://*.budibase.net",
|
||||
"https://cdn.budi.live",
|
||||
"https://js.intercomcdn.com",
|
||||
"https://widget.intercom.io",
|
||||
"https://d2l5prqdbvm3op.cloudfront.net",
|
||||
"https://us-assets.i.posthog.com",
|
||||
],
|
||||
"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://*.budibase.app",
|
||||
"https://*.budibaseqa.app",
|
||||
"https://*.budibase.net",
|
||||
"https://api-iam.intercom.io",
|
||||
"https://api-ping.intercom.io",
|
||||
"https://app.posthog.com",
|
||||
"https://us.i.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",
|
||||
"https://*.amazonaws.com",
|
||||
"https://*.s3.amazonaws.com",
|
||||
"https://*.s3.us-east-2.amazonaws.com",
|
||||
"https://*.s3.us-east-1.amazonaws.com",
|
||||
"https://*.s3.us-west-1.amazonaws.com",
|
||||
"https://*.s3.us-west-2.amazonaws.com",
|
||||
"https://*.s3.af-south-1.amazonaws.com",
|
||||
"https://*.s3.ap-east-1.amazonaws.com",
|
||||
"https://*.s3.ap-south-1.amazonaws.com",
|
||||
"https://*.s3.ap-northeast-2.amazonaws.com",
|
||||
"https://*.s3.ap-southeast-1.amazonaws.com",
|
||||
"https://*.s3.ap-southeast-2.amazonaws.com",
|
||||
"https://*.s3.ap-northeast-1.amazonaws.com",
|
||||
"https://*.s3.ca-central-1.amazonaws.com",
|
||||
"https://*.s3.cn-north-1.amazonaws.com",
|
||||
"https://*.s3.cn-northwest-1.amazonaws.com",
|
||||
"https://*.s3.eu-central-1.amazonaws.com",
|
||||
"https://*.s3.eu-west-1.amazonaws.com",
|
||||
"https://*.s3.eu-west-2.amazonaws.com",
|
||||
"https://*.s3.eu-south-1.amazonaws.com",
|
||||
"https://*.s3.eu-west-3.amazonaws.com",
|
||||
"https://*.s3.eu-north-1.amazonaws.com",
|
||||
"https://*.s3.sa-east-1.amazonaws.com",
|
||||
"https://*.s3.me-south-1.amazonaws.com",
|
||||
"https://*.s3.us-gov-east-1.amazonaws.com",
|
||||
"https://*.s3.us-gov-west-1.amazonaws.com",
|
||||
"https://api.github.com",
|
||||
],
|
||||
"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",
|
||||
],
|
||||
"frame-src": ["'self'", "https:"],
|
||||
"img-src": ["http:", "https:", "data:", "blob:"],
|
||||
"manifest-src": ["'self'"],
|
||||
"media-src": [
|
||||
"'self'",
|
||||
"https://js.intercomcdn.com",
|
||||
"https://cdn.budi.live",
|
||||
],
|
||||
"worker-src": ["blob:"],
|
||||
}
|
||||
|
||||
export async function contentSecurityPolicy(ctx: any, next: any) {
|
||||
try {
|
||||
const nonce = crypto.randomBytes(16).toString("base64")
|
||||
|
||||
const directives = { ...CSP_DIRECTIVES }
|
||||
directives["script-src"] = [
|
||||
...CSP_DIRECTIVES["script-src"],
|
||||
`'nonce-${nonce}'`,
|
||||
]
|
||||
|
||||
ctx.state.nonce = nonce
|
||||
|
||||
const cspHeader = Object.entries(directives)
|
||||
.map(([key, sources]) => `${key} ${sources.join(" ")}`)
|
||||
.join("; ")
|
||||
ctx.set("Content-Security-Policy", cspHeader)
|
||||
await next()
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`Error occurred in Content-Security-Policy middleware: ${err}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default contentSecurityPolicy
|
|
@ -19,5 +19,6 @@ export { default as pino } from "../logging/pino/middleware"
|
|||
export { default as correlation } from "../logging/correlation/middleware"
|
||||
export { default as errorHandling } from "./errorHandling"
|
||||
export { default as querystringToBody } from "./querystringToBody"
|
||||
export { default as csp } from "./contentSecurityPolicy"
|
||||
export * as joiValidator from "./joi-validator"
|
||||
export { default as ip } from "./ip"
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
import crypto from "crypto"
|
||||
import contentSecurityPolicy from "../contentSecurityPolicy"
|
||||
|
||||
jest.mock("crypto", () => ({
|
||||
randomBytes: jest.fn(),
|
||||
randomUUID: jest.fn(),
|
||||
}))
|
||||
|
||||
describe("contentSecurityPolicy middleware", () => {
|
||||
let ctx: any
|
||||
let next: any
|
||||
const mockNonce = "mocked/nonce"
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = {
|
||||
state: {},
|
||||
set: jest.fn(),
|
||||
}
|
||||
next = jest.fn()
|
||||
// @ts-ignore
|
||||
crypto.randomBytes.mockReturnValue(Buffer.from(mockNonce, "base64"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should generate a nonce and set it in the script-src directive", async () => {
|
||||
await contentSecurityPolicy(ctx, next)
|
||||
|
||||
expect(ctx.state.nonce).toBe(mockNonce)
|
||||
expect(ctx.set).toHaveBeenCalledWith(
|
||||
"Content-Security-Policy",
|
||||
expect.stringContaining(
|
||||
`script-src 'self' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com 'nonce-${mockNonce}'`
|
||||
)
|
||||
)
|
||||
expect(next).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should include all CSP directives in the header", async () => {
|
||||
await contentSecurityPolicy(ctx, next)
|
||||
|
||||
const cspHeader = ctx.set.mock.calls[0][1]
|
||||
expect(cspHeader).toContain("default-src 'self'")
|
||||
expect(cspHeader).toContain("script-src 'self' 'unsafe-eval'")
|
||||
expect(cspHeader).toContain("style-src 'self' 'unsafe-inline'")
|
||||
expect(cspHeader).toContain("object-src 'none'")
|
||||
expect(cspHeader).toContain("base-uri 'self'")
|
||||
expect(cspHeader).toContain("connect-src 'self'")
|
||||
expect(cspHeader).toContain("font-src 'self'")
|
||||
expect(cspHeader).toContain("frame-src 'self'")
|
||||
expect(cspHeader).toContain("img-src http: https: data: blob:")
|
||||
expect(cspHeader).toContain("manifest-src 'self'")
|
||||
expect(cspHeader).toContain("media-src 'self'")
|
||||
expect(cspHeader).toContain("worker-src blob:")
|
||||
})
|
||||
|
||||
it("should handle errors and log an error message", async () => {
|
||||
const consoleSpy = jest.spyOn(console, "error").mockImplementation()
|
||||
const error = new Error("Test error")
|
||||
// @ts-ignore
|
||||
crypto.randomBytes.mockImplementation(() => {
|
||||
throw error
|
||||
})
|
||||
|
||||
await contentSecurityPolicy(ctx, next)
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
`Error occurred in Content-Security-Policy middleware: ${error}`
|
||||
)
|
||||
expect(next).not.toHaveBeenCalled()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
|
@ -1,5 +1,5 @@
|
|||
import events from "events"
|
||||
import { newid, timeout } from "../utils"
|
||||
import { newid } from "../utils"
|
||||
import { Queue, QueueOptions, JobOptions } from "./queue"
|
||||
|
||||
interface JobMessage {
|
||||
|
@ -141,7 +141,7 @@ class InMemoryQueue implements Partial<Queue> {
|
|||
} else {
|
||||
pushMessage()
|
||||
}
|
||||
return {} as any
|
||||
return { id: jobId } as any
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -184,16 +184,6 @@ class InMemoryQueue implements Partial<Queue> {
|
|||
// do nothing
|
||||
return this as any
|
||||
}
|
||||
|
||||
async waitForCompletion() {
|
||||
do {
|
||||
await timeout(50)
|
||||
} while (this.hasRunningJobs())
|
||||
}
|
||||
|
||||
hasRunningJobs() {
|
||||
return this._addCount > this._runCount
|
||||
}
|
||||
}
|
||||
|
||||
export default InMemoryQueue
|
||||
|
|
|
@ -15,7 +15,7 @@ const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs()
|
|||
const QUEUE_LOCK_RENEW_INTERNAL_MS = Duration.fromSeconds(30).toMs()
|
||||
// cleanup the queue every 60 seconds
|
||||
const CLEANUP_PERIOD_MS = Duration.fromSeconds(60).toMs()
|
||||
let QUEUES: BullQueue.Queue[] | InMemoryQueue[] = []
|
||||
let QUEUES: BullQueue.Queue[] = []
|
||||
let cleanupInterval: NodeJS.Timeout
|
||||
|
||||
async function cleanup() {
|
||||
|
@ -45,11 +45,18 @@ export function createQueue<T>(
|
|||
if (opts.jobOptions) {
|
||||
queueConfig.defaultJobOptions = opts.jobOptions
|
||||
}
|
||||
let queue: any
|
||||
let queue: BullQueue.Queue<T>
|
||||
if (!env.isTest()) {
|
||||
queue = new BullQueue(jobQueue, queueConfig)
|
||||
} else if (
|
||||
process.env.BULL_TEST_REDIS_PORT &&
|
||||
!isNaN(+process.env.BULL_TEST_REDIS_PORT)
|
||||
) {
|
||||
queue = new BullQueue(jobQueue, {
|
||||
redis: { host: "localhost", port: +process.env.BULL_TEST_REDIS_PORT },
|
||||
})
|
||||
} else {
|
||||
queue = new InMemoryQueue(jobQueue, queueConfig)
|
||||
queue = new InMemoryQueue(jobQueue, queueConfig) as any
|
||||
}
|
||||
addListeners(queue, jobQueue, opts?.removeStalledCb)
|
||||
QUEUES.push(queue)
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
import { PermissionLevel, PermissionType } from "@budibase/types"
|
||||
import {
|
||||
PermissionLevel,
|
||||
PermissionType,
|
||||
BuiltinPermissionID,
|
||||
} from "@budibase/types"
|
||||
import flatten from "lodash/flatten"
|
||||
import cloneDeep from "lodash/fp/cloneDeep"
|
||||
|
||||
|
@ -57,14 +61,6 @@ export function getAllowedLevels(userPermLevel: PermissionLevel): string[] {
|
|||
}
|
||||
}
|
||||
|
||||
export enum BuiltinPermissionID {
|
||||
PUBLIC = "public",
|
||||
READ_ONLY = "read_only",
|
||||
WRITE = "write",
|
||||
ADMIN = "admin",
|
||||
POWER = "power",
|
||||
}
|
||||
|
||||
export const BUILTIN_PERMISSIONS: {
|
||||
[key in keyof typeof BuiltinPermissionID]: {
|
||||
_id: (typeof BuiltinPermissionID)[key]
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { BuiltinPermissionID, PermissionLevel } from "./permissions"
|
||||
import semver from "semver"
|
||||
import {
|
||||
prefixRoleID,
|
||||
getRoleParams,
|
||||
|
@ -7,9 +7,19 @@ import {
|
|||
doWithDB,
|
||||
} from "../db"
|
||||
import { getAppDB } from "../context"
|
||||
import { Screen, Role as RoleDoc, RoleUIMetadata } from "@budibase/types"
|
||||
import {
|
||||
Screen,
|
||||
Role as RoleDoc,
|
||||
RoleUIMetadata,
|
||||
Database,
|
||||
App,
|
||||
BuiltinPermissionID,
|
||||
PermissionLevel,
|
||||
} from "@budibase/types"
|
||||
import cloneDeep from "lodash/fp/cloneDeep"
|
||||
import { RoleColor } from "@budibase/shared-core"
|
||||
import { RoleColor, helpers } from "@budibase/shared-core"
|
||||
import { uniqBy } from "lodash"
|
||||
import { default as env } from "../environment"
|
||||
|
||||
export const BUILTIN_ROLE_IDS = {
|
||||
ADMIN: "ADMIN",
|
||||
|
@ -23,14 +33,6 @@ const BUILTIN_IDS = {
|
|||
BUILDER: "BUILDER",
|
||||
}
|
||||
|
||||
// exclude internal roles like builder
|
||||
const EXTERNAL_BUILTIN_ROLE_IDS = [
|
||||
BUILTIN_IDS.ADMIN,
|
||||
BUILTIN_IDS.POWER,
|
||||
BUILTIN_IDS.BASIC,
|
||||
BUILTIN_IDS.PUBLIC,
|
||||
]
|
||||
|
||||
export const RoleIDVersion = {
|
||||
// original version, with a UUID based ID
|
||||
UUID: undefined,
|
||||
|
@ -38,12 +40,20 @@ export const RoleIDVersion = {
|
|||
NAME: "name",
|
||||
}
|
||||
|
||||
function rolesInList(roleIds: string[], ids: string | string[]) {
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter(id => roleIds.includes(id)).length === ids.length
|
||||
} else {
|
||||
return roleIds.includes(ids)
|
||||
}
|
||||
}
|
||||
|
||||
export class Role implements RoleDoc {
|
||||
_id: string
|
||||
_rev?: string
|
||||
name: string
|
||||
permissionId: string
|
||||
inherits?: string
|
||||
permissionId: BuiltinPermissionID
|
||||
inherits?: string | string[]
|
||||
version?: string
|
||||
permissions: Record<string, PermissionLevel[]> = {}
|
||||
uiMetadata?: RoleUIMetadata
|
||||
|
@ -51,7 +61,7 @@ export class Role implements RoleDoc {
|
|||
constructor(
|
||||
id: string,
|
||||
name: string,
|
||||
permissionId: string,
|
||||
permissionId: BuiltinPermissionID,
|
||||
uiMetadata?: RoleUIMetadata
|
||||
) {
|
||||
this._id = id
|
||||
|
@ -62,12 +72,70 @@ export class Role implements RoleDoc {
|
|||
this.version = RoleIDVersion.NAME
|
||||
}
|
||||
|
||||
addInheritance(inherits: string) {
|
||||
addInheritance(inherits?: string | string[]) {
|
||||
// make sure IDs are correct format
|
||||
if (inherits && typeof inherits === "string") {
|
||||
inherits = prefixRoleIDNoBuiltin(inherits)
|
||||
} else if (inherits && Array.isArray(inherits)) {
|
||||
inherits = inherits.map(prefixRoleIDNoBuiltin)
|
||||
}
|
||||
this.inherits = inherits
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class RoleHierarchyTraversal {
|
||||
allRoles: RoleDoc[]
|
||||
opts?: { defaultPublic?: boolean }
|
||||
|
||||
constructor(allRoles: RoleDoc[], opts?: { defaultPublic?: boolean }) {
|
||||
this.allRoles = allRoles
|
||||
this.opts = opts
|
||||
}
|
||||
|
||||
walk(role: RoleDoc): RoleDoc[] {
|
||||
const opts = this.opts,
|
||||
allRoles = this.allRoles
|
||||
// this will be a full walked list of roles - which may contain duplicates
|
||||
let roleList: RoleDoc[] = []
|
||||
if (!role || !role._id) {
|
||||
return roleList
|
||||
}
|
||||
roleList.push(role)
|
||||
if (Array.isArray(role.inherits)) {
|
||||
for (let roleId of role.inherits) {
|
||||
const foundRole = findRole(roleId, allRoles, opts)
|
||||
if (foundRole) {
|
||||
roleList = roleList.concat(this.walk(foundRole))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const foundRoleIds: string[] = []
|
||||
let currentRole: RoleDoc | undefined = role
|
||||
while (
|
||||
currentRole &&
|
||||
currentRole.inherits &&
|
||||
!rolesInList(foundRoleIds, currentRole.inherits)
|
||||
) {
|
||||
if (Array.isArray(currentRole.inherits)) {
|
||||
return roleList.concat(this.walk(currentRole))
|
||||
} else {
|
||||
foundRoleIds.push(currentRole.inherits)
|
||||
currentRole = findRole(currentRole.inherits, allRoles, opts)
|
||||
if (currentRole) {
|
||||
roleList.push(currentRole)
|
||||
}
|
||||
}
|
||||
// loop now found - stop iterating
|
||||
if (helpers.roles.checkForRoleInheritanceLoops(roleList)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqBy(roleList, role => role._id)
|
||||
}
|
||||
}
|
||||
|
||||
const BUILTIN_ROLES = {
|
||||
ADMIN: new Role(
|
||||
BUILTIN_IDS.ADMIN,
|
||||
|
@ -126,7 +194,15 @@ export function getBuiltinRoles(): { [key: string]: RoleDoc } {
|
|||
}
|
||||
|
||||
export function isBuiltin(role: string) {
|
||||
return getBuiltinRole(role) !== undefined
|
||||
return Object.values(BUILTIN_ROLE_IDS).includes(role)
|
||||
}
|
||||
|
||||
export function prefixRoleIDNoBuiltin(roleId: string) {
|
||||
if (isBuiltin(roleId)) {
|
||||
return roleId
|
||||
} else {
|
||||
return prefixRoleID(roleId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getBuiltinRole(roleId: string): Role | undefined {
|
||||
|
@ -139,13 +215,32 @@ export function getBuiltinRole(roleId: string): Role | undefined {
|
|||
return cloneDeep(role)
|
||||
}
|
||||
|
||||
export function validInherits(
|
||||
allRoles: RoleDoc[],
|
||||
inherits?: string | string[]
|
||||
): boolean {
|
||||
if (!inherits) {
|
||||
return false
|
||||
}
|
||||
const find = (id: string) => allRoles.find(r => roleIDsAreEqual(r._id!, id))
|
||||
if (Array.isArray(inherits)) {
|
||||
const filtered = inherits.filter(roleId => find(roleId))
|
||||
return inherits.length !== 0 && filtered.length === inherits.length
|
||||
} else {
|
||||
return !!find(inherits)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
|
||||
*/
|
||||
export function builtinRoleToNumber(id: string) {
|
||||
const builtins = getBuiltinRoles()
|
||||
const MAX = Object.values(builtins).length + 1
|
||||
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
|
||||
if (
|
||||
roleIDsAreEqual(id, BUILTIN_IDS.ADMIN) ||
|
||||
roleIDsAreEqual(id, BUILTIN_IDS.BUILDER)
|
||||
) {
|
||||
return MAX
|
||||
}
|
||||
let role = builtins[id],
|
||||
|
@ -154,7 +249,11 @@ export function builtinRoleToNumber(id: string) {
|
|||
if (!role) {
|
||||
break
|
||||
}
|
||||
role = builtins[role.inherits!]
|
||||
if (Array.isArray(role.inherits)) {
|
||||
throw new Error("Built-in roles don't support multi-inheritance")
|
||||
} else {
|
||||
role = builtins[role.inherits!]
|
||||
}
|
||||
count++
|
||||
} while (role !== null)
|
||||
return count
|
||||
|
@ -170,12 +269,33 @@ export async function roleToNumber(id: string) {
|
|||
const hierarchy = (await getUserRoleHierarchy(id, {
|
||||
defaultPublic: true,
|
||||
})) as RoleDoc[]
|
||||
for (let role of hierarchy) {
|
||||
if (role?.inherits && isBuiltin(role.inherits)) {
|
||||
const findNumber = (role: RoleDoc): number => {
|
||||
if (!role.inherits) {
|
||||
return 0
|
||||
}
|
||||
if (Array.isArray(role.inherits)) {
|
||||
// find the built-in roles, get their number, sort it, then get the last one
|
||||
const highestBuiltin: number | undefined = role.inherits
|
||||
.map(roleId => {
|
||||
const foundRole = hierarchy.find(role =>
|
||||
roleIDsAreEqual(role._id!, roleId)
|
||||
)
|
||||
if (foundRole) {
|
||||
return findNumber(foundRole) + 1
|
||||
}
|
||||
})
|
||||
.filter(number => number)
|
||||
.sort()
|
||||
.pop()
|
||||
if (highestBuiltin != undefined) {
|
||||
return highestBuiltin
|
||||
}
|
||||
} else if (isBuiltin(role.inherits)) {
|
||||
return builtinRoleToNumber(role.inherits) + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
return Math.max(...hierarchy.map(findNumber))
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -193,6 +313,53 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
|
|||
: roleId1
|
||||
}
|
||||
|
||||
export function roleIDsAreEqual(roleId1: string, roleId2: string) {
|
||||
// make sure both role IDs are prefixed correctly
|
||||
return prefixRoleID(roleId1) === prefixRoleID(roleId2)
|
||||
}
|
||||
|
||||
export function externalRole(role: RoleDoc): RoleDoc {
|
||||
let _id: string | undefined
|
||||
if (role._id) {
|
||||
_id = getExternalRoleID(role._id)
|
||||
}
|
||||
return {
|
||||
...role,
|
||||
_id,
|
||||
inherits: getExternalRoleIDs(role.inherits, role.version),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a list of roles, this will pick the role out, accounting for built ins.
|
||||
*/
|
||||
export function findRole(
|
||||
roleId: string,
|
||||
roles: RoleDoc[],
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): RoleDoc | undefined {
|
||||
// built in roles mostly come from the in-code implementation,
|
||||
// but can be extended by a doc stored about them (e.g. permissions)
|
||||
let role: RoleDoc | undefined = getBuiltinRole(roleId)
|
||||
if (!role) {
|
||||
// make sure has the prefix (if it has it then it won't be added)
|
||||
roleId = prefixRoleID(roleId)
|
||||
}
|
||||
const dbRole = roles.find(
|
||||
role => role._id && roleIDsAreEqual(role._id, roleId)
|
||||
)
|
||||
if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) {
|
||||
return cloneDeep(BUILTIN_ROLES.PUBLIC)
|
||||
}
|
||||
// combine the roles
|
||||
role = Object.assign(role || {}, dbRole)
|
||||
// finalise the ID
|
||||
if (role?._id) {
|
||||
role._id = getExternalRoleID(role._id, role.version)
|
||||
}
|
||||
return Object.keys(role).length === 0 ? undefined : role
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the role object, this is mainly useful for two purposes, to check if the level exists and
|
||||
* to check if the role inherits any others.
|
||||
|
@ -203,30 +370,28 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
|
|||
export async function getRole(
|
||||
roleId: string,
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): Promise<RoleDoc> {
|
||||
// built in roles mostly come from the in-code implementation,
|
||||
// but can be extended by a doc stored about them (e.g. permissions)
|
||||
let role: RoleDoc | undefined = getBuiltinRole(roleId)
|
||||
if (!role) {
|
||||
// make sure has the prefix (if it has it then it won't be added)
|
||||
roleId = prefixRoleID(roleId)
|
||||
}
|
||||
try {
|
||||
const db = getAppDB()
|
||||
const dbRole = await db.get<RoleDoc>(getDBRoleID(roleId))
|
||||
role = Object.assign(role || {}, dbRole)
|
||||
// finalise the ID
|
||||
role._id = getExternalRoleID(role._id!, role.version)
|
||||
} catch (err) {
|
||||
if (!isBuiltin(roleId) && opts?.defaultPublic) {
|
||||
return cloneDeep(BUILTIN_ROLES.PUBLIC)
|
||||
}
|
||||
// only throw an error if there is no role at all
|
||||
if (!role || Object.keys(role).length === 0) {
|
||||
throw err
|
||||
): Promise<RoleDoc | undefined> {
|
||||
const db = getAppDB()
|
||||
const roleList = []
|
||||
if (!isBuiltin(roleId)) {
|
||||
const role = await db.tryGet<RoleDoc>(getDBRoleID(roleId))
|
||||
if (role) {
|
||||
roleList.push(role)
|
||||
}
|
||||
}
|
||||
return role
|
||||
return findRole(roleId, roleList, opts)
|
||||
}
|
||||
|
||||
export async function saveRoles(roles: RoleDoc[]) {
|
||||
const db = getAppDB()
|
||||
await db.bulkDocs(
|
||||
roles
|
||||
.filter(role => role._id)
|
||||
.map(role => ({
|
||||
...role,
|
||||
_id: prefixRoleID(role._id!),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -236,24 +401,18 @@ async function getAllUserRoles(
|
|||
userRoleId: string,
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): Promise<RoleDoc[]> {
|
||||
const allRoles = await getAllRoles()
|
||||
// admins have access to all roles
|
||||
if (userRoleId === BUILTIN_IDS.ADMIN) {
|
||||
return getAllRoles()
|
||||
if (roleIDsAreEqual(userRoleId, BUILTIN_IDS.ADMIN)) {
|
||||
return allRoles
|
||||
}
|
||||
let currentRole = await getRole(userRoleId, opts)
|
||||
let roles = currentRole ? [currentRole] : []
|
||||
let roleIds = [userRoleId]
|
||||
|
||||
// get all the inherited roles
|
||||
while (
|
||||
currentRole &&
|
||||
currentRole.inherits &&
|
||||
roleIds.indexOf(currentRole.inherits) === -1
|
||||
) {
|
||||
roleIds.push(currentRole.inherits)
|
||||
currentRole = await getRole(currentRole.inherits)
|
||||
if (currentRole) {
|
||||
roles.push(currentRole)
|
||||
}
|
||||
const foundRole = findRole(userRoleId, allRoles, opts)
|
||||
let roles: RoleDoc[] = []
|
||||
if (foundRole) {
|
||||
const traversal = new RoleHierarchyTraversal(allRoles, opts)
|
||||
roles = traversal.walk(foundRole)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
@ -319,7 +478,7 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
|
|||
}
|
||||
return internal(appDB)
|
||||
}
|
||||
async function internal(db: any) {
|
||||
async function internal(db: Database | undefined) {
|
||||
let roles: RoleDoc[] = []
|
||||
if (db) {
|
||||
const body = await db.allDocs(
|
||||
|
@ -334,20 +493,42 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
|
|||
}
|
||||
const builtinRoles = getBuiltinRoles()
|
||||
|
||||
// exclude internal roles like builder
|
||||
let externalBuiltinRoles = []
|
||||
|
||||
if (!db || (await shouldIncludePowerRole(db))) {
|
||||
externalBuiltinRoles = [
|
||||
BUILTIN_IDS.ADMIN,
|
||||
BUILTIN_IDS.POWER,
|
||||
BUILTIN_IDS.BASIC,
|
||||
BUILTIN_IDS.PUBLIC,
|
||||
]
|
||||
} else {
|
||||
externalBuiltinRoles = [
|
||||
BUILTIN_IDS.ADMIN,
|
||||
BUILTIN_IDS.BASIC,
|
||||
BUILTIN_IDS.PUBLIC,
|
||||
]
|
||||
}
|
||||
|
||||
// need to combine builtin with any DB record of them (for sake of permissions)
|
||||
for (let builtinRoleId of EXTERNAL_BUILTIN_ROLE_IDS) {
|
||||
for (let builtinRoleId of externalBuiltinRoles) {
|
||||
const builtinRole = builtinRoles[builtinRoleId]
|
||||
const dbBuiltin = roles.filter(
|
||||
dbRole =>
|
||||
getExternalRoleID(dbRole._id!, dbRole.version) === builtinRoleId
|
||||
const dbBuiltin = roles.filter(dbRole =>
|
||||
roleIDsAreEqual(dbRole._id!, builtinRoleId)
|
||||
)[0]
|
||||
if (dbBuiltin == null) {
|
||||
roles.push(builtinRole || builtinRoles.BASIC)
|
||||
} else {
|
||||
// remove role and all back after combining with the builtin
|
||||
roles = roles.filter(role => role._id !== dbBuiltin._id)
|
||||
dbBuiltin._id = getExternalRoleID(dbBuiltin._id!, dbBuiltin.version)
|
||||
roles.push(Object.assign(builtinRole, dbBuiltin))
|
||||
dbBuiltin._id = getExternalRoleID(builtinRole._id!, dbBuiltin.version)
|
||||
roles.push({
|
||||
...builtinRole,
|
||||
...dbBuiltin,
|
||||
name: builtinRole.name,
|
||||
_id: getExternalRoleID(builtinRole._id!, builtinRole.version),
|
||||
})
|
||||
}
|
||||
}
|
||||
// check permissions
|
||||
|
@ -366,6 +547,21 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
|
|||
}
|
||||
}
|
||||
|
||||
async function shouldIncludePowerRole(db: Database) {
|
||||
const app = await db.tryGet<App>(DocumentType.APP_METADATA)
|
||||
const creationVersion = app?.creationVersion
|
||||
if (!creationVersion || !semver.valid(creationVersion)) {
|
||||
// Old apps don't have creationVersion, so we should include it for backward compatibility
|
||||
return true
|
||||
}
|
||||
|
||||
const isGreaterThan3x = semver.gte(
|
||||
creationVersion,
|
||||
env.MIN_VERSION_WITHOUT_POWER_ROLE
|
||||
)
|
||||
return !isGreaterThan3x
|
||||
}
|
||||
|
||||
export class AccessController {
|
||||
userHierarchies: { [key: string]: string[] }
|
||||
constructor() {
|
||||
|
@ -378,9 +574,9 @@ export class AccessController {
|
|||
if (
|
||||
tryingRoleId == null ||
|
||||
tryingRoleId === "" ||
|
||||
tryingRoleId === userRoleId ||
|
||||
tryingRoleId === BUILTIN_IDS.BUILDER ||
|
||||
userRoleId === BUILTIN_IDS.BUILDER
|
||||
roleIDsAreEqual(tryingRoleId, BUILTIN_IDS.BUILDER) ||
|
||||
roleIDsAreEqual(userRoleId!, tryingRoleId) ||
|
||||
roleIDsAreEqual(userRoleId!, BUILTIN_IDS.BUILDER)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
@ -390,7 +586,10 @@ export class AccessController {
|
|||
this.userHierarchies[userRoleId] = roleIds
|
||||
}
|
||||
|
||||
return roleIds?.indexOf(tryingRoleId) !== -1
|
||||
return (
|
||||
roleIds?.find(roleId => roleIDsAreEqual(roleId, tryingRoleId)) !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
async checkScreensAccess(screens: Screen[], userRoleId: string) {
|
||||
|
@ -432,10 +631,25 @@ export function getDBRoleID(roleName: string) {
|
|||
export function getExternalRoleID(roleId: string, version?: string) {
|
||||
// for built-in roles we want to remove the DB role ID element (role_)
|
||||
if (
|
||||
roleId.startsWith(DocumentType.ROLE) &&
|
||||
roleId.startsWith(`${DocumentType.ROLE}${SEPARATOR}`) &&
|
||||
(isBuiltin(roleId) || version === RoleIDVersion.NAME)
|
||||
) {
|
||||
return roleId.split(`${DocumentType.ROLE}${SEPARATOR}`)[1]
|
||||
const parts = roleId.split(SEPARATOR)
|
||||
parts.shift()
|
||||
return parts.join(SEPARATOR)
|
||||
}
|
||||
return roleId
|
||||
}
|
||||
|
||||
export function getExternalRoleIDs(
|
||||
roleIds: string | string[] | undefined,
|
||||
version?: string
|
||||
) {
|
||||
if (!roleIds) {
|
||||
return roleIds
|
||||
} else if (typeof roleIds === "string") {
|
||||
return getExternalRoleID(roleIds, version)
|
||||
} else {
|
||||
return roleIds.map(roleId => getExternalRoleID(roleId, version))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import env from "../../environment"
|
|||
describe("encryption", () => {
|
||||
it("should throw an error if API encryption key is not set", () => {
|
||||
const jwt = getSecret(SecretOption.API)
|
||||
expect(jwt).toBe(env.JWT_SECRET)
|
||||
expect(jwt).toBe(env.JWT_SECRET?.export().toString())
|
||||
})
|
||||
|
||||
it("should throw an error if encryption key is not set", () => {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import cloneDeep from "lodash/cloneDeep"
|
||||
import * as permissions from "../permissions"
|
||||
import { BUILTIN_ROLE_IDS } from "../roles"
|
||||
import { BuiltinPermissionID } from "@budibase/types"
|
||||
|
||||
describe("levelToNumber", () => {
|
||||
it("should return 0 for EXECUTE", () => {
|
||||
|
@ -77,7 +78,7 @@ describe("doesHaveBasePermission", () => {
|
|||
const rolesHierarchy = [
|
||||
{
|
||||
roleId: BUILTIN_ROLE_IDS.ADMIN,
|
||||
permissionId: permissions.BuiltinPermissionID.ADMIN,
|
||||
permissionId: BuiltinPermissionID.ADMIN,
|
||||
},
|
||||
]
|
||||
expect(
|
||||
|
@ -91,7 +92,7 @@ describe("doesHaveBasePermission", () => {
|
|||
const rolesHierarchy = [
|
||||
{
|
||||
roleId: BUILTIN_ROLE_IDS.PUBLIC,
|
||||
permissionId: permissions.BuiltinPermissionID.PUBLIC,
|
||||
permissionId: BuiltinPermissionID.PUBLIC,
|
||||
},
|
||||
]
|
||||
expect(
|
||||
|
@ -129,7 +130,7 @@ describe("getBuiltinPermissions", () => {
|
|||
describe("getBuiltinPermissionByID", () => {
|
||||
it("returns correct permission object for valid ID", () => {
|
||||
const expectedPermission = {
|
||||
_id: permissions.BuiltinPermissionID.PUBLIC,
|
||||
_id: BuiltinPermissionID.PUBLIC,
|
||||
name: "Public",
|
||||
permissions: [
|
||||
new permissions.Permission(
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -17,7 +17,7 @@ import SchemaBuilder = Knex.SchemaBuilder
|
|||
import CreateTableBuilder = Knex.CreateTableBuilder
|
||||
|
||||
function isIgnoredType(type: FieldType) {
|
||||
const ignored = [FieldType.LINK, FieldType.FORMULA]
|
||||
const ignored = [FieldType.LINK, FieldType.FORMULA, FieldType.AI]
|
||||
return ignored.indexOf(type) !== -1
|
||||
}
|
||||
|
||||
|
@ -144,6 +144,9 @@ function generateSchema(
|
|||
case FieldType.FORMULA:
|
||||
// This is allowed, but nothing to do on the external datasource
|
||||
break
|
||||
case FieldType.AI:
|
||||
// This is allowed, but nothing to do on the external datasource
|
||||
break
|
||||
case FieldType.ATTACHMENTS:
|
||||
case FieldType.ATTACHMENT_SINGLE:
|
||||
case FieldType.SIGNATURE_SINGLE:
|
||||
|
|
|
@ -1,29 +1,6 @@
|
|||
import { getDB } from "../db/db"
|
||||
import { getGlobalDBName } from "../context"
|
||||
import { TenantInfo } from "@budibase/types"
|
||||
|
||||
export function getTenantDB(tenantId: string) {
|
||||
return getDB(getGlobalDBName(tenantId))
|
||||
}
|
||||
|
||||
export async function saveTenantInfo(tenantInfo: TenantInfo) {
|
||||
const db = getTenantDB(tenantInfo.tenantId)
|
||||
// save the tenant info to db
|
||||
return db.put({
|
||||
_id: "tenant_info",
|
||||
...tenantInfo,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTenantInfo(
|
||||
tenantId: string
|
||||
): Promise<TenantInfo | undefined> {
|
||||
try {
|
||||
const db = getTenantDB(tenantId)
|
||||
const tenantInfo = (await db.get("tenant_info")) as TenantInfo
|
||||
delete tenantInfo.owner.password
|
||||
return tenantInfo
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,14 +16,15 @@ import {
|
|||
isSSOUser,
|
||||
SaveUserOpts,
|
||||
User,
|
||||
UserStatus,
|
||||
UserGroup,
|
||||
UserIdentifier,
|
||||
UserStatus,
|
||||
PlatformUserBySsoId,
|
||||
PlatformUserById,
|
||||
AnyDocument,
|
||||
} from "@budibase/types"
|
||||
import {
|
||||
getAccountHolderFromUserIds,
|
||||
getAccountHolderFromUsers,
|
||||
isAdmin,
|
||||
isCreator,
|
||||
validateUniqueUser,
|
||||
|
@ -412,7 +413,9 @@ export class UserDB {
|
|||
)
|
||||
}
|
||||
|
||||
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
|
||||
static async bulkDelete(
|
||||
users: Array<UserIdentifier>
|
||||
): Promise<BulkUserDeleted> {
|
||||
const db = getGlobalDB()
|
||||
|
||||
const response: BulkUserDeleted = {
|
||||
|
@ -421,13 +424,13 @@ export class UserDB {
|
|||
}
|
||||
|
||||
// remove the account holder from the delete request if present
|
||||
const account = await getAccountHolderFromUserIds(userIds)
|
||||
if (account) {
|
||||
userIds = userIds.filter(u => u !== account.budibaseUserId)
|
||||
const accountHolder = await getAccountHolderFromUsers(users)
|
||||
if (accountHolder) {
|
||||
users = users.filter(u => u.userId !== accountHolder.userId)
|
||||
// mark user as unsuccessful
|
||||
response.unsuccessful.push({
|
||||
_id: account.budibaseUserId,
|
||||
email: account.email,
|
||||
_id: accountHolder.userId,
|
||||
email: accountHolder.email,
|
||||
reason: "Account holder cannot be deleted",
|
||||
})
|
||||
}
|
||||
|
@ -435,7 +438,7 @@ export class UserDB {
|
|||
// Get users and delete
|
||||
const allDocsResponse = await db.allDocs<User>({
|
||||
include_docs: true,
|
||||
keys: userIds,
|
||||
keys: users.map(u => u.userId),
|
||||
})
|
||||
const usersToDelete = allDocsResponse.rows.map(user => {
|
||||
return user.doc!
|
||||
|
|
|
@ -70,6 +70,17 @@ export async function getAllUserIds() {
|
|||
return response.rows.map(row => row.id)
|
||||
}
|
||||
|
||||
export async function getAllUsers(): Promise<User[]> {
|
||||
const db = getGlobalDB()
|
||||
const startKey = `${DocumentType.USER}${SEPARATOR}`
|
||||
const response = await db.allDocs({
|
||||
startkey: startKey,
|
||||
endkey: `${startKey}${UNICODE_MAX}`,
|
||||
include_docs: true,
|
||||
})
|
||||
return response.rows.map(row => row.doc) as User[]
|
||||
}
|
||||
|
||||
export async function bulkUpdateGlobalUsers(users: User[]) {
|
||||
const db = getGlobalDB()
|
||||
return (await db.bulkDocs(users)) as BulkDocsResponse
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types"
|
||||
import { ContextUser, User, UserGroup, UserIdentifier } from "@budibase/types"
|
||||
import * as accountSdk from "../accounts"
|
||||
import env from "../environment"
|
||||
import { getFirstPlatformUser } from "./lookup"
|
||||
import { getExistingAccounts, getFirstPlatformUser } from "./lookup"
|
||||
import { EmailUnavailableError } from "../errors"
|
||||
import { getTenantId } from "../context"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import { getAccountByTenantId } from "../accounts"
|
||||
import { BUILTIN_ROLE_IDS } from "../security/roles"
|
||||
import * as context from "../context"
|
||||
|
||||
|
@ -67,21 +65,17 @@ export async function validateUniqueUser(email: string, tenantId: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* For the given user id's, return the account holder if it is in the ids.
|
||||
* For a list of users, return the account holder if there is an email match.
|
||||
*/
|
||||
export async function getAccountHolderFromUserIds(
|
||||
userIds: string[]
|
||||
): Promise<CloudAccount | undefined> {
|
||||
export async function getAccountHolderFromUsers(
|
||||
users: Array<UserIdentifier>
|
||||
): Promise<UserIdentifier | undefined> {
|
||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||
const tenantId = getTenantId()
|
||||
const account = await getAccountByTenantId(tenantId)
|
||||
if (!account) {
|
||||
throw new Error(`Account not found for tenantId=${tenantId}`)
|
||||
}
|
||||
|
||||
const budibaseUserId = account.budibaseUserId
|
||||
if (userIds.includes(budibaseUserId)) {
|
||||
return account
|
||||
}
|
||||
const accountMetadata = await getExistingAccounts(
|
||||
users.map(user => user.email)
|
||||
)
|
||||
return users.find(user =>
|
||||
accountMetadata.map(metadata => metadata.email).includes(user.email)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,3 +4,4 @@ export { generator } from "./structures"
|
|||
export * as testContainerUtils from "./testContainerUtils"
|
||||
export * as utils from "./utils"
|
||||
export * from "./jestUtils"
|
||||
export * as queue from "./queue"
|
||||
|
|
|
@ -102,6 +102,14 @@ export const useAppBuilders = () => {
|
|||
return useFeature(Feature.APP_BUILDERS)
|
||||
}
|
||||
|
||||
export const useBudibaseAI = () => {
|
||||
return useFeature(Feature.BUDIBASE_AI)
|
||||
}
|
||||
|
||||
export const useAICustomConfigs = () => {
|
||||
return useFeature(Feature.AI_CUSTOM_CONFIGS)
|
||||
}
|
||||
|
||||
// QUOTAS
|
||||
|
||||
export const setAutomationLogsQuota = (value: number) => {
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { Queue } from "bull"
|
||||
|
||||
export async function processMessages(queue: Queue) {
|
||||
do {
|
||||
await queue.whenCurrentJobsFinished()
|
||||
} while (await queue.count())
|
||||
|
||||
await queue.whenCurrentJobsFinished()
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
import { execSync } from "child_process"
|
||||
import { cloneDeep } from "lodash"
|
||||
import { GenericContainer, StartedTestContainer } from "testcontainers"
|
||||
|
||||
const IPV4_PORT_REGEX = new RegExp(`0\\.0\\.0\\.0:(\\d+)->(\\d+)/tcp`, "g")
|
||||
|
||||
|
@ -106,3 +108,58 @@ export function setupEnv(...envs: any[]) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function startContainer(container: GenericContainer) {
|
||||
const imageName = (container as any).imageName.string as string
|
||||
let key: string = imageName
|
||||
if (imageName.includes("@sha256")) {
|
||||
key = imageName.split("@")[0]
|
||||
}
|
||||
key = key.replace(/\//g, "-").replace(/:/g, "-")
|
||||
|
||||
container = container
|
||||
.withReuse()
|
||||
.withLabels({ "com.budibase": "true" })
|
||||
.withName(`${key}_testcontainer`)
|
||||
|
||||
let startedContainer: StartedTestContainer | undefined = undefined
|
||||
let lastError = undefined
|
||||
for (let i = 0; i < 10; i++) {
|
||||
try {
|
||||
// container.start() is not an idempotent operation, calling `start`
|
||||
// modifies the internal state of a GenericContainer instance such that
|
||||
// the hash it uses to determine reuse changes. We need to clone the
|
||||
// container before calling start to ensure that we're using the same
|
||||
// reuse hash every time.
|
||||
const containerCopy = cloneDeep(container)
|
||||
startedContainer = await containerCopy.start()
|
||||
lastError = undefined
|
||||
break
|
||||
} catch (e: any) {
|
||||
lastError = e
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
if (!startedContainer) {
|
||||
if (lastError) {
|
||||
throw lastError
|
||||
}
|
||||
throw new Error(`failed to start container: ${imageName}`)
|
||||
}
|
||||
|
||||
const info = getContainerById(startedContainer.getId())
|
||||
if (!info) {
|
||||
throw new Error("Container not found")
|
||||
}
|
||||
|
||||
// Some Docker runtimes, when you expose a port, will bind it to both
|
||||
// 127.0.0.1 and ::1, so ipv4 and ipv6. The port spaces of ipv4 and ipv6
|
||||
// addresses are not shared, and testcontainers will sometimes give you back
|
||||
// the ipv6 port. There's no way to know that this has happened, and if you
|
||||
// try to then connect to `localhost:port` you may attempt to bind to the v4
|
||||
// address which could be unbound or even an entirely different container. For
|
||||
// that reason, we don't use testcontainers' `getExposedPort` function,
|
||||
// preferring instead our own method that guaranteed v4 ports.
|
||||
return getExposedV4Ports(info)
|
||||
}
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
export * as time from "./time"
|
||||
export * as queue from "./queue"
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import { Queue } from "bull"
|
||||
import { GenericContainer, Wait } from "testcontainers"
|
||||
import { startContainer } from "../testContainerUtils"
|
||||
|
||||
export async function useRealQueues() {
|
||||
const ports = await startContainer(
|
||||
new GenericContainer("redis")
|
||||
.withExposedPorts(6379)
|
||||
.withWaitStrategy(
|
||||
Wait.forSuccessfulCommand(`redis-cli`).withStartupTimeout(10000)
|
||||
)
|
||||
)
|
||||
|
||||
const port = ports.find(x => x.container === 6379)?.host
|
||||
if (!port) {
|
||||
throw new Error("Redis port not found")
|
||||
}
|
||||
process.env.BULL_TEST_REDIS_PORT = port.toString()
|
||||
}
|
||||
|
||||
export async function processMessages(queue: Queue) {
|
||||
do {
|
||||
await queue.whenCurrentJobsFinished()
|
||||
} while (await queue.count())
|
||||
|
||||
await queue.whenCurrentJobsFinished()
|
||||
}
|
|
@ -81,6 +81,7 @@
|
|||
"@spectrum-css/typography": "3.0.1",
|
||||
"@spectrum-css/underlay": "2.0.9",
|
||||
"@spectrum-css/vars": "3.0.1",
|
||||
"atrament": "^4.3.0",
|
||||
"dayjs": "^1.10.8",
|
||||
"easymde": "^2.16.1",
|
||||
"svelte-dnd-action": "^0.9.8",
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
<script>
|
||||
import "@spectrum-css/actionbutton/dist/index-vars.css"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||
import { fade } from "svelte/transition"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
import { hexToRGBA } from "../helpers"
|
||||
|
||||
export let quiet = false
|
||||
export let emphasized = false
|
||||
export let selected = false
|
||||
export let longPressable = false
|
||||
export let disabled = false
|
||||
export let icon = ""
|
||||
export let size = "M"
|
||||
|
@ -17,82 +13,64 @@
|
|||
export let fullWidth = false
|
||||
export let noPadding = false
|
||||
export let tooltip = ""
|
||||
export let accentColor = null
|
||||
|
||||
let showTooltip = false
|
||||
|
||||
function longPress(element) {
|
||||
if (!longPressable) return
|
||||
let timer
|
||||
$: accentStyle = getAccentStyle(accentColor)
|
||||
|
||||
const listener = () => {
|
||||
timer = setTimeout(() => {
|
||||
dispatch("longpress")
|
||||
}, 700)
|
||||
}
|
||||
|
||||
element.addEventListener("pointerdown", listener)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
clearTimeout(timer)
|
||||
element.removeEventListener("pointerdown", longPress)
|
||||
},
|
||||
const getAccentStyle = color => {
|
||||
if (!color) {
|
||||
return ""
|
||||
}
|
||||
let style = ""
|
||||
style += `--accent-bg-color:${hexToRGBA(color, 0.15)};`
|
||||
style += `--accent-border-color:${hexToRGBA(color, 0.35)};`
|
||||
return style
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<span
|
||||
class="btn-wrap"
|
||||
<button
|
||||
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
|
||||
class:spectrum-ActionButton--quiet={quiet}
|
||||
class:is-selected={selected}
|
||||
class:noPadding
|
||||
class:fullWidth
|
||||
class:active
|
||||
class:disabled
|
||||
class:accent={accentColor != null}
|
||||
on:click|preventDefault
|
||||
on:mouseover={() => (showTooltip = true)}
|
||||
on:mouseleave={() => (showTooltip = false)}
|
||||
on:focus={() => (showTooltip = true)}
|
||||
{disabled}
|
||||
style={accentStyle}
|
||||
>
|
||||
<button
|
||||
use:longPress
|
||||
class:spectrum-ActionButton--quiet={quiet}
|
||||
class:spectrum-ActionButton--emphasized={emphasized}
|
||||
class:is-selected={selected}
|
||||
class:noPadding
|
||||
class:fullWidth
|
||||
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
|
||||
class:active
|
||||
class:disabled
|
||||
{disabled}
|
||||
on:longPress
|
||||
on:click|preventDefault
|
||||
>
|
||||
{#if longPressable}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if icon}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-Icon--sizeS"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
aria-label={icon}
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if $$slots}
|
||||
<span class="spectrum-ActionButton-label"><slot /></span>
|
||||
{/if}
|
||||
{#if tooltip && showTooltip}
|
||||
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
|
||||
<Tooltip textWrapping direction="bottom" text={tooltip} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</span>
|
||||
{#if icon}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-Icon--sizeS"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
aria-label={icon}
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if $$slots}
|
||||
<span class="spectrum-ActionButton-label"><slot /></span>
|
||||
{/if}
|
||||
{#if tooltip && showTooltip}
|
||||
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
|
||||
<Tooltip textWrapping direction="bottom" text={tooltip} />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
button {
|
||||
transition: filter 130ms ease-out, background 130ms ease-out,
|
||||
border 130ms ease-out, color 130ms ease-out;
|
||||
}
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -104,9 +82,7 @@
|
|||
margin-left: 0;
|
||||
transition: color ease-out 130ms;
|
||||
}
|
||||
.is-selected:not(.spectrum-ActionButton--emphasized):not(
|
||||
.spectrum-ActionButton--quiet
|
||||
) {
|
||||
.is-selected:not(.spectrum-ActionButton--quiet) {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
border-color: var(--spectrum-global-color-gray-500);
|
||||
}
|
||||
|
@ -115,12 +91,13 @@
|
|||
}
|
||||
.spectrum-ActionButton--quiet.is-selected {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.noPadding {
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.is-selected:not(.emphasized) .spectrum-Icon {
|
||||
.is-selected .spectrum-Icon {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
.is-selected.disabled .spectrum-Icon {
|
||||
|
@ -137,4 +114,12 @@
|
|||
text-align: center;
|
||||
z-index: 1;
|
||||
}
|
||||
.accent.is-selected,
|
||||
.accent:active {
|
||||
border: 1px solid var(--accent-border-color);
|
||||
background: var(--accent-bg-color);
|
||||
}
|
||||
.accent:hover {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,14 +1,20 @@
|
|||
<script>
|
||||
import { setContext } from "svelte"
|
||||
import { setContext, getContext } from "svelte"
|
||||
import Popover from "../Popover/Popover.svelte"
|
||||
import Menu from "../Menu/Menu.svelte"
|
||||
|
||||
export let disabled = false
|
||||
export let align = "left"
|
||||
export let portalTarget
|
||||
export let openOnHover = false
|
||||
export let animate
|
||||
export let offset
|
||||
|
||||
const actionMenuContext = getContext("actionMenu")
|
||||
|
||||
let anchor
|
||||
let dropdown
|
||||
let timeout
|
||||
|
||||
// This is needed because display: contents is considered "invisible".
|
||||
// It should only ever be an action button, so should be fine.
|
||||
|
@ -16,11 +22,19 @@
|
|||
anchor = node.firstChild
|
||||
}
|
||||
|
||||
export const show = () => {
|
||||
cancelHide()
|
||||
dropdown.show()
|
||||
}
|
||||
|
||||
export const hide = () => {
|
||||
dropdown.hide()
|
||||
}
|
||||
export const show = () => {
|
||||
dropdown.show()
|
||||
|
||||
// Hides this menu and all parent menus
|
||||
const hideAll = () => {
|
||||
hide()
|
||||
actionMenuContext?.hide()
|
||||
}
|
||||
|
||||
const openMenu = event => {
|
||||
|
@ -30,12 +44,25 @@
|
|||
}
|
||||
}
|
||||
|
||||
setContext("actionMenu", { show, hide })
|
||||
const queueHide = () => {
|
||||
timeout = setTimeout(hide, 10)
|
||||
}
|
||||
|
||||
const cancelHide = () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
setContext("actionMenu", { show, hide, hideAll })
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div use:getAnchor on:click={openMenu}>
|
||||
<div
|
||||
use:getAnchor
|
||||
on:click={openOnHover ? null : openMenu}
|
||||
on:mouseenter={openOnHover ? show : null}
|
||||
on:mouseleave={openOnHover ? queueHide : null}
|
||||
>
|
||||
<slot name="control" />
|
||||
</div>
|
||||
<Popover
|
||||
|
@ -43,9 +70,13 @@
|
|||
{anchor}
|
||||
{align}
|
||||
{portalTarget}
|
||||
{animate}
|
||||
{offset}
|
||||
resizable={false}
|
||||
on:open
|
||||
on:close
|
||||
on:mouseenter={openOnHover ? cancelHide : null}
|
||||
on:mouseleave={openOnHover ? queueHide : null}
|
||||
>
|
||||
<Menu>
|
||||
<slot />
|
||||
|
|
|
@ -151,9 +151,9 @@ export default function positionDropdown(element, opts) {
|
|||
// Determine X strategy
|
||||
if (align === "right") {
|
||||
applyXStrategy(Strategies.EndToEnd)
|
||||
} else if (align === "right-outside") {
|
||||
} else if (align === "right-outside" || align === "right-context-menu") {
|
||||
applyXStrategy(Strategies.StartToEnd)
|
||||
} else if (align === "left-outside") {
|
||||
} else if (align === "left-outside" || align === "left-context-menu") {
|
||||
applyXStrategy(Strategies.EndToStart)
|
||||
} else if (align === "center") {
|
||||
applyXStrategy(Strategies.MidPoint)
|
||||
|
@ -164,6 +164,12 @@ export default function positionDropdown(element, opts) {
|
|||
// Determine Y strategy
|
||||
if (align === "right-outside" || align === "left-outside") {
|
||||
applyYStrategy(Strategies.MidPoint)
|
||||
} else if (
|
||||
align === "right-context-menu" ||
|
||||
align === "left-context-menu"
|
||||
) {
|
||||
applyYStrategy(Strategies.StartToStart)
|
||||
styles.top -= 5 // Manual adjustment for action menu padding
|
||||
} else {
|
||||
applyYStrategy(Strategies.StartToEnd)
|
||||
}
|
||||
|
@ -240,7 +246,7 @@ export default function positionDropdown(element, opts) {
|
|||
}
|
||||
|
||||
// Apply initial styles which don't need to change
|
||||
element.style.position = "absolute"
|
||||
element.style.position = "fixed"
|
||||
element.style.zIndex = "9999"
|
||||
|
||||
// Set up a scroll listener
|
||||
|
|
|
@ -17,6 +17,8 @@
|
|||
export let tooltip = undefined
|
||||
export let newStyles = true
|
||||
export let id
|
||||
export let ref
|
||||
export let reverse = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
@ -25,6 +27,7 @@
|
|||
<button
|
||||
{id}
|
||||
{type}
|
||||
bind:this={ref}
|
||||
class:spectrum-Button--cta={cta}
|
||||
class:spectrum-Button--primary={primary}
|
||||
class:spectrum-Button--secondary={secondary}
|
||||
|
@ -41,6 +44,9 @@
|
|||
}
|
||||
}}
|
||||
>
|
||||
{#if $$slots && reverse}
|
||||
<span class="spectrum-Button-label"><slot /></span>
|
||||
{/if}
|
||||
{#if icon}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
|
||||
|
@ -51,7 +57,7 @@
|
|||
<use xlink:href="#spectrum-icon-18-{icon}" />
|
||||
</svg>
|
||||
{/if}
|
||||
{#if $$slots}
|
||||
{#if $$slots && !reverse}
|
||||
<span class="spectrum-Button-label"><slot /></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
@ -91,4 +97,11 @@
|
|||
.spectrum-Button--secondary.new-styles.is-disabled {
|
||||
color: var(--spectrum-global-color-gray-500);
|
||||
}
|
||||
.spectrum-Button .spectrum-Button-label + .spectrum-Icon {
|
||||
margin-left: var(--spectrum-button-primary-icon-gap);
|
||||
margin-right: calc(
|
||||
-1 * (var(--spectrum-button-primary-textonly-padding-left-adjusted) -
|
||||
var(--spectrum-button-primary-padding-left-adjusted))
|
||||
);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
<script>
|
||||
import Button from "../Button/Button.svelte"
|
||||
import Popover from "../Popover/Popover.svelte"
|
||||
import Menu from "../Menu/Menu.svelte"
|
||||
import MenuItem from "../Menu/Item.svelte"
|
||||
|
||||
export let buttons
|
||||
export let text = "Action"
|
||||
export let size = "M"
|
||||
export let align = "left"
|
||||
export let offset
|
||||
export let animate
|
||||
export let quiet = false
|
||||
|
||||
let anchor
|
||||
let popover
|
||||
|
||||
const handleClick = async button => {
|
||||
popover.hide()
|
||||
await button.onClick?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button
|
||||
bind:ref={anchor}
|
||||
{size}
|
||||
icon="ChevronDown"
|
||||
{quiet}
|
||||
primary={quiet}
|
||||
cta={!quiet}
|
||||
newStyles={!quiet}
|
||||
on:click={() => popover?.show()}
|
||||
on:click
|
||||
reverse
|
||||
>
|
||||
{text || "Action"}
|
||||
</Button>
|
||||
<Popover
|
||||
bind:this={popover}
|
||||
{align}
|
||||
{anchor}
|
||||
{offset}
|
||||
{animate}
|
||||
resizable={false}
|
||||
on:close
|
||||
on:open
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
>
|
||||
<Menu>
|
||||
{#each buttons as button}
|
||||
<MenuItem on:click={() => handleClick(button)} disabled={button.disabled}>
|
||||
{button.text || "Button"}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</Menu>
|
||||
</Popover>
|
|
@ -6,6 +6,11 @@
|
|||
import Icon from "../Icon/Icon.svelte"
|
||||
import Input from "../Form/Input.svelte"
|
||||
import { capitalise } from "../helpers"
|
||||
import {
|
||||
ensureValidTheme,
|
||||
getThemeClassNames,
|
||||
DefaultAppTheme,
|
||||
} from "@budibase/shared-core"
|
||||
|
||||
export let value
|
||||
export let size = "M"
|
||||
|
@ -18,6 +23,7 @@
|
|||
|
||||
$: customValue = getCustomValue(value)
|
||||
$: checkColor = getCheckColor(value)
|
||||
$: themeClasses = getThemeClasses(spectrumTheme)
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const categories = [
|
||||
|
@ -91,6 +97,14 @@
|
|||
},
|
||||
]
|
||||
|
||||
const getThemeClasses = theme => {
|
||||
if (!theme) {
|
||||
return ""
|
||||
}
|
||||
theme = ensureValidTheme(theme, DefaultAppTheme)
|
||||
return getThemeClassNames(theme)
|
||||
}
|
||||
|
||||
const onChange = value => {
|
||||
dispatch("change", value)
|
||||
dropdown.hide()
|
||||
|
@ -147,7 +161,7 @@
|
|||
}}
|
||||
>
|
||||
<div
|
||||
class="fill {spectrumTheme || ''}"
|
||||
class="fill {themeClasses}"
|
||||
style={value ? `background: ${value};` : ""}
|
||||
class:placeholder={!value}
|
||||
/>
|
||||
|
@ -171,7 +185,7 @@
|
|||
title={prettyPrint(color)}
|
||||
>
|
||||
<div
|
||||
class="fill {spectrumTheme || ''}"
|
||||
class="fill {themeClasses}"
|
||||
style="background: var(--spectrum-global-color-{color}); color: {checkColor};"
|
||||
>
|
||||
{#if value === `var(--spectrum-global-color-${color})`}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import Link from "../../Link/Link.svelte"
|
||||
import Tag from "../../Tags/Tag.svelte"
|
||||
import Tags from "../../Tags/Tags.svelte"
|
||||
import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte"
|
||||
|
||||
const BYTES_IN_KB = 1000
|
||||
const BYTES_IN_MB = 1000000
|
||||
|
@ -39,12 +40,14 @@
|
|||
"jfif",
|
||||
"webp",
|
||||
]
|
||||
|
||||
const fieldId = id || uuid()
|
||||
|
||||
let selectedImageIdx = 0
|
||||
let fileDragged = false
|
||||
let selectedUrl
|
||||
let fileInput
|
||||
let loading = false
|
||||
|
||||
$: selectedImage = value?.[selectedImageIdx] ?? null
|
||||
$: fileCount = value?.length ?? 0
|
||||
$: isImage =
|
||||
|
@ -86,10 +89,15 @@
|
|||
}
|
||||
|
||||
if (processFiles) {
|
||||
const processedFiles = await processFiles(fileList)
|
||||
const newValue = [...value, ...processedFiles]
|
||||
dispatch("change", newValue)
|
||||
selectedImageIdx = newValue.length - 1
|
||||
loading = true
|
||||
try {
|
||||
const processedFiles = await processFiles(fileList)
|
||||
const newValue = [...value, ...processedFiles]
|
||||
dispatch("change", newValue)
|
||||
selectedImageIdx = newValue.length - 1
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
} else {
|
||||
dispatch("change", fileList)
|
||||
}
|
||||
|
@ -227,7 +235,7 @@
|
|||
{#if showDropzone}
|
||||
<div
|
||||
class="spectrum-Dropzone"
|
||||
class:disabled
|
||||
class:disabled={disabled || loading}
|
||||
role="region"
|
||||
tabindex="0"
|
||||
on:dragover={handleDragOver}
|
||||
|
@ -241,7 +249,7 @@
|
|||
id={fieldId}
|
||||
{disabled}
|
||||
type="file"
|
||||
multiple
|
||||
multiple={maximum !== 1}
|
||||
accept={extensions}
|
||||
bind:this={fileInput}
|
||||
on:change={handleFile}
|
||||
|
@ -339,6 +347,12 @@
|
|||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<ProgressCircle size="M" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
@ -464,6 +478,7 @@
|
|||
|
||||
.spectrum-Dropzone {
|
||||
height: 220px;
|
||||
position: relative;
|
||||
}
|
||||
.compact .spectrum-Dropzone {
|
||||
height: 40px;
|
||||
|
@ -488,4 +503,14 @@
|
|||
.tag {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
{disabled}
|
||||
on:change={onChange}
|
||||
on:click
|
||||
on:click|stopPropagation
|
||||
{id}
|
||||
type="checkbox"
|
||||
class="spectrum-Switch-input"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import "@spectrum-css/textfield/dist/index-vars.css"
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
import { createEventDispatcher, onMount, tick } from "svelte"
|
||||
|
||||
export let value = null
|
||||
export let placeholder = null
|
||||
|
@ -68,10 +68,13 @@
|
|||
return type === "number" ? "decimal" : "text"
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
onMount(async () => {
|
||||
if (disabled) return
|
||||
focus = autofocus
|
||||
if (focus) field.focus()
|
||||
if (focus) {
|
||||
await tick()
|
||||
field.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
@ -60,10 +60,11 @@
|
|||
.newStyles {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: color var(--spectrum-global-animation-duration-100, 130ms);
|
||||
}
|
||||
svg.hoverable {
|
||||
pointer-events: all;
|
||||
transition: color var(--spectrum-global-animation-duration-100, 130ms);
|
||||
}
|
||||
svg.hoverable:hover {
|
||||
color: var(--hover-color) !important;
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
export let onConfirm = undefined
|
||||
export let buttonText = ""
|
||||
export let cta = false
|
||||
|
||||
$: icon = selectIcon(type)
|
||||
// if newlines used, convert them to different elements
|
||||
$: split = message.split("\n")
|
||||
|
|
|
@ -1,55 +1,68 @@
|
|||
<script>
|
||||
import Body from "../Typography/Body.svelte"
|
||||
import IconAvatar from "../Icon/IconAvatar.svelte"
|
||||
import Label from "../Label/Label.svelte"
|
||||
import Avatar from "../Avatar/Avatar.svelte"
|
||||
import Icon from "../Icon/Icon.svelte"
|
||||
import StatusLight from "../StatusLight/StatusLight.svelte"
|
||||
|
||||
export let icon = null
|
||||
export let iconBackground = null
|
||||
export let iconColor = null
|
||||
export let avatar = false
|
||||
export let title = null
|
||||
export let subtitle = null
|
||||
export let url = null
|
||||
export let hoverable = false
|
||||
|
||||
$: initials = avatar ? title?.[0] : null
|
||||
export let showArrow = false
|
||||
export let selected = false
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="list-item" class:hoverable on:click>
|
||||
<div class="left">
|
||||
{#if icon}
|
||||
<IconAvatar {icon} color={iconColor} background={iconBackground} />
|
||||
<a
|
||||
href={url}
|
||||
class="list-item"
|
||||
class:hoverable={hoverable || url != null}
|
||||
class:large={!!subtitle}
|
||||
on:click
|
||||
class:selected
|
||||
>
|
||||
<div class="list-item__left">
|
||||
{#if icon === "StatusLight"}
|
||||
<StatusLight square size="L" color={iconColor} />
|
||||
{:else if icon}
|
||||
<div class="list-item__icon">
|
||||
<Icon name={icon} color={iconColor} size={subtitle ? "XL" : "M"} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if avatar}
|
||||
<Avatar {initials} />
|
||||
{/if}
|
||||
{#if title}
|
||||
<Body>{title}</Body>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<Label>{subtitle}</Label>
|
||||
<div class="list-item__text">
|
||||
{#if title}
|
||||
<div class="list-item__title">
|
||||
{title}
|
||||
</div>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<div class="list-item__subtitle">
|
||||
{subtitle}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-item__right">
|
||||
<slot name="right" />
|
||||
{#if showArrow}
|
||||
<Icon name="ChevronRight" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if $$slots.default}
|
||||
<div class="right">
|
||||
<slot />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.list-item {
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
background: var(--spectrum-global-color-gray-50);
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
background: var(--spectrum-global-color-gray-75);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
transition: background 130ms ease-out;
|
||||
transition: background 130ms ease-out, border-color 130ms ease-out;
|
||||
gap: var(--spacing-m);
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.list-item:not(:first-child) {
|
||||
border-top: none;
|
||||
|
@ -64,32 +77,87 @@
|
|||
}
|
||||
.hoverable:hover {
|
||||
cursor: pointer;
|
||||
background: var(--spectrum-global-color-gray-75);
|
||||
}
|
||||
.left,
|
||||
.right {
|
||||
.hoverable:not(.selected):hover {
|
||||
background: var(--spectrum-global-color-gray-200);
|
||||
border-color: var(--spectrum-global-color-gray-400);
|
||||
}
|
||||
.selected {
|
||||
background: var(--spectrum-global-color-blue-100);
|
||||
}
|
||||
|
||||
/* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
|
||||
.list-item.selected {
|
||||
background-color: var(--spectrum-global-color-blue-100);
|
||||
border-color: var(--spectrum-global-color-blue-100);
|
||||
}
|
||||
.list-item.selected:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 1px solid var(--spectrum-global-color-blue-400);
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
z-index: 1;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Large icons */
|
||||
.list-item.large .list-item__icon {
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
transition: background-color 130ms ease-out, border-color 130ms ease-out,
|
||||
color 130ms ease-out;
|
||||
}
|
||||
.list-item.large.hoverable:not(.selected):hover .list-item__icon {
|
||||
background-color: var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.list-item.large.selected .list-item__icon {
|
||||
background-color: var(--spectrum-global-color-blue-400);
|
||||
color: white;
|
||||
border-color: var(--spectrum-global-color-blue-100);
|
||||
}
|
||||
|
||||
/* Internal layout */
|
||||
.list-item__left,
|
||||
.list-item__right {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xl);
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.left {
|
||||
.list-item.large .list-item__left,
|
||||
.list-item.large .list-item__right {
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.list-item__left {
|
||||
width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.right {
|
||||
.list-item__right {
|
||||
flex: 0 0 auto;
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
.list-item :global(.spectrum-Icon),
|
||||
.list-item :global(.spectrum-Avatar) {
|
||||
flex: 0 0 auto;
|
||||
|
||||
/* Text */
|
||||
.list-item__text {
|
||||
flex: 1 1 auto;
|
||||
width: 0;
|
||||
}
|
||||
.list-item :global(.spectrum-Body) {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
.list-item :global(.spectrum-Body) {
|
||||
.list-item__title,
|
||||
.list-item__subtitle {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.list-item__subtitle {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
|
||||
const onClick = () => {
|
||||
if (actionMenu && !noClose) {
|
||||
actionMenu.hide()
|
||||
actionMenu.hideAll()
|
||||
}
|
||||
dispatch("click")
|
||||
}
|
||||
|
@ -35,7 +35,7 @@
|
|||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<li
|
||||
on:click|preventDefault={disabled ? null : onClick}
|
||||
on:click={disabled ? null : onClick}
|
||||
class="spectrum-Menu-item"
|
||||
class:is-disabled={disabled}
|
||||
role="menuitem"
|
||||
|
@ -47,8 +47,9 @@
|
|||
</div>
|
||||
{/if}
|
||||
<span class="spectrum-Menu-itemLabel"><slot /></span>
|
||||
{#if keys?.length}
|
||||
{#if keys?.length || $$slots.right}
|
||||
<div class="keys">
|
||||
<slot name="right" />
|
||||
{#each keys as key}
|
||||
<div class="key">
|
||||
{#if key.startsWith("!")}
|
||||
|
|
|
@ -30,7 +30,9 @@
|
|||
export let custom = false
|
||||
|
||||
const { hide, cancel } = getContext(Context.Modal)
|
||||
|
||||
let loading = false
|
||||
|
||||
$: confirmDisabled = disabled || loading
|
||||
|
||||
async function secondary(e) {
|
||||
|
@ -90,7 +92,7 @@
|
|||
|
||||
<!-- TODO: Remove content-grid class once Layout components are in bbui -->
|
||||
<section class="spectrum-Dialog-content content-grid">
|
||||
<slot />
|
||||
<slot {loading} />
|
||||
</section>
|
||||
{#if showCancelButton || showConfirmButton || $$slots.footer}
|
||||
<div
|
||||
|
@ -145,6 +147,9 @@
|
|||
.spectrum-Dialog--extraLarge {
|
||||
width: 1000px;
|
||||
}
|
||||
.spectrum-Dialog--medium {
|
||||
width: 540px;
|
||||
}
|
||||
|
||||
.content-grid {
|
||||
display: grid;
|
||||
|
|
|
@ -27,11 +27,7 @@
|
|||
<div class="spectrum-Toast-body" class:actionBody={!!action}>
|
||||
<div class="wrap spectrum-Toast-content">{message || ""}</div>
|
||||
{#if action}
|
||||
<ActionButton
|
||||
quiet
|
||||
emphasized
|
||||
on:click={() => action(() => dispatch("dismiss"))}
|
||||
>
|
||||
<ActionButton quiet on:click={() => action(() => dispatch("dismiss"))}>
|
||||
<div style="color: white; font-weight: 600;">{actionMessage}</div>
|
||||
</ActionButton>
|
||||
{/if}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import "@spectrum-css/popover/dist/index-vars.css"
|
||||
import Portal from "svelte-portal"
|
||||
import { createEventDispatcher, getContext } from "svelte"
|
||||
import { createEventDispatcher, getContext, onDestroy } from "svelte"
|
||||
import positionDropdown from "../Actions/position_dropdown"
|
||||
import clickOutside from "../Actions/click_outside"
|
||||
import { fly } from "svelte/transition"
|
||||
|
@ -28,7 +28,24 @@
|
|||
export let resizable = true
|
||||
export let wrap = false
|
||||
|
||||
const animationDuration = 260
|
||||
|
||||
let timeout
|
||||
let blockPointerEvents = false
|
||||
|
||||
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
|
||||
$: {
|
||||
// Disable pointer events for the initial part of the animation, because we
|
||||
// fly from top to bottom and initially can be positioned under the cursor,
|
||||
// causing a flashing hover state in the content
|
||||
if (open && animate) {
|
||||
blockPointerEvents = true
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
blockPointerEvents = false
|
||||
}, animationDuration / 2)
|
||||
}
|
||||
}
|
||||
|
||||
export const show = () => {
|
||||
dispatch("open")
|
||||
|
@ -77,6 +94,10 @@
|
|||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearTimeout(timeout)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
|
@ -104,9 +125,13 @@
|
|||
class="spectrum-Popover is-open"
|
||||
class:customZindex
|
||||
class:hidden={!showPopover}
|
||||
class:blockPointerEvents
|
||||
role="presentation"
|
||||
style="height: {customHeight}; --customZindex: {customZindex};"
|
||||
transition:fly|local={{ y: -20, duration: animate ? 260 : 0 }}
|
||||
transition:fly|local={{
|
||||
y: -20,
|
||||
duration: animate ? animationDuration : 0,
|
||||
}}
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
>
|
||||
|
@ -121,6 +146,12 @@
|
|||
border-color: var(--spectrum-global-color-gray-300);
|
||||
overflow: auto;
|
||||
transition: opacity 260ms ease-out;
|
||||
filter: none;
|
||||
-webkit-filter: none;
|
||||
box-shadow: 0 1px 4px var(--drop-shadow);
|
||||
}
|
||||
.blockPointerEvents {
|
||||
pointer-events: none;
|
||||
}
|
||||
.hidden {
|
||||
opacity: 0;
|
||||
|
|
|
@ -228,3 +228,13 @@ export const getDateDisplayValue = (
|
|||
return value.format(`${localeDateFormat} HH:mm`)
|
||||
}
|
||||
}
|
||||
|
||||
export const hexToRGBA = (color, opacity) => {
|
||||
if (color.includes("#")) {
|
||||
color = color.replace("#", "")
|
||||
}
|
||||
const r = parseInt(color.substring(0, 2), 16)
|
||||
const g = parseInt(color.substring(2, 4), 16)
|
||||
const b = parseInt(color.substring(4, 6), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
|
|||
export { default as Multiselect } from "./Form/Multiselect.svelte"
|
||||
export { default as Search } from "./Form/Search.svelte"
|
||||
export { default as RichTextField } from "./Form/RichTextField.svelte"
|
||||
export { default as FieldLabel } from "./Form/FieldLabel.svelte"
|
||||
export { default as Slider } from "./Form/Slider.svelte"
|
||||
export { default as File } from "./Form/File.svelte"
|
||||
|
||||
|
@ -39,6 +40,7 @@ export { default as ActionGroup } from "./ActionGroup/ActionGroup.svelte"
|
|||
export { default as ActionMenu } from "./ActionMenu/ActionMenu.svelte"
|
||||
export { default as Button } from "./Button/Button.svelte"
|
||||
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
|
||||
export { default as CollapsedButtonGroup } from "./ButtonGroup/CollapsedButtonGroup.svelte"
|
||||
export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
|
||||
export { default as Icon } from "./Icon/Icon.svelte"
|
||||
export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
|
||||
|
|
|
@ -59,12 +59,14 @@
|
|||
"@codemirror/state": "^6.2.0",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.11.2",
|
||||
"@dagrejs/dagre": "1.1.4",
|
||||
"@fontsource/source-sans-pro": "^5.0.3",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.2",
|
||||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/vars": "^3.0.1",
|
||||
"@xyflow/svelte": "^0.1.18",
|
||||
"@zerodevx/svelte-json-view": "^1.0.7",
|
||||
"codemirror": "^5.65.16",
|
||||
"cron-parser": "^4.9.0",
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
import Flowchart from "./FlowChart/FlowChart.svelte"
|
||||
</script>
|
||||
|
||||
{#if $selectedAutomation}
|
||||
{#key $selectedAutomation._id}
|
||||
<Flowchart automation={$selectedAutomation} />
|
||||
{#if $selectedAutomation?.data}
|
||||
{#key $selectedAutomation.data._id}
|
||||
<Flowchart automation={$selectedAutomation.data} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
|
|
@ -0,0 +1,497 @@
|
|||
<script>
|
||||
import { writable } from "svelte/store"
|
||||
import {
|
||||
setContext,
|
||||
onMount,
|
||||
createEventDispatcher,
|
||||
onDestroy,
|
||||
tick,
|
||||
} from "svelte"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
import { selectedAutomation, automationStore } from "stores/builder"
|
||||
|
||||
export function zoomIn() {
|
||||
const scale = Number(Math.min($view.scale + 0.1, 1.5).toFixed(2))
|
||||
view.update(state => ({
|
||||
...state,
|
||||
scale,
|
||||
}))
|
||||
}
|
||||
|
||||
export function zoomOut() {
|
||||
const scale = Number(Math.max($view.scale - 0.1, 0).toFixed(2))
|
||||
view.update(state => ({
|
||||
...state,
|
||||
scale,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function reset() {
|
||||
contentDims = {
|
||||
...contentDims,
|
||||
w: contentDims.original.w,
|
||||
h: contentDims.original.h,
|
||||
}
|
||||
dragOffset = []
|
||||
contentPos.update(state => ({
|
||||
...state,
|
||||
x: 0,
|
||||
y: 0,
|
||||
}))
|
||||
view.update(state => ({
|
||||
...state,
|
||||
scale: 1,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function zoomToFit() {
|
||||
const { width: wViewPort, height: hViewPort } =
|
||||
viewPort.getBoundingClientRect()
|
||||
|
||||
const scaleTarget = Math.min(
|
||||
wViewPort / contentDims.original.w,
|
||||
hViewPort / contentDims.original.h
|
||||
)
|
||||
|
||||
// Smallest ratio determines which dimension needs squeezed
|
||||
view.update(state => ({
|
||||
...state,
|
||||
scale: scaleTarget,
|
||||
}))
|
||||
|
||||
await tick()
|
||||
|
||||
const adjustedY = (hViewPort - contentDims.original.h) / 2
|
||||
|
||||
contentPos.update(state => ({
|
||||
...state,
|
||||
x: 0,
|
||||
y: parseInt(0 + adjustedY),
|
||||
}))
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// View State
|
||||
const view = writable({
|
||||
dragging: false,
|
||||
moveStep: null,
|
||||
dragSpot: null,
|
||||
scale: 1,
|
||||
dropzones: {},
|
||||
//focus - node to center on?
|
||||
})
|
||||
|
||||
setContext("draggableView", view)
|
||||
|
||||
// View internal pos tracking
|
||||
const internalPos = writable({ x: 0, y: 0 })
|
||||
setContext("viewPos", internalPos)
|
||||
|
||||
// Content pos tracking
|
||||
const contentPos = writable({ x: 0, y: 0, scrollX: 0, scrollY: 0 })
|
||||
setContext("contentPos", contentPos)
|
||||
|
||||
// Elements
|
||||
let mainContent
|
||||
let viewPort
|
||||
let contentWrap
|
||||
|
||||
// Mouse down
|
||||
let down = false
|
||||
|
||||
// Monitor the size of the viewPort
|
||||
let observer
|
||||
|
||||
// Size of the core display content
|
||||
let contentDims = {}
|
||||
|
||||
// Size of the view port
|
||||
let viewDims = {}
|
||||
|
||||
// When dragging the content, maintain the drag start offset
|
||||
let dragOffset
|
||||
|
||||
// Used when focusing the UI on trigger
|
||||
let loaded = false
|
||||
|
||||
// Edge around the draggable content
|
||||
let contentDragPadding = 200
|
||||
|
||||
const onScale = async () => {
|
||||
dispatch("zoom", $view.scale)
|
||||
await getDims()
|
||||
}
|
||||
|
||||
const getDims = async () => {
|
||||
if (!mainContent) return
|
||||
|
||||
if (!contentDims.original) {
|
||||
contentDims.original = {
|
||||
w: parseInt(mainContent.getBoundingClientRect().width),
|
||||
h: parseInt(mainContent.getBoundingClientRect().height),
|
||||
}
|
||||
}
|
||||
|
||||
viewDims = viewPort.getBoundingClientRect()
|
||||
|
||||
contentDims = {
|
||||
...contentDims,
|
||||
w: contentDims.original.w * $view.scale,
|
||||
h: contentDims.original.h * $view.scale,
|
||||
}
|
||||
}
|
||||
|
||||
const eleXY = (coords, ele) => {
|
||||
const { clientX, clientY } = coords
|
||||
const rect = ele.getBoundingClientRect()
|
||||
const x = Math.round(clientX - rect.left)
|
||||
const y = Math.round(clientY - rect.top)
|
||||
return { x: Math.max(x, 0), y: Math.max(y, 0) }
|
||||
}
|
||||
|
||||
const buildWrapStyles = (pos, scale, dims) => {
|
||||
const { x, y } = pos
|
||||
const { w, h } = dims
|
||||
return `--posX: ${x}px; --posY: ${y}px; --scale: ${scale}; --wrapH: ${h}px; --wrapW: ${w}px`
|
||||
}
|
||||
|
||||
const onViewScroll = e => {
|
||||
e.preventDefault()
|
||||
let currentScale = $view.scale
|
||||
let scrollIncrement = 35
|
||||
let xBump = 0
|
||||
let yBump = 0
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Scroll horizontal - Needs Limits
|
||||
xBump = scrollIncrement * (e.deltaX < 0 ? -1 : 1)
|
||||
contentPos.update(state => ({
|
||||
...state,
|
||||
x: state.x - xBump,
|
||||
y: state.y,
|
||||
// If scrolling *and* dragging, maintain a record of the scroll offset
|
||||
...($view.dragging
|
||||
? {
|
||||
scrollX: state.scrollX - xBump,
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
// Scale the content on scrolling
|
||||
let updatedScale
|
||||
if (e.deltaY < 0) {
|
||||
updatedScale = Math.min(1, currentScale + 0.05)
|
||||
} else if (e.deltaY > 0) {
|
||||
updatedScale = Math.max(0, currentScale - 0.05)
|
||||
}
|
||||
|
||||
view.update(state => ({
|
||||
...state,
|
||||
scale: Number(updatedScale.toFixed(2)),
|
||||
}))
|
||||
} else {
|
||||
yBump = scrollIncrement * (e.deltaY < 0 ? -1 : 1)
|
||||
contentPos.update(state => ({
|
||||
...state,
|
||||
x: state.x,
|
||||
y: state.y - yBump,
|
||||
// If scrolling *and* dragging, maintain a record of the scroll offset
|
||||
...($view.dragging
|
||||
? {
|
||||
scrollY: state.scrollY - yBump,
|
||||
}
|
||||
: {}),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Optimization options
|
||||
const onViewMouseMove = async e => {
|
||||
if (!viewPort) {
|
||||
return
|
||||
}
|
||||
|
||||
const { x, y } = eleXY(e, viewPort)
|
||||
|
||||
internalPos.update(() => ({
|
||||
x,
|
||||
y,
|
||||
}))
|
||||
|
||||
if (down && !$view.dragging && dragOffset) {
|
||||
contentPos.update(state => ({
|
||||
...state,
|
||||
x: x - dragOffset[0],
|
||||
y: y - dragOffset[1],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const onViewDragEnd = () => {
|
||||
down = false
|
||||
dragOffset = [0, 0]
|
||||
}
|
||||
|
||||
const handleDragDrop = () => {
|
||||
const sourceBlock = $selectedAutomation.blockRefs[$view.moveStep.id]
|
||||
const sourcePath = sourceBlock.pathTo
|
||||
|
||||
const dropZone = $view.dropzones[$view.droptarget]
|
||||
const destPath = dropZone?.path
|
||||
|
||||
automationStore.actions.moveBlock(
|
||||
sourcePath,
|
||||
destPath,
|
||||
$selectedAutomation.data
|
||||
)
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
if ($view.droptarget) {
|
||||
handleDragDrop()
|
||||
}
|
||||
|
||||
view.update(state => ({
|
||||
...state,
|
||||
dragging: false,
|
||||
moveStep: null,
|
||||
dragSpot: null,
|
||||
dropzones: {},
|
||||
droptarget: null,
|
||||
}))
|
||||
|
||||
// Clear the scroll offset for dragging
|
||||
contentPos.update(state => ({
|
||||
...state,
|
||||
scrollY: 0,
|
||||
scrollX: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const onMouseMove = async e => {
|
||||
if (!viewPort) {
|
||||
return
|
||||
}
|
||||
// Update viewDims to get the latest viewport dimensions
|
||||
viewDims = viewPort.getBoundingClientRect()
|
||||
|
||||
if ($view.moveStep && $view.dragging === false) {
|
||||
view.update(state => ({
|
||||
...state,
|
||||
dragging: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const checkIntersection = (pos, dzRect) => {
|
||||
return (
|
||||
pos.x < dzRect.right &&
|
||||
pos.x > dzRect.left &&
|
||||
pos.y < dzRect.bottom &&
|
||||
pos.y > dzRect.top
|
||||
)
|
||||
}
|
||||
|
||||
if ($view.dragging) {
|
||||
const adjustedX =
|
||||
(e.clientX - viewDims.left - $view.moveStep.offsetX) / $view.scale
|
||||
const adjustedY =
|
||||
(e.clientY - viewDims.top - $view.moveStep.offsetY) / $view.scale
|
||||
|
||||
view.update(state => ({
|
||||
...state,
|
||||
dragSpot: {
|
||||
x: adjustedX,
|
||||
y: adjustedY,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if ($view.moveStep && $view.dragging) {
|
||||
let hovering = false
|
||||
Object.entries($view.dropzones).forEach(entry => {
|
||||
const [dzKey, dz] = entry
|
||||
if (checkIntersection({ x: e.clientX, y: e.clientY }, dz.dims)) {
|
||||
hovering = true
|
||||
view.update(state => ({
|
||||
...state,
|
||||
droptarget: dzKey,
|
||||
}))
|
||||
}
|
||||
})
|
||||
// Ensure that when it stops hovering, it clears the drop target
|
||||
if (!hovering) {
|
||||
view.update(state => ({
|
||||
...state,
|
||||
droptarget: null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onMoveContent = e => {
|
||||
if (down || !viewPort) {
|
||||
return
|
||||
}
|
||||
const { x, y } = eleXY(e, viewPort)
|
||||
|
||||
dragOffset = [Math.abs(x - $contentPos.x), Math.abs(y - $contentPos.y)]
|
||||
}
|
||||
|
||||
const focusOnLoad = () => {
|
||||
if ($view.focusEle && !loaded) {
|
||||
const focusEleDims = $view.focusEle
|
||||
const viewWidth = viewDims.width
|
||||
|
||||
// The amount to shift the content in order to center the trigger on load.
|
||||
// The content is also padded with `contentDragPadding`
|
||||
// The sidebar offset factors into the left positioning of the content here.
|
||||
const targetX =
|
||||
contentWrap.getBoundingClientRect().x -
|
||||
focusEleDims.x +
|
||||
(viewWidth / 2 - focusEleDims.width / 2)
|
||||
|
||||
// Update the content position state
|
||||
// Shift the content up slightly to accommodate the padding
|
||||
contentPos.update(state => ({
|
||||
...state,
|
||||
x: targetX,
|
||||
y: -(contentDragPadding / 2),
|
||||
}))
|
||||
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
// Update dims after scaling
|
||||
$: {
|
||||
$view.scale
|
||||
onScale()
|
||||
}
|
||||
|
||||
// Focus on a registered element
|
||||
$: {
|
||||
$view.focusEle
|
||||
focusOnLoad()
|
||||
}
|
||||
|
||||
// Content mouse pos and scale to css variables.
|
||||
// The wrap is set to match the content size
|
||||
$: wrapStyles = buildWrapStyles($contentPos, $view.scale, contentDims)
|
||||
|
||||
onMount(() => {
|
||||
observer = new ResizeObserver(getDims)
|
||||
observer.observe(viewPort)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
observer.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<div
|
||||
class="draggable-canvas"
|
||||
role="region"
|
||||
aria-label="Viewport for building automations"
|
||||
on:mouseup={onMouseUp}
|
||||
on:mousemove={Utils.domDebounce(onMouseMove)}
|
||||
style={`--dragPadding: ${contentDragPadding}px;`}
|
||||
>
|
||||
<div
|
||||
class="draggable-view"
|
||||
bind:this={viewPort}
|
||||
on:wheel={Utils.domDebounce(onViewScroll)}
|
||||
on:mousemove={Utils.domDebounce(onViewMouseMove)}
|
||||
on:mouseup={onViewDragEnd}
|
||||
on:mouseleave={onViewDragEnd}
|
||||
>
|
||||
<!-- <div class="debug">
|
||||
<span>
|
||||
View Pos [{$internalPos.x}, {$internalPos.y}]
|
||||
</span>
|
||||
<span>View Dims [{viewDims.width}, {viewDims.height}]</span>
|
||||
<span>Mouse Down [{down}]</span>
|
||||
<span>Drag [{$view.dragging}]</span>
|
||||
<span>Dragging [{$view?.moveStep?.id || "no"}]</span>
|
||||
<span>Scale [{$view.scale}]</span>
|
||||
<span>Content [{JSON.stringify($contentPos)}]</span>
|
||||
</div> -->
|
||||
<div
|
||||
class="content-wrap"
|
||||
style={wrapStyles}
|
||||
bind:this={contentWrap}
|
||||
class:dragging={down}
|
||||
>
|
||||
<div
|
||||
class="content"
|
||||
bind:this={mainContent}
|
||||
on:mousemove={Utils.domDebounce(onMoveContent)}
|
||||
on:mousedown={e => {
|
||||
if (e.which === 1 || e.button === 0) {
|
||||
down = true
|
||||
}
|
||||
}}
|
||||
on:mouseup={e => {
|
||||
if (e.which === 1 || e.button === 0) {
|
||||
down = false
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
down = false
|
||||
view.update(state => ({
|
||||
...state,
|
||||
dragging: false,
|
||||
moveStep: null,
|
||||
dragSpot: null,
|
||||
dropzones: {},
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<slot name="content" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.draggable-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.draggable-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.content-wrap {
|
||||
min-width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--wrapW);
|
||||
height: var(--wrapH);
|
||||
cursor: grab;
|
||||
transform: translate(var(--posX), var(--posY));
|
||||
}
|
||||
.content {
|
||||
transform: scale(var(--scale));
|
||||
user-select: none;
|
||||
padding: var(--dragPadding);
|
||||
}
|
||||
|
||||
.content-wrap.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* .debug {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
position: fixed;
|
||||
padding: 8px;
|
||||
z-index: 2;
|
||||
} */
|
||||
</style>
|
|
@ -9,27 +9,42 @@
|
|||
Tags,
|
||||
Tag,
|
||||
} from "@budibase/bbui"
|
||||
import { AutomationActionStepId } from "@budibase/types"
|
||||
import { automationStore, selectedAutomation } from "stores/builder"
|
||||
import { admin, licensing } from "stores/portal"
|
||||
import { externalActions } from "./ExternalActions"
|
||||
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
|
||||
import { checkForCollectStep } from "helpers/utils"
|
||||
|
||||
export let blockIdx
|
||||
export let lastStep
|
||||
export let block
|
||||
export let modal
|
||||
|
||||
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
|
||||
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
|
||||
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
|
||||
let selectedAction
|
||||
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
|
||||
let actions = Object.entries($automationStore.blockDefinitions.ACTION).filter(
|
||||
entry => {
|
||||
const [key] = entry
|
||||
return key !== AutomationActionStepId.BRANCH
|
||||
}
|
||||
)
|
||||
let lockedFeatures = [
|
||||
ActionStepID.COLLECT,
|
||||
ActionStepID.TRIGGER_AUTOMATION_RUN,
|
||||
]
|
||||
|
||||
$: collectBlockExists = checkForCollectStep($selectedAutomation)
|
||||
$: blockRef = $selectedAutomation.blockRefs?.[block.id]
|
||||
$: lastStep = blockRef?.terminating
|
||||
$: pathSteps = block.id
|
||||
? automationStore.actions.getPathSteps(
|
||||
blockRef.pathTo,
|
||||
$selectedAutomation?.data
|
||||
)
|
||||
: []
|
||||
|
||||
$: collectBlockExists = pathSteps?.some(
|
||||
step => step.stepId === ActionStepID.COLLECT
|
||||
)
|
||||
|
||||
const disabled = () => {
|
||||
return {
|
||||
|
@ -75,7 +90,7 @@
|
|||
// Filter out Collect block if not App Action or Webhook
|
||||
if (
|
||||
!collectBlockAllowedSteps.includes(
|
||||
$selectedAutomation.definition.trigger.stepId
|
||||
$selectedAutomation.data.definition.trigger.stepId
|
||||
)
|
||||
) {
|
||||
delete acc.COLLECT
|
||||
|
@ -100,9 +115,14 @@
|
|||
action.stepId,
|
||||
action
|
||||
)
|
||||
await automationStore.actions.addBlockToAutomation(newBlock, blockIdx + 1)
|
||||
|
||||
await automationStore.actions.addBlockToAutomation(
|
||||
newBlock,
|
||||
blockRef ? blockRef.pathTo : block.pathTo
|
||||
)
|
||||
modal.hide()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error("Error saving automation")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,283 @@
|
|||
<script>
|
||||
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
ActionButton,
|
||||
Icon,
|
||||
Layout,
|
||||
Body,
|
||||
Divider,
|
||||
TooltipPosition,
|
||||
TooltipType,
|
||||
Button,
|
||||
Modal,
|
||||
ModalContent,
|
||||
} from "@budibase/bbui"
|
||||
import PropField from "components/automation/SetupPanel/PropField.svelte"
|
||||
import AutomationBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||
import FlowItemHeader from "./FlowItemHeader.svelte"
|
||||
import FlowItemActions from "./FlowItemActions.svelte"
|
||||
import { automationStore, selectedAutomation } from "stores/builder"
|
||||
import { QueryUtils } from "@budibase/frontend-core"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { createEventDispatcher, getContext } from "svelte"
|
||||
import DragZone from "./DragZone.svelte"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let pathTo
|
||||
export let branchIdx
|
||||
export let step
|
||||
export let isLast
|
||||
export let bindings
|
||||
export let automation
|
||||
|
||||
const view = getContext("draggableView")
|
||||
|
||||
let drawer
|
||||
let condition
|
||||
let open = true
|
||||
let confirmDeleteModal
|
||||
|
||||
$: branch = step.inputs?.branches?.[branchIdx]
|
||||
$: editableConditionUI = cloneDeep(branch.conditionUI || {})
|
||||
$: condition = QueryUtils.buildQuery(editableConditionUI)
|
||||
|
||||
// Parse all the bindings into fields for the condition builder
|
||||
$: schemaFields = bindings.map(binding => {
|
||||
return {
|
||||
name: `{{${binding.runtimeBinding}}}`,
|
||||
displayName: `${binding.category} - ${binding.display.name}`,
|
||||
type: "string",
|
||||
}
|
||||
})
|
||||
$: branchBlockRef = {
|
||||
branchNode: true,
|
||||
pathTo: (pathTo || []).concat({ branchIdx, branchStepId: step.id }),
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={confirmDeleteModal}>
|
||||
<ModalContent
|
||||
size="M"
|
||||
title={"Are you sure you want to delete?"}
|
||||
confirmText="Delete"
|
||||
onConfirm={async () => {
|
||||
await automationStore.actions.deleteBranch(
|
||||
branchBlockRef.pathTo,
|
||||
$selectedAutomation.data
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Body>By deleting this branch, you will delete all of its contents.</Body>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Drawer bind:this={drawer} title="Branch condition" forceModal>
|
||||
<Button
|
||||
cta
|
||||
slot="buttons"
|
||||
on:click={() => {
|
||||
drawer.hide()
|
||||
dispatch("change", {
|
||||
conditionUI: editableConditionUI,
|
||||
condition,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<DrawerContent slot="body">
|
||||
<FilterBuilder
|
||||
filters={editableConditionUI}
|
||||
{bindings}
|
||||
{schemaFields}
|
||||
datasource={{ type: "custom" }}
|
||||
panel={AutomationBindingPanel}
|
||||
on:change={e => {
|
||||
editableConditionUI = e.detail
|
||||
}}
|
||||
allowOnEmpty={false}
|
||||
builderType={"condition"}
|
||||
docsURL={null}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<div class="flow-item">
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class={`block branch-node hoverable`}
|
||||
class:selected={false}
|
||||
on:mousedown={e => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<FlowItemHeader
|
||||
{automation}
|
||||
{open}
|
||||
itemName={branch.name}
|
||||
block={step}
|
||||
deleteStep={async () => {
|
||||
const branchSteps = step.inputs?.children[branch.id]
|
||||
if (branchSteps.length) {
|
||||
confirmDeleteModal.show()
|
||||
} else {
|
||||
await automationStore.actions.deleteBranch(
|
||||
branchBlockRef.pathTo,
|
||||
$selectedAutomation.data
|
||||
)
|
||||
}
|
||||
}}
|
||||
on:update={async e => {
|
||||
let stepUpdate = cloneDeep(step)
|
||||
let branchUpdate = stepUpdate.inputs?.branches.find(
|
||||
stepBranch => stepBranch.id == branch.id
|
||||
)
|
||||
branchUpdate.name = e.detail
|
||||
|
||||
const updatedAuto = automationStore.actions.updateStep(
|
||||
pathTo,
|
||||
$selectedAutomation.data,
|
||||
stepUpdate
|
||||
)
|
||||
await automationStore.actions.save(updatedAuto)
|
||||
}}
|
||||
on:toggle={() => (open = !open)}
|
||||
>
|
||||
<div slot="custom-actions" class="branch-actions">
|
||||
<Icon
|
||||
on:click={() => {
|
||||
automationStore.actions.branchLeft(
|
||||
branchBlockRef.pathTo,
|
||||
$selectedAutomation.data,
|
||||
step
|
||||
)
|
||||
}}
|
||||
tooltip={"Move left"}
|
||||
tooltipType={TooltipType.Info}
|
||||
tooltipPosition={TooltipPosition.Top}
|
||||
hoverable
|
||||
disabled={branchIdx == 0}
|
||||
name="ArrowLeft"
|
||||
/>
|
||||
<Icon
|
||||
on:click={() => {
|
||||
automationStore.actions.branchRight(
|
||||
branchBlockRef.pathTo,
|
||||
$selectedAutomation.data,
|
||||
step
|
||||
)
|
||||
}}
|
||||
tooltip={"Move right"}
|
||||
tooltipType={TooltipType.Info}
|
||||
tooltipPosition={TooltipPosition.Top}
|
||||
hoverable
|
||||
disabled={isLast}
|
||||
name="ArrowRight"
|
||||
/>
|
||||
</div>
|
||||
</FlowItemHeader>
|
||||
{#if open}
|
||||
<Divider noMargin />
|
||||
<div class="blockSection">
|
||||
<!-- Content body for possible slot -->
|
||||
<Layout noPadding>
|
||||
<PropField label="Only run when">
|
||||
<ActionButton fullWidth on:click={drawer.show}>
|
||||
{editableConditionUI?.groups?.length
|
||||
? "Update condition"
|
||||
: "Add condition"}
|
||||
</ActionButton>
|
||||
</PropField>
|
||||
<div class="footer">
|
||||
<Icon
|
||||
name="InfoOutline"
|
||||
size="S"
|
||||
color="var(--spectrum-global-color-gray-700)"
|
||||
/>
|
||||
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
|
||||
Only the first branch which matches its condition will run
|
||||
</Body>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="separator" />
|
||||
|
||||
{#if $view.dragging}
|
||||
<DragZone path={branchBlockRef.pathTo} />
|
||||
{:else}
|
||||
<FlowItemActions block={branchBlockRef} />
|
||||
{/if}
|
||||
|
||||
{#if step.inputs.children[branch.id]?.length}
|
||||
<div class="separator" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.branch-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.flow-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.block-options {
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
.center-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.splitHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.iconAlign {
|
||||
padding: 0 0 0 var(--spacing-m);
|
||||
display: inline-block;
|
||||
}
|
||||
.block {
|
||||
width: 480px;
|
||||
font-size: 16px;
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
border-radius: 4px 4px 4px 4px;
|
||||
}
|
||||
|
||||
.blockSection {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 25px;
|
||||
border-left: 1px dashed var(--grey-4);
|
||||
color: var(--grey-4);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.blockTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,66 @@
|
|||
<script>
|
||||
import { getContext, onMount } from "svelte"
|
||||
import { generate } from "shortid"
|
||||
|
||||
export let path
|
||||
|
||||
let dropEle
|
||||
let dzid = generate()
|
||||
|
||||
const view = getContext("draggableView")
|
||||
|
||||
onMount(() => {
|
||||
// Always return up-to-date values
|
||||
view.update(state => {
|
||||
return {
|
||||
...state,
|
||||
dropzones: {
|
||||
...(state.dropzones || {}),
|
||||
[dzid]: {
|
||||
get dims() {
|
||||
return dropEle.getBoundingClientRect()
|
||||
},
|
||||
path,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
id={`dz-${dzid}`}
|
||||
bind:this={dropEle}
|
||||
class="drag-zone"
|
||||
class:drag-over={$view?.droptarget === dzid}
|
||||
>
|
||||
<span class="move-to">Move to</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.drag-zone.drag-over {
|
||||
background-color: #1ca872b8;
|
||||
}
|
||||
.drag-zone {
|
||||
min-height: calc(var(--spectrum-global-dimension-size-225) + 12px);
|
||||
min-width: 100%;
|
||||
background-color: rgba(28, 168, 114, 0.2);
|
||||
border-radius: 4px;
|
||||
border: 1px dashed #1ca872;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
.move-to {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: -50%;
|
||||
margin: auto;
|
||||
width: fit-content;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
padding: 4px 8px;
|
||||
background-color: rgb(28, 168, 114);
|
||||
color: var(--spectrum-global-color-gray-50);
|
||||
}
|
||||
</style>
|
|
@ -1,24 +1,64 @@
|
|||
<script>
|
||||
import {
|
||||
automationStore,
|
||||
selectedAutomation,
|
||||
automationHistoryStore,
|
||||
selectedAutomation,
|
||||
} from "stores/builder"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import FlowItem from "./FlowItem.svelte"
|
||||
import TestDataModal from "./TestDataModal.svelte"
|
||||
import { flip } from "svelte/animate"
|
||||
import { fly } from "svelte/transition"
|
||||
import { Icon, notifications, Modal, Toggle } from "@budibase/bbui"
|
||||
import {
|
||||
Icon,
|
||||
notifications,
|
||||
Modal,
|
||||
Toggle,
|
||||
Button,
|
||||
ActionButton,
|
||||
} from "@budibase/bbui"
|
||||
import { ActionStepID } from "constants/backend/automations"
|
||||
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
|
||||
import StepNode from "./StepNode.svelte"
|
||||
import { memo } from "@budibase/frontend-core"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import DraggableCanvas from "../DraggableCanvas.svelte"
|
||||
|
||||
export let automation
|
||||
|
||||
const memoAutomation = memo(automation)
|
||||
|
||||
let testDataModal
|
||||
let confirmDeleteDialog
|
||||
let scrolling = false
|
||||
$: blocks = getBlocks(automation).filter(x => x.stepId !== ActionStepID.LOOP)
|
||||
let blockRefs = {}
|
||||
let treeEle
|
||||
let draggable
|
||||
|
||||
// Memo auto - selectedAutomation
|
||||
$: memoAutomation.set(automation)
|
||||
|
||||
// Parse the automation tree state
|
||||
$: refresh($memoAutomation)
|
||||
|
||||
$: blocks = getBlocks($memoAutomation).filter(
|
||||
x => x.stepId !== ActionStepID.LOOP
|
||||
)
|
||||
$: isRowAction = sdk.automations.isRowAction($memoAutomation)
|
||||
|
||||
const refresh = () => {
|
||||
// Build global automation bindings.
|
||||
const environmentBindings =
|
||||
automationStore.actions.buildEnvironmentBindings()
|
||||
|
||||
// Get all processed block references
|
||||
blockRefs = $selectedAutomation.blockRefs
|
||||
|
||||
automationStore.update(state => {
|
||||
return {
|
||||
...state,
|
||||
bindings: [...environmentBindings],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const getBlocks = automation => {
|
||||
let blocks = []
|
||||
if (automation.definition.trigger) {
|
||||
|
@ -30,39 +70,46 @@
|
|||
|
||||
const deleteAutomation = async () => {
|
||||
try {
|
||||
await automationStore.actions.delete($selectedAutomation)
|
||||
await automationStore.actions.delete(automation)
|
||||
} catch (error) {
|
||||
notifications.error("Error deleting automation")
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = e => {
|
||||
if (e.target.scrollTop >= 30) {
|
||||
scrolling = true
|
||||
} else if (e.target.scrollTop) {
|
||||
// Set scrolling back to false if scrolled back to less than 100px
|
||||
scrolling = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="header" class:scrolling>
|
||||
<div class="header-left">
|
||||
<UndoRedoControl store={automationHistoryStore} />
|
||||
<UndoRedoControl store={automationHistoryStore} showButtonGroup />
|
||||
|
||||
<div class="zoom">
|
||||
<div class="group">
|
||||
<ActionButton icon="Add" quiet on:click={draggable.zoomIn} />
|
||||
<ActionButton icon="Remove" quiet on:click={draggable.zoomOut} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
secondary
|
||||
on:click={() => {
|
||||
draggable.zoomToFit()
|
||||
}}
|
||||
>
|
||||
Zoom to fit
|
||||
</Button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div
|
||||
class:disabled={!$selectedAutomation?.definition?.trigger}
|
||||
<Button
|
||||
icon={"Play"}
|
||||
cta
|
||||
disabled={!automation?.definition?.trigger}
|
||||
on:click={() => {
|
||||
testDataModal.show()
|
||||
}}
|
||||
class="buttons"
|
||||
>
|
||||
<Icon size="M" name="Play" />
|
||||
<div>Run test</div>
|
||||
</div>
|
||||
Run test
|
||||
</Button>
|
||||
<div class="buttons">
|
||||
<Icon disabled={!$automationStore.testResults} size="M" name="Multiple" />
|
||||
<div
|
||||
|
@ -74,35 +121,40 @@
|
|||
Test details
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-spacing">
|
||||
<Toggle
|
||||
text={automation.disabled ? "Paused" : "Activated"}
|
||||
on:change={automationStore.actions.toggleDisabled(
|
||||
automation._id,
|
||||
automation.disabled
|
||||
)}
|
||||
disabled={!$selectedAutomation?.definition?.trigger}
|
||||
value={!automation.disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas" on:scroll={handleScroll}>
|
||||
<div class="content">
|
||||
{#each blocks as block, idx (block.id)}
|
||||
<div
|
||||
class="block"
|
||||
animate:flip={{ duration: 500 }}
|
||||
in:fly={{ x: 500, duration: 500 }}
|
||||
out:fly|local={{ x: 500, duration: 500 }}
|
||||
>
|
||||
{#if block.stepId !== ActionStepID.LOOP}
|
||||
<FlowItem {testDataModal} {block} {idx} />
|
||||
{/if}
|
||||
{#if !isRowAction}
|
||||
<div class="toggle-active setting-spacing">
|
||||
<Toggle
|
||||
text={automation.disabled ? "Paused" : "Activated"}
|
||||
on:change={automationStore.actions.toggleDisabled(
|
||||
automation._id,
|
||||
automation.disabled
|
||||
)}
|
||||
disabled={!automation?.definition?.trigger}
|
||||
value={!automation.disabled}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="root" bind:this={treeEle}>
|
||||
<DraggableCanvas bind:this={draggable}>
|
||||
<span class="main-content" slot="content">
|
||||
{#if Object.keys(blockRefs).length}
|
||||
{#each blocks as block, idx (block.id)}
|
||||
<StepNode
|
||||
step={blocks[idx]}
|
||||
stepIdx={idx}
|
||||
isLast={blocks?.length - 1 === idx}
|
||||
automation={$memoAutomation}
|
||||
blocks={blockRefs}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</DraggableCanvas>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:this={confirmDeleteDialog}
|
||||
okText="Delete Automation"
|
||||
|
@ -119,32 +171,43 @@
|
|||
</Modal>
|
||||
|
||||
<style>
|
||||
.canvas {
|
||||
padding: var(--spacing-l) var(--spacing-xl);
|
||||
overflow-y: auto;
|
||||
.toggle-active :global(.spectrum-Switch) {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.root :global(.main-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.header-left :global(div) {
|
||||
border-right: none;
|
||||
}
|
||||
/* Fix for firefox not respecting bottom padding in scrolling containers */
|
||||
.canvas > *:last-child {
|
||||
padding-bottom: 40px;
|
||||
|
||||
.root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.block {
|
||||
.root :global(.block) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex-grow: 1;
|
||||
padding: 23px 23px 80px;
|
||||
.root :global(.blockSection) {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.header.scrolling {
|
||||
|
@ -160,13 +223,15 @@
|
|||
align-items: center;
|
||||
padding-left: var(--spacing-l);
|
||||
transition: background 130ms ease-out;
|
||||
flex: 0 0 48px;
|
||||
flex: 0 0 60px;
|
||||
padding-right: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
@ -182,4 +247,33 @@
|
|||
pointer-events: none;
|
||||
color: var(--spectrum-global-color-gray-500) !important;
|
||||
}
|
||||
|
||||
.group {
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.group :global(> *:not(:first-child)) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: 2px solid var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.group :global(> *:not(:last-child)) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.header-left .group :global(.spectrum-Button),
|
||||
.header-left .group :global(.spectrum-ActionButton),
|
||||
.header-left .group :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-gray-900) !important;
|
||||
}
|
||||
.header-left .group :global(.spectrum-Button),
|
||||
.header-left .group :global(.spectrum-ActionButton) {
|
||||
background: var(--spectrum-global-color-gray-200) !important;
|
||||
}
|
||||
.header-left .group :global(.spectrum-Button:hover),
|
||||
.header-left .group :global(.spectrum-ActionButton:hover) {
|
||||
background: var(--spectrum-global-color-gray-300) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<script>
|
||||
import {
|
||||
automationStore,
|
||||
selectedAutomation,
|
||||
permissions,
|
||||
selectedAutomationDisplayData,
|
||||
selectedAutomation,
|
||||
tables,
|
||||
} from "stores/builder"
|
||||
import {
|
||||
Icon,
|
||||
|
@ -11,47 +11,114 @@
|
|||
Layout,
|
||||
Detail,
|
||||
Modal,
|
||||
Button,
|
||||
notifications,
|
||||
Label,
|
||||
AbsTooltip,
|
||||
InlineAlert,
|
||||
} from "@budibase/bbui"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
|
||||
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
||||
import ActionModal from "./ActionModal.svelte"
|
||||
import FlowItemHeader from "./FlowItemHeader.svelte"
|
||||
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
|
||||
import { ActionStepID, TriggerStepID } from "constants/backend/automations"
|
||||
import { AutomationStepType } from "@budibase/types"
|
||||
import FlowItemActions from "./FlowItemActions.svelte"
|
||||
import DragHandle from "components/design/settings/controls/DraggableList/drag-handle.svelte"
|
||||
import { getContext } from "svelte"
|
||||
import DragZone from "./DragZone.svelte"
|
||||
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
|
||||
|
||||
export let block
|
||||
export let blockRef
|
||||
export let testDataModal
|
||||
export let idx
|
||||
export let automation
|
||||
export let bindings
|
||||
export let draggable = true
|
||||
|
||||
const view = getContext("draggableView")
|
||||
const pos = getContext("viewPos")
|
||||
const contentPos = getContext("contentPos")
|
||||
|
||||
let selected
|
||||
let webhookModal
|
||||
let actionModal
|
||||
let open = true
|
||||
let showLooping = false
|
||||
let role
|
||||
let blockEle
|
||||
let positionStyles
|
||||
let blockDims
|
||||
|
||||
$: collectBlockExists = $selectedAutomation.definition.steps.some(
|
||||
const updateBlockDims = () => {
|
||||
if (!blockEle) {
|
||||
return
|
||||
}
|
||||
const { width, height } = blockEle.getBoundingClientRect()
|
||||
blockDims = { width: width / $view.scale, height: height / $view.scale }
|
||||
}
|
||||
|
||||
const loadSteps = blockRef => {
|
||||
return blockRef
|
||||
? automationStore.actions.getPathSteps(blockRef.pathTo, automation)
|
||||
: []
|
||||
}
|
||||
|
||||
$: pathSteps = loadSteps(blockRef)
|
||||
|
||||
$: collectBlockExists = pathSteps.some(
|
||||
step => step.stepId === ActionStepID.COLLECT
|
||||
)
|
||||
$: automationId = $selectedAutomation?._id
|
||||
$: isTrigger = block.type === "TRIGGER"
|
||||
$: steps = $selectedAutomation?.definition?.steps ?? []
|
||||
$: blockIdx = steps.findIndex(step => step.id === block.id)
|
||||
$: lastStep = !isTrigger && blockIdx + 1 === steps.length
|
||||
$: totalBlocks = $selectedAutomation?.definition?.steps.length + 1
|
||||
$: loopBlock = $selectedAutomation?.definition.steps.find(
|
||||
x => x.blockToLoop === block.id
|
||||
)
|
||||
$: automationId = automation?._id
|
||||
$: isTrigger = block.type === AutomationStepType.TRIGGER
|
||||
$: lastStep = blockRef?.terminating
|
||||
|
||||
$: loopBlock = pathSteps.find(x => x.blockToLoop === block.id)
|
||||
$: isAppAction = block?.stepId === TriggerStepID.APP
|
||||
$: isAppAction && setPermissions(role)
|
||||
$: isAppAction && getPermissions(automationId)
|
||||
|
||||
$: triggerInfo = $selectedAutomationDisplayData?.triggerInfo
|
||||
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation?.data) && {
|
||||
title: "Automation trigger",
|
||||
tableName: $tables.list.find(
|
||||
x =>
|
||||
x._id === $selectedAutomation.data?.definition?.trigger?.inputs?.tableId
|
||||
)?.name,
|
||||
}
|
||||
|
||||
$: selected = $view?.moveStep && $view?.moveStep?.id === block.id
|
||||
|
||||
$: if (selected && blockEle) {
|
||||
updateBlockDims()
|
||||
}
|
||||
|
||||
$: placeholderDims = buildPlaceholderStyles(blockDims)
|
||||
|
||||
// Move the selected item
|
||||
// Listen for scrolling in the content. As its scrolled this will be updated
|
||||
$: move(
|
||||
blockEle,
|
||||
$view?.dragSpot,
|
||||
selected,
|
||||
$contentPos?.scrollX,
|
||||
$contentPos?.scrollY
|
||||
)
|
||||
|
||||
const move = (block, dragPos, selected, scrollX, scrollY) => {
|
||||
if ((!block && !selected) || !dragPos) {
|
||||
return
|
||||
}
|
||||
positionStyles = `
|
||||
--blockPosX: ${Math.round(dragPos.x - scrollX / $view.scale)}px;
|
||||
--blockPosY: ${Math.round(dragPos.y - scrollY / $view.scale)}px;
|
||||
`
|
||||
}
|
||||
|
||||
const buildPlaceholderStyles = dims => {
|
||||
if (!dims) {
|
||||
return ""
|
||||
}
|
||||
const { width, height } = dims
|
||||
return `--pswidth: ${Math.round(width)}px;
|
||||
--psheight: ${Math.round(height)}px;`
|
||||
}
|
||||
|
||||
async function setPermissions(role) {
|
||||
if (!role || !automationId) {
|
||||
|
@ -76,23 +143,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function removeLooping() {
|
||||
try {
|
||||
await automationStore.actions.deleteAutomationBlock(loopBlock)
|
||||
} catch (error) {
|
||||
notifications.error("Error saving automation")
|
||||
}
|
||||
async function deleteStep() {
|
||||
await automationStore.actions.deleteAutomationBlock(blockRef.pathTo)
|
||||
}
|
||||
|
||||
async function deleteStep() {
|
||||
try {
|
||||
if (loopBlock) {
|
||||
await automationStore.actions.deleteAutomationBlock(loopBlock)
|
||||
}
|
||||
await automationStore.actions.deleteAutomationBlock(block, blockIdx)
|
||||
} catch (error) {
|
||||
notifications.error("Error saving automation")
|
||||
}
|
||||
async function removeLooping() {
|
||||
let loopBlockRef = $selectedAutomation.blockRefs[blockRef.looped]
|
||||
await automationStore.actions.deleteAutomationBlock(loopBlockRef.pathTo)
|
||||
}
|
||||
|
||||
async function addLooping() {
|
||||
|
@ -103,122 +160,201 @@
|
|||
loopDefinition
|
||||
)
|
||||
loopBlock.blockToLoop = block.id
|
||||
await automationStore.actions.addBlockToAutomation(loopBlock, blockIdx)
|
||||
await automationStore.actions.addBlockToAutomation(
|
||||
loopBlock,
|
||||
blockRef.pathTo
|
||||
)
|
||||
}
|
||||
|
||||
const onHandleMouseDown = e => {
|
||||
if (isTrigger) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
e.stopPropagation()
|
||||
|
||||
view.update(state => ({
|
||||
...state,
|
||||
moveStep: {
|
||||
id: block.id,
|
||||
offsetX: $pos.x,
|
||||
offsetY: $pos.y,
|
||||
},
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class={`block ${block.type} hoverable`} class:selected on:click={() => {}}>
|
||||
{#if loopBlock}
|
||||
<div class="blockSection">
|
||||
{#if block.stepId !== "LOOP"}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
id={`block-${block.id}`}
|
||||
class={`block ${block.type} hoverable`}
|
||||
class:selected
|
||||
class:draggable
|
||||
>
|
||||
<div class="wrap">
|
||||
{#if $view.dragging && selected}
|
||||
<div class="drag-placeholder" style={placeholderDims} />
|
||||
{/if}
|
||||
|
||||
<div
|
||||
on:click={() => {
|
||||
showLooping = !showLooping
|
||||
bind:this={blockEle}
|
||||
class="block-content"
|
||||
class:dragging={$view.dragging && selected}
|
||||
style={positionStyles}
|
||||
on:mousedown={e => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
class="splitHeader"
|
||||
>
|
||||
<div class="center-items">
|
||||
<svg
|
||||
width="28px"
|
||||
height="28px"
|
||||
class="spectrum-Icon"
|
||||
style="color:var(--spectrum-global-color-gray-700);"
|
||||
focusable="false"
|
||||
{#if draggable}
|
||||
<div
|
||||
class="handle"
|
||||
class:grabbing={selected}
|
||||
on:mousedown={onHandleMouseDown}
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-Reuse" />
|
||||
</svg>
|
||||
<div class="iconAlign">
|
||||
<Detail size="S">Looping</Detail>
|
||||
<DragHandle />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="block-core">
|
||||
{#if loopBlock}
|
||||
<div class="blockSection">
|
||||
<div
|
||||
on:click={() => {
|
||||
showLooping = !showLooping
|
||||
}}
|
||||
class="splitHeader"
|
||||
>
|
||||
<div class="center-items">
|
||||
<svg
|
||||
width="28px"
|
||||
height="28px"
|
||||
class="spectrum-Icon"
|
||||
style="color:var(--spectrum-global-color-gray-700);"
|
||||
focusable="false"
|
||||
>
|
||||
<use xlink:href="#spectrum-icon-18-Reuse" />
|
||||
</svg>
|
||||
<div class="iconAlign">
|
||||
<Detail size="S">Looping</Detail>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blockTitle">
|
||||
<AbsTooltip type="negative" text="Remove looping">
|
||||
<Icon on:click={removeLooping} hoverable name="DeleteOutline" />
|
||||
</AbsTooltip>
|
||||
<div class="blockTitle">
|
||||
<AbsTooltip type="negative" text="Remove looping">
|
||||
<Icon
|
||||
on:click={removeLooping}
|
||||
hoverable
|
||||
name="DeleteOutline"
|
||||
/>
|
||||
</AbsTooltip>
|
||||
|
||||
<div style="margin-left: 10px;" on:click={() => {}}>
|
||||
<Icon hoverable name={showLooping ? "ChevronDown" : "ChevronUp"} />
|
||||
</div>
|
||||
<div style="margin-left: 10px;" on:click={() => {}}>
|
||||
<Icon
|
||||
hoverable
|
||||
name={showLooping ? "ChevronDown" : "ChevronUp"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider noMargin />
|
||||
{#if !showLooping}
|
||||
<div class="blockSection">
|
||||
<Layout noPadding gap="S">
|
||||
<AutomationBlockSetup
|
||||
schemaProperties={Object.entries(
|
||||
$automationStore.blockDefinitions.ACTION.LOOP.schema
|
||||
.inputs.properties
|
||||
)}
|
||||
{webhookModal}
|
||||
block={loopBlock}
|
||||
{automation}
|
||||
{bindings}
|
||||
/>
|
||||
</Layout>
|
||||
</div>
|
||||
<Divider noMargin />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<FlowItemHeader
|
||||
{automation}
|
||||
{open}
|
||||
{block}
|
||||
{testDataModal}
|
||||
{idx}
|
||||
{addLooping}
|
||||
{deleteStep}
|
||||
on:toggle={() => (open = !open)}
|
||||
on:update={async e => {
|
||||
const newName = e.detail
|
||||
if (newName.length === 0) {
|
||||
await automationStore.actions.deleteAutomationName(block.id)
|
||||
} else {
|
||||
await automationStore.actions.saveAutomationName(
|
||||
block.id,
|
||||
newName
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if open}
|
||||
<Divider noMargin />
|
||||
<div class="blockSection">
|
||||
<Layout noPadding gap="S">
|
||||
{#if isAppAction}
|
||||
<div>
|
||||
<Label>Role</Label>
|
||||
<RoleSelect bind:value={role} />
|
||||
</div>
|
||||
{/if}
|
||||
<AutomationBlockSetup
|
||||
schemaProperties={Object.entries(
|
||||
block?.schema?.inputs?.properties || {}
|
||||
)}
|
||||
{block}
|
||||
{webhookModal}
|
||||
{automation}
|
||||
{bindings}
|
||||
/>
|
||||
{#if isTrigger && triggerInfo}
|
||||
<InfoDisplay
|
||||
title={triggerInfo.title}
|
||||
body="This trigger is tied to your '{triggerInfo.tableName}' table"
|
||||
icon="InfoOutline"
|
||||
/>
|
||||
{/if}
|
||||
</Layout>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider noMargin />
|
||||
{#if !showLooping}
|
||||
<div class="blockSection">
|
||||
<Layout noPadding gap="S">
|
||||
<AutomationBlockSetup
|
||||
schemaProperties={Object.entries(
|
||||
$automationStore.blockDefinitions.ACTION.LOOP.schema.inputs
|
||||
.properties
|
||||
)}
|
||||
{webhookModal}
|
||||
block={loopBlock}
|
||||
/>
|
||||
</Layout>
|
||||
</div>
|
||||
<Divider noMargin />
|
||||
</div>
|
||||
{#if !collectBlockExists || !lastStep}
|
||||
<div class="separator" />
|
||||
{#if $view.dragging}
|
||||
<DragZone path={blockRef?.pathTo} />
|
||||
{:else}
|
||||
<FlowItemActions
|
||||
{block}
|
||||
on:branch={() => {
|
||||
automationStore.actions.branchAutomation(
|
||||
$selectedAutomation.blockRefs[block.id].pathTo,
|
||||
automation
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if !lastStep}
|
||||
<div class="separator" />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<FlowItemHeader
|
||||
{open}
|
||||
{block}
|
||||
{testDataModal}
|
||||
{idx}
|
||||
{addLooping}
|
||||
{deleteStep}
|
||||
on:toggle={() => (open = !open)}
|
||||
/>
|
||||
{#if open}
|
||||
<Divider noMargin />
|
||||
<div class="blockSection">
|
||||
<Layout noPadding gap="S">
|
||||
{#if isAppAction}
|
||||
<div>
|
||||
<Label>Role</Label>
|
||||
<RoleSelect bind:value={role} />
|
||||
</div>
|
||||
{/if}
|
||||
<AutomationBlockSetup
|
||||
schemaProperties={Object.entries(block.schema.inputs.properties)}
|
||||
{block}
|
||||
{webhookModal}
|
||||
/>
|
||||
{#if isTrigger && triggerInfo}
|
||||
<InlineAlert
|
||||
header={triggerInfo.type}
|
||||
message={`This trigger is tied to the row action ${triggerInfo.rowAction.name} on your ${triggerInfo.table.name} table`}
|
||||
/>
|
||||
{/if}
|
||||
{#if lastStep}
|
||||
<Button on:click={() => testDataModal.show()} cta>
|
||||
Finish and test automation
|
||||
</Button>
|
||||
{/if}
|
||||
</Layout>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !collectBlockExists || !lastStep}
|
||||
<div class="separator" />
|
||||
<Icon
|
||||
on:click={() => actionModal.show()}
|
||||
hoverable
|
||||
name="AddCircle"
|
||||
size="S"
|
||||
/>
|
||||
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
|
||||
<div class="separator" />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<Modal bind:this={actionModal} width="30%">
|
||||
<ActionModal modal={actionModal} {lastStep} {blockIdx} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={webhookModal} width="30%">
|
||||
<CreateWebhookModal />
|
||||
</Modal>
|
||||
|
@ -249,27 +385,73 @@
|
|||
.block {
|
||||
width: 480px;
|
||||
font-size: 16px;
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
border-radius: 4px 4px 4px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.block .wrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.block.draggable .wrap {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.block.draggable .wrap .handle {
|
||||
height: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--grey-3);
|
||||
padding: 6px;
|
||||
color: var(--grey-6);
|
||||
cursor: grab;
|
||||
}
|
||||
.block.draggable .wrap .handle.grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.block.draggable .wrap .handle :global(.drag-handle) {
|
||||
width: 6px;
|
||||
}
|
||||
.block .wrap .block-content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--grey-3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.blockSection {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 25px;
|
||||
border-left: 1px dashed var(--grey-4);
|
||||
color: var(--grey-4);
|
||||
/* center horizontally */
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.blockTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
.drag-placeholder {
|
||||
height: calc(var(--psheight) - 2px);
|
||||
width: var(--pswidth);
|
||||
background-color: rgba(92, 92, 92, 0.1);
|
||||
border: 1px dashed #5c5c5c;
|
||||
border-radius: 4px;
|
||||
display: block;
|
||||
}
|
||||
.block-core {
|
||||
flex: 1;
|
||||
}
|
||||
.block-core.dragging {
|
||||
pointer-events: none;
|
||||
}
|
||||
.block-content.dragging {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: var(--blockPosY);
|
||||
left: var(--blockPosX);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
<script>
|
||||
import { Icon, TooltipPosition, TooltipType, Modal } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import ActionModal from "./ActionModal.svelte"
|
||||
|
||||
export let block
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let actionModal
|
||||
</script>
|
||||
|
||||
<Modal bind:this={actionModal} width="30%">
|
||||
<ActionModal modal={actionModal} {block} />
|
||||
</Modal>
|
||||
|
||||
<div class="action-bar">
|
||||
{#if !block.branchNode}
|
||||
<Icon
|
||||
hoverable
|
||||
name="Branch3"
|
||||
on:click={() => {
|
||||
dispatch("branch")
|
||||
}}
|
||||
tooltipType={TooltipType.Info}
|
||||
tooltipPosition={TooltipPosition.Left}
|
||||
tooltip={"Create branch"}
|
||||
size={"S"}
|
||||
/>
|
||||
{/if}
|
||||
<Icon
|
||||
hoverable
|
||||
name="AddCircle"
|
||||
on:click={() => {
|
||||
actionModal.show()
|
||||
}}
|
||||
tooltipType={TooltipType.Info}
|
||||
tooltipPosition={TooltipPosition.Right}
|
||||
tooltip={"Add a step"}
|
||||
size={"S"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.action-bar {
|
||||
background-color: var(--background);
|
||||
border-radius: 4px 4px 4px 4px;
|
||||
display: flex;
|
||||
gap: var(--spacing-m);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
|
@ -10,21 +10,25 @@
|
|||
export let showTestStatus = false
|
||||
export let testResult
|
||||
export let isTrigger
|
||||
export let idx
|
||||
export let addLooping
|
||||
export let deleteStep
|
||||
export let enableNaming = true
|
||||
export let itemName
|
||||
export let automation
|
||||
|
||||
let validRegex = /^[A-Za-z0-9_\s]+$/
|
||||
let typing = false
|
||||
let editing = false
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: stepNames = $selectedAutomation?.definition.stepNames
|
||||
$: allSteps = $selectedAutomation?.definition.steps || []
|
||||
$: automationName = stepNames?.[block.id] || block?.name || ""
|
||||
$: blockRefs = $selectedAutomation?.blockRefs || {}
|
||||
$: stepNames = automation?.definition.stepNames
|
||||
$: allSteps = automation?.definition.steps || []
|
||||
$: automationName = itemName || stepNames?.[block.id] || block?.name || ""
|
||||
$: automationNameError = getAutomationNameError(automationName)
|
||||
$: status = updateStatus(testResult)
|
||||
$: isHeaderTrigger = isTrigger || block.type === "TRIGGER"
|
||||
$: isBranch = block.stepId === "BRANCH"
|
||||
|
||||
$: {
|
||||
if (!testResult) {
|
||||
|
@ -33,12 +37,12 @@
|
|||
)?.[0]
|
||||
}
|
||||
}
|
||||
$: loopBlock = $selectedAutomation?.definition.steps.find(
|
||||
x => x.blockToLoop === block?.id
|
||||
)
|
||||
|
||||
$: blockRef = blockRefs[block.id]
|
||||
$: isLooped = blockRef?.looped
|
||||
|
||||
async function onSelect(block) {
|
||||
await automationStore.update(state => {
|
||||
automationStore.update(state => {
|
||||
state.selectedBlock = block
|
||||
return state
|
||||
})
|
||||
|
@ -84,30 +88,18 @@
|
|||
return null
|
||||
}
|
||||
|
||||
const saveName = async () => {
|
||||
if (automationNameError || block.name === automationName) {
|
||||
return
|
||||
}
|
||||
|
||||
if (automationName.length === 0) {
|
||||
await automationStore.actions.deleteAutomationName(block.id)
|
||||
} else {
|
||||
await automationStore.actions.saveAutomationName(block.id, automationName)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = () => {
|
||||
editing = true
|
||||
typing = true
|
||||
}
|
||||
|
||||
const stopEditing = async () => {
|
||||
const stopEditing = () => {
|
||||
editing = false
|
||||
typing = false
|
||||
if (automationNameError) {
|
||||
automationName = stepNames[block.id] || block?.name
|
||||
} else {
|
||||
await saveName()
|
||||
dispatch("update", automationName)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -118,7 +110,6 @@
|
|||
class:typing={typing && !automationNameError && editing}
|
||||
class:typing-error={automationNameError && editing}
|
||||
class="blockSection"
|
||||
on:click={() => dispatch("toggle")}
|
||||
>
|
||||
<div class="splitHeader">
|
||||
<div class="center-items">
|
||||
|
@ -144,16 +135,14 @@
|
|||
{#if isHeaderTrigger}
|
||||
<Body size="XS"><b>Trigger</b></Body>
|
||||
{:else}
|
||||
<div style="margin-left: 2px;">
|
||||
<Body size="XS"><b>Step {idx}</b></Body>
|
||||
</div>
|
||||
<Body size="XS"><b>{isBranch ? "Branch" : "Step"}</b></Body>
|
||||
{/if}
|
||||
|
||||
{#if enableNaming}
|
||||
<input
|
||||
class="input-text"
|
||||
disabled={!enableNaming}
|
||||
placeholder="Enter step name"
|
||||
placeholder={`Enter ${isBranch ? "branch" : "step"} name`}
|
||||
name="name"
|
||||
autocomplete="off"
|
||||
value={automationName}
|
||||
|
@ -208,8 +197,9 @@
|
|||
onSelect(block)
|
||||
}}
|
||||
>
|
||||
<slot name="custom-actions" />
|
||||
{#if !showTestStatus}
|
||||
{#if !isHeaderTrigger && !loopBlock && (block?.features?.[Features.LOOPING] || !block.features)}
|
||||
{#if !isHeaderTrigger && !isLooped && !isBranch && (block?.features?.[Features.LOOPING] || !block.features)}
|
||||
<AbsTooltip type="info" text="Add looping">
|
||||
<Icon on:click={addLooping} hoverable name="RotateCW" />
|
||||
</AbsTooltip>
|
||||
|
@ -220,6 +210,9 @@
|
|||
</AbsTooltip>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if !showTestStatus && !isHeaderTrigger}
|
||||
<span class="action-spacer" />
|
||||
{/if}
|
||||
{#if !showTestStatus}
|
||||
<Icon
|
||||
on:click={e => {
|
||||
|
@ -245,6 +238,9 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.action-spacer {
|
||||
border-left: 1px solid var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.status-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -298,6 +294,8 @@
|
|||
font-size: var(--spectrum-alias-font-size-default);
|
||||
font-family: var(--font-sans);
|
||||
text-overflow: ellipsis;
|
||||
padding-left: 0px;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
|
|
|
@ -0,0 +1,227 @@
|
|||
<script>
|
||||
import FlowItem from "./FlowItem.svelte"
|
||||
import BranchNode from "./BranchNode.svelte"
|
||||
import { AutomationActionStepId } from "@budibase/types"
|
||||
import { ActionButton, notifications } from "@budibase/bbui"
|
||||
import { automationStore } from "stores/builder"
|
||||
import { environment } from "stores/portal"
|
||||
import { cloneDeep } from "lodash"
|
||||
import { memo } from "@budibase/frontend-core"
|
||||
import { getContext, onMount } from "svelte"
|
||||
|
||||
export let step = {}
|
||||
export let stepIdx
|
||||
export let automation
|
||||
export let blocks
|
||||
export let isLast = false
|
||||
|
||||
const memoEnvVariables = memo($environment.variables)
|
||||
const view = getContext("draggableView")
|
||||
|
||||
let stepEle
|
||||
|
||||
$: memoEnvVariables.set($environment.variables)
|
||||
$: blockRef = blocks?.[step.id]
|
||||
$: pathToCurrentNode = blockRef?.pathTo
|
||||
$: isBranch = step.stepId === AutomationActionStepId.BRANCH
|
||||
$: branches = step.inputs?.branches
|
||||
|
||||
// All bindings available to this point
|
||||
$: availableBindings = automationStore.actions.getPathBindings(
|
||||
step.id,
|
||||
automation
|
||||
)
|
||||
|
||||
// Fetch the env bindings
|
||||
$: environmentBindings =
|
||||
automationStore.actions.buildEnvironmentBindings($memoEnvVariables)
|
||||
|
||||
$: userBindings = automationStore.actions.buildUserBindings()
|
||||
$: settingBindings = automationStore.actions.buildSettingBindings()
|
||||
|
||||
// Combine all bindings for the step
|
||||
$: bindings = [
|
||||
...availableBindings,
|
||||
...environmentBindings,
|
||||
...userBindings,
|
||||
...settingBindings,
|
||||
]
|
||||
onMount(() => {
|
||||
// Register the trigger as the focus element for the automation
|
||||
// Onload, the canvas will use the dimensions to center the step
|
||||
if (stepEle && step.type === "TRIGGER" && !$view.focusEle) {
|
||||
view.update(state => ({
|
||||
...state,
|
||||
focusEle: stepEle.getBoundingClientRect(),
|
||||
}))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if isBranch}
|
||||
<div class="split-branch-btn">
|
||||
<ActionButton
|
||||
icon="AddCircle"
|
||||
on:click={() => {
|
||||
automationStore.actions.branchAutomation(pathToCurrentNode, automation)
|
||||
}}
|
||||
>
|
||||
Add branch
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div class="branched">
|
||||
{#each branches as branch, bIdx}
|
||||
{@const leftMost = bIdx === 0}
|
||||
{@const rightMost = branches?.length - 1 === bIdx}
|
||||
<div class="branch-wrap">
|
||||
<div
|
||||
class="branch"
|
||||
class:left={leftMost}
|
||||
class:right={rightMost}
|
||||
class:middle={!leftMost && !rightMost}
|
||||
>
|
||||
<div class="branch-node">
|
||||
<BranchNode
|
||||
{automation}
|
||||
{step}
|
||||
{bindings}
|
||||
pathTo={pathToCurrentNode}
|
||||
branchIdx={bIdx}
|
||||
isLast={rightMost}
|
||||
on:change={async e => {
|
||||
const updatedBranch = { ...branch, ...e.detail }
|
||||
|
||||
if (!step?.inputs?.branches?.[bIdx]) {
|
||||
console.error(`Cannot load target branch: ${bIdx}`)
|
||||
return
|
||||
}
|
||||
|
||||
let branchStepUpdate = cloneDeep(step)
|
||||
branchStepUpdate.inputs.branches[bIdx] = updatedBranch
|
||||
|
||||
// Ensure valid base configuration for all branches
|
||||
// Reinitialise empty branch conditions on update
|
||||
branchStepUpdate.inputs.branches.forEach(
|
||||
(branch, i, branchArray) => {
|
||||
if (!Object.keys(branch.condition).length) {
|
||||
branchArray[i] = {
|
||||
...branch,
|
||||
...automationStore.actions.generateDefaultConditions(),
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const updated = automationStore.actions.updateStep(
|
||||
blockRef?.pathTo,
|
||||
automation,
|
||||
branchStepUpdate
|
||||
)
|
||||
|
||||
try {
|
||||
await automationStore.actions.save(updated)
|
||||
} catch (e) {
|
||||
notifications.error("Error saving branch update")
|
||||
console.error("Error saving automation branch", e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Branch steps -->
|
||||
{#each step.inputs?.children[branch.id] || [] as bStep, sIdx}
|
||||
<!-- Recursive StepNode -->
|
||||
<svelte:self
|
||||
step={bStep}
|
||||
stepIdx={sIdx}
|
||||
branchIdx={bIdx}
|
||||
isLast={blockRef.terminating}
|
||||
pathTo={pathToCurrentNode}
|
||||
{automation}
|
||||
{blocks}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="block" bind:this={stepEle}>
|
||||
<FlowItem
|
||||
block={step}
|
||||
idx={stepIdx}
|
||||
{blockRef}
|
||||
{isLast}
|
||||
{automation}
|
||||
{bindings}
|
||||
draggable={step.type !== "TRIGGER"}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.branch-wrap {
|
||||
width: inherit;
|
||||
}
|
||||
|
||||
.branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: inherit;
|
||||
}
|
||||
|
||||
.branched {
|
||||
display: flex;
|
||||
gap: 64px;
|
||||
}
|
||||
|
||||
.branch::before {
|
||||
height: 64px;
|
||||
border-left: 1px dashed var(--grey-4);
|
||||
border-top: 1px dashed var(--grey-4);
|
||||
content: "";
|
||||
color: var(--grey-4);
|
||||
width: 50%;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -16px;
|
||||
}
|
||||
|
||||
.branch.left::before {
|
||||
color: var(--grey-4);
|
||||
width: calc(50% + 62px);
|
||||
}
|
||||
|
||||
.branch.middle::after {
|
||||
height: 64px;
|
||||
border-top: 1px dashed var(--grey-4);
|
||||
content: "";
|
||||
color: var(--grey-4);
|
||||
width: calc(50% + 62px);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -16px;
|
||||
}
|
||||
|
||||
.branch.right::before {
|
||||
left: 0px;
|
||||
border-right: 1px dashed var(--grey-4);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.branch.middle::before {
|
||||
left: 0px;
|
||||
border-right: 1px dashed var(--grey-4);
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.branch-node {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.split-branch-btn {
|
||||
z-index: 2;
|
||||
}
|
||||
</style>
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
import { tick } from "svelte"
|
||||
import {
|
||||
ModalContent,
|
||||
TextArea,
|
||||
|
@ -8,7 +9,6 @@
|
|||
import { automationStore, selectedAutomation } from "stores/builder"
|
||||
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { memo } from "@budibase/frontend-core"
|
||||
import { AutomationEventType } from "@budibase/types"
|
||||
|
||||
let failedParse = null
|
||||
|
@ -19,6 +19,7 @@
|
|||
AutomationEventType.ROW_DELETE,
|
||||
AutomationEventType.ROW_UPDATE,
|
||||
AutomationEventType.ROW_SAVE,
|
||||
AutomationEventType.ROW_ACTION,
|
||||
]
|
||||
|
||||
/**
|
||||
|
@ -28,7 +29,7 @@
|
|||
* @todo Parse *all* data for each trigger type and relay adequate feedback
|
||||
*/
|
||||
const parseTestData = testData => {
|
||||
const autoTrigger = $selectedAutomation?.definition?.trigger
|
||||
const autoTrigger = $selectedAutomation.data?.definition?.trigger
|
||||
const { tableId } = autoTrigger?.inputs || {}
|
||||
|
||||
// Ensure the tableId matches the trigger table for row trigger automations
|
||||
|
@ -62,12 +63,11 @@
|
|||
return true
|
||||
}
|
||||
|
||||
const memoTestData = memo(parseTestData($selectedAutomation.testData))
|
||||
$: memoTestData.set(parseTestData($selectedAutomation.testData))
|
||||
$: testData = testData || parseTestData($selectedAutomation.data.testData)
|
||||
|
||||
$: {
|
||||
// clone the trigger so we're not mutating the reference
|
||||
trigger = cloneDeep($selectedAutomation.definition.trigger)
|
||||
trigger = cloneDeep($selectedAutomation.data.definition.trigger)
|
||||
|
||||
// get the outputs so we can define the fields
|
||||
let schema = Object.entries(trigger.schema?.outputs?.properties || {})
|
||||
|
@ -82,7 +82,7 @@
|
|||
$: isError =
|
||||
!isTriggerValid(trigger) ||
|
||||
!(trigger.schema.outputs.required || []).every(
|
||||
required => $memoTestData?.[required] || required !== "row"
|
||||
required => testData?.[required] || required !== "row"
|
||||
)
|
||||
|
||||
function parseTestJSON(e) {
|
||||
|
@ -109,8 +109,10 @@
|
|||
}
|
||||
|
||||
const testAutomation = async () => {
|
||||
// Ensure testData reactiveness is processed
|
||||
await tick()
|
||||
try {
|
||||
await automationStore.actions.test($selectedAutomation, $memoTestData)
|
||||
await automationStore.actions.test($selectedAutomation.data, testData)
|
||||
$automationStore.showTestPanel = true
|
||||
} catch (error) {
|
||||
notifications.error(error)
|
||||
|
@ -148,7 +150,7 @@
|
|||
{#if selectedValues}
|
||||
<div class="tab-content-padding">
|
||||
<AutomationBlockSetup
|
||||
testData={$memoTestData}
|
||||
bind:testData
|
||||
{schemaProperties}
|
||||
isTestModal
|
||||
block={trigger}
|
||||
|
@ -158,7 +160,7 @@
|
|||
{#if selectedJSON}
|
||||
<div class="text-area-container">
|
||||
<TextArea
|
||||
value={JSON.stringify($selectedAutomation.testData, null, 2)}
|
||||
value={JSON.stringify($selectedAutomation.data.testData, null, 2)}
|
||||
error={failedParse}
|
||||
on:change={e => parseTestJSON(e)}
|
||||
/>
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte"
|
||||
import { ActionStepID } from "constants/backend/automations"
|
||||
import { JsonView } from "@zerodevx/svelte-json-view"
|
||||
import { automationStore } from "stores/builder"
|
||||
import { AutomationActionStepId } from "@budibase/types"
|
||||
|
||||
export let automation
|
||||
export let automationBlockRefs = {}
|
||||
export let testResults
|
||||
export let width = "400px"
|
||||
|
||||
let openBlocks = {}
|
||||
let blocks
|
||||
|
@ -28,21 +30,28 @@
|
|||
}
|
||||
}
|
||||
|
||||
$: filteredResults = prepTestResults(testResults)
|
||||
const getBranchName = (step, id) => {
|
||||
if (!step || !id) {
|
||||
return
|
||||
}
|
||||
return step.inputs.branches.find(branch => branch.id === id)?.name
|
||||
}
|
||||
|
||||
$: filteredResults = prepTestResults(testResults)
|
||||
$: {
|
||||
if (testResults.message) {
|
||||
blocks = automation?.definition?.trigger
|
||||
? [automation.definition.trigger]
|
||||
: []
|
||||
const trigger = automation?.definition?.trigger
|
||||
blocks = trigger ? [trigger] : []
|
||||
} else if (automation) {
|
||||
blocks = []
|
||||
if (automation.definition.trigger) {
|
||||
blocks.push(automation.definition.trigger)
|
||||
const terminatingStep = filteredResults.at(-1)
|
||||
const terminatingBlockRef = automationBlockRefs[terminatingStep.id]
|
||||
if (terminatingBlockRef) {
|
||||
const pathSteps = automationStore.actions.getPathSteps(
|
||||
terminatingBlockRef.pathTo,
|
||||
automation
|
||||
)
|
||||
blocks = [...pathSteps].filter(x => x.stepId !== ActionStepID.LOOP)
|
||||
}
|
||||
blocks = blocks
|
||||
.concat(automation.definition.steps || [])
|
||||
.filter(x => x.stepId !== ActionStepID.LOOP)
|
||||
} else if (filteredResults) {
|
||||
blocks = filteredResults || []
|
||||
// make sure there is an ID for each block being displayed
|
||||
|
@ -56,10 +65,14 @@
|
|||
|
||||
<div class="container">
|
||||
{#each blocks as block, idx}
|
||||
<div class="block" style={width ? `width: ${width}` : ""}>
|
||||
<div class="block">
|
||||
{#if block.stepId !== ActionStepID.LOOP}
|
||||
<FlowItemHeader
|
||||
{automation}
|
||||
enableNaming={false}
|
||||
itemName={block.stepId === AutomationActionStepId.BRANCH
|
||||
? getBranchName(block, filteredResults?.[idx].outputs?.branchId)
|
||||
: null}
|
||||
open={!!openBlocks[block.id]}
|
||||
on:toggle={() => (openBlocks[block.id] = !openBlocks[block.id])}
|
||||
isTrigger={idx === 0}
|
||||
|
@ -133,7 +146,10 @@
|
|||
.container {
|
||||
padding: 0 30px 30px 30px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
|
@ -175,17 +191,17 @@
|
|||
|
||||
.block {
|
||||
display: inline-block;
|
||||
width: 400px;
|
||||
height: auto;
|
||||
height: fit-content;
|
||||
font-size: 16px;
|
||||
background-color: var(--background);
|
||||
border: 1px solid var(--spectrum-global-color-gray-300);
|
||||
border-radius: 4px 4px 4px 4px;
|
||||
min-width: 425px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
border-left: 1px dashed var(--grey-4);
|
||||
color: var(--grey-4);
|
||||
/* center horizontally */
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { Icon, Divider } from "@budibase/bbui"
|
||||
import TestDisplay from "./TestDisplay.svelte"
|
||||
import { automationStore } from "stores/builder"
|
||||
import { automationStore, selectedAutomation } from "stores/builder"
|
||||
|
||||
export let automation
|
||||
</script>
|
||||
|
@ -9,9 +9,9 @@
|
|||
<div class="title">
|
||||
<div class="title-text">
|
||||
<Icon name="MultipleCheck" />
|
||||
<div style="padding-left: var(--spacing-l); ">Test Details</div>
|
||||
<div>Test Details</div>
|
||||
</div>
|
||||
<div style="padding-right: var(--spacing-xl)">
|
||||
<div>
|
||||
<Icon
|
||||
on:click={async () => {
|
||||
$automationStore.showTestPanel = false
|
||||
|
@ -24,7 +24,11 @@
|
|||
|
||||
<Divider />
|
||||
|
||||
<TestDisplay {automation} testResults={$automationStore.testResults} />
|
||||
<TestDisplay
|
||||
{automation}
|
||||
testResults={$automationStore.testResults}
|
||||
automationBlockRefs={$selectedAutomation.blockRefs}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.title {
|
||||
|
@ -32,7 +36,8 @@
|
|||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding-left: var(--spacing-xl);
|
||||
padding: 0px 30px;
|
||||
padding-top: var(--spacing-l);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
|
@ -40,7 +45,7 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-top: var(--spacing-s);
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.title :global(h1) {
|
||||
|
|
|
@ -17,11 +17,14 @@
|
|||
let confirmDeleteDialog
|
||||
let updateAutomationDialog
|
||||
|
||||
$: isRowAction = sdk.automations.isRowAction(automation)
|
||||
|
||||
async function deleteAutomation() {
|
||||
try {
|
||||
await automationStore.actions.delete(automation)
|
||||
notifications.success("Automation deleted successfully")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error("Error deleting automation")
|
||||
}
|
||||
}
|
||||
|
@ -36,42 +39,7 @@
|
|||
}
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
const isRowAction = sdk.automations.isRowAction(automation)
|
||||
const result = []
|
||||
if (!isRowAction) {
|
||||
result.push(
|
||||
...[
|
||||
{
|
||||
icon: "Delete",
|
||||
name: "Delete",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
disabled: false,
|
||||
callback: confirmDeleteDialog.show,
|
||||
},
|
||||
{
|
||||
icon: "Edit",
|
||||
name: "Edit",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
disabled: !automation.definition.trigger,
|
||||
callback: updateAutomationDialog.show,
|
||||
},
|
||||
{
|
||||
icon: "Duplicate",
|
||||
name: "Duplicate",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
disabled:
|
||||
!automation.definition.trigger ||
|
||||
automation.definition.trigger?.name === "Webhook",
|
||||
callback: duplicateAutomation,
|
||||
},
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
result.push({
|
||||
const pause = {
|
||||
icon: automation.disabled ? "CheckmarkCircle" : "Cancel",
|
||||
name: automation.disabled ? "Activate" : "Pause",
|
||||
keyBind: null,
|
||||
|
@ -83,8 +51,50 @@
|
|||
automation.disabled
|
||||
)
|
||||
},
|
||||
})
|
||||
return result
|
||||
}
|
||||
const del = {
|
||||
icon: "Delete",
|
||||
name: "Delete",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
disabled: false,
|
||||
callback: confirmDeleteDialog.show,
|
||||
}
|
||||
if (!isRowAction) {
|
||||
return [
|
||||
{
|
||||
icon: "Edit",
|
||||
name: "Edit",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
disabled: !automation.definition.trigger,
|
||||
callback: updateAutomationDialog.show,
|
||||
},
|
||||
{
|
||||
icon: "Duplicate",
|
||||
name: "Duplicate",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
disabled:
|
||||
!automation.definition.trigger ||
|
||||
automation.definition.trigger?.name === "Webhook",
|
||||
callback: duplicateAutomation,
|
||||
},
|
||||
pause,
|
||||
del,
|
||||
]
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
icon: "Edit",
|
||||
name: "Edit",
|
||||
keyBind: null,
|
||||
visible: true,
|
||||
callback: updateAutomationDialog.show,
|
||||
},
|
||||
del,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const openContextMenu = e => {
|
||||
|
@ -99,17 +109,17 @@
|
|||
<NavItem
|
||||
on:contextmenu={openContextMenu}
|
||||
{icon}
|
||||
iconColor={"var(--spectrum-global-color-gray-900)"}
|
||||
text={automation.displayName}
|
||||
selected={automation._id === $selectedAutomation?._id}
|
||||
iconColor={automation.disabled
|
||||
? "var(--spectrum-global-color-gray-600)"
|
||||
: "var(--spectrum-global-color-gray-900)"}
|
||||
text={automation.name}
|
||||
selected={automation._id === $selectedAutomation?.data?._id}
|
||||
hovering={automation._id === $contextMenuStore.id}
|
||||
on:click={() => automationStore.actions.select(automation._id)}
|
||||
selectedBy={$userSelectedResourceMap[automation._id]}
|
||||
disabled={automation.disabled}
|
||||
>
|
||||
<div class="icon">
|
||||
<Icon on:click={openContextMenu} size="S" hoverable name="MoreSmallList" />
|
||||
</div>
|
||||
<Icon on:click={openContextMenu} size="S" hoverable name="MoreSmallList" />
|
||||
</NavItem>
|
||||
|
||||
<ConfirmDialog
|
||||
|
@ -122,13 +132,5 @@
|
|||
<i>{automation.name}?</i>
|
||||
This action cannot be undone.
|
||||
</ConfirmDialog>
|
||||
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />
|
||||
|
||||
<style>
|
||||
div.icon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />
|
||||
|
|
|
@ -3,13 +3,21 @@
|
|||
import { Modal, notifications, Layout } from "@budibase/bbui"
|
||||
import NavHeader from "components/common/NavHeader.svelte"
|
||||
import { onMount } from "svelte"
|
||||
import { automationStore } from "stores/builder"
|
||||
import { automationStore, tables } from "stores/builder"
|
||||
import AutomationNavItem from "./AutomationNavItem.svelte"
|
||||
import { TriggerStepID } from "constants/backend/automations"
|
||||
|
||||
export let modal
|
||||
export let webhookModal
|
||||
let searchString
|
||||
|
||||
const dsTriggers = [
|
||||
TriggerStepID.ROW_SAVED,
|
||||
TriggerStepID.ROW_UPDATED,
|
||||
TriggerStepID.ROW_DELETED,
|
||||
TriggerStepID.ROW_ACTION,
|
||||
]
|
||||
|
||||
$: filteredAutomations = $automationStore.automations
|
||||
.filter(automation => {
|
||||
return (
|
||||
|
@ -17,31 +25,53 @@
|
|||
automation.name.toLowerCase().includes(searchString.toLowerCase())
|
||||
)
|
||||
})
|
||||
.map(automation => ({
|
||||
...automation,
|
||||
displayName:
|
||||
$automationStore.automationDisplayData[automation._id]?.displayName ||
|
||||
automation.name,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const lowerA = a.displayName.toLowerCase()
|
||||
const lowerB = b.displayName.toLowerCase()
|
||||
const lowerA = a.name.toLowerCase()
|
||||
const lowerB = b.name.toLowerCase()
|
||||
return lowerA > lowerB ? 1 : -1
|
||||
})
|
||||
|
||||
$: groupedAutomations = filteredAutomations.reduce((acc, auto) => {
|
||||
const catName = auto.definition?.trigger?.event || "No Trigger"
|
||||
acc[catName] ??= {
|
||||
icon: auto.definition?.trigger?.icon || "AlertCircle",
|
||||
name: (auto.definition?.trigger?.name || "No Trigger").toUpperCase(),
|
||||
entries: [],
|
||||
}
|
||||
acc[catName].entries.push(auto)
|
||||
return acc
|
||||
}, {})
|
||||
$: groupedAutomations = groupAutomations(filteredAutomations)
|
||||
|
||||
$: showNoResults = searchString && !filteredAutomations.length
|
||||
|
||||
const groupAutomations = automations => {
|
||||
let groups = {}
|
||||
|
||||
for (let auto of automations) {
|
||||
let category = null
|
||||
let dataTrigger = false
|
||||
|
||||
// Group by datasource if possible
|
||||
if (dsTriggers.includes(auto.definition?.trigger?.stepId)) {
|
||||
if (auto.definition.trigger.inputs?.tableId) {
|
||||
const tableId = auto.definition.trigger.inputs?.tableId
|
||||
category = $tables.list.find(x => x._id === tableId)?.name
|
||||
}
|
||||
}
|
||||
// Otherwise group by trigger
|
||||
if (!category) {
|
||||
category = auto.definition?.trigger?.name || "No Trigger"
|
||||
} else {
|
||||
dataTrigger = true
|
||||
}
|
||||
groups[category] ??= {
|
||||
icon: auto.definition?.trigger?.icon || "AlertCircle",
|
||||
name: category.toUpperCase(),
|
||||
entries: [],
|
||||
dataTrigger,
|
||||
}
|
||||
groups[category].entries.push(auto)
|
||||
}
|
||||
|
||||
return Object.values(groups).sort((a, b) => {
|
||||
if (a.dataTrigger === b.dataTrigger) {
|
||||
return a.name < b.name ? -1 : 1
|
||||
}
|
||||
return a.dataTrigger ? -1 : 1
|
||||
})
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
await automationStore.actions.fetch()
|
||||
|
@ -88,16 +118,22 @@
|
|||
|
||||
<style>
|
||||
.nav-group {
|
||||
padding-top: var(--spacing-l);
|
||||
padding-top: 24px;
|
||||
}
|
||||
.nav-group:first-child {
|
||||
padding-top: var(--spacing-s);
|
||||
}
|
||||
.nav-group-header {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
padding: 0px calc(var(--spacing-l) + 4px);
|
||||
padding-bottom: var(--spacing-l);
|
||||
padding-bottom: var(--spacing-m);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.side-bar {
|
||||
flex: 0 0 260px;
|
||||
display: flex;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import { automationStore } from "stores/builder"
|
||||
import {
|
||||
notifications,
|
||||
|
@ -32,11 +33,12 @@
|
|||
triggerVal.stepId,
|
||||
triggerVal
|
||||
)
|
||||
await automationStore.actions.create(name, trigger)
|
||||
const automation = await automationStore.actions.create(name, trigger)
|
||||
if (triggerVal.stepId === TriggerStepID.WEBHOOK) {
|
||||
webhookModal.show()
|
||||
}
|
||||
notifications.success(`Automation ${name} created`)
|
||||
$goto(`../automation/${automation._id}`)
|
||||
} catch (error) {
|
||||
notifications.error("Error creating automation")
|
||||
}
|
||||
|
|
|
@ -21,8 +21,8 @@
|
|||
} from "@budibase/bbui"
|
||||
|
||||
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
|
||||
import { automationStore, selectedAutomation, tables } from "stores/builder"
|
||||
import { environment, licensing } from "stores/portal"
|
||||
import { automationStore, tables } from "stores/builder"
|
||||
import { environment } from "stores/portal"
|
||||
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
|
||||
import {
|
||||
BindingSidePanel,
|
||||
|
@ -46,10 +46,7 @@
|
|||
} from "components/common/CodeEditor"
|
||||
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
|
||||
import { QueryUtils, Utils, search, memo } from "@budibase/frontend-core"
|
||||
import {
|
||||
getSchemaForDatasourcePlus,
|
||||
getEnvironmentBindings,
|
||||
} from "dataBinding"
|
||||
import { getSchemaForDatasourcePlus } from "dataBinding"
|
||||
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
|
||||
import { onMount } from "svelte"
|
||||
import { writable } from "svelte/store"
|
||||
|
@ -60,22 +57,24 @@
|
|||
AutomationActionStepId,
|
||||
AutomationCustomIOType,
|
||||
} from "@budibase/types"
|
||||
import { FIELDS } from "constants/backend"
|
||||
import PropField from "./PropField.svelte"
|
||||
import { utils } from "@budibase/shared-core"
|
||||
|
||||
export let automation
|
||||
export let block
|
||||
export let testData
|
||||
export let schemaProperties
|
||||
export let isTestModal = false
|
||||
export let bindings = []
|
||||
|
||||
// Stop unnecessary rendering
|
||||
const memoBlock = memo(block)
|
||||
const memoEnvVariables = memo($environment.variables)
|
||||
|
||||
const rowTriggers = [
|
||||
TriggerStepID.ROW_UPDATED,
|
||||
TriggerStepID.ROW_SAVED,
|
||||
TriggerStepID.ROW_DELETED,
|
||||
TriggerStepID.ROW_ACTION,
|
||||
]
|
||||
|
||||
const rowEvents = [
|
||||
|
@ -92,19 +91,17 @@
|
|||
let insertAtPos, getCaretPosition
|
||||
let stepLayouts = {}
|
||||
|
||||
$: memoEnvVariables.set($environment.variables)
|
||||
$: memoBlock.set(block)
|
||||
|
||||
$: filters = lookForFilters(schemaProperties) || []
|
||||
$: tempFilters = filters
|
||||
$: stepId = $memoBlock.stepId
|
||||
$: filters = lookForFilters(schemaProperties)
|
||||
$: filterCount =
|
||||
filters?.groups?.reduce((acc, group) => {
|
||||
acc = acc += group?.filters?.length || 0
|
||||
return acc
|
||||
}, 0) || 0
|
||||
|
||||
$: automationBindings = getAvailableBindings(
|
||||
$memoBlock,
|
||||
$selectedAutomation?.definition
|
||||
)
|
||||
$: environmentBindings = buildEnvironmentBindings($memoEnvVariables)
|
||||
$: bindings = [...automationBindings, ...environmentBindings]
|
||||
$: tempFilters = cloneDeep(filters)
|
||||
$: stepId = $memoBlock.stepId
|
||||
|
||||
$: getInputData(testData, $memoBlock.inputs)
|
||||
$: tableId = inputData ? inputData.tableId : null
|
||||
|
@ -138,21 +135,6 @@
|
|||
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
|
||||
: []
|
||||
|
||||
const buildEnvironmentBindings = () => {
|
||||
if ($licensing.environmentVariablesEnabled) {
|
||||
return getEnvironmentBindings().map(binding => {
|
||||
return {
|
||||
...binding,
|
||||
display: {
|
||||
...binding.display,
|
||||
rank: 98,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getInputData = (testData, blockInputs) => {
|
||||
// Test data is not cloned for reactivity
|
||||
let newInputData = testData || cloneDeep(blockInputs)
|
||||
|
@ -429,7 +411,7 @@
|
|||
|
||||
if (
|
||||
Object.hasOwn(update, "tableId") &&
|
||||
$selectedAutomation.testData?.row?.tableId !== update.tableId
|
||||
automation.testData?.row?.tableId !== update.tableId
|
||||
) {
|
||||
const reqSchema = getSchemaForDatasourcePlus(update.tableId, {
|
||||
searchableSchema: true,
|
||||
|
@ -521,7 +503,15 @@
|
|||
row: { "Active": true, "Order Id" : 14, ... }
|
||||
})
|
||||
*/
|
||||
const onChange = Utils.sequential(async update => {
|
||||
const onChange = async update => {
|
||||
if (isTestModal) {
|
||||
testData = update
|
||||
}
|
||||
|
||||
updateAutomation(update)
|
||||
}
|
||||
|
||||
const updateAutomation = Utils.sequential(async update => {
|
||||
const request = cloneDeep(update)
|
||||
// Process app trigger updates
|
||||
if (isTrigger && !isTestModal) {
|
||||
|
@ -558,7 +548,7 @@
|
|||
...newTestData,
|
||||
body: {
|
||||
...update,
|
||||
...$selectedAutomation.testData?.body,
|
||||
...automation.testData?.body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -566,6 +556,7 @@
|
|||
...newTestData,
|
||||
...request,
|
||||
}
|
||||
|
||||
await automationStore.actions.addTestDataToAutomation(newTestData)
|
||||
} else {
|
||||
const data = { schema, ...request }
|
||||
|
@ -577,201 +568,6 @@
|
|||
}
|
||||
})
|
||||
|
||||
function getAvailableBindings(block, automation) {
|
||||
if (!block || !automation) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Find previous steps to the selected one
|
||||
let allSteps = [...automation.steps]
|
||||
|
||||
if (automation.trigger) {
|
||||
allSteps = [automation.trigger, ...allSteps]
|
||||
}
|
||||
let blockIdx = allSteps.findIndex(step => step.id === block.id)
|
||||
|
||||
// Extract all outputs from all previous steps as available bindingsx§x
|
||||
let bindings = []
|
||||
let loopBlockCount = 0
|
||||
const addBinding = (name, value, icon, idx, isLoopBlock, bindingName) => {
|
||||
if (!name) return
|
||||
const runtimeBinding = determineRuntimeBinding(
|
||||
name,
|
||||
idx,
|
||||
isLoopBlock,
|
||||
bindingName
|
||||
)
|
||||
const categoryName = determineCategoryName(idx, isLoopBlock, bindingName)
|
||||
bindings.push(
|
||||
createBindingObject(
|
||||
name,
|
||||
value,
|
||||
icon,
|
||||
idx,
|
||||
loopBlockCount,
|
||||
isLoopBlock,
|
||||
runtimeBinding,
|
||||
categoryName,
|
||||
bindingName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const determineRuntimeBinding = (name, idx, isLoopBlock, bindingName) => {
|
||||
let runtimeName
|
||||
|
||||
/* Begin special cases for generating custom schemas based on triggers */
|
||||
if (
|
||||
idx === 0 &&
|
||||
automation.trigger?.event === AutomationEventType.APP_TRIGGER
|
||||
) {
|
||||
return `trigger.fields.${name}`
|
||||
}
|
||||
|
||||
if (
|
||||
idx === 0 &&
|
||||
(automation.trigger?.event === AutomationEventType.ROW_UPDATE ||
|
||||
automation.trigger?.event === AutomationEventType.ROW_SAVE)
|
||||
) {
|
||||
let noRowKeywordBindings = ["id", "revision", "oldRow"]
|
||||
if (!noRowKeywordBindings.includes(name)) return `trigger.row.${name}`
|
||||
}
|
||||
/* End special cases for generating custom schemas based on triggers */
|
||||
|
||||
let hasUserDefinedName = automation.stepNames?.[allSteps[idx]?.id]
|
||||
if (isLoopBlock) {
|
||||
runtimeName = `loop.${name}`
|
||||
} else if (idx === 0) {
|
||||
runtimeName = `trigger.${name}`
|
||||
} else if (block.name.startsWith("JS")) {
|
||||
runtimeName = hasUserDefinedName
|
||||
? `stepsByName["${bindingName}"].${name}`
|
||||
: `steps["${idx - loopBlockCount}"].${name}`
|
||||
} else {
|
||||
runtimeName = hasUserDefinedName
|
||||
? `stepsByName.${bindingName}.${name}`
|
||||
: `steps.${idx - loopBlockCount}.${name}`
|
||||
}
|
||||
return runtimeName
|
||||
}
|
||||
|
||||
const determineCategoryName = (idx, isLoopBlock, bindingName) => {
|
||||
if (idx === 0) return "Trigger outputs"
|
||||
if (isLoopBlock) return "Loop Outputs"
|
||||
return bindingName
|
||||
? `${bindingName} outputs`
|
||||
: `Step ${idx - loopBlockCount} outputs`
|
||||
}
|
||||
|
||||
const createBindingObject = (
|
||||
name,
|
||||
value,
|
||||
icon,
|
||||
idx,
|
||||
loopBlockCount,
|
||||
isLoopBlock,
|
||||
runtimeBinding,
|
||||
categoryName,
|
||||
bindingName
|
||||
) => {
|
||||
const field = Object.values(FIELDS).find(
|
||||
field => field.type === value.type && field.subtype === value.subtype
|
||||
)
|
||||
return {
|
||||
readableBinding:
|
||||
bindingName && !isLoopBlock && idx !== 0
|
||||
? `steps.${bindingName}.${name}`
|
||||
: runtimeBinding,
|
||||
runtimeBinding,
|
||||
type: value.type,
|
||||
description: value.description,
|
||||
icon,
|
||||
category: categoryName,
|
||||
display: {
|
||||
type: field?.name || value.type,
|
||||
name,
|
||||
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < blockIdx; idx++) {
|
||||
let wasLoopBlock = allSteps[idx - 1]?.stepId === ActionStepID.LOOP
|
||||
let isLoopBlock =
|
||||
allSteps[idx]?.stepId === ActionStepID.LOOP &&
|
||||
allSteps.some(x => x.blockToLoop === block.id)
|
||||
let schema = cloneDeep(allSteps[idx]?.schema?.outputs?.properties) ?? {}
|
||||
if (allSteps[idx]?.name.includes("Looping")) {
|
||||
isLoopBlock = true
|
||||
loopBlockCount++
|
||||
}
|
||||
let bindingName =
|
||||
automation.stepNames?.[allSteps[idx].id] || allSteps[idx].name
|
||||
|
||||
if (isLoopBlock) {
|
||||
schema = {
|
||||
currentItem: {
|
||||
type: "string",
|
||||
description: "the item currently being executed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
idx === 0 &&
|
||||
automation.trigger?.event === AutomationEventType.APP_TRIGGER
|
||||
) {
|
||||
schema = Object.fromEntries(
|
||||
Object.keys(automation.trigger.inputs.fields || []).map(key => [
|
||||
key,
|
||||
{ type: automation.trigger.inputs.fields[key] },
|
||||
])
|
||||
)
|
||||
}
|
||||
if (
|
||||
(idx === 0 &&
|
||||
automation.trigger.event === AutomationEventType.ROW_UPDATE) ||
|
||||
(idx === 0 && automation.trigger.event === AutomationEventType.ROW_SAVE)
|
||||
) {
|
||||
let table = $tables.list.find(
|
||||
table => table._id === automation.trigger.inputs.tableId
|
||||
)
|
||||
// We want to generate our own schema for the bindings from the table schema itself
|
||||
for (const key in table?.schema) {
|
||||
schema[key] = {
|
||||
type: table.schema[key].type,
|
||||
subtype: table.schema[key].subtype,
|
||||
}
|
||||
}
|
||||
// remove the original binding
|
||||
delete schema.row
|
||||
}
|
||||
let icon =
|
||||
idx === 0
|
||||
? automation.trigger.icon
|
||||
: isLoopBlock
|
||||
? "Reuse"
|
||||
: allSteps[idx].icon
|
||||
|
||||
if (wasLoopBlock) {
|
||||
schema = cloneDeep(allSteps[idx - 1]?.schema?.outputs?.properties)
|
||||
}
|
||||
Object.entries(schema).forEach(([name, value]) => {
|
||||
addBinding(name, value, icon, idx, isLoopBlock, bindingName)
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
allSteps[blockIdx - 1]?.stepId !== ActionStepID.LOOP &&
|
||||
allSteps
|
||||
.slice(0, blockIdx)
|
||||
.some(step => step.stepId === ActionStepID.LOOP)
|
||||
) {
|
||||
bindings = bindings.filter(x => !x.readableBinding.includes("loop"))
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
function lookForFilters(properties) {
|
||||
if (!properties) {
|
||||
return []
|
||||
|
@ -790,14 +586,15 @@
|
|||
break
|
||||
}
|
||||
}
|
||||
return filters || []
|
||||
return Array.isArray(filters)
|
||||
? utils.processSearchFilters(filters)
|
||||
: filters
|
||||
}
|
||||
|
||||
function saveFilters(key) {
|
||||
const filters = QueryUtils.buildQuery(tempFilters)
|
||||
|
||||
const query = QueryUtils.buildQuery(tempFilters)
|
||||
onChange({
|
||||
[key]: filters,
|
||||
[key]: query,
|
||||
[`${key}-def`]: tempFilters, // need to store the builder definition in the automation
|
||||
})
|
||||
|
||||
|
@ -856,7 +653,7 @@
|
|||
<!-- Custom Layouts -->
|
||||
{#if stepLayouts[block.stepId]}
|
||||
{#each Object.keys(stepLayouts[block.stepId] || {}) as key}
|
||||
{#if canShowField(key, stepLayouts[block.stepId].schema)}
|
||||
{#if canShowField(stepLayouts[block.stepId].schema)}
|
||||
{#each stepLayouts[block.stepId][key].content as config}
|
||||
{#if config.title}
|
||||
<PropField label={config.title} labelTooltip={config.tooltip}>
|
||||
|
@ -881,7 +678,7 @@
|
|||
{:else}
|
||||
<!-- Default Schema Property Layout -->
|
||||
{#each schemaProperties as [key, value]}
|
||||
{#if canShowField(key, value)}
|
||||
{#if canShowField(value)}
|
||||
{@const label = getFieldLabel(key, value)}
|
||||
<div class:block-field={shouldRenderField(value)}>
|
||||
{#if key !== "fields" && value.type !== "boolean" && shouldRenderField(value)}
|
||||
|
@ -903,8 +700,8 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class:field-width={shouldRenderField(value)}>
|
||||
{#if value.type === "string" && value.enum && canShowField(key, value)}
|
||||
<div>
|
||||
{#if value.type === "string" && value.enum && canShowField(value)}
|
||||
<Select
|
||||
on:change={e => onChange({ [key]: e.detail })}
|
||||
value={inputData[key]}
|
||||
|
@ -1026,18 +823,24 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else if value.customType === AutomationCustomIOType.FILTERS || value.customType === AutomationCustomIOType.TRIGGER_FILTER}
|
||||
<ActionButton fullWidth on:click={drawer.show}
|
||||
>{filters.length > 0
|
||||
? "Update Filter"
|
||||
: "No Filter set"}</ActionButton
|
||||
<ActionButton fullWidth on:click={drawer.show}>
|
||||
{filterCount > 0 ? "Update Filter" : "No Filter set"}
|
||||
</ActionButton>
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
title="Filtering"
|
||||
forceModal
|
||||
on:drawerShow={() => {
|
||||
tempFilters = filters
|
||||
}}
|
||||
>
|
||||
<Drawer bind:this={drawer} title="Filtering">
|
||||
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
<DrawerContent slot="body">
|
||||
<FilterBuilder
|
||||
{filters}
|
||||
filters={tempFilters}
|
||||
{bindings}
|
||||
{schemaFields}
|
||||
datasource={{ type: "table", tableId }}
|
||||
|
@ -1193,8 +996,9 @@
|
|||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
.field-width {
|
||||
width: 320px;
|
||||
|
||||
.label-container :global(label) {
|
||||
white-space: unset;
|
||||
}
|
||||
|
||||
.step-fields {
|
||||
|
@ -1206,12 +1010,9 @@
|
|||
}
|
||||
|
||||
.block-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
}
|
||||
|
||||
.attachment-field-width {
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
$: filteredAutomations = $automationStore.automations.filter(
|
||||
automation =>
|
||||
automation.definition.trigger.stepId === TriggerStepID.APP &&
|
||||
automation._id !== $selectedAutomation._id
|
||||
automation._id !== $selectedAutomation.data._id
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue