Merge branch 'master' of github.com:Budibase/budibase into chore/refactor-finalizerow
This commit is contained in:
commit
f8e766823c
|
@ -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:
|
||||
|
@ -117,9 +114,11 @@ jobs:
|
|||
- name: Test
|
||||
run: |
|
||||
if ${{ env.ONLY_AFFECTED_TASKS }}; then
|
||||
yarn test --ignore=@budibase/worker --ignore=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
|
||||
yarn test -- --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/builder --no-prefix --since=${{ env.NX_BASE_BRANCH }} -- --verbose --reporters=default --reporters=github-actions
|
||||
yarn test -- --scope=@budibase/builder --since=${{ env.NX_BASE_BRANCH }}
|
||||
else
|
||||
yarn test --ignore=@budibase/worker --ignore=@budibase/server
|
||||
yarn test -- --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/builder --no-prefix -- --verbose --reporters=default --reporters=github-actions
|
||||
yarn test -- --scope=@budibase/builder --no-prefix
|
||||
fi
|
||||
|
||||
test-worker:
|
||||
|
@ -141,13 +140,22 @@ jobs:
|
|||
- name: Test worker
|
||||
run: |
|
||||
if ${{ env.ONLY_AFFECTED_TASKS }}; then
|
||||
node scripts/run-affected.js --task=test --scope=@budibase/worker --since=${{ env.NX_BASE_BRANCH }}
|
||||
else
|
||||
yarn test --scope=@budibase/worker
|
||||
AFFECTED=$(yarn --silent nx show projects --affected -t test --base=${{ env.NX_BASE_BRANCH }} -p @budibase/worker)
|
||||
if [ -z "$AFFECTED" ]; then
|
||||
echo "No affected tests to run"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
cd packages/worker
|
||||
yarn test --verbose --reporters=default --reporters=github-actions
|
||||
|
||||
test-server:
|
||||
runs-on: budi-tubby-tornado-quad-core-300gb
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
datasource:
|
||||
[mssql, mysql, postgres, mongodb, mariadb, oracle, sqs, none]
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
@ -170,12 +178,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 +201,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 --verbose --reporters=default --reporters=github-actions
|
||||
|
||||
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,64 +279,6 @@ jobs:
|
|||
echo 'All good, the submodule had been merged and setup correctly!'
|
||||
fi
|
||||
|
||||
check-accountportal-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')
|
||||
steps:
|
||||
- name: Checkout repo and submodules
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: changes
|
||||
with:
|
||||
filters: |
|
||||
src:
|
||||
- packages/account-portal/**
|
||||
|
||||
- if: steps.changes.outputs.src == 'true'
|
||||
name: Check account portal commit
|
||||
id: get_accountportal_commits
|
||||
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"
|
||||
else
|
||||
echo "Nothing to do - branch to branch merge."
|
||||
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!')
|
||||
}
|
||||
|
||||
check-lockfile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
|
|
@ -8,6 +8,7 @@ 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
|
||||
|
@ -110,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
|
|
@ -20,16 +20,6 @@
|
|||
"args": ["${workspaceFolder}/packages/worker/src/index.ts"],
|
||||
"cwd": "${workspaceFolder}/packages/worker"
|
||||
},
|
||||
{
|
||||
"name": "Camunda Worker",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeArgs": ["--nolazy", "-r", "ts-node/register/transpile-only"],
|
||||
"args": [
|
||||
"${workspaceFolder}/packages/account-portal/packages/server/src/v2/run.ts"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/packages/account-portal/packages/server"
|
||||
},
|
||||
{
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
|
|
|
@ -12,12 +12,12 @@ metadata:
|
|||
type: Opaque
|
||||
data:
|
||||
{{- if $existingSecret }}
|
||||
internalApiKey: {{ index $existingSecret.data "internalApiKey" }}
|
||||
jwtSecret: {{ index $existingSecret.data "jwtSecret" }}
|
||||
objectStoreAccess: {{ index $existingSecret.data "objectStoreAccess" }}
|
||||
objectStoreSecret: {{ index $existingSecret.data "objectStoreSecret" }}
|
||||
bbEncryptionKey: {{ index $existingSecret.data "bbEncryptionKey" }}
|
||||
apiEncryptionKey: {{ index $existingSecret.data "apiEncryptionKey" }}
|
||||
internalApiKey: {{ index $existingSecret.data "internalApiKey" | quote }}
|
||||
jwtSecret: {{ index $existingSecret.data "jwtSecret" | quote }}
|
||||
objectStoreAccess: {{ index $existingSecret.data "objectStoreAccess" | quote }}
|
||||
objectStoreSecret: {{ index $existingSecret.data "objectStoreSecret" | quote }}
|
||||
bbEncryptionKey: {{ index $existingSecret.data "bbEncryptionKey" | quote }}
|
||||
apiEncryptionKey: {{ index $existingSecret.data "apiEncryptionKey" | quote }}
|
||||
{{- else }}
|
||||
internalApiKey: {{ template "budibase.defaultsecret" .Values.globals.internalApiKey }}
|
||||
jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
@ -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`)
|
||||
|
|
|
@ -1,12 +1,7 @@
|
|||
{
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||
"version": "3.0.3",
|
||||
"version": "3.2.12",
|
||||
"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"]
|
||||
}
|
||||
|
|
29
package.json
29
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",
|
||||
|
@ -24,22 +25,22 @@
|
|||
"prettier": "2.8.8",
|
||||
"prettier-plugin-svelte": "^2.3.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"svelte": "^4.2.10",
|
||||
"svelte": "4.2.19",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"typescript": "5.5.2",
|
||||
"typescript-eslint": "^7.3.1",
|
||||
"yargs": "^17.7.2"
|
||||
"yargs": "^17.7.2",
|
||||
"cross-spawn": "7.0.6"
|
||||
},
|
||||
"scripts": {
|
||||
"get-past-client-version": "node scripts/getPastClientVersion.js",
|
||||
"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,15 +53,12 @@
|
|||
"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",
|
||||
"test": "lerna run --concurrency 1 --stream test",
|
||||
"test:containers:kill": "./scripts/killTestcontainers.sh",
|
||||
"lint:eslint": "eslint packages --max-warnings=0",
|
||||
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"",
|
||||
|
@ -98,9 +96,7 @@
|
|||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"!packages/account-portal",
|
||||
"packages/account-portal/packages/*"
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"resolutions": {
|
||||
|
@ -114,7 +110,7 @@
|
|||
"semver": "7.5.3",
|
||||
"http-cache-semantics": "4.1.1",
|
||||
"msgpackr": "1.10.1",
|
||||
"axios": "1.6.3",
|
||||
"axios": "1.7.7",
|
||||
"xml2js": "0.6.2",
|
||||
"unset-value": "2.0.1",
|
||||
"passport": "0.6.0",
|
||||
|
@ -124,6 +120,5 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0 <21.0.0"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 9bef5d1656b4f3c991447ded6d65b0eba393a140
|
|
@ -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"
|
||||
},
|
||||
|
@ -25,17 +33,21 @@
|
|||
"@budibase/pouchdb-replication-stream": "1.2.11",
|
||||
"@budibase/shared-core": "0.0.0",
|
||||
"@budibase/types": "0.0.0",
|
||||
"@techpass/passport-openidconnect": "0.3.3",
|
||||
"aws-cloudfront-sign": "3.0.2",
|
||||
"aws-sdk": "2.1030.0",
|
||||
"aws-sdk": "2.1692.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"bull": "4.10.1",
|
||||
"correlation-id": "4.0.0",
|
||||
"dd-trace": "5.2.0",
|
||||
"dd-trace": "5.26.0",
|
||||
"dotenv": "16.0.1",
|
||||
"google-auth-library": "^8.0.1",
|
||||
"google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.5",
|
||||
"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",
|
||||
|
@ -46,17 +58,17 @@
|
|||
"pino": "8.11.0",
|
||||
"pino-http": "8.3.3",
|
||||
"posthog-node": "4.0.1",
|
||||
"pouchdb": "7.3.0",
|
||||
"pouchdb-find": "7.2.2",
|
||||
"pouchdb": "9.0.0",
|
||||
"pouchdb-find": "9.0.0",
|
||||
"redlock": "4.2.0",
|
||||
"rotating-file-stream": "3.1.0",
|
||||
"sanitize-s3-objectkey": "0.0.1",
|
||||
"semver": "^7.5.4",
|
||||
"tar-fs": "2.1.1",
|
||||
"uuid": "^8.3.2",
|
||||
"knex": "2.4.2"
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/types": "^29.6.3",
|
||||
"@shopify/jest-koa-mocks": "5.1.1",
|
||||
"@swc/core": "1.3.71",
|
||||
"@swc/jest": "0.2.27",
|
||||
|
@ -64,8 +76,9 @@
|
|||
"@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/pouchdb": "6.4.2",
|
||||
"@types/redlock": "4.0.7",
|
||||
"@types/semver": "7.3.7",
|
||||
"@types/tar-fs": "2.0.1",
|
||||
|
@ -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 })
|
||||
)
|
||||
})
|
||||
|
|
|
@ -10,7 +10,6 @@ import {
|
|||
DatabaseQueryOpts,
|
||||
DBError,
|
||||
Document,
|
||||
FeatureFlag,
|
||||
isDocument,
|
||||
RowResponse,
|
||||
RowValue,
|
||||
|
@ -27,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."
|
||||
|
||||
|
@ -192,7 +190,7 @@ export class DatabaseImpl implements Database {
|
|||
}
|
||||
}
|
||||
|
||||
private async performCall<T>(call: DBCallback<T>): Promise<any> {
|
||||
private async performCall<T>(call: DBCallback<T>): Promise<T> {
|
||||
const db = this.getDb()
|
||||
const fnc = await call(db)
|
||||
try {
|
||||
|
@ -456,10 +454,7 @@ export class DatabaseImpl implements Database {
|
|||
}
|
||||
|
||||
async destroy() {
|
||||
if (
|
||||
(await flags.isEnabled(FeatureFlag.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
|
||||
|
@ -472,7 +467,7 @@ export class DatabaseImpl implements Database {
|
|||
} catch (err: any) {
|
||||
// didn't exist, don't worry
|
||||
if (err.statusCode === 404) {
|
||||
return
|
||||
return { ok: true }
|
||||
} else {
|
||||
throw new CouchDBError(err.message, err)
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ export class DDInstrumentedDatabase implements Database {
|
|||
|
||||
exists(docId?: string): Promise<boolean> {
|
||||
return tracer.trace("db.exists", span => {
|
||||
span?.addTags({ db_name: this.name, doc_id: docId })
|
||||
span.addTags({ db_name: this.name, doc_id: docId })
|
||||
if (docId) {
|
||||
return this.db.exists(docId)
|
||||
}
|
||||
|
@ -37,15 +37,17 @@ export class DDInstrumentedDatabase implements Database {
|
|||
|
||||
get<T extends Document>(id?: string | undefined): Promise<T> {
|
||||
return tracer.trace("db.get", span => {
|
||||
span?.addTags({ db_name: this.name, doc_id: id })
|
||||
span.addTags({ db_name: this.name, doc_id: id })
|
||||
return this.db.get(id)
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
return tracer.trace("db.tryGet", async span => {
|
||||
span.addTags({ db_name: this.name, doc_id: id })
|
||||
const doc = await this.db.tryGet<T>(id)
|
||||
span.addTags({ doc_found: doc !== undefined })
|
||||
return doc
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -53,13 +55,15 @@ export class DDInstrumentedDatabase implements Database {
|
|||
ids: string[],
|
||||
opts?: { allowMissing?: boolean | undefined } | undefined
|
||||
): Promise<T[]> {
|
||||
return tracer.trace("db.getMultiple", span => {
|
||||
span?.addTags({
|
||||
return tracer.trace("db.getMultiple", async span => {
|
||||
span.addTags({
|
||||
db_name: this.name,
|
||||
num_docs: ids.length,
|
||||
allow_missing: opts?.allowMissing,
|
||||
})
|
||||
return this.db.getMultiple(ids, opts)
|
||||
const docs = await this.db.getMultiple<T>(ids, opts)
|
||||
span.addTags({ num_docs_found: docs.length })
|
||||
return docs
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -69,12 +73,14 @@ export class DDInstrumentedDatabase implements Database {
|
|||
idOrDoc: string | Document,
|
||||
rev?: string
|
||||
): Promise<DocumentDestroyResponse> {
|
||||
return tracer.trace("db.remove", span => {
|
||||
span?.addTags({ db_name: this.name, doc_id: idOrDoc })
|
||||
return tracer.trace("db.remove", async span => {
|
||||
span.addTags({ db_name: this.name, doc_id: idOrDoc, rev })
|
||||
const isDocument = typeof idOrDoc === "object"
|
||||
const id = isDocument ? idOrDoc._id! : idOrDoc
|
||||
rev = isDocument ? idOrDoc._rev : rev
|
||||
return this.db.remove(id, rev)
|
||||
const resp = await this.db.remove(id, rev)
|
||||
span.addTags({ ok: resp.ok })
|
||||
return resp
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -83,7 +89,11 @@ export class DDInstrumentedDatabase implements Database {
|
|||
opts?: { silenceErrors?: boolean }
|
||||
): Promise<void> {
|
||||
return tracer.trace("db.bulkRemove", span => {
|
||||
span?.addTags({ db_name: this.name, num_docs: documents.length })
|
||||
span.addTags({
|
||||
db_name: this.name,
|
||||
num_docs: documents.length,
|
||||
silence_errors: opts?.silenceErrors,
|
||||
})
|
||||
return this.db.bulkRemove(documents, opts)
|
||||
})
|
||||
}
|
||||
|
@ -92,15 +102,21 @@ export class DDInstrumentedDatabase implements Database {
|
|||
document: AnyDocument,
|
||||
opts?: DatabasePutOpts | undefined
|
||||
): Promise<DocumentInsertResponse> {
|
||||
return tracer.trace("db.put", span => {
|
||||
span?.addTags({ db_name: this.name, doc_id: document._id })
|
||||
return this.db.put(document, opts)
|
||||
return tracer.trace("db.put", async span => {
|
||||
span.addTags({
|
||||
db_name: this.name,
|
||||
doc_id: document._id,
|
||||
force: opts?.force,
|
||||
})
|
||||
const resp = await this.db.put(document, opts)
|
||||
span.addTags({ ok: resp.ok })
|
||||
return resp
|
||||
})
|
||||
}
|
||||
|
||||
bulkDocs(documents: AnyDocument[]): Promise<DocumentBulkResponse[]> {
|
||||
return tracer.trace("db.bulkDocs", span => {
|
||||
span?.addTags({ db_name: this.name, num_docs: documents.length })
|
||||
span.addTags({ db_name: this.name, num_docs: documents.length })
|
||||
return this.db.bulkDocs(documents)
|
||||
})
|
||||
}
|
||||
|
@ -108,9 +124,15 @@ export class DDInstrumentedDatabase implements Database {
|
|||
allDocs<T extends Document | RowValue>(
|
||||
params: DatabaseQueryOpts
|
||||
): Promise<AllDocsResponse<T>> {
|
||||
return tracer.trace("db.allDocs", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
return this.db.allDocs(params)
|
||||
return tracer.trace("db.allDocs", async span => {
|
||||
span.addTags({ db_name: this.name, ...params })
|
||||
const resp = await this.db.allDocs<T>(params)
|
||||
span.addTags({
|
||||
total_rows: resp.total_rows,
|
||||
rows_length: resp.rows.length,
|
||||
offset: resp.offset,
|
||||
})
|
||||
return resp
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -118,57 +140,75 @@ export class DDInstrumentedDatabase implements Database {
|
|||
viewName: string,
|
||||
params: DatabaseQueryOpts
|
||||
): Promise<AllDocsResponse<T>> {
|
||||
return tracer.trace("db.query", span => {
|
||||
span?.addTags({ db_name: this.name, view_name: viewName })
|
||||
return this.db.query(viewName, params)
|
||||
return tracer.trace("db.query", async span => {
|
||||
span.addTags({ db_name: this.name, view_name: viewName, ...params })
|
||||
const resp = await this.db.query<T>(viewName, params)
|
||||
span.addTags({
|
||||
total_rows: resp.total_rows,
|
||||
rows_length: resp.rows.length,
|
||||
offset: resp.offset,
|
||||
})
|
||||
return resp
|
||||
})
|
||||
}
|
||||
|
||||
destroy(): Promise<void | OkResponse> {
|
||||
return tracer.trace("db.destroy", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
return this.db.destroy()
|
||||
destroy(): Promise<OkResponse> {
|
||||
return tracer.trace("db.destroy", async span => {
|
||||
span.addTags({ db_name: this.name })
|
||||
const resp = await this.db.destroy()
|
||||
span.addTags({ ok: resp.ok })
|
||||
return resp
|
||||
})
|
||||
}
|
||||
|
||||
compact(): Promise<void | OkResponse> {
|
||||
return tracer.trace("db.compact", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
return this.db.compact()
|
||||
compact(): Promise<OkResponse> {
|
||||
return tracer.trace("db.compact", async span => {
|
||||
span.addTags({ db_name: this.name })
|
||||
const resp = await this.db.compact()
|
||||
span.addTags({ ok: resp.ok })
|
||||
return resp
|
||||
})
|
||||
}
|
||||
|
||||
dump(stream: Writable, opts?: DatabaseDumpOpts | undefined): Promise<any> {
|
||||
return tracer.trace("db.dump", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span.addTags({
|
||||
db_name: this.name,
|
||||
batch_limit: opts?.batch_limit,
|
||||
batch_size: opts?.batch_size,
|
||||
style: opts?.style,
|
||||
timeout: opts?.timeout,
|
||||
num_doc_ids: opts?.doc_ids?.length,
|
||||
view: opts?.view,
|
||||
})
|
||||
return this.db.dump(stream, opts)
|
||||
})
|
||||
}
|
||||
|
||||
load(...args: any[]): Promise<any> {
|
||||
return tracer.trace("db.load", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span.addTags({ db_name: this.name, num_args: args.length })
|
||||
return this.db.load(...args)
|
||||
})
|
||||
}
|
||||
|
||||
createIndex(...args: any[]): Promise<any> {
|
||||
return tracer.trace("db.createIndex", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span.addTags({ db_name: this.name, num_args: args.length })
|
||||
return this.db.createIndex(...args)
|
||||
})
|
||||
}
|
||||
|
||||
deleteIndex(...args: any[]): Promise<any> {
|
||||
return tracer.trace("db.deleteIndex", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span.addTags({ db_name: this.name, num_args: args.length })
|
||||
return this.db.deleteIndex(...args)
|
||||
})
|
||||
}
|
||||
|
||||
getIndexes(...args: any[]): Promise<any> {
|
||||
return tracer.trace("db.getIndexes", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span.addTags({ db_name: this.name, num_args: args.length })
|
||||
return this.db.getIndexes(...args)
|
||||
})
|
||||
}
|
||||
|
@ -177,22 +217,27 @@ export class DDInstrumentedDatabase implements Database {
|
|||
sql: string,
|
||||
parameters?: SqlQueryBinding
|
||||
): Promise<T[]> {
|
||||
return tracer.trace("db.sql", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
return this.db.sql(sql, parameters)
|
||||
return tracer.trace("db.sql", async span => {
|
||||
span.addTags({ db_name: this.name, num_bindings: parameters?.length })
|
||||
const resp = await this.db.sql<T>(sql, parameters)
|
||||
span.addTags({ num_rows: resp.length })
|
||||
return resp
|
||||
})
|
||||
}
|
||||
|
||||
sqlPurgeDocument(docIds: string[] | string): Promise<void> {
|
||||
return tracer.trace("db.sqlPurgeDocument", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span.addTags({
|
||||
db_name: this.name,
|
||||
num_docs: Array.isArray(docIds) ? docIds.length : 1,
|
||||
})
|
||||
return this.db.sqlPurgeDocument(docIds)
|
||||
})
|
||||
}
|
||||
|
||||
sqlDiskCleanup(): Promise<void> {
|
||||
return tracer.trace("db.sqlDiskCleanup", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span.addTags({ db_name: this.name })
|
||||
return this.db.sqlDiskCleanup()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
@ -126,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",
|
||||
|
@ -225,6 +236,8 @@ const environment = {
|
|||
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 {
|
||||
|
|
|
@ -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({
|
||||
[FeatureFlag.DEFAULT_VALUES]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.AUTOMATION_BRANCHING]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.SQS]: Flag.boolean(true),
|
||||
[FeatureFlag.AI_CUSTOM_CONFIGS]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.ENRICHED_RELATIONSHIPS]: Flag.boolean(env.isDev()),
|
||||
[FeatureFlag.BUDIBASE_AI]: 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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,27 +1,63 @@
|
|||
<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
|
||||
let blockRefs = {}
|
||||
let treeEle
|
||||
let draggable
|
||||
|
||||
$: blocks = getBlocks(automation).filter(x => x.stepId !== ActionStepID.LOOP)
|
||||
$: isRowAction = sdk.automations.isRowAction(automation)
|
||||
// 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 = []
|
||||
|
@ -34,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
|
||||
|
@ -79,36 +122,39 @@
|
|||
</div>
|
||||
</div>
|
||||
{#if !isRowAction}
|
||||
<div class="setting-spacing">
|
||||
<div class="toggle-active setting-spacing">
|
||||
<Toggle
|
||||
text={automation.disabled ? "Paused" : "Activated"}
|
||||
on:change={automationStore.actions.toggleDisabled(
|
||||
automation._id,
|
||||
automation.disabled
|
||||
)}
|
||||
disabled={!$selectedAutomation?.definition?.trigger}
|
||||
disabled={!automation?.definition?.trigger}
|
||||
value={!automation.disabled}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
</div>
|
||||
{/each}
|
||||
</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"
|
||||
|
@ -125,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 {
|
||||
|
@ -166,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;
|
||||
|
@ -188,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,8 +1,8 @@
|
|||
<script>
|
||||
import {
|
||||
automationStore,
|
||||
selectedAutomation,
|
||||
permissions,
|
||||
selectedAutomation,
|
||||
tables,
|
||||
} from "stores/builder"
|
||||
import {
|
||||
|
@ -11,54 +11,115 @@
|
|||
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 = sdk.automations.isRowAction($selectedAutomation) && {
|
||||
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation?.data) && {
|
||||
title: "Automation trigger",
|
||||
tableName: $tables.list.find(
|
||||
x => x._id === $selectedAutomation.definition.trigger.inputs?.tableId
|
||||
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) {
|
||||
return
|
||||
|
@ -82,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() {
|
||||
|
@ -109,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 triggerInfo}
|
||||
<InlineAlert
|
||||
header={triggerInfo.title}
|
||||
message={`This trigger is tied to your "${triggerInfo.tableName}" 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>
|
||||
|
@ -255,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
|
||||
|
@ -29,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
|
||||
|
@ -63,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 || {})
|
||||
|
@ -83,7 +82,7 @@
|
|||
$: isError =
|
||||
!isTriggerValid(trigger) ||
|
||||
!(trigger.schema.outputs.required || []).every(
|
||||
required => $memoTestData?.[required] || required !== "row"
|
||||
required => testData?.[required] || required !== "row"
|
||||
)
|
||||
|
||||
function parseTestJSON(e) {
|
||||
|
@ -110,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)
|
||||
|
@ -149,7 +150,7 @@
|
|||
{#if selectedValues}
|
||||
<div class="tab-content-padding">
|
||||
<AutomationBlockSetup
|
||||
testData={$memoTestData}
|
||||
bind:testData
|
||||
{schemaProperties}
|
||||
isTestModal
|
||||
block={trigger}
|
||||
|
@ -159,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) {
|
||||
|
|
|
@ -113,7 +113,7 @@
|
|||
? "var(--spectrum-global-color-gray-600)"
|
||||
: "var(--spectrum-global-color-gray-900)"}
|
||||
text={automation.name}
|
||||
selected={automation._id === $selectedAutomation?._id}
|
||||
selected={automation._id === $selectedAutomation?.data?._id}
|
||||
hovering={automation._id === $contextMenuStore.id}
|
||||
on:click={() => automationStore.actions.select(automation._id)}
|
||||
selectedBy={$userSelectedResourceMap[automation._id]}
|
||||
|
|
|
@ -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,18 +57,18 @@
|
|||
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,
|
||||
|
@ -94,7 +91,6 @@
|
|||
let insertAtPos, getCaretPosition
|
||||
let stepLayouts = {}
|
||||
|
||||
$: memoEnvVariables.set($environment.variables)
|
||||
$: memoBlock.set(block)
|
||||
|
||||
$: filters = lookForFilters(schemaProperties)
|
||||
|
@ -107,13 +103,6 @@
|
|||
$: tempFilters = cloneDeep(filters)
|
||||
$: stepId = $memoBlock.stepId
|
||||
|
||||
$: automationBindings = getAvailableBindings(
|
||||
$memoBlock,
|
||||
$selectedAutomation?.definition
|
||||
)
|
||||
$: environmentBindings = buildEnvironmentBindings($memoEnvVariables)
|
||||
$: bindings = [...automationBindings, ...environmentBindings]
|
||||
|
||||
$: getInputData(testData, $memoBlock.inputs)
|
||||
$: tableId = inputData ? inputData.tableId : null
|
||||
$: table = tableId
|
||||
|
@ -146,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)
|
||||
|
@ -437,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,
|
||||
|
@ -529,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) {
|
||||
|
@ -566,7 +548,7 @@
|
|||
...newTestData,
|
||||
body: {
|
||||
...update,
|
||||
...$selectedAutomation.testData?.body,
|
||||
...automation.testData?.body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -574,6 +556,7 @@
|
|||
...newTestData,
|
||||
...request,
|
||||
}
|
||||
|
||||
await automationStore.actions.addTestDataToAutomation(newTestData)
|
||||
} else {
|
||||
const data = { schema, ...request }
|
||||
|
@ -585,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 []
|
||||
|
@ -865,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}>
|
||||
|
@ -890,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)}
|
||||
|
@ -912,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]}
|
||||
|
@ -1208,8 +996,9 @@
|
|||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
.field-width {
|
||||
width: 320px;
|
||||
|
||||
.label-container :global(label) {
|
||||
white-space: unset;
|
||||
}
|
||||
|
||||
.step-fields {
|
||||
|
@ -1221,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>
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
let schemaURL
|
||||
let propCount = 0
|
||||
|
||||
$: automation = $selectedAutomation
|
||||
$: automation = $selectedAutomation?.data
|
||||
|
||||
onMount(async () => {
|
||||
if (!automation?.definition?.trigger?.inputs.schemaUrl) {
|
||||
|
|
|
@ -11,7 +11,6 @@
|
|||
export let disabledPermissions = []
|
||||
export let columns
|
||||
export let fromRelationshipField
|
||||
export let canSetRelationshipSchemas
|
||||
|
||||
const { datasource, dispatch } = getContext("grid")
|
||||
|
||||
|
@ -129,6 +128,8 @@
|
|||
}
|
||||
})
|
||||
|
||||
$: hasLinkColumns = columns.some(c => c.schema.type === FieldType.LINK)
|
||||
|
||||
async function toggleColumn(column, permission) {
|
||||
const visible = permission !== FieldPermissions.HIDDEN
|
||||
const readonly = permission === FieldPermissions.READONLY
|
||||
|
@ -184,7 +185,7 @@
|
|||
value={columnToPermissionOptions(column)}
|
||||
options={column.options}
|
||||
/>
|
||||
{#if canSetRelationshipSchemas && column.schema.type === FieldType.LINK && columnToPermissionOptions(column) !== FieldPermissions.HIDDEN}
|
||||
{#if column.schema.type === FieldType.LINK && columnToPermissionOptions(column) !== FieldPermissions.HIDDEN}
|
||||
<div class="relationship-columns">
|
||||
<ActionButton
|
||||
on:click={e => {
|
||||
|
@ -203,7 +204,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if canSetRelationshipSchemas}
|
||||
{#if hasLinkColumns}
|
||||
<Popover
|
||||
on:close={() => (relationshipFieldName = null)}
|
||||
open={relationshipFieldName}
|
||||
|
|
|
@ -10,8 +10,6 @@
|
|||
import { getContext } from "svelte"
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import ColumnsSettingContent from "./ColumnsSettingContent.svelte"
|
||||
import { isEnabled } from "helpers/featureFlags"
|
||||
import { FeatureFlag } from "@budibase/types"
|
||||
import DetailPopover from "components/common/DetailPopover.svelte"
|
||||
|
||||
const { tableColumns, datasource } = getContext("grid")
|
||||
|
@ -46,9 +44,5 @@
|
|||
{text}
|
||||
</ActionButton>
|
||||
</svelte:fragment>
|
||||
<ColumnsSettingContent
|
||||
columns={$tableColumns}
|
||||
canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)}
|
||||
{permissions}
|
||||
/>
|
||||
<ColumnsSettingContent columns={$tableColumns} {permissions} />
|
||||
</DetailPopover>
|
||||
|
|
|
@ -371,6 +371,7 @@
|
|||
delete editableColumn.relationshipType
|
||||
delete editableColumn.formulaType
|
||||
delete editableColumn.constraints
|
||||
delete editableColumn.responseType
|
||||
|
||||
// Add in defaults and initial definition
|
||||
const definition = fieldDefinitions[type?.toUpperCase()]
|
||||
|
@ -386,6 +387,7 @@
|
|||
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
|
||||
} else if (editableColumn.type === FieldType.FORMULA) {
|
||||
editableColumn.formulaType = "dynamic"
|
||||
editableColumn.responseType = field.responseType || FIELDS.STRING.type
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -767,6 +769,25 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="split-label">
|
||||
<div class="label-length">
|
||||
<Label size="M">Response Type</Label>
|
||||
</div>
|
||||
<div class="input-length">
|
||||
<Select
|
||||
bind:value={editableColumn.responseType}
|
||||
options={[
|
||||
FIELDS.STRING,
|
||||
FIELDS.NUMBER,
|
||||
FIELDS.BOOLEAN,
|
||||
FIELDS.DATETIME,
|
||||
]}
|
||||
getOptionLabel={option => option.name}
|
||||
getOptionValue={option => option.type}
|
||||
tooltip="Formulas by default will return a string - however if you need another type the response can be coerced."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-label">
|
||||
<div class="label-length">
|
||||
<Label size="M">Formula</Label>
|
||||
|
|
|
@ -84,8 +84,8 @@
|
|||
on:mouseleave
|
||||
on:click={onClick}
|
||||
on:contextmenu
|
||||
ondragover="return false"
|
||||
ondragenter="return false"
|
||||
on:dragover={e => e.preventDefault()}
|
||||
on:dragenter={e => e.preventDefault()}
|
||||
{id}
|
||||
{style}
|
||||
{draggable}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
<script>
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { Icon, ActionButton } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import { isBuilderInputFocused } from "helpers"
|
||||
|
||||
export let store
|
||||
export let showButtonGroup = false
|
||||
|
||||
const handleKeyPress = e => {
|
||||
if (!(e.ctrlKey || e.metaKey)) {
|
||||
|
@ -32,19 +33,36 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<div class="undo-redo">
|
||||
<Icon
|
||||
name="Undo"
|
||||
hoverable
|
||||
on:click={store.undo}
|
||||
disabled={!$store.canUndo}
|
||||
/>
|
||||
<Icon
|
||||
name="Redo"
|
||||
hoverable
|
||||
on:click={store.redo}
|
||||
disabled={!$store.canRedo}
|
||||
/>
|
||||
<div class="undo-redo" class:buttons={showButtonGroup}>
|
||||
{#if showButtonGroup}
|
||||
<div class="group">
|
||||
<ActionButton
|
||||
icon="Undo"
|
||||
quiet
|
||||
on:click={store.undo}
|
||||
disabled={!$store.canUndo}
|
||||
/>
|
||||
<ActionButton
|
||||
icon="Redo"
|
||||
quiet
|
||||
on:click={store.redo}
|
||||
disabled={!$store.canRedo}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Icon
|
||||
name="Undo"
|
||||
hoverable
|
||||
on:click={store.undo}
|
||||
disabled={!$store.canUndo}
|
||||
/>
|
||||
<Icon
|
||||
name="Redo"
|
||||
hoverable
|
||||
on:click={store.redo}
|
||||
disabled={!$store.canRedo}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -60,4 +78,36 @@
|
|||
.undo-redo :global(svg) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.undo-redo.buttons :global(svg) {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.undo-redo.buttons {
|
||||
padding-right: 0px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.group :global(.spectrum-Button),
|
||||
.group :global(.spectrum-ActionButton) {
|
||||
background: var(--spectrum-global-color-gray-200) !important;
|
||||
}
|
||||
.group :global(.spectrum-Button:hover),
|
||||
.group :global(.spectrum-ActionButton:hover) {
|
||||
background: var(--spectrum-global-color-gray-300) !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -12,8 +12,10 @@
|
|||
export let bindings = []
|
||||
export let panel = ClientBindingPanel
|
||||
export let allowBindings = true
|
||||
export let allowOnEmpty
|
||||
export let datasource
|
||||
export let showFilterEmptyDropdown
|
||||
export let builderType
|
||||
export let docsURL
|
||||
</script>
|
||||
|
||||
<CoreFilterBuilder
|
||||
|
@ -26,7 +28,9 @@
|
|||
{schemaFields}
|
||||
{datasource}
|
||||
{allowBindings}
|
||||
{showFilterEmptyDropdown}
|
||||
{bindings}
|
||||
{allowOnEmpty}
|
||||
{builderType}
|
||||
{docsURL}
|
||||
on:change
|
||||
/>
|
||||
|
|
|
@ -614,6 +614,40 @@ const getDeviceBindings = () => {
|
|||
return bindings
|
||||
}
|
||||
|
||||
export const getSettingBindings = () => {
|
||||
let bindings = []
|
||||
const safeSetting = makePropSafe("settings")
|
||||
|
||||
bindings = [
|
||||
{
|
||||
type: "context",
|
||||
runtimeBinding: `${safeSetting}.${makePropSafe("url")}`,
|
||||
readableBinding: `Settings.url`,
|
||||
category: "Settings",
|
||||
icon: "Settings",
|
||||
display: { type: "string", name: "url" },
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
runtimeBinding: `${safeSetting}.${makePropSafe("logo")}`,
|
||||
readableBinding: `Settings.logo`,
|
||||
category: "Settings",
|
||||
icon: "Settings",
|
||||
display: { type: "string", name: "logo" },
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
runtimeBinding: `${safeSetting}.${makePropSafe("company")}`,
|
||||
readableBinding: `Settings.company`,
|
||||
category: "Settings",
|
||||
icon: "Settings",
|
||||
display: { type: "string", name: "company" },
|
||||
},
|
||||
]
|
||||
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all selected rows bindings for tables in the current asset.
|
||||
* TODO: remove in future because we don't need a separate store for this
|
||||
|
@ -1469,3 +1503,31 @@ export const updateReferencesInObject = ({
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate references
|
||||
// Switch all bindings to reference their ids
|
||||
export const migrateReferencesInObject = ({ obj, label = "steps", steps }) => {
|
||||
const stepIndexRegex = new RegExp(`{{\\s*${label}\\.(\\d+)\\.`, "g")
|
||||
const updateActionStep = (str, index, replaceWith) =>
|
||||
str.replace(`{{ ${label}.${index}.`, `{{ ${label}.${replaceWith}.`)
|
||||
|
||||
for (const key in obj) {
|
||||
if (typeof obj[key] === "string") {
|
||||
let matches
|
||||
while ((matches = stepIndexRegex.exec(obj[key])) !== null) {
|
||||
const referencedStep = parseInt(matches[1])
|
||||
|
||||
obj[key] = updateActionStep(
|
||||
obj[key],
|
||||
referencedStep,
|
||||
steps[referencedStep]?.id
|
||||
)
|
||||
}
|
||||
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
migrateReferencesInObject({
|
||||
obj: obj[key],
|
||||
steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
selectedAutomation,
|
||||
} from "stores/builder"
|
||||
|
||||
$: automationId = $selectedAutomation?._id
|
||||
$: automationId = $selectedAutomation?.data?._id
|
||||
$: builderStore.selectResource(automationId)
|
||||
|
||||
// Keep URL and state in sync for selected screen ID
|
||||
|
@ -68,7 +68,7 @@
|
|||
|
||||
{#if $automationStore.showTestPanel}
|
||||
<div class="setup">
|
||||
<TestPanel automation={$selectedAutomation} />
|
||||
<TestPanel automation={$selectedAutomation.data} />
|
||||
</div>
|
||||
{/if}
|
||||
<Modal bind:this={modal}>
|
||||
|
|
|
@ -68,8 +68,8 @@
|
|||
on:scroll
|
||||
bind:this={scrollRef}
|
||||
on:drop={onDrop}
|
||||
ondragover="return false"
|
||||
ondragenter="return false"
|
||||
on:dragover={e => e.preventDefault()}
|
||||
on:dragenter={e => e.preventDefault()}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
@ -56,7 +56,7 @@
|
|||
{/if}
|
||||
{#key history}
|
||||
<div class="history">
|
||||
<TestDisplay testResults={history} width="320px" />
|
||||
<TestDisplay testResults={history} />
|
||||
</div>
|
||||
{/key}
|
||||
</Layout>
|
||||
|
@ -65,6 +65,14 @@
|
|||
{/if}
|
||||
|
||||
<style>
|
||||
.history :global(.block) {
|
||||
min-width: unset;
|
||||
}
|
||||
.history :global(> .container) {
|
||||
max-width: 320px;
|
||||
width: 320px;
|
||||
padding: 0px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: var(--spacing-s);
|
||||
|
@ -76,7 +84,4 @@
|
|||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.history {
|
||||
margin: 0 -30px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<script>
|
||||
import { redirect } from "@roxi/routify"
|
||||
import { licensing, featureFlags } from "stores/portal"
|
||||
import { featureFlags } from "stores/portal"
|
||||
|
||||
if ($featureFlags.AI_CUSTOM_CONFIGS && $licensing.customAIConfigsEnabled) {
|
||||
if ($featureFlags.AI_CUSTOM_CONFIGS) {
|
||||
$redirect("./ai")
|
||||
} else {
|
||||
$redirect("./auth")
|
||||
|
|
|
@ -4,12 +4,10 @@
|
|||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Button,
|
||||
Divider,
|
||||
notifications,
|
||||
Label,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Link,
|
||||
} from "@budibase/bbui"
|
||||
import { API } from "api"
|
||||
import { auth, admin } from "stores/portal"
|
||||
|
@ -21,8 +19,6 @@
|
|||
let githubVersion
|
||||
let githubPublishedDate
|
||||
let githubPublishedTime
|
||||
let needsUpdate = true
|
||||
let updateModal
|
||||
|
||||
// Only admins allowed here
|
||||
$: {
|
||||
|
@ -31,21 +27,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function updateBudibase() {
|
||||
try {
|
||||
notifications.info("Updating budibase..")
|
||||
await fetch("/v1/update", {
|
||||
headers: {
|
||||
Authorization: "Bearer budibase",
|
||||
},
|
||||
})
|
||||
notifications.success("Your budibase installation is up to date.")
|
||||
getVersion()
|
||||
} catch (err) {
|
||||
notifications.error(`Error installing budibase update ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function getVersion() {
|
||||
try {
|
||||
version = await API.getBudibaseVersion()
|
||||
|
@ -69,13 +50,6 @@
|
|||
githubPublishedDate = new Date(githubResponse.published_at)
|
||||
githubPublishedTime = githubPublishedDate.toLocaleTimeString()
|
||||
githubPublishedDate = githubPublishedDate.toLocaleDateString()
|
||||
|
||||
//Does Budibase need to be updated?
|
||||
if (githubVersion === version) {
|
||||
needsUpdate = false
|
||||
} else {
|
||||
needsUpdate = true
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.error("Error getting the latest Budibase version")
|
||||
githubVersion = null
|
||||
|
@ -115,23 +89,15 @@
|
|||
>
|
||||
</Layout>
|
||||
<Divider />
|
||||
<div>
|
||||
<Button cta on:click={updateModal.show} disabled={!needsUpdate}
|
||||
>Update Budibase</Button
|
||||
<Layout noPadding gap="XS">
|
||||
<Heading>Updating Budibase</Heading>
|
||||
<Body
|
||||
>To update your self-host installation, follow the docs found <Link
|
||||
size="L"
|
||||
href="https://docs.budibase.com/docs/updating-budibase">here.</Link
|
||||
></Body
|
||||
>
|
||||
<Modal bind:this={updateModal}>
|
||||
<ModalContent
|
||||
title="Update Budibase"
|
||||
confirmText="Update"
|
||||
onConfirm={updateBudibase}
|
||||
>
|
||||
<span
|
||||
>Are you sure you want to update your budibase installation to the
|
||||
latest version?</span
|
||||
>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
</Layout>
|
||||
{/if}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -33,8 +33,7 @@
|
|||
"sanitize-html": "^2.13.0",
|
||||
"screenfull": "^6.0.1",
|
||||
"shortid": "^2.2.15",
|
||||
"svelte-spa-router": "^4.0.1",
|
||||
"atrament": "^4.3.0"
|
||||
"svelte-spa-router": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^5.1.0",
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { makePropSafe as safe } from "@budibase/string-templates"
|
||||
import { API } from "../api/index.js"
|
||||
import { UILogicalOperator } from "@budibase/types"
|
||||
import { OnEmptyFilter } from "@budibase/frontend-core/src/constants.js"
|
||||
|
||||
// Map of data types to component types for search fields inside blocks
|
||||
const schemaComponentMap = {
|
||||
|
@ -60,7 +62,11 @@ export const enrichSearchColumns = async (searchColumns, schema) => {
|
|||
* @param formId the ID of the form containing the search fields
|
||||
*/
|
||||
export const enrichFilter = (filter, columns, formId) => {
|
||||
let enrichedFilter = [...(filter || [])]
|
||||
if (!columns?.length) {
|
||||
return filter
|
||||
}
|
||||
|
||||
let newFilters = []
|
||||
columns?.forEach(column => {
|
||||
const safePath = column.name.split(".").map(safe).join(".")
|
||||
const stringType = column.type === "string" || column.type === "formula"
|
||||
|
@ -69,7 +75,7 @@ export const enrichFilter = (filter, columns, formId) => {
|
|||
|
||||
// For dates, use a range of the entire day selected
|
||||
if (dateType) {
|
||||
enrichedFilter.push({
|
||||
newFilters.push({
|
||||
field: column.name,
|
||||
type: column.type,
|
||||
operator: "rangeLow",
|
||||
|
@ -79,7 +85,7 @@ export const enrichFilter = (filter, columns, formId) => {
|
|||
const format = "YYYY-MM-DDTHH:mm:ss.SSSZ"
|
||||
let hbs = `{{ date (add (date ${binding} "x") 86399999) "${format}" }}`
|
||||
hbs = `{{#if ${binding} }}${hbs}{{/if}}`
|
||||
enrichedFilter.push({
|
||||
newFilters.push({
|
||||
field: column.name,
|
||||
type: column.type,
|
||||
operator: "rangeHigh",
|
||||
|
@ -90,7 +96,7 @@ export const enrichFilter = (filter, columns, formId) => {
|
|||
|
||||
// For other fields, do an exact match
|
||||
else {
|
||||
enrichedFilter.push({
|
||||
newFilters.push({
|
||||
field: column.name,
|
||||
type: column.type,
|
||||
operator: stringType ? "string" : "equal",
|
||||
|
@ -99,5 +105,16 @@ export const enrichFilter = (filter, columns, formId) => {
|
|||
})
|
||||
}
|
||||
})
|
||||
return enrichedFilter
|
||||
|
||||
return {
|
||||
logicalOperator: UILogicalOperator.ALL,
|
||||
onEmptyFilter: OnEmptyFilter.RETURN_ALL,
|
||||
groups: [
|
||||
...(filter?.groups || []),
|
||||
{
|
||||
logicalOperator: UILogicalOperator.ALL,
|
||||
filters: newFilters,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
<script>
|
||||
import { Input, Icon, Drawer, Button } from "@budibase/bbui"
|
||||
import { isJSBinding } from "@budibase/string-templates"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let filter
|
||||
export let disabled = false
|
||||
export let bindings = []
|
||||
export let panel
|
||||
export let drawerTitle
|
||||
export let toReadable
|
||||
export let toRuntime
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let bindingDrawer
|
||||
let fieldValue
|
||||
|
||||
$: fieldValue = filter?.field
|
||||
$: readableValue = toReadable ? toReadable(bindings, fieldValue) : fieldValue
|
||||
$: drawerValue = toDrawerValue(fieldValue)
|
||||
$: isJS = isJSBinding(fieldValue)
|
||||
|
||||
const drawerOnChange = e => {
|
||||
drawerValue = e.detail
|
||||
}
|
||||
|
||||
const onChange = e => {
|
||||
fieldValue = e.detail
|
||||
dispatch("change", {
|
||||
field: toRuntime ? toRuntime(bindings, fieldValue) : fieldValue,
|
||||
})
|
||||
}
|
||||
|
||||
const onConfirmBinding = () => {
|
||||
dispatch("change", {
|
||||
field: toRuntime ? toRuntime(bindings, drawerValue) : drawerValue,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts arrays into strings. The CodeEditor expects a string or encoded JS
|
||||
*
|
||||
* @param{string} fieldValue
|
||||
*/
|
||||
const toDrawerValue = fieldValue => {
|
||||
return Array.isArray(fieldValue) ? fieldValue.join(",") : readableValue
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<Drawer
|
||||
on:drawerHide
|
||||
on:drawerShow
|
||||
bind:this={bindingDrawer}
|
||||
title={drawerTitle || ""}
|
||||
forceModal
|
||||
>
|
||||
<Button
|
||||
cta
|
||||
slot="buttons"
|
||||
on:click={() => {
|
||||
onConfirmBinding()
|
||||
bindingDrawer.hide()
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
<svelte:component
|
||||
this={panel}
|
||||
slot="body"
|
||||
value={drawerValue}
|
||||
allowJS
|
||||
allowHelpers
|
||||
allowHBS
|
||||
on:change={drawerOnChange}
|
||||
{bindings}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<div class="field-wrap" class:bindings={true}>
|
||||
<div class="field">
|
||||
<Input
|
||||
disabled={filter.noValue}
|
||||
readonly={isJS}
|
||||
value={isJS ? "(JavaScript function)" : readableValue}
|
||||
on:change={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="binding-control">
|
||||
{#if !disabled}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="icon binding"
|
||||
on:click={() => {
|
||||
bindingDrawer.show()
|
||||
}}
|
||||
>
|
||||
<Icon size="S" name="FlashOn" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.field-wrap {
|
||||
display: flex;
|
||||
}
|
||||
.field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-wrap.bindings .field :global(.spectrum-Form-itemField),
|
||||
.field-wrap.bindings .field :global(input),
|
||||
.field-wrap.bindings .field :global(.spectrum-Picker) {
|
||||
border-top-right-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}
|
||||
|
||||
.field-wrap.bindings
|
||||
.field
|
||||
:global(.spectrum-InputGroup.spectrum-Datepicker) {
|
||||
min-width: unset;
|
||||
border-radius: 0px;
|
||||
}
|
||||
|
||||
.field-wrap.bindings
|
||||
.field
|
||||
:global(
|
||||
.spectrum-InputGroup.spectrum-Datepicker
|
||||
.spectrum-Textfield-input.spectrum-InputGroup-input
|
||||
) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.binding-control .icon {
|
||||
border: 1px solid
|
||||
var(
|
||||
--spectrum-textfield-m-border-color,
|
||||
var(--spectrum-alias-border-color)
|
||||
);
|
||||
border-left: 0px;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
box-sizing: border-box;
|
||||
width: 31px;
|
||||
color: var(--spectrum-alias-text-color);
|
||||
background-color: var(--spectrum-global-color-gray-75);
|
||||
transition: background-color
|
||||
var(--spectrum-global-animation-duration-100, 130ms),
|
||||
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
|
||||
border-color var(--spectrum-global-animation-duration-100, 130ms);
|
||||
height: calc(var(--spectrum-alias-item-height-m));
|
||||
}
|
||||
.binding-control .icon.binding {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.binding-control .icon:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
border-color: var(--spectrum-alias-border-color-hover);
|
||||
color: var(--spectrum-alias-text-color-hover);
|
||||
}
|
||||
|
||||
.binding-control .icon.binding:hover {
|
||||
color: var(--yellow);
|
||||
}
|
||||
</style>
|
|
@ -16,6 +16,7 @@
|
|||
import { QueryUtils, Constants } from "@budibase/frontend-core"
|
||||
import { getContext, createEventDispatcher } from "svelte"
|
||||
import FilterField from "./FilterField.svelte"
|
||||
import ConditionField from "./ConditionField.svelte"
|
||||
import { utils } from "@budibase/shared-core"
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
@ -33,8 +34,10 @@
|
|||
export let datasource
|
||||
export let behaviourFilters = false
|
||||
export let allowBindings = false
|
||||
export let allowOnEmpty = true
|
||||
export let builderType = "filter"
|
||||
export let docsURL = "https://docs.budibase.com/docs/searchfilter-data"
|
||||
|
||||
// Review
|
||||
export let bindings
|
||||
export let panel
|
||||
export let toReadable
|
||||
|
@ -53,6 +56,10 @@
|
|||
schemaFields = [...schemaFields, { name: "_id", type: "string" }]
|
||||
}
|
||||
}
|
||||
$: prefix =
|
||||
builderType === "filter"
|
||||
? "Show data which matches"
|
||||
: "Run branch when matching"
|
||||
|
||||
// We still may need to migrate this even though the backend does it automatically now
|
||||
// for query definitions. This is because we might be editing saved filter definitions
|
||||
|
@ -103,6 +110,10 @@
|
|||
}
|
||||
|
||||
const getValidOperatorsForType = filter => {
|
||||
if (builderType === "condition") {
|
||||
return [OperatorOptions.Equals, OperatorOptions.NotEquals]
|
||||
}
|
||||
|
||||
if (!filter?.field && !filter?.name) {
|
||||
return []
|
||||
}
|
||||
|
@ -222,6 +233,9 @@
|
|||
} else if (addFilter) {
|
||||
targetGroup.filters.push({
|
||||
valueType: FilterValueType.VALUE,
|
||||
...(builderType === "condition"
|
||||
? { operator: OperatorOptions.Equals.value, type: FieldType.STRING }
|
||||
: {}),
|
||||
})
|
||||
} else if (group) {
|
||||
editable.groups[groupIdx] = {
|
||||
|
@ -242,6 +256,11 @@
|
|||
filters: [
|
||||
{
|
||||
valueType: FilterValueType.VALUE,
|
||||
...(builderType === "condition"
|
||||
? {
|
||||
operator: OperatorOptions.Equals.value,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
@ -271,7 +290,7 @@
|
|||
|
||||
{#if editableFilters?.groups?.length}
|
||||
<div class="global-filter-header">
|
||||
<span>Show data which matches</span>
|
||||
<span>{prefix}</span>
|
||||
<span class="operator-picker">
|
||||
<Select
|
||||
value={editableFilters?.logicalOperator}
|
||||
|
@ -286,7 +305,7 @@
|
|||
placeholder={false}
|
||||
/>
|
||||
</span>
|
||||
<span>of the following filter groups:</span>
|
||||
<span>of the following {builderType} groups:</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if editableFilters?.groups?.length}
|
||||
|
@ -315,7 +334,7 @@
|
|||
placeholder={false}
|
||||
/>
|
||||
</span>
|
||||
<span>of the following filters are matched:</span>
|
||||
<span>of the following {builderType}s are matched:</span>
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<Icon
|
||||
|
@ -346,20 +365,38 @@
|
|||
<div class="filters">
|
||||
{#each group.filters as filter, filterIdx}
|
||||
<div class="filter">
|
||||
<Select
|
||||
value={filter.field}
|
||||
options={fieldOptions}
|
||||
on:change={e => {
|
||||
const updated = { ...filter, field: e.detail }
|
||||
onFieldChange(updated)
|
||||
onFilterFieldUpdate(updated, groupIdx, filterIdx)
|
||||
}}
|
||||
placeholder="Column"
|
||||
/>
|
||||
|
||||
{#if builderType === "filter"}
|
||||
<Select
|
||||
value={filter.field}
|
||||
options={fieldOptions}
|
||||
on:change={e => {
|
||||
const updated = { ...filter, field: e.detail }
|
||||
onFieldChange(updated)
|
||||
onFilterFieldUpdate(updated, groupIdx, filterIdx)
|
||||
}}
|
||||
placeholder="Column"
|
||||
/>
|
||||
{:else}
|
||||
<ConditionField
|
||||
placeholder="Value"
|
||||
{filter}
|
||||
drawerTitle={"Edit Binding"}
|
||||
{bindings}
|
||||
{panel}
|
||||
{toReadable}
|
||||
{toRuntime}
|
||||
on:change={e => {
|
||||
const updated = {
|
||||
...filter,
|
||||
field: e.detail.field,
|
||||
}
|
||||
onFilterFieldUpdate(updated, groupIdx, filterIdx)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<Select
|
||||
value={filter.operator}
|
||||
disabled={!filter.field}
|
||||
disabled={!filter.field && builderType === "filter"}
|
||||
options={getValidOperatorsForType(filter)}
|
||||
on:change={e => {
|
||||
const updated = { ...filter, operator: e.detail }
|
||||
|
@ -368,11 +405,18 @@
|
|||
}}
|
||||
placeholder={false}
|
||||
/>
|
||||
|
||||
<FilterField
|
||||
placeholder="Value"
|
||||
drawerTitle={builderType === "condition"
|
||||
? "Edit binding"
|
||||
: null}
|
||||
{allowBindings}
|
||||
{filter}
|
||||
filter={{
|
||||
...filter,
|
||||
...(builderType === "condition"
|
||||
? { type: FieldType.STRING }
|
||||
: {}),
|
||||
}}
|
||||
{schemaFields}
|
||||
{bindings}
|
||||
{panel}
|
||||
|
@ -408,7 +452,7 @@
|
|||
|
||||
<div class="filters-footer">
|
||||
<Layout noPadding>
|
||||
{#if behaviourFilters && editableFilters?.groups?.length}
|
||||
{#if behaviourFilters && allowOnEmpty && editableFilters?.groups?.length}
|
||||
<div class="empty-filter">
|
||||
<span>Return</span>
|
||||
<span class="empty-filter-picker">
|
||||
|
@ -425,7 +469,7 @@
|
|||
placeholder={false}
|
||||
/>
|
||||
</span>
|
||||
<span>when all filters are empty</span>
|
||||
<span>when all {builderType}s are empty</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="add-group">
|
||||
|
@ -439,17 +483,16 @@
|
|||
})
|
||||
}}
|
||||
>
|
||||
Add filter group
|
||||
Add {builderType} group
|
||||
</Button>
|
||||
<a
|
||||
href="https://docs.budibase.com/docs/searchfilter-data"
|
||||
target="_blank"
|
||||
>
|
||||
<Icon
|
||||
name="HelpOutline"
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
/>
|
||||
</a>
|
||||
{#if docsURL}
|
||||
<a href={docsURL} target="_blank">
|
||||
<Icon
|
||||
name="HelpOutline"
|
||||
color="var(--spectrum-global-color-gray-600)"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
export let allowBindings = false
|
||||
export let schemaFields
|
||||
export let panel
|
||||
export let drawerTitle
|
||||
export let toReadable
|
||||
export let toRuntime
|
||||
|
||||
|
@ -28,7 +29,6 @@
|
|||
const { OperatorOptions, FilterValueType } = Constants
|
||||
|
||||
let bindingDrawer
|
||||
let fieldValue
|
||||
|
||||
$: fieldValue = filter?.value
|
||||
$: readableValue = toReadable ? toReadable(bindings, fieldValue) : fieldValue
|
||||
|
@ -133,7 +133,7 @@
|
|||
on:drawerHide
|
||||
on:drawerShow
|
||||
bind:this={bindingDrawer}
|
||||
title={filter.field}
|
||||
title={drawerTitle || filter.field}
|
||||
forceModal
|
||||
>
|
||||
<Button
|
||||
|
@ -168,7 +168,7 @@
|
|||
{#if filter.valueType === FilterValueType.BINDING}
|
||||
<Input
|
||||
disabled={filter.noValue}
|
||||
readonly={true}
|
||||
readonly={isJS}
|
||||
value={isJS ? "(JavaScript function)" : readableValue}
|
||||
on:change={onChange}
|
||||
/>
|
||||
|
@ -231,7 +231,7 @@
|
|||
|
||||
<div class="binding-control">
|
||||
<!-- needs field, operator -->
|
||||
{#if !disabled && allowBindings && filter.field && !filter.noValue}
|
||||
{#if !disabled && allowBindings && !filter.noValue}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
|
|
|
@ -1,5 +1,21 @@
|
|||
<script>
|
||||
import TextCell from "./TextCell.svelte"
|
||||
import DateCell from "./DateCell.svelte"
|
||||
import NumberCell from "./NumberCell.svelte"
|
||||
import BooleanCell from "./BooleanCell.svelte"
|
||||
import { FieldType } from "@budibase/types"
|
||||
|
||||
export let schema
|
||||
|
||||
$: responseType = schema.responseType
|
||||
</script>
|
||||
|
||||
<TextCell {...$$props} readonly />
|
||||
{#if responseType === FieldType.NUMBER}
|
||||
<NumberCell {...$$props} readonly />
|
||||
{:else if responseType === FieldType.BOOLEAN}
|
||||
<BooleanCell {...$$props} readonly />
|
||||
{:else if responseType === FieldType.DATETIME}
|
||||
<DateCell {...$$props} readonly />
|
||||
{:else}
|
||||
<TextCell {...$$props} readonly />
|
||||
{/if}
|
||||
|
|
|
@ -53,6 +53,7 @@
|
|||
on:close={close}
|
||||
maxHeight={null}
|
||||
resizable
|
||||
minWidth={360}
|
||||
>
|
||||
<div class="content">
|
||||
<slot />
|
||||
|
@ -80,7 +81,6 @@
|
|||
}
|
||||
|
||||
.content {
|
||||
width: 300px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -5,6 +5,7 @@ export default class NestedProviderFetch extends DataFetch {
|
|||
// Nested providers should already have exposed their own schema
|
||||
return {
|
||||
schema: datasource?.value?.schema,
|
||||
primaryDisplay: datasource?.value?.primaryDisplay,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 2ab8536b6005576684810d774f1ac22239218546
|
||||
Subproject commit 25dd40ee12b048307b558ebcedb36548d6e042cd
|
|
@ -13,6 +13,7 @@
|
|||
"build": "node ./scripts/build.js",
|
||||
"postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/",
|
||||
"check:types": "tsc -p tsconfig.json --noEmit --paths null --target es2020",
|
||||
"check:dependencies": "node ../../scripts/depcheck.js",
|
||||
"build:isolated-vm-lib:snippets": "esbuild --minify --bundle src/jsRunner/bundles/snippets.ts --outfile=src/jsRunner/bundles/snippets.ivm.bundle.js --platform=node --format=iife --global-name=snippets",
|
||||
"build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers",
|
||||
"build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson",
|
||||
|
@ -49,9 +50,11 @@
|
|||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "10.0.3",
|
||||
"@azure/msal-node": "^2.5.1",
|
||||
"@budibase/backend-core": "0.0.0",
|
||||
"@budibase/client": "0.0.0",
|
||||
"@budibase/frontend-core": "0.0.0",
|
||||
"@budibase/nano": "10.1.5",
|
||||
"@budibase/pro": "0.0.0",
|
||||
"@budibase/shared-core": "0.0.0",
|
||||
"@budibase/string-templates": "0.0.0",
|
||||
|
@ -60,15 +63,17 @@
|
|||
"@bull-board/koa": "5.10.2",
|
||||
"@elastic/elasticsearch": "7.10.0",
|
||||
"@google-cloud/firestore": "7.8.0",
|
||||
"@koa/router": "8.0.8",
|
||||
"@koa/cors": "5.0.0",
|
||||
"@koa/router": "13.1.0",
|
||||
"@socket.io/redis-adapter": "^8.2.1",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"airtable": "0.12.2",
|
||||
"arangojs": "7.2.0",
|
||||
"archiver": "7.0.1",
|
||||
"aws-sdk": "2.1030.0",
|
||||
"aws-sdk": "2.1692.0",
|
||||
"bcrypt": "5.1.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"bson": "^6.9.0",
|
||||
"buffer": "6.0.3",
|
||||
"bull": "4.10.1",
|
||||
"chokidar": "3.5.3",
|
||||
|
@ -76,17 +81,20 @@
|
|||
"cookies": "0.8.0",
|
||||
"csvtojson": "2.0.10",
|
||||
"curlconverter": "3.21.0",
|
||||
"dd-trace": "5.2.0",
|
||||
"dayjs": "^1.10.8",
|
||||
"dd-trace": "5.26.0",
|
||||
"dotenv": "8.2.0",
|
||||
"form-data": "4.0.0",
|
||||
"global-agent": "3.0.0",
|
||||
"google-auth-library": "^8.0.1",
|
||||
"google-spreadsheet": "npm:@budibase/google-spreadsheet@4.1.5",
|
||||
"ioredis": "5.3.2",
|
||||
"isolated-vm": "^4.7.2",
|
||||
"jimp": "0.22.12",
|
||||
"jimp": "1.1.4",
|
||||
"joi": "17.6.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"jsonschema": "1.4.0",
|
||||
"jsonwebtoken": "9.0.2",
|
||||
"knex": "2.4.2",
|
||||
"koa": "2.13.4",
|
||||
"koa-body": "4.2.0",
|
||||
|
@ -97,7 +105,7 @@
|
|||
"lodash": "4.17.21",
|
||||
"memorystream": "0.3.1",
|
||||
"mongodb": "6.7.0",
|
||||
"mssql": "10.0.1",
|
||||
"mssql": "11.0.1",
|
||||
"mysql2": "3.9.8",
|
||||
"node-fetch": "2.6.7",
|
||||
"object-sizeof": "2.6.1",
|
||||
|
@ -105,24 +113,28 @@
|
|||
"openapi-types": "9.3.1",
|
||||
"oracledb": "6.5.1",
|
||||
"pg": "8.10.0",
|
||||
"pouchdb": "7.3.0",
|
||||
"pouchdb": "9.0.0",
|
||||
"pouchdb-all-dbs": "1.1.1",
|
||||
"pouchdb-find": "7.2.2",
|
||||
"pouchdb-find": "9.0.0",
|
||||
"redis": "4",
|
||||
"semver": "^7.5.4",
|
||||
"serialize-error": "^7.0.1",
|
||||
"server-destroy": "1.0.1",
|
||||
"snowflake-promise": "^4.5.0",
|
||||
"socket.io": "4.7.5",
|
||||
"snowflake-sdk": "^1.15.0",
|
||||
"socket.io": "4.8.1",
|
||||
"svelte": "^4.2.10",
|
||||
"tar": "6.2.1",
|
||||
"tmp": "0.2.3",
|
||||
"to-json-schema": "0.2.5",
|
||||
"uuid": "^8.3.2",
|
||||
"validate.js": "0.13.1",
|
||||
"worker-farm": "1.7.0",
|
||||
"xml2js": "0.5.0"
|
||||
"xml2js": "0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.5",
|
||||
"@babel/preset-env": "7.16.11",
|
||||
"@jest/types": "^29.6.3",
|
||||
"@swc/core": "1.3.71",
|
||||
"@swc/jest": "0.2.27",
|
||||
"@types/archiver": "6.0.2",
|
||||
|
@ -130,19 +142,24 @@
|
|||
"@types/jest": "29.5.5",
|
||||
"@types/koa": "2.13.4",
|
||||
"@types/koa-send": "^4.1.6",
|
||||
"@types/koa__router": "8.0.8",
|
||||
"@types/koa__cors": "5.0.0",
|
||||
"@types/koa__router": "12.0.4",
|
||||
"@types/lodash": "4.14.200",
|
||||
"@types/mssql": "9.1.4",
|
||||
"@types/mssql": "9.1.5",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/oracledb": "6.5.1",
|
||||
"@types/pg": "8.6.6",
|
||||
"@types/pouchdb": "6.4.2",
|
||||
"@types/server-destroy": "1.0.1",
|
||||
"@types/supertest": "2.0.14",
|
||||
"@types/tar": "6.1.5",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/uuid": "8.3.4",
|
||||
"chance": "^1.1.12",
|
||||
"copyfiles": "2.4.1",
|
||||
"docker-compose": "0.23.17",
|
||||
"ioredis-mock": "8.9.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-extended": "^4.0.2",
|
||||
"jest-openapi": "0.14.2",
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
set -ex
|
||||
|
||||
if [[ -n $CI ]]
|
||||
then
|
||||
export NODE_OPTIONS="--max-old-space-size=4096 --no-node-snapshot $NODE_OPTIONS"
|
||||
jest --coverage --maxWorkers=4 --forceExit --workerIdleMemoryLimit=2000MB --bail $@
|
||||
jest --coverage --maxWorkers=4 --forceExit --workerIdleMemoryLimit=2000MB --bail "$@"
|
||||
else
|
||||
# --maxWorkers performs better in development
|
||||
export NODE_OPTIONS="--no-node-snapshot $NODE_OPTIONS"
|
||||
jest --coverage --maxWorkers=2 --forceExit $@
|
||||
jest --coverage --maxWorkers=2 --forceExit "$@"
|
||||
fi
|
File diff suppressed because it is too large
Load Diff
|
@ -23,6 +23,13 @@ components:
|
|||
description: The ID of the table which this request is targeting.
|
||||
schema:
|
||||
type: string
|
||||
viewId:
|
||||
in: path
|
||||
name: viewId
|
||||
required: true
|
||||
description: The ID of the view which this request is targeting.
|
||||
schema:
|
||||
type: string
|
||||
rowId:
|
||||
in: path
|
||||
name: rowId
|
||||
|
@ -36,7 +43,7 @@ components:
|
|||
required: true
|
||||
description: The ID of the app which this request is targeting.
|
||||
schema:
|
||||
default: "{{ appId }}"
|
||||
default: "{{appId}}"
|
||||
type: string
|
||||
appIdUrl:
|
||||
in: path
|
||||
|
@ -44,7 +51,7 @@ components:
|
|||
required: true
|
||||
description: The ID of the app which this request is targeting.
|
||||
schema:
|
||||
default: "{{ appId }}"
|
||||
default: "{{appId}}"
|
||||
type: string
|
||||
queryId:
|
||||
in: path
|
||||
|
@ -442,6 +449,74 @@ components:
|
|||
# TYPE budibase_quota_limit_automations gauge
|
||||
|
||||
budibase_quota_limit_automations 9007199254740991
|
||||
view:
|
||||
value:
|
||||
data:
|
||||
name: peopleView
|
||||
tableId: ta_896a325f7e8147d2a2cda93c5d236511
|
||||
schema:
|
||||
name:
|
||||
visible: true
|
||||
readonly: false
|
||||
order: 1
|
||||
width: 300
|
||||
age:
|
||||
visible: true
|
||||
readonly: true
|
||||
order: 2
|
||||
width: 200
|
||||
salary:
|
||||
visible: false
|
||||
readonly: false
|
||||
query:
|
||||
logicalOperator: all
|
||||
onEmptyFilter: none
|
||||
groups:
|
||||
- logicalOperator: any
|
||||
filters:
|
||||
- operator: string
|
||||
field: name
|
||||
value: John
|
||||
- operator: range
|
||||
field: age
|
||||
value:
|
||||
low: 18
|
||||
high: 100
|
||||
primaryDisplay: name
|
||||
views:
|
||||
value:
|
||||
data:
|
||||
- name: peopleView
|
||||
tableId: ta_896a325f7e8147d2a2cda93c5d236511
|
||||
schema:
|
||||
name:
|
||||
visible: true
|
||||
readonly: false
|
||||
order: 1
|
||||
width: 300
|
||||
age:
|
||||
visible: true
|
||||
readonly: true
|
||||
order: 2
|
||||
width: 200
|
||||
salary:
|
||||
visible: false
|
||||
readonly: false
|
||||
query:
|
||||
logicalOperator: all
|
||||
onEmptyFilter: none
|
||||
groups:
|
||||
- logicalOperator: any
|
||||
filters:
|
||||
- operator: string
|
||||
field: name
|
||||
value: John
|
||||
- operator: range
|
||||
field: age
|
||||
value:
|
||||
low: 18
|
||||
high: 100
|
||||
primaryDisplay: name
|
||||
securitySchemes:
|
||||
ApiKeyAuth:
|
||||
type: apiKey
|
||||
|
@ -761,7 +836,6 @@ components:
|
|||
enum:
|
||||
- static
|
||||
- dynamic
|
||||
- ai
|
||||
description: Defines whether this is a static or dynamic formula.
|
||||
- type: object
|
||||
properties:
|
||||
|
@ -931,7 +1005,6 @@ components:
|
|||
enum:
|
||||
- static
|
||||
- dynamic
|
||||
- ai
|
||||
description: Defines whether this is a static or dynamic formula.
|
||||
- type: object
|
||||
properties:
|
||||
|
@ -1108,7 +1181,6 @@ components:
|
|||
enum:
|
||||
- static
|
||||
- dynamic
|
||||
- ai
|
||||
description: Defines whether this is a static or dynamic formula.
|
||||
- type: object
|
||||
properties:
|
||||
|
@ -1704,6 +1776,644 @@ components:
|
|||
- userIds
|
||||
required:
|
||||
- data
|
||||
view:
|
||||
description: The view to be created/updated.
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- schema
|
||||
- tableId
|
||||
properties:
|
||||
name:
|
||||
description: The name of the view.
|
||||
type: string
|
||||
tableId:
|
||||
description: The ID of the table this view is based on.
|
||||
type: string
|
||||
type:
|
||||
description: The type of view - standard (empty value) or calculation.
|
||||
type: string
|
||||
enum:
|
||||
- calculation
|
||||
primaryDisplay:
|
||||
type: string
|
||||
description: A column used to display rows from this view - usually used when
|
||||
rendered in tables.
|
||||
query:
|
||||
description: Search parameters for view
|
||||
type: object
|
||||
required: []
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
onEmptyFilter:
|
||||
description: If no filters match, should the view return all rows, or no rows.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- none
|
||||
groups:
|
||||
description: A grouping of filters to be applied.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
filters:
|
||||
description: A list of filters to apply
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
description: The type of search operation which is being performed.
|
||||
enum:
|
||||
- equal
|
||||
- notEqual
|
||||
- empty
|
||||
- notEmpty
|
||||
- fuzzy
|
||||
- string
|
||||
- contains
|
||||
- notContains
|
||||
- containsAny
|
||||
- oneOf
|
||||
- range
|
||||
field:
|
||||
type: string
|
||||
description: The field in the view to perform the search on.
|
||||
value:
|
||||
description: The value to search for - the type will depend on the operator in
|
||||
use.
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- type: object
|
||||
- type: array
|
||||
groups:
|
||||
description: A grouping of filters to be applied.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
filters:
|
||||
description: A list of filters to apply
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
description: The type of search operation which is being performed.
|
||||
enum:
|
||||
- equal
|
||||
- notEqual
|
||||
- empty
|
||||
- notEmpty
|
||||
- fuzzy
|
||||
- string
|
||||
- contains
|
||||
- notContains
|
||||
- containsAny
|
||||
- oneOf
|
||||
- range
|
||||
field:
|
||||
type: string
|
||||
description: The field in the view to perform the search on.
|
||||
value:
|
||||
description: The value to search for - the type will depend on the operator in
|
||||
use.
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- type: object
|
||||
- type: array
|
||||
sort:
|
||||
type: object
|
||||
required:
|
||||
- field
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: The field from the table/view schema to sort on.
|
||||
order:
|
||||
type: string
|
||||
description: The order in which to sort.
|
||||
enum:
|
||||
- ascending
|
||||
- descending
|
||||
type:
|
||||
type: string
|
||||
description: The type of sort to perform (by number, or by alphabetically).
|
||||
enum:
|
||||
- string
|
||||
- number
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
visible:
|
||||
type: boolean
|
||||
description: Defines whether the column is visible or not - rows
|
||||
retrieved/updated through this view will not be able to
|
||||
access it.
|
||||
readonly:
|
||||
type: boolean
|
||||
description: "When used in combination with 'visible: true' the column will be
|
||||
visible in row responses but cannot be updated."
|
||||
order:
|
||||
type: integer
|
||||
description: A number defining where the column shows up in tables, lowest being
|
||||
first.
|
||||
width:
|
||||
type: integer
|
||||
description: A width for the column, defined in pixels - this affects rendering
|
||||
in tables.
|
||||
column:
|
||||
type: array
|
||||
description: If this is a relationship column, we can set the columns we wish to
|
||||
include
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
readonly:
|
||||
type: boolean
|
||||
- type: object
|
||||
properties:
|
||||
calculationType:
|
||||
type: string
|
||||
description: This column should be built from a calculation, specifying a type
|
||||
and field. It is important to note when a calculation is
|
||||
configured all non-calculation columns will be used for
|
||||
grouping.
|
||||
enum:
|
||||
- sum
|
||||
- avg
|
||||
- count
|
||||
- min
|
||||
- max
|
||||
field:
|
||||
type: string
|
||||
description: The field from the table to perform the calculation on.
|
||||
distinct:
|
||||
type: boolean
|
||||
description: Can be used in tandem with the count calculation type, to count
|
||||
unique entries.
|
||||
viewOutput:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
description: The view to be created/updated.
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- schema
|
||||
- tableId
|
||||
- id
|
||||
properties:
|
||||
name:
|
||||
description: The name of the view.
|
||||
type: string
|
||||
tableId:
|
||||
description: The ID of the table this view is based on.
|
||||
type: string
|
||||
type:
|
||||
description: The type of view - standard (empty value) or calculation.
|
||||
type: string
|
||||
enum:
|
||||
- calculation
|
||||
primaryDisplay:
|
||||
type: string
|
||||
description: A column used to display rows from this view - usually used when
|
||||
rendered in tables.
|
||||
query:
|
||||
description: Search parameters for view
|
||||
type: object
|
||||
required: []
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
onEmptyFilter:
|
||||
description: If no filters match, should the view return all rows, or no rows.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- none
|
||||
groups:
|
||||
description: A grouping of filters to be applied.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
filters:
|
||||
description: A list of filters to apply
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
description: The type of search operation which is being performed.
|
||||
enum:
|
||||
- equal
|
||||
- notEqual
|
||||
- empty
|
||||
- notEmpty
|
||||
- fuzzy
|
||||
- string
|
||||
- contains
|
||||
- notContains
|
||||
- containsAny
|
||||
- oneOf
|
||||
- range
|
||||
field:
|
||||
type: string
|
||||
description: The field in the view to perform the search on.
|
||||
value:
|
||||
description: The value to search for - the type will depend on the operator in
|
||||
use.
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- type: object
|
||||
- type: array
|
||||
groups:
|
||||
description: A grouping of filters to be applied.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
filters:
|
||||
description: A list of filters to apply
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
description: The type of search operation which is being performed.
|
||||
enum:
|
||||
- equal
|
||||
- notEqual
|
||||
- empty
|
||||
- notEmpty
|
||||
- fuzzy
|
||||
- string
|
||||
- contains
|
||||
- notContains
|
||||
- containsAny
|
||||
- oneOf
|
||||
- range
|
||||
field:
|
||||
type: string
|
||||
description: The field in the view to perform the search on.
|
||||
value:
|
||||
description: The value to search for - the type will depend on the operator in
|
||||
use.
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- type: object
|
||||
- type: array
|
||||
sort:
|
||||
type: object
|
||||
required:
|
||||
- field
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: The field from the table/view schema to sort on.
|
||||
order:
|
||||
type: string
|
||||
description: The order in which to sort.
|
||||
enum:
|
||||
- ascending
|
||||
- descending
|
||||
type:
|
||||
type: string
|
||||
description: The type of sort to perform (by number, or by alphabetically).
|
||||
enum:
|
||||
- string
|
||||
- number
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
visible:
|
||||
type: boolean
|
||||
description: Defines whether the column is visible or not - rows
|
||||
retrieved/updated through this view will not be able
|
||||
to access it.
|
||||
readonly:
|
||||
type: boolean
|
||||
description: "When used in combination with 'visible: true' the column will be
|
||||
visible in row responses but cannot be updated."
|
||||
order:
|
||||
type: integer
|
||||
description: A number defining where the column shows up in tables, lowest being
|
||||
first.
|
||||
width:
|
||||
type: integer
|
||||
description: A width for the column, defined in pixels - this affects rendering
|
||||
in tables.
|
||||
column:
|
||||
type: array
|
||||
description: If this is a relationship column, we can set the columns we wish to
|
||||
include
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
readonly:
|
||||
type: boolean
|
||||
- type: object
|
||||
properties:
|
||||
calculationType:
|
||||
type: string
|
||||
description: This column should be built from a calculation, specifying a type
|
||||
and field. It is important to note when a calculation
|
||||
is configured all non-calculation columns will be used
|
||||
for grouping.
|
||||
enum:
|
||||
- sum
|
||||
- avg
|
||||
- count
|
||||
- min
|
||||
- max
|
||||
field:
|
||||
type: string
|
||||
description: The field from the table to perform the calculation on.
|
||||
distinct:
|
||||
type: boolean
|
||||
description: Can be used in tandem with the count calculation type, to count
|
||||
unique entries.
|
||||
id:
|
||||
description: The ID of the view.
|
||||
type: string
|
||||
required:
|
||||
- data
|
||||
viewSearch:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
description: The view to be created/updated.
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- schema
|
||||
- tableId
|
||||
- id
|
||||
properties:
|
||||
name:
|
||||
description: The name of the view.
|
||||
type: string
|
||||
tableId:
|
||||
description: The ID of the table this view is based on.
|
||||
type: string
|
||||
type:
|
||||
description: The type of view - standard (empty value) or calculation.
|
||||
type: string
|
||||
enum:
|
||||
- calculation
|
||||
primaryDisplay:
|
||||
type: string
|
||||
description: A column used to display rows from this view - usually used when
|
||||
rendered in tables.
|
||||
query:
|
||||
description: Search parameters for view
|
||||
type: object
|
||||
required: []
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
onEmptyFilter:
|
||||
description: If no filters match, should the view return all rows, or no rows.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- none
|
||||
groups:
|
||||
description: A grouping of filters to be applied.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
filters:
|
||||
description: A list of filters to apply
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
description: The type of search operation which is being performed.
|
||||
enum:
|
||||
- equal
|
||||
- notEqual
|
||||
- empty
|
||||
- notEmpty
|
||||
- fuzzy
|
||||
- string
|
||||
- contains
|
||||
- notContains
|
||||
- containsAny
|
||||
- oneOf
|
||||
- range
|
||||
field:
|
||||
type: string
|
||||
description: The field in the view to perform the search on.
|
||||
value:
|
||||
description: The value to search for - the type will depend on the operator in
|
||||
use.
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- type: object
|
||||
- type: array
|
||||
groups:
|
||||
description: A grouping of filters to be applied.
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
logicalOperator:
|
||||
description: When using groups this defines whether all of the filters must
|
||||
match, or only one of them.
|
||||
type: string
|
||||
enum:
|
||||
- all
|
||||
- any
|
||||
filters:
|
||||
description: A list of filters to apply
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
operator:
|
||||
type: string
|
||||
description: The type of search operation which is being performed.
|
||||
enum:
|
||||
- equal
|
||||
- notEqual
|
||||
- empty
|
||||
- notEmpty
|
||||
- fuzzy
|
||||
- string
|
||||
- contains
|
||||
- notContains
|
||||
- containsAny
|
||||
- oneOf
|
||||
- range
|
||||
field:
|
||||
type: string
|
||||
description: The field in the view to perform the search on.
|
||||
value:
|
||||
description: The value to search for - the type will depend on the operator in
|
||||
use.
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: number
|
||||
- type: boolean
|
||||
- type: object
|
||||
- type: array
|
||||
sort:
|
||||
type: object
|
||||
required:
|
||||
- field
|
||||
properties:
|
||||
field:
|
||||
type: string
|
||||
description: The field from the table/view schema to sort on.
|
||||
order:
|
||||
type: string
|
||||
description: The order in which to sort.
|
||||
enum:
|
||||
- ascending
|
||||
- descending
|
||||
type:
|
||||
type: string
|
||||
description: The type of sort to perform (by number, or by alphabetically).
|
||||
enum:
|
||||
- string
|
||||
- number
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
visible:
|
||||
type: boolean
|
||||
description: Defines whether the column is visible or not - rows
|
||||
retrieved/updated through this view will not be able
|
||||
to access it.
|
||||
readonly:
|
||||
type: boolean
|
||||
description: "When used in combination with 'visible: true' the column will be
|
||||
visible in row responses but cannot be updated."
|
||||
order:
|
||||
type: integer
|
||||
description: A number defining where the column shows up in tables, lowest being
|
||||
first.
|
||||
width:
|
||||
type: integer
|
||||
description: A width for the column, defined in pixels - this affects rendering
|
||||
in tables.
|
||||
column:
|
||||
type: array
|
||||
description: If this is a relationship column, we can set the columns we wish to
|
||||
include
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
readonly:
|
||||
type: boolean
|
||||
- type: object
|
||||
properties:
|
||||
calculationType:
|
||||
type: string
|
||||
description: This column should be built from a calculation, specifying a type
|
||||
and field. It is important to note when a
|
||||
calculation is configured all non-calculation
|
||||
columns will be used for grouping.
|
||||
enum:
|
||||
- sum
|
||||
- avg
|
||||
- count
|
||||
- min
|
||||
- max
|
||||
field:
|
||||
type: string
|
||||
description: The field from the table to perform the calculation on.
|
||||
distinct:
|
||||
type: boolean
|
||||
description: Can be used in tandem with the count calculation type, to count
|
||||
unique entries.
|
||||
id:
|
||||
description: The ID of the view.
|
||||
type: string
|
||||
required:
|
||||
- data
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
paths:
|
||||
|
@ -2136,6 +2846,32 @@ paths:
|
|||
examples:
|
||||
search:
|
||||
$ref: "#/components/examples/rows"
|
||||
"/views/{viewId}/rows/search":
|
||||
post:
|
||||
operationId: rowViewSearch
|
||||
summary: Search for rows in a view
|
||||
tags:
|
||||
- rows
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/viewId"
|
||||
- $ref: "#/components/parameters/appId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/rowSearch"
|
||||
responses:
|
||||
"200":
|
||||
description: The response will contain an array of rows that match the search
|
||||
parameters.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/searchOutput"
|
||||
examples:
|
||||
search:
|
||||
$ref: "#/components/examples/rows"
|
||||
/tables:
|
||||
post:
|
||||
operationId: tableCreate
|
||||
|
@ -2359,4 +3095,123 @@ paths:
|
|||
examples:
|
||||
users:
|
||||
$ref: "#/components/examples/users"
|
||||
/views:
|
||||
post:
|
||||
operationId: viewCreate
|
||||
summary: Create a view
|
||||
description: Create a view, this can be against an internal or external table.
|
||||
tags:
|
||||
- views
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/appId"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/view"
|
||||
examples:
|
||||
view:
|
||||
$ref: "#/components/examples/view"
|
||||
responses:
|
||||
"200":
|
||||
description: Returns the created view, including the ID which has been generated
|
||||
for it.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/viewOutput"
|
||||
examples:
|
||||
view:
|
||||
$ref: "#/components/examples/view"
|
||||
"/views/{viewId}":
|
||||
put:
|
||||
operationId: viewUpdate
|
||||
summary: Update a view
|
||||
description: Update a view, this can be against an internal or external table.
|
||||
tags:
|
||||
- views
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/viewId"
|
||||
- $ref: "#/components/parameters/appId"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/view"
|
||||
examples:
|
||||
view:
|
||||
$ref: "#/components/examples/view"
|
||||
responses:
|
||||
"200":
|
||||
description: Returns the updated view.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/viewOutput"
|
||||
examples:
|
||||
view:
|
||||
$ref: "#/components/examples/view"
|
||||
delete:
|
||||
operationId: viewDestroy
|
||||
summary: Delete a view
|
||||
description: Delete a view, this can be against an internal or external table.
|
||||
tags:
|
||||
- views
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/viewId"
|
||||
- $ref: "#/components/parameters/appId"
|
||||
responses:
|
||||
"200":
|
||||
description: Returns the deleted view.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/viewOutput"
|
||||
examples:
|
||||
view:
|
||||
$ref: "#/components/examples/view"
|
||||
get:
|
||||
operationId: viewGetById
|
||||
summary: Retrieve a view
|
||||
description: Lookup a view, this could be internal or external.
|
||||
tags:
|
||||
- views
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/viewId"
|
||||
- $ref: "#/components/parameters/appId"
|
||||
responses:
|
||||
"200":
|
||||
description: Returns the retrieved view.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/viewOutput"
|
||||
examples:
|
||||
view:
|
||||
$ref: "#/components/examples/view"
|
||||
/views/search:
|
||||
post:
|
||||
operationId: viewSearch
|
||||
summary: Search for views
|
||||
description: Based on view properties (currently only name) search for views.
|
||||
tags:
|
||||
- views
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/appId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/nameSearch"
|
||||
responses:
|
||||
"200":
|
||||
description: Returns the found views, based on the search parameters.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/viewSearch"
|
||||
examples:
|
||||
views:
|
||||
$ref: "#/components/examples/views"
|
||||
tags: []
|
||||
|
|
|
@ -8,6 +8,16 @@ export const tableId = {
|
|||
},
|
||||
}
|
||||
|
||||
export const viewId = {
|
||||
in: "path",
|
||||
name: "viewId",
|
||||
required: true,
|
||||
description: "The ID of the view which this request is targeting.",
|
||||
schema: {
|
||||
type: "string",
|
||||
},
|
||||
}
|
||||
|
||||
export const rowId = {
|
||||
in: "path",
|
||||
name: "rowId",
|
||||
|
|
|
@ -6,6 +6,7 @@ import user from "./user"
|
|||
import metrics from "./metrics"
|
||||
import misc from "./misc"
|
||||
import roles from "./roles"
|
||||
import view from "./view"
|
||||
|
||||
export const examples = {
|
||||
...application.getExamples(),
|
||||
|
@ -16,6 +17,7 @@ export const examples = {
|
|||
...misc.getExamples(),
|
||||
...metrics.getExamples(),
|
||||
...roles.getExamples(),
|
||||
...view.getExamples(),
|
||||
}
|
||||
|
||||
export const schemas = {
|
||||
|
@ -26,4 +28,5 @@ export const schemas = {
|
|||
...user.getSchemas(),
|
||||
...misc.getSchemas(),
|
||||
...roles.getSchemas(),
|
||||
...view.getSchemas(),
|
||||
}
|
||||
|
|
|
@ -1,99 +1,101 @@
|
|||
import { object } from "./utils"
|
||||
import Resource from "./utils/Resource"
|
||||
|
||||
export const searchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
allOr: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Specifies that a row should be returned if it satisfies any of the specified options, rather than requiring it to fulfill all the search parameters. This defaults to false, meaning AND logic will be used.",
|
||||
},
|
||||
string: {
|
||||
type: "object",
|
||||
example: {
|
||||
columnName1: "value",
|
||||
columnName2: "value",
|
||||
},
|
||||
description:
|
||||
"A map of field name to the string to search for, this will look for rows that have a value starting with the string value.",
|
||||
additionalProperties: {
|
||||
type: "string",
|
||||
description: "The value to search for in the column.",
|
||||
},
|
||||
},
|
||||
fuzzy: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.",
|
||||
},
|
||||
range: {
|
||||
type: "object",
|
||||
description:
|
||||
'Searches within a range, the format of this must be in the format of an object with a "low" and "high" property.',
|
||||
example: {
|
||||
columnName1: {
|
||||
low: 10,
|
||||
high: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
equal: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for rows that have a column value that is exactly the value set.",
|
||||
},
|
||||
notEqual: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for any row which does not contain the specified column value.",
|
||||
},
|
||||
empty: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for rows which do not contain the specified column. The object should simply contain keys of the column names, these can map to any value.",
|
||||
example: {
|
||||
columnName1: "",
|
||||
},
|
||||
},
|
||||
notEmpty: {
|
||||
type: "object",
|
||||
description: "Searches for rows which have the specified column.",
|
||||
},
|
||||
oneOf: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].",
|
||||
},
|
||||
contains: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.",
|
||||
example: {
|
||||
arrayColumn: ["a", "b"],
|
||||
},
|
||||
},
|
||||
notContains: {
|
||||
type: "object",
|
||||
description:
|
||||
"The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.",
|
||||
example: {
|
||||
arrayColumn: ["a", "b"],
|
||||
},
|
||||
},
|
||||
containsAny: {
|
||||
type: "object",
|
||||
description:
|
||||
"As with the contains search, only works on array column types and searches for any of the provided values when given an array.",
|
||||
example: {
|
||||
arrayColumn: ["a", "b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default new Resource().setSchemas({
|
||||
rowSearch: object(
|
||||
{
|
||||
query: {
|
||||
type: "object",
|
||||
properties: {
|
||||
allOr: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Specifies that a row should be returned if it satisfies any of the specified options, rather than requiring it to fulfill all the search parameters. This defaults to false, meaning AND logic will be used.",
|
||||
},
|
||||
string: {
|
||||
type: "object",
|
||||
example: {
|
||||
columnName1: "value",
|
||||
columnName2: "value",
|
||||
},
|
||||
description:
|
||||
"A map of field name to the string to search for, this will look for rows that have a value starting with the string value.",
|
||||
additionalProperties: {
|
||||
type: "string",
|
||||
description: "The value to search for in the column.",
|
||||
},
|
||||
},
|
||||
fuzzy: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.",
|
||||
},
|
||||
range: {
|
||||
type: "object",
|
||||
description:
|
||||
'Searches within a range, the format of this must be in the format of an object with a "low" and "high" property.',
|
||||
example: {
|
||||
columnName1: {
|
||||
low: 10,
|
||||
high: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
equal: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for rows that have a column value that is exactly the value set.",
|
||||
},
|
||||
notEqual: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for any row which does not contain the specified column value.",
|
||||
},
|
||||
empty: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for rows which do not contain the specified column. The object should simply contain keys of the column names, these can map to any value.",
|
||||
example: {
|
||||
columnName1: "",
|
||||
},
|
||||
},
|
||||
notEmpty: {
|
||||
type: "object",
|
||||
description: "Searches for rows which have the specified column.",
|
||||
},
|
||||
oneOf: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].",
|
||||
},
|
||||
contains: {
|
||||
type: "object",
|
||||
description:
|
||||
"Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.",
|
||||
example: {
|
||||
arrayColumn: ["a", "b"],
|
||||
},
|
||||
},
|
||||
notContains: {
|
||||
type: "object",
|
||||
description:
|
||||
"The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.",
|
||||
example: {
|
||||
arrayColumn: ["a", "b"],
|
||||
},
|
||||
},
|
||||
containsAny: {
|
||||
type: "object",
|
||||
description:
|
||||
"As with the contains search, only works on array column types and searches for any of the provided values when given an array.",
|
||||
example: {
|
||||
arrayColumn: ["a", "b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
query: searchSchema,
|
||||
paginate: {
|
||||
type: "boolean",
|
||||
description: "Enables pagination, by default this is disabled.",
|
||||
|
|
|
@ -0,0 +1,274 @@
|
|||
import { object } from "./utils"
|
||||
import Resource from "./utils/Resource"
|
||||
import {
|
||||
ArrayOperator,
|
||||
BasicOperator,
|
||||
CalculationType,
|
||||
RangeOperator,
|
||||
SortOrder,
|
||||
SortType,
|
||||
} from "@budibase/types"
|
||||
import { cloneDeep } from "lodash"
|
||||
|
||||
const view = {
|
||||
name: "peopleView",
|
||||
tableId: "ta_896a325f7e8147d2a2cda93c5d236511",
|
||||
schema: {
|
||||
name: {
|
||||
visible: true,
|
||||
readonly: false,
|
||||
order: 1,
|
||||
width: 300,
|
||||
},
|
||||
age: {
|
||||
visible: true,
|
||||
readonly: true,
|
||||
order: 2,
|
||||
width: 200,
|
||||
},
|
||||
salary: {
|
||||
visible: false,
|
||||
readonly: false,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
logicalOperator: "all",
|
||||
onEmptyFilter: "none",
|
||||
groups: [
|
||||
{
|
||||
logicalOperator: "any",
|
||||
filters: [
|
||||
{ operator: "string", field: "name", value: "John" },
|
||||
{ operator: "range", field: "age", value: { low: 18, high: 100 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
primaryDisplay: "name",
|
||||
}
|
||||
|
||||
const baseColumnDef = {
|
||||
visible: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Defines whether the column is visible or not - rows retrieved/updated through this view will not be able to access it.",
|
||||
},
|
||||
readonly: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"When used in combination with 'visible: true' the column will be visible in row responses but cannot be updated.",
|
||||
},
|
||||
order: {
|
||||
type: "integer",
|
||||
description:
|
||||
"A number defining where the column shows up in tables, lowest being first.",
|
||||
},
|
||||
width: {
|
||||
type: "integer",
|
||||
description:
|
||||
"A width for the column, defined in pixels - this affects rendering in tables.",
|
||||
},
|
||||
column: {
|
||||
type: "array",
|
||||
description:
|
||||
"If this is a relationship column, we can set the columns we wish to include",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
readonly: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const logicalOperator = {
|
||||
description:
|
||||
"When using groups this defines whether all of the filters must match, or only one of them.",
|
||||
type: "string",
|
||||
enum: ["all", "any"],
|
||||
}
|
||||
|
||||
const filterGroup = {
|
||||
description: "A grouping of filters to be applied.",
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
logicalOperator,
|
||||
filters: {
|
||||
description: "A list of filters to apply",
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
operator: {
|
||||
type: "string",
|
||||
description:
|
||||
"The type of search operation which is being performed.",
|
||||
enum: [
|
||||
...Object.values(BasicOperator),
|
||||
...Object.values(ArrayOperator),
|
||||
...Object.values(RangeOperator),
|
||||
],
|
||||
},
|
||||
field: {
|
||||
type: "string",
|
||||
description: "The field in the view to perform the search on.",
|
||||
},
|
||||
value: {
|
||||
description:
|
||||
"The value to search for - the type will depend on the operator in use.",
|
||||
oneOf: [
|
||||
{ type: "string" },
|
||||
{ type: "number" },
|
||||
{ type: "boolean" },
|
||||
{ type: "object" },
|
||||
{ type: "array" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// have to clone to avoid constantly recursive structure - we can't represent this easily
|
||||
const layeredFilterGroup: any = cloneDeep(filterGroup)
|
||||
layeredFilterGroup.items.properties.groups = filterGroup
|
||||
|
||||
const viewQuerySchema = {
|
||||
description: "Search parameters for view",
|
||||
type: "object",
|
||||
required: [],
|
||||
properties: {
|
||||
logicalOperator,
|
||||
onEmptyFilter: {
|
||||
description:
|
||||
"If no filters match, should the view return all rows, or no rows.",
|
||||
type: "string",
|
||||
enum: ["all", "none"],
|
||||
},
|
||||
groups: layeredFilterGroup,
|
||||
},
|
||||
}
|
||||
|
||||
const viewSchema = {
|
||||
description: "The view to be created/updated.",
|
||||
type: "object",
|
||||
required: ["name", "schema", "tableId"],
|
||||
properties: {
|
||||
name: {
|
||||
description: "The name of the view.",
|
||||
type: "string",
|
||||
},
|
||||
tableId: {
|
||||
description: "The ID of the table this view is based on.",
|
||||
type: "string",
|
||||
},
|
||||
type: {
|
||||
description: "The type of view - standard (empty value) or calculation.",
|
||||
type: "string",
|
||||
enum: ["calculation"],
|
||||
},
|
||||
primaryDisplay: {
|
||||
type: "string",
|
||||
description:
|
||||
"A column used to display rows from this view - usually used when rendered in tables.",
|
||||
},
|
||||
query: viewQuerySchema,
|
||||
sort: {
|
||||
type: "object",
|
||||
required: ["field"],
|
||||
properties: {
|
||||
field: {
|
||||
type: "string",
|
||||
description: "The field from the table/view schema to sort on.",
|
||||
},
|
||||
order: {
|
||||
type: "string",
|
||||
description: "The order in which to sort.",
|
||||
enum: Object.values(SortOrder),
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
description:
|
||||
"The type of sort to perform (by number, or by alphabetically).",
|
||||
enum: Object.values(SortType),
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: baseColumnDef,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
calculationType: {
|
||||
type: "string",
|
||||
description:
|
||||
"This column should be built from a calculation, specifying a type and field. It is important to note when a calculation is configured all non-calculation columns will be used for grouping.",
|
||||
enum: Object.values(CalculationType),
|
||||
},
|
||||
field: {
|
||||
type: "string",
|
||||
description:
|
||||
"The field from the table to perform the calculation on.",
|
||||
},
|
||||
distinct: {
|
||||
type: "boolean",
|
||||
description:
|
||||
"Can be used in tandem with the count calculation type, to count unique entries.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const viewOutputSchema = {
|
||||
...viewSchema,
|
||||
properties: {
|
||||
...viewSchema.properties,
|
||||
id: {
|
||||
description: "The ID of the view.",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: [...viewSchema.required, "id"],
|
||||
}
|
||||
|
||||
export default new Resource()
|
||||
.setExamples({
|
||||
view: {
|
||||
value: {
|
||||
data: view,
|
||||
},
|
||||
},
|
||||
views: {
|
||||
value: {
|
||||
data: [view],
|
||||
},
|
||||
},
|
||||
})
|
||||
.setSchemas({
|
||||
view: viewSchema,
|
||||
viewOutput: object({
|
||||
data: viewOutputSchema,
|
||||
}),
|
||||
viewSearch: object({
|
||||
data: {
|
||||
type: "array",
|
||||
items: viewOutputSchema,
|
||||
},
|
||||
}),
|
||||
})
|
|
@ -153,7 +153,11 @@ async function createInstance(appId: string, template: AppTemplate) {
|
|||
await createAllSearchIndex()
|
||||
|
||||
if (template && template.useTemplate) {
|
||||
await sdk.backups.importApp(appId, db, template)
|
||||
const opts = {
|
||||
importObjStoreContents: true,
|
||||
updateAttachmentColumns: !template.key, // preserve attachments when using Budibase templates
|
||||
}
|
||||
await sdk.backups.importApp(appId, db, template, opts)
|
||||
} else {
|
||||
// create the users table
|
||||
await db.put(USERS_TABLE_SCHEMA)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Application } from "./types"
|
||||
import { RequiredKeys } from "@budibase/types"
|
||||
|
||||
function application(body: any): Application {
|
||||
function application(body: any): RequiredKeys<Application> {
|
||||
let app = body?.application ? body.application : body
|
||||
return {
|
||||
_id: app.appId,
|
||||
|
|
|
@ -3,6 +3,7 @@ import applications from "./applications"
|
|||
import users from "./users"
|
||||
import rows from "./rows"
|
||||
import queries from "./queries"
|
||||
import views from "./views"
|
||||
|
||||
export default {
|
||||
...tables,
|
||||
|
@ -10,4 +11,5 @@ export default {
|
|||
...users,
|
||||
...rows,
|
||||
...queries,
|
||||
...views,
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Query, ExecuteQuery } from "./types"
|
||||
import { RequiredKeys } from "@budibase/types"
|
||||
|
||||
function query(body: any): Query {
|
||||
function query(body: any): RequiredKeys<Query> {
|
||||
return {
|
||||
_id: body._id,
|
||||
datasourceId: body.datasourceId,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Row, RowSearch } from "./types"
|
||||
import { RequiredKeys } from "@budibase/types"
|
||||
|
||||
function row(body: any): Row {
|
||||
function row(body: any): RequiredKeys<Row> {
|
||||
delete body._rev
|
||||
// have to input everything, since structure unknown
|
||||
return {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Table } from "./types"
|
||||
import { RequiredKeys } from "@budibase/types"
|
||||
|
||||
function table(body: any): Table {
|
||||
function table(body: any): RequiredKeys<Table> {
|
||||
return {
|
||||
_id: body._id,
|
||||
name: body.name,
|
||||
|
|
|
@ -9,6 +9,9 @@ export type CreateApplicationParams = components["schemas"]["application"]
|
|||
export type Table = components["schemas"]["tableOutput"]["data"]
|
||||
export type CreateTableParams = components["schemas"]["table"]
|
||||
|
||||
export type View = components["schemas"]["viewOutput"]["data"]
|
||||
export type CreateViewParams = components["schemas"]["view"]
|
||||
|
||||
export type Row = components["schemas"]["rowOutput"]["data"]
|
||||
export type RowSearch = components["schemas"]["searchOutput"]
|
||||
export type CreateRowParams = components["schemas"]["row"]
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { User } from "./types"
|
||||
import { RequiredKeys } from "@budibase/types"
|
||||
|
||||
function user(body: any): User {
|
||||
function user(body: any): RequiredKeys<User> {
|
||||
return {
|
||||
_id: body._id,
|
||||
email: body.email,
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { View } from "./types"
|
||||
import { ViewV2, Ctx, RequiredKeys } from "@budibase/types"
|
||||
import { dataFilters } from "@budibase/shared-core"
|
||||
|
||||
function view(body: ViewV2): RequiredKeys<View> {
|
||||
return {
|
||||
id: body.id,
|
||||
tableId: body.tableId,
|
||||
type: body.type,
|
||||
name: body.name,
|
||||
schema: body.schema!,
|
||||
primaryDisplay: body.primaryDisplay,
|
||||
query: dataFilters.buildQuery(body.query),
|
||||
sort: body.sort,
|
||||
}
|
||||
}
|
||||
|
||||
function mapView(ctx: Ctx<{ data: ViewV2 }>): { data: View } {
|
||||
return {
|
||||
data: view(ctx.body.data),
|
||||
}
|
||||
}
|
||||
|
||||
function mapViews(ctx: Ctx<{ data: ViewV2[] }>): { data: View[] } {
|
||||
const views = ctx.body.data.map((body: ViewV2) => view(body))
|
||||
return { data: views }
|
||||
}
|
||||
|
||||
export default {
|
||||
mapView,
|
||||
mapViews,
|
||||
}
|
|
@ -22,13 +22,13 @@ export function fixRow(row: Row, params: any) {
|
|||
return row
|
||||
}
|
||||
|
||||
export async function search(ctx: UserCtx, next: Next) {
|
||||
function buildSearchRequestBody(ctx: UserCtx) {
|
||||
let { sort, paginate, bookmark, limit, query } = ctx.request.body
|
||||
// update the body to the correct format of the internal search
|
||||
if (!sort) {
|
||||
sort = {}
|
||||
}
|
||||
ctx.request.body = {
|
||||
return {
|
||||
sort: sort.column,
|
||||
sortType: sort.type,
|
||||
sortOrder: sort.order,
|
||||
|
@ -37,10 +37,23 @@ export async function search(ctx: UserCtx, next: Next) {
|
|||
limit,
|
||||
query,
|
||||
}
|
||||
}
|
||||
|
||||
export async function search(ctx: UserCtx, next: Next) {
|
||||
ctx.request.body = buildSearchRequestBody(ctx)
|
||||
await rowController.search(ctx)
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function viewSearch(ctx: UserCtx, next: Next) {
|
||||
ctx.request.body = buildSearchRequestBody(ctx)
|
||||
ctx.params = {
|
||||
viewId: ctx.params.viewId,
|
||||
}
|
||||
await rowController.views.searchView(ctx)
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function create(ctx: UserCtx, next: Next) {
|
||||
ctx.request.body = fixRow(ctx.request.body, ctx.params)
|
||||
await rowController.save(ctx)
|
||||
|
@ -79,4 +92,5 @@ export default {
|
|||
update,
|
||||
destroy,
|
||||
search,
|
||||
viewSearch,
|
||||
}
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
import { search as stringSearch } from "./utils"
|
||||
import * as controller from "../view"
|
||||
import { ViewV2, UserCtx, UISearchFilter, PublicAPIView } from "@budibase/types"
|
||||
import { Next } from "koa"
|
||||
import { merge } from "lodash"
|
||||
|
||||
function viewRequest(view: PublicAPIView, params?: { viewId: string }) {
|
||||
const viewV2: ViewV2 = view
|
||||
if (!viewV2) {
|
||||
return viewV2
|
||||
}
|
||||
if (params?.viewId) {
|
||||
viewV2.id = params.viewId
|
||||
}
|
||||
if (!view.query) {
|
||||
viewV2.query = {}
|
||||
} else {
|
||||
// public API only has one form of query
|
||||
viewV2.queryUI = viewV2.query as UISearchFilter
|
||||
}
|
||||
viewV2.version = 2
|
||||
return viewV2
|
||||
}
|
||||
|
||||
function viewResponse(view: ViewV2): PublicAPIView {
|
||||
// remove our internal structure - always un-necessary
|
||||
delete view.query
|
||||
return {
|
||||
...view,
|
||||
query: view.queryUI,
|
||||
}
|
||||
}
|
||||
|
||||
function viewsResponse(views: ViewV2[]): PublicAPIView[] {
|
||||
return views.map(viewResponse)
|
||||
}
|
||||
|
||||
export async function search(ctx: UserCtx, next: Next) {
|
||||
const { name } = ctx.request.body
|
||||
await controller.v2.fetch(ctx)
|
||||
ctx.body.data = viewsResponse(stringSearch(ctx.body.data, name))
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function create(ctx: UserCtx, next: Next) {
|
||||
ctx = merge(ctx, {
|
||||
request: {
|
||||
body: viewRequest(ctx.request.body),
|
||||
},
|
||||
})
|
||||
await controller.v2.create(ctx)
|
||||
ctx.body.data = viewResponse(ctx.body.data)
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function read(ctx: UserCtx, next: Next) {
|
||||
ctx = merge(ctx, {
|
||||
params: {
|
||||
viewId: ctx.params.viewId,
|
||||
},
|
||||
})
|
||||
await controller.v2.get(ctx)
|
||||
ctx.body.data = viewResponse(ctx.body.data)
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function update(ctx: UserCtx, next: Next) {
|
||||
const viewId = ctx.params.viewId
|
||||
ctx = merge(ctx, {
|
||||
request: {
|
||||
body: {
|
||||
data: viewRequest(ctx.request.body, { viewId }),
|
||||
},
|
||||
},
|
||||
params: {
|
||||
viewId,
|
||||
},
|
||||
})
|
||||
await controller.v2.update(ctx)
|
||||
ctx.body.data = viewResponse(ctx.body.data)
|
||||
await next()
|
||||
}
|
||||
|
||||
export async function destroy(ctx: UserCtx, next: Next) {
|
||||
await controller.v2.remove(ctx)
|
||||
await next()
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
read,
|
||||
update,
|
||||
destroy,
|
||||
search,
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue