Merge branch 'develop' of github.com:Budibase/budibase into rest-pagination

This commit is contained in:
Andrew Kingston 2022-01-04 14:34:09 +00:00
commit db55074b0a
95 changed files with 1975 additions and 437 deletions

View File

@ -16,26 +16,46 @@ jobs:
- name: Pull values.yaml from budibase-infra
run: |
curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ [c3a7a9d12]
curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \
-H 'Accept: application/vnd.github.v3.raw' \
-o values.production.yaml \
-L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.yaml
wc -l values.production.yaml
- name: Get the latest budibase release version if not specifically set
if: ${{ !github.event.inputs.version }}
- name: Get the latest budibase release version
id: version
run: |
sudo apt-get install -y jq
if [ -z "${{ github.event.inputs.version }}" ]; then
release_version=$(cat lerna.json | jq -r '.version')
echo "::set-output name=release_version::$release_version"
else
release_version=${{ github.event.inputs.version }}
fi
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
- uses: peymanmortazavi/eks-helm-deploy@v1
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- name: Deploy to EKS
uses: craftech-io/eks-helm-deploy-action@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS__KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
cluster-name: budibase-eks-production
config-files: values.production.yaml
chart-path: charts/budibase
namespace: budibase
values: appVersion=v${{ github.event.inputs.version || github.steps.version.outputs.release_version }}
values: globals.appVersion=v${{ env.RELEASE_VERSION }}
name: budibase-prod
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
with:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Production Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Cloud."
embed-title: ${{ env.RELEASE_VERSION }}

59
.github/workflows/deploy-preprod.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Budibase Release Preprod
on:
workflow_dispatch:
env:
POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }}
INTERCOM_TOKEN: ${{ secrets.INTERCOM_TOKEN }}
POSTHOG_URL: ${{ secrets.POSTHOG_URL }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: eu-west-1
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
# - name: Pull values.yaml from budibase-infra
# run: |
# curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \ [c3a7a9d12]
# -H 'Accept: application/vnd.github.v3.raw' \
# -o values.preprod.yaml \
# -L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.preprod.yaml
- name: Deploy to Preprod Environment
uses: deliverybot/helm@v1
with:
release: budibase-preprod
namespace: budibase
chart: charts/budibase
token: ${{ github.token }}
values: |
globals:
appVersion: ${{ steps.previoustag.outputs.tag }}
# value-files: >-
# [
# "charts/budibase/values.yaml"
# ]
env:
KUBECONFIG_FILE: '${{ secrets.PREPROD_KUBECONFIG }}'
- name: Discord Webhook Action
uses: tsickert/discord-webhook@v4.0.0
with:
webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
content: "Preprod Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Pre-prod."
embed-title: ${{ env.RELEASE_VERSION }}

View File

@ -9,15 +9,16 @@ jobs:
steps:
- uses: actions/checkout@v2
with:
fetch_depth: 0
- name: Tag and release Docker images (Self Host)
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
# Get latest release version
sudo apt-get install -y jq
release_version=$(cat lerna.json | jq -r '.version')
echo "::set-output name=release_version::$release_version"
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
release_tag=v$release_version
# Pull apps and worker images
@ -25,16 +26,16 @@ jobs:
docker pull budibase/worker:$release_tag
# Tag apps and worker images
docker tag budibase/apps:$release_tag $SELF_HOST_TAG
docker tag budibase/worker:$release_tag $SELF_HOST_TAG
docker tag budibase/apps:$release_tag budibase/apps:$SELFHOST_TAG
docker tag budibase/worker:$release_tag budibase/worker:$SELFHOST_TAG
# Push images
docker push --all-tags budibase/apps
docker push --all-tags budibase/worker
docker push budibase/apps:$SELFHOST_TAG
docker push budibase/worker:$SELFHOST_TAG
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
SELF_HOST_TAG: latest
SELFHOST_TAG: latest
- name: Setup Helm
uses: azure/setup-helm@v1
@ -44,17 +45,25 @@ jobs:
# run: |
# git config user.name "Budibase Helm Bot"
# git config user.email "<>"
# helm package charts/budibase
# mv budibase-${{ env.RELEASE_VERSION }}.tgz docs
# helm repo index docs
# git checkout gh-pages
# mv budibase-${{ github.steps.version.outputs.release_version }}.tgz docs
# git add -A
# git commit -m "Helm Release: ${{ github.steps.version.outputs.release_version }}"
# git commit -m "Helm Release: ${{ env.RELEASE_VERSION }}"
# git push
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# - name: Perform github release
# run: |
# echo release
# env:
# GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
# - name: Deploy
# uses: peaceiris/actions-gh-pages@v3
# with:
# github_token: ${{ secrets.GITHUB_TOKEN }}
# publish_dir: ./public
# full_commit_message: "Helm Release: ${{ env.RELEASE_VERSION }}"
- name: Perform Github Release
uses: softprops/action-gh-release@v1
with:
name: v${{ env.RELEASE_VERSION }}
tag_name: v${{ env.RELEASE_VERSION }}
generate_release_notes: true

View File

@ -3,7 +3,7 @@ name: Budibase Release
on:
push:
branches:
- master
- test
env:
POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }}
@ -59,7 +59,7 @@ jobs:
# - name: Pull values.yaml from budibase-infra
# run: |
# curl -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ [c3a7a9d12]
# curl -H "Authorization: token ${{ secrets.GH_PERSONAL_TOKEN }}" \ [c3a7a9d12]
# -H 'Accept: application/vnd.github.v3.raw' \
# -o values.preprod.yaml \
# -L https://api.github.com/repos/budibase/budibase-infra/contents/kubernetes/values.preprod.yaml
@ -72,10 +72,18 @@ jobs:
# chart: charts/budibase
# token: ${{ github.token }}
# values: |
# globals:
# appVersion: ${{ steps.previoustag.outputs.tag }}
# value-files: >-
# [
# "values.preprod.yaml"
# ]
# # value-files: >-
# # [
# # "charts/budibase/values.yaml"
# # ]
# env:
# KUBECONFIG_FILE: '${{ secrets.PREPROD_KUBECONFIG }}'
# - name: Discord Webhook Action
# uses: tsickert/discord-webhook@v4.0.0
# with:
# webhook-url: ${{ secrets.PROD_DEPLOY_WEBHOOK_URL }}
# content: "Preprod Deployment Complete: ${{ env.RELEASE_VERSION }} deployed to Budibase Pre-prod."
# embed-title: ${{ env.RELEASE_VERSION }}

View File

@ -50,6 +50,14 @@ static_resources:
route:
cluster: app-service
- match:
safe_regex:
google_re2: {}
regex: "/api/.*/export"
route:
timeout: 0s
cluster: app-service
- match: { path: "/api/deploy" }
route:
timeout: 60s

214
i18n/README.fr.md Normal file
View File

