This commit is contained in:
Martin McKeaveney 2024-02-02 17:00:09 -03:00
commit b17bd4d8fd
268 changed files with 5043 additions and 3266 deletions

View File

@ -45,6 +45,16 @@
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"local-rules/no-budibase-imports": "error" "local-rules/no-budibase-imports": "error"
} }
},
{
"files": [
"packages/builder/**/*",
"packages/client/**/*",
"packages/frontend-core/**/*"
],
"rules": {
"no-console": ["error", { "allow": ["warn", "error", "debug"] } ]
}
} }
], ],
"rules": { "rules": {

View File

@ -33,13 +33,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -50,14 +50,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -80,7 +80,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@ -92,14 +92,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -116,14 +116,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -140,14 +140,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -165,14 +165,14 @@ jobs:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase' if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0 fetch-depth: 0
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn cache: yarn
@ -189,13 +189,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }} submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 20.x node-version: 20.x
cache: yarn 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') if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase')
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
@ -249,7 +249,7 @@ jobs:
- name: Check submodule merged to base branch - name: Check submodule merged to base branch
if: ${{ steps.get_pro_commits.outputs.base_commit != '' }} if: ${{ steps.get_pro_commits.outputs.base_commit != '' }}
uses: actions/github-script@v4 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | 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') if: inputs.run_as_oss != true && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase')
steps: steps:
- name: Checkout repo and submodules - name: Checkout repo and submodules
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
@ -299,7 +299,7 @@ jobs:
- name: Check submodule merged to base branch - name: Check submodule merged to base branch
if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }} if: ${{ steps.get_accountportal_commits.outputs.base_commit != '' }}
uses: actions/github-script@v4 uses: actions/github-script@v7
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

View File

@ -17,7 +17,7 @@ jobs:
github.event.label.name == 'feature-branch' github.event.label.name == 'feature-branch'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: passeidireto/trigger-external-workflow-action@main - uses: passeidireto/trigger-external-workflow-action@main
env: env:
PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }} PAYLOAD_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.BRANCH || github.head_ref }}

View File

@ -17,7 +17,7 @@ jobs:
contains(github.event.pull_request.labels.*.name, 'feature-branch') contains(github.event.pull_request.labels.*.name, 'feature-branch')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- uses: passeidireto/trigger-external-workflow-action@main - uses: passeidireto/trigger-external-workflow-action@main
env: env:
PAYLOAD_BRANCH: ${{ github.head_ref }} PAYLOAD_BRANCH: ${{ github.head_ref }}

View File

@ -28,7 +28,7 @@ jobs:
run: | run: |
echo "Ref is not master, you must run this job from master." echo "Ref is not master, you must run this job from master."
exit 1 exit 1
- uses: actions/checkout@v2 - uses: actions/checkout@v4
with: with:
submodules: true submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
@ -53,7 +53,7 @@ jobs:
needs: [tag-release] needs: [tag-release]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: peter-evans/repository-dispatch@v2 - uses: peter-evans/repository-dispatch@v2
with: with:

View File

