Merge pull request #10774 from Budibase/develop

May Release
This commit is contained in:
Rory Powell 2023-06-06 07:23:51 +01:00 committed by GitHub
commit b2575ea352
405 changed files with 17295 additions and 5213 deletions

View File

@ -1,98 +1,149 @@
name: Budibase CI
on:
# Trigger the workflow on push or pull request,
# but only for the master branch
push:
branches:
- master
- develop
pull_request:
on:
# Trigger the workflow on push or pull request,
# but only for the master branch
push:
branches:
- master
- develop
workflow_dispatch:
pull_request:
branches:
- master
- develop
workflow_dispatch:
env:
BRANCH: ${{ github.event.pull_request.head.ref }}
BASE_BRANCH: ${{ github.event.pull_request.base.ref}}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
uses: actions/setup-node@v1
with:
node-version: 14.x
- run: yarn
- run: yarn lint
- uses: actions/checkout@v3
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn lint
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
cache: "yarn"
- run: yarn
- run: yarn bootstrap
# Run build all the projects
- run: yarn build
# Check the types of the projects built via esbuild
- run: yarn check:types
test:
test-libraries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
cache: "yarn"
- run: yarn
- run: yarn bootstrap
- run: yarn build
- run: yarn test
- run: yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }} # not required for public repos
name: codecov-umbrella
verbose: true
test-services:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v3
with:
node-version: 14.x
cache: "yarn"
- run: yarn
- run: yarn test --scope=@budibase/worker --scope=@budibase/server
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella
verbose: true
test-pro:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
cache: "yarn"
- run: yarn
- run: yarn bootstrap
- run: yarn test:pro
- run: yarn test --scope=@budibase/pro
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 14.x
uses: actions/setup-node@v1
uses: actions/setup-node@v3
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro $BRANCH $BASE_BRANCH
- run: yarn && yarn bootstrap && yarn build
- run: |
cache: "yarn"
- run: yarn
- run: yarn build
- name: Run tests
run: |
cd qa-core
yarn setup
yarn test:ci
env:
BB_ADMIN_USER_EMAIL: admin
BB_ADMIN_USER_PASSWORD: admin
check-pro-submodule:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Check submodule
run: |
cd packages/pro
git fetch
if ! git merge-base --is-ancestor $(git log -n 1 --pretty=format:%H) origin/develop; then
echo "Current commit has not been merged to develop"
echo "Refer to the pro repo to merge your changes: https://github.com/Budibase/budibase-pro/blob/develop/docs/getting_started.md"
exit 1
else
echo "All good, the submodule had been merged!"
fi

View File

@ -1,21 +1,13 @@
name: Budibase Prerelease
concurrency: release-prerelease
concurrency:
group: release-prerelease
cancel-in-progress: false
on:
push:
branches:
- develop
paths:
- '.aws/**'
- '.github/**'
- 'charts/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'yarn.lock'
- 'package.json'
- 'yarn.lock'
workflow_dispatch:
on:
push:
tags:
- v*-alpha.*
workflow_dispatch:
env:
# Posthog token used by ui at build time
@ -24,43 +16,60 @@ env:
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
FEATURE_PREVIEW_URL: https://budirelease.live
jobs:
release-images:
runs-on: ubuntu-latest
runs-on: ubuntu-latest
steps:
- name: Fail if branch is not develop
if: github.ref != 'refs/heads/develop'
run: |
echo "Ref is not develop, you must run this job from develop."
exit 1
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if tag is not develop
run: |
if ! git merge-base --is-ancestor ${{ github.sha }} origin/develop; then
echo "Tag is not in develop"
exit 1
fi
- uses: actions/setup-node@v1
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro develop
- run: yarn
- run: yarn bootstrap
- run: yarn build
- run: yarn install --frozen-lockfile
- name: Update versions
run: |
version=$(cat lerna.json \
| grep version \
| head -1 \
| awk -F: '{gsub(/"/,"",$2);gsub(/[[:space:]]*/,"",$2); print $2}' \
| sed 's/[",]//g')
echo "Setting version $version"
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
echo "Updating dependencies"
node scripts/syncLocalDependencies.js $version
echo "Syncing yarn workspace"
yarn
- run: yarn build --configuration=production
- run: yarn build:sdk
# - run: yarn test
- name: Publish budibase packages to NPM
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
run: |
# setup the username and email.
git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>"
git submodule foreach git commit -a -m 'Release process'
git commit -a -m 'Release process'
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release:develop
- name: Build/release Docker images
run: |
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker:develop
env:
@ -84,7 +93,7 @@ jobs:
git config user.name "Budibase Helm Bot"
git config user.email "<>"
git reset --hard
git pull
git fetch
mkdir sync
echo "Packaging chart to sync dir"
helm package charts/budibase --version 0.0.0-develop --app-version develop --destination sync

View File

@ -1,60 +1,65 @@
name: Budibase Release
concurrency: release
concurrency:
group: release
cancel-in-progress: false
on:
push:
branches:
- master
paths:
- '.aws/**'
- '.github/**'
- 'charts/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'yarn.lock'
- 'package.json'
- 'yarn.lock'
workflow_dispatch:
inputs:
versioning:
type: choice
description: "Versioning type: patch, minor, major"
default: patch
options:
- patch
- minor
- major
required: true
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+"
# Exclude all pre-releases
- "!v*[0-9]+.[0-9]+.[0-9]+-*"
workflow_dispatch:
inputs:
tags:
description: "Release tag"
required: true
type: boolean
env:
# Posthog token used by ui at build time
# Posthog token used by ui at build time
POSTHOG_TOKEN: phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
jobs:
release-images:
runs-on: ubuntu-latest
steps:
- name: Fail if branch is not master
if: github.ref != 'refs/heads/master'
run: |
echo "Ref is not master, you must run this job from master."
exit 1
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
fetch-depth: 0
- name: Fail if branch is not master
if: github.ref != 'refs/heads/master'
run: |
echo "Ref is not master, you must run this job from master."
// Change to "exit 1" when merged. Left to 0 to not fail all the pipelines and not to cause noise
exit 0
- uses: actions/setup-node@v1
with:
node-version: 14.x
- name: Install Pro
run: yarn install:pro master
- run: yarn
- run: yarn bootstrap
- run: yarn install --frozen-lockfile
- name: Update versions
run: |
version=$(cat lerna.json \
| grep version \
| head -1 \
| awk -F: '{gsub(/"/,"",$2);gsub(/[[:space:]]*/,"",$2); print $2}' \
| sed 's/[",]//g')
echo "Setting version $version"
yarn lerna exec "yarn version --no-git-tag-version --new-version=$version"
echo "Updating dependencies"
node scripts/syncLocalDependencies.js $version
echo "Syncing yarn workspace"
yarn
- run: yarn lint
- run: yarn build
- run: yarn build --configuration=production
- run: yarn build:sdk
- name: Publish budibase packages to NPM
@ -65,15 +70,17 @@ jobs:
# setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default
git config --global user.name "Budibase Release Bot"
git config --global user.email "<>"
git submodule foreach git commit -a -m 'Release process'
git commit -a -m 'Release process'
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc
yarn release
- name: 'Get Previous tag'
- name: "Get Previous tag"
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
- name: Build/release Docker images
run: |
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker
env:
@ -103,7 +110,7 @@ jobs:
git config user.name "Budibase Helm Bot"
git config user.email "<>"
git reset --hard
git pull
git fetch
mkdir sync
echo "Packaging chart to sync dir"
helm package charts/budibase --version 0.0.0-master --app-version v"$RELEASE_VERSION" --destination sync

41
.github/workflows/tag-prerelease.yml vendored Normal file
View File

@ -0,0 +1,41 @@
name: Tag prerelease
concurrency:
group: tag-prerelease
cancel-in-progress: false
on:
push:
branches:
- develop
paths:
- ".aws/**"
- ".github/**"
- "charts/**"
- "packages/**"
- "scripts/**"
- "package.json"
- "yarn.lock"
workflow_dispatch:
jobs:
tag-prerelease:
runs-on: ubuntu-latest
steps:
- name: Fail if branch is not develop
if: github.ref != 'refs/heads/develop'
run: |
echo "Ref is not develop, you must run this job from develop."
exit 1
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- run: yarn
- name: Tag prerelease
run: |
# setup the username and email.
git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>"
./scripts/versionCommit.sh prerelease

51
.github/workflows/tag-release.yml vendored Normal file
View File

@ -0,0 +1,51 @@
name: Tag release
concurrency:
group: tag-release
cancel-in-progress: false
on:
push:
branches:
- master
paths:
- ".aws/**"
- ".github/**"
- "charts/**"
- "packages/**"
- "scripts/**"
- "package.json"
- "yarn.lock"
workflow_dispatch:
inputs:
versioning:
type: choice
description: "Versioning type: patch, minor, major"
default: patch
options:
- patch
- minor
- major
required: true
jobs:
tag-prerelease:
runs-on: ubuntu-latest
steps:
- name: Fail if branch is not master
if: github.ref != 'refs/heads/master'
run: |
echo "Ref is not master, you must run this job from master."
exit 1
- uses: actions/checkout@v2
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
- run: yarn
- name: Tag prerelease
run: |
# setup the username and email.
git config --global user.name "Budibase Staging Release Bot"
git config --global user.email "<>"
./scripts/versionCommit.sh ${{ github.event.inputs.versioning }}

3
.gitmodules vendored
View File

@ -0,0 +1,3 @@
[submodule "packages/pro"]
path = packages/pro
url = git@github.com:Budibase/budibase-pro.git

4
.husky/post-checkout Executable file
View File

@ -0,0 +1,4 @@
# .husky/post-checkout
# ...
git config submodule.recurse true

2
.nvmrc
View File

@ -1 +1 @@
v14.20.1
v14.20.1

View File

@ -1 +1 @@
3.10.0
3.10.0

View File

@ -144,8 +144,6 @@ The following commands can be executed to manually get Budibase up and running (
`yarn` to install project dependencies
`yarn bootstrap` will install all budibase modules and symlink them together using lerna.
`yarn build` will build all budibase packages.
#### 4. Running
@ -243,7 +241,7 @@ An overview of the CI pipelines can be found [here](../.github/workflows/README.
Note that only budibase maintainers will be able to access the pro repo.
The `yarn bootstrap` command can be used to replace the NPM supplied dependency with the local source aware version. This is achieved using the `yarn link` command. To see specifically how dependencies are linked see [scripts/link-dependencies.sh](../scripts/link-dependencies.sh). The same link script is used to link dependencies to account-portal in local dev.
By default, NX will make sure that dependencies are replaced with local source aware version. This is achieved using the `yarn link` command. To see specifically how dependencies are linked see [scripts/link-dependencies.sh](../scripts/link-dependencies.sh). The same link script is used to link dependencies to account-portal in local dev.
### Troubleshooting

View File

@ -1,13 +1,17 @@
## Dev Environment on Debian 11
### Install NVM & Node 14
NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating
Install NVM
```
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
```
Install Node 14
```
nvm install 14
```
@ -17,13 +21,16 @@ nvm install 14
```
npm install -g yarn jest lerna
```
### Install Docker and Docker Compose
```
apt install docker.io
pip3 install docker-compose
```
### Clone the repo
```
git clone https://github.com/Budibase/budibase.git
```
@ -44,10 +51,13 @@ This setup process was tested on Debian 11 (bullseye) with version numbers show
cd budibase
yarn setup
```
The yarn setup command runs several build steps i.e.
```
node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev
```
So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose.
The dev version will be available on port 10000 i.e.
@ -55,6 +65,7 @@ The dev version will be available on port 10000 i.e.
http://127.0.0.1:10000/builder/admin
### File descriptor issues with Vite and Chrome in Linux
If your dev environment stalls forever, with some network requests stuck in flight, it's likely that Chrome is trying to open more file descriptors than your system allows.
To fix this, apply the following tweaks.
@ -62,4 +73,4 @@ Debian based distros:
Add `* - nofile 65536` to `/etc/security/limits.conf`.
Arch:
Add `DefaultLimitNOFILE=65536` to `/etc/systemd/system.conf`.
Add `DefaultLimitNOFILE=65536` to `/etc/systemd/system.conf`.

View File

@ -4,14 +4,14 @@
Install instructions [here](https://brew.sh/)
| **NOTE**: If you are working on a M1 Apple Silicon which is running Z shell, you could need to add
`eval $(/opt/homebrew/bin/brew shellenv)` line to your `.zshrc`. This will make your zsh to find the apps you install
| **NOTE**: If you are working on a M1 Apple Silicon which is running Z shell, you could need to add
`eval $(/opt/homebrew/bin/brew shellenv)` line to your `.zshrc`. This will make your zsh to find the apps you install
through brew.
### Install Node
Budibase requires a recent version of node 14:
```
brew install node npm
node -v
@ -22,12 +22,15 @@ node -v
```
npm install -g yarn jest lerna
```
### Install Docker and Docker Compose
```
brew install docker docker-compose
```
### Clone the repo
```
git clone https://github.com/Budibase/budibase.git
```
@ -48,10 +51,13 @@ This setup process was tested on Mac OSX 12 (Monterey) with version numbers show
cd budibase
yarn setup
```
The yarn setup command runs several build steps i.e.
```
node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev
```
So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose.
The dev version will be available on port 10000 i.e.

View File

@ -1,13 +1,15 @@
## Dev Environment on Windows 10/11 (WSL2)
### Install WSL with Ubuntu LTS
Enable WSL 2 on Windows 10/11 for docker support.
```
wsl --set-default-version 2
```
Install Ubuntu LTS.
```
wsl --install Ubuntu
```
@ -16,6 +18,7 @@ Or follow the instruction here:
https://learn.microsoft.com/en-us/windows/wsl/install
### Install Docker in windows
Download the installer from docker and install it.
Check this url for more detailed instructions:
@ -24,18 +27,21 @@ https://docs.docker.com/desktop/install/windows-install/
You should follow the next steps from within the Ubuntu terminal.
### Install NVM & Node 14
NVM documentation: https://github.com/nvm-sh/nvm#installing-and-updating
Install NVM
```
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
```
Install Node 14
```
nvm install 14
```
### Install npm requirements
```
@ -43,6 +49,7 @@ npm install -g yarn jest lerna
```
### Clone the repo
```
git clone https://github.com/Budibase/budibase.git
```
@ -63,10 +70,13 @@ This setup process was tested on Windows 11 with version numbers show below. You
cd budibase
yarn setup
```
The yarn setup command runs several build steps i.e.
```
node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev
```
So this command will actually run the application in dev mode. It creates .env files under `./packages/server` and `./packages/worker` and runs docker containers for each service via docker-compose.
The dev version will be available on port 10000 i.e.
@ -74,8 +84,9 @@ The dev version will be available on port 10000 i.e.
http://127.0.0.1:10000/builder/admin
### Working with the code
Here are the instructions to work on the application from within Visual Studio Code (in Windows) through the WSL. All the commands and files are within the Ubuntu system and it should run as if you were working on a Linux machine.
https://code.visualstudio.com/docs/remote/wsl
Note you will be able to run the application from within the WSL terminal and you will be able to access the application from the a browser in Windows.
Note you will be able to run the application from within the WSL terminal and you will be able to access the application from the a browser in Windows.

View File

@ -0,0 +1,77 @@
version: "3"
# optional ports are specified throughout for more advanced use cases.
services:
app-service:
build: ../packages/server
container_name: build-bbapps
environment:
SELF_HOSTED: 1
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
WORKER_URL: http://worker-service:4003
MINIO_URL: http://minio-service:9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
BUDIBASE_ENVIRONMENT: ${BUDIBASE_ENVIRONMENT}
PORT: 4002
API_ENCRYPTION_KEY: ${API_ENCRYPTION_KEY}
JWT_SECRET: ${JWT_SECRET}
LOG_LEVEL: info
SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131
ENABLE_ANALYTICS: "true"
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
BB_ADMIN_USER_EMAIL: ${BB_ADMIN_USER_EMAIL}
BB_ADMIN_USER_PASSWORD: ${BB_ADMIN_USER_PASSWORD}
PLUGINS_DIR: ${PLUGINS_DIR}
depends_on:
- worker-service
- redis-service
# volumes:
# - /some/path/to/plugins:/plugins
worker-service:
build: ../packages/worker
container_name: build-bbworker
environment:
SELF_HOSTED: 1
PORT: 4003
CLUSTER_PORT: ${MAIN_PORT}
API_ENCRYPTION_KEY: ${API_ENCRYPTION_KEY}
JWT_SECRET: ${JWT_SECRET}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
MINIO_URL: http://minio-service:9000
APPS_URL: http://app-service:4002
COUCH_DB_USERNAME: ${COUCH_DB_USER}
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131
INTERNAL_API_KEY: ${INTERNAL_API_KEY}
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on:
- redis-service
- minio-service
proxy-service-docker:
ports:
- "${MAIN_PORT}:10000"
container_name: build-bbproxy
image: budibase/proxy
environment:
- PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
- PROXY_RATE_LIMIT_API_PER_SECOND=20
- APPS_UPSTREAM_URL=http://app-service:4002
- 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
- worker-service
- app-service
- couchdb-service

View File

@ -1,22 +1,22 @@
FROM node:14-slim as build
FROM node:16-slim as build
# install node-gyp dependencies
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends apt-utils cron g++ make python
# add pin script
WORKDIR /
ADD scripts/pinVersions.js scripts/cleanup.sh ./
ADD scripts/cleanup.sh ./
RUN chmod +x /cleanup.sh
# build server
WORKDIR /app
ADD packages/server .
RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh
RUN yarn install --frozen-lockfile --production=true && /cleanup.sh
# build worker
WORKDIR /worker
ADD packages/worker .
RUN node /pinVersions.js && yarn && yarn build && /cleanup.sh
RUN yarn install --frozen-lockfile --production=true && /cleanup.sh
FROM budibase/couchdb
ARG TARGETARCH
@ -31,9 +31,7 @@ COPY --from=build /worker /worker
# install base dependencies
RUN apt-get update && \
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server && \
apt-add-repository 'deb http://security.debian.org/debian-security bullseye-security/updates main' && \
apt-get update
apt-get install -y --no-install-recommends software-properties-common nginx uuid-runtime redis-server
# install other dependencies, nodejs, oracle requirements, jdk8, redis, nginx
WORKDIR /nodejs

View File

@ -1,8 +1,22 @@
{
"version": "2.6.23",
"version": "2.6.24-alpha.0",
"npmClient": "yarn",
"packages": [
"packages/backend-core",
"packages/bbui",
"packages/builder",
"packages/cli",
"packages/client",
"packages/frontend-core",
"packages/sdk",
"packages/server",
"packages/shared-core",
"packages/string-templates",
"packages/types",
"packages/worker",
"packages/pro/packages/pro"
],
"useWorkspaces": true,
"packages": ["packages/*"],
"command": {
"publish": {
"ignoreChanges": [
@ -17,4 +31,4 @@
"loadEnvFiles": false
}
}
}
}

10
nx.json
View File

@ -6,5 +6,15 @@
"cacheableOperations": ["build", "test"]
}
}
},
"targetDefaults": {
"dev:builder": {
"dependsOn": [
{
"projects": ["@budibase/string-templates"],
"target": "build"
}
]
}
}
}

