diff --git a/.eslintrc.json b/.eslintrc.json index 79f7e56712..917443014b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -45,6 +45,16 @@ "no-prototype-builtins": "off", "local-rules/no-budibase-imports": "error" } + }, + { + "files": [ + "packages/builder/**/*", + "packages/client/**/*", + "packages/frontend-core/**/*" + ], + "rules": { + "no-console": ["error", { "allow": ["warn", "error", "debug"] } ] + } } ], "rules": { diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index d6bbf19940..3060660d47 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -33,13 +33,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -50,14 +50,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -80,7 +80,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 @@ -92,14 +92,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -116,14 +116,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -140,14 +140,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -165,14 +165,14 @@ jobs: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' steps: - name: Checkout repo and submodules - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} fetch-depth: 0 - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -189,13 +189,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} - name: Use Node.js 20.x - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 20.x cache: yarn @@ -219,7 +219,7 @@ jobs: if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') steps: - name: Checkout repo and submodules - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} @@ -249,7 +249,7 @@ jobs: - name: Check submodule merged to base branch if: ${{ steps.get_pro_commits.outputs.base_commit != '' }} - uses: actions/github-script@v4 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -269,7 +269,7 @@ jobs: if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') steps: - name: Checkout repo and submodules - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} @@ -299,7 +299,7 @@ jobs: - name: Check submodule merged to base branch if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }} - uses: actions/github-script@v4 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/close-featurebranch.yml b/.github/workflows/close-featurebranch.yml index 5da3eb52cd..0439aec443 100644 --- a/.github/workflows/close-featurebranch.yml +++ b/.github/workflows/close-featurebranch.yml @@ -17,7 +17,7 @@ jobs: github.event.label.name == 'feature-branch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: passeidireto/trigger-external-workflow-action@main env: PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }} diff --git a/.github/workflows/deploy-featurebranch.yml b/.github/workflows/deploy-featurebranch.yml index a5636fe912..eccc783dfb 100644 --- a/.github/workflows/deploy-featurebranch.yml +++ b/.github/workflows/deploy-featurebranch.yml @@ -17,7 +17,7 @@ jobs: contains(github.event.pull_request.labels.*.name, 'feature-branch') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: passeidireto/trigger-external-workflow-action@main env: PAYLOAD_BRANCH: ${{ github.head_ref }} diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 13d59d1019..483e895e98 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -28,7 +28,7 @@ jobs: run: | echo "Ref is not master, you must run this job from master." exit 1 - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: submodules: true token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} @@ -53,7 +53,7 @@ jobs: needs: [tag-release] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: peter-evans/repository-dispatch@v2 with: diff --git a/README.md b/README.md index 35b84a8816..4979f0ee8e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ The low code platform you'll enjoy using

- Budibase is an open source low-code platform, and the easiest way to build internal apps that improve productivity. + Budibase is an open-source low-code platform that saves engineers 100s of hours building forms, portals, and approval apps, securely.

@@ -20,7 +20,7 @@

- Budibase design ui + Budibase design ui

@@ -57,7 +57,7 @@ ## ✨ Features ### Build and ship real software -Unlike other platforms, with Budibase you build and ship single page applications. Budibase applications have performance baked in and can be designed responsively, providing your users with a great experience. +Unlike other platforms, with Budibase you build and ship single page applications. Budibase applications have performance baked in and can be designed responsively, providing users with a great experience.

### Open source and extensible @@ -65,40 +65,36 @@ Budibase is open-source - licensed as GPL v3. This should fill you with confiden

### Load data or start from scratch -Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no datasources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). +Budibase pulls data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new datasources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).

- Budibase data + Budibase data



### Design and build apps with powerful pre-made components -Budibase comes out of the box with beautifully designed, powerful components which you can use like building blocks to build your UI. We also expose a lot of your favourite CSS styling options so you can go that extra creative mile. [Request new component](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). +Budibase comes out of the box with beautifully designed, powerful components which you can use like building blocks to build your UI. We also expose many of your favourite CSS styling options so you can go that extra creative mile. [Request new component](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).

- Budibase design + Budibase design



-### Automate processes, integrate with other tools, and connect to webhooks -Save time by automating manual processes and workflows. From connecting to webhooks, to automating emails, simply tell Budibase what to do and let it work for you. You can easily [create new automations for Budibase here](https://github.com/Budibase/automations) or [Request new automation](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). - -

- Budibase automations -

+### Automate processes, integrate with other tools and connect to webhooks +Save time by automating manual processes and workflows. From connecting to webhooks to automating emails, simply tell Budibase what to do and let it work for you. You can easily [create new automations for Budibase here](https://github.com/Budibase/automations) or [Request new automation](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).

### Integrate with your favorite tools Budibase integrates with a number of popular tools allowing you to build apps that perfectly fit your stack.

- Budibase integrations + Budibase integrations



-### Admin paradise -Budibase is made to scale. With Budibase, you can self-host on your own infrastructure and globally manage users, onboarding, SMTP, apps, groups, theming and more. You can also provide users/groups with an app portal and disseminate user-management to the group manager. +### Deploy with confidence and security +Budibase is made to scale. With Budibase, you can self-host on your own infrastructure and globally manage users, onboarding, SMTP, apps, groups, theming and more. You can also provide users/groups with an app portal and disseminate user management to the group manager. - Checkout the promo video: https://youtu.be/xoljVpty_Kw @@ -119,17 +115,14 @@ As with anything that we build in Budibase, our new public API is simple to use, #### Docs You can learn more about the Budibase API at the following places: -- [General documentation](https://docs.budibase.com/docs/public-api) : Learn how to get your API key, how to use spec, and how to use with Postman +- [General documentation](https://docs.budibase.com/docs/public-api): Learn how to get your API key, how to use spec, and how to use Postman - [Interactive API documentation](https://docs.budibase.com/reference/post_applications) : Learn how to interact with the API -#### Guides - -- [Build an app with Budibase and Next.js](https://budibase.com/blog/building-a-crud-app-with-budibase-and-next.js/) +

## 🏁 Get started -Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean. -Or use Budibase Cloud if you don't need to self-host, and would like to get started quickly. +Deploy Budibase using Docker, Kubernetes, and Digital Ocean on your existing infrastructure. Or use Budibase Cloud if you don't need to self-host and would like to get started quickly. ### [Get started with self-hosting Budibase](https://docs.budibase.com/docs/hosting-methods) @@ -162,7 +155,7 @@ If you have a question or would like to talk with other Budibase users and join ## ❗ Code of conduct -Budibase is dedicated to providing a welcoming, diverse, and harrassment-free experience for everyone. We expect everyone in the Budibase community to abide by our [**Code of Conduct**](https://github.com/Budibase/budibase/blob/HEAD/docs/CODE_OF_CONDUCT.md). Please read it. +Budibase is dedicated to providing everyone a welcoming, diverse, and harassment-free experience. We expect everyone in the Budibase community to abide by our [**Code of Conduct**](https://github.com/Budibase/budibase/blob/HEAD/docs/CODE_OF_CONDUCT.md). Please read it.
@@ -171,16 +164,16 @@ Budibase is dedicated to providing a welcoming, diverse, and harrassment-free ex ## 🙌 Contributing to Budibase -From opening a bug report to creating a pull request: every contribution is appreciated and welcomed. If you're planning to implement a new feature or change the API please create an issue first. This way we can ensure your work is not in vain. -Environment setup instructions are available for [Debian](https://github.com/Budibase/budibase/tree/HEAD/docs/DEV-SETUP-DEBIAN.md) and [MacOSX](https://github.com/Budibase/budibase/tree/HEAD/docs/DEV-SETUP-MACOSX.md) +From opening a bug report to creating a pull request: every contribution is appreciated and welcomed. If you're planning to implement a new feature or change the API, please create an issue first. This way, we can ensure your work is not in vain. +Environment setup instructions are available [here](https://github.com/Budibase/budibase/tree/HEAD/docs/CONTRIBUTING.md). ### Not Sure Where to Start? -A good place to start contributing, is the [First time issues project](https://github.com/Budibase/budibase/projects/22). +A good place to start contributing is the [First time issues project](https://github.com/Budibase/budibase/projects/22). ### How the repository is organized Budibase is a monorepo managed by lerna. Lerna manages the building and publishing of the budibase packages. At a high level, here are the packages that make up Budibase. -- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - contains code for the budibase builder client side svelte application. +- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - contains code for the budibase builder client-side svelte application. - [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - A module that runs in the browser responsible for reading JSON definition and creating living, breathing web apps from it. @@ -193,7 +186,7 @@ For more information, see [CONTRIBUTING.md](https://github.com/Budibase/budibase ## 📝 License -Budibase is open-source, licensed as [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). The client and component libraries are licensed as [MPL](https://directory.fsf.org/wiki/License:MPL-2.0) - so the apps that you build can be licensed however you like. +Budibase is open-source, licensed as [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). The client and component libraries are licensed as [MPL](https://directory.fsf.org/wiki/License:MPL-2.0) - so the apps you build can be licensed however you like.