@ -11,7 +11,7 @@
The low code platform you'll enjoy using The low code platform you'll enjoy using
</h3> </h3>
<p align="center"> <p align="center">
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.
</p> </p>
<h3 align="center"> <h3 align="center">
@ -20,7 +20,7 @@
<br> <br>
<p align="center"> <p align="center">
<img alt="Budibase design ui" src="https://res.cloudinary.com/daog6scxm/image/upload/v1633524049/ui/design-ui-wide-mobile_gdaveq.jpg"> <img alt="Budibase design ui" src="https://res.cloudinary.com/daog6scxm/image/upload/v1680181644/ui/homepage-design-ui_sizp7b.png">
</p> </p>
<p align="center"> <p align="center">
@ -57,7 +57,7 @@
## ✨ Features ## ✨ Features
### Build and ship real software ### 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.
<br /><br /> <br /><br />
### Open source and extensible ### Open source and extensible
@ -65,40 +65,36 @@ Budibase is open-source - licensed as GPL v3. This should fill you with confiden
<br /><br /> <br /><br />
### Load data or start from scratch ### 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).
<p align="center"> <p align="center">
<img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/data_n1tlhf.png"> <img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1680281798/ui/data_klbuna.png">
</p> </p>
<br /><br /> <br /><br />
### Design and build apps with powerful pre-made components ### 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).
<p align="center"> <p align="center">
<img alt="Budibase design" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970243/Out%20of%20beta%20launch/design-like-a-pro_qhlfeu.gif"> <img alt="Budibase design" src="https://res.cloudinary.com/daog6scxm/image/upload/v1675437167/ui/form_2x_mbli8y.png">
</p> </p>
<br /><br /> <br /><br />
### Automate processes, integrate with other tools, and connect to webhooks ### 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). 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).
<p align="center">
<img alt="Budibase automations" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970486/Out%20of%20beta%20launch/automation_riro7u.png">
</p>
<br /><br /> <br /><br />
### Integrate with your favorite tools ### Integrate with your favorite tools
Budibase integrates with a number of popular tools allowing you to build apps that perfectly fit your stack. Budibase integrates with a number of popular tools allowing you to build apps that perfectly fit your stack.
<p align="center"> <p align="center">
<img alt="Budibase integrations" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/integrations_kc7dqt.png"> <img alt="Budibase integrations" src="https://res.cloudinary.com/daog6scxm/image/upload/v1680195228/ui/automate_fg9z07.png">
</p> </p>
<br /><br /> <br /><br />
### Admin paradise ### 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. 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 - 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 #### Docs
You can learn more about the Budibase API at the following places: 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 - [Interactive API documentation](https://docs.budibase.com/reference/post_applications) : Learn how to interact with the API
#### Guides <br /><br />
- [Build an app with Budibase and Next.js](https://budibase.com/blog/building-a-crud-app-with-budibase-and-next.js/)
## 🏁 Get started ## 🏁 Get started
Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean. 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.
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) ### [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 ## ❗ 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.
<br /> <br />
@ -171,16 +164,16 @@ Budibase is dedicated to providing a welcoming, diverse, and harrassment-free ex
## 🙌 Contributing to Budibase ## 🙌 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. 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) Environment setup instructions are available [here](https://github.com/Budibase/budibase/tree/HEAD/docs/CONTRIBUTING.md).
### Not Sure Where to Start? ### 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 ### 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. 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. - [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 ## 📝 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.
<br /><br /> <br /><br />

View File

@ -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`.

View File

@ -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
```

View File

@ -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.

View File

@ -124,6 +124,8 @@ HEALTHCHECK --interval=15s --timeout=15s --start-period=45s CMD "/healthcheck.sh
# must set this just before running # must set this just before running
ENV NODE_ENV=production ENV NODE_ENV=production
# this is required for isolated-vm to work on Node 20+
ENV NODE_OPTIONS="--no-node-snapshot"
WORKDIR / WORKDIR /
CMD ["./runner.sh"] CMD ["./runner.sh"]

View File

@ -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), 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. 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) Aqui tienes instrucciones de como configurar tu entorno Budibase para [aquí](https://github.com/Budibase/budibase/tree/HEAD/docs/CONTRIBUTING.md).
y [MacOSX](https://github.com/Budibase/budibase/tree/HEAD/docs/DEV-SETUP-MACOSX.md)
### No estas seguro por donde empezar? ### No estas seguro por donde empezar?
Un buen lugar para empezar a contribuir con nosotros es [aqui](https://github.com/Budibase/budibase/projects/22). Un buen lugar para empezar a contribuir con nosotros es [aqui](https://github.com/Budibase/budibase/projects/22).

View File

@ -1,5 +1,5 @@
{ {
"version": "2.14.4", "version": "2.17.2",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -46,7 +46,7 @@
"kill-accountportal": "kill-port 3001 4003", "kill-accountportal": "kill-port 3001 4003",
"kill-all": "yarn run kill-builder && yarn run kill-server && yarn kill-accountportal", "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": "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: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: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", "dev:all": "yarn run kill-all && lerna run --stream dev",

View File

@ -21,7 +21,7 @@
"test:watch": "jest --watchAll" "test:watch": "jest --watchAll"
}, },
"dependencies": { "dependencies": {
"@budibase/nano": "10.1.4", "@budibase/nano": "10.1.5",
"@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/shared-core": "0.0.0", "@budibase/shared-core": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
@ -32,7 +32,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"bull": "4.10.1", "bull": "4.10.1",
"correlation-id": "4.0.0", "correlation-id": "4.0.0",
"dd-trace": "3.13.2", "dd-trace": "5.0.0",
"dotenv": "16.0.1", "dotenv": "16.0.1",
"ioredis": "5.3.2", "ioredis": "5.3.2",
"joi": "17.6.0", "joi": "17.6.0",
@ -54,7 +54,7 @@
"sanitize-s3-objectkey": "0.0.1", "sanitize-s3-objectkey": "0.0.1",
"semver": "^7.5.4", "semver": "^7.5.4",
"tar-fs": "2.1.1", "tar-fs": "2.1.1",
"uuid": "8.3.2" "uuid": "^8.3.2"
}, },
"devDependencies": { "devDependencies": {
"@shopify/jest-koa-mocks": "5.1.1", "@shopify/jest-koa-mocks": "5.1.1",

View File

@ -1,5 +1,5 @@
import { IdentityContext } from "@budibase/types" 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 // keep this out of Budibase types, don't want to expose context info
export type ContextMap = { export type ContextMap = {
@ -10,5 +10,9 @@ export type ContextMap = {
isScim?: boolean isScim?: boolean
automationId?: string automationId?: string
isMigrating?: boolean isMigrating?: boolean
jsExecutionTracker?: ExecutionTimeTracker isolateRefs?: {
jsIsolate: Isolate
jsContext: Context
helpersModule: Module
}
} }

View File

@ -19,6 +19,8 @@ import { WriteStream, ReadStream } from "fs"
import { newid } from "../../docIds/newid" import { newid } from "../../docIds/newid"
import { DDInstrumentedDatabase } from "../instrumentation" import { DDInstrumentedDatabase } from "../instrumentation"
const DATABASE_NOT_FOUND = "Database does not exist."
function buildNano(couchInfo: { url: string; cookie: string }) { function buildNano(couchInfo: { url: string; cookie: string }) {
return Nano({ return Nano({
url: couchInfo.url, url: couchInfo.url,
@ -31,6 +33,8 @@ function buildNano(couchInfo: { url: string; cookie: string }) {
}) })
} }
type DBCall<T> = () => Promise<T>
export function DatabaseWithConnection( export function DatabaseWithConnection(
dbName: string, dbName: string,
connection: string, connection: string,
@ -78,7 +82,11 @@ export class DatabaseImpl implements Database {
return this.instanceNano || DatabaseImpl.nano 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 let shouldCreate = !this.pouchOpts?.skip_setup
// check exists in a lightweight fashion // check exists in a lightweight fashion
let exists = await this.exists() 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<T>(
call: (db: Nano.DocumentScope<any>) => Promise<DBCall<T>> | DBCall<T>
): Promise<any> {
const db = this.getDb()
const fnc = await call(db)
try { try {
return await fnc() return await fnc()
} catch (err: any) { } 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 err.status = err.statusCode
} }
throw err throw err
@ -110,11 +126,12 @@ export class DatabaseImpl implements Database {
} }
async get<T extends Document>(id?: string): Promise<T> { async get<T extends Document>(id?: string): Promise<T> {
const db = await this.checkSetup() return this.performCall(db => {
if (!id) { if (!id) {
throw new Error("Unable to get doc without a valid _id.") throw new Error("Unable to get doc without a valid _id.")
} }
return this.updateOutput(() => db.get(id)) return () => db.get(id)
})
} }
async getMultiple<T extends Document>( async getMultiple<T extends Document>(
@ -147,22 +164,23 @@ export class DatabaseImpl implements Database {
} }
async remove(idOrDoc: string | Document, rev?: string) { async remove(idOrDoc: string | Document, rev?: string) {
const db = await this.checkSetup() return this.performCall(db => {
let _id: string let _id: string
let _rev: string let _rev: string
if (isDocument(idOrDoc)) { if (isDocument(idOrDoc)) {
_id = idOrDoc._id! _id = idOrDoc._id!
_rev = idOrDoc._rev! _rev = idOrDoc._rev!
} else { } else {
_id = idOrDoc _id = idOrDoc
_rev = rev! _rev = rev!
} }
if (!_id || !_rev) { if (!_id || !_rev) {
throw new Error("Unable to remove doc without a valid _id and _rev.") throw new Error("Unable to remove doc without a valid _id and _rev.")
} }
return this.updateOutput(() => db.destroy(_id, _rev)) return () => db.destroy(_id, _rev)
})
} }
async post(document: AnyDocument, opts?: DatabasePutOpts) { async post(document: AnyDocument, opts?: DatabasePutOpts) {
@ -176,45 +194,49 @@ export class DatabaseImpl implements Database {
if (!document._id) { if (!document._id) {
throw new Error("Cannot store document without _id field.") throw new Error("Cannot store document without _id field.")
} }
const db = await this.checkSetup() return this.performCall(async db => {
if (!document.createdAt) { if (!document.createdAt) {
document.createdAt = new Date().toISOString() document.createdAt = new Date().toISOString()
} }
document.updatedAt = new Date().toISOString() document.updatedAt = new Date().toISOString()
if (opts?.force && document._id) { if (opts?.force && document._id) {
try { try {
const existing = await this.get(document._id) const existing = await this.get(document._id)
if (existing) { if (existing) {
document._rev = existing._rev document._rev = existing._rev
} }
} catch (err: any) { } catch (err: any) {
if (err.status !== 404) { if (err.status !== 404) {
throw err throw err
}
} }
} }
} return () => db.insert(document)
return this.updateOutput(() => db.insert(document)) })
} }
async bulkDocs(documents: AnyDocument[]) { async bulkDocs(documents: AnyDocument[]) {
const db = await this.checkSetup() return this.performCall(db => {
return this.updateOutput(() => db.bulk({ docs: documents })) return () => db.bulk({ docs: documents })
})
} }
async allDocs<T extends Document>( async allDocs<T extends Document>(
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> { ): Promise<AllDocsResponse<T>> {
const db = await this.checkSetup() return this.performCall(db => {
return this.updateOutput(() => db.list(params)) return () => db.list(params)
})
} }
async query<T extends Document>( async query<T extends Document>(
viewName: string, viewName: string,
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> { ): Promise<AllDocsResponse<T>> {
const db = await this.checkSetup() return this.performCall(db => {
const [database, view] = viewName.split("/") const [database, view] = viewName.split("/")
return this.updateOutput(() => db.view(database, view, params)) return () => db.view(database, view, params)
})
} }
async destroy() { async destroy() {
@ -231,8 +253,9 @@ export class DatabaseImpl implements Database {
} }
async compact() { async compact() {
const db = await this.checkSetup() return this.performCall(db => {
return this.updateOutput(() => db.compact()) return () => db.compact()
})
} }
// All below functions are in-frequently called, just utilise PouchDB // All below functions are in-frequently called, just utilise PouchDB

View File

@ -31,13 +31,6 @@ export class DDInstrumentedDatabase implements Database {
}) })
} }
checkSetup(): Promise<DocumentScope<any>> {
return tracer.trace("db.checkSetup", span => {
span?.addTags({ db_name: this.name })
return this.db.checkSetup()
})
}
get<T extends Document>(id?: string | undefined): Promise<T> { get<T extends Document>(id?: string | undefined): Promise<T> {
return tracer.trace("db.get", span => { return tracer.trace("db.get", span => {
span?.addTags({ db_name: this.name, doc_id: id }) span?.addTags({ db_name: this.name, doc_id: id })

View File

@ -179,6 +179,7 @@ const environment = {
...getPackageJsonFields(), ...getPackageJsonFields(),
DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER, DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER,
OFFLINE_MODE: process.env.OFFLINE_MODE, OFFLINE_MODE: process.env.OFFLINE_MODE,
SESSION_EXPIRY_SECONDS: process.env.SESSION_EXPIRY_SECONDS,
_set(key: any, value: any) { _set(key: any, value: any) {
process.env[key] = value process.env[key] = value
// @ts-ignore // @ts-ignore

View File

@ -2,6 +2,7 @@ export * as configs from "./configs"
export * as events from "./events" export * as events from "./events"
export * as migrations from "./migrations" export * as migrations from "./migrations"
export * as users from "./users" export * as users from "./users"
export * as userUtils from "./users/utils"
export * as roles from "./security/roles" export * as roles from "./security/roles"
export * as permissions from "./security/permissions" export * as permissions from "./security/permissions"
export * as accounts from "./accounts" export * as accounts from "./accounts"

View File

@ -23,7 +23,7 @@ const getCloudfrontSignParams = () => {
return { return {
keypairId: env.CLOUDFRONT_PUBLIC_KEY_ID!, keypairId: env.CLOUDFRONT_PUBLIC_KEY_ID!,
privateKeyString: getPrivateKey(), privateKeyString: getPrivateKey(),
expireTime: new Date().getTime() + 1000 * 60 * 60, // 1 hour expireTime: new Date().getTime() + 1000 * 60 * 60 * 24, // 1 day
} }
} }

View File

@ -7,7 +7,7 @@ import tar from "tar-fs"
import zlib from "zlib" import zlib from "zlib"
import { promisify } from "util" import { promisify } from "util"
import { join } from "path" import { join } from "path"
import fs from "fs" import fs, { ReadStream } from "fs"
import env from "../environment" import env from "../environment"
import { budibaseTempDir } from "./utils" import { budibaseTempDir } from "./utils"
import { v4 } from "uuid" import { v4 } from "uuid"
@ -184,7 +184,7 @@ export async function upload({
export async function streamUpload( export async function streamUpload(
bucketName: string, bucketName: string,
filename: string, filename: string,
stream: any, stream: ReadStream | ReadableStream,
extra = {} extra = {}
) { ) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)

View File

@ -1,8 +1,8 @@
const redis = require("../redis/init") import * as redis from "../redis/init"
const { v4: uuidv4 } = require("uuid") import { v4 as uuidv4 } from "uuid"
const { logWarn } = require("../logging") import { logWarn } from "../logging"
import env from "../environment" import env from "../environment"
import { Duration } from "../utils"
import { import {
Session, Session,
ScannedSession, ScannedSession,
@ -10,8 +10,10 @@ import {
CreateSession, CreateSession,
} from "@budibase/types" } from "@budibase/types"
// a week in seconds // a week expiry is the default
const EXPIRY_SECONDS = 86400 * 7 const EXPIRY_SECONDS = env.SESSION_EXPIRY_SECONDS
? parseInt(env.SESSION_EXPIRY_SECONDS)
: Duration.fromDays(7).toSeconds()
function makeSessionID(userId: string, sessionId: string) { function makeSessionID(userId: string, sessionId: string) {
return `${userId}/${sessionId}` return `${userId}/${sessionId}`

View File

@ -20,41 +20,3 @@ export function cleanup() {
} }
intervals = [] 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<T>(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`
)
}
}
}

View File

@ -251,7 +251,8 @@ export class UserDB {
} }
const change = dbUser ? 0 : 1 // no change if there is existing user 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 () => { return UserDB.quotas.addUsers(change, creatorsChange, async () => {
await validateUniqueUser(email, tenantId) await validateUniqueUser(email, tenantId)
@ -335,7 +336,7 @@ export class UserDB {
} }
newUser.userGroups = groups || [] newUser.userGroups = groups || []
newUsers.push(newUser) newUsers.push(newUser)
if (isCreator(newUser)) { if (await isCreator(newUser)) {
newCreators.push(newUser) newCreators.push(newUser)
} }
} }
@ -432,12 +433,16 @@ export class UserDB {
_deleted: true, _deleted: true,
})) }))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) 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) { for (let user of usersToDelete) {
await bulkDeleteProcessing(user) await bulkDeleteProcessing(user)
} }
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length) await UserDB.quotas.removeUsers(toDelete.length, creatorsToDeleteCount)
// Build Response // Build Response
// index users by id // index users by id
@ -486,7 +491,7 @@ export class UserDB {
await db.remove(userId, dbUser._rev) 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 UserDB.quotas.removeUsers(1, creatorsToDelete)
await eventHelpers.handleDeleteEvents(dbUser) await eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)

View File

@ -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)
}
})
})
})

View File

@ -309,7 +309,8 @@ export async function getCreatorCount() {
let creators = 0 let creators = 0
async function iterate(startPage?: string) { async function iterate(startPage?: string) {
const page = await paginatedUsers({ bookmark: startPage }) 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) { if (page.hasNextPage) {
await iterate(page.nextPage) await iterate(page.nextPage)
} }

View File

@ -1,4 +1,4 @@
import { CloudAccount } from "@budibase/types" import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types"
import * as accountSdk from "../accounts" import * as accountSdk from "../accounts"
import env from "../environment" import env from "../environment"
import { getPlatformUser } from "./lookup" import { getPlatformUser } from "./lookup"
@ -6,17 +6,48 @@ import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context" import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts" 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 // extract from shared-core to make easily accessible from backend-core
export const isBuilder = sdk.users.isBuilder export const isBuilder = sdk.users.isBuilder
export const isAdmin = sdk.users.isAdmin export const isAdmin = sdk.users.isAdmin
export const isCreator = sdk.users.isCreator
export const isGlobalBuilder = sdk.users.isGlobalBuilder export const isGlobalBuilder = sdk.users.isGlobalBuilder
export const isAdminOrBuilder = sdk.users.isAdminOrBuilder export const isAdminOrBuilder = sdk.users.isAdminOrBuilder
export const hasAdminPermissions = sdk.users.hasAdminPermissions export const hasAdminPermissions = sdk.users.hasAdminPermissions
export const hasBuilderPermissions = sdk.users.hasBuilderPermissions export const hasBuilderPermissions = sdk.users.hasBuilderPermissions
export const hasAppBuilderPermissions = sdk.users.hasAppBuilderPermissions 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<UserGroup>(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) { export async function validateUniqueUser(email: string, tenantId: string) {
// check budibase users in other tenants // check budibase users in other tenants
if (env.MULTI_TENANCY) { if (env.MULTI_TENANCY) {

View File

@ -18,7 +18,6 @@ export default function positionDropdown(element, opts) {
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate, customUpdate,
offsetBelow,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -48,7 +47,7 @@ export default function positionDropdown(element, opts) {
styles.top = anchorBounds.top - elementBounds.height - offset styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240 styles.maxHeight = maxHeight || 240
} else { } else {
styles.top = anchorBounds.bottom + (offsetBelow || offset) styles.top = anchorBounds.bottom + offset
styles.maxHeight = styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20 maxHeight || window.innerHeight - anchorBounds.bottom - 20
} }

View File

@ -184,7 +184,7 @@
{#if environmentVariablesEnabled} {#if environmentVariablesEnabled}
<div on:click={() => showModal()} class="add-variable"> <div on:click={() => showModal()} class="add-variable">
<svg <svg
class="spectrum-Icon spectrum-Icon--sizeS " class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false" focusable="false"
aria-hidden="true" aria-hidden="true"
> >
@ -195,7 +195,7 @@
{:else} {:else}
<div on:click={() => handleUpgradePanel()} class="add-variable"> <div on:click={() => handleUpgradePanel()} class="add-variable">
<svg <svg
class="spectrum-Icon spectrum-Icon--sizeS " class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false" focusable="false"
aria-hidden="true" aria-hidden="true"
> >

View File

@ -15,8 +15,6 @@
export let autoWidth = false export let autoWidth = false
export let searchTerm = null export let searchTerm = null
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let open = false export let open = false
export let loading export let loading
@ -98,7 +96,5 @@
{sort} {sort}
{autoWidth} {autoWidth}
{customPopoverHeight} {customPopoverHeight}
{customPopoverOffsetBelow}
{customPopoverMaxHeight}
{loading} {loading}
/> />

View File

@ -37,8 +37,6 @@
export let sort = false export let sort = false
export let searchTerm = null export let searchTerm = null
export let customPopoverHeight export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let align = "left" export let align = "left"
export let footer = null export let footer = null
export let customAnchor = null export let customAnchor = null
@ -156,9 +154,7 @@
on:close={() => (open = false)} on:close={() => (open = false)}
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : null}
maxHeight={customPopoverMaxHeight}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
offsetBelow={customPopoverOffsetBelow}
> >
<div <div
class="popover-content" class="popover-content"

View File

@ -12,6 +12,7 @@
export let getOptionIcon = () => null export let getOptionIcon = () => null
export let getOptionColour = () => null export let getOptionColour = () => null
export let getOptionSubtitle = () => null export let getOptionSubtitle = () => null
export let compare = null
export let useOptionIconImage = false export let useOptionIconImage = false
export let isOptionEnabled export let isOptionEnabled
export let readonly = false export let readonly = false
@ -23,8 +24,6 @@
export let footer = null export let footer = null
export let open = false export let open = false
export let tag = null export let tag = null
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let searchTerm = null export let searchTerm = null
export let loading export let loading
@ -34,13 +33,19 @@
$: fieldIcon = getFieldAttribute(getOptionIcon, value, options) $: fieldIcon = getFieldAttribute(getOptionIcon, value, options)
$: fieldColour = getFieldAttribute(getOptionColour, 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) => { const getFieldAttribute = (getAttribute, value, options) => {
// Wait for options to load if there is a value but no options // Wait for options to load if there is a value but no options
if (!options?.length) { if (!options?.length) {
return "" return ""
} }
const index = options.findIndex( const index = options.findIndex((option, idx) =>
(option, idx) => getOptionValue(option, idx) === value compareOptionAndValue(getOptionValue(option, idx), value)
) )
return index !== -1 ? getAttribute(options[index], index) : null return index !== -1 ? getAttribute(options[index], index) : null
} }
@ -90,11 +95,9 @@
{autocomplete} {autocomplete}
{sort} {sort}
{tag} {tag}
{customPopoverOffsetBelow}
{customPopoverMaxHeight}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false ? null : placeholder} placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => compareOptionAndValue(option, value)}
onSelectOption={selectOption} onSelectOption={selectOption}
{loading} {loading}
/> />

View File

@ -28,6 +28,7 @@
export let footer = null export let footer = null
export let tag = null export let tag = null
export let helpText = null export let helpText = null
export let compare
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -65,6 +66,7 @@
{autocomplete} {autocomplete}
{customPopoverHeight} {customPopoverHeight}
{tag} {tag}
{compare}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View File

@ -40,7 +40,7 @@
loading = false loading = false
} }
async function confirm() { export async function confirm() {
loading = true loading = true
if (!onConfirm || (await onConfirm()) !== keepOpen) { if (!onConfirm || (await onConfirm()) !== keepOpen) {
hide() hide()

View File

@ -18,7 +18,6 @@
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true export let dismissible = true
export let offset = 5 export let offset = 5
export let offsetBelow
export let customHeight export let customHeight
export let animate = true export let animate = true
export let customZindex export let customZindex
@ -89,7 +88,6 @@
maxWidth, maxWidth,
useAnchorWidth, useAnchorWidth,
offset, offset,
offsetBelow,
customUpdate: handlePostionUpdate, customUpdate: handlePostionUpdate,
}} }}
use:clickOutside={{ use:clickOutside={{

View File

@ -66,6 +66,7 @@
"@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.4.2",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",
"@zerodevx/svelte-json-view": "^1.0.7",
"codemirror": "^5.59.0", "codemirror": "^5.59.0",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",

View File

@ -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 = ( export const findClosestMatchingComponent = (
rootComponent, rootComponent,

View File

@ -1,6 +1,7 @@
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { get } from "svelte/store" import { get } from "svelte/store"
import { import {
findAllComponents,
findAllMatchingComponents, findAllMatchingComponents,
findComponent, findComponent,
findComponentPath, findComponentPath,
@ -102,6 +103,9 @@ export const getAuthBindings = () => {
return bindings return bindings
} }
/**
* Gets all bindings for environment variables
*/
export const getEnvironmentBindings = () => { export const getEnvironmentBindings = () => {
let envVars = get(environment).variables let envVars = get(environment).variables
return envVars.map(variable => { return envVars.map(variable => {
@ -130,26 +134,22 @@ export const toBindingsArray = (valueMap, prefix, category) => {
if (!binding) { if (!binding) {
return acc return acc
} }
let config = { let config = {
type: "context", type: "context",
runtimeBinding: binding, runtimeBinding: binding,
readableBinding: `${prefix}.${binding}`, readableBinding: `${prefix}.${binding}`,
icon: "Brackets", icon: "Brackets",
} }
if (category) { if (category) {
config.category = category config.category = category
} }
acc.push(config) acc.push(config)
return acc 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) => { export const readableToRuntimeMap = (bindings, ctx) => {
if (!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) => { export const runtimeToReadableMap = (bindings, ctx) => {
if (!bindings || !ctx) { if (!bindings || !ctx) {
@ -188,15 +188,23 @@ export const getComponentBindableProperties = (asset, componentId) => {
if (!def?.context) { if (!def?.context) {
return [] return []
} }
const contexts = Array.isArray(def.context) ? def.context : [def.context]
// Get the bindings for the component // 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, asset,
componentId, componentId,
type, type,
@ -205,30 +213,55 @@ export const getContextProviderComponents = (
if (!asset || !componentId) { if (!asset || !componentId) {
return [] return []
} }
let map = {}
// Get the component tree leading up to this component, ignoring the component // Processes all contexts exposed by a component
// itself const processContexts = scope => component => {
const path = findComponentPath(asset.props, componentId)
if (!options?.includeSelf) {
path.pop()
}
// Filter by only data provider components
return path.filter(component => {
const def = store.actions.components.getDefinition(component._component) const def = store.actions.components.getDefinition(component._component)
if (!def?.context) { if (!def?.context) {
return false return
} }
if (!map[component._id]) {
// If no type specified, return anything that exposes context map[component._id] = {
if (!type) { component,
return true definition: def,
contexts: [],
}
} }
// Otherwise only match components with the specific context type
const contexts = Array.isArray(def.context) ? def.context : [def.context] 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, actionType,
options = { includeSelf: false } options = { includeSelf: false }
) => { ) => {
if (!asset || !componentId) { if (!asset) {
return [] return []
} }
// Get the component tree leading up to this component, ignoring the component // Get all components
// itself const components = findAllComponents(asset.props)
const path = findComponentPath(asset.props, componentId)
if (!options?.includeSelf) {
path.pop()
}
// Find matching contexts and generate bindings // Find matching contexts and generate bindings
let providers = [] let providers = []
path.forEach(component => { components.forEach(component => {
if (!options?.includeSelf && component._id === componentId) {
return
}
const def = store.actions.components.getDefinition(component._component) const def = store.actions.components.getDefinition(component._component)
const actions = (def?.actions || []).map(action => { const actions = (def?.actions || []).map(action => {
return typeof action === "string" ? { type: action } : 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. * Gets all bindable data properties from component data contexts.
*/ */
const getContextBindings = (asset, componentId) => { const getContextBindings = (asset, componentId) => {
// Extract any components which provide data contexts // Get all available contexts for this component
const dataProviders = getContextProviderComponents(asset, componentId) const componentContexts = getComponentContexts(asset, componentId)
// Generate bindings for all matching components // Generate bindings for each context
return getProviderContextBindings(asset, dataProviders) 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) => { const generateComponentContextBindings = (asset, componentContext) => {
if (!asset || !dataProviders) { const { component, definition, contexts } = componentContext
if (!component || !definition || !contexts?.length) {
return [] return []
} }
// Ensure providers is an array
if (!Array.isArray(dataProviders)) {
dataProviders = [dataProviders]
}
// Create bindings for each data provider // Create bindings for each data provider
let bindings = [] let bindings = []
dataProviders.forEach(component => { contexts.forEach(context => {
const def = store.actions.components.getDefinition(component._component) if (!context?.type) {
const contexts = Array.isArray(def.context) ? def.context : [def.context] return
}
// Create bindings for each context block provided by this data provider let schema
contexts.forEach(context => { let table
if (!context?.type) { 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 return
} }
const info = getSchemaForDatasource(asset, datasource)
schema = info.schema
table = info.table
let schema // Determine what to prefix bindings with
let table if (datasource.type === "jsonarray") {
let readablePrefix // For JSON arrays, use the array name as the readable prefix
let runtimeSuffix = context.suffix const split = datasource.label.split(".")
readablePrefix = split[split.length - 1]
if (context.type === "form") { } else if (datasource.type === "viewV2") {
// Forms do not need table schemas // For views, use the view name
// Their schemas are built from their component field names const view = Object.values(table?.views || {}).find(
schema = buildFormSchema(component, asset) view => view.id === datasource.id
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
) )
readablePrefix = view?.name
} else {
// Otherwise use the table name
readablePrefix = info.table?.name
}
}
if (!schema) {
return
}
// Create the binding object const keys = Object.keys(schema).sort()
bindings.push({
type: "context", // Generate safe unique runtime prefix
runtimeBinding, let providerId = component._id
readableBinding, if (runtimeSuffix) {
// Field schema and provider are required to construct relationship providerId += `-${runtimeSuffix}`
// datasource options, based on bindable properties }
fieldSchema, const safeComponentId = makePropSafe(providerId)
providerId,
// Table ID is used by JSON fields to know what table the field is in // Create bindable properties for each schema field
tableId: table?._id, keys.forEach(key => {
component: component._component, const fieldSchema = schema[key]
category: bindingCategory.category,
icon: bindingCategory.icon, // Make safe runtime binding
display: { const safeKey = key.split(".").map(makePropSafe).join(".")
name: fieldSchema.name || key, const runtimeBinding = `${safeComponentId}.${safeKey}`
type: fieldSchema.type,
}, // 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 return bindings
} }
// Exclude a data context based on the component settings /**
const filterCategoryByContext = (component, context) => { * Checks if a certain data context is compatible with a certain instance of a
const { _component } = component * 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.endsWith("formblock")) {
if ( if (
(component.actionType === "Create" && context.type === "schema") || (actionType === "Create" && type === "schema") ||
(component.actionType === "View" && context.type === "form") (actionType === "View" && type === "form")
) { ) {
return false return false
} }
} }
// Allow the context by default
return true return true
} }
// Enrich binding category information for certain components // Enrich binding category information for certain components
const getComponentBindingCategory = (component, context, def) => { const getComponentBindingCategory = (component, context, def) => {
// Default category to component name
let icon = def.icon let icon = def.icon
let category = component._instanceName let category = component._instanceName
// Form block edge case
if (component._component.endsWith("formblock")) { if (component._component.endsWith("formblock")) {
if (context.type === "form") { if (context.type === "form") {
category = `${component._instanceName} - Fields` 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 = () => { export const getUserBindings = () => {
let bindings = [] let bindings = []
@ -566,6 +600,7 @@ const getDeviceBindings = () => {
/** /**
* Gets all selected rows bindings for tables in the current asset. * 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 => { const getSelectedRowsBindings = asset => {
let bindings = [] let bindings = []
@ -608,6 +643,9 @@ const getSelectedRowsBindings = asset => {
return bindings return bindings
} }
/**
* Generates a state binding for a certain key name
*/
export const makeStateBinding = key => { export const makeStateBinding = key => {
return { return {
type: "context", type: "context",
@ -662,6 +700,9 @@ const getUrlBindings = asset => {
return urlParamBindings.concat([queryParamsBinding]) return urlParamBindings.concat([queryParamsBinding])
} }
/**
* Generates all bindings for role IDs
*/
const getRoleBindings = () => { const getRoleBindings = () => {
return (get(rolesStore) || []).map(role => { return (get(rolesStore) || []).map(role => {
return { return {
@ -1035,11 +1076,48 @@ export const getAllStateVariables = () => {
getAllAssets().forEach(asset => { getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => { findAllMatchingComponents(asset.props, component => {
const settings = getComponentSettings(component._component) const settings = getComponentSettings(component._component)
settings
.filter(setting => setting.type === "event") const parseEventSettings = (settings, comp) => {
.forEach(setting => { settings
eventSettings.push(component[setting.key]) .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)
})
}) })
}) })

View File

@ -9,6 +9,7 @@ import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { createHistoryStore } from "builderStore/store/history" import { createHistoryStore } from "builderStore/store/history"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { getHoverStore } from "./store/hover"
export const store = getFrontendStore() export const store = getFrontendStore()
export const automationStore = getAutomationStore() export const automationStore = getAutomationStore()
@ -16,6 +17,7 @@ export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore() export const temporalStore = getTemporalStore()
export const userStore = getUserStore() export const userStore = getUserStore()
export const deploymentStore = getDeploymentStore() export const deploymentStore = getDeploymentStore()
export const hoverStore = getHoverStore()
// Setup history for screens // Setup history for screens
export const screenHistoryStore = createHistoryStore({ export const screenHistoryStore = createHistoryStore({

View File

@ -85,7 +85,6 @@ const INITIAL_FRONTEND_STATE = {
selectedScreenId: null, selectedScreenId: null,
selectedComponentId: null, selectedComponentId: null,
selectedLayoutId: null, selectedLayoutId: null,
hoverComponentId: null,
// Client state // Client state
selectedComponentInstance: null, selectedComponentInstance: null,
@ -159,6 +158,7 @@ export const getFrontendStore = () => {
...INITIAL_FRONTEND_STATE.features, ...INITIAL_FRONTEND_STATE.features,
...application.features, ...application.features,
}, },
automations: application.automations || {},
icon: application.icon || {}, icon: application.icon || {},
initialised: true, initialised: true,
})) }))
@ -707,10 +707,9 @@ export const getFrontendStore = () => {
else { else {
if (setting.type === "dataProvider") { if (setting.type === "dataProvider") {
// Validate data provider exists, or else clear it // Validate data provider exists, or else clear it
const treeId = parent?._id || component._id const providers = findAllMatchingComponents(
const path = findComponentPath(screen?.props, treeId) screen?.props,
const providers = path.filter(component => component => component._component?.endsWith("/dataprovider")
component._component?.endsWith("/dataprovider")
) )
// Validate non-empty values // Validate non-empty values
const valid = providers?.some(dp => value.includes?.(dp._id)) const valid = providers?.some(dp => value.includes?.(dp._id))
@ -732,6 +731,16 @@ export const getFrontendStore = () => {
return null 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 // Generate basic component structure
let instance = { let instance = {
_id: Helpers.uuid(), _id: Helpers.uuid(),
@ -741,7 +750,7 @@ export const getFrontendStore = () => {
hover: {}, hover: {},
active: {}, active: {},
}, },
_instanceName: `New ${definition.friendlyName || definition.name}`, _instanceName: name,
...presetProps, ...presetProps,
} }

View File

@ -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 },
}
}

View File

@ -21,7 +21,7 @@ export const createBuilderWebsocket = appId => {
}) })
}) })
socket.on("connect_error", err => { 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", () => { socket.on("disconnect", () => {
userStore.actions.reset() userStore.actions.reset()

View File

@ -19,10 +19,15 @@
export let lastStep export let lastStep
let syncAutomationsEnabled = $licensing.syncAutomationsEnabled let syncAutomationsEnabled = $licensing.syncAutomationsEnabled
let triggerAutomationRunEnabled = $licensing.triggerAutomationRunEnabled
let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK] let collectBlockAllowedSteps = [TriggerStepID.APP, TriggerStepID.WEBHOOK]
let selectedAction let selectedAction
let actionVal let actionVal
let actions = Object.entries($automationStore.blockDefinitions.ACTION) let actions = Object.entries($automationStore.blockDefinitions.ACTION)
let lockedFeatures = [
ActionStepID.COLLECT,
ActionStepID.TRIGGER_AUTOMATION_RUN,
]
$: collectBlockExists = checkForCollectStep($selectedAutomation) $: collectBlockExists = checkForCollectStep($selectedAutomation)
@ -36,6 +41,10 @@
disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists, disabled: !lastStep || !syncAutomationsEnabled || collectBlockExists,
message: collectDisabledMessage(), message: collectDisabledMessage(),
}, },
TRIGGER_AUTOMATION_RUN: {
disabled: !triggerAutomationRunEnabled,
message: "Please upgrade to a paid plan",
},
} }
} }
@ -149,10 +158,10 @@
<div class="item-body"> <div class="item-body">
<Icon name={action.icon} /> <Icon name={action.icon} />
<Body size="XS">{action.name}</Body> <Body size="XS">{action.name}</Body>
{#if isDisabled && !syncAutomationsEnabled && action.stepId === ActionStepID.COLLECT} {#if isDisabled && !syncAutomationsEnabled && !triggerAutomationRunEnabled && lockedFeatures.includes(action.stepId)}
<div class="tag-color"> <div class="tag-color">
<Tags> <Tags>
<Tag icon="LockClosed">Business</Tag> <Tag icon="LockClosed">Premium</Tag>
</Tags> </Tags>
</div> </div>
{:else if isDisabled} {:else if isDisabled}

View File

@ -1,7 +1,8 @@
<script> <script>
import { Icon, Divider, Tabs, Tab, TextArea, Label } from "@budibase/bbui" import { Icon, Divider, Tabs, Tab, Label } from "@budibase/bbui"
import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte" import FlowItemHeader from "./FlowChart/FlowItemHeader.svelte"
import { ActionStepID } from "constants/backend/automations" import { ActionStepID } from "constants/backend/automations"
import { JsonView } from "@zerodevx/svelte-json-view"
export let automation export let automation
export let testResults export let testResults
@ -14,13 +15,6 @@
return results?.steps.filter(x => x.stepId !== ActionStepID.LOOP || []) return results?.steps.filter(x => x.stepId !== ActionStepID.LOOP || [])
} }
function textArea(results, message) {
if (!results) {
return message
}
return JSON.stringify(results, null, 2)
}
$: filteredResults = prepTestResults(testResults) $: filteredResults = prepTestResults(testResults)
$: { $: {
@ -71,26 +65,34 @@
{/if} {/if}
<div class="tabs"> <div class="tabs">
<Tabs noHorizPadding selected="Input"> <Tabs quiet noHorizPadding selected="Input">
<Tab title="Input"> <Tab title="Input">
<TextArea <div class="wrap">
minHeight="160px" {#if filteredResults?.[idx]?.inputs}
disabled <JsonView depth={2} json={filteredResults?.[idx]?.inputs} />
value={textArea(filteredResults?.[idx]?.inputs, "No input")} {:else}
/> No input
{/if}
</div>
</Tab> </Tab>
<Tab title="Output"> <Tab title="Output">
<TextArea <div class="wrap">
minHeight="160px" {#if filteredResults?.[idx]?.outputs}
disabled <JsonView
value={textArea(filteredResults?.[idx]?.outputs, "No output")} depth={2}
/> json={filteredResults?.[idx]?.outputs}
/>
{:else}
No input
{/if}
</div>
</Tab> </Tab>
</Tabs> </Tabs>
</div> </div>
{/if} {/if}
{/if} {/if}
</div> </div>
{#if blocks.length - 1 !== idx} {#if blocks.length - 1 !== idx}
<div class="separator" /> <div class="separator" />
{/if} {/if}
@ -104,6 +106,33 @@
overflow: auto; overflow: auto;
} }
.wrap {
font-family: monospace;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
border: 1px solid var(--spectrum-global-color-gray-300);
font-size: 12px;
max-height: 160px; /* Adjusted max-height */
height: 160px;
--jsonPaddingLeft: 20px;
--jsonborderleft: 20px;
overflow: auto;
overflow: overlay;
overflow-x: hidden;
padding-left: var(--spacing-s);
padding-top: var(--spacing-xl);
border-radius: 4px;
}
.wrap::-webkit-scrollbar {
width: 7px; /* width of the scrollbar */
}
.wrap::-webkit-scrollbar-track {
background: transparent; /* transparent track */
}
.tabs { .tabs {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,44 +1,117 @@
<script> <script>
import AutomationList from "./AutomationList.svelte"
import CreateAutomationModal from "./CreateAutomationModal.svelte" import CreateAutomationModal from "./CreateAutomationModal.svelte"
import { Modal, Icon } from "@budibase/bbui" import { Modal, notifications, Layout } from "@budibase/bbui"
import Panel from "components/design/Panel.svelte" import NavHeader from "components/common/NavHeader.svelte"
import { onMount } from "svelte"
import {
automationStore,
selectedAutomation,
userSelectedResourceMap,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte"
import EditAutomationPopover from "./EditAutomationPopover.svelte"
export let modal export let modal
export let webhookModal export let webhookModal
let searchString
$: selectedAutomationId = $selectedAutomation?._id
$: filteredAutomations = $automationStore.automations
.filter(automation => {
return (
!searchString ||
automation.name.toLowerCase().includes(searchString.toLowerCase())
)
})
.sort((a, b) => {
const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase()
return lowerA > lowerB ? 1 : -1
})
$: showNoResults = searchString && !filteredAutomations.length
onMount(async () => {
try {
await automationStore.actions.fetch()
} catch (error) {
notifications.error("Error getting automations list")
}
})
function selectAutomation(id) {
automationStore.actions.select(id)
}
</script> </script>
<Panel title="Automations" borderRight noHeaderBorder titleCSS={false}> <div class="side-bar">
<span class="panel-title-content" slot="panel-title-content"> <div class="side-bar-controls">
<div class="header"> <NavHeader
<div>Automations</div> title="Automations"
<div on:click={modal.show} class="add-automation-button"> placeholder="Search for automation"
<Icon name="Add" /> bind:value={searchString}
</div> onAdd={() => modal.show()}
</div> />
</span> </div>
<AutomationList /> <div class="side-bar-nav">
</Panel> {#each filteredAutomations as automation}
<NavItem
text={automation.name}
selected={automation._id === selectedAutomationId}
on:click={() => selectAutomation(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
>
<EditAutomationPopover {automation} />
</NavItem>
{/each}
{#if showNoResults}
<Layout paddingY="none" paddingX="L">
<div class="no-results">
There aren't any automations matching that name
</div>
</Layout>
{/if}
</div>
</div>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<CreateAutomationModal {webhookModal} /> <CreateAutomationModal {webhookModal} />
</Modal> </Modal>
<style> <style>
.header { .side-bar {
flex: 0 0 260px;
display: flex; display: flex;
flex-direction: column;
align-items: stretch;
border-right: var(--border-light);
background: var(--spectrum-global-color-gray-100);
overflow: hidden;
transition: margin-left 300ms ease-out;
}
@media (max-width: 640px) {
.side-bar {
margin-left: -262px;
}
}
.side-bar-controls {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center; align-items: center;
justify-content: space-between; gap: var(--spacing-l);
gap: var(--spacing-m); padding: 0 var(--spacing-l);
}
.side-bar-nav {
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
} }
.add-automation-button { .no-results {
margin-left: 130px; color: var(--spectrum-global-color-gray-600);
color: var(--grey-7);
cursor: pointer;
}
.add-automation-button:hover {
color: var(--ink);
} }
</style> </style>

View File

@ -28,6 +28,7 @@
import CodeEditorModal from "./CodeEditorModal.svelte" import CodeEditorModal from "./CodeEditorModal.svelte"
import QuerySelector from "./QuerySelector.svelte" import QuerySelector from "./QuerySelector.svelte"
import QueryParamSelector from "./QueryParamSelector.svelte" import QueryParamSelector from "./QueryParamSelector.svelte"
import AutomationSelector from "./AutomationSelector.svelte"
import CronBuilder from "./CronBuilder.svelte" import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
@ -51,7 +52,6 @@
export let testData export let testData
export let schemaProperties export let schemaProperties
export let isTestModal = false export let isTestModal = false
let webhookModal let webhookModal
let drawer let drawer
let fillWidth = true let fillWidth = true
@ -101,7 +101,6 @@
} }
} }
} }
const onChange = Utils.sequential(async (e, key) => { const onChange = Utils.sequential(async (e, key) => {
// We need to cache the schema as part of the definition because it is // 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 // used in the server to detect relationships. It would be far better to
@ -145,6 +144,7 @@
if (!block || !automation) { if (!block || !automation) {
return [] return []
} }
// Find previous steps to the selected one // Find previous steps to the selected one
let allSteps = [...automation.steps] let allSteps = [...automation.steps]
@ -156,22 +156,98 @@
// Extract all outputs from all previous steps as available bindingsx§x // Extract all outputs from all previous steps as available bindingsx§x
let bindings = [] let bindings = []
let loopBlockCount = 0 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++) { for (let idx = 0; idx < blockIdx; idx++) {
let wasLoopBlock = allSteps[idx - 1]?.stepId === ActionStepID.LOOP let wasLoopBlock = allSteps[idx - 1]?.stepId === ActionStepID.LOOP
let isLoopBlock = let isLoopBlock =
allSteps[idx]?.stepId === ActionStepID.LOOP && 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) { if (isLoopBlock) {
schema = { schema = {
currentItem: { currentItem: {
@ -180,54 +256,44 @@
}, },
} }
} }
const outputs = Object.entries(schema)
let bindingIcon = "" if (idx === 0 && automation.trigger?.event === "app:trigger") {
let bindingRank = 0 schema = Object.fromEntries(
if (idx === 0) { Object.keys(automation.trigger.inputs.fields || []).map(key => [
bindingIcon = automation.trigger.icon key,
} else if (isLoopBlock) { { type: automation.trigger.inputs.fields[key] },
bindingIcon = "Reuse" ])
bindingRank = idx + 1 )
} else {
bindingIcon = allSteps[idx].icon
bindingRank = idx - loopBlockCount
} }
let bindingName = if (
automation.stepNames?.[allSteps[idx - loopBlockCount].id] (idx === 0 && automation.trigger.event === "row:update") ||
bindings = bindings.concat( (idx === 0 && automation.trigger.event === "row:save")
outputs.map(([name, value]) => { ) {
let runtimeName = isLoopBlock let table = $tables.list.find(
? `loop.${name}` table => table._id === automation.trigger.inputs.tableId
: block.name.startsWith("JS") )
? `steps[${idx - loopBlockCount}].${name}` // We want to generate our own schema for the bindings from the table schema itself
: `steps.${idx - loopBlockCount}.${name}` for (const key in table?.schema) {
const runtime = idx === 0 ? `trigger.${name}` : runtimeName schema[key] = {
type: table.schema[key].type,
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`
} }
}
// remove the original binding
delete schema.row
}
let icon =
idx === 0
? automation.trigger.icon
: isLoopBlock
? "Reuse"
: allSteps[idx].icon
return { if (wasLoopBlock) {
readableBinding: bindingName ? `${bindingName}.${name}` : runtime, loopBlockCount++
runtimeBinding: runtime, continue
type: value.type, }
description: value.description, Object.entries(schema).forEach(([name, value]) =>
icon: bindingIcon, addBinding(name, value, icon, idx, isLoopBlock, bindingName)
category: categoryName,
display: {
type: value.type,
name: name,
rank: bindingRank,
},
}
})
) )
} }
@ -245,10 +311,8 @@
}) })
) )
} }
return bindings return bindings
} }
function lookForFilters(properties) { function lookForFilters(properties) {
if (!properties) { if (!properties) {
return [] return []
@ -286,7 +350,8 @@
value.customType !== "code" && value.customType !== "code" &&
value.customType !== "queryParams" && value.customType !== "queryParams" &&
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" value.customType !== "triggerSchema" &&
value.customType !== "automationFields"
) )
} }
@ -421,6 +486,12 @@
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}
value={inputData[key]} value={inputData[key]}
/> />
{:else if value.customType === "automationFields"}
<AutomationSelector
on:change={e => onChange(e, key)}
value={inputData[key]}
{bindings}
/>
{:else if value.customType === "queryParams"} {:else if value.customType === "queryParams"}
<QueryParamSelector <QueryParamSelector
on:change={e => onChange(e, key)} on:change={e => onChange(e, key)}

View File

@ -0,0 +1,87 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { automationStore, selectedAutomation } from "builderStore"
import { TriggerStepID } from "constants/backend/automations"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
const dispatch = createEventDispatcher()
export let value
export let bindings = []
const onChangeAutomation = e => {
value.automationId = e.detail
dispatch("change", value)
}
const onChange = (e, field) => {
value[field] = e.detail
dispatch("change", value)
}
$: if (value?.automationId == null) value = { automationId: "" }
$: automationFields =
$automationStore.automations.find(
automation => automation._id === value?.automationId
)?.definition?.trigger?.inputs?.fields || []
$: filteredAutomations = $automationStore.automations.filter(
automation =>
automation.definition.trigger.stepId === TriggerStepID.APP &&
automation._id !== $selectedAutomation._id
)
</script>
<div class="schema-field">
<Label>Automation</Label>
<div class="field-width">
<Select
on:change={onChangeAutomation}
value={value.automationId}
options={filteredAutomations}
getOptionValue={automation => automation._id}
getOptionLabel={automation => automation.name}
/>
</div>
</div>
{#if Object.keys(automationFields)}
{#each Object.keys(automationFields) as field}
<div class="schema-field">
<Label>{field}</Label>
<div class="field-width">
<DrawerBindableInput
panel={AutomationBindingPanel}
extraThin
value={value[field]}
on:change={e => onChange(e, field)}
type="string"
{bindings}
fillWidth={true}
updateOnChange={false}
/>
</div>
</div>
{/each}
{/if}
<style>
.field-width {
width: 320px;
}
.schema-field {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
align-items: center;
gap: 10px;
flex: 1;
margin-bottom: 10px;
}
.schema-field :global(label) {
text-transform: capitalize;
}
</style>

View File

@ -41,7 +41,7 @@
{ label: "False", value: "false" }, { label: "False", value: "false" },
]} ]}
/> />
{:else if schema.type === "array"} {:else if schemaHasOptions(schema) && schema.type === "array"}
<Multiselect <Multiselect
bind:value={value[field]} bind:value={value[field]}
options={schema.constraints.inclusion} options={schema.constraints.inclusion}
@ -77,7 +77,7 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false} useLabel={false}
/> />
{:else if ["string", "number", "bigint", "barcodeqr"].includes(schema.type)} {:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
<svelte:component <svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
panel={AutomationBindingPanel} panel={AutomationBindingPanel}

View File

@ -172,22 +172,6 @@
} }
} }
} }
if (!savingColumn && !originalName) {
let highestNumber = 0
Object.keys(table.schema).forEach(columnName => {
const columnNumber = extractColumnNumber(columnName)
if (columnNumber > highestNumber) {
highestNumber = columnNumber
}
return highestNumber
})
if (highestNumber >= 1) {
editableColumn.name = `Column 0${highestNumber + 1}`
} else {
editableColumn.name = "Column 01"
}
}
if (!savingColumn) { if (!savingColumn) {
editableColumn.fieldId = makeFieldId( editableColumn.fieldId = makeFieldId(
@ -389,11 +373,6 @@
deleteColName = "" deleteColName = ""
} }
function extractColumnNumber(columnName) {
const match = columnName.match(/Column (\d+)/)
return match ? parseInt(match[1]) : 0
}
function getAllowedTypes() { function getAllowedTypes() {
if ( if (
originalName && originalName &&

View File

@ -13,6 +13,7 @@
Icon, Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { getFormattedPlanName } from "helpers/planTitle"
import { get } from "svelte/store" import { get } from "svelte/store"
export let resourceId export let resourceId
@ -99,7 +100,9 @@
{#if requiresPlanToModify} {#if requiresPlanToModify}
<span class="lock-tag"> <span class="lock-tag">
<Tags> <Tags>
<Tag icon="LockClosed">{capitalise(requiresPlanToModify)}</Tag> <Tag icon="LockClosed"
>{getFormattedPlanName(requiresPlanToModify)}</Tag
>
</Tags> </Tags>
</span> </span>
{/if} {/if}

View File

@ -88,8 +88,12 @@
hasValidated = false hasValidated = false
}) })
} }
$: valid = $: valid =
getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType) getErrorCount(errors) === 0 &&
allRequiredAttributesSet(relationshipType) &&
fromId &&
toId
$: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY $: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY
$: isManyToOne = $: isManyToOne =
relationshipType === RelationshipType.MANY_TO_ONE || relationshipType === RelationshipType.MANY_TO_ONE ||
@ -369,6 +373,7 @@
confirmText="Save" confirmText="Save"
onConfirm={saveRelationship} onConfirm={saveRelationship}
disabled={!valid} disabled={!valid}
size="L"
> >
<div class="headings"> <div class="headings">
<Detail>Tables</Detail> <Detail>Tables</Detail>