View File

@ -2,37 +2,44 @@
"name": "root",
"private": true,
"devDependencies": {
"@esbuild-plugins/node-resolve": "^0.2.2",
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/js": "16.2.1",
"@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0",
"babel-eslint": "^10.0.3",
"esbuild": "^0.17.18",
"eslint": "^7.28.0",
"eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-svelte3": "^3.2.0",
"husky": "^7.0.1",
"husky": "^8.0.3",
"js-yaml": "^4.1.0",
"kill-port": "^1.6.1",
"lerna": "^6.6.1",
"lerna": "7.0.0-alpha.0",
"madge": "^6.0.0",
"minimist": "^1.2.8",
"nx": "^16.2.1",
"prettier": "^2.3.1",
"prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0",
"semver": "^7.5.0",
"svelte": "^3.38.2",
"typescript": "4.7.3"
},
"scripts": {
"setup": "node ./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev",
"bootstrap": "lerna link && ./scripts/link-dependencies.sh",
"build": "lerna run --stream build",
"build:dev": "lerna run --stream prebuild && tsc --build --watch --preserveWatchOutput",
"preinstall": "node scripts/syncProPackage.js",
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"bootstrap": "./scripts/link-dependencies.sh && echo '***BOOTSTRAP ONLY REQUIRED FOR USE WITH ACCOUNT PORTAL***'",
"build": "yarn nx run-many -t=build",
"build:dev": "lerna run --stream prebuild && yarn nx run-many --target=build --output-style=dynamic --watch --preserveWatchOutput",
"check:types": "lerna run check:types --skip-nx-cache",
"backend:bootstrap": "./scripts/scopeBackend.sh && yarn run bootstrap",
"backend:build": "./scripts/scopeBackend.sh 'lerna run --stream build'",
"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 ${RELEASE_VERSION_TYPE:-patch} --yes --force-publish && yarn release:pro",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop --exact && yarn release:pro:develop",
"release:pro": "bash scripts/pro/release.sh",
"release:pro:develop": "bash scripts/pro/release.sh develop",
"release": "lerna publish ${RELEASE_VERSION_TYPE:-patch} --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
"release:develop": "lerna publish from-package --yes --force-publish --dist-tag develop --exact --no-git-tag-version --no-push --no-git-reset",
"restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore",
@ -41,12 +48,12 @@
"kill-builder": "kill-port 3000",
"kill-server": "kill-port 4001 4002",
"kill-all": "yarn run kill-builder && yarn run kill-server",
"dev": "yarn run kill-all && lerna link && lerna run --stream --parallel dev:builder --concurrency 1 --stream",
"dev:noserver": "yarn run kill-builder && lerna link && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --concurrency 1 --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --concurrency 1 --scope @budibase/backend-core --scope @budibase/worker --scope @budibase/server",
"dev": "yarn run kill-all && lerna run --stream --parallel dev:builder --stream",
"dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --scope @budibase/worker --scope @budibase/server",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built",
"dev:docker": "yarn build && docker-compose -f hosting/docker-compose.build.yaml -f hosting/docker-compose.dev.yaml --env-file hosting/.env up --build --scale proxy-service=0",
"test": "lerna run --stream test --stream",
"test:pro": "bash scripts/pro/test.sh",
"lint:eslint": "eslint packages && eslint qa-core",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --check \"qa-core/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier",
@ -54,16 +61,16 @@
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"",
"lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"build:specs": "lerna run --stream specs",
"build:docker": "lerna run --stream build:docker && npm run build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker": "lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker:pre": "lerna run --stream build && lerna run --stream predocker",
"build:docker:proxy": "docker build hosting/proxy -t proxy-service",
"build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && npm run build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",
"build:docker:single:multiarch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single:image": "docker build -f hosting/single/Dockerfile -t budibase:latest .",
"build:docker:single": "npm run build:docker:pre && npm run build:docker:single:image",
"build:docker:single": "yarn build && lerna run --concurrency 1 predocker && yarn build:docker:single:image",
"build:docker:dependencies": "docker build -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest ./hosting",
"publish:docker:couch": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/couchdb/Dockerfile -t budibase/couchdb:latest -t budibase/couchdb:v3.2.1 --push ./hosting/couchdb",
"publish:docker:dependencies": "docker buildx build --platform linux/arm64,linux/amd64 -f hosting/dependencies/Dockerfile -t budibase/dependencies:latest -t budibase/dependencies:v3.2.1 --push ./hosting",
@ -82,12 +89,32 @@
"mode:account": "yarn mode:cloud && yarn env:account:enable",
"security:audit": "node scripts/audit.js",
"postinstall": "husky install",
"install:pro": "bash scripts/pro/install.sh",
"dep:clean": "yarn clean && yarn bootstrap"
"dep:clean": "yarn clean -y && yarn bootstrap",
"submodules:load": "git submodule init && git submodule update && yarn && yarn bootstrap",
"submodules:unload": "git submodule deinit --all && yarn && yarn bootstrap"
},
"workspaces": {
"packages": [
"packages/*"
"packages/backend-core",
"packages/bbui",
"packages/builder",
"packages/cli",
"packages/client",
"packages/frontend-core",
"packages/sdk",
"packages/server",
"packages/shared-core",
"packages/string-templates",
"packages/types",
"packages/worker",
"packages/pro/packages/pro"
]
}
},
"resolutions": {
"@budibase/backend-core": "0.0.0",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0"
},
"dependencies": {}
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "2.6.23",
"version": "0.0.0",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -15,8 +15,6 @@
"prebuild": "rimraf dist/",
"prepack": "cp package.json dist",
"build": "tsc -p tsconfig.build.json",
"build:pro": "../../scripts/pro/build.sh",
"postbuild": "yarn run build:pro",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"test": "bash scripts/test.sh",
"test:watch": "jest --watchAll"
@ -24,7 +22,7 @@
"dependencies": {
"@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "^2.6.23",
"@budibase/types": "0.0.0",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",
@ -35,7 +33,7 @@
"correlation-id": "4.0.0",
"dotenv": "16.0.1",
"emitter-listener": "1.1.2",
"ioredis": "4.28.0",
"ioredis": "5.3.2",
"joi": "17.6.0",
"jsonwebtoken": "9.0.0",
"koa-passport": "4.1.4",
@ -64,7 +62,6 @@
"@swc/jest": "^0.2.24",
"@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3",
"@types/ioredis": "4.28.0",
"@types/jest": "29.5.0",
"@types/koa": "2.13.4",
"@types/lodash": "4.14.180",
@ -76,7 +73,7 @@
"@types/tar-fs": "2.0.1",
"@types/uuid": "8.3.4",
"chance": "1.1.8",
"ioredis-mock": "5.8.0",
"ioredis-mock": "8.7.0",
"jest": "29.5.0",
"jest-environment-node": "29.5.0",
"jest-serial-runner": "^1.2.1",
@ -90,5 +87,19 @@
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/types"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
}

View File

@ -72,16 +72,12 @@ describe("writethrough", () => {
writethrough.put({ ...current, value: 4 }),
])
// with a lock, this will work
const newRev = responses.map(x => x.rev).find(x => x !== current._rev)
expect(newRev).toBeDefined()
expect(responses.map(x => x.rev)).toEqual(
expect.arrayContaining([current._rev, current._rev, newRev])
)
expectFunctionWasCalledTimesWith(
mocks.alerts.logWarn,
2,
"Ignoring redlock conflict in write-through cache"
)
const output = await db.get(current._id)
expect(output.value).toBe(4)

View File

@ -21,7 +21,7 @@ export enum ViewName {
AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase",
USER_BY_GROUP = "by_group_user",
USER_BY_GROUP = "user_by_group",
APP_BACKUP_BY_TRIGGER = "by_trigger",
}

View File