diff --git a/docs/DEV-SETUP-DEBIAN.md b/docs/DEV-SETUP-DEBIAN.md deleted file mode 100644 index e098862c64..0000000000 --- a/docs/DEV-SETUP-DEBIAN.md +++ /dev/null @@ -1,76 +0,0 @@ -## 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 -``` - -### Install npm requirements - -``` -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 -``` - -### Check Versions - -This setup process was tested on Debian 11 (bullseye) with version numbers show below. Your mileage may vary using anything else. - -- Docker: 20.10.5 -- Docker-Compose: 1.29.2 -- Node: v14.20.1 -- Yarn: 1.22.19 -- Lerna: 5.1.4 - -### Build - -``` -cd budibase -yarn setup -``` - -The yarn setup command runs several build steps i.e. - -``` -node ./hosting/scripts/setup.js && yarn && 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. - -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. - -Debian based distros: -Add `* - nofile 65536` to `/etc/security/limits.conf`. - -Arch: -Add `DefaultLimitNOFILE=65536` to `/etc/systemd/system.conf`. diff --git a/docs/DEV-SETUP-MACOSX.md b/docs/DEV-SETUP-MACOSX.md deleted file mode 100644 index 0e13d540b3..0000000000 --- a/docs/DEV-SETUP-MACOSX.md +++ /dev/null @@ -1,84 +0,0 @@ -## Dev Environment on MAC OSX 12 (Monterey) - -### Install Homebrew - -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 -through brew. - -### Install Node - -Budibase requires a recent version of node 14: - -``` -brew install node npm -node -v -``` - -### Install npm requirements - -``` -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 -``` - -### Check Versions - -This setup process was tested on Mac OSX 12 (Monterey) with version numbers shown below. Your mileage may vary using anything else. - -- Docker: 20.10.14 -- Docker-Compose: 2.6.0 -- Node: 14.20.1 -- Yarn: 1.22.19 -- Lerna: 5.1.4 - -### Build - -``` -cd budibase -yarn setup -``` - -The yarn setup command runs several build steps i.e. - -``` -node ./hosting/scripts/setup.js && yarn && 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. - -http://127.0.0.1:10000/builder/admin - -| **NOTE**: If you are working on a M1 Apple Silicon, you will need to uncomment `# platform: linux/amd64` line in -[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml) - -### Troubleshootings - -#### Yarn setup errors - -If there are errors with the `yarn setup` command, you can try installing nvm and node 14. This is the same as the instructions for Debian 11. - -#### Node 14.20.1 not supported for arm64 - -If you are working with M1 or M2 Mac and trying the Node installation via `nvm`, probably you will find the error `curl: (22) The requested URL returned error: 404`. - -Version `v14.20.1` is not supported for arm64; in order to use it, you can switch the CPU architecture for this by the following command: - -```shell -arch -x86_64 zsh #Run this before nvm install -``` diff --git a/docs/DEV-SETUP-WINDOWS.md b/docs/DEV-SETUP-WINDOWS.md deleted file mode 100644 index f26a5a0882..0000000000 --- a/docs/DEV-SETUP-WINDOWS.md +++ /dev/null @@ -1,92 +0,0 @@ -## 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 -``` - -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: -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 - -``` -npm install -g yarn jest lerna -``` - -### Clone the repo - -``` -git clone https://github.com/Budibase/budibase.git -``` - -### Check Versions - -This setup process was tested on Windows 11 with version numbers show below. Your mileage may vary using anything else. - -- Docker: 20.10.7 -- Docker-Compose: 2.10.2 -- Node: v14.20.1 -- Yarn: 1.22.19 -- Lerna: 5.5.4 - -### Build - -``` -cd budibase -yarn setup -``` - -The yarn setup command runs several build steps i.e. - -``` -node ./hosting/scripts/setup.js && yarn && 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. - -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. diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index 67ac677984..f9044cd124 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -124,6 +124,8 @@ HEALTHCHECK --interval=15s --timeout=15s --start-period=45s CMD "/healthcheck.sh # must set this just before running ENV NODE_ENV=production +# this is required for isolated-vm to work on Node 20+ +ENV NODE_OPTIONS="--no-node-snapshot" WORKDIR / CMD ["./runner.sh"] diff --git a/i18n/README.es.md b/i18n/README.es.md index 227d5d5d5f..a7d1112914 100644 --- a/i18n/README.es.md +++ b/i18n/README.es.md @@ -207,8 +207,7 @@ Desde comunicar un bug a solventar un error en el codigo, toda contribucion es a implementar una nueva funcionalidad o un realizar un cambio en la API, por favor crea un [nuevo mensaje aqui](https://github.com/Budibase/budibase/issues), de esta manera nos encargaremos que tu trabajo no sea en vano. -Aqui tienes instrucciones de como configurar tu entorno Budibase para [Debian](https://github.com/Budibase/budibase/tree/HEAD/docs/DEV-SETUP-DEBIAN.md) -y [MacOSX](https://github.com/Budibase/budibase/tree/HEAD/docs/DEV-SETUP-MACOSX.md) +Aqui tienes instrucciones de como configurar tu entorno Budibase para [aquí](https://github.com/Budibase/budibase/tree/HEAD/docs/CONTRIBUTING.md). ### No estas seguro por donde empezar? Un buen lugar para empezar a contribuir con nosotros es [aqui](https://github.com/Budibase/budibase/projects/22). diff --git a/lerna.json b/lerna.json index 386fbe425a..8938ed4c5f 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.14.4", + "version": "2.17.2", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/package.json b/package.json index 4422f2bd23..4f81d216ad 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "kill-accountportal": "kill-port 3001 4003", "kill-all": "yarn run kill-builder && yarn run kill-server && yarn kill-accountportal", "dev": "yarn run kill-all && lerna run --parallel prebuild && lerna run --stream dev --ignore=@budibase/account-portal-ui --ignore @budibase/account-portal-server", - "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up && lerna run --stream dev --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker --ignore=@budibase/account-portal-ui --ignore @budibase/account-portal-server", + "dev:noserver": "yarn run kill-builder && lerna run --stream dev:stack:up --ignore @budibase/account-portal-server && lerna run --stream dev --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker --ignore=@budibase/account-portal-ui --ignore @budibase/account-portal-server", "dev:server": "yarn run kill-server && lerna run --stream dev --scope @budibase/worker --scope @budibase/server", "dev:accountportal": "yarn kill-accountportal && lerna run dev --stream --scope @budibase/account-portal-ui --scope @budibase/account-portal-server", "dev:all": "yarn run kill-all && lerna run --stream dev", diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index fde478f973..642e7ec8b6 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -21,7 +21,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/nano": "10.1.4", + "@budibase/nano": "10.1.5", "@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/shared-core": "0.0.0", "@budibase/types": "0.0.0", @@ -32,7 +32,7 @@ "bcryptjs": "2.4.3", "bull": "4.10.1", "correlation-id": "4.0.0", - "dd-trace": "3.13.2", + "dd-trace": "5.0.0", "dotenv": "16.0.1", "ioredis": "5.3.2", "joi": "17.6.0", @@ -54,7 +54,7 @@ "sanitize-s3-objectkey": "0.0.1", "semver": "^7.5.4", "tar-fs": "2.1.1", - "uuid": "8.3.2" + "uuid": "^8.3.2" }, "devDependencies": { "@shopify/jest-koa-mocks": "5.1.1", diff --git a/packages/backend-core/src/context/types.ts b/packages/backend-core/src/context/types.ts index f73dc9f5c7..cc052ca505 100644 --- a/packages/backend-core/src/context/types.ts +++ b/packages/backend-core/src/context/types.ts @@ -1,5 +1,5 @@ import { IdentityContext } from "@budibase/types" -import { ExecutionTimeTracker } from "../timers" +import { Isolate, Context, Module } from "isolated-vm" // keep this out of Budibase types, don't want to expose context info export type ContextMap = { @@ -10,5 +10,9 @@ export type ContextMap = { isScim?: boolean automationId?: string isMigrating?: boolean - jsExecutionTracker?: ExecutionTimeTracker + isolateRefs?: { + jsIsolate: Isolate + jsContext: Context + helpersModule: Module + } } diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index 3fec573bb9..0e2b4173b0 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -19,6 +19,8 @@ import { WriteStream, ReadStream } from "fs" import { newid } from "../../docIds/newid" import { DDInstrumentedDatabase } from "../instrumentation" +const DATABASE_NOT_FOUND = "Database does not exist." + function buildNano(couchInfo: { url: string; cookie: string }) { return Nano({ url: couchInfo.url, @@ -31,6 +33,8 @@ function buildNano(couchInfo: { url: string; cookie: string }) { }) } +type DBCall = () => Promise + export function DatabaseWithConnection( dbName: string, connection: string, @@ -78,7 +82,11 @@ export class DatabaseImpl implements Database { return this.instanceNano || DatabaseImpl.nano } - async checkSetup() { + private getDb() { + return this.nano().db.use(this.name) + } + + private async checkAndCreateDb() { let shouldCreate = !this.pouchOpts?.skip_setup // check exists in a lightweight fashion let exists = await this.exists() @@ -95,14 +103,22 @@ export class DatabaseImpl implements Database { } } } - return this.nano().db.use(this.name) + return this.getDb() } - private async updateOutput(fnc: any) { + // this function fetches the DB and handles if DB creation is needed + private async performCall( + call: (db: Nano.DocumentScope) => Promise> | DBCall + ): Promise { + const db = this.getDb() + const fnc = await call(db) try { return await fnc() } catch (err: any) { - if (err.statusCode) { + if (err.statusCode === 404 && err.reason === DATABASE_NOT_FOUND) { + await this.checkAndCreateDb() + return await this.performCall(call) + } else if (err.statusCode) { err.status = err.statusCode } throw err @@ -110,11 +126,12 @@ export class DatabaseImpl implements Database { } async get(id?: string): Promise { - const db = await this.checkSetup() - if (!id) { - throw new Error("Unable to get doc without a valid _id.") - } - return this.updateOutput(() => db.get(id)) + return this.performCall(db => { + if (!id) { + throw new Error("Unable to get doc without a valid _id.") + } + return () => db.get(id) + }) } async getMultiple( @@ -147,22 +164,23 @@ export class DatabaseImpl implements Database { } async remove(idOrDoc: string | Document, rev?: string) { - const db = await this.checkSetup() - let _id: string - let _rev: string + return this.performCall(db => { + let _id: string + let _rev: string - if (isDocument(idOrDoc)) { - _id = idOrDoc._id! - _rev = idOrDoc._rev! - } else { - _id = idOrDoc - _rev = rev! - } + if (isDocument(idOrDoc)) { + _id = idOrDoc._id! + _rev = idOrDoc._rev! + } else { + _id = idOrDoc + _rev = rev! + } - if (!_id || !_rev) { - throw new Error("Unable to remove doc without a valid _id and _rev.") - } - return this.updateOutput(() => db.destroy(_id, _rev)) + if (!_id || !_rev) { + throw new Error("Unable to remove doc without a valid _id and _rev.") + } + return () => db.destroy(_id, _rev) + }) } async post(document: AnyDocument, opts?: DatabasePutOpts) { @@ -176,45 +194,49 @@ export class DatabaseImpl implements Database { if (!document._id) { throw new Error("Cannot store document without _id field.") } - const db = await this.checkSetup() - if (!document.createdAt) { - document.createdAt = new Date().toISOString() - } - document.updatedAt = new Date().toISOString() - if (opts?.force && document._id) { - try { - const existing = await this.get(document._id) - if (existing) { - document._rev = existing._rev - } - } catch (err: any) { - if (err.status !== 404) { - throw err + return this.performCall(async db => { + if (!document.createdAt) { + document.createdAt = new Date().toISOString() + } + document.updatedAt = new Date().toISOString() + if (opts?.force && document._id) { + try { + const existing = await this.get(document._id) + if (existing) { + document._rev = existing._rev + } + } catch (err: any) { + if (err.status !== 404) { + throw err + } } } - } - return this.updateOutput(() => db.insert(document)) + return () => db.insert(document) + }) } async bulkDocs(documents: AnyDocument[]) { - const db = await this.checkSetup() - return this.updateOutput(() => db.bulk({ docs: documents })) + return this.performCall(db => { + return () => db.bulk({ docs: documents }) + }) } async allDocs( params: DatabaseQueryOpts ): Promise> { - const db = await this.checkSetup() - return this.updateOutput(() => db.list(params)) + return this.performCall(db => { + return () => db.list(params) + }) } async query( viewName: string, params: DatabaseQueryOpts ): Promise> { - const db = await this.checkSetup() - const [database, view] = viewName.split("/") - return this.updateOutput(() => db.view(database, view, params)) + return this.performCall(db => { + const [database, view] = viewName.split("/") + return () => db.view(database, view, params) + }) } async destroy() { @@ -231,8 +253,9 @@ export class DatabaseImpl implements Database { } async compact() { - const db = await this.checkSetup() - return this.updateOutput(() => db.compact()) + return this.performCall(db => { + return () => db.compact() + }) } // All below functions are in-frequently called, just utilise PouchDB diff --git a/packages/backend-core/src/db/instrumentation.ts b/packages/backend-core/src/db/instrumentation.ts index ba5febcba6..aa2ac424ae 100644 --- a/packages/backend-core/src/db/instrumentation.ts +++ b/packages/backend-core/src/db/instrumentation.ts @@ -31,13 +31,6 @@ export class DDInstrumentedDatabase implements Database { }) } - checkSetup(): Promise> { - return tracer.trace("db.checkSetup", span => { - span?.addTags({ db_name: this.name }) - return this.db.checkSetup() - }) - } - get(id?: string | undefined): Promise { return tracer.trace("db.get", span => { span?.addTags({ db_name: this.name, doc_id: id }) diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 0fec786c31..b3179cbeea 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -179,6 +179,7 @@ const environment = { ...getPackageJsonFields(), DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER, OFFLINE_MODE: process.env.OFFLINE_MODE, + SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS, _set(key: any, value: any) { process.env[key] = value // @ts-ignore diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index 7bf26f3688..8001017092 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -2,6 +2,7 @@ export * as configs from "./configs" export * as events from "./events" export * as migrations from "./migrations" export * as users from "./users" +export * as userUtils from "./users/utils" export * as roles from "./security/roles" export * as permissions from "./security/permissions" export * as accounts from "./accounts" diff --git a/packages/backend-core/src/objectStore/cloudfront.ts b/packages/backend-core/src/objectStore/cloudfront.ts index 866fe9e880..3bca97d11e 100644 --- a/packages/backend-core/src/objectStore/cloudfront.ts +++ b/packages/backend-core/src/objectStore/cloudfront.ts @@ -23,7 +23,7 @@ const getCloudfrontSignParams = () => { return { keypairId: env.CLOUDFRONT_PUBLIC_KEY_ID!, privateKeyString: getPrivateKey(), - expireTime: new Date().getTime() + 1000 * 60 * 60, // 1 hour + expireTime: new Date().getTime() + 1000 * 60 * 60 * 24, // 1 day } } diff --git a/packages/backend-core/src/objectStore/objectStore.ts b/packages/backend-core/src/objectStore/objectStore.ts index 57ead0e809..3a3b9cdaab 100644 --- a/packages/backend-core/src/objectStore/objectStore.ts +++ b/packages/backend-core/src/objectStore/objectStore.ts @@ -7,7 +7,7 @@ import tar from "tar-fs" import zlib from "zlib" import { promisify } from "util" import { join } from "path" -import fs from "fs" +import fs, { ReadStream } from "fs" import env from "../environment" import { budibaseTempDir } from "./utils" import { v4 } from "uuid" @@ -184,7 +184,7 @@ export async function upload({ export async function streamUpload( bucketName: string, filename: string, - stream: any, + stream: ReadStream | ReadableStream, extra = {} ) { const objectStore = ObjectStore(bucketName) diff --git a/packages/backend-core/src/security/sessions.ts b/packages/backend-core/src/security/sessions.ts index a86a829b17..8d7b43d5b6 100644 --- a/packages/backend-core/src/security/sessions.ts +++ b/packages/backend-core/src/security/sessions.ts @@ -1,8 +1,8 @@ -const redis = require("../redis/init") -const { v4: uuidv4 } = require("uuid") -const { logWarn } = require("../logging") - +import * as redis from "../redis/init" +import { v4 as uuidv4 } from "uuid" +import { logWarn } from "../logging" import env from "../environment" +import { Duration } from "../utils" import { Session, ScannedSession, @@ -10,8 +10,10 @@ import { CreateSession, } from "@budibase/types" -// a week in seconds -const EXPIRY_SECONDS = 86400 * 7 +// a week expiry is the default +const EXPIRY_SECONDS = env.SESSION_EXPIRY_SECONDS + ? parseInt(env.SESSION_EXPIRY_SECONDS) + : Duration.fromDays(7).toSeconds() function makeSessionID(userId: string, sessionId: string) { return `${userId}/${sessionId}` diff --git a/packages/backend-core/src/timers/timers.ts b/packages/backend-core/src/timers/timers.ts index 9121c576cd..000be74821 100644 --- a/packages/backend-core/src/timers/timers.ts +++ b/packages/backend-core/src/timers/timers.ts @@ -20,41 +20,3 @@ export function cleanup() { } intervals = [] } - -export class ExecutionTimeoutError extends Error { - public readonly name = "ExecutionTimeoutError" -} - -export class ExecutionTimeTracker { - static withLimit(limitMs: number) { - return new ExecutionTimeTracker(limitMs) - } - - constructor(readonly limitMs: number) {} - - private totalTimeMs = 0 - - track(f: () => T): T { - this.checkLimit() - const start = process.hrtime.bigint() - try { - return f() - } finally { - const end = process.hrtime.bigint() - this.totalTimeMs += Number(end - start) / 1e6 - this.checkLimit() - } - } - - get elapsedMS() { - return this.totalTimeMs - } - - checkLimit() { - if (this.totalTimeMs > this.limitMs) { - throw new ExecutionTimeoutError( - `Execution time limit of ${this.limitMs}ms exceeded: ${this.totalTimeMs}ms` - ) - } - } -} diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index 4d0d216603..136cb4b8ad 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -251,7 +251,8 @@ export class UserDB { } const change = dbUser ? 0 : 1 // no change if there is existing user - const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0 + const creatorsChange = + (await isCreator(dbUser)) !== (await isCreator(user)) ? 1 : 0 return UserDB.quotas.addUsers(change, creatorsChange, async () => { await validateUniqueUser(email, tenantId) @@ -335,7 +336,7 @@ export class UserDB { } newUser.userGroups = groups || [] newUsers.push(newUser) - if (isCreator(newUser)) { + if (await isCreator(newUser)) { newCreators.push(newUser) } } @@ -432,12 +433,16 @@ export class UserDB { _deleted: true, })) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) - const creatorsToDelete = usersToDelete.filter(isCreator) + + const creatorsEval = await Promise.all(usersToDelete.map(isCreator)) + const creatorsToDeleteCount = creatorsEval.filter( + creator => !!creator + ).length for (let user of usersToDelete) { await bulkDeleteProcessing(user) } - await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length) + await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount) // Build Response // index users by id @@ -486,7 +491,7 @@ export class UserDB { await db.remove(userId, dbUser._rev) - const creatorsToDelete = isCreator(dbUser) ? 1 : 0 + const creatorsToDelete = (await isCreator(dbUser)) ? 1 : 0 await UserDB.quotas.removeUsers(1, creatorsToDelete) await eventHelpers.handleDeleteEvents(dbUser) await cache.user.invalidateUser(userId) diff --git a/packages/backend-core/src/users/test/utils.spec.ts b/packages/backend-core/src/users/test/utils.spec.ts new file mode 100644 index 0000000000..0fe27f57a6 --- /dev/null +++ b/packages/backend-core/src/users/test/utils.spec.ts @@ -0,0 +1,67 @@ +import { User, UserGroup } from "@budibase/types" +import { generator, structures } from "../../../tests" +import { DBTestConfiguration } from "../../../tests/extra" +import { getGlobalDB } from "../../context" +import { isCreator } from "../utils" + +const config = new DBTestConfiguration() + +describe("Users", () => { + it("User is a creator if it is configured as a global builder", async () => { + const user: User = structures.users.user({ builder: { global: true } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it is configured as a global admin", async () => { + const user: User = structures.users.user({ admin: { global: true } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it is configured with creator permission", async () => { + const user: User = structures.users.user({ builder: { creator: true } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it is a builder in some application", async () => { + const user: User = structures.users.user({ builder: { apps: ["app1"] } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it has CREATOR permission in some application", async () => { + const user: User = structures.users.user({ roles: { app1: "CREATOR" } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it has ADMIN permission in some application", async () => { + const user: User = structures.users.user({ roles: { app1: "ADMIN" } }) + expect(await isCreator(user)).toBe(true) + }) + + it("User is a creator if it remains to a group with ADMIN permissions", async () => { + const usersInGroup = 10 + const groupId = "gr_17abffe89e0b40268e755b952f101a59" + const group: UserGroup = { + ...structures.userGroups.userGroup(), + ...{ _id: groupId, roles: { app1: "ADMIN" } }, + } + const users: User[] = [] + for (const _ of Array.from({ length: usersInGroup })) { + const userId = `us_${generator.guid()}` + const user: User = structures.users.user({ + _id: userId, + userGroups: [groupId], + }) + users.push(user) + } + + await config.doInTenant(async () => { + const db = getGlobalDB() + await db.put(group) + for (let user of users) { + await db.put(user) + const creator = await isCreator(user) + expect(creator).toBe(true) + } + }) + }) +}) diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts index cc2b4fc27f..638da4a5b1 100644 --- a/packages/backend-core/src/users/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -309,7 +309,8 @@ export async function getCreatorCount() { let creators = 0 async function iterate(startPage?: string) { const page = await paginatedUsers({ bookmark: startPage }) - creators += page.data.filter(isCreator).length + const creatorsEval = await Promise.all(page.data.map(isCreator)) + creators += creatorsEval.filter(creator => !!creator).length if (page.hasNextPage) { await iterate(page.nextPage) } diff --git a/packages/backend-core/src/users/utils.ts b/packages/backend-core/src/users/utils.ts index 0ef4b77998..348ad1532f 100644 --- a/packages/backend-core/src/users/utils.ts +++ b/packages/backend-core/src/users/utils.ts @@ -1,4 +1,4 @@ -import { CloudAccount } from "@budibase/types" +import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types" import * as accountSdk from "../accounts" import env from "../environment" import { getPlatformUser } from "./lookup" @@ -6,17 +6,48 @@ import { EmailUnavailableError } from "../errors" import { getTenantId } from "../context" import { sdk } from "@budibase/shared-core" import { getAccountByTenantId } from "../accounts" +import { BUILTIN_ROLE_IDS } from "../security/roles" +import * as context from "../context" // extract from shared-core to make easily accessible from backend-core export const isBuilder = sdk.users.isBuilder export const isAdmin = sdk.users.isAdmin -export const isCreator = sdk.users.isCreator export const isGlobalBuilder = sdk.users.isGlobalBuilder export const isAdminOrBuilder = sdk.users.isAdminOrBuilder export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasBuilderPermissions = sdk.users.hasBuilderPermissions export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions +export async function isCreator(user?: User | ContextUser) { + const isCreatorByUserDefinition = sdk.users.isCreator(user) + if (!isCreatorByUserDefinition && user) { + return await isCreatorByGroupMembership(user) + } + return isCreatorByUserDefinition +} + +async function isCreatorByGroupMembership(user?: User | ContextUser) { + const userGroups = user?.userGroups || [] + if (userGroups.length > 0) { + const db = context.getGlobalDB() + const groups: UserGroup[] = [] + for (let groupId of userGroups) { + try { + const group = await db.get(groupId) + groups.push(group) + } catch (e: any) { + if (e.error !== "not_found") { + throw e + } + } + } + return groups.some(group => + Object.values(group.roles || {}).includes(BUILTIN_ROLE_IDS.ADMIN) + ) + } + return false +} + export async function validateUniqueUser(email: string, tenantId: string) { // check budibase users in other tenants if (env.MULTI_TENANCY) { diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index f2018272f6..cc169eac09 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -18,7 +18,6 @@ export default function positionDropdown(element, opts) { useAnchorWidth, offset = 5, customUpdate, - offsetBelow, } = opts if (!anchor) { return @@ -48,7 +47,7 @@ export default function positionDropdown(element, opts) { styles.top = anchorBounds.top - elementBounds.height - offset styles.maxHeight = maxHeight || 240 } else { - styles.top = anchorBounds.bottom + (offsetBelow || offset) + styles.top = anchorBounds.bottom + offset styles.maxHeight = maxHeight || window.innerHeight - anchorBounds.bottom - 20 } diff --git a/packages/bbui/src/Form/Core/EnvDropdown.svelte b/packages/bbui/src/Form/Core/EnvDropdown.svelte index 2edf8a5f9d..c690ffbc6b 100644 --- a/packages/bbui/src/Form/Core/EnvDropdown.svelte +++ b/packages/bbui/src/Form/Core/EnvDropdown.svelte @@ -184,7 +184,7 @@ {#if environmentVariablesEnabled}
showModal()} class="add-variable">
handleUpgradePanel()} class="add-variable">
null export let getOptionColour = () => null export let getOptionSubtitle = () => null + export let compare = null export let useOptionIconImage = false export let isOptionEnabled export let readonly = false @@ -23,8 +24,6 @@ export let footer = null export let open = false export let tag = null - export let customPopoverOffsetBelow - export let customPopoverMaxHeight export let searchTerm = null export let loading @@ -34,13 +33,19 @@ $: fieldIcon = getFieldAttribute(getOptionIcon, value, options) $: fieldColour = getFieldAttribute(getOptionColour, value, options) + function compareOptionAndValue(option, value) { + return typeof compare === "function" + ? compare(option, value) + : option === value + } + const getFieldAttribute = (getAttribute, value, options) => { // Wait for options to load if there is a value but no options if (!options?.length) { return "" } - const index = options.findIndex( - (option, idx) => getOptionValue(option, idx) === value + const index = options.findIndex((option, idx) => + compareOptionAndValue(getOptionValue(option, idx), value) ) return index !== -1 ? getAttribute(options[index], index) : null } @@ -90,11 +95,9 @@ {autocomplete} {sort} {tag} - {customPopoverOffsetBelow} - {customPopoverMaxHeight} isPlaceholder={value == null || value === ""} placeholderOption={placeholder === false ? null : placeholder} - isOptionSelected={option => option === value} + isOptionSelected={option => compareOptionAndValue(option, value)} onSelectOption={selectOption} {loading} /> diff --git a/packages/bbui/src/Form/Select.svelte b/packages/bbui/src/Form/Select.svelte index 8235b68faf..2119a37980 100644 --- a/packages/bbui/src/Form/Select.svelte +++ b/packages/bbui/src/Form/Select.svelte @@ -28,6 +28,7 @@ export let footer = null export let tag = null export let helpText = null + export let compare const dispatch = createEventDispatcher() const onChange = e => { value = e.detail @@ -65,6 +66,7 @@ {autocomplete} {customPopoverHeight} {tag} + {compare} on:change={onChange} on:click /> diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index 3ca584504c..189ef70c2b 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -40,7 +40,7 @@ loading = false } - async function confirm() { + export async function confirm() { loading = true if (!onConfirm || (await onConfirm()) !== keepOpen) { hide() diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index a68430e973..5066e3aa05 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -18,7 +18,6 @@ export let useAnchorWidth = false export let dismissible = true export let offset = 5 - export let offsetBelow export let customHeight export let animate = true export let customZindex @@ -89,7 +88,6 @@ maxWidth, useAnchorWidth, offset, - offsetBelow, customUpdate: handlePostionUpdate, }} use:clickOutside={{ diff --git a/packages/builder/package.json b/packages/builder/package.json index 9472c51965..273994ed0e 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -66,6 +66,7 @@ "@fortawesome/free-solid-svg-icons": "^6.4.2", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", + "@zerodevx/svelte-json-view": "^1.0.7", "codemirror": "^5.59.0", "dayjs": "^1.10.8", "downloadjs": "1.4.7", diff --git a/packages/builder/src/builderStore/componentUtils.js b/packages/builder/src/builderStore/componentUtils.js index a7ee3ff351..733ce0948e 100644 --- a/packages/builder/src/builderStore/componentUtils.js +++ b/packages/builder/src/builderStore/componentUtils.js @@ -92,7 +92,14 @@ export const findAllMatchingComponents = (rootComponent, selector) => { } /** - * Finds the closes parent component which matches certain criteria + * Recurses through the component tree and finds all components. + */ +export const findAllComponents = rootComponent => { + return findAllMatchingComponents(rootComponent, () => true) +} + +/** + * Finds the closest parent component which matches certain criteria */ export const findClosestMatchingComponent = ( rootComponent, diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js index 52368a0723..86aecd466f 100644 --- a/packages/builder/src/builderStore/dataBinding.js +++ b/packages/builder/src/builderStore/dataBinding.js @@ -1,6 +1,7 @@ import { cloneDeep } from "lodash/fp" import { get } from "svelte/store" import { + findAllComponents, findAllMatchingComponents, findComponent, findComponentPath, @@ -102,6 +103,9 @@ export const getAuthBindings = () => { return bindings } +/** + * Gets all bindings for environment variables + */ export const getEnvironmentBindings = () => { let envVars = get(environment).variables return envVars.map(variable => { @@ -130,26 +134,22 @@ export const toBindingsArray = (valueMap, prefix, category) => { if (!binding) { return acc } - let config = { type: "context", runtimeBinding: binding, readableBinding: `${prefix}.${binding}`, icon: "Brackets", } - if (category) { config.category = category } - acc.push(config) - return acc }, []) } /** - * Utility - coverting a map of readable bindings to runtime + * Utility to covert a map of readable bindings to runtime */ export const readableToRuntimeMap = (bindings, ctx) => { if (!bindings || !ctx) { @@ -162,7 +162,7 @@ export const readableToRuntimeMap = (bindings, ctx) => { } /** - * Utility - coverting a map of runtime bindings to readable + * Utility to covert a map of runtime bindings to readable bindings */ export const runtimeToReadableMap = (bindings, ctx) => { if (!bindings || !ctx) { @@ -188,15 +188,23 @@ export const getComponentBindableProperties = (asset, componentId) => { if (!def?.context) { return [] } + const contexts = Array.isArray(def.context) ? def.context : [def.context] // Get the bindings for the component - return getProviderContextBindings(asset, component) + const componentContext = { + component, + definition: def, + contexts, + } + return generateComponentContextBindings(asset, componentContext) } /** - * Gets all data provider components above a component. + * Gets all component contexts available to a certain component. This handles + * both global and local bindings, taking into account a component's position + * in the component tree. */ -export const getContextProviderComponents = ( +export const getComponentContexts = ( asset, componentId, type, @@ -205,30 +213,55 @@ export const getContextProviderComponents = ( if (!asset || !componentId) { return [] } + let map = {} - // Get the component tree leading up to this component, ignoring the component - // itself - const path = findComponentPath(asset.props, componentId) - if (!options?.includeSelf) { - path.pop() - } - - // Filter by only data provider components - return path.filter(component => { + // Processes all contexts exposed by a component + const processContexts = scope => component => { const def = store.actions.components.getDefinition(component._component) if (!def?.context) { - return false + return } - - // If no type specified, return anything that exposes context - if (!type) { - return true + if (!map[component._id]) { + map[component._id] = { + component, + definition: def, + contexts: [], + } } - - // Otherwise only match components with the specific context type const contexts = Array.isArray(def.context) ? def.context : [def.context] - return contexts.find(context => context.type === type) != null - }) + contexts.forEach(context => { + // Ensure type matches + if (type && context.type !== type) { + return + } + // Ensure scope matches + let contextScope = context.scope || "global" + if (contextScope !== scope) { + return + } + // Ensure the context is compatible with the component's current settings + if (!isContextCompatibleWithComponent(context, component)) { + return + } + map[component._id].contexts.push(context) + }) + } + + // Process all global contexts + const allComponents = findAllComponents(asset.props) + allComponents.forEach(processContexts("global")) + + // Process all local contexts + const localComponents = findComponentPath(asset.props, componentId) + localComponents.forEach(processContexts("local")) + + // Exclude self if required + if (!options?.includeSelf) { + delete map[componentId] + } + + // Only return components which provide at least 1 matching context + return Object.values(map).filter(x => x.contexts.length > 0) } /** @@ -240,20 +273,19 @@ export const getActionProviders = ( actionType, options = { includeSelf: false } ) => { - if (!asset || !componentId) { + if (!asset) { return [] } - // Get the component tree leading up to this component, ignoring the component - // itself - const path = findComponentPath(asset.props, componentId) - if (!options?.includeSelf) { - path.pop() - } + // Get all components + const components = findAllComponents(asset.props) // Find matching contexts and generate bindings let providers = [] - path.forEach(component => { + components.forEach(component => { + if (!options?.includeSelf && component._id === componentId) { + return + } const def = store.actions.components.getDefinition(component._component) const actions = (def?.actions || []).map(action => { return typeof action === "string" ? { type: action } : action @@ -317,142 +349,131 @@ export const getDatasourceForProvider = (asset, component) => { * Gets all bindable data properties from component data contexts. */ const getContextBindings = (asset, componentId) => { - // Extract any components which provide data contexts - const dataProviders = getContextProviderComponents(asset, componentId) + // Get all available contexts for this component + const componentContexts = getComponentContexts(asset, componentId) - // Generate bindings for all matching components - return getProviderContextBindings(asset, dataProviders) + // Generate bindings for each context + return componentContexts + .map(componentContext => { + return generateComponentContextBindings(asset, componentContext) + }) + .flat() } /** - * Gets the context bindings exposed by a set of data provider components. + * Generates a set of bindings for a given component context */ -const getProviderContextBindings = (asset, dataProviders) => { - if (!asset || !dataProviders) { +const generateComponentContextBindings = (asset, componentContext) => { + const { component, definition, contexts } = componentContext + if (!component || !definition || !contexts?.length) { return [] } - // Ensure providers is an array - if (!Array.isArray(dataProviders)) { - dataProviders = [dataProviders] - } - // Create bindings for each data provider let bindings = [] - dataProviders.forEach(component => { - const def = store.actions.components.getDefinition(component._component) - const contexts = Array.isArray(def.context) ? def.context : [def.context] + contexts.forEach(context => { + if (!context?.type) { + return + } - // Create bindings for each context block provided by this data provider - contexts.forEach(context => { - if (!context?.type) { + let schema + let table + let readablePrefix + let runtimeSuffix = context.suffix + + if (context.type === "form") { + // Forms do not need table schemas + // Their schemas are built from their component field names + schema = buildFormSchema(component, asset) + readablePrefix = "Fields" + } else if (context.type === "static") { + // Static contexts are fully defined by the components + schema = {} + const values = context.values || [] + values.forEach(value => { + schema[value.key] = { + name: value.label, + type: value.type || "string", + } + }) + } else if (context.type === "schema") { + // Schema contexts are generated dynamically depending on their data + const datasource = getDatasourceForProvider(asset, component) + if (!datasource) { return } + const info = getSchemaForDatasource(asset, datasource) + schema = info.schema + table = info.table - let schema - let table - let readablePrefix - let runtimeSuffix = context.suffix - - if (context.type === "form") { - // Forms do not need table schemas - // Their schemas are built from their component field names - schema = buildFormSchema(component, asset) - readablePrefix = "Fields" - } else if (context.type === "static") { - // Static contexts are fully defined by the components - schema = {} - const values = context.values || [] - values.forEach(value => { - schema[value.key] = { - name: value.label, - type: value.type || "string", - } - }) - } else if (context.type === "schema") { - // Schema contexts are generated dynamically depending on their data - const datasource = getDatasourceForProvider(asset, component) - if (!datasource) { - return - } - const info = getSchemaForDatasource(asset, datasource) - schema = info.schema - table = info.table - - // Determine what to prefix bindings with - if (datasource.type === "jsonarray") { - // For JSON arrays, use the array name as the readable prefix - const split = datasource.label.split(".") - readablePrefix = split[split.length - 1] - } else if (datasource.type === "viewV2") { - // For views, use the view name - const view = Object.values(table?.views || {}).find( - view => view.id === datasource.id - ) - readablePrefix = view?.name - } else { - // Otherwise use the table name - readablePrefix = info.table?.name - } - } - if (!schema) { - return - } - - const keys = Object.keys(schema).sort() - - // Generate safe unique runtime prefix - let providerId = component._id - if (runtimeSuffix) { - providerId += `-${runtimeSuffix}` - } - - if (!filterCategoryByContext(component, context)) { - return - } - - const safeComponentId = makePropSafe(providerId) - - // Create bindable properties for each schema field - keys.forEach(key => { - const fieldSchema = schema[key] - - // Make safe runtime binding - const safeKey = key.split(".").map(makePropSafe).join(".") - const runtimeBinding = `${safeComponentId}.${safeKey}` - - // Optionally use a prefix with readable bindings - let readableBinding = component._instanceName - if (readablePrefix) { - readableBinding += `.${readablePrefix}` - } - readableBinding += `.${fieldSchema.name || key}` - - const bindingCategory = getComponentBindingCategory( - component, - context, - def + // Determine what to prefix bindings with + if (datasource.type === "jsonarray") { + // For JSON arrays, use the array name as the readable prefix + const split = datasource.label.split(".") + readablePrefix = split[split.length - 1] + } else if (datasource.type === "viewV2") { + // For views, use the view name + const view = Object.values(table?.views || {}).find( + view => view.id === datasource.id ) + readablePrefix = view?.name + } else { + // Otherwise use the table name + readablePrefix = info.table?.name + } + } + if (!schema) { + return + } - // Create the binding object - bindings.push({ - type: "context", - runtimeBinding, - readableBinding, - // Field schema and provider are required to construct relationship - // datasource options, based on bindable properties - fieldSchema, - providerId, - // Table ID is used by JSON fields to know what table the field is in - tableId: table?._id, - component: component._component, - category: bindingCategory.category, - icon: bindingCategory.icon, - display: { - name: fieldSchema.name || key, - type: fieldSchema.type, - }, - }) + const keys = Object.keys(schema).sort() + + // Generate safe unique runtime prefix + let providerId = component._id + if (runtimeSuffix) { + providerId += `-${runtimeSuffix}` + } + const safeComponentId = makePropSafe(providerId) + + // Create bindable properties for each schema field + keys.forEach(key => { + const fieldSchema = schema[key] + + // Make safe runtime binding + const safeKey = key.split(".").map(makePropSafe).join(".") + const runtimeBinding = `${safeComponentId}.${safeKey}` + + // Optionally use a prefix with readable bindings + let readableBinding = component._instanceName + if (readablePrefix) { + readableBinding += `.${readablePrefix}` + } + readableBinding += `.${fieldSchema.name || key}` + + // Determine which category this binding belongs in + const bindingCategory = getComponentBindingCategory( + component, + context, + definition + ) + // Create the binding object + bindings.push({ + type: "context", + runtimeBinding, + readableBinding: `${readableBinding}`, + // Field schema and provider are required to construct relationship + // datasource options, based on bindable properties + fieldSchema, + providerId, + // Table ID is used by JSON fields to know what table the field is in + tableId: table?._id, + component: component._component, + category: bindingCategory.category, + icon: bindingCategory.icon, + display: { + name: `${fieldSchema.name || key}`, + type: fieldSchema.type, + }, }) }) }) @@ -460,25 +481,38 @@ const getProviderContextBindings = (asset, dataProviders) => { return bindings } -// Exclude a data context based on the component settings -const filterCategoryByContext = (component, context) => { - const { _component } = component +/** + * Checks if a certain data context is compatible with a certain instance of a + * configured component. + */ +const isContextCompatibleWithComponent = (context, component) => { + if (!component) { + return false + } + const { _component, actionType } = component + const { type } = context + + // Certain types of form blocks only allow certain contexts if (_component.endsWith("formblock")) { if ( - (component.actionType === "Create" && context.type === "schema") || - (component.actionType === "View" && context.type === "form") + (actionType === "Create" && type === "schema") || + (actionType === "View" && type === "form") ) { return false } } + + // Allow the context by default return true } // Enrich binding category information for certain components const getComponentBindingCategory = (component, context, def) => { + // Default category to component name let icon = def.icon let category = component._instanceName + // Form block edge case if (component._component.endsWith("formblock")) { if (context.type === "form") { category = `${component._instanceName} - Fields` @@ -496,7 +530,7 @@ const getComponentBindingCategory = (component, context, def) => { } /** - * Gets all bindable properties from the logged in user. + * Gets all bindable properties from the logged-in user. */ export const getUserBindings = () => { let bindings = [] @@ -566,6 +600,7 @@ const getDeviceBindings = () => { /** * Gets all selected rows bindings for tables in the current asset. + * TODO: remove in future because we don't need a separate store for this */ const getSelectedRowsBindings = asset => { let bindings = [] @@ -608,6 +643,9 @@ const getSelectedRowsBindings = asset => { return bindings } +/** + * Generates a state binding for a certain key name + */ export const makeStateBinding = key => { return { type: "context", @@ -662,6 +700,9 @@ const getUrlBindings = asset => { return urlParamBindings.concat([queryParamsBinding]) } +/** + * Generates all bindings for role IDs + */ const getRoleBindings = () => { return (get(rolesStore) || []).map(role => { return { @@ -1035,11 +1076,48 @@ export const getAllStateVariables = () => { getAllAssets().forEach(asset => { findAllMatchingComponents(asset.props, component => { const settings = getComponentSettings(component._component) - settings - .filter(setting => setting.type === "event") - .forEach(setting => { - eventSettings.push(component[setting.key]) - }) + + const parseEventSettings = (settings, comp) => { + settings + .filter(setting => setting.type === "event") + .forEach(setting => { + eventSettings.push(comp[setting.key]) + }) + } + + const parseComponentSettings = (settings, component) => { + // Parse the nested button configurations + settings + .filter(setting => setting.type === "buttonConfiguration") + .forEach(setting => { + const buttonConfig = component[setting.key] + + if (Array.isArray(buttonConfig)) { + buttonConfig.forEach(button => { + const nestedSettings = getComponentSettings(button._component) + parseEventSettings(nestedSettings, button) + }) + } + }) + + parseEventSettings(settings, component) + } + + // Parse the base component settings + parseComponentSettings(settings, component) + + // Parse step configuration + const stepSetting = settings.find( + setting => setting.type === "stepConfiguration" + ) + const steps = stepSetting ? component[stepSetting.key] : [] + const stepDefinition = getComponentSettings( + "@budibase/standard-components/multistepformblockstep" + ) + + steps.forEach(step => { + parseComponentSettings(stepDefinition, step) + }) }) }) diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index dd54dcf13e..b58d196024 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -9,6 +9,7 @@ import { findComponent, findComponentPath } from "./componentUtils" import { RoleUtils } from "@budibase/frontend-core" import { createHistoryStore } from "builderStore/store/history" import { cloneDeep } from "lodash/fp" +import { getHoverStore } from "./store/hover" export const store = getFrontendStore() export const automationStore = getAutomationStore() @@ -16,6 +17,7 @@ export const themeStore = getThemeStore() export const temporalStore = getTemporalStore() export const userStore = getUserStore() export const deploymentStore = getDeploymentStore() +export const hoverStore = getHoverStore() // Setup history for screens export const screenHistoryStore = createHistoryStore({ diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 2d62a0667e..55208bb97e 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -85,7 +85,6 @@ const INITIAL_FRONTEND_STATE = { selectedScreenId: null, selectedComponentId: null, selectedLayoutId: null, - hoverComponentId: null, // Client state selectedComponentInstance: null, @@ -159,6 +158,7 @@ export const getFrontendStore = () => { ...INITIAL_FRONTEND_STATE.features, ...application.features, }, + automations: application.automations || {}, icon: application.icon || {}, initialised: true, })) @@ -707,10 +707,9 @@ export const getFrontendStore = () => { else { if (setting.type === "dataProvider") { // Validate data provider exists, or else clear it - const treeId = parent?._id || component._id - const path = findComponentPath(screen?.props, treeId) - const providers = path.filter(component => - component._component?.endsWith("/dataprovider") + const providers = findAllMatchingComponents( + screen?.props, + component => component._component?.endsWith("/dataprovider") ) // Validate non-empty values const valid = providers?.some(dp => value.includes?.(dp._id)) @@ -732,6 +731,16 @@ export const getFrontendStore = () => { return null } + // Find all existing components of this type so that we can give this + // component a unique name + const screen = get(selectedScreen).props + const otherComponents = findAllMatchingComponents( + screen, + x => x._component === definition.component && x._id !== screen._id + ) + let name = definition.friendlyName || definition.name + name = `${name} ${otherComponents.length + 1}` + // Generate basic component structure let instance = { _id: Helpers.uuid(), @@ -741,7 +750,7 @@ export const getFrontendStore = () => { hover: {}, active: {}, }, - _instanceName: `New ${definition.friendlyName || definition.name}`, + _instanceName: name, ...presetProps, } diff --git a/packages/builder/src/builderStore/store/hover.js b/packages/builder/src/builderStore/store/hover.js new file mode 100644 index 0000000000..5db9272975 --- /dev/null +++ b/packages/builder/src/builderStore/store/hover.js @@ -0,0 +1,27 @@ +import { get, writable } from "svelte/store" +import { store as builder } from "builderStore" + +export const getHoverStore = () => { + const initialValue = { + componentId: null, + } + + const store = writable(initialValue) + + const update = (componentId, notifyClient = true) => { + if (componentId === get(store).componentId) { + return + } + store.update(state => { + state.componentId = componentId + return state + }) + if (notifyClient) { + builder.actions.preview.sendEvent("hover-component", componentId) + } + } + return { + subscribe: store.subscribe, + actions: { update }, + } +} diff --git a/packages/builder/src/builderStore/websocket.js b/packages/builder/src/builderStore/websocket.js index ca3f49e9a3..4482e3ae1e 100644 --- a/packages/builder/src/builderStore/websocket.js +++ b/packages/builder/src/builderStore/websocket.js @@ -21,7 +21,7 @@ export const createBuilderWebsocket = appId => { }) }) socket.on("connect_error", err => { - console.log("Failed to connect to builder websocket:", err.message) + console.error("Failed to connect to builder websocket:", err.message) }) socket.on("disconnect", () => { userStore.actions.reset() diff --git a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte index 15dd864168..fbc79b967c 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/FlowChart/ActionModal.svelte @@ -19,10 +19,15 @@ export let lastStep let syncAutomationsEnabled = $licensing.syncAutomationsEnabled + let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK] let selectedAction let actionVal let actions = Object.entries($automationStore.blockDefinitions.ACTION) + let lockedFeatures = [ + ActionStepID.COLLECT, + ActionStepID.TRIGGER_AUTOMATION_RUN, + ] $: collectBlockExists = checkForCollectStep($selectedAutomation) @@ -36,6 +41,10 @@ disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists, message: collectDisabledMessage(), }, + TRIGGER_AUTOMATION_RUN: { + disabled: !triggerAutomationRunEnabled, + message: "Please upgrade to a paid plan", + }, } } @@ -149,10 +158,10 @@
{action.name} - {#if isDisabled && !syncAutomationsEnabled && action.stepId === ActionStepID.COLLECT} + {#if isDisabled && !syncAutomationsEnabled && !triggerAutomationRunEnabled && lockedFeatures.includes(action.stepId)}
- Business + Premium
{:else if isDisabled} diff --git a/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte index 89406f15d8..2cad22c820 100644 --- a/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte +++ b/packages/builder/src/components/automation/AutomationBuilder/TestDisplay.svelte @@ -1,7 +1,8 @@ - - -
-
Automations
-
- -
-
-
- -
+ diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 158ecd8281..a9e7ab0d39 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -28,6 +28,7 @@ import CodeEditorModal from "./CodeEditorModal.svelte" import QuerySelector from "./QuerySelector.svelte" import QueryParamSelector from "./QueryParamSelector.svelte" + import AutomationSelector from "./AutomationSelector.svelte" import CronBuilder from "./CronBuilder.svelte" import Editor from "components/integration/QueryEditor.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" @@ -51,7 +52,6 @@ export let testData export let schemaProperties export let isTestModal = false - let webhookModal let drawer let fillWidth = true @@ -101,7 +101,6 @@ } } } - const onChange = Utils.sequential(async (e, key) => { // We need to cache the schema as part of the definition because it is // used in the server to detect relationships. It would be far better to @@ -145,6 +144,7 @@ if (!block || !automation) { return [] } + // Find previous steps to the selected one let allSteps = [...automation.steps] @@ -156,22 +156,98 @@ // Extract all outputs from all previous steps as available bindingsx§x let bindings = [] let loopBlockCount = 0 + const addBinding = (name, value, icon, idx, isLoopBlock, bindingName) => { + if (!name) return + const runtimeBinding = determineRuntimeBinding(name, idx, isLoopBlock) + const categoryName = determineCategoryName(idx, isLoopBlock, bindingName) + + bindings.push( + createBindingObject( + name, + value, + icon, + idx, + loopBlockCount, + isLoopBlock, + runtimeBinding, + categoryName, + bindingName + ) + ) + } + + const determineRuntimeBinding = (name, idx, isLoopBlock) => { + let runtimeName + + /* Begin special cases for generating custom schemas based on triggers */ + if (idx === 0 && automation.trigger?.event === "app:trigger") { + return `trigger.fields.${name}` + } + + if ( + idx === 0 && + (automation.trigger?.event === "row:update" || + automation.trigger?.event === "row:save") + ) { + if (name !== "id" && name !== "revision") return `trigger.row.${name}` + } + /* End special cases for generating custom schemas based on triggers */ + + if (isLoopBlock) { + runtimeName = `loop.${name}` + } else if (block.name.startsWith("JS")) { + runtimeName = `steps[${idx - loopBlockCount}].${name}` + } else { + runtimeName = `steps.${idx - loopBlockCount}.${name}` + } + return idx === 0 ? `trigger.${name}` : runtimeName + } + + const determineCategoryName = (idx, isLoopBlock, bindingName) => { + if (idx === 0) return "Trigger outputs" + if (isLoopBlock) return "Loop Outputs" + return bindingName + ? `${bindingName} outputs` + : `Step ${idx - loopBlockCount} outputs` + } + + const createBindingObject = ( + name, + value, + icon, + idx, + loopBlockCount, + isLoopBlock, + runtimeBinding, + categoryName, + bindingName + ) => { + return { + readableBinding: bindingName + ? `${bindingName}.${name}` + : runtimeBinding, + runtimeBinding, + type: value.type, + description: value.description, + icon, + category: categoryName, + display: { + type: value.type, + name, + rank: isLoopBlock ? idx + 1 : idx - loopBlockCount, + }, + } + } + for (let idx = 0; idx < blockIdx; idx++) { let wasLoopBlock = allSteps[idx - 1]?.stepId === ActionStepID.LOOP let isLoopBlock = allSteps[idx]?.stepId === ActionStepID.LOOP && - allSteps.find(x => x.blockToLoop === block.id) + allSteps.some(x => x.blockToLoop === block.id) + let schema = cloneDeep(allSteps[idx]?.schema?.outputs?.properties) ?? {} + let bindingName = + automation.stepNames?.[allSteps[idx - loopBlockCount].id] - // If the previous block was a loop block, decrement the index so the following - // steps are in the correct order - if (wasLoopBlock) { - loopBlockCount++ - continue - } - - let schema = allSteps[idx]?.schema?.outputs?.properties ?? {} - - // If its a Loop Block, we need to add this custom schema if (isLoopBlock) { schema = { currentItem: { @@ -180,54 +256,44 @@ }, } } - const outputs = Object.entries(schema) - let bindingIcon = "" - let bindingRank = 0 - if (idx === 0) { - bindingIcon = automation.trigger.icon - } else if (isLoopBlock) { - bindingIcon = "Reuse" - bindingRank = idx + 1 - } else { - bindingIcon = allSteps[idx].icon - bindingRank = idx - loopBlockCount + + if (idx === 0 && automation.trigger?.event === "app:trigger") { + schema = Object.fromEntries( + Object.keys(automation.trigger.inputs.fields || []).map(key => [ + key, + { type: automation.trigger.inputs.fields[key] }, + ]) + ) } - let bindingName = - automation.stepNames?.[allSteps[idx - loopBlockCount].id] - bindings = bindings.concat( - outputs.map(([name, value]) => { - let runtimeName = isLoopBlock - ? `loop.${name}` - : block.name.startsWith("JS") - ? `steps[${idx - loopBlockCount}].${name}` - : `steps.${idx - loopBlockCount}.${name}` - const runtime = idx === 0 ? `trigger.${name}` : runtimeName - - let categoryName - if (idx === 0) { - categoryName = "Trigger outputs" - } else if (isLoopBlock) { - categoryName = "Loop Outputs" - } else if (bindingName) { - categoryName = `${bindingName} outputs` - } else { - categoryName = `Step ${idx - loopBlockCount} outputs` + if ( + (idx === 0 && automation.trigger.event === "row:update") || + (idx === 0 && automation.trigger.event === "row:save") + ) { + let table = $tables.list.find( + table => table._id === automation.trigger.inputs.tableId + ) + // We want to generate our own schema for the bindings from the table schema itself + for (const key in table?.schema) { + schema[key] = { + type: table.schema[key].type, } + } + // remove the original binding + delete schema.row + } + let icon = + idx === 0 + ? automation.trigger.icon + : isLoopBlock + ? "Reuse" + : allSteps[idx].icon - return { - readableBinding: bindingName ? `${bindingName}.${name}` : runtime, - runtimeBinding: runtime, - type: value.type, - description: value.description, - icon: bindingIcon, - category: categoryName, - display: { - type: value.type, - name: name, - rank: bindingRank, - }, - } - }) + if (wasLoopBlock) { + loopBlockCount++ + continue + } + Object.entries(schema).forEach(([name, value]) => + addBinding(name, value, icon, idx, isLoopBlock, bindingName) ) } @@ -245,10 +311,8 @@ }) ) } - return bindings } - function lookForFilters(properties) { if (!properties) { return [] @@ -286,7 +350,8 @@ value.customType !== "code" && value.customType !== "queryParams" && value.customType !== "cron" && - value.customType !== "triggerSchema" + value.customType !== "triggerSchema" && + value.customType !== "automationFields" ) } @@ -421,6 +486,12 @@ on:change={e => onChange(e, key)} value={inputData[key]} /> + {:else if value.customType === "automationFields"} + onChange(e, key)} + value={inputData[key]} + {bindings} + /> {:else if value.customType === "queryParams"} onChange(e, key)} diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationSelector.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationSelector.svelte new file mode 100644 index 0000000000..7e3ba92420 --- /dev/null +++ b/packages/builder/src/components/automation/SetupPanel/AutomationSelector.svelte @@ -0,0 +1,87 @@ + + +
+ +
+ +
editTableNameModal.confirm()}> + +
{ } else if (mode.name == "handlebars") { parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text) } else { - console.log("Unsupported") + console.warn("Unsupported") return } diff --git a/packages/builder/src/components/common/RelationshipSelector.svelte b/packages/builder/src/components/common/RelationshipSelector.svelte index 0636deaf08..63f0357a8f 100644 --- a/packages/builder/src/components/common/RelationshipSelector.svelte +++ b/packages/builder/src/components/common/RelationshipSelector.svelte @@ -17,7 +17,7 @@
-
+
diff --git a/packages/builder/src/components/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingPicker.svelte index 93d9d62021..d85a0a2bbb 100644 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ b/packages/builder/src/components/common/bindings/BindingPicker.svelte @@ -47,9 +47,10 @@ }) $: filteredHelpers = helpers?.filter(helper => { return ( - !search || - helper.label.match(searchRgx) || - helper.description.match(searchRgx) + (!search || + helper.label.match(searchRgx) || + helper.description.match(searchRgx)) && + (mode.name !== "javascript" || helper.allowsJs) ) }) diff --git a/packages/builder/src/components/design/ScreenDetailsModal.svelte b/packages/builder/src/components/design/ScreenDetailsModal.svelte index 7d0c58ce56..f31cc8a99c 100644 --- a/packages/builder/src/components/design/ScreenDetailsModal.svelte +++ b/packages/builder/src/components/design/ScreenDetailsModal.svelte @@ -13,6 +13,7 @@ const appPrefix = "/app" let touched = false let error + let modal $: appUrl = screenUrl ? `${window.location.origin}${appPrefix}${screenUrl}` @@ -50,6 +51,7 @@ - -
- {appUrl} -
+
modal.confirm()}> + +
+ {appUrl} +
+
diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte index fb9ee2c8a5..b2bcd3daa4 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte @@ -66,7 +66,7 @@ try { await store.actions.screens.updateSetting(get(selectedScreen), key, value) } catch (error) { - console.log(error) + console.error(error) notifications.error("Error saving screen settings") } } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte index 65ea172012..011980bbe2 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPreview.svelte @@ -1,7 +1,7 @@
    @@ -120,9 +112,9 @@ on:dragover={dragover(component, index)} on:iconClick={() => toggleNodeOpen(component._id)} on:drop={onDrop} - hovering={$store.hoverComponentId === component._id} - on:mouseenter={() => handleMouseover(component._id)} - on:mouseleave={() => handleMouseout(component._id)} + hovering={$hoverStore.componentId === component._id} + on:mouseenter={() => hover(component._id)} + on:mouseleave={() => hover(null)} text={getComponentText(component)} icon={getComponentIcon(component)} iconTooltip={getComponentName(component)} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/index.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/index.svelte index 1e2ea47e63..13f2e73853 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/index.svelte @@ -1,7 +1,12 @@
    @@ -65,46 +64,31 @@ scrollable icon="WebPage" on:drop={onDrop} - on:click={() => { - $store.selectedComponentId = `${$store.selectedScreenId}-screen` - }} - hovering={$store.hoverComponentId === - `${$store.selectedScreenId}-screen`} - on:mouseenter={() => - handleMouseover(`${$store.selectedScreenId}-screen`)} - on:mouseleave={() => - handleMouseout(`${$store.selectedScreenId}-screen`)} - id={`component-screen`} - selectedBy={$userSelectedResourceMap[ - `${$store.selectedScreenId}-screen` - ]} + on:click={() => ($store.selectedComponentId = screenComponentId)} + hovering={$hoverStore.componentId === screenComponentId} + on:mouseenter={() => hover(screenComponentId)} + on:mouseleave={() => hover(null)} + id="component-screen" + selectedBy={$userSelectedResourceMap[screenComponentId]} > { - $store.selectedComponentId = `${$store.selectedScreenId}-navigation` - }} - hovering={$store.hoverComponentId === - `${$store.selectedScreenId}-navigation`} - on:mouseenter={() => - handleMouseover(`${$store.selectedScreenId}-navigation`)} - on:mouseleave={() => - handleMouseout(`${$store.selectedScreenId}-navigation`)} - id={`component-nav`} - selectedBy={$userSelectedResourceMap[ - `${$store.selectedScreenId}-navigation` - ]} + on:click={() => ($store.selectedComponentId = navComponentId)} + hovering={$hoverStore.componentId === navComponentId} + on:mouseenter={() => hover(navComponentId)} + on:mouseleave={() => hover(null)} + id="component-nav" + selectedBy={$userSelectedResourceMap[navComponentId]} /> $store.appId?.includes(app.appId)) $: licensePlan = $auth.user?.license?.plan $: page = $pageInfo.page $: fetchLogs(automationId, status, page, timeRange) - + $: isCloud = $admin.cloud + $: chainAutomations = app?.automations?.chainAutomations ?? !isCloud const timeOptions = [ { value: "90-d", label: "Past 90 days" }, { value: "30-d", label: "Past 30 days" }, @@ -124,6 +127,18 @@ sidePanel.open() } + async function save({ detail }) { + try { + await apps.update($store.appId, { + automations: { + chainAutomations: detail, + }, + }) + } catch (error) { + notifications.error("Error updating automation chaining setting") + } + } + onMount(async () => { await automationStore.actions.fetch() const params = new URLSearchParams(window.location.search) @@ -150,11 +165,30 @@ - Automation History - View the automations your app has executed + Automations + See your automation history and edit advanced settings + + Chain automations + Allow automations to trigger from other automations +
    + { + save(e) + }} + value={chainAutomations} + /> +
    +
    + + + + History + Free plan stores up to 1 day of automation history +