View File

@ -17,7 +17,7 @@
export let table export let table
let editorModal let editorModal, editTableNameModal
let confirmDeleteDialog let confirmDeleteDialog
let error = "" let error = ""
@ -101,18 +101,21 @@
<Modal bind:this={editorModal} on:show={initForm}> <Modal bind:this={editorModal} on:show={initForm}>
<ModalContent <ModalContent
bind:this={editTableNameModal}
title="Edit Table" title="Edit Table"
confirmText="Save" confirmText="Save"
onConfirm={save} onConfirm={save}
disabled={updatedName === originalName || error} disabled={updatedName === originalName || error}
> >
<Input <form on:submit|preventDefault={() => editTableNameModal.confirm()}>
label="Table Name" <Input
thin label="Table Name"
bind:value={updatedName} thin
on:input={checkValid} bind:value={updatedName}
{error} on:input={checkValid}
/> {error}
/>
</form>
</ModalContent> </ModalContent>
</Modal> </Modal>
<ConfirmDialog <ConfirmDialog

View File

@ -312,7 +312,7 @@ export const insertBinding = (view, from, to, text, mode) => {
} else if (mode.name == "handlebars") { } else if (mode.name == "handlebars") {
parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text) parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text)
} else { } else {
console.log("Unsupported") console.warn("Unsupported")
return return
} }