@ -16,6 +16,7 @@ export enum Header {
LICENSE_KEY = "x-budibase-license-key",
API_VER = "x-budibase-api-version",
APP_ID = "x-budibase-app-id",
SESSION_ID = "x-budibase-session-id",
TYPE = "x-budibase-type",
PREVIEW_ROLE = "x-budibase-role",
TENANT_ID = "x-budibase-tenant-id",

View File

@ -12,7 +12,7 @@ import {
isDocument,
} from "@budibase/types"
import { getCouchInfo } from "./connections"
import { directCouchCall } from "./utils"
import { directCouchUrlCall } from "./utils"
import { getPouchDB } from "./pouchDB"
import { WriteStream, ReadStream } from "fs"
import { newid } from "../../docIds/newid"
@ -46,6 +46,8 @@ export class DatabaseImpl implements Database {
private readonly instanceNano?: Nano.ServerScope
private readonly pouchOpts: DatabaseOpts
private readonly couchInfo = getCouchInfo()
constructor(dbName?: string, opts?: DatabaseOpts, connection?: string) {
if (dbName == null) {
throw new Error("Database name cannot be undefined.")
@ -53,8 +55,8 @@ export class DatabaseImpl implements Database {
this.name = dbName
this.pouchOpts = opts || {}
if (connection) {
const couchInfo = getCouchInfo(connection)
this.instanceNano = buildNano(couchInfo)
this.couchInfo = getCouchInfo(connection)
this.instanceNano = buildNano(this.couchInfo)
}
if (!DatabaseImpl.nano) {
DatabaseImpl.init()
@ -67,7 +69,11 @@ export class DatabaseImpl implements Database {
}
async exists() {
let response = await directCouchCall(`/${this.name}`, "HEAD")
const response = await directCouchUrlCall({
url: `${this.couchInfo.url}/${this.name}`,
method: "HEAD",
cookie: this.couchInfo.cookie,
})
return response.status === 200
}

View File

@ -4,21 +4,21 @@ export const getCouchInfo = (connection?: string) => {
const urlInfo = getUrlInfo(connection)
let username
let password
if (env.COUCH_DB_USERNAME) {
// set from env
username = env.COUCH_DB_USERNAME
} else if (urlInfo.auth.username) {
if (urlInfo.auth?.username) {
// set from url
username = urlInfo.auth.username
} else if (env.COUCH_DB_USERNAME) {
// set from env
username = env.COUCH_DB_USERNAME
} else if (!env.isTest()) {
throw new Error("CouchDB username not set")
}
if (env.COUCH_DB_PASSWORD) {
// set from env
password = env.COUCH_DB_PASSWORD
} else if (urlInfo.auth.password) {
if (urlInfo.auth?.password) {
// set from url
password = urlInfo.auth.password
} else if (env.COUCH_DB_PASSWORD) {
// set from env
password = env.COUCH_DB_PASSWORD
} else if (!env.isTest()) {
throw new Error("CouchDB password not set")
}

View File

@ -9,6 +9,20 @@ export async function directCouchCall(
) {
let { url, cookie } = getCouchInfo()
const couchUrl = `${url}/${path}`
return await directCouchUrlCall({ url: couchUrl, cookie, method, body })
}
export async function directCouchUrlCall({
url,
cookie,
method,
body,
}: {
url: string
cookie: string
method: string
body?: any
}) {
const params: any = {
method: method,
headers: {
@ -19,7 +33,7 @@ export async function directCouchCall(
params.body = JSON.stringify(body)
params.headers["Content-Type"] = "application/json"
}
return await fetch(checkSlashesInUrl(encodeURI(couchUrl)), params)
return await fetch(checkSlashesInUrl(encodeURI(url)), params)
}
export async function directCouchQuery(

View File

@ -97,7 +97,6 @@ const environment = {
REDIS_URL: process.env.REDIS_URL || "localhost:6379",
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
REDIS_CLUSTERED: process.env.REDIS_CLUSTERED,
MOCK_REDIS: process.env.MOCK_REDIS,
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
AWS_REGION: process.env.AWS_REGION,
@ -129,6 +128,7 @@ const environment = {
PLUGIN_BUCKET_NAME:
process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS,
USE_COUCH: process.env.USE_COUCH || true,
MOCK_REDIS: process.env.MOCK_REDIS,
DEFAULT_LICENSE: process.env.DEFAULT_LICENSE,
SERVICE: process.env.SERVICE || "budibase",
LOG_LEVEL: process.env.LOG_LEVEL || "info",

View File

@ -21,6 +21,7 @@ export * as context from "./context"
export * as cache from "./cache"
export * as objectStore from "./objectStore"
export * as redis from "./redis"
export { Client as RedisClient } from "./redis"
export * as locks from "./redis/redlockImpl"
export * as utils from "./utils"
export * as errors from "./errors"

View File

@ -96,6 +96,7 @@ if (!env.DISABLE_PINO_LOGGER) {
const mergingObject: any = {
err: error,
pid: process.pid,
...contextObject,
}

View File

@ -6,7 +6,8 @@ let userClient: Client,
appClient: Client,
cacheClient: Client,
writethroughClient: Client,
lockClient: Client
lockClient: Client,
socketClient: Client
async function init() {
userClient = await new Client(utils.Databases.USER_CACHE).init()
@ -14,9 +15,10 @@ async function init() {
appClient = await new Client(utils.Databases.APP_METADATA).init()
cacheClient = await new Client(utils.Databases.GENERIC_CACHE).init()
lockClient = await new Client(utils.Databases.LOCKS).init()
writethroughClient = await new Client(
utils.Databases.WRITE_THROUGH,
utils.SelectableDatabase.WRITE_THROUGH
writethroughClient = await new Client(utils.Databases.WRITE_THROUGH).init()
socketClient = await new Client(
utils.Databases.SOCKET_IO,
utils.SelectableDatabase.SOCKET_IO
).init()
}
@ -27,6 +29,7 @@ export async function shutdown() {
if (cacheClient) await cacheClient.finish()
if (writethroughClient) await writethroughClient.finish()
if (lockClient) await lockClient.finish()
if (socketClient) await socketClient.finish()
}
process.on("exit", async () => {
@ -74,3 +77,10 @@ export async function getLockClient() {
}
return lockClient
}
export async function getSocketClient() {
if (!socketClient) {
await init()
}
return socketClient
}

View File

@ -1,6 +1,15 @@
import env from "../environment"
// ioredis mock is all in memory
const Redis = env.MOCK_REDIS ? require("ioredis-mock") : require("ioredis")
import Redis from "ioredis"
// mock-redis doesn't have any typing
let MockRedis: any | undefined
if (env.MOCK_REDIS) {
try {
// ioredis mock is all in memory
MockRedis = require("ioredis-mock")
} catch (err) {
console.log("Mock redis unavailable")
}
}
import {
addDbPrefix,
removeDbPrefix,
@ -18,7 +27,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
// for testing just generate the client once
let CLOSED = false
let CLIENTS: { [key: number]: any } = {}
0
let CONNECTED = false
// mock redis always connected
@ -55,6 +64,7 @@ function connectionError(
* will return the ioredis client which will be ready to use.
*/
function init(selectDb = DEFAULT_SELECT_DB) {
const RedisCore = env.MOCK_REDIS && MockRedis ? MockRedis : Redis
let timeout: NodeJS.Timeout
CLOSED = false
let client = pickClient(selectDb)
@ -64,7 +74,7 @@ function init(selectDb = DEFAULT_SELECT_DB) {
}
// testing uses a single in memory client
if (env.MOCK_REDIS) {
CLIENTS[selectDb] = new Redis(getRedisOptions())
CLIENTS[selectDb] = new RedisCore(getRedisOptions())
}
// start the timer - only allowed 5 seconds to connect
timeout = setTimeout(() => {
@ -84,11 +94,11 @@ function init(selectDb = DEFAULT_SELECT_DB) {
const { redisProtocolUrl, opts, host, port } = getRedisOptions()
if (CLUSTERED) {
client = new Redis.Cluster([{ host, port }], opts)
client = new RedisCore.Cluster([{ host, port }], opts)
} else if (redisProtocolUrl) {
client = new Redis(redisProtocolUrl)
client = new RedisCore(redisProtocolUrl)
} else {
client = new Redis(opts)
client = new RedisCore(opts)
}
// attach handlers
client.on("end", (err: Error) => {
@ -183,6 +193,9 @@ class RedisWrapper {
CLOSED = false
init(this._select)
await waitForConnection(this._select)
if (this._select && !env.isTest()) {
this.getClient().select(this._select)
}
return this
}
@ -209,6 +222,11 @@ class RedisWrapper {
return this.getClient().keys(addDbPrefix(db, pattern))
}
async exists(key: string) {
const db = this._db
return await this.getClient().exists(addDbPrefix(db, key))
}
async get(key: string) {
const db = this._db
let response = await this.getClient().get(addDbPrefix(db, key))

View File

@ -4,10 +4,10 @@ import { LockOptions, LockType } from "@budibase/types"
import * as context from "../context"
import env from "../environment"
const getClient = async (
async function getClient(
type: LockType,
opts?: Redlock.Options
): Promise<Redlock> => {
): Promise<Redlock> {
if (type === LockType.CUSTOM) {
return newRedlock(opts)
}
@ -18,6 +18,9 @@ const getClient = async (
case LockType.TRY_ONCE: {
return newRedlock(OPTIONS.TRY_ONCE)
}
case LockType.TRY_TWICE: {
return newRedlock(OPTIONS.TRY_TWICE)
}
case LockType.DEFAULT: {
return newRedlock(OPTIONS.DEFAULT)
}
@ -35,6 +38,9 @@ const OPTIONS = {
// immediately throws an error if the lock is already held
retryCount: 0,
},
TRY_TWICE: {
retryCount: 1,
},
TEST: {
// higher retry count in unit tests
// due to high contention.
@ -62,7 +68,7 @@ const OPTIONS = {
},
}
const newRedlock = async (opts: Redlock.Options = {}) => {
export async function newRedlock(opts: Redlock.Options = {}) {
let options = { ...OPTIONS.DEFAULT, ...opts }
const redisWrapper = await getLockClient()
const client = redisWrapper.getClient()
@ -81,22 +87,26 @@ type RedlockExecution<T> =
| SuccessfulRedlockExecution<T>
| UnsuccessfulRedlockExecution
export const doWithLock = async <T>(
function getLockName(opts: LockOptions) {
// determine lock name
// by default use the tenantId for uniqueness, unless using a system lock
const prefix = opts.systemLock ? "system" : context.getTenantId()
let name: string = `lock:${prefix}_${opts.name}`
// add additional unique name if required
if (opts.resource) {
name = name + `_${opts.resource}`
}
return name
}
export async function doWithLock<T>(
opts: LockOptions,
task: () => Promise<T>
): Promise<RedlockExecution<T>> => {
): Promise<RedlockExecution<T>> {
const redlock = await getClient(opts.type, opts.customOptions)
let lock
try {
// determine lock name
// by default use the tenantId for uniqueness, unless using a system lock
const prefix = opts.systemLock ? "system" : context.getTenantId()
let name: string = `lock:${prefix}_${opts.name}`
// add additional unique name if required
if (opts.resource) {
name = name + `_${opts.resource}`
}
const name = getLockName(opts)
// create the lock
lock = await redlock.lock(name, opts.ttl)
@ -112,7 +122,6 @@ export const doWithLock = async <T>(
if (opts.type === LockType.TRY_ONCE) {
// don't throw for try-once locks, they will always error
// due to retry count (0) exceeded
console.warn(e)
return { executed: false }
} else {
console.error(e)

View File

@ -27,6 +27,7 @@ export enum Databases {
GENERIC_CACHE = "data_cache",
WRITE_THROUGH = "writeThrough",
LOCKS = "locks",
SOCKET_IO = "socket_io",
}
/**
@ -40,7 +41,7 @@ export enum Databases {
*/
export enum SelectableDatabase {
DEFAULT = 0,
WRITE_THROUGH = 1,
SOCKET_IO = 1,
UNUSED_1 = 2,
UNUSED_2 = 3,
UNUSED_3 = 4,
@ -94,7 +95,7 @@ export function getRedisOptions() {
opts.port = port
opts.password = password
}
return { opts, host, port, redisProtocolUrl }
return { opts, host, port: parseInt(port), redisProtocolUrl }
}
export function addDbPrefix(db: string, key: string) {

View File

@ -5,6 +5,7 @@ import * as db from "../../db"
import { Header } from "../../constants"
import { newid } from "../../utils"
import env from "../../environment"
import { BBContext } from "@budibase/types"
describe("utils", () => {
const config = new DBTestConfiguration()
@ -106,4 +107,85 @@ describe("utils", () => {
expect(actual).toBe(undefined)
})
})
describe("isServingBuilder", () => {
let ctx: BBContext
const expectResult = (result: boolean) =>
expect(utils.isServingBuilder(ctx)).toBe(result)
beforeEach(() => {
ctx = structures.koa.newContext()
})
it("returns true if current path is in builder", async () => {
ctx.path = "/builder/app/app_"
expectResult(true)
})
it("returns false if current path doesn't have '/' suffix", async () => {
ctx.path = "/builder/app"
expectResult(false)
ctx.path = "/xx"
expectResult(false)
})
})
describe("isServingBuilderPreview", () => {
let ctx: BBContext
const expectResult = (result: boolean) =>
expect(utils.isServingBuilderPreview(ctx)).toBe(result)
beforeEach(() => {
ctx = structures.koa.newContext()
})
it("returns true if current path is in builder preview", async () => {
ctx.path = "/app/preview/xx"
expectResult(true)
})
it("returns false if current path is not in builder preview", async () => {
ctx.path = "/builder"
expectResult(false)
ctx.path = "/xx"
expectResult(false)
})
})
describe("isPublicAPIRequest", () => {
let ctx: BBContext
const expectResult = (result: boolean) =>
expect(utils.isPublicApiRequest(ctx)).toBe(result)
beforeEach(() => {
ctx = structures.koa.newContext()
})
it("returns true if current path remains to public API", async () => {
ctx.path = "/api/public/v1/invoices"
expectResult(true)
ctx.path = "/api/public/v1"
expectResult(true)
ctx.path = "/api/public/v2"
expectResult(true)
ctx.path = "/api/public/v21"
expectResult(true)
})
it("returns false if current path doesn't remain to public API", async () => {
ctx.path = "/api/public"
expectResult(false)
ctx.path = "/xx"
expectResult(false)
})
})
})

View File

@ -1,11 +1,5 @@
import { getAllApps, queryGlobalView } from "../db"
import {
Header,
MAX_VALID_DATE,
DocumentType,
SEPARATOR,
ViewName,
} from "../constants"
import { getAllApps } from "../db"
import { Header, MAX_VALID_DATE, DocumentType, SEPARATOR } from "../constants"
import env from "../environment"
import * as tenancy from "../tenancy"
import * as context from "../context"
@ -23,7 +17,9 @@ const APP_PREFIX = DocumentType.APP + SEPARATOR
const PROD_APP_PREFIX = "/app/"
const BUILDER_PREVIEW_PATH = "/app/preview"
const BUILDER_REFERER_PREFIX = "/builder/app/"
const BUILDER_PREFIX = "/builder"
const BUILDER_APP_PREFIX = `${BUILDER_PREFIX}/app/`
const PUBLIC_API_PREFIX = "/api/public/v"
function confirmAppId(possibleAppId: string | undefined) {
return possibleAppId && possibleAppId.startsWith(APP_PREFIX)
@ -69,6 +65,18 @@ export function isServingApp(ctx: Ctx) {
return false
}
export function isServingBuilder(ctx: Ctx): boolean {
return ctx.path.startsWith(BUILDER_APP_PREFIX)
}
export function isServingBuilderPreview(ctx: Ctx): boolean {
return ctx.path.startsWith(BUILDER_PREVIEW_PATH)
}
export function isPublicApiRequest(ctx: Ctx): boolean {
return ctx.path.startsWith(PUBLIC_API_PREFIX)
}
/**
* Given a request tries to find the appId, which can be located in various places
* @param {object} ctx The main request body to look through.
@ -110,7 +118,7 @@ export async function getAppIdFromCtx(ctx: Ctx) {
// make sure this is performed after prod app url resolution, in case the
// referer header is present from a builder redirect
const referer = ctx.request.headers.referer
if (!appId && referer?.includes(BUILDER_REFERER_PREFIX)) {
if (!appId && referer?.includes(BUILDER_APP_PREFIX)) {
const refererId = parseAppIdFromUrl(ctx.request.headers.referer)
appId = confirmAppId(refererId)
}

View File

@ -90,6 +90,10 @@ export const useScimIntegration = () => {
return useFeature(Feature.SCIM)
}
export const useSyncAutomations = () => {
return useFeature(Feature.SYNC_AUTOMATIONS)
}
// QUOTAS
export const setAutomationLogsQuota = (value: number) => {

View File

@ -7,11 +7,6 @@
"@budibase/types": ["../types/src"]
}
},
"references": [
{ "path": "../types" }
],
"exclude": [
"node_modules",
"dist",
]
}
"exclude": ["node_modules", "dist"]
}

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "2.6.23",
"version": "0.0.0",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,8 +38,8 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/shared-core": "^2.6.23",
"@budibase/string-templates": "^2.6.23",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1",
@ -90,5 +90,19 @@
"resolutions": {
"loader-utils": "1.4.1"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/string-templates"
],
"target": "build"
}
]
}
}
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
}

View File

@ -102,7 +102,9 @@
margin-left: 0;
transition: color ease-out 130ms;
}
.is-selected:not(.spectrum-ActionButton--emphasized):not(.spectrum-ActionButton--quiet) {
.is-selected:not(.spectrum-ActionButton--emphasized):not(
.spectrum-ActionButton--quiet
) {
background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-gray-500);
}

View File

@ -56,6 +56,8 @@ export default function positionDropdown(element, opts) {
styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") {
styles.left = anchorBounds.right + offset
} else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset
} else {
styles.left = anchorBounds.left
}

View File

@ -13,10 +13,12 @@
export let url = ""
export let disabled = false
export let initials = "JD"
export let color = null
const DefaultColor = "#3aab87"
$: color = getColor(initials)
$: avatarColor = color || getColor(initials)
$: style = getStyle(size, avatarColor)
const getColor = initials => {
if (!initials?.length) {
@ -26,6 +28,12 @@
const hue = ((code % 26) / 26) * 360
return `hsl(${hue}, 50%, 50%)`
}
const getStyle = (sizeKey, color) => {
const size = `var(${sizes.get(sizeKey)})`
const fontSize = `calc(${size} / 2)`
return `width:${size}; height:${size}; font-size:${fontSize}; background:${color};`
}
</script>
{#if url}
@ -37,13 +45,7 @@
style="width: var({sizes.get(size)}); height: var({sizes.get(size)});"
/>
{:else}
<div
class="spectrum-Avatar"
class:is-disabled={disabled}
style="width: var({sizes.get(size)}); height: var({sizes.get(
size
)}); font-size: calc(var({sizes.get(size)}) / 2); background: {color};"
>
<div class="spectrum-Avatar" class:is-disabled={disabled} {style}>
{initials || ""}
</div>
{/if}

View File

@ -2,6 +2,7 @@
import "@spectrum-css/button/dist/index-vars.css"
import Tooltip from "../Tooltip/Tooltip.svelte"
export let type
export let disabled = false
export let size = "M"
export let cta = false
@ -21,6 +22,7 @@
<button
{id}
{type}
class:spectrum-Button--cta={cta}
class:spectrum-Button--primary={primary}
class:spectrum-Button--secondary={secondary}
@ -73,6 +75,7 @@
button {
position: relative;
}
.spectrum-Button-label {
white-space: nowrap;
overflow: hidden;

View File

@ -3,11 +3,13 @@
import Button from "../Button/Button.svelte"
import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte"
import { setContext } from "svelte"
export let title
export let fillWidth
export let left = "314px"
export let width = "calc(100% - 626px)"
export let headless = false
let visible = false
@ -25,6 +27,11 @@
visible = false
}
setContext("drawer-actions", {
hide,
show,
})
const easeInOutQuad = x => {
return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2
}
@ -47,27 +54,34 @@
<section
class:fillWidth
class="drawer"
class:headless
transition:slide|local
style={`width: ${width}; left: ${left};`}
>
<header>
<div class="text">
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
<div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" />
</div>
</header>
{#if !headless}
<header>
<div class="text">
<Heading size="XS">{title}</Heading>
<Body size="S">
<slot name="description" />
</Body>
</div>
<div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" />
</div>
</header>
{/if}
<slot name="body" />
</section>
</Portal>
{/if}
<style>
.drawer.headless :global(.drawer-contents) {
height: calc(40vh + 75px);
}
.buttons {
display: flex;
gap: var(--spacing-m);

View File

@ -0,0 +1,19 @@
<script>
import { slide } from "svelte/transition"
export let error = null
</script>
<div transition:slide|local={{ duration: 130 }} class="error-message">
{error}
</div>
<style>
.error-message {
background: var(--spectrum-global-color-red-400);
color: white;
font-size: 14px;
padding: 6px 16px;
font-weight: 500;
}
</style>

View File

@ -1,7 +1,7 @@
<script>
import Icon from "../Icon/Icon.svelte"
import { getContext, onMount } from "svelte"
import { slide } from "svelte/transition"
import ErrorMessage from "./ErrorMessage.svelte"
export let disabled = false
export let error = null
@ -55,9 +55,7 @@
{/if}
</div>
{#if error}
<div transition:slide|local={{ duration: 130 }} class="error-message">
{error}
</div>
<ErrorMessage {error} />
{/if}
</div>
@ -110,13 +108,6 @@
.field {
flex: 1 1 auto;
}
.error-message {
background: var(--spectrum-global-color-red-400);
color: white;
font-size: 14px;
padding: 6px 16px;
font-weight: 500;
}
.error-icon {
flex: 0 0 auto;
}

View File

@ -4,3 +4,4 @@ export { default as FancySelect } from "./FancySelect.svelte"
export { default as FancyButton } from "./FancyButton.svelte"
export { default as FancyForm } from "./FancyForm.svelte"
export { default as FancyButtonRadio } from "./FancyButtonRadio.svelte"
export { default as ErrorMessage } from "./ErrorMessage.svelte"

View File

@ -18,10 +18,14 @@
export let ignoreTimezones = false
export let time24hr = false
export let range = false
export let flatpickr
export let useKeyboardShortcuts = true
const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper`
let open = false
let flatpickr, flatpickrOptions
let flatpickrOptions
// Another classic flatpickr issue. Errors were randomly being thrown due to
// flatpickr internal code. Making sure that "destroy" is a valid function
@ -59,6 +63,8 @@
dispatch("change", timestamp.toISOString())
}
},
onOpen: () => dispatch("open"),
onClose: () => dispatch("close"),
}
$: redrawOptions = {
@ -113,12 +119,16 @@
const onOpen = () => {
open = true
document.addEventListener("keyup", clearDateOnBackspace)
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
}
const onClose = () => {
open = false
document.removeEventListener("keyup", clearDateOnBackspace)
if (useKeyboardShortcuts) {
document.removeEventListener("keyup", clearDateOnBackspace)
}
// Manually blur all input fields since flatpickr creates a second
// duplicate input field.

View File

@ -165,7 +165,7 @@
{/if}
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
<Icon name="Delete" />
</div>
{/if}
</div>
@ -209,7 +209,7 @@
{/if}
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
<Icon name="Delete" />
</div>
{/if}
</div>

View File

@ -12,6 +12,7 @@
export let emphasized = false
export let onTop = false
export let size = "M"
export let beforeSwitch = null
let thisSelected = undefined
@ -28,9 +29,18 @@
thisSelected = selected
dispatch("select", thisSelected)
} else if ($tab.title !== thisSelected) {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
if (typeof beforeSwitch == "function") {
const proceed = beforeSwitch($tab.title)
if (proceed) {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
}
} else {
thisSelected = $tab.title
selected = $tab.title
dispatch("select", thisSelected)
}
}
if ($tab.title !== thisSelected) {
tab.update(state => {

View File

@ -31,4 +31,12 @@
.spectrum-Tooltip-tip {
border-top-color: var(--spectrum-global-color-gray-500);
}
.spectrum-Tooltip {
max-width: 280px;
}
.spectrum-Tooltip-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "2.6.23",
"version": "0.0.0",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -9,7 +9,7 @@
"dev:builder": "routify -c dev:vite",
"dev:vite": "vite --host 0.0.0.0",
"rollup": "rollup -c -w",
"test": "vitest"
"test": "vitest run"
},
"jest": {
"globals": {
@ -58,10 +58,18 @@
}
},
"dependencies": {
"@budibase/bbui": "^2.6.23",
"@budibase/frontend-core": "^2.6.23",
"@budibase/shared-core": "^2.6.23",
"@budibase/string-templates": "^2.6.23",
"@budibase/bbui": "0.0.0",
"@budibase/frontend-core": "0.0.0",
"@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0",
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/commands": "^6.2.4",
"@codemirror/lang-javascript": "^6.1.8",
"@codemirror/language": "^6.6.0",
"@codemirror/state": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.11.2",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
@ -116,5 +124,31 @@
"vite": "^3.0.8",
"vitest": "^0.29.2"
},
"nx": {
"targets": {
"build": {
"dependsOn": [
{
"projects": [
"@budibase/string-templates",
"@budibase/shared-core"
],
"target": "build"
}
]
},
"test": {
"dependsOn": [
{
"projects": [
"@budibase/shared-core",
"@budibase/string-templates"
],
"target": "build"
}
]
}
}
},
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"
}

View File

@ -77,7 +77,7 @@ export const getAuthBindings = () => {
runtime: `${safeUser}.${safeOAuth2}.${safeAccessToken}`,
readable: `Current User.OAuthToken`,
key: "accessToken",
display: { name: "OAuthToken" },
display: { name: "OAuthToken", type: "text" },
},
]
@ -434,6 +434,9 @@ export const getUserBindings = () => {
providerId: "user",
category: "Current User",
icon: "User",
display: {
name: key,
},
})
return acc
}, [])
@ -550,7 +553,7 @@ const getUrlBindings = asset => {
readableBinding: `URL.${param}`,
category: "URL",
icon: "RailTop",
display: { type: "string" },
display: { type: "string", name: param },
}))
const queryParamsBinding = {
type: "context",
@ -558,7 +561,7 @@ const getUrlBindings = asset => {
readableBinding: "Query params",
category: "URL",
icon: "RailTop",
display: { type: "object" },
display: { type: "object", name: "Query params" },
}
return urlParamBindings.concat([queryParamsBinding])
}
@ -589,7 +592,6 @@ export const getEventContextBindings = (
actionId
) => {
let bindings = []
// Check if any context bindings are provided by the component for this
// setting
const component = findComponent(asset.props, componentId)
@ -605,6 +607,9 @@ export const getEventContextBindings = (
)}`,
category: component._instanceName,
icon: def.icon,
display: {
name: contextEntry.label,
},
})
})
}
@ -628,6 +633,9 @@ export const getEventContextBindings = (
runtimeBinding: `actions.${idx}.${contextValue.value}`,
category: "Actions",
icon: "JourneyAction",
display: {
name: contextValue.label,
},
})
})
}

View File

@ -2,6 +2,7 @@ import { datasources, tables } from "../stores/backend"
import { IntegrationNames } from "../constants/backend"
import { get } from "svelte/store"
import cloneDeep from "lodash/cloneDeepWith"
import { API } from "api"
function prepareData(config) {
let datasource = {}
@ -37,3 +38,9 @@ export async function createRestDatasource(integration) {
const config = cloneDeep(integration)
return saveDatasource(config)
}
export async function validateDatasourceConfig(config) {
const datasource = prepareData(config)
const resp = await API.validateDatasource(datasource)
return resp
}

View File

@ -2,6 +2,7 @@ import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users"
import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core"
@ -12,6 +13,7 @@ export const store = getFrontendStore()
export const automationStore = getAutomationStore()
export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore()
export const userStore = getUserStore()
// Setup history for screens
export const screenHistoryStore = createHistoryStore({

View File

@ -147,6 +147,9 @@ const automationActions = store => ({
testData,
})
if (!result?.trigger && !result?.steps?.length) {
if (result?.err?.code === "usage_limit_exceeded") {
throw "You have exceeded your automation quota"
}
throw "Something went wrong testing your automation"
}
store.update(state => {

View File

@ -37,8 +37,10 @@ import {
} from "builderStore/dataBinding"
import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket"
const INITIAL_FRONTEND_STATE = {
initialised: false,
apps: [],
name: "",
url: "",
@ -69,7 +71,9 @@ const INITIAL_FRONTEND_STATE = {
customTheme: {},
previewDevice: "desktop",
highlightedSettingKey: null,
propertyFocus: null,
builderSidePanel: false,
hasLock: true,
// URL params
selectedScreenId: null,
@ -86,6 +90,7 @@ const INITIAL_FRONTEND_STATE = {
export const getFrontendStore = () => {
const store = writable({ ...INITIAL_FRONTEND_STATE })
let websocket
// This is a fake implementation of a "patch" API endpoint to try and prevent
// 409s. All screen doc mutations (aside from creation) use this function,
@ -110,10 +115,11 @@ export const getFrontendStore = () => {
store.actions = {
reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
},
initialise: async pkg => {
const { layouts, screens, application, clientLibPath } = pkg
const { layouts, screens, application, clientLibPath, hasLock } = pkg
websocket = createBuilderWebsocket(application.appId)
await store.actions.components.refreshDefinitions(application.appId)
// Reset store state
@ -137,6 +143,8 @@ export const getFrontendStore = () => {
upgradableVersion: application.upgradableVersion,
navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [],
hasLock,
initialised: true,
}))
screenHistoryStore.reset()
automationHistoryStore.reset()
@ -1319,6 +1327,12 @@ export const getFrontendStore = () => {
highlightedSettingKey: key,
}))
},
propertyFocus: key => {
store.update(state => ({
...state,
propertyFocus: key,
}))
},
},
dnd: {
start: component => {

View File

@ -0,0 +1,42 @@
import { writable, get } from "svelte/store"
export const getUserStore = () => {
const store = writable([])
const init = users => {
store.set(users)
}
const updateUser = user => {
const $users = get(store)
if (!$users.some(x => x.sessionId === user.sessionId)) {
store.set([...$users, user])
} else {
store.update(state => {
const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
}
}
const removeUser = sessionId => {
store.update(state => {
return state.filter(x => x.sessionId !== sessionId)
})
}
const reset = () => {
store.set([])
}
return {
...store,
actions: {
init,
updateUser,
removeUser,
reset,
},
}
}

View File

@ -1,3 +1,4 @@
import { ActionStepID } from "constants/backend/automations"
import { TableNames } from "../constants"
import {
AUTO_COLUMN_DISPLAY_NAMES,
@ -53,3 +54,9 @@ export function buildAutoColumn(tableName, name, subtype) {
}
return base
}
export function checkForCollectStep(automation) {
return automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
}

View File

@ -0,0 +1,53 @@
import { createWebsocket } from "@budibase/frontend-core"
import { userStore, store } from "builderStore"
import { datasources, tables } from "stores/backend"
import { get } from "svelte/store"
import { auth } from "stores/portal"
import { SocketEvent, BuilderSocketEvent } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui"
export const createBuilderWebsocket = appId => {
const socket = createWebsocket("/socket/builder")
// Built-in events
socket.on("connect", () => {
socket.emit(BuilderSocketEvent.SelectApp, { appId }, ({ users }) => {
userStore.actions.init(users)
})
})
socket.on("connect_error", err => {
console.log("Failed to connect to builder websocket:", err.message)
})
socket.on("disconnect", () => {
userStore.actions.reset()
})
// User events
socket.onOther(SocketEvent.UserUpdate, ({ user }) => {
userStore.actions.updateUser(user)
})
socket.onOther(SocketEvent.UserDisconnect, ({ sessionId }) => {
userStore.actions.removeUser(sessionId)
})
socket.onOther(BuilderSocketEvent.LockTransfer, ({ userId }) => {
if (userId === get(auth)?.user?._id) {
notifications.success("You can now edit screens and automations")
store.update(state => ({
...state,
hasLock: true,
}))
}
})
// Table events
socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => {
tables.replaceTable(id, table)
})
// Datasource events
socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource)
})
return socket
}

View File

@ -6,24 +6,48 @@
Body,
Icon,
notifications,
Tags,
Tag,
} from "@budibase/bbui"
import { automationStore } from "builderStore"
import { admin } from "stores/portal"
import { automationStore, selectedAutomation } from "builderStore"
import { admin, licensing } from "stores/portal"
import { externalActions } from "./ExternalActions"
import { TriggerStepID } from "constants/backend/automations"
import { checkForCollectStep } from "builderStore/utils"
export let blockIdx
export let lastStep
const disabled = {
SEND_EMAIL_SMTP: {
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
}
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction
let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION)
$: collectBlockExists = checkForCollectStep($selectedAutomation)
const disabled = () => {
return {
SEND_EMAIL_SMTP: {
disabled: !$admin.checklist.smtp.checked,
message: "Please configure SMTP",
},
COLLECT: {
disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists,
message: collectDisabledMessage(),
},
}
}
const collectDisabledMessage = () => {
if (collectBlockExists) {
return "Only one Collect step allowed"
}
if (!lastStep) {
return "Only available as the last step"
}
}
const external = actions.reduce((acc, elm) => {
const [k, v] = elm
if (!v.internal && !v.custom) {
@ -38,6 +62,15 @@
acc[k] = v
}
delete acc.LOOP
// Filter out Collect block if not App Action or Webhook
if (
!collectBlockAllowedSteps.includes(
$selectedAutomation.definition.trigger.stepId
)
) {
delete acc.COLLECT
}
return acc
}, {})
@ -48,7 +81,6 @@
}
return acc
}, {})
console.log(plugins)
const selectAction = action => {
actionVal = action
@ -72,7 +104,7 @@
<ModalContent
title="Add automation step"
confirmText="Save"
size="M"
size="L"
disabled={!selectedAction}
onConfirm={addBlockToAutomation}
>
@ -107,7 +139,7 @@
<Detail size="S">Actions</Detail>
<div class="item-list">
{#each Object.entries(internal) as [idx, action]}
{@const isDisabled = disabled[idx] && disabled[idx].disabled}
{@const isDisabled = disabled()[idx] && disabled()[idx].disabled}
<div
class="item"
class:disabled={isDisabled}
@ -117,8 +149,14 @@
<div class="item-body">
<Icon name={action.icon} />
<Body size="XS">{action.name}</Body>
{#if isDisabled}
<Icon name="Help" tooltip={disabled[idx].message} />
{#if isDisabled && !syncAutomationsEnabled}
<div class="tag-color">
<Tags>
<Tag icon="LockClosed">Business</Tag>
</Tags>
</div>
{:else if isDisabled}
<Icon name="Help" tooltip={disabled()[idx].message} />
{/if}
</div>
</div>
@ -152,6 +190,7 @@
display: flex;
margin-left: var(--spacing-m);
gap: var(--spacing-m);
align-items: center;
}
.item-list {
display: grid;
@ -181,4 +220,8 @@
.disabled :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-600);
}
.tag-color :global(.spectrum-Tags-item) {
background: var(--spectrum-global-color-gray-200);
}
</style>

View File

@ -17,7 +17,11 @@
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 {
ActionStepID,
TriggerStepID,
Features,
} from "constants/backend/automations"
import { permissions } from "stores/backend"
export let block
@ -31,6 +35,9 @@
let showLooping = false
let role
$: collectBlockExists = $selectedAutomation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
$: automationId = $selectedAutomation?._id
$: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW ||
@ -184,7 +191,7 @@
{#if !isTrigger}
<div>
<div class="block-options">
{#if !loopBlock}
{#if block?.features?.[Features.LOOPING] || !block.features}
<ActionButton on:click={() => addLooping()} icon="Reuse">
Add Looping
</ActionButton>
@ -224,21 +231,28 @@
</Layout>
</div>
{/if}
<Modal bind:this={actionModal} width="30%">
<ActionModal {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
</div>
<div class="separator" />
<Icon on:click={() => actionModal.show()} hoverable name="AddCircle" size="S" />
{#if isTrigger ? totalBlocks > 1 : blockIdx !== totalBlocks - 2}
{#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 {lastStep} {blockIdx} />
</Modal>
<Modal bind:this={webhookModal} width="30%">
<CreateWebhookModal />
</Modal>
<style>
.delete-padding {
padding-left: 30px;

View File

@ -52,7 +52,7 @@
await automationStore.actions.test($selectedAutomation, testData)
$automationStore.showTestPanel = true
} catch (error) {
notifications.error("Error testing automation")
notifications.error(error)
}
}
</script>

View File

@ -11,8 +11,8 @@
ActionButton,
Drawer,
Modal,
Detail,
notifications,
Icon,
} from "@budibase/bbui"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import { automationStore, selectedAutomation } from "builderStore"
@ -27,9 +27,18 @@
import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import {
bindingsToCompletions,
jsAutocomplete,
EditorModes,
} from "components/common/CodeEditor"
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte"
import { LuceneUtils } from "@budibase/frontend-core"
import { getSchemaForTable } from "builderStore/dataBinding"
import {
getSchemaForTable,
getEnvironmentBindings,
} from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte"
@ -43,7 +52,6 @@
let webhookModal
let drawer
let fillWidth = true
let codeBindingOpen = false
let inputData
$: filters = lookForFilters(schemaProperties) || []
@ -61,11 +69,63 @@
$: isTrigger = block?.type === "TRIGGER"
$: isUpdateRow = stepId === ActionStepID.UPDATE_ROW
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
let deprecatedSchemaProperties
$: {
if (block?.stepId === "integromat" || block?.stepId === "zapier") {
deprecatedSchemaProperties = schemaProperties.filter(
prop => !prop[0].startsWith("value")
)
if (!deprecatedSchemaProperties.map(entry => entry[0]).includes("body")) {
deprecatedSchemaProperties.push([
"body",
{
title: "Payload",
type: "json",
},
])
}
} else {
deprecatedSchemaProperties = schemaProperties
}
}
/****************************************************/
const getInputData = (testData, blockInputs) => {
let newInputData = testData || blockInputs
if (block.event === "app:trigger" && !newInputData?.fields) {
newInputData = cloneDeep(blockInputs)
}
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
if (
(block?.stepId === "integromat" || block?.stepId === "zapier") &&
!newInputData?.body?.value
) {
let deprecatedValues = {
...newInputData,
}
delete deprecatedValues.url
delete deprecatedValues.body
newInputData = {
url: newInputData.url,
body: {
value: JSON.stringify(deprecatedValues),
},
}
}
/**********************************/
inputData = newInputData
setDefaultEnumValues()
}
@ -158,6 +218,19 @@
}
const outputs = Object.entries(schema)
let bindingIcon = ""
let bindindingRank = 0
if (idx === 0) {
bindingIcon = automation.trigger.icon
} else if (isLoopBlock) {
bindingIcon = "Reuse"
bindindingRank = idx + 1
} else {
bindingIcon = allSteps[idx].icon
bindindingRank = idx - loopBlockCount
}
bindings = bindings.concat(
outputs.map(([name, value]) => {
let runtimeName = isLoopBlock
@ -166,17 +239,24 @@
? `steps[${idx - loopBlockCount}].${name}`
: `steps.${idx - loopBlockCount}.${name}`
const runtime = idx === 0 ? `trigger.${name}` : runtimeName
const categoryName =
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx - loopBlockCount} outputs`
return {
label: runtime,
readableBinding: runtime,
runtimeBinding: runtime,
type: value.type,
description: value.description,
category:
idx === 0
? "Trigger outputs"
: isLoopBlock
? "Loop Outputs"
: `Step ${idx - loopBlockCount} outputs`,
path: runtime,
icon: bindingIcon,
category: categoryName,
display: {
type: value.type,
name: name,
rank: bindindingRank,
},
}
})
)
@ -185,15 +265,12 @@
// Environment bindings
if ($licensing.environmentVariablesEnabled) {
bindings = bindings.concat(
$environment.variables.map(variable => {
getEnvironmentBindings().map(binding => {
return {
label: `env.${variable.name}`,
path: `env.${variable.name}`,
icon: "Key",
category: "Environment",
...binding,
display: {
type: "string",
name: variable.name,
...binding.display,
rank: 98,
},
}
})
@ -239,7 +316,7 @@
</script>
<div class="fields">
{#each schemaProperties as [key, value]}
{#each deprecatedSchemaProperties as [key, value]}
<div class="block-field">
{#if key !== "fields"}
<Label
@ -256,6 +333,28 @@
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/>
{:else if value.type === "json"}
<Editor
editorHeight="250"
editorWidth="448"
mode="json"
value={inputData[key]?.value}
on:change={e => {
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
delete inputData.value1
delete inputData.value2
delete inputData.value3
delete inputData.value4
delete inputData.value5
/***********************/
onChange(e, key)
}}
/>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
@ -363,25 +462,27 @@
<SchemaSetup on:change={e => onChange(e, key)} value={inputData[key]} />
{:else if value.customType === "code"}
<CodeEditorModal>
<ActionButton
on:click={() => (codeBindingOpen = !codeBindingOpen)}
quiet
icon={codeBindingOpen ? "ChevronDown" : "ChevronRight"}
>
<Detail size="S">Bindings</Detail>
</ActionButton>
{#if codeBindingOpen}
<pre>{JSON.stringify(bindings, null, 2)}</pre>
{/if}
<Editor
mode="javascript"
<CodeEditor
value={inputData[key]}
on:change={e => {
// need to pass without the value inside
onChange({ detail: e.detail.value }, key)
inputData[key] = e.detail.value
onChange({ detail: e.detail }, key)
inputData[key] = e.detail
}}
value={inputData[key]}
completions={[
jsAutocomplete([
...bindingsToCompletions(bindings, EditorModes.JS),
]),
]}
mode={EditorModes.JS}
height={500}
/>
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>Add available bindings by typing <strong>$</strong></div>
</div>
</div>
</CodeEditorModal>
{:else if value.customType === "loopOption"}
<Select
@ -431,6 +532,11 @@
{/if}
<style>
.messaging {
display: flex;
align-items: center;
margin-top: var(--spacing-xl);
}
.fields {
display: flex;
flex-direction: column;

View File

@ -32,10 +32,12 @@
<Grid
{API}
tableId={id}
tableType={$tables.selected?.type}
allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
on:updatetable={e => tables.updateTable(e.detail)}
showAvatars={false}
on:updatetable={e => tables.replaceTable(id, e.detail)}
>
<svelte:fragment slot="controls">
{#if isInternal}

View File

@ -3,6 +3,7 @@
export let query = {}
export let data = []
export let editRows = false
let loading = false
let error = false
@ -12,7 +13,14 @@
{#if error}
<div class="errors">{error}</div>
{/if}
<Table schema={query.schema} {data} {loading} {type} rowCount={5} />
<Table
schema={query.schema}
{data}
{loading}
{type}
rowCount={5}
allowEditing={editRows}
/>
<style>
.errors {

View File

@ -81,6 +81,7 @@
<Label>{label}</Label>
<Editor
editorHeight="250"
editorWidth="320"
mode="json"
on:change={({ detail }) => (value = detail.value)}
value={stringVal}

View File

@ -23,6 +23,7 @@
export let disableSorting = false
export let customPlaceholder = false
export let allowEditing = true
export let allowClickRows
const dispatch = createEventDispatcher()
@ -112,6 +113,7 @@
{customPlaceholder}
allowEditRows={allowEditing}
showAutoColumns={!hideAutocolumns}
{allowClickRows}
on:clickrelationship={e => selectRelationship(e.detail)}
on:sort
>

View File

@ -3,6 +3,7 @@
import ImportModal from "../modals/ImportModal.svelte"
export let tableId
export let tableType
export let disabled
let modal
@ -12,5 +13,5 @@
Import
</ActionButton>
<Modal bind:this={modal}>
<ImportModal {tableId} on:importrows />
<ImportModal {tableId} {tableType} on:importrows />
</Modal>

View File

@ -4,11 +4,12 @@
export let disabled = false
const { rows, tableId } = getContext("grid")
const { rows, tableId, tableType } = getContext("grid")
</script>
<ImportButton
{disabled}
tableId={$tableId}
{tableType}
on:importrows={rows.actions.refreshData}
/>

View File

@ -113,17 +113,26 @@
})
download(data, `export.${exportFormat}`)
} else if (filters || sorting) {
const data = await API.exportRows({
tableId: view,
format: exportFormat,
search: {
query: luceneFilter,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,
},
})
download(data, `export.${exportFormat}`)
let response
try {
response = await API.exportRows({
tableId: view,
format: exportFormat,
search: {
query: luceneFilter,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,
},
})
} catch (e) {
console.error("Failed to export", e)
notifications.error("Export Failed")
}
if (response) {
download(response, `export.${exportFormat}`)
notifications.success("Export Successful")
}
} else {
await exportView()
}

View File

@ -13,15 +13,18 @@
const dispatch = createEventDispatcher()
export let tableId
export let tableType
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
async function importData() {
try {
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
} catch (error) {
@ -45,6 +48,13 @@
</Body>
<Layout gap="XS" noPadding>
<Label grey extraSmall>CSV or JSON file to import</Label>
<TableDataImport {tableId} bind:rows bind:allValid bind:displayColumn />
<TableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
</Layout>
</ModalContent>

View File

@ -1,254 +0,0 @@
<script>
import {
ModalContent,
Modal,
Body,
Layout,
Detail,
Heading,
notifications,
} from "@budibase/bbui"
import { onMount } from "svelte"
import ICONS from "../icons"
import { API } from "api"
import { IntegrationTypes, DatasourceTypes } from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import GoogleDatasourceConfigModal from "components/backend/DatasourceNavigator/modals/GoogleDatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
import { goto } from "@roxi/routify"
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
import DatasourceCard from "../_components/DatasourceCard.svelte"
export let modal
let integrations = {}
let integration = {}
let internalTableModal
let externalDatasourceModal
let importModal
$: showImportButton = false
$: customIntegrations = Object.entries(integrations).filter(
entry => entry[1].custom
)
$: sortedIntegrations = sortIntegrations(integrations)
checkShowImport()
onMount(() => {
fetchIntegrations()
})
function selectIntegration(integrationType) {
const selected = integrations[integrationType]
// build the schema
const config = {}
for (let key of Object.keys(selected.datasource)) {
config[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
config,
schema: selected.datasource,
auth: selected.auth,
}
if (selected.friendlyName) {
integration.name = selected.friendlyName
}
checkShowImport()
}
function checkShowImport() {
showImportButton = integration.type === "REST"
}
function showImportModal() {
importModal.show()
}
async function chooseNextModal() {
if (integration.type === IntegrationTypes.INTERNAL) {
externalDatasourceModal.hide()
internalTableModal.show()
} else if (integration.type === IntegrationTypes.REST) {
try {
// Skip modal for rest, create straight away
const resp = await createRestDatasource(integration)
$goto(`./datasource/${resp._id}`)
} catch (error) {
notifications.error("Error creating datasource")
}
} else {
externalDatasourceModal.show()
}
}
async function fetchIntegrations() {
let newIntegrations = {
[IntegrationTypes.INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
}
try {
const integrationList = await API.getIntegrations()
newIntegrations = {
...newIntegrations,
...integrationList,
}
} catch (error) {
notifications.error("Error fetching integrations")
}
integrations = newIntegrations
}
function sortIntegrations(integrations) {
let integrationsArray = Object.entries(integrations)
function getTypeOrder(schema) {
if (schema.type === DatasourceTypes.API) {
return 1
}
if (schema.type === DatasourceTypes.RELATIONAL) {
return 2
}
return schema.type?.charCodeAt(0)
}
integrationsArray.sort((a, b) => {
let typeOrderA = getTypeOrder(a[1])
let typeOrderB = getTypeOrder(b[1])
if (typeOrderA === typeOrderB) {
return a[1].friendlyName?.localeCompare(b[1].friendlyName)
}
return typeOrderA < typeOrderB ? -1 : 1
})
return integrationsArray
}
</script>
<Modal bind:this={internalTableModal}>
<CreateTableModal />
</Modal>
<Modal bind:this={externalDatasourceModal}>
{#if integration?.auth?.type === "google"}
<GoogleDatasourceConfigModal {integration} {modal} />
{:else}
<DatasourceConfigModal {integration} {modal} />
{/if}
</Modal>
<Modal bind:this={importModal}>
{#if integration.type === "REST"}
<ImportRestQueriesModal
navigateDatasource={true}
createDatasource={true}
onCancel={() => modal.show()}
/>
{/if}
</Modal>
<Modal bind:this={modal}>
<ModalContent
disabled={!Object.keys(integration).length}
title="Add datasource"
confirmText="Continue"
showSecondaryButton={showImportButton}
secondaryButtonText="Import"
secondaryAction={() => showImportModal()}
showCancelButton={false}
size="M"
onConfirm={() => {
chooseNextModal()
}}
>
<Layout noPadding gap="XS">
<Body size="S">Get started with Budibase DB</Body>
<div
class:selected={integration.type === IntegrationTypes.INTERNAL}
on:click={() => selectIntegration(IntegrationTypes.INTERNAL)}
class="item hoverable"
>
<div class="item-body with-type">
<svelte:component this={ICONS.BUDIBASE} height="20" width="20" />
<div class="text">
<Heading size="XXS">Budibase DB</Heading>
<Detail size="S" class="type">Non-relational</Detail>
</div>
</div>
</div>
</Layout>
<Layout noPadding gap="XS">
<Body size="S">Connect to an external datasource</Body>
<div class="item-list">
{#each sortedIntegrations.filter(([key, val]) => key !== IntegrationTypes.INTERNAL && !val.custom) as [integrationType, schema]}
<DatasourceCard
on:selected={evt => selectIntegration(evt.detail)}
{schema}
bind:integrationType
{integration}
/>
{/each}
</div>
</Layout>
{#if customIntegrations.length > 0}
<Layout noPadding gap="XS">
<Body size="S">Custom datasource</Body>
<div class="item-list">
{#each customIntegrations as [integrationType, schema]}
<DatasourceCard
on:selected={evt => selectIntegration(evt.detail)}
{schema}
bind:integrationType
{integration}
/>
{/each}
</div>
</Layout>
{/if}
</ModalContent>
</Modal>
<style>
.item-list {
display: grid;
grid-template-columns: repeat(2, minmax(150px, 1fr));
grid-gap: var(--spectrum-alias-grid-baseline);
}
.item {
cursor: pointer;
display: grid;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s)
var(--spectrum-alias-item-padding-m);
background: var(--spectrum-alias-background-color-secondary);
transition: background 0.13s ease-out;
border-radius: 5px;
box-sizing: border-box;
border-width: 2px;
}
.item:hover,
.item.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.item-body {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
.item-body.with-type {
align-items: flex-start;
}
.item-body.with-type :global(svg) {
margin-top: 4px;
}
.text :global(.spectrum-Detail) {
color: var(--spectrum-global-color-gray-700);
}
</style>

View File

@ -4,55 +4,66 @@
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { IntegrationNames } from "constants/backend"
import cloneDeep from "lodash/cloneDeepWith"
import { saveDatasource as save } from "builderStore/datasource"
import { onMount } from "svelte"
import {
saveDatasource as save,
validateDatasourceConfig,
} from "builderStore/datasource"
import { DatasourceFeature } from "@budibase/types"
export let integration
export let modal
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
let skipFetch = false
let isValid = false
$: name =
IntegrationNames[datasource.type] || datasource.name || datasource.type
async function validateConfig() {
const displayError = message =>
notifications.error(message ?? "Error validating datasource")
let connected = false
try {
const resp = await validateDatasourceConfig(datasource)
if (!resp.connected) {
displayError(`Unable to connect - ${resp.error}`)
}
connected = resp.connected
} catch (err) {
displayError(err?.message)
}
return connected
}
async function saveDatasource() {
if (integration.features[DatasourceFeature.CONNECTION_CHECKING]) {
const valid = await validateConfig()
if (!valid) {
return false
}
}
try {
if (!datasource.name) {
datasource.name = name
}
const resp = await save(datasource, skipFetch)
const resp = await save(datasource)
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource updated successfully.`)
notifications.success(`Datasource created successfully.`)
} catch (err) {
notifications.error(err?.message ?? "Error saving datasource")
// prevent the modal from closing
return false
}
}
onMount(() => {
skipFetch = false
})
</script>
<ModalContent
title={`Connect to ${name}`}
onConfirm={() => saveDatasource()}
onCancel={() => modal.show()}
confirmText={datasource.plus
? "Save and fetch tables"
: "Save and continue to query"}
confirmText={datasource.plus ? "Connect" : "Save and continue to query"}
cancelText="Back"
showSecondaryButton={datasource.plus}
secondaryButtonText={datasource.plus ? "Skip table fetch" : undefined}
secondaryAction={() => {
skipFetch = true
saveDatasource()
return true
}}
size="L"
disabled={!isValid}
>

View File

@ -8,7 +8,6 @@
import { onMount } from "svelte"
export let integration
export let modal
// kill the reference so the input isn't saved
let datasource = cloneDeep(integration)
@ -21,7 +20,6 @@
<ModalContent
title={`Connect to ${IntegrationNames[datasource.type]}`}
onCancel={() => modal.show()}
cancelText="Back"
size="L"
>

View File

@ -1,5 +1,5 @@
<script>
import { Select } from "@budibase/bbui"
import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { FIELDS } from "constants/backend"
import { API } from "api"
import { parseFile } from "./utils"
@ -9,14 +9,17 @@
let fileType = null
let loading = false
let updateExistingRows = false
let validation = {}
let validateHash = ""
let schema = null
let invalidColumns = []
export let tableId = null
export let tableType
export let rows = []
export let allValid = false
export let identifierFields = []
const typeOptions = [
{
@ -159,6 +162,22 @@
</div>
{/each}
</div>
{#if tableType === "internal"}
<br />
<Toggle
bind:value={updateExistingRows}
on:change={() => (identifierFields = [])}
thin
text="Update existing rows"
/>
{#if updateExistingRows}
<Multiselect
label="Identifier field(s)"
options={Object.keys(validation)}
bind:value={identifierFields}
/>
{/if}
{/if}
{#if invalidColumns.length > 0}
<p class="spectrum-FieldLabel spectrum-FieldLabel--sizeM">
The following columns are present in the data you wish to import, but do

View File

@ -4,6 +4,7 @@
import { API } from "api"
import { parseFile } from "./utils"
let fileInput
let error = null
let fileName = null
let fileType = null
@ -16,6 +17,7 @@
export let schema = {}
export let allValid = true
export let displayColumn = null
export let promptUpload = false
const typeOptions = [
{
@ -99,10 +101,19 @@
schema[name].type = e.detail
schema[name].constraints = FIELDS[e.detail.toUpperCase()].constraints
}
const openFileUpload = (promptUpload, fileInput) => {
if (promptUpload && fileInput) {
fileInput.click()
}
}
$: openFileUpload(promptUpload, fileInput)
</script>
<div class="dropzone">
<input
bind:this={fileInput}
disabled={loading}
id="file-upload"
accept="text/csv,application/json"

View File

@ -6,7 +6,8 @@
import NavItem from "components/common/NavItem.svelte"
import { goto, isActive } from "@roxi/routify"
const alphabetical = (a, b) => a.name?.toLowerCase() > b.name?.toLowerCase()
const alphabetical = (a, b) =>
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
export let sourceId

View File

@ -28,6 +28,7 @@
? selectedSource._id
: BUDIBASE_INTERNAL_DB_ID
export let promptUpload = false
export let name
export let beforeSave = async () => {}
export let afterSave = async table => {
@ -136,7 +137,13 @@
<Label grey extraSmall
>Create a Table from a CSV or JSON file (Optional)</Label
>
<TableDataImport bind:rows bind:schema bind:allValid bind:displayColumn />
<TableDataImport
{promptUpload}
bind:rows
bind:schema
bind:allValid
bind:displayColumn
/>
</Layout>
</div>
</ModalContent>

View File

@ -1,143 +0,0 @@
<script>
import {
Button,
ButtonGroup,
ModalContent,
Modal,
notifications,
ProgressCircle,
Layout,
Body,
Icon,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
export let app
export let buttonSize = "M"
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
let appLockModal
let processing = false
$: lockedBy = app?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
}`
$: lockedByHeading =
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
const getExpiryDuration = app => {
if (!app?.lockedBy?.lockedAt) {
return -1
}
let expiry =
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
return expiry - new Date().getTime()
}
const releaseLock = async () => {
processing = true
if (app) {
try {
await API.releaseAppLock(app.devId)
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error("Error releasing lock")
}
} else {
notifications.error("No application is selected")
}
processing = false
}
</script>
{#if lockedBy}
<div class="lock-status">
<Icon
name="LockClosed"
hoverable
size={buttonSize}
on:click={e => {
e.stopPropagation()
appLockModal.show()
}}
/>
</div>
{/if}
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work being lost from overlapping changes
between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now.",
{
time: getExpiryDuration(app),
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
cta
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</Layout>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-l);
gap: var(--spacing-xl);
}
.lock-status {
display: flex;
gap: var(--spacing-s);
max-width: 175px;
}
</style>

View File

@ -0,0 +1,289 @@
<script>
import { Label } from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import {
autocompletion,
closeBrackets,
completionKeymap,
closeBracketsKeymap,
} from "@codemirror/autocomplete"
import {
EditorView,
lineNumbers,
keymap,
highlightSpecialChars,
drawSelection,
dropCursor,
highlightActiveLine,
highlightActiveLineGutter,
highlightWhitespace,
placeholder as placeholderFn,
MatchDecorator,
ViewPlugin,
Decoration,
} from "@codemirror/view"
import {
bracketMatching,
foldKeymap,
foldGutter,
syntaxHighlighting,
} from "@codemirror/language"
import { oneDark, oneDarkHighlightStyle } from "@codemirror/theme-one-dark"
import {
defaultKeymap,
historyKeymap,
history,
indentWithTab,
} from "@codemirror/commands"
import { Compartment } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript"
import { EditorModes, getDefaultTheme } from "./"
import { themeStore } from "builderStore"
export let label
export let completions = []
export let height = 200
export let resize = "none"
export let mode = EditorModes.Handlebars
export let value = ""
export let placeholder = null
// Export a function to expose caret position
export const getCaretPosition = () => {
const selection_range = editor.state.selection.ranges[0]
return {
start: selection_range.from,
end: selection_range.to,
}
}
export const insertAtPos = opts => {
// Updating the value inside.
// Retain focus
editor.dispatch({
changes: {
from: opts.start || editor.state.doc.length,
to: opts.end || editor.state.doc.length,
insert: opts.value,
},
selection: opts.cursor
? {
anchor: opts.start + opts.value.length,
}
: undefined,
})
}
// For handlebars only.
const bindStyle = new MatchDecorator({
regexp: /{{[."#\-\w\s\][]*}}/g,
decoration: () => {
return Decoration.mark({
tag: "span",
attributes: {
class: "binding-wrap",
},
})
},
})
let plugin = ViewPlugin.define(
view => ({
decorations: bindStyle.createDeco(view),
update(u) {
this.decorations = bindStyle.updateDeco(u, this.decorations)
},
}),
{
decorations: v => v.decorations,
}
)
const dispatch = createEventDispatcher()
// Theming!
let currentTheme = $themeStore?.theme
let isDark = !currentTheme.includes("light")
let themeConfig = new Compartment()
const buildKeymap = () => {
const baseMap = [
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
]
return baseMap
}
const buildBaseExtensions = () => {
return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(),
autocompletion({
override: [...completions],
closeOnBlur: true,
icons: false,
optionClass: () => "autocomplete-option",
}),
EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
]
}
const buildExtensions = base => {
const complete = [...base]
if (mode.name == "javascript") {
complete.push(javascript())
complete.push(highlightWhitespace())
complete.push(lineNumbers())
complete.push(foldGutter())
complete.push(
EditorView.inputHandler.of((view, from, to, insert) => {
if (insert === "$") {
let { text } = view.state.doc.lineAt(from)
const left = from ? text.substring(0, from) : ""
const right = to ? text.substring(to) : ""
const wrap = !left.includes('$("') || !right.includes('")')
const tr = view.state.update(
{
changes: [{ from, insert: wrap ? '$("")' : "$" }],
selection: {
anchor: from + (wrap ? 3 : 1),
},
},
{
scrollIntoView: true,
userEvent: "input.type",
}
)
view.dispatch(tr)
return true
}
return false
})
)
}
if (placeholder) {
complete.push(placeholderFn(placeholder))
}
return complete
}
let textarea
let editor
let mounted = false
let isEditorInitialised = false
const initEditor = () => {
const baseExtensions = buildBaseExtensions()
editor = new EditorView({
doc: value,
extensions: buildExtensions(baseExtensions),
parent: textarea,
})
}
$: editorHeight = typeof height === "number" ? `${height}px` : height
// Init when all elements are ready
$: if (mounted && !isEditorInitialised) {
isEditorInitialised = true
initEditor()
}
// Theme change
$: if (mounted && isEditorInitialised && $themeStore?.theme) {
if (currentTheme != $themeStore?.theme) {
currentTheme = $themeStore?.theme
isDark = !currentTheme.includes("light")
// Issue theme compartment update
editor.dispatch({
effects: themeConfig.reconfigure([
getDefaultTheme({
height: editorHeight,
resize,
dark: isDark,
}),
...(isDark ? [oneDark] : []),
]),
})
}
}
onMount(async () => {
mounted = true
return () => {
if (editor) {
editor.destroy()
}
}
})
</script>
{#if label}
<div>
<Label small>{label}</Label>
</div>
{/if}
<div class={`code-editor ${mode?.name || ""}`}>
<div tabindex="-1" bind:this={textarea} />
</div>
<style>
.code-editor.handlebars :global(.cm-content) {
font-family: var(--font-sans);
}
.code-editor :global(.cm-tooltip.cm-completionInfo) {
padding: var(--spacing-m);
}
.code-editor :global(.cm-tooltip-autocomplete > ul > li[aria-selected]) {
border-radius: var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
var(
--spectrum-popover-border-radius,
var(--spectrum-alias-border-radius-regular)
),
0, 0;
}
.code-editor :global(.autocomplete-option .cm-completionDetail) {
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 4px 6px;
}
</style>

View File

@ -0,0 +1,387 @@
import { EditorView } from "@codemirror/view"
import { getManifest } from "@budibase/string-templates"
import sanitizeHtml from "sanitize-html"
import { groupBy } from "lodash"
export const EditorModes = {
JS: {
name: "javascript",
json: false,
match: /\$$/,
},
Handlebars: {
name: "handlebars",
base: "text/html",
match: /{{[\s]*[\w\s]*/,
},
Text: {
name: "text/html",
},
}
export const SECTIONS = {
HB_HELPER: {
name: "Helper",
type: "helper",
icon: "Code",
},
}
export const getDefaultTheme = opts => {
const { height, resize, dark } = opts
return EditorView.theme(
{
"&.cm-focused .cm-cursor": {
borderLeftColor: "var(--spectrum-alias-text-color)",
},
"&": {
height: height ? `${height}` : "",
lineHeight: "1.3",
border:
"var(--spectrum-alias-border-size-thin) solid var(--spectrum-alias-border-color)",
borderRadius: "var(--border-radius-s)",
backgroundColor:
"var( --spectrum-textfield-m-background-color, var(--spectrum-global-color-gray-50) )",
resize: resize ? `${resize}` : "",
overflow: "hidden",
color: "var(--spectrum-alias-text-color)",
},
"& .cm-tooltip.cm-tooltip-autocomplete > ul": {
fontFamily:
"var(--spectrum-alias-body-text-font-family, var(--spectrum-global-font-family-base))",
maxHeight: "16em",
},
"& .cm-placeholder": {
color: "var(--spectrum-alias-text-color)",
fontStyle: "italic",
},
"&.cm-focused": {
outline: "none",
borderColor: "var(--spectrum-alias-border-color-mouse-focus)",
},
// AUTO COMPLETE
"& .cm-completionDetail": {
fontStyle: "unset",
textTransform: "uppercase",
fontSize: "10px",
backgroundColor: "var(--spectrum-global-color-gray-100)",
color: "var(--spectrum-global-color-gray-600)",
},
"& .cm-completionLabel": {
marginLeft:
"calc(var(--spectrum-alias-workflow-icon-size-m) + var(--spacing-m))",
},
"& .info-bubble": {
fontSize: "var(--font-size-s)",
display: "grid",
gridGap: "var(--spacing-s)",
gridTemplateColumns: "1fr",
color: "var(--spectrum-global-color-gray-800)",
},
"& .cm-tooltip": {
marginLeft: "var(--spacing-s)",
border: "1px solid var(--spectrum-global-color-gray-300)",
borderRadius:
"var( --spectrum-popover-border-radius, var(--spectrum-alias-border-radius-regular) )",
backgroundColor: "var(--spectrum-global-color-gray-50)",
},
// Section header
"& .info-section": {
display: "flex",
padding: "var(--spacing-s)",
gap: "var(--spacing-m)",
borderBottom: "1px solid var(--spectrum-global-color-gray-200)",
color: "var(--spectrum-global-color-gray-800)",
fontWeight: "bold",
},
"& .info-section .spectrum-Icon": {
color: "var(--spectrum-global-color-gray-600)",
},
// Autocomplete Option
"& .cm-tooltip.cm-tooltip-autocomplete .autocomplete-option": {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "var(--spectrum-alias-font-size-default)",
padding: "var(--spacing-s)",
color: "var(--spectrum-global-color-gray-800)",
},
"& .cm-tooltip-autocomplete ul li[aria-selected].autocomplete-option": {
backgroundColor: "var(--spectrum-global-color-gray-200)",
},
"& .binding-wrap": {
color: "var(--spectrum-global-color-blue-700)",
fontFamily: "monospace",
},
},
{ dark }
)
}
export const buildHelperInfoNode = (completion, helper) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = helper.example
? `<div class="binding__example">${helper.example}</div>`
: ""
const descriptionMarkup = sanitizeHtml(helper.description, {
allowedTags: [],
allowedAttributes: {},
})
const descriptionNodeHtml = `<div class="binding__description">${descriptionMarkup}</div>`
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
return ele
}
const toSpectrumIcon = name => {
return `<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="false"
aria-label="${name}-section-icon"
>
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-${name}" />
</svg>`
}
export const buildSectionHeader = (type, sectionName, icon, rank) => {
const ele = document.createElement("div")
ele.classList.add("info-section")
ele.classList.add(type)
ele.innerHTML = `${toSpectrumIcon(icon)}<span>${sectionName}</span>`
return {
name: sectionName,
header: () => ele,
rank,
}
}
export const helpersToCompletion = (helpers, mode) => {
const { type, name: sectionName, icon } = SECTIONS.HB_HELPER
const helperSection = buildSectionHeader(type, sectionName, icon, 99)
return Object.keys(helpers).reduce((acc, key) => {
let helper = helpers[key]
acc.push({
label: key,
info: completion => {
return buildHelperInfoNode(completion, helper)
},
type: "helper",
section: helperSection,
detail: "FUNCTION",
apply: (view, completion, from, to) => {
insertBinding(view, from, to, key, mode)
},
})
return acc
}, [])
}
export const getHelperCompletions = mode => {
const manifest = getManifest()
return Object.keys(manifest).reduce((acc, key) => {
acc = acc || []
return [...acc, ...helpersToCompletion(manifest[key], mode)]
}, [])
}
const bindingFilter = (options, query) => {
return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase()
const label_parsed = completion.label.toLowerCase()
const query_parsed = query.toLowerCase()
return (
section_parsed.includes(query_parsed) ||
label_parsed.includes(query_parsed)
)
})
}
export const hbAutocomplete = baseCompletions => {
async function coreCompletion(context) {
let bindingStart = context.matchBefore(EditorModes.Handlebars.match)
let options = baseCompletions || []
if (!bindingStart) {
return null
}
// Accommodate spaces
const match = bindingStart.text.match(/{{[\s]*/)
const query = bindingStart.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
return {
from: bindingStart.from + match[0].length,
filter: false,
options: filtered,
}
}
return coreCompletion
}
export const jsAutocomplete = baseCompletions => {
async function coreCompletion(context) {
let jsBinding = context.matchBefore(/\$\("[\s\w]*/)
let options = baseCompletions || []
if (jsBinding) {
// Accommodate spaces
const match = jsBinding.text.match(/\$\("[\s]*/)
const query = jsBinding.text.replace(match[0], "")
let filtered = bindingFilter(options, query)
return {
from: jsBinding.from + match[0].length,
filter: false,
options: filtered,
}
}
return null
}
return coreCompletion
}
export const buildBindingInfoNode = (completion, binding) => {
const ele = document.createElement("div")
ele.classList.add("info-bubble")
const exampleNodeHtml = binding.readableBinding
? `<div class="binding__example">{{ ${binding.readableBinding} }}</div>`
: ""
const descriptionNodeHtml = binding.description
? `<div class="binding__description">${binding.description}</div>`
: ""
ele.innerHTML = `
${exampleNodeHtml}
${descriptionNodeHtml}
`
return ele
}
// Readdress these methods. They shouldn't be used
export const hbInsert = (value, from, to, text) => {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
const right = to ? value.substring(to) : ""
if (!left.includes("{{") || !right.includes("}}")) {
parsedInsert = `{{ ${text} }}`
} else {
parsedInsert = ` ${text} `
}
return parsedInsert
}
export function jsInsert(value, from, to, text, { helper } = {}) {
let parsedInsert = ""
const left = from ? value.substring(0, from) : ""
const right = to ? value.substring(to) : ""
if (helper) {
parsedInsert = `helpers.${text}()`
} else if (!left.includes('$("') || !right.includes('")')) {
parsedInsert = `$("${text}")`
} else {
parsedInsert = text
}
return parsedInsert
}
// Autocomplete apply behaviour
export const insertBinding = (view, from, to, text, mode) => {
let parsedInsert
if (mode.name == "javascript") {
parsedInsert = jsInsert(view.state.doc?.toString(), from, to, text)
} else if (mode.name == "handlebars") {
parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text)
} else {
console.log("Unsupported")
return
}
let bindingClosePattern = mode.name == "javascript" ? /[\s]*"\)/ : /[\s]*}}/
let sliced = view.state.doc?.toString().slice(to)
const rightBrace = sliced.match(bindingClosePattern)
let cursorPos = from + parsedInsert.length
if (rightBrace) {
cursorPos = from + parsedInsert.length + rightBrace[0].length
}
view.dispatch({
changes: {
from,
to,
insert: parsedInsert,
},
selection: {
anchor: cursorPos,
},
})
}
export const bindingsToCompletions = (bindings, mode) => {
const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => {
acc[ele.category] = acc[ele.category] || {}
if (ele.icon) {
acc[ele.category]["icon"] = acc[ele.category]["icon"] || ele.icon
}
if (typeof ele.display?.rank == "number") {
acc[ele.category]["rank"] = acc[ele.category]["rank"] || ele.display.rank
}
return acc
}, {})
const completions = Object.keys(bindingByCategory).reduce((comps, catKey) => {
const { icon, rank } = categoryMeta[catKey] || {}
const bindindSectionHeader = buildSectionHeader(
bindingByCategory.type,
catKey,
icon || "",
typeof rank == "number" ? rank : 1
)
return [
...comps,
...bindingByCategory[catKey].reduce((acc, binding) => {
let displayType = binding.fieldSchema?.type || binding.display?.type
acc.push({
label: binding.display?.name || "NO NAME",
info: completion => {
return buildBindingInfoNode(completion, binding)
},
type: "binding",
detail: displayType,
section: bindindSectionHeader,
apply: (view, completion, from, to) => {
insertBinding(view, from, to, binding.readableBinding, mode)
},
})
return acc
}, []),
]
}, [])
return completions
}

View File

@ -146,15 +146,18 @@
/* Override default active line highlight colour in dark theme */
div
:global(.CodeMirror-focused.cm-s-tomorrow-night-eighties
.CodeMirror-activeline-background) {
:global(
.CodeMirror-focused.cm-s-tomorrow-night-eighties
.CodeMirror-activeline-background
) {
background: rgba(255, 255, 255, 0.075);
}
/* Remove active line styling when not focused */
div
:global(.CodeMirror:not(.CodeMirror-focused)
.CodeMirror-activeline-background) {
:global(
.CodeMirror:not(.CodeMirror-focused) .CodeMirror-activeline-background
) {
background: unset;
}

View File

@ -28,7 +28,6 @@
.dash-card {
background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s);
overflow: hidden;
min-height: 170px;
}
.dash-card-header {

View File

@ -8,6 +8,7 @@
faLock,
faFileArrowUp,
faChevronLeft,
faCircleInfo,
} from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
@ -20,7 +21,8 @@
faDiscord,
faEnvelope,
faFileArrowUp,
faChevronLeft
faChevronLeft,
faCircleInfo
)
dom.watch()
</script>

View File

@ -83,7 +83,7 @@
.help {
z-index: 2;
position: absolute;
bottom: var(--spacing-xl);
bottom: 24px;
right: 24px;
}

View File

@ -17,6 +17,7 @@
export let highlighted = false
export let rightAlignIcon = false
export let id
export let showTooltip = false
const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher()
@ -84,7 +85,7 @@
<Icon color={iconColor} size="S" name={icon} />
</div>
{/if}
<div class="text">{text}</div>
<div class="text" title={showTooltip ? text : null}>{text}</div>
{#if withActions}
<div class="actions">
<slot />

View File

@ -1,17 +1,13 @@
<script>
import groupBy from "lodash/fp/groupBy"
import {
Search,
TextArea,
DrawerContent,
Tabs,
Tab,
Body,
Layout,
Button,
ActionButton,
Heading,
Icon,
Popover,
} from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte"
import {
@ -23,11 +19,21 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import { handlebarsCompletions } from "constants/completions"
import { addHBSBinding, addJSBinding } from "./utils"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { store } from "builderStore"
import { convertToJS } from "@budibase/string-templates"
import { admin } from "stores/portal"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
import {
getHelperCompletions,
jsAutocomplete,
hbAutocomplete,
EditorModes,
bindingsToCompletions,
hbInsert,
jsInsert,
} from "../CodeEditor"
import { getContext } from "svelte"
import BindingPicker from "./BindingPicker.svelte"
const dispatch = createEventDispatcher()
@ -41,54 +47,21 @@
export let allowJS = false
export let allowHelpers = true
let helpers = handlebarsCompletions()
const drawerActions = getContext("drawer-actions")
const bindingDrawerActions = getContext("binding-drawer-actions")
let getCaretPosition
let search = ""
let insertAtPos
let initialValueJS = typeof value === "string" && value?.startsWith("{{ js ")
let mode = initialValueJS ? "JavaScript" : "Handlebars"
let mode = initialValueJS ? "JavaScript" : "Text"
let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value
let selectedCategory = null
let popover
let popoverAnchor
let hoverTarget
let sidebar = true
let targetMode = null
$: usingJS = mode === "JavaScript"
$: searchRgx = new RegExp(search, "ig")
$: categories = Object.entries(groupBy("category", bindings))
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
})
$: categoryNames = getCategoryNames(categories)
$: codeMirrorHints = bindings?.map(x => `$("${x.readableBinding}")`)
$: editorMode = mode == "JavaScript" ? EditorModes.JS : EditorModes.Handlebars
$: bindingCompletions = bindingsToCompletions(bindings, editorMode)
const updateValue = val => {
valid = isValid(readableToRuntimeBinding(bindings, val))
@ -97,43 +70,30 @@
}
}
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
// Adds a JS/HBS helper to the expression
const addHelper = (helper, js) => {
let tempVal
const onSelectHelper = (helper, js) => {
const pos = getCaretPosition()
const { start, end } = pos
if (js) {
const decoded = decodeJSBinding(jsValue)
tempVal = jsValue = encodeJSBinding(
addJSBinding(decoded, pos, helper.text, { helper: true })
)
let js = decodeJSBinding(jsValue)
const insertVal = jsInsert(js, start, end, helper.text, { helper: true })
insertAtPos({ start, end, value: insertVal })
} else {
tempVal = hbsValue = addHBSBinding(hbsValue, pos, helper.text)
const insertVal = hbInsert(hbsValue, start, end, helper.text)
insertAtPos({ start, end, value: insertVal })
}
updateValue(tempVal)
}
// Adds a data binding to the expression
const addBinding = (binding, { forceJS } = {}) => {
const onSelectBinding = (binding, { forceJS } = {}) => {
const { start, end } = getCaretPosition()
if (usingJS || forceJS) {
let js = decodeJSBinding(jsValue)
js = addJSBinding(js, getCaretPosition(), binding.readableBinding)
jsValue = encodeJSBinding(js)
updateValue(jsValue)
const insertVal = jsInsert(js, start, end, binding.readableBinding)
insertAtPos({ start, end, value: insertVal })
} else {
hbsValue = addHBSBinding(
hbsValue,
getCaretPosition(),
binding.readableBinding
)
updateValue(hbsValue)
const insertVal = hbInsert(hbsValue, start, end, binding.readableBinding)
insertAtPos({ start, end, value: insertVal })
}
}
@ -152,24 +112,25 @@
updateValue(jsValue)
}
const switchMode = () => {
if (targetMode == "Text") {
jsValue = null
updateValue(jsValue)
} else {
hbsValue = null
updateValue(hbsValue)
}
mode = targetMode + ""
targetMode = null
}
const convert = () => {
const runtime = readableToRuntimeBinding(bindings, hbsValue)
const runtimeJs = encodeJSBinding(convertToJS(runtime))
jsValue = runtimeToReadableBinding(bindings, runtimeJs)
hbsValue = null
mode = "JavaScript"
addBinding("", { forceJS: true })
}
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
onSelectBinding("", { forceJS: true })
}
onMount(() => {
@ -177,332 +138,301 @@
})
</script>
<span class="detailPopover">
<Popover
align="right-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<span class="binding-drawer">
<DrawerContent>
<div class="main">
<Tabs
selected={mode}
on:select={onChangeMode}
beforeSwitch={selectedMode => {
if (selectedMode == mode) {
return true
}
<DrawerContent>
<svelte:fragment slot="sidebar">
<Layout noPadding gap="S">
{#if selectedCategory}
<div>
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
//Get the current mode value
const editorValue = usingJS ? decodeJSBinding(jsValue) : hbsValue
{#if !selectedCategory}
<div class="heading">Search</div>
<Search placeholder="Search" bind:value={search} />
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<li
class="binding"
on:mouseenter={e => {
popoverAnchor = e.target
if (!binding.description) {
return
}
hoverTarget = {
title: binding.display?.name || binding.fieldSchema?.name,
description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
if (editorValue) {
targetMode = selectedMode
return false
}
return true
}}
>
<Tab title="Text">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<div class="overlay-wrap">
{#if targetMode}
<div class="mode-overlay">
<div class="prompt-body">
<Heading size="S">
{`Switch to ${targetMode}?`}
</Heading>
<Body>This will discard anything in your binding</Body>
<div class="switch-actions">
<Button
secondary
size="S"
on:click={() => {
targetMode = null
}}
>
No - keep text
</Button>
<Button cta size="S" on:click={switchMode}>
Yes - discard text
</Button>
</div>
</div>
</div>
{/if}
<CodeEditor
value={hbsValue}
on:change={onChangeHBSValue}
bind:getCaretPosition
bind:insertAtPos
completions={[
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
placeholder=""
height="100%"
/>
</div>
<div class="binding-footer">
<div class="messaging">
{#if !valid}
<div class="syntax-error">
Current Handlebars syntax is invalid, please check the
guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</div>
{:else}
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing &#123;&#123; or use the
menu on the right
</div>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, usingJS)}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(helper, usingJS),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
{/if}
{/if}
{/if}
</Layout>
</svelte:fragment>
<div class="main">
<Tabs selected={mode} on:select={onChangeMode}>
<Tab title="Handlebars">
<div class="main-content">
<TextArea
bind:getCaretPosition
value={hbsValue}
on:change={onChangeHBSValue}
placeholder="Add text, or click the objects on the left to add them to the textbox."
/>
{#if !valid}
<p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</p>
{/if}
{#if $admin.isDev && allowJS}
<div class="convert">
<Button secondary on:click={convert}>Convert to JS</Button>
</div>
<div class="actions">
{#if $admin.isDev && allowJS}
<ActionButton
secondary
on:click={() => {
convert()
targetMode = null
}}
>
Convert To JS
</ActionButton>
{/if}
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{/if}
</div>
</Tab>
{#if allowJS}
<Tab title="JavaScript">
<div class="main-content">
<Layout noPadding gap="XS">
<CodeMirrorEditor
bind:getCaretPosition
height={200}
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
hints={codeMirrorHints}
/>
<Body size="S">
JavaScript expressions are executed as functions, so ensure that
your expression returns a value.
</Body>
</Layout>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{/if}
</Tabs>
</div>
</DrawerContent>
{#if allowJS}
<Tab title="JavaScript">
<div class="main-content" class:binding-panel={sidebar}>
<div class="editor">
<div class="overlay-wrap">
{#if targetMode}
<div class="mode-overlay">
<div class="prompt-body">
<Heading size="S">
{`Switch to ${targetMode}?`}
</Heading>
<Body>This will discard anything in your binding</Body>
<div class="switch-actions">
<Button
secondary
size="S"
on:click={() => {
targetMode = null
}}
>
No - keep javascript
</Button>
<Button cta size="S" on:click={switchMode}>
Yes - discard javascript
</Button>
</div>
</div>
</div>
{/if}
<CodeEditor
value={decodeJSBinding(jsValue)}
on:change={onChangeJSValue}
completions={[
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(editorMode),
]),
]}
mode={EditorModes.JS}
bind:getCaretPosition
bind:insertAtPos
height="100%"
/>
</div>
<div class="binding-footer">
<div class="messaging">
<Icon name="FlashOn" />
<div class="messaging-wrap">
<div>
Add available bindings by typing $ or use the menu on
the right
</div>
</div>
</div>
<div class="actions">
<ActionButton
secondary
icon={sidebar ? "RailRightClose" : "RailRightOpen"}
on:click={() => {
sidebar = !sidebar
}}
/>
</div>
</div>
</div>
{#if sidebar}
<div class="binding-picker">
<BindingPicker
{bindings}
{allowHelpers}
addHelper={onSelectHelper}
addBinding={onSelectBinding}
mode={editorMode}
/>
</div>
{/if}
</div>
</Tab>
{/if}
<div class="drawer-actions">
<Button
secondary
quiet
on:click={() => {
store.actions.settings.propertyFocus(null)
drawerActions.hide()
}}
>
Cancel
</Button>
<Button
cta
disabled={!valid}
on:click={() => {
bindingDrawerActions.save()
}}
>
Save
</Button>
</div>
</Tabs>
</div>
</DrawerContent>
</span>
<style>
ul.helpers li * {
pointer-events: none;
.binding-drawer :global(.container > .main) {
overflow: hidden;
height: 100%;
padding: 0px;
}
ul.category-list li {
.binding-drawer :global(.container > .main > .main) {
overflow: hidden;
height: 100%;
display: flex;
flex-direction: column;
}
.binding-drawer :global(.spectrum-Tabs-content) {
flex: 1;
overflow: hidden;
}
.binding-drawer :global(.spectrum-Tabs-content > div),
.binding-drawer :global(.spectrum-Tabs-content > div > div),
.binding-drawer :global(.spectrum-Tabs-content .main-content) {
height: 100%;
}
.binding-drawer .main-content {
grid-template-rows: unset;
}
.messaging {
display: flex;
align-items: center;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
min-width: 0;
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
.messaging-wrap {
overflow: hidden;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
.messaging-wrap > div {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.main :global(textarea) {
min-height: 202px !important;
}
.main {
margin: calc(-1 * var(--spacing-xl));
}
.main-content {
padding: var(--spacing-s) var(--spacing-xl);
}
.heading,
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
.main :global(.spectrum-Tabs div.drawer-actions) {
display: flex;
gap: var(--spacing-m);
align-items: center;
margin-left: auto;
}
ul {
list-style: none;
padding: 0;
margin: 0;
.main :global(.spectrum-Tabs-content),
.main :global(.spectrum-Tabs-content .main-content) {
margin-top: 0px;
padding: 0px;
}
li {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
.helper {
.main :global(.spectrum-Tabs) {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: var(--spacing-xs);
}
.helper__name {
font-weight: bold;
}
.helper__description,
.helper__description :global(*) {
color: var(--spectrum-global-color-gray-700);
}
.helper__example {
white-space: normal;
margin: 0.5rem 0 0 0;
font-weight: 700;
}
.helper__description :global(p) {
margin: 0;
}
.syntax-error {
padding-top: var(--spacing-m);
color: var(--red);
font-size: 12px;
}
@ -511,7 +441,66 @@
text-decoration: underline;
}
.convert {
padding-top: var(--spacing-m);
.binding-footer {
width: 100%;
display: flex;
justify-content: space-between;
}
.main-content {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 380px;
}
.main-content.binding-panel {
grid-template-columns: 1fr 320px;
}
.binding-picker {
border-left: 2px solid var(--border-light);
border-left: var(--border-light);
overflow: scroll;
height: 100%;
}
.editor {
padding: var(--spacing-xl);
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--spacing-xl);
}
.overlay-wrap {
position: relative;
flex: 1;
}
.mode-overlay {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border-radius: var(--border-radius-s);
}
.prompt-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-l);
}
.prompt-body .switch-actions {
display: flex;
gap: var(--spacing-l);
}
.binding-drawer :global(.code-editor),
.binding-drawer :global(.code-editor > div) {
height: 100%;
}
</style>

View File

@ -0,0 +1,393 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { convertToJS } from "@budibase/string-templates"
import { Input, Layout, ActionButton, Icon, Popover } from "@budibase/bbui"
import { handlebarsCompletions } from "constants/completions"
export let addHelper
export let addBinding
export let bindings
export let mode
export let allowHelpers
let search = ""
let popover
let popoverAnchor
let hoverTarget
let helpers = handlebarsCompletions()
let selectedCategory
$: searchRgx = new RegExp(search, "ig")
// Icons
$: bindingIcons = bindings?.reduce((acc, ele) => {
if (ele.icon) {
acc[ele.category] = acc[ele.category] || ele.icon
}
return acc
}, {})
$: categoryIcons = { ...bindingIcons, Helpers: "MagicWand" }
$: categories = Object.entries(groupBy("category", bindings))
$: categoryNames = getCategoryNames(categories)
$: filteredCategories = categories
.map(([name, categoryBindings]) => ({
name,
bindings: categoryBindings?.filter(binding => {
return binding.readableBinding.match(searchRgx)
}),
}))
.filter(category => {
return (
category.bindings?.length > 0 &&
(!selectedCategory ? true : selectedCategory === category.name)
)
})
$: filteredHelpers = helpers?.filter(helper => {
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
})
const getHelperExample = (helper, js) => {
let example = helper.example || ""
if (js) {
example = convertToJS(example).split("\n")[0].split("= ")[1]
if (example === "null;") {
example = ""
}
}
return example || ""
}
const getCategoryNames = categories => {
let names = [...categories.map(cat => cat[0])]
if (allowHelpers) {
names.push("Helpers")
}
return names
}
</script>
<span class="detailPopover">
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
maxWidth={300}
dismissible={false}
>
<Layout gap="S">
<div class="helper">
{#if hoverTarget.title}
<div class="helper__name">{hoverTarget.title}</div>
{/if}
{#if hoverTarget.description}
<div class="helper__description">
{@html hoverTarget.description}
</div>
{/if}
{#if hoverTarget.example}
<pre class="helper__example">{hoverTarget.example}</pre>
{/if}
</div>
</Layout>
</Popover>
</span>
<Layout noPadding gap="S">
{#if selectedCategory}
<div class="sub-section-back">
<ActionButton
secondary
icon={"ArrowLeft"}
on:click={() => {
selectedCategory = null
}}
>
Back
</ActionButton>
</div>
{/if}
{#if !selectedCategory}
<div class="search">
<span class="search-input">
<Input
placeholder={"Search for bindings"}
autocomplete="off"
bind:value={search}
/>
</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<span
class="search-input-icon"
on:click={() => {
if (!search) {
return
}
search = null
}}
class:searching={search}
>
<Icon name={search ? "Close" : "Search"} />
</span>
</div>
{/if}
{#if !selectedCategory && !search}
<ul class="category-list">
{#each categoryNames as categoryName}
<li
on:click={() => {
selectedCategory = categoryName
}}
>
<Icon name={categoryIcons[categoryName]} />
<span class="category-name">{categoryName} </span>
<span class="category-chevron"><Icon name="ChevronRight" /></span>
</li>
{/each}
</ul>
{/if}
{#if selectedCategory || search}
{#each filteredCategories as category}
{#if category.bindings?.length}
<div class="sub-section">
<div class="cat-heading">
<Icon name={categoryIcons[category.name]} />{category.name}
</div>
<ul>
{#each category.bindings as binding}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
class="binding"
on:mouseenter={e => {
popoverAnchor = e.target
if (!binding.description) {
return
}
hoverTarget = {
title: binding.display?.name || binding.fieldSchema?.name,
description: binding.description,
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
on:click={() => addBinding(binding)}
>
<span class="binding__label">
{#if binding.display?.name}
{binding.display.name}
{:else if binding.fieldSchema?.name}
{binding.fieldSchema?.name}
{:else}
{binding.readableBinding}
{/if}
</span>
{#if binding.display?.type || binding.fieldSchema?.type}
<span class="binding__typeWrap">
<span class="binding__type">
{binding.display?.type || binding.fieldSchema?.type}
</span>
</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
{#if selectedCategory === "Helpers" || search}
{#if filteredHelpers?.length}
<div class="sub-section">
<div class="cat-heading">Helpers</div>
<ul class="helpers">
{#each filteredHelpers as helper}
<li
class="binding"
on:click={() => addHelper(helper, mode.name == "javascript")}
on:mouseenter={e => {
popoverAnchor = e.target
if (!helper.displayText && helper.description) {
return
}
hoverTarget = {
title: helper.displayText,
description: helper.description,
example: getHelperExample(
helper,
mode.name == "javascript"
),
}
popover.show()
e.stopPropagation()
}}
on:mouseleave={() => {
popover.hide()
popoverAnchor = null
hoverTarget = null
}}
on:focus={() => {}}
on:blur={() => {}}
>
<span class="binding__label">{helper.displayText}</span>
<span class="binding__typeWrap">
<span class="binding__type">function</span>
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</Layout>
<style>
.search :global(input) {
border: none;
border-radius: 0px;
background: none;
padding: 0px;
}
.search {
padding: var(--spacing-m) var(--spacing-l);
display: flex;
align-items: center;
border-top: 0px;
border-bottom: var(--border-light);
border-left: 2px solid transparent;
border-right: 2px solid transparent;
margin-right: 1px;
position: sticky;
top: 0;
background-color: var(--background);
z-index: 2;
}
.search-input {
flex: 1;
}
.search-input-icon.searching {
cursor: pointer;
}
ul.category-list {
padding: 0px var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.sub-section {
padding: var(--spacing-l);
padding-top: 0px;
}
.sub-section-back {
padding: var(--spacing-l);
padding-top: var(--spacing-xl);
padding-bottom: 0px;
}
.cat-heading {
margin-bottom: var(--spacing-l);
}
ul.helpers li * {
pointer-events: none;
}
ul.category-list li {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul.category-list .category-name {
font-weight: 600;
text-transform: capitalize;
}
ul.category-list .category-chevron {
flex: 1;
text-align: right;
}
ul.category-list .category-chevron :global(div.icon),
.cat-heading :global(div.icon) {
display: inline-block;
}
li.binding {
display: flex;
align-items: center;
}
li.binding .binding__typeWrap {
flex: 1;
text-align: right;
text-transform: capitalize;
}
:global(.drawer-actions) {
display: flex;
gap: var(--spacing-m);
}
.cat-heading {
font-size: var(--font-size-s);
font-weight: 600;
text-transform: uppercase;
color: var(--spectrum-global-color-gray-600);
}
.cat-heading {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
border-color 130ms ease-in-out;
word-wrap: break-word;
}
li:not(:last-of-type) {
margin-bottom: var(--spacing-s);
}
li :global(*) {
transition: color 130ms ease-in-out;
}
li:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
.binding__label {
font-weight: 600;
text-transform: capitalize;
}
.binding__type {
font-family: var(--font-mono);
background-color: var(--spectrum-global-color-gray-200);
border-radius: var(--border-radius-s);
padding: 2px 4px;
margin-left: 2px;
font-weight: 600;
}
</style>

View File

@ -5,7 +5,7 @@
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel
@ -34,6 +34,10 @@
bindingDrawer.hide()
}
setContext("binding-drawer-actions", {
save: handleClose,
})
const onChange = (value, optionPicked) => {
// Add HBS braces if picking binding
if (optionPicked && !options?.includes(value)) {
@ -63,7 +67,6 @@
on:pick={e => onChange(e.detail, true)}
on:blur={() => dispatch("blur")}
{placeholder}
options={allOptions}
{error}
/>
{#if !disabled}
@ -77,6 +80,7 @@
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" on:click={handleClose} disabled={!valid}>
Save
</Button>

View File

@ -4,8 +4,11 @@
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import { store } from "builderStore"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel
@ -20,6 +23,7 @@
export let allowHelpers = true
export let updateOnChange = true
export let drawerLeft
export let key
const dispatch = createEventDispatcher()
let bindingDrawer
@ -32,10 +36,15 @@
const saveBinding = () => {
onChange(tempValue)
store.actions.settings.propertyFocus(null)
onBlur()
bindingDrawer.hide()
}
setContext("binding-drawer-actions", {
save: saveBinding,
})
const onChange = value => {
currentVal = readableToRuntimeBinding(bindings, value)
dispatch("change", currentVal)
@ -58,12 +67,24 @@
{updateOnChange}
/>
{#if !disabled}
<div class="icon" on:click={bindingDrawer.show}>
<div
class="icon"
on:click={() => {
store.actions.settings.propertyFocus(key)
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</div>
<Drawer {fillWidth} bind:this={bindingDrawer} {title} left={drawerLeft}>
<Drawer
{fillWidth}
bind:this={bindingDrawer}
{title}
left={drawerLeft}
headless
>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>

View File

@ -113,109 +113,113 @@
})
</script>
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
<RevertModal />
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
{#if $store.hasLock}
<div class="action-top-nav">
<div class="action-buttons">
<div class="version">
<VersionModal />
</div>
{/if}
<RevertModal />
{#if !isPublished}
<ActionButton
quiet
icon="GlobeStrike"
size="M"
tooltip="Your app has not been published yet"
disabled
/>
{/if}
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<ActionButton
quiet
icon="Globe"
size="M"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
anchor={publishPopoverAnchor}
offset={10}
>
<div class="popover-content">
<Layout noPadding gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="buttons">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</div>
</Popover>
</div>
{/if}
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
{#if !isPublished}
<ActionButton
quiet
icon="UserGroup"
icon="GlobeStrike"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
tooltip="Your app has not been published yet"
disabled
/>
{/if}
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<span id="builder-app-users-button">
<ActionButton
quiet
icon="UserGroup"
size="M"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</span>
</TourWrap>
</div>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
{/if}
<div class="buttons">
<Button on:click={previewApp} secondary>Preview</Button>
<DeployModal onOk={completePublish} />
{#if $store.hasLock}
<DeployModal onOk={completePublish} />
{/if}
</div>
<style>

View File

@ -9,6 +9,8 @@
import { store } from "builderStore"
import { API } from "api"
export let disabled = false
let revertModal
let appName
@ -34,6 +36,7 @@
size="M"
tooltip="Revert changes"
on:click={revertModal.show}
{disabled}
/>
<Modal bind:this={revertModal}>

View File

@ -58,6 +58,7 @@
justify-content: flex-start;
align-items: stretch;
transition: width 130ms ease-out;
overflow: hidden;
}
.panel.borderLeft {
border-left: var(--border-light);

View File

@ -17,14 +17,14 @@ import URLSelect from "./controls/URLSelect.svelte"
import OptionsEditor from "./controls/OptionsEditor/OptionsEditor.svelte"
import FormFieldSelect from "./controls/FormFieldSelect.svelte"
import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte"
import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
const componentMap = {
text: DrawerBindableCombobox,
text: DrawerBindableInput,
select: Select,
radio: RadioGroup,
dataSource: DataSourceSelect,

View File

@ -126,8 +126,7 @@
}
const getAllBindings = (bindings, eventContextBindings, actions) => {
let allBindings = eventContextBindings.concat(bindings)
let allBindings = []
if (!actions) {
return []
}
@ -145,14 +144,35 @@
.forEach(action => {
// Check we have a binding for this action, and generate one if not
const stateBinding = makeStateBinding(action.parameters.key)
const hasKey = allBindings.some(binding => {
const hasKey = bindings.some(binding => {
return binding.runtimeBinding === stateBinding.runtimeBinding
})
if (!hasKey) {
allBindings.push(stateBinding)
bindings.push(stateBinding)
}
})
// Get which indexes are asynchronous automations as we want to filter them out from the bindings
const asynchronousAutomationIndexes = actions
.map((action, index) => {
if (
action[EVENT_TYPE_KEY] === "Trigger Automation" &&
!action.parameters?.synchronous
) {
return index
}
})
.filter(index => index !== undefined)
// Based on the above, filter out the asynchronous automations from the bindings
if (asynchronousAutomationIndexes) {
allBindings = eventContextBindings
.filter((binding, index) => {
return !asynchronousAutomationIndexes.includes(index)
})
.concat(bindings)
} else {
allBindings = eventContextBindings.concat(bindings)
}
return allBindings
}
</script>

View File

@ -1,8 +1,8 @@
<script>
import { Select, Label, Input, Checkbox } from "@budibase/bbui"
import { Select, Label, Input, Checkbox, Icon } from "@budibase/bbui"
import { automationStore } from "builderStore"
import SaveFields from "./SaveFields.svelte"
import { TriggerStepID } from "constants/backend/automations"
import { TriggerStepID, ActionStepID } from "constants/backend/automations"
export let parameters = {}
export let bindings = []
@ -16,6 +16,14 @@
? AUTOMATION_STATUS.EXISTING
: AUTOMATION_STATUS.NEW
$: {
if (automationStatus === AUTOMATION_STATUS.NEW) {
parameters.synchronous = false
}
parameters.synchronous = automations.find(
automation => automation._id === parameters.automationId
)?.synchronous
}
$: automations = $automationStore.automations
.filter(a => a.definition.trigger?.stepId === TriggerStepID.APP)
.map(automation => {
@ -23,10 +31,15 @@
automation.definition.trigger.inputs.fields || {}
).map(([name, type]) => ({ name, type }))
let hasCollectBlock = automation.definition.steps.some(
step => step.stepId === ActionStepID.COLLECT
)
return {
name: automation.name,
_id: automation._id,
schema,
synchronous: hasCollectBlock,
}
})
$: hasAutomations = automations && automations.length > 0
@ -35,6 +48,8 @@
)
$: selectedSchema = selectedAutomation?.schema
$: error = parameters.timeout > 120 ? "Timeout must be less than 120s" : null
const onFieldsChanged = e => {
parameters.fields = Object.entries(e.detail || {}).reduce(
(acc, [key, value]) => {
@ -57,6 +72,14 @@
parameters.fields = {}
parameters.automationId = automations[0]?._id
}
const onChange = value => {
let automationId = value.detail
parameters.synchronous = automations.find(
automation => automation._id === automationId
)?.synchronous
parameters.automationId = automationId
}
</script>
<div class="root">
@ -85,6 +108,7 @@
{#if automationStatus === AUTOMATION_STATUS.EXISTING}
<Select
on:change={onChange}
bind:value={parameters.automationId}
placeholder="Choose automation"
options={automations}
@ -98,6 +122,29 @@
/>
{/if}
{#if parameters.synchronous}
<Label small />
<div class="synchronous-info">
<Icon name="Info" />
<div>
<i
>This automation will run synchronously as it contains a Collect
step</i
>
</div>
</div>
<Label small />
<div class="timeout-width">
<Input
label="Timeout in seconds (120 max)"
type="number"
{error}
bind:value={parameters.timeout}
/>
</div>
{/if}
<Label small />
<Checkbox
text="Do not display default notification"
@ -133,6 +180,9 @@
max-width: 800px;
margin: 0 auto;
}
.timeout-width {
width: 30%;
}
.params {
display: grid;
@ -142,6 +192,11 @@
align-items: center;
}
.synchronous-info {
display: flex;
gap: var(--spacing-s);
}
.fields {
margin-top: var(--spacing-l);
display: grid;

View File

@ -57,7 +57,13 @@
{
"name": "Trigger Automation",
"type": "application",
"component": "TriggerAutomation"
"component": "TriggerAutomation",
"context": [
{
"label": "Automation Result",
"value": "result"
}
]
},
{
"name": "Update Field Value",

View File

@ -21,6 +21,7 @@
export let componentBindings = []
export let nested = false
export let highlighted = false
export let propertyFocus = false
export let info = null
$: nullishValue = value == null || value === ""
@ -72,6 +73,10 @@
if (highlighted) {
store.actions.settings.highlight(null)
}
// To fix focus 'affect' when property is target of a drawer other actions in the builder.
if (propertyFocus) {
store.actions.settings.propertyFocus(null)
}
})
</script>
@ -79,6 +84,7 @@
class="property-control"
class:wide={!label || labelHidden}
class:highlighted={highlighted && nullishValue}
class:property-focus={propertyFocus}
>
{#if label && !labelHidden}
<div class="label">
@ -125,6 +131,14 @@
background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-static-red-600);
}
.property-control.property-focus :global(input) {
border-color: var(
--spectrum-textfield-m-border-color-down,
var(--spectrum-alias-border-color-mouse-focus)
);
}
.label {
margin-top: 16px;
transform: translateY(-50%);

View File

@ -18,6 +18,7 @@
export let tab = true
export let mode
export let editorHeight = 500
export let editorWidth = 640
// export let parameters = []
let width
@ -169,7 +170,9 @@
{#if label}
<Label small>{label}</Label>
{/if}
<div style={`--code-mirror-height: ${editorHeight}px`}>
<div
style={`--code-mirror-height: ${editorHeight}px; --code-mirror-width: ${editorWidth}px;`}
>
<textarea tabindex="0" bind:this={refs.editor} readonly {value} />
</div>
@ -183,6 +186,7 @@
}
div :global(.CodeMirror) {
width: var(--code-mirror-width) !important;
height: var(--code-mirror-height) !important;
border-radius: var(--border-radius-s);
font-family: var(--font-mono);

View File

@ -1,5 +1,5 @@
<script>
import { goto } from "@roxi/routify"
import { goto, beforeUrlChange } from "@roxi/routify"
import {
Icon,
Select,
@ -12,6 +12,8 @@
Heading,
Tabs,
Tab,
Modal,
ModalContent,
} from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
@ -29,6 +31,12 @@
export let query
const resumeNavigation = () => {
if (typeof navigateTo == "string") {
$goto(typeof navigateTo == "string" ? `${navigateTo}` : navigateTo)
}
}
const transformerDocs = "https://docs.budibase.com/docs/transformers"
let fields = query?.schema ? schemaToFields(query.schema) : []
@ -36,6 +44,31 @@
let data = []
let saveId
let currentTab = "JSON"
let saveModal
let override = false
let navigateTo = null
// seed the transformer
if (query && !query.transformer) {
query.transformer = "return data"
}
// initialise a new empty schema
if (query && !query.schema) {
query.schema = {}
}
let queryStr = JSON.stringify(query)
$beforeUrlChange(event => {
const updated = JSON.stringify(query)
if (updated !== queryStr && !override) {
navigateTo = event.type == "pushstate" ? event.url : null
saveModal.show()
return false
} else return true
})
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
$: query.schema = fieldsToSchema(fields)
@ -60,11 +93,6 @@
}
}
// seed the transformer
if (query && !query.transformer) {
query.transformer = "return data"
}
function resetDependentFields() {
if (query.fields.extra) {
query.fields.extra = {}
@ -101,22 +129,48 @@
}
}
// return the query.
async function saveQuery() {
try {
const { _id } = await queries.save(query.datasourceId, query)
saveId = _id
notifications.success(`Query saved successfully`)
const response = await queries.save(query.datasourceId, query)
saveId = response._id
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${_id}`)
if (response?._rev) {
queryStr = JSON.stringify(query)
}
return response
} catch (error) {
notifications.error("Error saving query")
}
}
</script>
<Modal
bind:this={saveModal}
on:hide={() => {
navigateTo = null
}}
>
<ModalContent
title="You have unsaved changes"
confirmText="Save and Continue"
cancelText="Discard Changes"
size="L"
onConfirm={async () => {
await saveQuery()
override = true
resumeNavigation()
}}
onCancel={async () => {
override = true
resumeNavigation()
}}
>
<Body>Leaving this section will mean losing and changes to your query</Body>
</ModalContent>
</Modal>
<div class="wrapper">
<Layout gap="S" noPadding>
<Heading size="M">Query {integrationInfo?.friendlyName}</Heading>
@ -125,7 +179,13 @@
<div class="config">
<div class="config-field">
<Label>Query Name</Label>
<Input bind:value={query.name} />
<Input
value={query.name}
on:input={e => {
let newValue = e.target.value || ""
query.name = newValue.trim()
}}
/>
</div>
{#if queryConfig}
<div class="config-field">
@ -149,18 +209,20 @@
/>
{/if}
{#key query.parameters}
<BindingBuilder
queryBindings={query.parameters}
bindable={false}
on:change={e => {
query.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
<div class="binding-wrap">
<BindingBuilder
queryBindings={query.parameters}
bindable={false}
on:change={e => {
query.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
</div>
{/key}
{/if}
</div>
@ -203,7 +265,18 @@
<div class="viewer-controls">
<Heading size="S">Results</Heading>
<ButtonGroup gap="XS">
<Button cta disabled={queryInvalid} on:click={saveQuery}>
<Button
cta
disabled={queryInvalid}
on:click={async () => {
await saveQuery()
notifications.success(`Query saved successfully`)
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${query._id}`)
}
}}
>
Save Query
</Button>
<Button secondary on:click={previewQuery}>Run Query</Button>
@ -274,4 +347,9 @@
min-width: 150px;
align-items: center;
}
.binding-wrap :global(div.container) {
padding-left: 0px;
padding-right: 0px;
}
</style>

View File

@ -0,0 +1,31 @@
<script>
import { Modal, ModalContent, Body } from "@budibase/bbui"
let modal
export let onConfirm
export function show() {
modal.show()
}
export function hide() {
modal.hide()
}
</script>
<Modal bind:this={modal} on:hide={modal}>
<ModalContent
title="Your account is currently de-activated"
size="S"
showCancelButton={true}
showCloseIcon={false}
confirmText={"View plans"}
{onConfirm}
>
<Body size="S"
>Due to the free plan user limit being exceeded, your account has been
de-activated. Upgrade your plan to re-activate your account.</Body
>
</ModalContent>
</Modal>

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