@ -0,0 +1,214 @@
<p align="center">
<a href="https://www.budibase.com">
<img alt="Budibase" src="https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" width="60" />
</a>
</p>
<h1 align="center">
Budibase
</h1>
<h3 align="center">
La plateform low-code que vous aimerez utiliser
</h3>
<p align="center">
Budibase est une plateforme low-code open source et c'est la façon la plus facile de créer des outils internes qui améliore la productivité.
</p>
<h3 align="center">
🤖 🎨 🚀
</h3>
<br>
<p align="center">
<img alt="Budibase design ui" src="https://res.cloudinary.com/daog6scxm/image/upload/v1633524049/ui/design-ui-wide-mobile_gdaveq.jpg">
</p>
<p align="center">
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub toutes les releases" src="https://img.shields.io/github/downloads/Budibase/budibase/total">
</a>
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub release (par ordre chronologique)" src="https://img.shields.io/github/v/release/Budibase/budibase">
</a>
<a href="https://twitter.com/intent/follow?screen_name=budibase">
<img src="https://img.shields.io/twitter/follow/budibase?style=social" alt="Suivre @budibase" />
</a>
<img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Code de conduite" />
<a href="https://codecov.io/gh/Budibase/budibase">
<img src="https://codecov.io/gh/Budibase/budibase/graph/badge.svg?token=E8W2ZFXQOH"/>
</a>
</p>
<h3 align="center">
<a href="https://docs.budibase.com/getting-started">Commencer</a>
<span> · </span>
<a href="https://docs.budibase.com">Documentation</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas">Demandes d'amélioration</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/issues">Signaler un bug</a>
<span> · </span>
Support: <a href="https://github.com/Budibase/budibase/discussions">Discussions</a>
</h3>
<br /><br />
## ✨ Fontionnalités
### Construire et déployer un vrai logiciel
Contrairement à d'autres plateformes, avec Budibase vous construisez et déployez des applications one-page. Les applications Budibase sont très perfomantes et peuvent être designées de manière responsive, offrant ainsi à vos utilisateurs une expérience exceptionnelle.
<br /><br />
### Source libre et extensible
Budibase est un logiciel libre - sous licence GPL v3. Cela devrait vous rassurer sur le fait que Budibase sera toujours là. Vous pouvez également coder dans Budibase ou le forker et apporter des modifications à votre guise, ce qui en fera une expérience conviviale pour les développeurs.
<br /><br />
### Importer les données ou partir de zéro
Budibase peut tirer ses données de plusieurs sources, dont MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB ou une API REST. Et contrairement à d'autres plateformes, avec Budibase, vous pouvez partir de zéro et créer des applications métier sans aucune source de données. [Demander une nouvelle source de données](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/data_n1tlhf.png">
</p>
<br /><br />
### Concevoir et créer des applications à l'aide de composants prédéfinis.
Budibase est livré avec des composants joliment conçus et puissants que vous pouvez utiliser comme des blocs de construction pour bâtir votre interface utilisateur. Nous exposons également un grand nombre de vos options de style CSS préférées afin que vous puissiez faire preuve d'une créativité accrue. [Demander un nouveau composant](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<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">
</p>
<br /><br />
### Automatiser les processus, intégrer d'autres outils et se connecter à des webhooks
Gagnez du temps en automatisant les processus manuels et les flux de travail. Qu'il s'agisse de se connecter à des webhooks ou d'automatiser des e-mails, il suffit de dire à Budibase ce qu'il doit faire et de le laisser travailler pour vous. Vous pouvez aisément [créer une nouvelle automatisation pour Budibase ici](https://github.com/Budibase/automations) ou [Demander une nouvelle automatisation](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 />
### Intégration avec vos outils préférés
Budibase s'intègre à un certain nombre d'outils populaires, ce qui vous permet de créer des applications qui s'adaptent parfaitement à votre pile technologique.
<p align="center">
<img alt="Budibase integrations" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/integrations_kc7dqt.png">
</p>
<br /><br />
### Paradis des admins
Budibase est conçu pour évoluer. Avec Budibase, vous pouvez vous auto-héberger sur votre propre infrastructure et gérer globalement les utilisateurs, l'accueil, le SMTP, les applications, les groupes, l'apparence et plus encore. Vous pouvez également fournir aux utilisateurs/groupes un portail d'applications et confier la gestion des utilisateurs au responsable du groupe.
- Regardez la vidéo de promotion: https://youtu.be/xoljVpty_Kw
<br /><br /><br />
## 🏁 Commencer
<img src="https://res.cloudinary.com/daog6scxm/image/upload/v1634808888/logo/deploy_npl9za.png" />
Déployez Budibase en auto-hébergement dans votre infrastructure existante, en utilisant Docker, Kubernetes et Digital Ocean.
Ou utilisez Budibase Cloud si vous n'avez pas besoin de vous auto-héberger, et que vous souhaitez démarrer rapidement.
### [Commencer avec Budibase](https://budibase.com)
<br /><br />
## 🎓 Apprendre Budibase
La documentation Budibase [est ic](https://docs.budibase.com).
<br />
<br /><br />
## 💬 Communauté
Si vous avez une question ou si vous souhaitez discuter avec d'autres utilisateurs de Budibase et rejoindre notre communauté, veuillez vous rendre à l'adresse suivante : [Discussions Github](https://github.com/Budibase/budibase/discussions)
<br /><br /><br />
## ❗ Code de conduite
Budibase s'engage à offrir à chacun une expérience accueillante, diversifiée et exempte de harcèlement. Nous attendons de tous les membres de la communauté Budibase qu'ils se conforment aux principes de notre [**Code de conduite**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md). Merci de le lire.
<br />
<br /><br />
## 🙌 Contribuer à Budibase
Qu'il s'agisse d'ouvrir un rapport de bug ou de créer une Pull request, toute contribution est appréciée et bienvenue. Si vous envisagez de mettre en œuvre une nouvelle fonctionnalité ou de modifier l'API, veuillez d'abord créer un Issue. Nous pourrons ainsi nous assurer que votre travail n'est pas vain.
### Vous ne savez pas par où commencer ?
Un bon endroit pour commencer à contribuer, c'est ici : [Projets en cours](https://github.com/Budibase/budibase/projects/22).
### Comment le repo est-il organisé ?
Budibase est une monorepo gérée par lerna. Lerna gère la construction et la publication des paquets de Budibase. Voici, à un haut niveau, les paquets qui composent Budibase.
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - contient le code pour l'application svelte côté client du budibase builder.
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - Un module qui s'exécute dans le navigateur et qui est chargé de lire les définitions JSON et de créer des applications web vivantes à partir de celles-ci..
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server) - Le serveur budibase. Cette application Koa est responsable de servir le JS pour les applications builder et budibase, ainsi que de fournir l'API pour l'interaction avec la base de données et le système de fichiers.
Pour plus d'informations, voir [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md)
<br /><br />
## 📝 Licence
Budibase est open source, sous licence de [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). Les bibliothèques du client et des composants sont sous licence [MPL](https://directory.fsf.org/wiki/License:MPL-2.0) - afin que les applications que vous créez puissent être utilisées sous licence comme vous le souhaitez.
<br /><br />
## ⭐ Stargazers dans le temps
[![Stargazers dans le temps](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase)
Si vous rencontrez des problèmes entre les mises à jour du builder, veuillez utiliser le guide suivant [ici](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting) pour nettoyer votre environnement.
<br /><br />
## Contributeurs ✨
Merci à ces personnes merveilleuses ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="http://martinmck.com"><img src="https://avatars1.githubusercontent.com/u/11256663?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Martin McKeaveney</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Tests">⚠️</a> <a href="#infra-shogunpurple" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="http://www.michaeldrury.co.uk/"><img src="https://avatars2.githubusercontent.com/u/4407001?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Drury</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Tests">⚠️</a> <a href="#infra-mike12345567" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/aptkingston"><img src="https://avatars3.githubusercontent.com/u/9075550?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrew Kingston</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Tests">⚠️</a> <a href="#design-aptkingston" title="Design">🎨</a></td>
<td align="center"><a href="https://budibase.com/"><img src="https://avatars3.githubusercontent.com/u/3524181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Shanks</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/kevmodrome"><img src="https://avatars3.githubusercontent.com/u/534488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kevin Åberg Kultalahti</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Tests">⚠️</a></td>
<td align="center"><a href="https://www.budibase.com/"><img src="https://avatars2.githubusercontent.com/u/49767913?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Code">💻</a> <a href="#content-joebudi" title="Content">🖋</a> <a href="#design-joebudi" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/PClmnt"><img src="https://avatars.githubusercontent.com/u/5665926?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Peter Clement</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars1.githubusercontent.com/u/36074859?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Conor_Mack</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/pngwn"><img src="https://avatars1.githubusercontent.com/u/12937446?v=4?s=100" width="100px;" alt=""/><br /><sub><b>pngwn</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/HugoLd"><img src="https://avatars0.githubusercontent.com/u/26521848?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HugoLd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=HugoLd" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/victoriasloan"><img src="https://avatars.githubusercontent.com/u/9913651?v=4?s=100" width="100px;" alt=""/><br /><sub><b>victoriasloan</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=victoriasloan" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/yashank09"><img src="https://avatars.githubusercontent.com/u/37672190?v=4?s=100" width="100px;" alt=""/><br /><sub><b>yashank09</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=yashank09" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/SOVLOOKUP"><img src="https://avatars.githubusercontent.com/u/53158137?v=4?s=100" width="100px;" alt=""/><br /><sub><b>SOVLOOKUP</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=SOVLOOKUP" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/seoulaja"><img src="https://avatars.githubusercontent.com/u/15101654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>seoulaja</b></sub></a><br /><a href="#translation-seoulaja" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/mslourens"><img src="https://avatars.githubusercontent.com/u/1907152?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maurits Lourens</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="#infra-Rory-Powell" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
Ce projet suit la spécification [all-contributors](https://allcontributors.org/docs/fr/overview). Les contributions de toute nature sont les bienvenues !

View File

@ -1,5 +1,5 @@
{
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"npmClient": "yarn",
"packages": [
"packages/*"

View File

@ -23,8 +23,8 @@
"build": "lerna run build",
"publishdev": "lerna run publishdev",
"publishnpm": "yarn build && lerna publish --force-publish",
"release": "yarn build && lerna publish patch --yes --force-publish",
"release:develop": "yarn build && lerna publish prerelease --yes --force-publish --dist-tag develop",
"release": "lerna publish patch --yes --force-publish",
"release:develop": "lerna publish prerelease --yes --force-publish --dist-tag develop",
"restore": "yarn run clean && yarn run bootstrap && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore",

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/auth",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js",
"author": "Budibase",

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",

View File

@ -21,16 +21,8 @@
}
visible = false
}
function handleKey(e) {
if (visible && e.key === "Escape") {
hide()
}
}
</script>
<svelte:window on:keydown={handleKey} />
{#if visible}
<Portal>
<section class:fillWidth class="drawer" transition:slide>

View File

@ -4,6 +4,7 @@
import CellRenderer from "./CellRenderer.svelte"
import SelectEditRenderer from "./SelectEditRenderer.svelte"
import { cloneDeep } from "lodash"
import { deepGet } from "../utils/helpers"
/**
* The expected schema is our normal couch schemas for our tables.
@ -318,7 +319,7 @@
{customRenderers}
{row}
schema={schema[field]}
value={row[field]}
value={deepGet(row, field)}
on:clickrelationship
>
<slot />

View File

@ -77,3 +77,6 @@ export { default as clickOutside } from "./Actions/click_outside"
// Stores
export { notifications, createNotificationStore } from "./Stores/notifications"
// Utils
export * from "./utils/helpers"

View File

@ -6,3 +6,61 @@ export const generateID = () => {
}
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)
/**
* Gets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c".
* Exact matches of keys with dots in them take precedence over nested keys of
* the same path - e.g. getting "a.b" from { "a.b": "foo", a: { b: "bar" } }
* will return "foo" over "bar".
* @param obj the object
* @param key the key
* @return {*|null} the value or null if a value was not found for this key
*/
export const deepGet = (obj, key) => {
if (!obj || !key) {
return null
}
if (Object.prototype.hasOwnProperty.call(obj, key)) {
return obj[key]
}
const split = key.split(".")
for (let i = 0; i < split.length; i++) {
obj = obj?.[split[i]]
}
return obj
}
/**
* Sets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c".
* Exact matches of keys with dots in them take precedence over nested keys of
* the same path - e.g. setting "a.b" of { "a.b": "foo", a: { b: "bar" } }
* will override the value "foo" rather than "bar".
* If a deep path is specified and the parent keys don't exist then these will
* be created.
* @param obj the object
* @param key the key
* @param value the value
*/
export const deepSet = (obj, key, value) => {
if (!obj || !key) {
return
}
if (Object.prototype.hasOwnProperty.call(obj, key)) {
obj[key] = value
return
}
const split = key.split(".")
for (let i = 0; i < split.length - 1; i++) {
const nextKey = split[i]
if (obj && obj[nextKey] == null) {
obj[nextKey] = {}
}
obj = obj?.[nextKey]
}
if (!obj) {
return
}
obj[split[split.length - 1]] = value
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -65,10 +65,10 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.0.23",
"@budibase/client": "^1.0.23",
"@budibase/bbui": "^1.0.27-alpha.1",
"@budibase/client": "^1.0.27-alpha.1",
"@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^1.0.23",
"@budibase/string-templates": "^1.0.27-alpha.1",
"@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1",

View File

@ -127,18 +127,37 @@ const searchComponentTree = (rootComponent, matchComponent) => {
}
/**
* Searches a component's definition for a setting matching a certin predicate.
* Searches a component's definition for a setting matching a certain predicate.
* These settings are cached because they cannot change at run time.
*/
let componentSettingCache = {}
export const getComponentSettings = componentType => {
const def = store.actions.components.getDefinition(componentType)
if (!def) {
if (!componentType) {
return []
}
let settings = def.settings?.filter(setting => !setting.section) ?? []
// Ensure whole component name is used
if (!componentType.startsWith("@budibase")) {
componentType = `@budibase/standard-components/${componentType}`
}
// Check if we have cached this type already
if (componentSettingCache[componentType]) {
return componentSettingCache[componentType]
}
// Otherwise get the settings and cache them
const def = store.actions.components.getDefinition(componentType)
let settings = []
if (def) {
settings = def.settings?.filter(setting => !setting.section) ?? []
def.settings
?.filter(setting => setting.section)
.forEach(section => {
settings = settings.concat(section.settings || [])
})
}
componentSettingCache[componentType] = settings
return settings
}

View File

@ -5,7 +5,7 @@ import {
findComponent,
findComponentPath,
getComponentSettings,
} from "./storeUtils"
} from "./componentUtils"
import { store } from "builderStore"
import { queries as queriesStores, tables as tablesStore } from "stores/backend"
import {
@ -15,6 +15,11 @@ import {
encodeJSBinding,
} from "@budibase/string-templates"
import { TableNames } from "../constants"
import {
convertJSONSchemaToTableSchema,
getJSONArrayDatasourceSchema,
} from "./jsonUtils"
import ActionDefinitions from "components/design/PropertiesPanel/PropertyControls/ButtonActionEditor/manifest.json"
// Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
@ -186,6 +191,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
let schema
let table
let readablePrefix
let runtimeSuffix = context.suffix
@ -209,8 +215,17 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
const info = getSchemaForDatasource(asset, datasource)
schema = info.schema
table = info.table
// For JSON arrays, use the array name as the readable prefix.
// Otherwise use the table name
if (datasource.type === "jsonarray") {
const split = datasource.label.split(".")
readablePrefix = split[split.length - 1]
} else {
readablePrefix = info.table?.name
}
}
if (!schema) {
return
}
@ -229,7 +244,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
const fieldSchema = schema[key]
// Make safe runtime binding
const runtimeBinding = `${safeComponentId}.${makePropSafe(key)}`
const safeKey = key.split(".").map(makePropSafe).join(".")
const runtimeBinding = `${safeComponentId}.${safeKey}`
// Optionally use a prefix with readable bindings
let readableBinding = component._instanceName
@ -247,6 +263,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
// 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,
})
})
})
@ -339,6 +357,36 @@ const getUrlBindings = asset => {
}))
}
/**
* Gets all bindable properties exposed in a button actions flow up until
* the specified action ID.
*/
export const getButtonContextBindings = (actions, actionId) => {
// Get the steps leading up to this value
const index = actions?.findIndex(action => action.id === actionId)
if (index == null || index === -1) {
return []
}
const prevActions = actions.slice(0, index)
// Generate bindings for any steps which provide context
let bindings = []
prevActions.forEach((action, idx) => {
const def = ActionDefinitions.actions.find(
x => x.name === action["##eventHandlerType"]
)
if (def.context) {
def.context.forEach(contextValue => {
bindings.push({
readableBinding: `Action ${idx + 1}.${contextValue.label}`,
runtimeBinding: `actions.${idx}.${contextValue.value}`,
})
})
}
})
return bindings
}
/**
* Gets a schema for a datasource object.
*/
@ -347,16 +395,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
if (datasource) {
const { type } = datasource
const tables = get(tablesStore).list
// Determine the source table from the datasource type
// Determine the entity which backs this datasource.
// "provider" datasources are those targeting another data provider
if (type === "provider") {
const component = findComponent(asset.props, datasource.providerId)
const source = getDatasourceForProvider(asset, component)
return getSchemaForDatasource(asset, source, isForm)
} else if (type === "query") {
}
// "query" datasources are those targeting non-plus datasources or
// custom queries
else if (type === "query") {
const queries = get(queriesStores).list
table = queries.find(query => query._id === datasource._id)
} else if (type === "field") {
}
// "field" datasources are array-like fields of rows, such as attachments
// or multi-select fields
else if (type === "field") {
table = { name: datasource.fieldName }
const { fieldType } = datasource
if (fieldType === "attachment") {
@ -375,12 +433,22 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
},
}
}
} else {
const tables = get(tablesStore).list
}
// "jsonarray" datasources are arrays inside JSON fields
else if (type === "jsonarray") {
table = tables.find(table => table._id === datasource.tableId)
let tableSchema = table?.schema
schema = getJSONArrayDatasourceSchema(tableSchema, datasource)
}
// Otherwise we assume we're targeting an internal table or a plus
// datasource, and we can treat it as a table with a schema
else {
table = tables.find(table => table._id === datasource.tableId)
}
// Determine the schema from the table if not already determined
// Determine the schema from the backing entity if not already determined
if (table && !schema) {
if (type === "view") {
schema = cloneDeep(table.views?.[datasource.name]?.schema)
@ -397,6 +465,26 @@ export const getSchemaForDatasource = (asset, datasource, isForm = false) => {
}
}
// Check for any JSON fields so we can add any top level properties
if (schema) {
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "json") {
const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
squashObjects: true,
})
Object.keys(jsonSchema).forEach(jsonKey => {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}
})
}
})
schema = { ...schema, ...jsonAdditions }
}
// Add _id and _rev fields for certain types
if (schema && !isForm && ["table", "link"].includes(datasource.type)) {
schema["_id"] = { type: "string" }
@ -450,15 +538,58 @@ const buildFormSchema = component => {
return schema
}
/**
* Returns an array of the keys of any state variables which are set anywhere
* in the app.
*/
export const getAllStateVariables = () => {
// Get all component containing assets
let allAssets = []
allAssets = allAssets.concat(get(store).layouts || [])
allAssets = allAssets.concat(get(store).screens || [])
// Find all button action settings in all components
let eventSettings = []
allAssets.forEach(asset => {
findAllMatchingComponents(asset.props, component => {
const settings = getComponentSettings(component._component)
settings
.filter(setting => setting.type === "event")
.forEach(setting => {
eventSettings.push(component[setting.key])
})
})
})
// Extract all state keys from any "update state" actions in each setting
let bindingSet = new Set()
eventSettings.forEach(setting => {
if (!Array.isArray(setting)) {
return
}
setting.forEach(action => {
if (
action["##eventHandlerType"] === "Update State" &&
action.parameters?.type === "set" &&
action.parameters?.key &&
action.parameters?.value
) {
bindingSet.add(action.parameters.key)
}
})
})
return Array.from(bindingSet)
}
/**
* Recurses the input object to remove any instances of bindings.
*/
export function removeBindings(obj) {
export const removeBindings = (obj, replacement = "Invalid binding") => {
for (let [key, value] of Object.entries(obj)) {
if (value && typeof value === "object") {
obj[key] = removeBindings(value)
obj[key] = removeBindings(value, replacement)
} else if (typeof value === "string") {
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, "Invalid binding")
obj[key] = value.replace(CAPTURE_HBS_TEMPLATE, replacement)
}
}
return obj
@ -468,8 +599,8 @@ export function removeBindings(obj) {
* When converting from readable to runtime it can sometimes add too many square brackets,
* this makes sure that doesn't happen.
*/
function shouldReplaceBinding(currentValue, from, convertTo) {
if (!currentValue?.includes(from)) {
const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => {
if (!currentValue?.includes(convertFrom)) {
return false
}
if (convertTo === "readableBinding") {
@ -478,7 +609,7 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
// remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
// this makes sure it is detected
const noSpaces = currentValue.replace(/\s+/g, "")
const fromNoSpaces = from.replace(/\s+/g, "")
const fromNoSpaces = convertFrom.replace(/\s+/g, "")
const invalids = [
`[${fromNoSpaces}]`,
`"${fromNoSpaces}"`,
@ -487,14 +618,21 @@ function shouldReplaceBinding(currentValue, from, convertTo) {
return !invalids.find(invalid => noSpaces?.includes(invalid))
}
function replaceBetween(string, start, end, replacement) {
/**
* Utility function which replaces a string between given indices.
*/
const replaceBetween = (string, start, end, replacement) => {
return string.substring(0, start) + replacement + string.substring(end)
}
/**
* utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
* Utility function for the readableToRuntimeBinding and runtimeToReadableBinding.
*/
function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
const bindingReplacement = (
bindableProperties,
textWithBindings,
convertTo
) => {
// Decide from base64 if using JS
const isJS = isJSBinding(textWithBindings)
if (isJS) {
@ -559,14 +697,17 @@ function bindingReplacement(bindableProperties, textWithBindings, convertTo) {
* Extracts a component ID from a handlebars expression setting of
* {{ literal [componentId] }}
*/
function extractLiteralHandlebarsID(value) {
const extractLiteralHandlebarsID = value => {
return value?.match(/{{\s*literal\s*\[+([^\]]+)].*}}/)?.[1]
}
/**
* Converts a readable data binding into a runtime data binding
*/
export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
export const readableToRuntimeBinding = (
bindableProperties,
textWithBindings
) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
@ -577,56 +718,13 @@ export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
/**
* Converts a runtime data binding into a readable data binding
*/
export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
export const runtimeToReadableBinding = (
bindableProperties,
textWithBindings
) => {
return bindingReplacement(
bindableProperties,
textWithBindings,
"readableBinding"
)
}
/**
* Returns an array of the keys of any state variables which are set anywhere
* in the app.
*/
export const getAllStateVariables = () => {
let allComponents = []
// Find all onClick settings in all layouts
get(store).layouts.forEach(layout => {
const components = findAllMatchingComponents(
layout.props,
c => c.onClick != null
)
allComponents = allComponents.concat(components || [])
})
// Find all onClick settings in all screens
get(store).screens.forEach(screen => {
const components = findAllMatchingComponents(
screen.props,
c => c.onClick != null
)
allComponents = allComponents.concat(components || [])
})
// Add state bindings for all state actions
let bindingSet = new Set()
allComponents.forEach(component => {
if (!Array.isArray(component.onClick)) {
return
}
component.onClick.forEach(action => {
if (
action["##eventHandlerType"] === "Update State" &&
action.parameters?.type === "set" &&
action.parameters?.key &&
action.parameters?.value
) {
bindingSet.add(action.parameters.key)
}
})
})
return Array.from(bindingSet)
}

View File

@ -4,7 +4,7 @@ import { getHostingStore } from "./store/hosting"
import { getThemeStore } from "./store/theme"
import { derived, writable } from "svelte/store"
import { FrontendTypes, LAYOUT_NAMES } from "../constants"
import { findComponent } from "./storeUtils"
import { findComponent } from "./componentUtils"
export const store = getFrontendStore()
export const automationStore = getAutomationStore()

View File

@ -0,0 +1,121 @@
/**
* Gets the schema for a datasource which is targeting a JSON array, including
* nested JSON arrays. The returned schema is a squashed, table-like schema
* which is fully compatible with the rest of the platform.
* @param tableSchema the full schema for the table this JSON field is in
* @param datasource the datasource configuration
*/
export const getJSONArrayDatasourceSchema = (tableSchema, datasource) => {
let jsonSchema = tableSchema
let keysToSchema = []
// If we are already deep inside a JSON field then we need to account
// for the keys that brought us here, so we can get the schema for the
// depth we're actually at
if (datasource.prefixKeys) {
keysToSchema = datasource.prefixKeys.concat(["schema"])
}
// We parse the label of the datasource to work out where we are inside
// the structure. We can use this to know which part of the schema
// is available underneath our current position.
keysToSchema = keysToSchema.concat(datasource.label.split(".").slice(2))
// Follow the JSON key path until we reach the schema for the level
// we are at
for (let i = 0; i < keysToSchema.length; i++) {
jsonSchema = jsonSchema?.[keysToSchema[i]]
if (jsonSchema?.schema) {
jsonSchema = jsonSchema.schema
}
}
// We need to convert the JSON schema into a more typical looking table
// schema so that it works with the rest of the platform
return convertJSONSchemaToTableSchema(jsonSchema, {
squashObjects: true,
prefixKeys: keysToSchema,
})
}
/**
* Converts a JSON field schema (or sub-schema of a nested field) into a schema
* that looks like a typical table schema.
* @param jsonSchema the JSON field schema or sub-schema
* @param options
*/
export const convertJSONSchemaToTableSchema = (jsonSchema, options) => {
if (!jsonSchema) {
return null
}
// Add default options
options = { squashObjects: false, prefixKeys: null, ...options }
// Immediately strip the wrapper schema for objects, or wrap shallow values in
// a fake "value" schema
if (jsonSchema.schema) {
jsonSchema = jsonSchema.schema
} else {
jsonSchema = {
value: jsonSchema,
}
}
// Extract all deep keys from the schema
const keys = extractJSONSchemaKeys(jsonSchema, options.squashObjects)
// Form a full schema from all the deep schema keys
let schema = {}
keys.forEach(({ key, type }) => {
schema[key] = { type, name: key, prefixKeys: options.prefixKeys }
})
return schema
}
/**
* Recursively builds paths to all leaf fields in a JSON field schema structure,
* stopping when leaf nodes or arrays are reached.
* @param jsonSchema the JSON field schema or sub-schema
* @param squashObjects whether to recurse into objects or not
*/
const extractJSONSchemaKeys = (jsonSchema, squashObjects = false) => {
if (!jsonSchema || !Object.keys(jsonSchema).length) {
return []
}
// Iterate through every schema key
let keys = []
Object.keys(jsonSchema).forEach(key => {
const type = jsonSchema[key].type
// If we encounter an object, then only go deeper if we want to squash
// object paths
if (type === "json" && squashObjects) {
// Find all keys within this objects schema
const childKeys = extractJSONSchemaKeys(
jsonSchema[key].schema,
squashObjects
)
// Append child paths onto the current path to build the full path
keys = keys.concat(
childKeys.map(childKey => ({
key: `${key}.${childKey.key}`,
type: childKey.type,
}))
)
}
// Otherwise add this as a lead node.
// We transform array types from "array" into "jsonarray" here to avoid
// confusion with the existing "array" type that represents a multi-select.
else {
keys.push({
key,
type: type === "array" ? "jsonarray" : type,
})
}
})
return keys
}

View File

@ -0,0 +1,56 @@
import { FIELDS } from "constants/backend"
function baseConversion(type) {
if (type === "string") {
return {
type: FIELDS.STRING.type,
}
} else if (type === "boolean") {
return {
type: FIELDS.BOOLEAN.type,
}
} else if (type === "number") {
return {
type: FIELDS.NUMBER.type,
}
}
}
function recurse(schemaLevel = {}, objectLevel) {
if (!objectLevel) {
return null
}
const baseType = typeof objectLevel
if (baseType !== "object") {
return baseConversion(baseType)
}
for (let [key, value] of Object.entries(objectLevel)) {
const type = typeof value
// check array first, since arrays are objects
if (Array.isArray(value)) {
const schema = recurse(schemaLevel[key], value[0])
if (schema) {
schemaLevel[key] = {
type: FIELDS.ARRAY.type,
schema,
}
}
} else if (type === "object") {
const schema = recurse(schemaLevel[key], objectLevel[key])
if (schema) {
schemaLevel[key] = schema
}
} else {
schemaLevel[key] = baseConversion(type)
}
}
if (!schemaLevel.type) {
return { type: FIELDS.JSON.type, schema: schemaLevel }
} else {
return schemaLevel
}
}
export function generate(object) {
return recurse({}, object).schema
}

View File

@ -26,7 +26,7 @@ import {
findAllMatchingComponents,
findComponent,
getComponentSettings,
} from "../storeUtils"
} from "../componentUtils"
import { uuid } from "../uuid"
import { removeBindings } from "../dataBinding"
@ -329,12 +329,12 @@ export const getFrontendStore = () => {
},
components: {
select: component => {
if (!component) {
const asset = get(currentAsset)
if (!asset || !component) {
return
}
// If this is the root component, select the asset instead
const asset = get(currentAsset)
const parent = findComponentParent(asset.props, component._id)
if (parent == null) {
const state = get(store)
@ -537,7 +537,7 @@ export const getFrontendStore = () => {
// immediately need to remove bindings, currently these aren't valid when pasted
if (!cut && !preserveBindings) {
state.componentToPaste = removeBindings(state.componentToPaste)
state.componentToPaste = removeBindings(state.componentToPaste, "")
}
// Clone the component to paste

View File

@ -137,6 +137,7 @@ const fieldTypeToComponentMap = {
datetime: "datetimefield",
attachment: "attachmentfield",
link: "relationshipfield",
json: "jsonfield",
}
export function makeDatasourceFormComponents(datasource) {
@ -146,7 +147,7 @@ export function makeDatasourceFormComponents(datasource) {
fields.forEach(field => {
const fieldSchema = schema[field]
// skip autocolumns
if (fieldSchema.autocolumn) {
if (fieldSchema.autocolumn || fieldSchema.nestedJSON) {
return
}
const fieldType =

View File

@ -6,16 +6,20 @@
Toggle,
TextArea,
Multiselect,
Label,
} from "@budibase/bbui"
import Dropzone from "components/common/Dropzone.svelte"
import { capitalise } from "helpers"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import Editor from "../../integration/QueryEditor.svelte"
export let defaultValue
export let meta
export let value = defaultValue || (meta.type === "boolean" ? false : "")
export let readonly
$: stringVal =
typeof value === "object" ? JSON.stringify(value, null, 2) : value
$: type = meta?.type
$: label = meta.name ? capitalise(meta.name) : ""
</script>
@ -40,6 +44,14 @@
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
{:else if type === "longform"}
<TextArea {label} bind:value />
{:else if type === "json"}
<Label>{label}</Label>
<Editor
editorHeight="250"
mode="json"
on:change={({ detail }) => (value = detail.value)}
value={stringVal}
/>
{:else}
<Input
{label}

View File

@ -9,6 +9,7 @@
DatePicker,
ModalContent,
Context,
Modal,
notifications,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
@ -32,12 +33,14 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula"
import { getContext } from "svelte"
import JSONSchemaModal from "./JSONSchemaModal.svelte"
const AUTO_TYPE = "auto"
const FORMULA_TYPE = FIELDS.FORMULA.type
const LINK_TYPE = FIELDS.LINK.type
const STRING_TYPE = FIELDS.STRING.type
const NUMBER_TYPE = FIELDS.NUMBER.type
const JSON_TYPE = FIELDS.JSON.type
const DATE_TYPE = FIELDS.DATETIME.type
const dispatch = createEventDispatcher()
@ -64,6 +67,7 @@
let confirmDeleteDialog
let deletion
let deleteColName
let jsonSchemaModal
$: checkConstraints(field)
$: required = !!field?.constraints?.presence || primaryDisplay
@ -79,10 +83,14 @@
// used to select what different options can be displayed for column type
$: canBeSearched =
field.type !== LINK_TYPE &&
field.type !== JSON_TYPE &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.CREATED_BY &&
field.subtype !== AUTO_COLUMN_SUB_TYPES.UPDATED_BY &&
field.type !== FORMULA_TYPE
$: canBeDisplay = field.type !== LINK_TYPE && field.type !== AUTO_TYPE
$: canBeDisplay =
field.type !== LINK_TYPE &&
field.type !== AUTO_TYPE &&
field.type !== JSON_TYPE
$: canBeRequired =
field.type !== LINK_TYPE && !uneditable && field.type !== AUTO_TYPE
$: relationshipOptions = getRelationshipOptions(field)
@ -176,6 +184,10 @@
}
}
function openJsonSchemaEditor() {
jsonSchemaModal.show()
}
function confirmDelete() {
confirmDeleteDialog.show()
deletion = true
@ -430,6 +442,10 @@
getOptionLabel={option => option[1].name}
getOptionValue={option => option[0]}
/>
{:else if field.type === JSON_TYPE}
<Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button
>
{/if}
<div slot="footer">
@ -438,6 +454,16 @@
{/if}
</div>
</ModalContent>
<Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal
schema={field.schema}
json={field.json}
on:save={({ detail }) => {
field.schema = detail.schema
field.json = detail.json
}}
/>
</Modal>
<ConfirmDialog
bind:this={confirmDeleteDialog}
okText="Delete Column"

View File

@ -0,0 +1,129 @@
<script>
import Editor from "components/integration/QueryEditor.svelte"
import {
ModalContent,
Tabs,
Tab,
Button,
Input,
Select,
Body,
Layout,
} from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import { FIELDS } from "constants/backend"
import { generate } from "builderStore/schemaGenerator"
export let schema = {}
export let json
let dispatcher = createEventDispatcher()
let mode = "Form"
let fieldCount = 0
let fieldKeys = {},
fieldTypes = {}
let keyValueOptions = [
{ label: "String", value: FIELDS.STRING.type },
{ label: "Number", value: FIELDS.NUMBER.type },
{ label: "Boolean", value: FIELDS.BOOLEAN.type },
{ label: "Object", value: FIELDS.JSON.type },
{ label: "Array", value: FIELDS.ARRAY.type },
]
let invalid = false
async function onJsonUpdate({ detail }) {
const input = detail.value
json = input
try {
// check json valid first
let inputJson = JSON.parse(input)
schema = generate(inputJson)
updateCounts()
invalid = false
} catch (err) {
// json not currently valid
invalid = true
}
}
function updateCounts() {
if (!schema) {
schema = {}
}
let i = 0
for (let [key, value] of Object.entries(schema)) {
fieldKeys[i] = key
fieldTypes[i] = value.type
i++
}
fieldCount = i
}
function saveSchema() {
for (let i of Object.keys(fieldKeys)) {
const key = fieldKeys[i]
// they were added to schema, rather than generated
if (!schema[key]) {
schema[key] = {
type: fieldTypes[i],
}
}
}
dispatcher("save", { schema, json })
}
onMount(() => {
updateCounts()
})
</script>
<ModalContent
title={"JSON Schema Editor"}
confirmText="Save Column"
onConfirm={saveSchema}
bind:disabled={invalid}
size="L"
>
<Tabs selected={mode} noPadding>
<Tab title="Form">
{#each Array(fieldCount) as _, i}
<div class="horizontal">
<Input outline label="Key" bind:value={fieldKeys[i]} />
<Select
label="Type"
options={keyValueOptions}
bind:value={fieldTypes[i]}
getOptionValue={field => field.value}
getOptionLabel={field => field.label}
/>
</div>
{/each}
<div class:add-field-btn={fieldCount !== 0}>
<Button primary text on:click={() => fieldCount++}>Add Field</Button>
</div>
</Tab>
<Tab title="JSON">
<Layout noPadding gap="XS">
<Body size="S">
Provide a sample JSON blob here to automatically determine your
schema.
</Body>
<Editor mode="json" on:change={onJsonUpdate} value={json} />
</Layout>
</Tab>
</Tabs>
</ModalContent>
<style>
.horizontal {
display: grid;
grid-template-columns: 30% 1fr;
grid-gap: var(--spacing-s);
align-items: center;
}
.add-field-btn {
margin-top: var(--spacing-xl);
}
</style>

View File

@ -14,7 +14,7 @@
notifications,
} from "@budibase/bbui"
import ErrorSVG from "assets/error.svg?raw"
import { findComponent, findComponentPath } from "builderStore/storeUtils"
import { findComponent, findComponentPath } from "builderStore/componentUtils"
let iframe
let layout
@ -69,15 +69,7 @@
previewDevice: $store.previewDevice,
messagePassing: $store.clientFeatures.messagePassing,
}
// Saving pages and screens to the DB causes them to have _revs.
// These revisions change every time a save happens and causes
// these reactive statements to fire, even though the actual
// definition hasn't changed.
// By deleting all _rev properties we can avoid this and increase
// performance.
$: json = JSON.stringify(previewData)
$: strippedJson = json.replace(/"_rev":\s*"[^"]+"/g, `"_rev":""`)
// Update the iframe with the builder info to render the correct preview
const refreshContent = message => {
@ -87,7 +79,7 @@
}
// Refresh the preview when required
$: refreshContent(strippedJson)
$: refreshContent(json)
function receiveMessage(message) {
const handlers = {
@ -102,7 +94,7 @@
if (!$store.clientFeatures.intelligentLoading) {
loading = false
}
refreshContent(strippedJson)
refreshContent(json)
},
[MessageTypes.ERROR]: event => {
// Catch any app errors

View File

@ -43,7 +43,8 @@
"attachmentfield",
"relationshipfield",
"daterangepicker",
"multifieldselect"
"multifieldselect",
"jsonfield"
]
},
{
@ -73,6 +74,7 @@
"heading",
"text",
"button",
"tag",
"divider",
"image",
"backgroundimage",

View File

@ -49,6 +49,7 @@ export default `
try {
parsed = JSON.parse(event.data)
} catch (error) {
console.error("Client received invalid JSON")
// Ignore
}
if (!parsed) {

View File

@ -2,7 +2,7 @@
import { get } from "svelte/store"
import { store, currentAsset } from "builderStore"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { findComponentParent } from "builderStore/storeUtils"
import { findComponentParent } from "builderStore/componentUtils"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
export let component

View File

@ -1,6 +1,6 @@
import { writable, get } from "svelte/store"
import { store as frontendStore } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils"
import { findComponentPath } from "builderStore/componentUtils"
export const DropEffect = {
MOVE: "move",

View File

@ -63,7 +63,14 @@
// If no specific value is depended upon, check if a value exists at all
// for the dependent setting
if (dependantValue == null) {
return !isEmpty(componentInstance[dependantSetting])
const currentValue = componentInstance[dependantSetting]
if (currentValue === false) {
return false
}
if (currentValue === true) {
return true
}
return !isEmpty(currentValue)
}
// Otherwise check the value matches

View File

@ -9,8 +9,9 @@
ActionMenu,
MenuItem,
} from "@budibase/bbui"
import { getAvailableActions } from "./actions"
import { getAvailableActions } from "./index"
import { generate } from "shortid"
import { getButtonContextBindings } from "builderStore/dataBinding"
const flipDurationMs = 150
const EVENT_TYPE_KEY = "##eventHandlerType"
@ -19,7 +20,16 @@
export let actions
export let bindings = []
// dndzone needs an id on the array items, so this adds some temporary ones.
let selectedAction = actions?.length ? actions[0] : null
// These are ephemeral bindings which only exist while executing actions
$: buttonContextBindings = getButtonContextBindings(
actions,
selectedAction?.id
)
$: allBindings = buttonContextBindings.concat(bindings)
// Assign a unique ID to each action
$: {
if (actions) {
actions.forEach(action => {
@ -30,8 +40,6 @@
}
}
let selectedAction = actions?.length ? actions[0] : null
$: selectedActionComponent =
selectedAction &&
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_KEY])?.component
@ -122,7 +130,7 @@
<svelte:component
this={selectedActionComponent}
parameters={selectedAction.parameters}
{bindings}
bindings={allBindings}
/>
</div>
{/if}

View File

@ -2,7 +2,7 @@
import { ActionButton, Button, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { notifications } from "@budibase/bbui"
import EventEditor from "./EventEditor.svelte"
import ButtonActionDrawer from "./ButtonActionDrawer.svelte"
import { automationStore } from "builderStore"
import { cloneDeep } from "lodash/fp"
@ -67,7 +67,7 @@
Define what actions to run.
</svelte:fragment>
<Button cta slot="buttons" on:click={saveEventData}>Save</Button>
<EventEditor
<ButtonActionDrawer
slot="body"
bind:actions={tmpValue}
eventType={name}

View File

@ -0,0 +1,152 @@
<script>
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { tables } from "stores/backend"
import {
getContextProviderComponents,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import SaveFields from "./SaveFields.svelte"
export let parameters
export let bindings = []
$: formComponents = getContextProviderComponents(
$currentAsset,
$store.selectedComponentId,
"form"
)
$: schemaComponents = getContextProviderComponents(
$currentAsset,
$store.selectedComponentId,
"schema"
)
$: providerOptions = getProviderOptions(formComponents, schemaComponents)
$: schemaFields = getSchemaFields($currentAsset, parameters?.tableId)
$: tableOptions = $tables.list || []
// 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)
}
// Gets options for valid context keys which provide valid data to submit
const getProviderOptions = (formComponents, schemaComponents) => {
const formContexts = formComponents.map(component => ({
component,
context: extractComponentContext(component, "form"),
}))
const schemaContexts = schemaComponents.map(component => ({
component,
context: extractComponentContext(component, "schema"),
}))
const allContexts = formContexts.concat(schemaContexts)
return allContexts.map(({ component, context }) => {
let runtimeBinding = component._id
if (context.suffix) {
runtimeBinding += `-${context.suffix}`
}
return {
label: component._instanceName,
value: runtimeBinding,
}
})
}
const getSchemaFields = (asset, tableId) => {
const { schema } = getSchemaForDatasource(asset, { type: "table", tableId })
delete schema._id
delete schema._rev
return Object.values(schema || {})
}
const onFieldsChanged = e => {
parameters.fields = e.detail
}
</script>
<div class="root">
<Body size="S">
Choose the data source that provides the row you would like to duplicate.
<br />
You can always add or override fields manually.
</Body>
<div class="params">
<Label small>Data Source</Label>
<Select
bind:value={parameters.providerId}
options={providerOptions}
placeholder="None"
/>
<Label small>Duplicate to Table</Label>
<Select
bind:value={parameters.tableId}
options={tableOptions}
getOptionLabel={option => option.name}
getOptionValue={option => option._id}
/>
<Label small />
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm}
<Label small>Confirm text</Label>
<Input
placeholder="Are you sure you want to duplicate this row?"
bind:value={parameters.confirmText}
/>
{/if}
</div>
{#if parameters.tableId}
<div class="fields">
<SaveFields
parameterFields={parameters.fields}
{schemaFields}
on:change={onFieldsChanged}
{bindings}
/>
</div>
{/if}
</div>
<style>
.root {
width: 100%;
max-width: 800px;
margin: 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.root :global(p) {
line-height: 1.5;
}
.params {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 100px 1fr;
align-items: center;
}
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 100px 1fr auto 1fr auto;
align-items: center;
}
</style>

View File

@ -0,0 +1,13 @@
export { default as NavigateTo } from "./NavigateTo.svelte"
export { default as SaveRow } from "./SaveRow.svelte"
export { default as DeleteRow } from "./DeleteRow.svelte"
export { default as ExecuteQuery } from "./ExecuteQuery.svelte"
export { default as TriggerAutomation } from "./TriggerAutomation.svelte"
export { default as ValidateForm } from "./ValidateForm.svelte"
export { default as LogOut } from "./LogOut.svelte"
export { default as ClearForm } from "./ClearForm.svelte"
export { default as CloseScreenModal } from "./CloseScreenModal.svelte"
export { default as ChangeFormStep } from "./ChangeFormStep.svelte"
export { default as UpdateState } from "./UpdateState.svelte"
export { default as RefreshDataProvider } from "./RefreshDataProvider.svelte"
export { default as DuplicateRow } from "./DuplicateRow.svelte"

View File

@ -0,0 +1,33 @@
import * as ActionComponents from "./actions"
import { get } from "svelte/store"
import { store } from "builderStore"
import ActionDefinitions from "./manifest.json"
// Defines which actions are available to configure in the front end.
// Unfortunately the "name" property is used as the identifier so please don't
// change them.
// The client library removes any spaces when processing actions, so they can
// be considered as camel case too.
// There is technical debt here to sanitize all these and standardise them
// across the packages but it's a breaking change to existing apps.
export const getAvailableActions = (getAllActions = false) => {
return ActionDefinitions.actions
.filter(action => {
// Filter down actions to those supported by the current client lib version
if (getAllActions || !action.dependsOnFeature) {
return true
}
return get(store).clientFeatures?.[action.dependsOnFeature] === true
})
.map(action => {
// Then enrich the actions with real components
return {
...action,
component: ActionComponents[action.component],
}
})
.filter(action => {
// Then strip any old actions for which we don't have constructors
return action.component != null
})
}

View File

@ -0,0 +1,75 @@
{
"actions": [
{
"name": "Save Row",
"component": "SaveRow",
"context": [
{
"label": "Saved row",
"value": "row"
}
]
},
{
"name": "Duplicate Row",
"component": "DuplicateRow",
"context": [
{
"label": "Duplicated row",
"value": "row"
}
]
},
{
"name": "Delete Row",
"component": "DeleteRow"
},
{
"name": "Navigate To",
"component": "NavigateTo"
},
{
"name": "Execute Query",
"component": "ExecuteQuery",
"context": [
{
"label": "Query result",
"value": "result"
}
]
},
{
"name": "Trigger Automation",
"component": "TriggerAutomation"
},
{
"name": "Validate Form",
"component": "ValidateForm"
},
{
"name": "Log Out",
"component": "LogOut"
},
{
"name": "Clear Form",
"component": "ClearForm"
},
{
"name": "Close Screen Modal",
"component": "CloseScreenModal"
},
{
"name": "Change Form Step",
"component": "ChangeFormStep"
},
{
"name": "Refresh Data Provider",
"component": "RefreshDataProvider"
},
{
"name": "Update State",
"component": "UpdateState",
"dependsOnFeature": "state"
}
]
}

View File

@ -16,7 +16,7 @@
import { selectedComponent } from "builderStore"
import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte"
import { getComponentSettings } from "builderStore/storeUtils"
import { getComponentSettings } from "builderStore/componentUtils"
export let conditions = []
export let bindings = []

View File

@ -2,7 +2,7 @@
import { Select } from "@budibase/bbui"
import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils"
import { findComponentPath } from "builderStore/componentUtils"
import { createEventDispatcher, onMount } from "svelte"
export let value

View File

@ -48,9 +48,7 @@
return [...acc, ...viewsArr]
}, [])
$: queries = $queriesStore.list
.filter(
query => showAllQueries || query.queryVerb === "read" || query.readable
)
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
.map(query => ({
label: query.name,
name: query.name,
@ -104,6 +102,22 @@
value: `{{ literal ${runtimeBinding} }}`,
}
})
$: jsonArrays = bindings
.filter(x => x.fieldSchema?.type === "jsonarray")
.map(binding => {
const { providerId, readableBinding, runtimeBinding, tableId } = binding
const { name, type, prefixKeys } = binding.fieldSchema
return {
providerId,
label: readableBinding,
fieldName: name,
fieldType: type,
tableId,
prefixKeys,
type: "jsonarray",
value: `{{ literal ${runtimeBinding} }}`,
}
})
const handleSelected = selected => {
dispatch("change", selected)
@ -230,6 +244,17 @@
{/each}
</ul>
{/if}
{#if jsonArrays?.length}
<Divider size="S" />
<div class="title">
<Heading size="XS">JSON Arrays</Heading>
</div>
<ul>
{#each jsonArrays as field}
<li on:click={() => handleSelected(field)}>{field.label}</li>
{/each}
</ul>
{/if}
{#if dataProviders?.length}
<Divider size="S" />
<div class="title">

View File

@ -1,80 +0,0 @@
import { store } from "builderStore"
import { get } from "svelte/store"
import NavigateTo from "./NavigateTo.svelte"
import SaveRow from "./SaveRow.svelte"
import DeleteRow from "./DeleteRow.svelte"
import ExecuteQuery from "./ExecuteQuery.svelte"
import TriggerAutomation from "./TriggerAutomation.svelte"
import ValidateForm from "./ValidateForm.svelte"
import LogOut from "./LogOut.svelte"
import ClearForm from "./ClearForm.svelte"
import CloseScreenModal from "./CloseScreenModal.svelte"
import ChangeFormStep from "./ChangeFormStep.svelte"
import UpdateStateStep from "./UpdateState.svelte"
import RefreshDataProvider from "./RefreshDataProvider.svelte"
// Defines which actions are available to configure in the front end.
// Unfortunately the "name" property is used as the identifier so please don't
// change them.
// The client library removes any spaces when processing actions, so they can
// be considered as camel case too.
// There is technical debt here to sanitize all these and standardise them
// across the packages but it's a breaking change to existing apps.
export const getAvailableActions = () => {
let actions = [
{
name: "Save Row",
component: SaveRow,
},
{
name: "Delete Row",
component: DeleteRow,
},
{
name: "Navigate To",
component: NavigateTo,
},
{
name: "Execute Query",
component: ExecuteQuery,
},
{
name: "Trigger Automation",
component: TriggerAutomation,
},
{
name: "Validate Form",
component: ValidateForm,
},
{
name: "Log Out",
component: LogOut,
},
{
name: "Clear Form",
component: ClearForm,
},
{
name: "Close Screen Modal",
component: CloseScreenModal,
},
{
name: "Change Form Step",
component: ChangeFormStep,
},
{
name: "Refresh Data Provider",
component: RefreshDataProvider,
},
]
if (get(store).clientFeatures?.state) {
actions.push({
name: "Update State",
component: UpdateStateStep,
})
}
return actions
}

View File

@ -1,2 +0,0 @@
import EventsEditor from "./EventPropertyControl.svelte"
export default EventsEditor

View File

@ -21,7 +21,7 @@
export let panel = ClientBindingPanel
export let allowBindings = true
const BannedTypes = ["link", "attachment", "formula"]
const BannedTypes = ["link", "attachment", "formula", "json", "jsonarray"]
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))

View File

@ -5,7 +5,7 @@
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { findClosestMatchingComponent } from "builderStore/componentUtils"
export let componentInstance
export let value

View File

@ -1,7 +1,7 @@
<script>
import { ActionButton } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { findClosestMatchingComponent } from "builderStore/componentUtils"
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"

View File

@ -11,7 +11,7 @@
DatePicker,
} from "@budibase/bbui"
import { currentAsset, selectedComponent } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { findClosestMatchingComponent } from "builderStore/componentUtils"
import { getSchemaForDatasource } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid"

View File

@ -1,7 +1,7 @@
import { Checkbox, Select, Stepper } from "@budibase/bbui"
import DataSourceSelect from "./DataSourceSelect.svelte"
import DataProviderSelect from "./DataProviderSelect.svelte"
import EventsEditor from "./EventsEditor"
import ButtonActionEditor from "./ButtonActionEditor/ButtonActionEditor.svelte"
import TableSelect from "./TableSelect.svelte"
import ColorPicker from "./ColorPicker.svelte"
import { IconSelect } from "./IconSelect"
@ -24,7 +24,7 @@ const componentMap = {
dataProvider: DataProviderSelect,
boolean: Checkbox,
number: Stepper,
event: EventsEditor,
event: ButtonActionEditor,
table: TableSelect,
color: ColorPicker,
icon: IconSelect,
@ -45,6 +45,7 @@ const componentMap = {
"field/attachment": FormFieldSelect,
"field/link": FormFieldSelect,
"field/array": FormFieldSelect,
"field/json": FormFieldSelect,
// Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation
"validation/string": ValidationEditor,

View File

@ -89,6 +89,14 @@ export const FIELDS = {
presence: false,
},
},
JSON: {
name: "JSON",
type: "json",
constraints: {
type: "object",
presence: false,
},
},
}
export const AUTO_COLUMN_SUB_TYPES = {

View File

@ -1,4 +1,5 @@
import { NoEmptyFilterStrings } from "../constants/lucene"
import { deepGet } from "@budibase/bbui"
/**
* Removes any fields that contain empty strings that would cause inconsistent
@ -96,9 +97,13 @@ export const buildLuceneQuery = filter => {
* @param query the JSON lucene query
*/
export const luceneQuery = (docs, query) => {
if (!docs || !Array.isArray(docs)) {
return []
}
if (!query) {
return docs
}
// make query consistent first
query = cleanupQuery(query)
@ -106,7 +111,9 @@ export const luceneQuery = (docs, query) => {
const match = (type, failFn) => doc => {
const filters = Object.entries(query[type] || {})
for (let i = 0; i < filters.length; i++) {
if (failFn(filters[i][0], filters[i][1], doc)) {
const [key, testValue] = filters[i]
const docValue = deepGet(doc, key)
if (failFn(docValue, testValue)) {
return false
}
}
@ -114,38 +121,42 @@ export const luceneQuery = (docs, query) => {
}
// Process a string match (fails if the value does not start with the string)
const stringMatch = match("string", (key, value, doc) => {
return !doc[key] || !doc[key].toLowerCase().startsWith(value?.toLowerCase())
const stringMatch = match("string", (docValue, testValue) => {
return (
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
})
// Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match("fuzzy", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
const fuzzyMatch = match("fuzzy", (docValue, testValue) => {
return (
!docValue || !docValue?.toLowerCase().startsWith(testValue?.toLowerCase())
)
})
// Process a range match
const rangeMatch = match("range", (key, value, doc) => {
return !doc[key] || doc[key] < value.low || doc[key] > value.high
const rangeMatch = match("range", (docValue, testValue) => {
return !docValue || docValue < testValue.low || docValue > testValue.high
})
// Process an equal match (fails if the value is different)
const equalMatch = match("equal", (key, value, doc) => {
return value != null && value !== "" && doc[key] !== value
const equalMatch = match("equal", (docValue, testValue) => {
return testValue != null && testValue !== "" && docValue !== testValue
})
// Process a not-equal match (fails if the value is the same)
const notEqualMatch = match("notEqual", (key, value, doc) => {
return value != null && value !== "" && doc[key] === value
const notEqualMatch = match("notEqual", (docValue, testValue) => {
return testValue != null && testValue !== "" && docValue === testValue
})
// Process an empty match (fails if the value is not empty)
const emptyMatch = match("empty", (key, value, doc) => {
return doc[key] != null && doc[key] !== ""
const emptyMatch = match("empty", docValue => {
return docValue != null && docValue !== ""
})
// Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match("notEmpty", (key, value, doc) => {
return doc[key] == null || doc[key] === ""
const notEmptyMatch = match("notEmpty", docValue => {
return docValue == null || docValue === ""
})
// Match a document against all criteria

View File

@ -13,7 +13,7 @@
import FrontendNavigatePane from "components/design/NavigationPanel/FrontendNavigatePane.svelte"
import { goto, leftover, params } from "@roxi/routify"
import { FrontendTypes } from "constants"
import { findComponent, findComponentPath } from "builderStore/storeUtils"
import { findComponent, findComponentPath } from "builderStore/componentUtils"
import { get } from "svelte/store"
import AppThemeSelect from "components/design/AppPreview/AppThemeSelect.svelte"
import ThemeEditor from "components/design/AppPreview/ThemeEditor.svelte"

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/cli",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js",
"bin": {

View File

@ -500,9 +500,20 @@
"defaultValue": "M"
}
],
"context": {
"context": [
{
"type": "schema"
},
{
"type": "static",
"values": [
{
"label": "Row Index",
"key": "index"
}
]
}
]
},
"stackedlist": {
"deprecated": true,
@ -808,6 +819,57 @@
}
]
},
"tag": {
"name": "Tag",
"icon": "TextParagraph",
"showSettingsBar": true,
"settings": [
{
"type": "text",
"label": "Text",
"key": "text"
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "M",
"showInBar": true,
"barStyle": "picker",
"options": [{
"label": "Small",
"value": "S"
}, {
"label": "Medium",
"value": "M"
}, {
"label": "Large",
"value": "L"
}]
},
{
"type": "color",
"label": "Color",
"key": "color",
"showInBar": true,
"barSeparator": false
},
{
"type": "boolean",
"label": "Show delete icon",
"key": "closable",
"showInBar": true,
"barIcon": "TagItalic",
"barTitle": "Show delete icon"
},
{
"type": "event",
"label": "On click delete icon",
"key": "onClick",
"dependsOn": "closable"
}
]
},
"image": {
"name": "Image",
"description": "A basic component for displaying images",
@ -1775,6 +1837,10 @@
{
"type": "static",
"values": [
{
"label": "Value",
"key": "__value"
},
{
"label": "Valid",
"key": "__valid"
@ -2407,6 +2473,40 @@
}
]
},
"jsonfield": {
"name": "JSON Field",
"icon": "Brackets",
"styles": ["size"],
"editable": true,
"settings": [
{
"type": "field/json",
"label": "Field",
"key": "field"
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
}
]
},
"dataprovider": {
"name": "Data Provider",
"info": "Pagination is only available for data stored in tables.",
@ -3200,6 +3300,16 @@
}
]
},
{
"type": "static",
"suffix": "repeater",
"values": [
{
"label": "Row Index",
"key": "index"
}
]
},
{
"type": "schema",
"suffix": "repeater"

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/client",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"license": "MPL-2.0",
"module": "dist/budibase-client.js",
"main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw"
},
"dependencies": {
"@budibase/bbui": "^1.0.23",
"@budibase/bbui": "^1.0.27-alpha.1",
"@budibase/standard-components": "^0.9.139",
"@budibase/string-templates": "^1.0.23",
"@budibase/string-templates": "^1.0.27-alpha.1",
"regexparam": "^1.3.0",
"shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5"
@ -35,6 +35,7 @@
"@spectrum-css/divider": "^1.0.3",
"@spectrum-css/link": "^3.1.3",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/tag": "^3.1.4",
"@spectrum-css/typography": "^3.0.2",
"@spectrum-css/vars": "^3.0.1",
"apexcharts": "^3.22.1",

View File

@ -21,8 +21,8 @@
{#if $component.empty}
<Placeholder />
{:else if rows.length > 0}
{#each rows as row}
<Provider data={row}>
{#each rows as row, index}
<Provider data={{ ...row, index }}>
<slot />
</Provider>
{/each}

View File

@ -0,0 +1,59 @@
<script>
import "@spectrum-css/tag/dist/index-vars.css"
import { ClearButton } from "@budibase/bbui"
import { getContext } from "svelte"
export let onClick
export let text = ""
export let color
export let closable = false
export let size = "M"
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
// Add color styles to main styles object, otherwise the styleable helper
// overrides the color when it's passed as inline style.
$: styles = enrichStyles($component.styles, color)
$: componentText = getComponentText(text, $builderStore, $component)
const getComponentText = (text, builderState, componentState) => {
if (!builderState.inBuilder || componentState.editing) {
return text || " "
}
return text || componentState.name || "Placeholder text"
}
const enrichStyles = (styles, color) => {
if (!color) {
return styles
}
return {
...styles,
normal: {
...styles?.normal,
"background-color": color,
"border-color": color,
color: "white",
"--spectrum-clearbutton-medium-icon-color": "white",
},
}
}
</script>
<div class="spectrum-Tag spectrum-Tag--size{size}" use:styleable={styles}>
<span class="spectrum-Tag-label">{componentText}</span>
{#if closable}
<ClearButton on:click={onClick} />
{/if}
</div>
<style>
.spectrum-Tag--sizeS,
.spectrum-Tag--sizeM {
padding: 0 var(--spectrum-global-dimension-size-100);
}
.spectrum-Tag--sizeL {
padding: 0 var(--spectrum-global-dimension-size-150);
}
</style>

View File

@ -74,7 +74,7 @@
enrichedFilter.push({
field: column.name,
operator: column.type === "string" ? "string" : "equal",
type: "string",
type: column.type === "string" ? "string" : "number",
valueType: "Binding",
value: `{{ [${formId}].[${column.name}] }}`,
})

View File

@ -62,7 +62,7 @@
enrichedFilter.push({
field: column.name,
operator: column.type === "string" ? "string" : "equal",
type: "string",
type: column.type === "string" ? "string" : "number",
valueType: "Binding",
value: `{{ ${safe(formId)}.${safe(column.name)} }}`,
})

View File

@ -19,7 +19,7 @@
export let schemaFields
export let filters = []
const BannedTypes = ["link", "attachment", "formula"]
const BannedTypes = ["link", "attachment", "formula", "json"]
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))

View File

@ -3,6 +3,8 @@
import { derived, get, writable } from "svelte/store"
import { createValidatorFromConstraints } from "./validation"
import { generateID } from "utils/helpers"
import { deepGet, deepSet } from "@budibase/bbui"
import { cloneDeep } from "lodash/fp"
export let dataSource
export let disabled = false
@ -49,6 +51,20 @@
})
}
// Derive value of whole form
$: formValue = deriveFormValue(initialValues, $values, $enrichments)
// Create data context to provide
$: dataContext = {
...formValue,
// These static values are prefixed to avoid clashes with actual columns
__value: formValue,
__valid: valid,
__currentStep: $currentStep,
__currentStepValid: $currentStepValid,
}
// Generates a derived store from an array of fields, comprised of a map of
// extracted values from the field array
const deriveFieldProperty = (fieldStores, getProp) => {
@ -78,6 +94,35 @@
})
}
// Derive the overall form value and deeply set all field paths so that we
// can support things like JSON fields.
const deriveFormValue = (initialValues, values, enrichments) => {
let formValue = cloneDeep(initialValues || {})
// We need to sort the keys to avoid a JSON field overwriting a nested field
const sortedFields = Object.entries(values || {})
.map(([key, value]) => {
const field = getField(key)
return {
key,
value,
lastUpdate: get(field).fieldState?.lastUpdate || 0,
}
})
.sort((a, b) => {
return a.lastUpdate > b.lastUpdate
})
// Merge all values and enrichments into a single value
sortedFields.forEach(({ key, value }) => {
deepSet(formValue, key, value)
})
Object.entries(enrichments || {}).forEach(([key, value]) => {
deepSet(formValue, key, value)
})
return formValue
}
// Searches the field array for a certain field
const getField = name => {
return fields.find(field => get(field).name === name)
@ -97,13 +142,20 @@
}
// If we've already registered this field then keep some existing state
let initialValue = initialValues[field] ?? defaultValue
let initialValue = deepGet(initialValues, field) ?? defaultValue
let fieldId = `id-${generateID()}`
const existingField = getField(field)
if (existingField) {
const { fieldState } = get(existingField)
initialValue = fieldState.value ?? initialValue
fieldId = fieldState.fieldId
// Use new default value if default value changed,
// otherwise use the current value if possible
if (defaultValue !== fieldState.defaultValue) {
initialValue = defaultValue
} else {
initialValue = fieldState.value ?? initialValue
}
}
// Auto columns are always disabled
@ -130,6 +182,7 @@
disabled: disabled || fieldDisabled || isAutoColumn,
defaultValue,
validator,
lastUpdate: Date.now(),
},
fieldApi: makeFieldApi(field, defaultValue),
fieldSchema: schema?.[field] ?? {},
@ -204,6 +257,7 @@
fieldInfo.update(state => {
state.fieldState.value = value
state.fieldState.error = error
state.fieldState.lastUpdate = Date.now()
return state
})
@ -220,6 +274,7 @@
fieldInfo.update(state => {
state.fieldState.value = newValue
state.fieldState.error = null
state.fieldState.lastUpdate = Date.now()
return state
})
}
@ -299,18 +354,6 @@
{ type: ActionTypes.ClearForm, callback: formApi.clear },
{ type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
]
// Create data context to provide
$: dataContext = {
...initialValues,
...$values,
...$enrichments,
// These static values are prefixed to avoid clashes with actual columns
__valid: valid,
__currentStep: $currentStep,
__currentStepValid: $currentStepValid,
}
</script>
<Provider {actions} data={dataContext}>

View File

@ -0,0 +1,71 @@
<script>
import { CoreTextArea } from "@budibase/bbui"
import Field from "./Field.svelte"
import { getContext } from "svelte"
export let field
export let label
export let placeholder
export let disabled = false
export let defaultValue = ""
const component = getContext("component")
const validation = [
{
constraint: "json",
type: "json",
error: "JSON syntax is invalid",
},
]
let fieldState
let fieldApi
$: height = $component.styles?.normal?.height || "124px"
const serialiseValue = value => {
return JSON.stringify(value || undefined, null, 4) || ""
}
const parseValue = value => {
try {
return JSON.parse(value)
} catch (error) {
return value
}
}
</script>
<Field
{label}
{field}
{disabled}
{validation}
{defaultValue}
type="json"
bind:fieldState
bind:fieldApi
>
{#if fieldState}
<div style="--height: {height};">
<CoreTextArea
value={serialiseValue(fieldState.value)}
on:change={e => fieldApi.setValue(parseValue(e.detail))}
disabled={fieldState.disabled}
error={fieldState.error}
id={fieldState.fieldId}
{placeholder}
/>
</div>
{/if}
</Field>
<style>
:global(.spectrum-Form-itemField .spectrum-Textfield--multiline) {
min-height: calc(var(--height) - 24px);
}
:global(.spectrum-Form--labelsAbove
.spectrum-Form-itemField
.spectrum-Textfield--multiline) {
min-height: calc(var(--height) - 24px);
}
</style>

View File

@ -1,6 +1,7 @@
<script>
import { CoreTextArea } from "@budibase/bbui"
import Field from "./Field.svelte"
import { getContext } from "svelte"
export let field
export let label
@ -11,6 +12,9 @@
let fieldState
let fieldApi
const component = getContext("component")
$: height = $component.styles?.normal?.height || "124px"
</script>
<Field
@ -24,6 +28,7 @@
bind:fieldApi
>
{#if fieldState}
<div style="--height: {height};">
<CoreTextArea
value={fieldState.value}
on:change={e => fieldApi.setValue(e.detail)}
@ -32,5 +37,17 @@
id={fieldState.fieldId}
{placeholder}
/>
</div>
{/if}
</Field>
<style>
:global(.spectrum-Form-itemField .spectrum-Textfield--multiline) {
min-height: calc(var(--height) - 24px);
}
:global(.spectrum-Form--labelsAbove
.spectrum-Form-itemField
.spectrum-Textfield--multiline) {
min-height: calc(var(--height) - 24px);
}
</style>

View File

@ -11,3 +11,4 @@ export { default as attachmentfield } from "./AttachmentField.svelte"
export { default as relationshipfield } from "./RelationshipField.svelte"
export { default as passwordfield } from "./PasswordField.svelte"
export { default as formstep } from "./FormStep.svelte"
export { default as jsonfield } from "./JSONField.svelte"

View File

@ -206,6 +206,7 @@ const parseType = (value, type) => {
return value
}
// Parse array, treating no elements as null
if (type === FieldTypes.ARRAY) {
if (!Array.isArray(value) || !value.length) {
return null
@ -213,6 +214,12 @@ const parseType = (value, type) => {
return value
}
// For JSON we don't touch the value at all as we want to verify it in its
// raw form
if (type === FieldTypes.JSON) {
return value
}
// If some unknown type, treat as null to avoid breaking validators
return null
}
@ -290,6 +297,19 @@ const notContainsHandler = (value, rule) => {
return !containsHandler(value, rule)
}
// Evaluates a constraint that the value must be a valid json object
const jsonHandler = value => {
if (typeof value !== "object" || Array.isArray(value)) {
return false
}
try {
JSON.parse(JSON.stringify(value))
return true
} catch (error) {
return false
}
}
/**
* Map of constraint types to handlers.
*/
@ -306,6 +326,7 @@ const handlerMap = {
notRegex: notRegexHandler,
contains: containsHandler,
notContains: notContainsHandler,
json: jsonHandler,
}
/**

View File

@ -29,6 +29,7 @@ export { default as backgroundimage } from "./BackgroundImage.svelte"
export { default as daterangepicker } from "./DateRangePicker.svelte"
export { default as cardstat } from "./CardStat.svelte"
export { default as spectrumcard } from "./SpectrumCard.svelte"
export { default as tag } from "./Tag.svelte"
export * from "./charts"
export * from "./forms"
export * from "./table"

View File

@ -24,6 +24,7 @@
class:flipped
class:line
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
class:withText={!!text}
>
{#if text}
<div class="text" class:flipped class:line class:right={alignRight}>
@ -39,12 +40,12 @@
z-index: var(--zIndex);
border: 2px solid var(--color);
pointer-events: none;
border-top-right-radius: 4px;
border-top-left-radius: 0;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-radius: 4px;
}
.indicator.flipped {
.indicator.withText {
border-top-left-radius: 0;
}
.indicator.withText.flipped {
border-top-left-radius: 4px;
}
.indicator.line {
@ -74,8 +75,7 @@
border-radius: 4px;
}
.text.flipped {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
border-radius: 4px;
transform: translateY(0%);
top: -2px;
}

View File

@ -13,6 +13,7 @@ export const FieldTypes = {
ATTACHMENT: "attachment",
LINK: "link",
FORMULA: "formula",
JSON: "json",
}
export const UnsortableTypes = [

View File

@ -61,7 +61,8 @@ export const createDataSourceStore = () => {
// Emit this as a window event, so parent screens which are iframing us in
// can also invalidate the same datasource
if (get(routeStore).queryParams?.peek) {
const inModal = get(routeStore).queryParams?.peek
if (inModal) {
window.parent.postMessage({
type: "invalidate-datasource",
detail: { dataSourceId },

View File

@ -8,20 +8,49 @@ import {
} from "stores"
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
import { ActionTypes } from "constants"
import { enrichDataBindings } from "./enrichDataBinding"
import { deepSet } from "@budibase/bbui"
const saveRowHandler = async (action, context) => {
const { fields, providerId, tableId } = action.parameters
let payload
if (providerId) {
let draft = context[providerId]
payload = { ...context[providerId] }
} else {
payload = {}
}
if (fields) {
for (let [field, value] of Object.entries(fields)) {
draft[field] = value
deepSet(payload, field, value)
}
}
if (tableId) {
draft.tableId = tableId
payload.tableId = tableId
}
const row = await saveRow(payload)
return {
row,
}
}
const duplicateRowHandler = async (action, context) => {
const { fields, providerId, tableId } = action.parameters
if (providerId) {
let payload = { ...context[providerId] }
if (fields) {
for (let [field, value] of Object.entries(fields)) {
deepSet(payload, field, value)
}
}
if (tableId) {
payload.tableId = tableId
}
delete payload._id
delete payload._rev
const row = await saveRow(payload)
return {
row,
}
await saveRow(draft)
}
}
@ -46,11 +75,12 @@ const navigationHandler = action => {
const queryExecutionHandler = async action => {
const { datasourceId, queryId, queryParams } = action.parameters
await executeQuery({
const result = await executeQuery({
datasourceId,
queryId,
parameters: queryParams,
})
return { result }
}
const executeActionHandler = async (
@ -129,6 +159,7 @@ const updateStateHandler = action => {
const handlerMap = {
["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler,
["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler,
["Execute Query"]: queryExecutionHandler,
@ -165,12 +196,27 @@ export const enrichButtonActions = (actions, context) => {
return actions
}
// Button context is built up as actions are executed.
// Inherit any previous button context which may have come from actions
// before a confirmable action since this breaks the chain.
let buttonContext = context.actions || []
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => {
for (let i = 0; i < handlers.length; i++) {
try {
const action = actions[i]
const callback = async () => handlers[i](action, context)
// Skip any non-existent action definitions
if (!handlers[i]) {
continue
}
// Built total context for this action
const totalContext = { ...context, actions: buttonContext }
// Get and enrich this button action with the total context
let action = actions[i]
action = enrichDataBindings(action, totalContext)
const callback = async () => handlers[i](action, totalContext)
// If this action is confirmable, show confirmation and await a
// callback to execute further actions
@ -185,7 +231,15 @@ export const enrichButtonActions = (actions, context) => {
// then execute the rest of the actions in the chain
const result = await callback()
if (result !== false) {
const next = enrichButtonActions(actions.slice(i + 1), context)
// Generate a new total context to pass into the next enrichment
buttonContext.push(result)
const newContext = { ...context, actions: buttonContext }
// Enrich and call the next button action
const next = enrichButtonActions(
actions.slice(i + 1),
newContext
)
await next()
}
}
@ -201,6 +255,8 @@ export const enrichButtonActions = (actions, context) => {
const result = await callback()
if (result === false) {
return
} else {
buttonContext.push(result)
}
}
} catch (error) {

View File

@ -32,35 +32,56 @@ export const enrichProps = (props, context) => {
data: context[context.closestComponentId],
}
// Enrich all data bindings in top level props
let enrichedProps = enrichDataBindings(props, totalContext)
// Enrich click actions if they exist
Object.keys(enrichedProps).forEach(prop => {
// We want to exclude any button actions from enrichment at this stage.
// Extract top level button action settings.
let normalProps = { ...props }
let actionProps = {}
Object.keys(normalProps).forEach(prop => {
if (prop?.toLowerCase().includes("onclick")) {
enrichedProps[prop] = enrichButtonActions(
enrichedProps[prop],
totalContext
)
actionProps[prop] = normalProps[prop]
delete normalProps[prop]
}
})
// Enrich any click actions in conditions
if (enrichedProps._conditions) {
enrichedProps._conditions.forEach(condition => {
// Handle conditional UI separately after normal settings
let conditions = normalProps._conditions
delete normalProps._conditions
// Enrich all props except button actions
let enrichedProps = enrichDataBindings(normalProps, totalContext)
// Enrich button actions.
// Actions are enriched into a function at this stage, but actual data
// binding enrichment is done dynamically at runtime.
Object.keys(actionProps).forEach(prop => {
enrichedProps[prop] = enrichButtonActions(actionProps[prop], totalContext)
})
// Conditions
if (conditions?.length) {
let enrichedConditions = []
conditions.forEach(condition => {
if (condition.setting?.toLowerCase().includes("onclick")) {
condition.settingValue = enrichButtonActions(
// Copy and remove the setting value from the condition as it needs
// enriched separately
let toEnrich = { ...condition }
delete toEnrich.settingValue
// Join the condition back together
enrichedConditions.push({
...enrichDataBindings(toEnrich, totalContext),
settingValue: enrichButtonActions(
condition.settingValue,
totalContext
)
// If there is an onclick function in here then it won't be serialised
// properly, and therefore will not be updated properly.
// The solution to this is add a rand which will ensure diffs happen
// every time.
condition.rand = Math.random()
),
rand: Math.random(),
})
} else {
// Normal condition
enrichedConditions.push(enrichDataBindings(condition, totalContext))
}
})
enrichedProps._conditions = enrichedConditions
}
return enrichedProps

View File

@ -1,3 +1,8 @@
import {
convertJSONSchemaToTableSchema,
getJSONArrayDatasourceSchema,
} from "builder/src/builderStore/jsonUtils"
import { fetchTableDefinition } from "api"
import TableFetch from "./fetch/TableFetch.js"
import ViewFetch from "./fetch/ViewFetch.js"
import QueryFetch from "./fetch/QueryFetch.js"
@ -7,16 +12,17 @@ import QueryFetch from "./fetch/QueryFetch.js"
*/
export const fetchDatasourceSchema = async datasource => {
const type = datasource?.type
let schema
// Nested providers should already have exposed their own schema
if (type === "provider") {
return datasource.value?.schema
schema = datasource.value?.schema
}
// Field sources have their schema statically defined
if (type === "field") {
if (datasource.fieldType === "attachment") {
return {
schema = {
url: {
type: "string",
},
@ -25,7 +31,7 @@ export const fetchDatasourceSchema = async datasource => {
},
}
} else if (datasource.fieldType === "array") {
return {
schema = {
value: {
type: "string",
},
@ -33,7 +39,14 @@ export const fetchDatasourceSchema = async datasource => {
}
}
// All normal datasource schema can use their corresponsing implementations
// JSON arrays need their table definitions fetched.
// We can then extract their schema as a subset of the table schema.
if (type === "jsonarray") {
const table = await fetchTableDefinition(datasource.tableId)
schema = getJSONArrayDatasourceSchema(table?.schema, datasource)
}
// All normal datasource schema can use their corresponding implementations
// in the data fetch classes
const handler = {
table: TableFetch,
@ -43,7 +56,27 @@ export const fetchDatasourceSchema = async datasource => {
}[type]
if (handler) {
const definition = await handler.getDefinition(datasource)
return handler.getSchema(datasource, definition)
schema = handler.getSchema(datasource, definition)
}
// Check for any JSON fields so we can add any top level properties
if (schema) {
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "json") {
const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
squashObjects: true,
})
Object.keys(jsonSchema).forEach(jsonKey => {
jsonAdditions[`${fieldKey}.${jsonKey}`] = {
type: jsonSchema[jsonKey].type,
nestedJSON: true,
}
})
}
})
return { ...schema, ...jsonAdditions }
}
return null

View File

@ -305,6 +305,11 @@
resolved "https://registry.yarnpkg.com/@spectrum-css/tabs/-/tabs-3.1.5.tgz#cc82e69c1fc721902345178231fb95d05938b983"
integrity sha512-UtfW8bA1quYnJM6v/lp6AVYGnQFkiUix2FHAf/4VHVrk4mh7ydtLiXS0IR3Kx+t/S8FWdSdSQHDZ8tHbY1ZLZg==
"@spectrum-css/tag@^3.1.4":
version "3.1.4"
resolved "https://registry.yarnpkg.com/@spectrum-css/tag/-/tag-3.1.4.tgz#334384dd789ddf0562679cae62ef763883480ac5"
integrity sha512-9dYBMhCEkjy+p75XJIfCA2/zU4JAqsJrL7fkYIDXakS6/BzeVtIvAW/6JaIHtLIA9lrj0Sn4m+ZjceKnZNIv1w==
"@spectrum-css/tags@^3.0.2":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@spectrum-css/tags/-/tags-3.0.3.tgz#fc76d2735cdc442de91b7eb3bee49a928c0767ac"

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/server",
"email": "hi@budibase.com",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"description": "Budibase Web Server",
"main": "src/index.ts",
"repository": {
@ -69,9 +69,10 @@
"author": "Budibase",
"license": "GPL-3.0",
"dependencies": {
"@budibase/auth": "^1.0.23",
"@budibase/client": "^1.0.23",
"@budibase/string-templates": "^1.0.23",
"@apidevtools/swagger-parser": "^10.0.3",
"@budibase/auth": "^1.0.27-alpha.1",
"@budibase/client": "^1.0.27-alpha.1",
"@budibase/string-templates": "^1.0.27-alpha.1",
"@bull-board/api": "^3.7.0",
"@bull-board/koa": "^3.7.0",
"@elastic/elasticsearch": "7.10.0",

View File

@ -50,10 +50,10 @@ exports.validate = async ({ appId, tableId, row, table }) => {
const errors = {}
for (let fieldName of Object.keys(table.schema)) {
const constraints = cloneDeep(table.schema[fieldName].constraints)
const type = table.schema[fieldName].type
// special case for options, need to always allow unselected (null)
if (
table.schema[fieldName].type ===
(FieldTypes.OPTIONS || FieldTypes.ARRAY) &&
(type === FieldTypes.OPTIONS || type === FieldTypes.ARRAY) &&
constraints.inclusion
) {
constraints.inclusion.push(null)
@ -61,17 +61,20 @@ exports.validate = async ({ appId, tableId, row, table }) => {
let res
// Validate.js doesn't seem to handle array
if (
table.schema[fieldName].type === FieldTypes.ARRAY &&
row[fieldName] &&
row[fieldName].length
) {
if (type === FieldTypes.ARRAY && row[fieldName] && row[fieldName].length) {
row[fieldName].map(val => {
if (!constraints.inclusion.includes(val)) {
errors[fieldName] = "Field not in list"
}
})
} else if (table.schema[fieldName].type === FieldTypes.FORMULA) {
} else if (type === FieldTypes.JSON && typeof row[fieldName] === "string") {
// this should only happen if there is an error
try {
JSON.parse(row[fieldName])
} catch (err) {
errors[fieldName] = [`Contains invalid JSON`]
}
} else if (type === FieldTypes.FORMULA) {
res = validateJs.single(
processStringSync(table.schema[fieldName].formula, row),
constraints

View File

@ -81,6 +81,18 @@ const TYPE_TRANSFORM_MAP = {
[FieldTypes.AUTO]: {
parse: () => undefined,
},
[FieldTypes.JSON]: {
parse: input => {
try {
if (input === "") {
return undefined
}
return JSON.parse(input)
} catch (err) {
return input
}
},
},
}
/**

View File

@ -27,7 +27,7 @@
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267"
integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==
"@apidevtools/swagger-parser@10.0.3":
"@apidevtools/swagger-parser@10.0.3", "@apidevtools/swagger-parser@^10.0.3":
version "10.0.3"
resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz#32057ae99487872c4dd96b314a1ab4b95d89eaf5"
integrity sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==
@ -983,10 +983,10 @@
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@budibase/auth@^1.0.23":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@budibase/auth/-/auth-1.0.24.tgz#69a015a7fe9dcc9ea9e471a7d76d2c0544de0b16"
integrity sha512-pX+x+ib5X8s9ek4TZVmddRXlCE3kFmK5EH7Qk0Wmcn9tZwbaZy1XXd4qlJ7aFGSmvy1VNbz5AXIyRtliescaEQ==
"@budibase/auth@^1.0.27-alpha.1":
version "1.0.33"
resolved "https://registry.yarnpkg.com/@budibase/auth/-/auth-1.0.33.tgz#bb0c527ebca852c001c7e163a9cdfce49984adab"
integrity sha512-CJcBspSB6B4UwXenGfzCiBh4wWPq9ZrRZm+Dqo774Yw7dfa/hHisfFiRAuz0vHxZl6AStLUsTBNynrzt7bs7Mg==
dependencies:
"@techpass/passport-openidconnect" "^0.3.0"
aws-sdk "^2.901.0"
@ -1056,10 +1056,10 @@
svelte-flatpickr "^3.2.3"
svelte-portal "^1.0.0"
"@budibase/bbui@^1.0.24":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.0.24.tgz#15a976b500c4103bc3e94ed665c6c557bb6271bf"
integrity sha512-4zr+fSChOelHDqNNM2OiWWbWOL57JGBVBx333GiMr2P7N0MoepjwHEp9MlITWl5mUfS8rxgWTC9mSSMpRdpSUQ==
"@budibase/bbui@^1.0.33":
version "1.0.33"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.0.33.tgz#e815c4883dbc9d28d31900160fc74aafc81d02c1"
integrity sha512-2W3Ub8J8brSuhwtXhznszTa54cTyUdDNMFDCnRrHseaDV05XrSykdndXYyWi4XC+FveV82AdvSICxgd6alRGzg==
dependencies:
"@adobe/spectrum-css-workflow-icons" "^1.2.1"
"@spectrum-css/actionbutton" "^1.0.1"
@ -1106,14 +1106,14 @@
svelte-flatpickr "^3.2.3"
svelte-portal "^1.0.0"
"@budibase/client@^1.0.23":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-1.0.24.tgz#69be5b66cc701c6018c816728975a076230c052a"
integrity sha512-ppb+exifHCfuDGqs1cQ0kzc3wWaOGkVgq/8Rdb7R7BLIOKYkB67L3FvkqTl+39wrc32CpmzOXXQ73keE4l6P8Q==
"@budibase/client@^1.0.27-alpha.1":
version "1.0.33"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-1.0.33.tgz#715aaaf15f13d9e40960fffea44002e817d8a569"
integrity sha512-4+xM/ZTI247JbJc1noQS58VFh8AqfuQK1ZUSZIowuol1TtDu5dLGN7BysUFUe0mJZoDB/L+cB1I//43XqaSWDw==
dependencies:
"@budibase/bbui" "^1.0.24"
"@budibase/bbui" "^1.0.33"
"@budibase/standard-components" "^0.9.139"
"@budibase/string-templates" "^1.0.24"
"@budibase/string-templates" "^1.0.33"
regexparam "^1.3.0"
shortid "^2.2.15"
svelte-spa-router "^3.0.5"
@ -1163,10 +1163,10 @@
svelte-apexcharts "^1.0.2"
svelte-flatpickr "^3.1.0"
"@budibase/string-templates@^1.0.23", "@budibase/string-templates@^1.0.24":
version "1.0.24"
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-1.0.24.tgz#8f317ff18159d8f8e36179de33252e019c85c267"
integrity sha512-KAwyYxtm3Mrsm44MhchSxOtB8ucZ2JI93VPBQn0hjdzHtMuAkadtFNPZFeD3pKpCjDmI13XuW+BmJqCF3B0tYg==
"@budibase/string-templates@^1.0.27-alpha.1", "@budibase/string-templates@^1.0.33":
version "1.0.33"
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-1.0.33.tgz#7c39f0821d86ca1b26e8d5e7eca4256f24b09de0"
integrity sha512-uvDJvVtAaBcMKnVylWlzGk+CzPXH+TIKojGAfgjCqfcBsc9GlyCCBLtANe8osi9Bdr1SsKOW9Fi5u9FTToNMJw==
dependencies:
"@budibase/handlebars-helpers" "^0.11.7"
dayjs "^1.10.4"
@ -3304,9 +3304,9 @@ aws-sdk@^2.767.0:
xml2js "0.4.19"
aws-sdk@^2.901.0:
version "2.1046.0"
resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1046.0.tgz#9147b0fa1c86acbebd1a061e951ab5012f4499d7"
integrity sha512-ocwHclMXdIA+NWocUyvp9Ild3/zy2vr5mHp3mTyodf0WU5lzBE8PocCVLSWhMAXLxyia83xv2y5f5AzAcetbqA==
version "2.1049.0"
resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1049.0.tgz#8146dcdf3a1ab603e50ff961169ee8abc537d48e"
integrity sha512-+wls9iNlotMeoZepwgR0yPzXsjXzr2ijoi5ERmsPWfMTFMHkm6INndBtSkm6fpu/NZnl+7EaPPES2yhaqnhoJg==
dependencies:
buffer "4.9.2"
events "1.1.1"

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/string-templates",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs",
"module": "dist/bundle.mjs",

View File

@ -1,4 +1,5 @@
const { atob } = require("../utilities")
const { cloneDeep } = require("lodash/fp")
// The method of executing JS scripts depends on the bundle being built.
// This setter is used in the entrypoint (either index.cjs or index.mjs).
@ -38,8 +39,12 @@ module.exports.processJS = (handlebars, context) => {
// This is required to allow the final `return` statement to be valid.
const js = `function run(){${atob(handlebars)}};run();`
// Our $ context function gets a value from context
const sandboxContext = { $: path => getContextValue(path, context) }
// Our $ context function gets a value from context.
// We clone the context to avoid mutation in the binding affecting real
// app context.
const sandboxContext = {
$: path => getContextValue(path, cloneDeep(context)),
}
// Create a sandbox with out context and run the JS
return runJS(js, sandboxContext)

View File

@ -1,12 +1,13 @@
const handlebars = require("handlebars")
const { registerAll } = require("./helpers/index")
const processors = require("./processors")
const { removeHandlebarsStatements, atob, btoa } = require("./utilities")
const { atob, btoa } = require("./utilities")
const manifest = require("../manifest.json")
const hbsInstance = handlebars.create()
registerAll(hbsInstance)
const hbsInstanceNoHelpers = handlebars.create()
const defaultOpts = { noHelpers: false }
/**
* utility function to check if the object is valid
@ -28,11 +29,7 @@ function testObject(object) {
* @param {object} opts optional - specify some options for processing.
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
*/
module.exports.processObject = async (
object,
context,
opts = { noHelpers: false }
) => {
module.exports.processObject = async (object, context, opts) => {
testObject(object)
for (let key of Object.keys(object || {})) {
if (object[key] != null) {
@ -63,11 +60,7 @@ module.exports.processObject = async (
* @param {object} opts optional - specify some options for processing.
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processString = async (
string,
context,
opts = { noHelpers: false }
) => {
module.exports.processString = async (string, context, opts) => {
// TODO: carry out any async calls before carrying out async call
return module.exports.processStringSync(string, context, opts)
}
@ -81,11 +74,7 @@ module.exports.processString = async (
* @param {object} opts optional - specify some options for processing.
* @returns {object|array} The structure input, as fully updated as possible.
*/
module.exports.processObjectSync = (
object,
context,
opts = { noHelpers: false }
) => {
module.exports.processObjectSync = (object, context, opts) => {
testObject(object)
for (let key of Object.keys(object || {})) {
let val = object[key]
@ -106,26 +95,20 @@ module.exports.processObjectSync = (
* @param {object} opts optional - specify some options for processing.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
module.exports.processStringSync = (
string,
context,
opts = { noHelpers: false }
) => {
if (!exports.isValid(string)) {
return string
}
// take a copy of input incase error
module.exports.processStringSync = (string, context, opts) => {
opts = { ...defaultOpts, ...opts }
// take a copy of input in case of error
const input = string
if (typeof string !== "string") {
throw "Cannot process non-string types."
}
try {
const noHelpers = opts && opts.noHelpers
// finalising adds a helper, can't do this with no helpers
const shouldFinalise = !noHelpers
const shouldFinalise = !opts.noHelpers
string = processors.preprocess(string, shouldFinalise)
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
const instance = noHelpers ? hbsInstanceNoHelpers : hbsInstance
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
const template = instance.compile(string, {
strict: false,
})
@ -136,7 +119,7 @@ module.exports.processStringSync = (
})
)
} catch (err) {
return removeHandlebarsStatements(input)
return input
}
}
@ -155,7 +138,8 @@ module.exports.makePropSafe = property => {
* @param opts optional - specify some options for processing.
* @returns {boolean} Whether or not the input string is valid.
*/
module.exports.isValid = (string, opts = { noHelpers: false }) => {
module.exports.isValid = (string, opts) => {
opts = { ...defaultOpts, ...opts }
const validCases = [
"string",
"number",
@ -169,7 +153,7 @@ module.exports.isValid = (string, opts = { noHelpers: false }) => {
// don't really need a real context to check if its valid
const context = {}
try {
const instance = opts && opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
instance.compile(processors.preprocess(string, false))(context)
return true
} catch (err) {

View File

@ -10,7 +10,10 @@ module.exports.swapStrings = (string, start, length, swap) => {
return string.slice(0, start) + swap + string.slice(start + length)
}
module.exports.removeHandlebarsStatements = string => {
module.exports.removeHandlebarsStatements = (
string,
replacement = "Invalid binding"
) => {
let regexp = new RegExp(exports.FIND_HBS_REGEX)
let matches = string.match(regexp)
if (matches == null) {
@ -18,7 +21,7 @@ module.exports.removeHandlebarsStatements = string => {
}
for (let match of matches) {
const idx = string.indexOf(match)
string = exports.swapStrings(string, idx, match.length, "Invalid Binding")
string = exports.swapStrings(string, idx, match.length, replacement)
}
return string
}

View File

@ -13,10 +13,14 @@ describe("test the custom helpers we have applied", () => {
describe("test that it can run without helpers", () => {
it("should be able to run without helpers", async () => {
const output = await processString("{{ avg 1 1 1 }}", {}, { noHelpers: true })
const output = await processString(
"{{ avg 1 1 1 }}",
{},
{ noHelpers: true }
)
const valid = await processString("{{ avg 1 1 1 }}", {})
expect(valid).toBe("1")
expect(output).toBe("Invalid Binding")
expect(output).toBe("{{ avg 1 1 1 }}")
})
})
@ -185,17 +189,22 @@ describe("test the date helpers", () => {
it("should test the timezone capabilities", async () => {
const date = new Date(1611577535000)
const output = await processString("{{ date time 'HH-mm-ss Z' 'America/New_York' }}", {
const output = await processString(
"{{ date time 'HH-mm-ss Z' 'America/New_York' }}",
{
time: date.toUTCString(),
})
const formatted = new dayjs(date).tz("America/New_York").format("HH-mm-ss Z")
}
)
const formatted = new dayjs(date)
.tz("America/New_York")
.format("HH-mm-ss Z")
expect(output).toBe(formatted)
})
it("should guess the users timezone when not specified", async () => {
const date = new Date()
const output = await processString("{{ date time 'Z' }}", {
time: date.toUTCString()
time: date.toUTCString(),
})
const timezone = dayjs.tz.guess()
const offset = new dayjs(date).tz(timezone).format("Z")
@ -307,12 +316,12 @@ describe("test the comparison helpers", () => {
describe("Test the object/array helper", () => {
it("should allow plucking from an array of objects", async () => {
const context = {
items: [
{ price: 20 },
{ price: 30 },
]
items: [{ price: 20 }, { price: 30 }],
}
const output = await processString("{{ literal ( sum ( pluck items 'price' ) ) }}", context)
const output = await processString(
"{{ literal ( sum ( pluck items 'price' ) ) }}",
context
)
expect(output).toBe(50)
})
@ -450,7 +459,7 @@ describe("Cover a few complex use cases", () => {
rowCount: 8,
}
const output = await processObject(input, tableJson)
expect(output.dataProvider).not.toBe("Invalid Binding")
expect(output.dataProvider).not.toBe("Invalid binding")
})
it("should be able to handle external ids", async () => {

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/worker",
"email": "hi@budibase.com",
"version": "1.0.23",
"version": "1.0.27-alpha.1",
"description": "Budibase background service",
"main": "src/index.js",
"repository": {
@ -29,8 +29,8 @@
"author": "Budibase",
"license": "GPL-3.0",
"dependencies": {
"@budibase/auth": "^1.0.23",
"@budibase/string-templates": "^1.0.23",
"@budibase/auth": "^1.0.27-alpha.1",
"@budibase/string-templates": "^1.0.27-alpha.1",
"@koa/router": "^8.0.0",
"@sentry/node": "^6.0.0",
"@techpass/passport-openidconnect": "^0.3.0",

View File

@ -8,7 +8,7 @@ jest.mock("nodemailer")
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn()
verify: jest.fn(),
})
describe("/api/global/email", () => {
@ -39,6 +39,6 @@ describe("/api/global/email", () => {
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
expect(emailCall.subject).toBe("Hello!")
expect(emailCall.html).not.toContain("Invalid Binding")
expect(emailCall.html).not.toContain("Invalid binding")
})
})