View File

@ -17,7 +17,7 @@
</script> </script>
<div class="relationship-container"> <div class="relationship-container">
<div class="relationship-part"> <div class="relationship-type">
<Select <Select
disabled={linkEditDisabled} disabled={linkEditDisabled}
bind:value={relationshipPart1} bind:value={relationshipPart1}
@ -39,7 +39,7 @@
</div> </div>
</div> </div>
<div class="relationship-container"> <div class="relationship-container">
<div class="relationship-part"> <div class="relationship-type">
<Select <Select
disabled={linkEditDisabled} disabled={linkEditDisabled}
bind:value={relationshipPart2} bind:value={relationshipPart2}
@ -79,6 +79,10 @@
} }
.relationship-part { .relationship-part {
flex-basis: 60%; flex-basis: 70%;
}
.relationship-type {
flex-basis: 30%;
} }
</style> </style>

View File

@ -47,9 +47,10 @@
}) })
$: filteredHelpers = helpers?.filter(helper => { $: filteredHelpers = helpers?.filter(helper => {
return ( return (
!search || (!search ||
helper.label.match(searchRgx) || helper.label.match(searchRgx) ||
helper.description.match(searchRgx) helper.description.match(searchRgx)) &&
(mode.name !== "javascript" || helper.allowsJs)
) )
}) })

View File

@ -13,6 +13,7 @@
const appPrefix = "/app" const appPrefix = "/app"
let touched = false let touched = false
let error let error
let modal
$: appUrl = screenUrl $: appUrl = screenUrl
? `${window.location.origin}${appPrefix}${screenUrl}` ? `${window.location.origin}${appPrefix}${screenUrl}`
@ -50,6 +51,7 @@
</script> </script>
<ModalContent <ModalContent
bind:this={modal}
size="M" size="M"
title={"Screen details"} title={"Screen details"}
{confirmText} {confirmText}
@ -58,15 +60,17 @@
cancelText={"Back"} cancelText={"Back"}
disabled={!screenUrl || error || !touched} disabled={!screenUrl || error || !touched}
> >
<Input <form on:submit|preventDefault={() => modal.confirm()}>
label="Enter a URL for the new screen" <Input
{error} label="Enter a URL for the new screen"
bind:value={screenUrl} {error}
on:change={routeChanged} bind:value={screenUrl}
/> on:change={routeChanged}
<div class="app-server" title={appUrl}> />
{appUrl} <div class="app-server" title={appUrl}>
</div> {appUrl}
</div>
</form>
</ModalContent> </ModalContent>
<style> <style>

View File

@ -1,5 +1,4 @@
import { getContextProviderComponents } from "builderStore/dataBinding" import { getComponentContexts } from "builderStore/dataBinding"
import { store } from "builderStore"
import { capitalise } from "helpers" import { capitalise } from "helpers"
// Generates bindings for all components that provider "datasource like" // Generates bindings for all components that provider "datasource like"
@ -8,58 +7,49 @@ import { capitalise } from "helpers"
// Some examples are saving rows or duplicating rows. // Some examples are saving rows or duplicating rows.
export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => { export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => {
// Get all form context providers // Get all form context providers
const formComponents = getContextProviderComponents( const formComponentContexts = getComponentContexts(
asset, asset,
componentId, componentId,
"form", "form",
{ includeSelf: nested } {
includeSelf: nested,
}
) )
// Get all schema context providers // Get all schema context providers
const schemaComponents = getContextProviderComponents( const schemaComponentContexts = getComponentContexts(
asset, asset,
componentId, componentId,
"schema", "schema",
{ includeSelf: nested } {
includeSelf: nested,
}
) )
// Generate contexts for all form providers
const formContexts = formComponents.map(component => ({
component,
context: extractComponentContext(component, "form"),
}))
// Generate contexts for all schema providers
const schemaContexts = schemaComponents.map(component => ({
component,
context: extractComponentContext(component, "schema"),
}))
// Check for duplicate contexts by the same component. In this case, attempt // Check for duplicate contexts by the same component. In this case, attempt
// to label contexts with their suffixes // to label contexts with their suffixes
schemaContexts.forEach(schemaContext => { schemaComponentContexts.forEach(schemaContext => {
// Check if we have a form context for this component // Check if we have a form context for this component
const id = schemaContext.component._id const id = schemaContext.component._id
const existing = formContexts.find(x => x.component._id === id) const existing = formComponentContexts.find(x => x.component._id === id)
if (existing) { if (existing) {
if (existing.context.suffix) { if (existing.contexts[0].suffix) {
const suffix = capitalise(existing.context.suffix) const suffix = capitalise(existing.contexts[0].suffix)
existing.readableSuffix = ` - ${suffix}` existing.readableSuffix = ` - ${suffix}`
} }
if (schemaContext.context.suffix) { if (schemaContext.contexts[0].suffix) {
const suffix = capitalise(schemaContext.context.suffix) const suffix = capitalise(schemaContext.contexts[0].suffix)
schemaContext.readableSuffix = ` - ${suffix}` schemaContext.readableSuffix = ` - ${suffix}`
} }
} }
}) })
// Generate bindings for all contexts // Generate bindings for all contexts
const allContexts = formContexts.concat(schemaContexts) const allContexts = formComponentContexts.concat(schemaComponentContexts)
return allContexts.map(({ component, context, readableSuffix }) => { return allContexts.map(({ component, contexts, readableSuffix }) => {
let readableBinding = component._instanceName let readableBinding = component._instanceName
let runtimeBinding = component._id let runtimeBinding = component._id
if (context.suffix) { if (contexts[0].suffix) {
runtimeBinding += `-${context.suffix}` runtimeBinding += `-${contexts[0].suffix}`
} }
if (readableSuffix) { if (readableSuffix) {
readableBinding += readableSuffix readableBinding += readableSuffix
@ -70,13 +60,3 @@ export const getDatasourceLikeProviders = ({ asset, componentId, nested }) => {
} }
}) })
} }
// Gets a context definition of a certain type from a component definition
const extractComponentContext = (component, contextType) => {
const def = store.actions.components.getDefinition(component?._component)
if (!def) {
return null
}
const contexts = Array.isArray(def.context) ? def.context : [def.context]
return contexts.find(context => context?.type === contextType)
}

View File

@ -5,6 +5,7 @@
import { store } from "builderStore" import { store } from "builderStore"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { getEventContextBindings } from "builderStore/dataBinding" import { getEventContextBindings } from "builderStore/dataBinding"
import { cloneDeep, isEqual } from "lodash/fp"
export let componentInstance export let componentInstance
export let componentBindings export let componentBindings
@ -17,8 +18,13 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let focusItem let focusItem
let cachedValue
$: buttonList = sanitizeValue(value) || [] $: if (!isEqual(value, cachedValue)) {
cachedValue = cloneDeep(value)
}
$: buttonList = sanitizeValue(cachedValue) || []
$: buttonCount = buttonList.length $: buttonCount = buttonList.length
$: eventContextBindings = getEventContextBindings({ $: eventContextBindings = getEventContextBindings({
componentInstance, componentInstance,

View File

@ -1,15 +1,16 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore" import { currentAsset } from "builderStore"
import { findComponentPath } from "builderStore/componentUtils" import { findAllMatchingComponents } from "builderStore/componentUtils"
export let value export let value
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}` const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: path = findComponentPath($currentAsset?.props, $store.selectedComponentId) $: providers = findAllMatchingComponents($currentAsset?.props, c =>
$: providers = path.filter(c => c._component?.endsWith("/dataprovider")) c._component?.endsWith("/dataprovider")
)
</script> </script>
<Select <Select

View File

@ -1,6 +1,5 @@
<script> <script>
import { import {
getContextProviderComponents,
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
@ -30,6 +29,7 @@
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte" import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { findAllComponents } from "builderStore/componentUtils"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte" import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "api" import { API } from "api"
@ -75,12 +75,13 @@
...query, ...query,
type: "query", type: "query",
})) }))
$: contextProviders = getContextProviderComponents( $: dataProviders = findAllComponents($currentAsset.props)
$currentAsset, .filter(component => {
$store.selectedComponentId return (
) component._component?.endsWith("/dataprovider") &&
$: dataProviders = contextProviders component._id !== $store.selectedComponentId
.filter(component => component._component?.endsWith("/dataprovider")) )
})
.map(provider => ({ .map(provider => ({
label: provider._instanceName, label: provider._instanceName,
name: provider._instanceName, name: provider._instanceName,

View File

@ -6,7 +6,7 @@
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { derived, writable } from "svelte/store" import { derived, writable } from "svelte/store"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { cloneDeep } from "lodash" import { cloneDeep, isEqual } from "lodash"
export let componentInstance export let componentInstance
export let componentBindings export let componentBindings
@ -21,21 +21,32 @@
const currentStep = derived(multiStepStore, state => state.currentStep) const currentStep = derived(multiStepStore, state => state.currentStep)
const componentType = "@budibase/standard-components/multistepformblockstep" const componentType = "@budibase/standard-components/multistepformblockstep"
let cachedValue
let cachedInstance = {}
$: if (!isEqual(cachedValue, value)) {
cachedValue = value
}
$: if (!isEqual(componentInstance, cachedInstance)) {
cachedInstance = componentInstance
}
setContext("multi-step-form-block", multiStepStore) setContext("multi-step-form-block", multiStepStore)
$: stepCount = value?.length || 0 $: stepCount = cachedValue?.length || 0
$: updateStore(stepCount) $: updateStore(stepCount)
$: dataSource = getDatasourceForProvider($currentAsset, componentInstance) $: dataSource = getDatasourceForProvider($currentAsset, cachedInstance)
$: emitCurrentStep($currentStep) $: emitCurrentStep($currentStep)
$: stepLabel = getStepLabel($multiStepStore) $: stepLabel = getStepLabel($multiStepStore)
$: stepDef = getDefinition(stepLabel) $: stepDef = getDefinition(stepLabel)
$: stepSettings = value?.[$currentStep] || {} $: stepSettings = cachedValue?.[$currentStep] || {}
$: defaults = Utils.buildMultiStepFormBlockDefaultProps({ $: defaults = Utils.buildMultiStepFormBlockDefaultProps({
_id: componentInstance._id, _id: cachedInstance._id,
stepCount: $multiStepStore.stepCount, stepCount: $multiStepStore.stepCount,
currentStep: $multiStepStore.currentStep, currentStep: $multiStepStore.currentStep,
actionType: componentInstance.actionType, actionType: cachedInstance.actionType,
dataSource: componentInstance.dataSource, dataSource: cachedInstance.dataSource,
}) })
$: stepInstance = { $: stepInstance = {
_id: Helpers.uuid(), _id: Helpers.uuid(),

View File

@ -23,7 +23,7 @@
stepAction("addStep") stepAction("addStep")
}} }}
> >
Add Step Add step
</ActionButton> </ActionButton>
</div> </div>
{:else} {:else}

View File

@ -1,6 +1,9 @@
<script> <script>
import { currentAsset } from "builderStore" import { currentAsset, store } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/componentUtils" import {
findClosestMatchingComponent,
findComponent,
} from "builderStore/componentUtils"
import { import {
getDatasourceForProvider, getDatasourceForProvider,
getSchemaForDatasource, getSchemaForDatasource,
@ -20,8 +23,23 @@
component => component._component.endsWith("/form") component => component._component.endsWith("/form")
) )
const resolveDatasource = (currentAsset, componentInstance, form) => {
if (!form && componentInstance._id != $store.selectedComponentId) {
const block = findComponent(
currentAsset.props,
$store.selectedComponentId
)
const def = store.actions.components.getDefinition(block._component)
return def?.block === true
? getDatasourceForProvider(currentAsset, block)
: {}
} else {
return getDatasourceForProvider(currentAsset, form)
}
}
// Get that form's schema // Get that form's schema
$: datasource = getDatasourceForProvider($currentAsset, form) $: datasource = resolveDatasource($currentAsset, componentInstance, form)
$: formSchema = getSchemaForDatasource($currentAsset, datasource)?.schema $: formSchema = getSchemaForDatasource($currentAsset, datasource)?.schema
// Get the schema for the relationship field that this picker is using // Get the schema for the relationship field that this picker is using

View File

@ -35,6 +35,7 @@
export let bindingDrawerLeft export let bindingDrawerLeft
export let allowHelpers = true export let allowHelpers = true
export let customButtonText = null export let customButtonText = null
export let compare = (option, value) => option === value
let fields = Object.entries(object || {}).map(([name, value]) => ({ let fields = Object.entries(object || {}).map(([name, value]) => ({
name, name,
@ -112,7 +113,12 @@
on:blur={changed} on:blur={changed}
/> />
{#if options} {#if options}
<Select bind:value={field.value} on:change={changed} {options} /> <Select
bind:value={field.value}
{compare}
on:change={changed}
{options}
/>
{:else if bindings && bindings.length} {:else if bindings && bindings.length}
<DrawerBindableInput <DrawerBindableInput
{bindings} {bindings}

View File

@ -1,6 +1,6 @@
<script> <script>
import KeyValueBuilder from "../KeyValueBuilder.svelte" import KeyValueBuilder from "../KeyValueBuilder.svelte"
import { SchemaTypeOptions } from "constants/backend" import { SchemaTypeOptionsExpanded } from "constants/backend"
export let schema export let schema
export let onSchemaChange = () => {} export let onSchemaChange = () => {}
@ -24,6 +24,7 @@
object={schema} object={schema}
name="field" name="field"
headings headings
options={SchemaTypeOptions} options={SchemaTypeOptionsExpanded}
compare={(option, value) => option.type === value.type}
/> />
{/key} {/key}

View File

@ -33,7 +33,7 @@
PaginationTypes, PaginationTypes,
RawRestBodyTypes, RawRestBodyTypes,
RestBodyTypes as bodyTypes, RestBodyTypes as bodyTypes,
SchemaTypeOptions, SchemaTypeOptionsExpanded,
} from "constants/backend" } from "constants/backend"
import JSONPreview from "components/integration/JSONPreview.svelte" import JSONPreview from "components/integration/JSONPreview.svelte"
import AccessLevelSelect from "components/integration/AccessLevelSelect.svelte" import AccessLevelSelect from "components/integration/AccessLevelSelect.svelte"
@ -97,9 +97,7 @@
$: schemaReadOnly = !responseSuccess $: schemaReadOnly = !responseSuccess
$: variablesReadOnly = !responseSuccess $: variablesReadOnly = !responseSuccess
$: showVariablesTab = shouldShowVariables(dynamicVariables, variablesReadOnly) $: showVariablesTab = shouldShowVariables(dynamicVariables, variablesReadOnly)
$: hasSchema = $: hasSchema = Object.keys(schema || {}).length !== 0
Object.keys(schema || {}).length !== 0 ||
Object.keys(query?.schema || {}).length !== 0
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs) $: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
@ -161,7 +159,7 @@
newQuery.fields.queryString = queryString newQuery.fields.queryString = queryString
newQuery.fields.authConfigId = authConfigId newQuery.fields.authConfigId = authConfigId
newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders) newQuery.fields.disabledHeaders = restUtils.flipHeaderState(enabledHeaders)
newQuery.schema = restUtils.fieldsToSchema(schema) newQuery.schema = schema
return newQuery return newQuery
} }
@ -231,6 +229,14 @@
notifications.info("Request did not return any data") notifications.info("Request did not return any data")
} else { } else {
response.info = response.info || { code: 200 } response.info = response.info || { code: 200 }
// if existing schema, copy over what it is
if (schema) {
for (let [name, field] of Object.entries(schema)) {
if (response.schema[name]) {
response.schema[name] = field
}
}
}
schema = response.schema schema = response.schema
notifications.success("Request sent successfully") notifications.success("Request sent successfully")
} }
@ -386,6 +392,7 @@
onMount(async () => { onMount(async () => {
query = getSelectedQuery() query = getSelectedQuery()
schema = query.schema
try { try {
// Clear any unsaved changes to the datasource // Clear any unsaved changes to the datasource
@ -416,7 +423,6 @@
query.fields.path = `${datasource.config.url}/${path ? path : ""}` query.fields.path = `${datasource.config.url}/${path ? path : ""}`
} }
url = buildUrl(query.fields.path, breakQs) url = buildUrl(query.fields.path, breakQs)
schema = restUtils.schemaToFields(query.schema)
requestBindings = restUtils.queryParametersToKeyValue(query.parameters) requestBindings = restUtils.queryParametersToKeyValue(query.parameters)
authConfigId = getAuthConfigId() authConfigId = getAuthConfigId()
if (!query.fields.disabledHeaders) { if (!query.fields.disabledHeaders) {
@ -682,10 +688,11 @@
bind:object={schema} bind:object={schema}
name="schema" name="schema"
headings headings
options={SchemaTypeOptions} options={SchemaTypeOptionsExpanded}
menuItems={schemaMenuItems} menuItems={schemaMenuItems}
showMenu={!schemaReadOnly} showMenu={!schemaReadOnly}
readOnly={schemaReadOnly} readOnly={schemaReadOnly}
compare={(option, value) => option.type === value.type}
/> />
</Tab> </Tab>
{/if} {/if}

View File

@ -67,7 +67,7 @@
})) }))
navigateStep(target) navigateStep(target)
} else { } else {
console.log("Could not retrieve step") console.warn("Could not retrieve step")
} }
} else { } else {
if (typeof tourStep.onComplete === "function") { if (typeof tourStep.onComplete === "function") {

View File

@ -3,11 +3,11 @@ import { get } from "svelte/store"
const registerNode = async (node, tourStepKey) => { const registerNode = async (node, tourStepKey) => {
if (!node) { if (!node) {
console.log("Tour Handler - an anchor node is required") console.warn("Tour Handler - an anchor node is required")
} }
if (!get(store).tourKey) { if (!get(store).tourKey) {
console.log("Tour Handler - No active tour ", tourStepKey, node) console.error("Tour Handler - No active tour ", tourStepKey, node)
return return
} }

View File

@ -45,7 +45,7 @@ const endUserOnboarding = async ({ skipped = false } = {}) => {
onboarding: false, onboarding: false,
})) }))
} catch (e) { } catch (e) {
console.log("Onboarding failed", e) console.error("Onboarding failed", e)
return false return false
} }
return true return true

View File

@ -21,6 +21,7 @@ export const ActionStepID = {
QUERY_ROWS: "QUERY_ROWS", QUERY_ROWS: "QUERY_ROWS",
LOOP: "LOOP", LOOP: "LOOP",
COLLECT: "COLLECT", COLLECT: "COLLECT",
TRIGGER_AUTOMATION_RUN: "TRIGGER_AUTOMATION_RUN",
// these used to be lowercase step IDs, maintain for backwards compat // these used to be lowercase step IDs, maintain for backwards compat
discord: "discord", discord: "discord",
slack: "slack", slack: "slack",

View File

@ -271,6 +271,11 @@ export const SchemaTypeOptions = [
{ label: "Datetime", value: "datetime" }, { label: "Datetime", value: "datetime" },
] ]
export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({
...el,
value: { type: el.value },
}))
export const RawRestBodyTypes = { export const RawRestBodyTypes = {
NONE: "none", NONE: "none",
FORM: "form", FORM: "form",

View File

@ -1,4 +1,4 @@
import { getManifest } from "@budibase/string-templates" import { getManifest, helpersToRemoveForJs } from "@budibase/string-templates"
export function handlebarsCompletions() { export function handlebarsCompletions() {
const manifest = getManifest() const manifest = getManifest()
@ -11,6 +11,9 @@ export function handlebarsCompletions() {
label: helperName, label: helperName,
displayText: helperName, displayText: helperName,
description: helperConfig.description, description: helperConfig.description,
allowsJs:
!helperConfig.requiresBlock &&
!helpersToRemoveForJs.includes(helperName),
})) }))
) )
} }

View File

@ -1,26 +1,6 @@
import { IntegrationTypes } from "constants/backend" import { IntegrationTypes } from "constants/backend"
import { findHBSBlocks } from "@budibase/string-templates" import { findHBSBlocks } from "@budibase/string-templates"
export function schemaToFields(schema) {
const response = {}
if (schema && typeof schema === "object") {
for (let [field, value] of Object.entries(schema)) {
response[field] = value?.type || "string"
}
}
return response
}
export function fieldsToSchema(fields) {
const response = {}
if (fields && typeof fields === "object") {
for (let [name, type] of Object.entries(fields)) {
response[name] = { name, type }
}
}
return response
}
export function breakQueryString(qs) { export function breakQueryString(qs) {
if (!qs) { if (!qs) {
return {} return {}
@ -184,10 +164,8 @@ export const parseToCsv = (headers, rows) => {
export default { export default {
breakQueryString, breakQueryString,
buildQueryString, buildQueryString,
fieldsToSchema,
flipHeaderState, flipHeaderState,
keyValueToQueryParameters, keyValueToQueryParameters,
parseToCsv, parseToCsv,
queryParametersToKeyValue, queryParametersToKeyValue,
schemaToFields,
} }

View File

@ -0,0 +1,27 @@
import { PlanType } from "@budibase/types"
export function getFormattedPlanName(userPlanType) {
let planName
switch (userPlanType) {
case PlanType.PRO:
planName = "Pro"
break
case PlanType.TEAM:
planName = "Team"
break
case PlanType.PREMIUM:
case PlanType.PREMIUM_PLUS:
planName = "Premium"
break
case PlanType.BUSINESS:
planName = "Business"
break
case PlanType.ENTERPRISE_BASIC:
case PlanType.ENTERPRISE:
planName = "Enterprise"
break
default:
planName = "Free" // Default to "Free" if the type is not explicitly handled
}
return `${planName} Plan`
}

View File

@ -52,7 +52,7 @@ export const syncURLToState = options => {
let cachedPage = get(routify.page) let cachedPage = get(routify.page)
let previousParamsHash = null let previousParamsHash = null
let debug = false let debug = false
const log = (...params) => debug && console.log(`[${urlParam}]`, ...params) const log = (...params) => debug && console.debug(`[${urlParam}]`, ...params)
// Navigate to a certain URL // Navigate to a certain URL
const gotoUrl = (url, params) => { const gotoUrl = (url, params) => {

View File

@ -107,7 +107,7 @@
return return
} }
if (!prodAppId) { if (!prodAppId) {
console.log("Application id required") console.error("Application id required")
return return
} }
await usersFetch.update({ await usersFetch.update({
@ -392,6 +392,10 @@
} }
const openInviteFlow = () => { const openInviteFlow = () => {
// prevent email from getting overwritten if changes are made
if (!email) {
email = query
}
$licensing.userLimitReached $licensing.userLimitReached
? userLimitReachedModal.show() ? userLimitReachedModal.show()
: (invitingFlow = true) : (invitingFlow = true)

View File

@ -108,6 +108,8 @@
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}
{bindings} {bindings}
iconTooltip={componentName}
componentTitle={title}
/> />
{/if} {/if}
{#if section == "conditions"} {#if section == "conditions"}

View File

@ -5,6 +5,9 @@
Drawer, Drawer,
Button, Button,
notifications, notifications,
AbsTooltip,
Icon,
Body,
} from "@budibase/bbui" } from "@budibase/bbui"
import { selectedScreen, store } from "builderStore" import { selectedScreen, store } from "builderStore"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
@ -15,6 +18,9 @@
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
export let componentInstance export let componentInstance
export let componentDefinition
export let iconTooltip
export let componentTitle
let tempValue let tempValue
let drawer let drawer
@ -24,6 +30,8 @@
$store.selectedComponentId $store.selectedComponentId
) )
$: icon = componentDefinition?.icon
const openDrawer = () => { const openDrawer = () => {
tempValue = runtimeToReadableBinding( tempValue = runtimeToReadableBinding(
bindings, bindings,
@ -54,7 +62,19 @@
{#key componentInstance?._id} {#key componentInstance?._id}
<Drawer bind:this={drawer} title="Custom CSS"> <Drawer bind:this={drawer} title="Custom CSS">
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Custom CSS overrides all other component styles. <div class="header">
Your CSS will overwrite styles for:
{#if icon}
<AbsTooltip type="info" text={iconTooltip}>
<Icon
color={`var(--spectrum-global-color-gray-600)`}
size="S"
name={icon}
/>
</AbsTooltip>
<Body size="S"><b>{componentTitle || ""}</b></Body>
{/if}
</div>
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button> <Button cta slot="buttons" on:click={save}>Save</Button>
<svelte:component <svelte:component
@ -68,3 +88,13 @@
/> />
</Drawer> </Drawer>
{/key} {/key}
<style>
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--spacing-m);
}
</style>

View File

@ -66,7 +66,7 @@
try { try {
await store.actions.screens.updateSetting(get(selectedScreen), key, value) await store.actions.screens.updateSetting(get(selectedScreen), key, value)
} catch (error) { } catch (error) {
console.log(error) console.error(error)
notifications.error("Error saving screen settings") notifications.error("Error saving screen settings")
} }
} }

View File

@ -1,7 +1,7 @@
<script> <script>
import { get } from "svelte/store" import { get } from "svelte/store"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { store, selectedScreen, currentAsset } from "builderStore" import { store, selectedScreen, currentAsset, hoverStore } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
ProgressCircle, ProgressCircle,
@ -36,14 +36,12 @@
// Determine selected component ID // Determine selected component ID
$: selectedComponentId = $store.selectedComponentId $: selectedComponentId = $store.selectedComponentId
$: hoverComponentId = $store.hoverComponentId
$: previewData = { $: previewData = {
appId: $store.appId, appId: $store.appId,
layout, layout,
screen, screen,
selectedComponentId, selectedComponentId,
hoverComponentId,
theme: $store.theme, theme: $store.theme,
customTheme: $store.customTheme, customTheme: $store.customTheme,
previewDevice: $store.previewDevice, previewDevice: $store.previewDevice,
@ -119,8 +117,8 @@
error = event.error || "An unknown error occurred" error = event.error || "An unknown error occurred"
} else if (type === "select-component" && data.id) { } else if (type === "select-component" && data.id) {
$store.selectedComponentId = data.id $store.selectedComponentId = data.id
} else if (type === "hover-component" && data.id) { } else if (type === "hover-component") {
$store.hoverComponentId = data.id hoverStore.actions.update(data.id, false)
} else if (type === "update-prop") { } else if (type === "update-prop") {
await store.actions.components.updateSetting(data.prop, data.value) await store.actions.components.updateSetting(data.prop, data.value)
} else if (type === "update-styles") { } else if (type === "update-styles") {

View File

@ -7,6 +7,7 @@
$: definition = store.actions.components.getDefinition(component?._component) $: definition = store.actions.components.getDefinition(component?._component)
$: noPaste = !$store.componentToPaste $: noPaste = !$store.componentToPaste
$: isBlock = definition?.block === true $: isBlock = definition?.block === true
$: canEject = !(definition?.ejectable === false)
const keyboardEvent = (key, ctrlKey = false) => { const keyboardEvent = (key, ctrlKey = false) => {
document.dispatchEvent( document.dispatchEvent(
@ -32,7 +33,7 @@
> >
Delete Delete
</MenuItem> </MenuItem>
{#if isBlock} {#if isBlock && canEject}
<MenuItem <MenuItem
icon="Export" icon="Export"
keyBind="Ctrl+E" keyBind="Ctrl+E"

View File

@ -32,8 +32,15 @@
await store.actions.components.paste(component, "below") await store.actions.components.paste(component, "below")
}, },
["Ctrl+e"]: component => { ["Ctrl+e"]: component => {
componentToEject = component const definition = store.actions.components.getDefinition(
confirmEjectDialog.show() component._component
)
const isBlock = definition?.block === true
const canEject = !(definition?.ejectable === false)
if (isBlock && canEject) {
componentToEject = component
confirmEjectDialog.show()
}
}, },
["Ctrl+Enter"]: () => { ["Ctrl+Enter"]: () => {
$goto(`./:componentId/new`) $goto(`./:componentId/new`)

View File

@ -5,6 +5,7 @@
selectedComponentPath, selectedComponentPath,
selectedComponent, selectedComponent,
selectedScreen, selectedScreen,
hoverStore,
} from "builderStore" } from "builderStore"
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte" import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
@ -90,16 +91,7 @@
return findComponentPath($selectedComponent, component._id)?.length > 0 return findComponentPath($selectedComponent, component._id)?.length > 0
} }
const handleMouseover = componentId => { const hover = hoverStore.actions.update
if ($store.hoverComponentId !== componentId) {
$store.hoverComponentId = componentId
}
}
const handleMouseout = componentId => {
if ($store.hoverComponentId === componentId) {
$store.hoverComponentId = null
}
}
</script> </script>
<ul> <ul>
@ -120,9 +112,9 @@
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:iconClick={() => toggleNodeOpen(component._id)} on:iconClick={() => toggleNodeOpen(component._id)}
on:drop={onDrop} on:drop={onDrop}
hovering={$store.hoverComponentId === component._id} hovering={$hoverStore.componentId === component._id}
on:mouseenter={() => handleMouseover(component._id)} on:mouseenter={() => hover(component._id)}
on:mouseleave={() => handleMouseout(component._id)} on:mouseleave={() => hover(null)}
text={getComponentText(component)} text={getComponentText(component)}
icon={getComponentIcon(component)} icon={getComponentIcon(component)}
iconTooltip={getComponentName(component)} iconTooltip={getComponentName(component)}

View File

@ -1,7 +1,12 @@
<script> <script>
import { notifications, Icon, Body } from "@budibase/bbui" import { notifications, Icon, Body } from "@budibase/bbui"
import { isActive, goto } from "@roxi/routify" import { isActive, goto } from "@roxi/routify"
import { store, selectedScreen, userSelectedResourceMap } from "builderStore" import {
store,
selectedScreen,
userSelectedResourceMap,
hoverStore,
} from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import ComponentTree from "./ComponentTree.svelte" import ComponentTree from "./ComponentTree.svelte"
import { dndStore, DropPosition } from "./dndStore.js" import { dndStore, DropPosition } from "./dndStore.js"
@ -12,6 +17,9 @@
let scrolling = false let scrolling = false
$: screenComponentId = `${$store.selectedScreenId}-screen`
$: navComponentId = `${$store.selectedScreenId}-navigation`
const toNewComponentRoute = () => { const toNewComponentRoute = () => {
if ($isActive(`./:componentId/new`)) { if ($isActive(`./:componentId/new`)) {
$goto(`./:componentId`) $goto(`./:componentId`)
@ -33,16 +41,7 @@
scrolling = e.target.scrollTop !== 0 scrolling = e.target.scrollTop !== 0
} }
const handleMouseover = componentId => { const hover = hoverStore.actions.update
if ($store.hoverComponentId !== componentId) {
$store.hoverComponentId = componentId
}
}
const handleMouseout = componentId => {
if ($store.hoverComponentId === componentId) {
$store.hoverComponentId = null
}
}
</script> </script>
<div class="components"> <div class="components">
@ -65,46 +64,31 @@
scrollable scrollable
icon="WebPage" icon="WebPage"
on:drop={onDrop} on:drop={onDrop}
on:click={() => { on:click={() => ($store.selectedComponentId = screenComponentId)}
$store.selectedComponentId = `${$store.selectedScreenId}-screen` hovering={$hoverStore.componentId === screenComponentId}
}} on:mouseenter={() => hover(screenComponentId)}
hovering={$store.hoverComponentId === on:mouseleave={() => hover(null)}
`${$store.selectedScreenId}-screen`} id="component-screen"
on:mouseenter={() => selectedBy={$userSelectedResourceMap[screenComponentId]}
handleMouseover(`${$store.selectedScreenId}-screen`)}
on:mouseleave={() =>
handleMouseout(`${$store.selectedScreenId}-screen`)}
id={`component-screen`}
selectedBy={$userSelectedResourceMap[
`${$store.selectedScreenId}-screen`
]}
> >
<ScreenslotDropdownMenu component={$selectedScreen?.props} /> <ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem> </NavItem>
<NavItem <NavItem
text="Navigation" text="Navigation"
indentLevel={0} indentLevel={0}
selected={$store.selectedComponentId === selected={$store.selectedComponentId === navComponentId}
`${$store.selectedScreenId}-navigation`}
opened opened
scrollable scrollable
icon={$selectedScreen.showNavigation icon={$selectedScreen.showNavigation
? "Visibility" ? "Visibility"
: "VisibilityOff"} : "VisibilityOff"}
on:drop={onDrop} on:drop={onDrop}
on:click={() => { on:click={() => ($store.selectedComponentId = navComponentId)}
$store.selectedComponentId = `${$store.selectedScreenId}-navigation` hovering={$hoverStore.componentId === navComponentId}
}} on:mouseenter={() => hover(navComponentId)}
hovering={$store.hoverComponentId === on:mouseleave={() => hover(null)}
`${$store.selectedScreenId}-navigation`} id="component-nav"
on:mouseenter={() => selectedBy={$userSelectedResourceMap[navComponentId]}
handleMouseover(`${$store.selectedScreenId}-navigation`)}
on:mouseleave={() =>
handleMouseout(`${$store.selectedScreenId}-navigation`)}
id={`component-nav`}
selectedBy={$userSelectedResourceMap[
`${$store.selectedScreenId}-navigation`
]}
/> />
<ComponentTree <ComponentTree
level={0} level={0}

View File

@ -71,7 +71,7 @@
$goto(`./${screenId}`) $goto(`./${screenId}`)
store.actions.screens.select(screenId) store.actions.screens.select(screenId)
} catch (error) { } catch (error) {
console.log(error) console.error(error)
notifications.error("Error creating screens") notifications.error("Error creating screens")
} }
} }

View File

@ -15,9 +15,9 @@
<Content showMobileNav> <Content showMobileNav>
<SideNav slot="side-nav"> <SideNav slot="side-nav">
<SideNavItem <SideNavItem
text="Automation History" text="Automations"
url={$url("./automation-history")} url={$url("./automations")}
active={$isActive("./automation-history")} active={$isActive("./automations")}
/> />
<SideNavItem <SideNavItem
text="Backups" text="Backups"

View File

@ -8,6 +8,8 @@
Body, Body,
Heading, Heading,
Divider, Divider,
Toggle,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte" import DateTimeRenderer from "components/common/renderers/DateTimeRenderer.svelte"
import StatusRenderer from "./_components/StatusRenderer.svelte" import StatusRenderer from "./_components/StatusRenderer.svelte"
@ -16,7 +18,7 @@
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { getContext, onDestroy, onMount } from "svelte" import { getContext, onDestroy, onMount } from "svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import { auth, licensing, admin } from "stores/portal" import { auth, licensing, admin, apps } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import Portal from "svelte-portal" import Portal from "svelte-portal"
@ -34,11 +36,12 @@
let status = null let status = null
let timeRange = null let timeRange = null
let loaded = false let loaded = false
$: app = $apps.find(app => $store.appId?.includes(app.appId))
$: licensePlan = $auth.user?.license?.plan $: licensePlan = $auth.user?.license?.plan
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange) $: fetchLogs(automationId, status, page, timeRange)
$: isCloud = $admin.cloud
$: chainAutomations = app?.automations?.chainAutomations ?? !isCloud
const timeOptions = [ const timeOptions = [
{ value: "90-d", label: "Past 90 days" }, { value: "90-d", label: "Past 90 days" },
{ value: "30-d", label: "Past 30 days" }, { value: "30-d", label: "Past 30 days" },
@ -124,6 +127,18 @@
sidePanel.open() 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 () => { onMount(async () => {
await automationStore.actions.fetch() await automationStore.actions.fetch()
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@ -150,11 +165,30 @@
<Layout noPadding> <Layout noPadding>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading>Automation History</Heading> <Heading>Automations</Heading>
<Body>View the automations your app has executed</Body> <Body size="S">See your automation history and edit advanced settings</Body>
</Layout> </Layout>
<Divider /> <Divider />
<Layout gap="XS" noPadding>
<Heading size="XS">Chain automations</Heading>
<Body size="S">Allow automations to trigger from other automations</Body>
<div class="setting-spacing">
<Toggle
text={"Enable chaining"}
on:change={e => {
save(e)
}}
value={chainAutomations}
/>
</div>
</Layout>
<Divider />
<Layout gap="XS" noPadding>
<Heading size="XS">History</Heading>
<Body size="S">Free plan stores up to 1 day of automation history</Body>
</Layout>
<div class="controls"> <div class="controls">
<div class="search"> <div class="search">
<div class="select"> <div class="select">
@ -237,6 +271,9 @@
{/if} {/if}
<style> <style>
.setting-spacing {
padding-top: var(--spacing-s);
}
.controls { .controls {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -13,6 +13,7 @@
import CreateRestoreModal from "./CreateRestoreModal.svelte" import CreateRestoreModal from "./CreateRestoreModal.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { isOnlyUser } from "builderStore" import { isOnlyUser } from "builderStore"
import { BackupType } from "constants/backend/backups"
export let row export let row
@ -42,12 +43,11 @@
</script> </script>
<div class="cell"> <div class="cell">
<ActionMenu align="right"> {#if row.type !== BackupType.RESTORE}
<div slot="control"> <ActionMenu align="right">
<Icon size="M" hoverable name="MoreSmallList" /> <div slot="control">
</div> <Icon size="M" hoverable name="MoreSmallList" />
</div>
{#if row.type !== "restore"}
<AbsTooltip <AbsTooltip
position={TooltipPosition.Left} position={TooltipPosition.Left}
text="Unavailable - another user is editing this app" text="Unavailable - another user is editing this app"
@ -62,8 +62,8 @@
</AbsTooltip> </AbsTooltip>
<MenuItem on:click={deleteDialog.show} icon="Delete">Delete</MenuItem> <MenuItem on:click={deleteDialog.show} icon="Delete">Delete</MenuItem>
<MenuItem on:click={downloadExport} icon="Download">Download</MenuItem> <MenuItem on:click={downloadExport} icon="Download">Download</MenuItem>
{/if} </ActionMenu>
</ActionMenu> {/if}
</div> </div>
<Modal bind:this={restoreBackupModal}> <Modal bind:this={restoreBackupModal}>

View File

@ -1,5 +1,5 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
$redirect("../settings/automation-history") $redirect("../settings/automations")
</script> </script>

View File

@ -31,7 +31,7 @@
async function login() { async function login() {
form.validate() form.validate()
if (Object.keys(errors).length > 0) { if (Object.keys(errors).length > 0) {
console.log("errors", errors) console.error("errors", errors)
return return
} }
try { try {

View File

@ -15,7 +15,7 @@
import { DashCard, Usage } from "components/usage" import { DashCard, Usage } from "components/usage"
import { PlanModel } from "constants" import { PlanModel } from "constants"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { PlanType } from "@budibase/types" import { getFormattedPlanName } from "helpers/planTitle"
let staticUsage = [] let staticUsage = []
let monthlyUsage = [] let monthlyUsage = []
@ -100,23 +100,6 @@
cancelAt = license?.billing?.subscription?.cancelAt cancelAt = license?.billing?.subscription?.cancelAt
} }
const capitalise = string => {
if (string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
}
const planTitle = () => {
const planType = license?.plan.type
let planName = license?.plan.type
if (planType === PlanType.PREMIUM_PLUS) {
planName = "Premium"
} else if (planType === PlanType.ENTERPRISE_BASIC) {
planName = "Enterprise"
}
return `${capitalise(planName)} Plan`
}
const getDaysRemaining = timestamp => { const getDaysRemaining = timestamp => {
if (!timestamp) { if (!timestamp) {
return return
@ -227,7 +210,7 @@
<DashCard <DashCard
description="YOUR CURRENT PLAN" description="YOUR CURRENT PLAN"
title={planTitle()} title={getFormattedPlanName(license?.plan.type)}
{primaryActionText} {primaryActionText}
primaryAction={showButton ? goToAccountPortal : undefined} primaryAction={showButton ? goToAccountPortal : undefined}
{textRows} {textRows}

View File

@ -214,7 +214,7 @@
<Heading size="M">Branding</Heading> <Heading size="M">Branding</Heading>
{#if !isCloud && !brandingEnabled} {#if !isCloud && !brandingEnabled}
<Tags> <Tags>
<Tag icon="LockClosed">Business</Tag> <Tag icon="LockClosed">Premium</Tag>
</Tags> </Tags>
{/if} {/if}
{#if isCloud && !brandingEnabled} {#if isCloud && !brandingEnabled}

View File

@ -97,7 +97,7 @@
<Heading size="M">Groups</Heading> <Heading size="M">Groups</Heading>
{#if !$licensing.groupsEnabled} {#if !$licensing.groupsEnabled}
<Tags> <Tags>
<Tag icon="LockClosed">Business</Tag> <Tag icon="LockClosed">Enterpise</Tag>
</Tags> </Tags>
{/if} {/if}
</div> </div>

View File

@ -89,8 +89,8 @@ export function createQueriesStore() {
// Assume all the fields are strings and create a basic schema from the // Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server // unique fields returned by the server
const schema = {} const schema = {}
for (let [field, type] of Object.entries(result.schemaFields)) { for (let [field, metadata] of Object.entries(result.schema)) {
schema[field] = type || "string" schema[field] = metadata || { type: "string" }
} }
return { ...result, schema, rows: result.rows || [] } return { ...result, schema, rows: result.rows || [] }
} }

View File

@ -125,6 +125,10 @@ export const createLicensingStore = () => {
const syncAutomationsEnabled = license.features.includes( const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS Constants.Features.SYNC_AUTOMATIONS
) )
const triggerAutomationRunEnabled = license.features.includes(
Constants.Features.TRIGGER_AUTOMATION_RUN
)
const perAppBuildersEnabled = license.features.includes( const perAppBuildersEnabled = license.features.includes(
Constants.Features.APP_BUILDERS Constants.Features.APP_BUILDERS
) )
@ -147,6 +151,7 @@ export const createLicensingStore = () => {
auditLogsEnabled, auditLogsEnabled,
enforceableSSO, enforceableSSO,
syncAutomationsEnabled, syncAutomationsEnabled,
triggerAutomationRunEnabled,
isViewPermissionsEnabled, isViewPermissionsEnabled,
perAppBuildersEnabled, perAppBuildersEnabled,
} }

View File

@ -573,7 +573,6 @@
"description": "A configurable data list that attaches to your backend tables.", "description": "A configurable data list that attaches to your backend tables.",
"icon": "JourneyData", "icon": "JourneyData",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"requiredAncestors": ["dataprovider"],
"hasChildren": true, "hasChildren": true,
"size": { "size": {
"width": 400, "width": 400,
@ -711,10 +710,12 @@
], ],
"context": [ "context": [
{ {
"type": "schema" "type": "schema",
"scope": "local"
}, },
{ {
"type": "static", "type": "static",
"scope": "local",
"values": [ "values": [
{ {
"label": "Row index", "label": "Row index",
@ -1564,7 +1565,6 @@
"name": "Bar Chart", "name": "Bar Chart",
"description": "Bar chart", "description": "Bar chart",
"icon": "GraphBarVertical", "icon": "GraphBarVertical",
"requiredAncestors": ["dataprovider"],
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 400
@ -1727,7 +1727,6 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1881,7 +1880,6 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2047,7 +2045,6 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2177,7 +2174,6 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -2307,7 +2303,6 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -3969,6 +3964,12 @@
"key": "allowManualEntry", "key": "allowManualEntry",
"defaultValue": false "defaultValue": false
}, },
{
"type": "boolean",
"label": "Auto confirm",
"key": "autoConfirm",
"defaultValue": false
},
{ {
"type": "boolean", "type": "boolean",
"label": "Play sound on scan", "label": "Play sound on scan",
@ -4081,7 +4082,6 @@
"width": 400, "width": 400,
"height": 320 "height": 320
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -4637,7 +4637,6 @@
"name": "Table", "name": "Table",
"icon": "Table", "icon": "Table",
"illegalChildren": ["section"], "illegalChildren": ["section"],
"requiredAncestors": ["dataprovider"],
"hasChildren": true, "hasChildren": true,
"showEmptyState": false, "showEmptyState": false,
"size": { "size": {
@ -4728,7 +4727,6 @@
"name": "Date Range", "name": "Date Range",
"icon": "Calendar", "icon": "Calendar",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["dataprovider"],
"hasChildren": false, "hasChildren": false,
"size": { "size": {
"width": 200, "width": 200,
@ -4836,7 +4834,6 @@
"width": 100, "width": 100,
"height": 35 "height": 35
}, },
"requiredAncestors": ["dataprovider"],
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -5611,7 +5608,38 @@
} }
] ]
} }
] ],
"context": {
"type": "static",
"suffix": "provider",
"values": [
{
"label": "Rows",
"key": "rows",
"type": "array"
},
{
"label": "Extra Info",
"key": "info",
"type": "string"
},
{
"label": "Rows Length",
"key": "rowsLength",
"type": "number"
},
{
"label": "Schema",
"key": "schema",
"type": "object"
},
{
"label": "Page Number",
"key": "pageNumber",
"type": "number"
}
]
}
}, },
"cardsblock": { "cardsblock": {
"block": true, "block": true,
@ -5790,7 +5818,8 @@
], ],
"context": { "context": {
"type": "schema", "type": "schema",
"suffix": "repeater" "suffix": "repeater",
"scope": "local"
} }
}, },
"repeaterblock": { "repeaterblock": {
@ -6014,7 +6043,8 @@
}, },
{ {
"type": "schema", "type": "schema",
"suffix": "repeater" "suffix": "repeater",
"scope": "local"
} }
] ]
}, },
@ -6093,15 +6123,44 @@
"key": "steps", "key": "steps",
"nested": true, "nested": true,
"labelHidden": true, "labelHidden": true,
"resetOn": [ "resetOn": ["dataSource", "actionType"],
"dataSource", "defaultValue": [{}]
"actionType"
],
"defaultValue": [
{}
]
} }
] ]
},
{
"tag": "style",
"type": "select",
"label": "Button position",
"key": "buttonPosition",
"options": [
{
"label": "Bottom",
"value": "bottom"
},
{
"label": "Top",
"value": "top"
}
],
"defaultValue": "bottom"
},
{
"tag": "style",
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
} }
], ],
"actions": [ "actions": [
@ -6131,6 +6190,10 @@
"type": "form", "type": "form",
"suffix": "form" "suffix": "form"
}, },
{
"type": "schema",
"suffix": "repeater"
},
{ {
"type": "static", "type": "static",
"suffix": "form", "suffix": "form",
@ -6444,9 +6507,27 @@
], ],
"context": { "context": {
"type": "schema", "type": "schema",
"suffix": "repeater" "suffix": "repeater",
"scope": "local"
} }
}, },
"grid": {
"name": "Grid",
"icon": "ViewGrid",
"hasChildren": true,
"settings": [
{
"type": "number",
"key": "cols",
"label": "Columns"
},
{
"type": "number",
"key": "rows",
"label": "Rows"
}
]
},
"gridblock": { "gridblock": {
"name": "Grid Block", "name": "Grid Block",
"icon": "Table", "icon": "Table",
@ -6590,7 +6671,8 @@
} }
], ],
"context": { "context": {
"type": "schema" "type": "schema",
"scope": "local"
}, },
"actions": ["RefreshDatasource"] "actions": ["RefreshDatasource"]
}, },

View File

@ -9,7 +9,7 @@
</script> </script>
<script> <script>
import { getContext, setContext, onMount, onDestroy } from "svelte" import { getContext, setContext, onMount } from "svelte"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { import {
enrichProps, enrichProps,
@ -30,6 +30,15 @@
import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte" import ScreenPlaceholder from "components/app/ScreenPlaceholder.svelte"
import ComponentErrorState from "components/error-states/ComponentErrorState.svelte" import ComponentErrorState from "components/error-states/ComponentErrorState.svelte"
import { BudibasePrefix } from "../stores/components.js" import { BudibasePrefix } from "../stores/components.js"
import {
decodeJSBinding,
findHBSBlocks,
isJSBinding,
} from "@budibase/string-templates"
import {
getActionContextKey,
getActionDependentContextKeys,
} from "../utils/buttonActions.js"
export let instance = {} export let instance = {}
export let isLayout = false export let isLayout = false
@ -81,7 +90,6 @@
// Keep track of stringified representations of context and instance // Keep track of stringified representations of context and instance
// to avoid enriching bindings as much as possible // to avoid enriching bindings as much as possible
let lastContextKey
let lastInstanceKey let lastInstanceKey
// Visibility flag used by conditional UI // Visibility flag used by conditional UI
@ -98,6 +106,13 @@
// We clear these whenever a new instance is received. // We clear these whenever a new instance is received.
let ephemeralStyles let ephemeralStyles
// Single string of all HBS blocks, used to check if we use a certain binding
// or not
let bindingString = ""
// List of context keys which we use inside bindings
let knownContextKeyMap = {}
// Set up initial state for each new component instance // Set up initial state for each new component instance
$: initialise(instance) $: initialise(instance)
@ -155,9 +170,6 @@
hasMissingRequiredSettings) hasMissingRequiredSettings)
$: emptyState = empty && showEmptyState $: emptyState = empty && showEmptyState
// Enrich component settings
$: enrichComponentSettings($context, settingsDefinitionMap)
// Evaluate conditional UI settings and store any component setting changes // Evaluate conditional UI settings and store any component setting changes
// which need to be made // which need to be made
$: evaluateConditions(conditions) $: evaluateConditions(conditions)
@ -206,6 +218,7 @@
errorState, errorState,
parent: id, parent: id,
ancestors: [...($component?.ancestors ?? []), instance._component], ancestors: [...($component?.ancestors ?? []), instance._component],
path: [...($component?.path ?? []), id],
}) })
const initialise = (instance, force = false) => { const initialise = (instance, force = false) => {
@ -214,7 +227,8 @@
} }
// Ensure we're processing a new instance // Ensure we're processing a new instance
const instanceKey = Helpers.hashString(JSON.stringify(instance)) const stringifiedInstance = JSON.stringify(instance)
const instanceKey = Helpers.hashString(stringifiedInstance)
if (instanceKey === lastInstanceKey && !force) { if (instanceKey === lastInstanceKey && !force) {
return return
} else { } else {
@ -274,13 +288,54 @@
return missing return missing
}) })
// When considering bindings we can ignore children, so we remove that
// before storing the reference stringified version
const noChildren = JSON.stringify({ ...instance, _children: null })
const bindings = findHBSBlocks(noChildren).map(binding => {
let sanitizedBinding = binding.replace(/\\"/g, '"')
if (isJSBinding(sanitizedBinding)) {
return decodeJSBinding(sanitizedBinding)
} else {
return sanitizedBinding
}
})
// The known context key map is built up at runtime, as changes to keys are
// encountered. We manually seed this to the required action keys as these
// are not encountered at runtime and so need computed in advance.
knownContextKeyMap = generateActionKeyMap(instance, settingsDefinition)
bindingString = bindings.join(" ")
// Run any migrations // Run any migrations
runMigrations(instance, settingsDefinition) runMigrations(instance, settingsDefinition)
// Force an initial enrichment of the new settings // Force an initial enrichment of the new settings
enrichComponentSettings(get(context), settingsDefinitionMap, { enrichComponentSettings(get(context), settingsDefinitionMap)
force: true, }
// Extracts a map of all context keys which are required by action settings
// to provide the functions to evaluate at runtime. This needs done manually
// as the action definitions themselves do not specify bindings for action
// keys, meaning we cannot do this while doing the other normal bindings.
const generateActionKeyMap = (instance, settingsDefinition) => {
let map = {}
settingsDefinition.forEach(setting => {
if (setting.type === "event") {
instance[setting.key]?.forEach(action => {
// We depend on the actual action key
const actionKey = getActionContextKey(action)
if (actionKey) {
map[actionKey] = true
}
// We also depend on any manually declared context keys
getActionDependentContextKeys(action)?.forEach(key => {
map[key] = true
})
})
}
}) })
return map
} }
const runMigrations = (instance, settingsDefinition) => { const runMigrations = (instance, settingsDefinition) => {
@ -381,17 +436,7 @@
} }
// Enriches any string component props using handlebars // Enriches any string component props using handlebars
const enrichComponentSettings = ( const enrichComponentSettings = (context, settingsDefinitionMap) => {
context,
settingsDefinitionMap,
options = { force: false }
) => {
const contextChanged = context.key !== lastContextKey
if (!contextChanged && !options?.force) {
return
}
lastContextKey = context.key
// Record the timestamp so we can reference it after enrichment // Record the timestamp so we can reference it after enrichment
latestUpdateTime = Date.now() latestUpdateTime = Date.now()
const enrichmentTime = latestUpdateTime const enrichmentTime = latestUpdateTime
@ -506,31 +551,46 @@
}) })
} }
const handleContextChange = key => {
// Check if we already know if this key is used
let used = knownContextKeyMap[key]
// If we don't know, check and cache
if (used == null) {
used = bindingString.indexOf(`[${key}]`) !== -1
knownContextKeyMap[key] = used
}
// Enrich settings if we use this key
if (used) {
enrichComponentSettings($context, settingsDefinitionMap)
}
}
// Register an unregister component instance
onMount(() => { onMount(() => {
if ( if ($appStore.isDevApp) {
$appStore.isDevApp && if (!componentStore.actions.isComponentRegistered(id)) {
!componentStore.actions.isComponentRegistered(id) componentStore.actions.registerInstance(id, {
) { component: instance._component,
componentStore.actions.registerInstance(id, { getSettings: () => cachedSettings,
component: instance._component, getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }),
getSettings: () => cachedSettings, getDataContext: () => get(context),
getRawSettings: () => ({ ...staticSettings, ...dynamicSettings }), reload: () => initialise(instance, true),
getDataContext: () => get(context), setEphemeralStyles: styles => (ephemeralStyles = styles),
reload: () => initialise(instance, true), state: store,
setEphemeralStyles: styles => (ephemeralStyles = styles), })
state: store, }
}) return () => {
if (componentStore.actions.isComponentRegistered(id)) {
componentStore.actions.unregisterInstance(id)
}
}
} }
}) })
onDestroy(() => { // Observe changes to context
if ( onMount(() => context.actions.observeChanges(handleContextChange))
$appStore.isDevApp &&
componentStore.actions.isComponentRegistered(id)
) {
componentStore.actions.unregisterInstance(id)
}
})
</script> </script>
{#if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden} {#if constructor && initialSettings && (visible || inSelectedPath) && !builderHidden}

View File

@ -29,7 +29,7 @@
type, type,
quiet, quiet,
disabled, disabled,
size, size: size || "M",
}} }}
/> />
{/each} {/each}

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