1
0
Fork 0
mirror of synced 2024-09-10 06:26:02 +12:00

Merge branch 'master' of github.com:Budibase/budibase into table-width-setting

This commit is contained in:
Andrew Kingston 2024-05-14 09:05:21 +01:00
commit 182b6463e0
158 changed files with 3876 additions and 2567 deletions

View file

@ -54,7 +54,8 @@
"ignoreRestSiblings": true "ignoreRestSiblings": true
} }
], ],
"local-rules/no-budibase-imports": "error" "no-redeclare": "off",
"@typescript-eslint/no-redeclare": "error"
} }
}, },
{ {

View file

@ -170,7 +170,8 @@ jobs:
docker pull mongo:7.0-jammy & docker pull mongo:7.0-jammy &
docker pull mariadb:lts & docker pull mariadb:lts &
docker pull testcontainers/ryuk:0.5.1 & docker pull testcontainers/ryuk:0.5.1 &
docker pull budibase/couchdb:v3.2.1-sql & docker pull budibase/couchdb:v3.2.1-sqs &
docker pull minio/minio &
docker pull redis & docker pull redis &
wait $(jobs -p) wait $(jobs -p)

View file

@ -2,7 +2,7 @@
apiVersion: {{ ternary "autoscaling/v2" "autoscaling/v2beta2" (.Capabilities.APIVersions.Has "autoscaling/v2") }} apiVersion: {{ ternary "autoscaling/v2" "autoscaling/v2beta2" (.Capabilities.APIVersions.Has "autoscaling/v2") }}
kind: HorizontalPodAutoscaler kind: HorizontalPodAutoscaler
metadata: metadata:
name: {{ include "budibase.fullname" . }}-apps name: {{ include "budibase.fullname" . }}-automation-worker
labels: labels:
{{- include "budibase.labels" . | nindent 4 }} {{- include "budibase.labels" . | nindent 4 }}
spec: spec:

View file

@ -46,7 +46,7 @@ export default async function setup() {
await killContainers(containers) await killContainers(containers)
try { try {
let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs") const couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs")
.withExposedPorts(5984, 4984) .withExposedPorts(5984, 4984)
.withEnvironment({ .withEnvironment({
COUCHDB_PASSWORD: "budibase", COUCHDB_PASSWORD: "budibase",
@ -69,7 +69,20 @@ export default async function setup() {
).withStartupTimeout(20000) ).withStartupTimeout(20000)
) )
await couchdb.start() const minio = new GenericContainer("minio/minio")
.withExposedPorts(9000)
.withCommand(["server", "/data"])
.withEnvironment({
MINIO_ACCESS_KEY: "budibase",
MINIO_SECRET_KEY: "budibase",
})
.withLabels({ "com.budibase": "true" })
.withReuse()
.withWaitStrategy(
Wait.forHttp("/minio/health/ready", 9000).withStartupTimeout(10000)
)
await Promise.all([couchdb.start(), minio.start()])
} finally { } finally {
lockfile.unlockSync(lockPath) lockfile.unlockSync(lockPath)
} }

View file

@ -70,10 +70,10 @@ sed -i "s#COUCHDB_ERLANG_COOKIE#${COUCHDB_ERLANG_COOKIE}#g" /opt/clouseau/clouse
/opt/clouseau/bin/clouseau > /dev/stdout 2>&1 & /opt/clouseau/bin/clouseau > /dev/stdout 2>&1 &
# Start CouchDB. # Start CouchDB.
/docker-entrypoint.sh /opt/couchdb/bin/couchdb & /docker-entrypoint.sh /opt/couchdb/bin/couchdb > /dev/stdout 2>&1 &
# Start SQS. # Start SQS. Use 127.0.0.1 instead of localhost to avoid IPv6 issues.
/opt/sqs/sqs --server "http://localhost:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 & /opt/sqs/sqs --server "http://127.0.0.1:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 > /dev/stdout 2>&1 &
# Wait for CouchDB to start up. # Wait for CouchDB to start up.
while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do

213
i18n/README.it.md Normal file
View file

@ -0,0 +1,213 @@
<p align="center">
<a href="https://www.budibase.com">
<img alt="Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1696515725/Branding/Assets/Symbol/RGB/Full%20Colour/Budibase_Symbol_RGB_FullColour_cbqvha_1_z5cwq2.svg" width="60" />
</a>
</p>
<h1 align="center">
Budibase
</h1>
<h3 align="center">
La piattaforma low-code che amerai utilizzare
</h3>
<p align="center">
Budibase è una piattaforma low-code open source ed è il modo più semplice per creare strumenti interni che migliorano la produttività.
</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 tutte le release" src="https://img.shields.io/github/downloads/Budibase/budibase/total">
</a>
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub release (ordine cronologico)" 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="Segui @budibase" />
</a>
<img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Codice di condotta" />
<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">Inizia</a>
<span> · </span>
<a href="https://docs.budibase.com">Documentazione</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas">Richieste di miglioramento</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/issues">Segnala un bug</a>
<span> · </span>
Supporto: <a href="https://github.com/Budibase/budibase/discussions">Discussioni</a>
</h3>
<br /><br />
## ✨ Funzionalità
### Costruisci e distribuisci software reale
A differenza di altre piattaforme, con Budibase puoi costruire e distribuire applicazioni one-page. Le applicazioni Budibase sono altamente performanti e possono essere progettate in modo responsive, offrendo ai tuoi utenti un'esperienza eccezionale.
<br /><br />
### Sorgente aperto ed estensibile
Budibase è software open source - sotto licenza GPL v3. Questo dovrebbe rassicurarti sul fatto che Budibase sarà sempre lì. Puoi anche codificare in Budibase o fare fork e apportare modifiche a tuo piacimento, rendendolo un'esperienza amichevole per gli sviluppatori.
<br /><br />
### Importa dati o inizia da zero
Budibase può estrarre i suoi dati da diverse fonti, tra cui MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB o un'API REST. E a differenza di altre piattaforme, con Budibase puoi partire da zero e creare applicazioni aziendali senza alcuna fonte di dati. [Richiedi una nuova fonte di dati](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Dati Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/data_n1tlhf.png">
</p>
<br /><br />
### Progetta e crea applicazioni utilizzando componenti predefiniti.
Budibase è dotato di componenti predefiniti belli e potenti che puoi utilizzare come mattoni per costruire la tua interfaccia utente. Esporremo anche molte delle tue opzioni di stile CSS preferite in modo che tu possa esprimere una creatività maggiore. [Richiedi un nuovo componente](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Design Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970243/Out%20of%20beta%20launch/design-like-a-pro_qhlfeu.gif">
</p>
<br /><br />
### Automatizza processi, integra altri strumenti e collegati a webhook
Risparmia tempo automatizzando processi manuali e flussi di lavoro. Che si tratti di connettersi a webhook o automatizzare email, basta dire a Budibase cosa fare e lasciarlo lavorare per te. Puoi facilmente [creare una nuova automazione per Budibase qui](https://github.com/Budibase/automations) o [Richiedere una nuova automazione](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Automazioni Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970486/Out%20of%20beta%20launch/automation_riro7u.png">
</p>
<br /><br />
### Integrazione con i tuoi strumenti preferiti
Budibase si integra con vari strumenti popolari, consentendoti di creare applicazioni che si adattano perfettamente alla tua stack tecnologica.
<p align="center">
<img alt="Integrazioni Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/integrations_kc7dqt.png">
</p>
<br /><br />
### Paradiso degli amministratori
Budibase è progettato per crescere. Con Budibase, puoi auto-ospitarti sulla tua infrastruttura e gestire globalmente utenti, home, SMTP, applicazioni, gruppi, aspetto e altro ancora. Puoi anche fornire agli utenti/gruppi un portale delle applicazioni e affidare la gestione degli utenti al responsabile del gruppo.
- Guarda il video promozionale: https://youtu.be/xoljVpty_Kw
<br /><br /><br />
## 🏁 Inizio
<img src="https://res.cloudinary.com/daog6scxm/image/upload/v1634808888/logo/deploy_npl9za.png" />
Implementa Budibase self-hosted nella tua infrastruttura esistente, utilizzando Docker, Kubernetes e Digital Ocean.
Oppure utilizza Budibase Cloud se non hai bisogno di auto-ospitare e desideri iniziare rapidamente.
### [Inizia con Budibase](https://budibase.com)
<br /><br />
## 🎓 Imparare Budibase
La documentazione Budibase [è qui](https://docs.budibase.com).
<br />
<br /><br />
## 💬 Comunità
Se hai domande o vuoi discutere con altri utenti di Budibase e unirti alla nostra comunità, vai su: [Discussioni Github](https://github.com/Budibase/budibase/discussions)
<br /><br /><br />
## ❗ Codice di condotta
Budibase si impegna a offrire a tutti un'esperienza accogliente, diversificata e priva di molestie. Ci aspettiamo che tutti i membri della comunità Budibase rispettino i principi del nostro [**Codice di condotta**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md). Grazie per la tua attenzione.
<br />
<br /><br />
## 🙌 Contribuire a Budibase
Che tu stia aprendo un rapporto di bug o creando una Pull request, ogni contributo è apprezzato e benvenuto. Se stai pensando di implementare una nuova funzionalità o modificare l'API, crea prima un Issue. In questo modo possiamo assicurarci che il tuo lavoro non sia inutile.
### Non sai da dove cominciare ?
Un buon punto di partenza per contribuire è qui: [Progetti in corso](https://github.com/Budibase/budibase/projects/22).
### Come è organizzato il repo ?
Budibase è un monorepo gestito da lerna. Lerna gestisce la costruzione e la pubblicazione dei pacchetti di Budibase. Ecco, a grandi linee, i pacchetti che compongono Budibase.
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - contiene il codice per l'applicazione svelte lato client di budibase builder.
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - Un modulo che viene eseguito nel browser e che è responsabile della lettura delle definizioni JSON e della creazione di applicazioni web viventi da esse.
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server) - Il server budibase. Questa applicazione Koa è responsabile del servizio del JS per le applicazioni builder e budibase, oltre a fornire l'API per l'interazione con il database e il filesystem.
Per ulteriori informazioni, vedere [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md)
<br /><br />
## 📝 Licenza
Budibase è open source, con licenza [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). Le librerie client e dei componenti sono con licenza [MPL](https://directory.fsf.org/wiki/License:MPL-2.0) - quindi le applicazioni che crei possono essere utilizzate con licenza come desideri.
<br /><br />
## ⭐ Stargazers nel tempo
[![Stargazers nel tempo](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase)
Se riscontri problemi tra gli aggiornamenti del builder, utilizza la seguente guida [qui](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting) per pulire il tuo ambiente.
<br /><br />
## Contributeurs ✨
Grazie a queste meravigliose persone ([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="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Test">⚠️</a> <a href="#infra-shogunpurple" title="Infrastruttura (Hosting, Strumenti di build, 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="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Test">⚠️</a> <a href="#infra-mike12345567" title="Infrastruttura (Hosting, Strumenti di build, 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="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Test">⚠️</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="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Test">⚠️</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="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Test">⚠️</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="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Codice">💻</a> <a href="#content-joebudi" title="Contenuto">🖋</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="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Test">⚠️</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="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Test">⚠️</a></td>
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars.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="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Documentazione">📖</a></td>
<td align="center"><a href="https://github.com/dominiccave"><img src="https://avatars.githubusercontent.com/u/17828738?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Dominic Cave</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=dominiccave" title="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=dominiccave" title="Documentazione">📖</a></td>
<td align="center"><a href="https://github.com/Rabonaire"><img src="https://avatars.githubusercontent.com/u/10060936?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rabonaire</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Rabonaire" title="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Rabonaire" title="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/issues?q=author%3ARabonaire" title="Report di bug">🐛</a></td>
<td align="center"><a href="https://github.com/Alexsyeung"><img src="https://avatars.githubusercontent.com/u/31413823?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alex Yeung</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Alexsyeung" title="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Alexsyeung" title="Codice">💻</a></td>
<td align="center"><a href="https://github.com/SamWoodsIV"><img src="https://avatars.githubusercontent.com/u/25854138?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sam Woods</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=SamWoodsIV" title="Documentazione">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=SamWoodsIV" title="Codice">💻</a> <a href="#infra-SamWoodsIV" title="Infrastruttura (Hosting, Strumenti di build, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/ScooterSwope"><img src="https://avatars.githubusercontent.com/u/21829556?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ScooterSwope</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=ScooterSwope" title="Codice">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=ScooterSwope" title="Documentazione">📖</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
Questo progetto segue il [convenant del contribuente](https://github.com/Budibase/budibase/blob/master/CODE_OF_CONDUCT.md). Ogni contributo è il benvenuto!
</td>
</tr>
</table>

211
i18n/README.por.md Normal file
View file

@ -0,0 +1,211 @@
<p align="center">
<a href="https://www.budibase.com">
<img alt="Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1696515725/Branding/Assets/Symbol/RGB/Full%20Colour/Budibase_Symbol_RGB_FullColour_cbqvha_1_z5cwq2.svg" width="60" />
</a>
</p>
<h1 align="center">
Budibase
</h1>
<h3 align="center">
A plataforma low-code que você vai adorar usar
</h3>
<p align="center">
Budibase é uma plataforma low-code de código aberto e é a maneira mais fácil de criar ferramentas internas que melhoram a produtividade.
</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 todos os releases" src="https://img.shields.io/github/downloads/Budibase/budibase/total">
</a>
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub release (por ordem cronológica)" 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="Siga @budibase" />
</a>
<img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Código de Conduta" />
<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">Começar</a>
<span> · </span>
<a href="https://docs.budibase.com">Documentação</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas">Solicitar melhorias</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/issues">Reportar um bug</a>
<span> · </span>
Suporte: <a href="https://github.com/Budibase/budibase/discussions">Discussões</a>
</h3>
<br /><br />
## ✨ Recursos
### Construa e implante um software real
Ao contrário de outras plataformas, com o Budibase você constrói e implanta aplicativos de uma página. Os aplicativos Budibase são altamente performáticos e podem ser designados de forma responsiva, proporcionando uma experiência excepcional aos seus usuários.
<br /><br />
### Código-fonte livre e extensível
Budibase é software livre - sob a licença GPL v3. Isso deve lhe dar confiança de que o Budibase estará sempre disponível. Você também pode codificar no Budibase ou bifurcá-lo e fazer alterações conforme desejar, tornando-o amigável para desenvolvedores.
<br /><br />
### Importar dados ou começar do zero
Budibase pode extrair dados de várias fontes, incluindo MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB ou uma API REST. E ao contrário de outras plataformas, com o Budibase você pode começar do zero e criar aplicativos de negócios sem nenhuma fonte de dados. [Solicitar uma nova fonte de dados](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Dados Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/data_n1tlhf.png">
</p>
<br /><br />
### Projetar e criar aplicativos usando componentes pré-definidos
O Budibase vem com componentes lindamente projetados e poderosos que você pode usar como blocos de construção para criar sua interface do usuário. Também oferecemos muitas das suas opções de estilo CSS favoritas para que você possa mostrar sua criatividade. [Solicitar um novo componente](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Design Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970243/Out%20of%20beta%20launch/design-like-a-pro_qhlfeu.gif">
</p>
<br /><br />
### Automatizar processos, integrar outras ferramentas e conectar webhooks
Economize tempo automatizando processos manuais e fluxos de trabalho. Seja conectando-se a webhooks ou automatizando e-mails, basta dizer ao Budibase o que fazer e deixá-lo trabalhar para você. Você pode facilmente [criar uma nova automação para o Budibase aqui](https://github.com/Budibase/automations) ou [Solicitar uma nova automação](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Automações Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970486/Out%20of%20beta%20launch/automation_riro7u.png">
</p>
<br /><br />
### Integração com suas ferramentas favoritas
O Budibase se integra a várias ferramentas populares, permitindo que você crie aplicativos que se encaixam perfeitamente em sua pilha tecnológica.
<p align="center">
<img alt="Integrações Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/integrations_kc7dqt.png">
</p>
<br /><br />
### Paraíso dos administradores
O Budibase é projetado para escalar. Com o Budibase, você pode se auto-hospedar em sua própria infraestrutura e gerenciar globalmente usuários, home, SMTP, aplicativos, grupos, aparência e muito mais. Você também pode fornecer aos usuários/grupos um portal de aplicativos e delegar o gerenciamento de usuários ao líder do grupo.
- Assista ao vídeo promocional: https://youtu.be/xoljVpty_Kw
<br /><br /><br />
## 🏁 Começar
<img src="https://res.cloudinary.com/daog6scxm/image/upload/v1634808888/logo/deploy_npl9za.png" />
Implante o Budibase em auto-hospedagem em sua infraestrutura existente, usando Docker, Kubernetes e Digital Ocean.
Ou use o Budibase Cloud se você não precisar se auto-hospedar e quiser começar rapidamente.
### [Começar com o Budibase](https://budibase.com)
<br /><br />
## 🎓 Aprenda Budibase
A documentação Budibase [está aqui](https://docs.budibase.com).
<br />
<br /><br />
## 💬 Comunidade
Se você tiver alguma dúvida ou quiser conversar com outros usuários do Budibase e se juntar à nossa comunidade, visite [Discussões do Github](https://github.com/Budibase/budibase/discussions)
<br /><br /><br />
## ❗ Código de Conduta
O Budibase está comprometido em oferecer a todos uma experiência acolhedora, diversificada e livre de assédio. Esperamos que todos os membros da comunidade Budibase sigam os princípios do nosso [**Código de Conduta**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md). Obrigado por ler.
<br />
<br /><br />
## 🙌 Contribuindo para o Budibase
Seja abrindo uma issue ou criando um pull request, toda contribuição é apreciada e bem-vinda. Se você está pensando em implementar uma nova funcionalidade ou alterar a API, por favor, crie primeiro uma Issue. Assim, podemos garantir que seu trabalho não seja em vão.
### Não sabe por onde começar?
Um bom lugar para começar a contribuir é aqui: [Projetos em andamento](https://github.com/Budibase/budibase/projects/22).
### Como o repositório está organizado?
O Budibase é um monorepo gerenciado pelo lerna. O Lerna cuida da construção e publicação dos pacotes do Budibase. Aqui estão, em alto nível, os pacotes que compõem o Budibase.
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - contém o código para o aplicativo svelte do lado do cliente do budibase builder.
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - Um módulo que roda no navegador e é responsável por ler definições JSON e criar aplicativos web dinâmicos a partir delas.
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server) - O servidor budibase. Este aplicativo Koa é responsável por servir o JS para os aplicativos builder e budibase, bem como fornecer a API para interagir com o banco de dados e o sistema de arquivos.
Para mais informações, veja [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md)
<br /><br />
## 📝 Licença
O Budibase é open source, sob a licença [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). As bibliotecas do cliente e dos componentes estão licenciadas sob [MPL](https://directory.fsf.org/wiki/License:MPL-2.0) - para que os aplicativos que você cria possam ser usados sob licença como você desejar.
<br /><br />
## ⭐ Stargazers ao longo do tempo
[![Stargazers ao longo do tempo](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase)
Se você tiver problemas entre as atualizações do builder, por favor, use o guia a seguir [aqui](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting) para limpar seu ambiente.
<br /><br />
## Contribuidores ✨
Agradecimentos a estas pessoas maravilhosas ([chave de emoji](https://allcontributors.org/docs/fr/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- 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="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Testes">⚠️</a> <a href="#infra-shogunpurple" title="Infraestrutura (Hospedagem, Ferramentas de Build, 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="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Testes">⚠️</a> <a href="#infra-mike12345567" title="Infraestrutura (Hospedagem, Ferramentas de Build, 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="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Testes">⚠️</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="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Testes">⚠️</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="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Testes">⚠️</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="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Código">💻</a> <a href="#content-joebudi" title="Conteúdo">🖋</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="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Testes">⚠️</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="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Testes">⚠️</a></td>
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars.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="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Documentação">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Testes">⚠️</a></td>
<td align="center"><a href="https://github.com/Grays-world"><img src="https://avatars.githubusercontent.com/u/89784014?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Grays-world</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Grays-world" title="Código">💻</a></td>
<td align="center"><a href="https://github.com/syluss"><img src="https://avatars.githubusercontent.com/u/1770743?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sylvain Galand</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=syluss" title="Código">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=syluss" title="Documentação">📖</a></td>
<td align="center"><a href="https://github.com/John-Mullins1"><img src="https://avatars.githubusercontent.com/u/89561797?v=4?s=100" width="100px;" alt=""/><br /><sub><b>John Mullins</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=John-Mullins1" title="Código">💻</a></td>
<td align="center"><a href="https://github.com/Jakeboyd"><img src="https://avatars.githubusercontent.com/u/55934414?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jakeboyd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Jakeboyd" title="Código">💻</a></td>
<td align="center"><a href="https://github.com/stevedoescode"><img src="https://avatars.githubusercontent.com/u/29486122?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Steve Bridle</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=stevedoescode" title="Código">💻</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
<br /><br />
## Licença
Distribuído sob a licença GPL v3.0. Veja `LICENSE` para mais informações.
</p>

207
i18n/README.ru.md Normal file
View file

@ -0,0 +1,207 @@
<p align="center">
<a href="https://www.budibase.com">
<img alt="Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1696515725/Branding/Assets/Symbol/RGB/Full%20Colour/Budibase_Symbol_RGB_FullColour_cbqvha_1_z5cwq2.svg" width="60" />
</a>
</p>
<h1 align="center">
Budibase
</h1>
<h3 align="center">
Низкокодовая платформа, которую вы полюбите использовать
</h3>
<p align="center">
Budibase - это открытая низкокодовая платформа, которая представляет собой самый простой способ создания внутренних инструментов, повышающих производительность.
</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 все релизы" src="https://img.shields.io/github/downloads/Budibase/budibase/total">
</a>
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub релизы (в хронологическом порядке)" 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="Подписаться на @budibase" />
</a>
<img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Кодекс поведения" />
<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">Начать</a>
<span> · </span>
<a href="https://docs.budibase.com">Документация</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas">Запросы на улучшения</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/issues">Сообщить об ошибке</a>
<span> · </span>
Поддержка: <a href="https://github.com/Budibase/budibase/discussions">Обсуждения</a>
</h3>
<br /><br />
## ✨ Функциональные возможности
### Строим и развертываем настоящее программное обеспечение
В отличие от других платформ, с помощью Budibase вы создаете и развертываете одностраничные приложения. Приложения Budibase имеют высокую производительность и могут быть адаптированы для разных устройств, обеспечивая вашим пользователям удивительный опыт.
<br /><br />
### Открытый и расширяемый исходный код
Budibase - это свободное программное обеспечение под лицензией GPL v3. Это должно вас уверить в том, что Budibase всегда будет здесь. Вы также можете писать код в Budibase или форкнуть его и вносить изменения по своему усмотрению, что сделает его дружелюбным для разработчиков.
<br /><br />
### Импорт данных или начало с нуля
Budibase может получать данные из различных источников, включая MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB или REST API. И в отличие от других платформ, с помощью Budibase вы можете начать с нуля и создавать бизнес-приложения без каких-либо источников данных. [Запросить новый источник данных](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 />
### Проектирование и создание приложений с использованием предварительно определенных компонентов.
Budibase поставляется с красиво оформленными и мощными компонентами, которые вы можете использовать как строительные блоки для создания вашего пользовательского интерфейса. Мы также предоставляем множество ваших любимых опций стилей CSS, чтобы вы могли проявить больше креативности. [Запросить новый компонент](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 />
### Автоматизация процессов, интеграция с другими инструментами и подключение к вебхукам
Экономьте время, автоматизируя ручные процессы и рабочие потоки. Будь то подключение к вебхукам или автоматизация отправки электронных писем, просто скажите Budibase, что он должен делать, и позвольте ему работать за вас. Вы можете легко [создать новую автоматизацию для Budibase здесь](https://github.com/Budibase/automations) или [Запросить новую автоматизацию](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 />
### Интеграция с вашими любимыми инструментами
Budibase интегрируется с рядом популярных инструментов, что позволяет вам создавать приложения, которые идеально вписываются в вашу технологическую стопку.
<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 />
### Рай для админов
Budibase разработан для масштабирования. С Budibase вы можете самостоятельно размещать его на своей собственной инфраструктуре и глобально управлять пользователями, доменами, SMTP, приложениями, группами, внешним видом и многим другим. Вы также можете предоставить пользователям/группам портал приложений и поручить управление пользователями руководителю группы.
- Смотрите промо-видео: https://youtu.be/xoljVpty_Kw
<br /><br /><br />
## 🏁 Начало работы
<img src="https://res.cloudinary.com/daog6scxm/image/upload/v1634808888/logo/deploy_npl9za.png" />
Разверните Budibase на своей собственной инфраструктуре с использованием Docker, Kubernetes и Digital Ocean.
Или используйте Budibase Cloud, если вам не нужно самостоятельно размещаться, и вы хотите быстро начать.
### [Начать работу с Budibase](https://budibase.com)
<br /><br />
## 🎓 Изучение Budibase
Документация Budibase [здесь](https://docs.budibase.com).
<br />
<br /><br />
## 💬 Сообщество
Если у вас есть вопросы или вы хотите обсудить что-то с другими пользователями Budibase и присоединиться к нашему сообществу, пожалуйста, перейдите по следующей ссылке: [Обсуждения на GitHub](https://github.com/Budibase/budibase/discussions)
<br /><br /><br />
## ❗ Кодекс поведения
Budibase обязуется обеспечить каждому дружелюбный, разнообразный и безопасный опыт. Мы ожидаем, что все члены сообщества Budibase будут следовать принципам нашего [**Кодекса поведения**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md). Спасибо за внимание.
<br />
<br /><br />
## 🙌 Вклад в Budibase
Будь то открытие ошибки или создание запроса на включение изменений, любой вклад приветствуется и приветствуется. Если вы планируете реализовать новую функциональность или изменить API, сначала создайте Issue. Так мы сможем убедиться, что ваша работа не напрасна.
### Не знаете, с чего начать?
Хорошее место для начала вклада - это здесь: [Текущие проекты](https://github.com/Budibase/budibase/projects/22).
### Как организован репозиторий?
Budibase - это монорепозиторий, управляемый с помощью lerna. Lerna управляет сборкой и публикацией пакетов Budibase. Вот, в общих чертах, пакеты, из которых состоит Budibase.
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - содержит код клиентского приложения Svelte для Budibase builder.
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - Модуль, который запускается в браузере и отвечает за чтение JSON-определений и создание веб-приложений из них.
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server) - Сервер Budibase. Это приложение Koa отвечает за предоставление JS для строителей и приложений Budibase, а также предоставляет API для взаимодействия с базой данных и файловой системой.
Для получения дополнительной информации см. [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md)
<br /><br />
## 📝 Лицензия
Budibase является проектом с открытым исходным кодом, лицензированным по [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). Клиентские библиотеки и компоненты лицензируются по [MPL](https://directory.fsf.org/wiki/License:MPL-2.0), так что приложения, которые вы создаете, могут использоваться под любой лицензией, как вам угодно.
<br /><br />
## ⭐ Старгейзеры во времени
[![Stargazers во времени](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase)
Если у вас возникли проблемы между обновлениями билдера, пожалуйста, используйте следующее руководство [здесь](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting), чтобы очистить ваше окружение.
<br /><br />
## Участники ✨
Благодарим этих замечательных людей ([ключи эмодзи](https://allcontributors.org/docs/ru/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="Код">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Документация">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Тесты">⚠️</a> <a href="#infra-shogunpurple" title="Инфраструктура (хостинг, средства сборки и т. д.)">🚇</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="Документация">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Код">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Тесты">⚠️</a> <a href="#infra-mike12345567" title="Инфраструктура (хостинг, средства сборки и т. д.)">🚇</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="Документация">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Код">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Тесты">⚠️</a> <a href="#design-aptkingston" title="Дизайн">🎨</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="Документация">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Код">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Тесты">⚠️</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="Документация">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Код">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Тесты">⚠️</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="#userTesting-joe14" title="User Testing">📓</a></td>
<td align="center"><a href="https://github.com/ntkleynhans"><img src="https://avatars.githubusercontent.com/u/4908235?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nico Kleynhans</b></sub></a><br /><a href="#design-ntkleynhans" title="Дизайн">🎨</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/kkeithle"><img src="https://avatars.githubusercontent.com/u/18712925?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Keith Lee</b></sub></a><br /><a href="#design-kkeithle" title="Дизайн">🎨</a></td>
<td align="center"><a href="https://github.com/Ben-Shabs"><img src="https://avatars.githubusercontent.com/u/26257661?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ben-Shabs</b></sub></a><br /><a href="#userTesting-Ben-Shabs" title="User Testing">📓</a></td>
<td align="center"><a href="https://www.reeceking.dev"><img src="https://avatars.githubusercontent.com/u/4020324?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Reece King</b></sub></a><br /><a href="#design-reeceking" title="Дизайн">🎨</a></td>
<td align="center"><a href="https://github.com/gunjan5"><img src="https://avatars.githubusercontent.com/u/6934146?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Gunjan Chhabra</b></sub></a><br /><a href="#userTesting-gunjan5" title="User Testing">📓</a></td>
<td align="center"><a href="https://github.com/stavros-liaskos"><img src="https://avatars.githubusercontent.com/u/29320217?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Stavros Liaskos</b></sub></a><br /><a href="#design-stavros-liaskos" title="Дизайн">🎨</a></td>
<td align="center"><a href="https://github.com/theshu8"><img src="https://avatars.githubusercontent.com/u/28013049?v=4?s=100" width="100px;" alt=""/><br /><sub><b>theshu8</b></sub></a><br /><a href="#userTesting-theshu8" title="User Testing">📓</a></td>
<td align="center"><a href="https://github.com/Kleebster"><img src="https://avatars.githubusercontent.com/u/63757547?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kleebster</b></sub></a><br /><a href="#userTesting-Kleebster" title="User Testing">📓</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
</p>

View file

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

View file

@ -73,7 +73,6 @@
"chance": "1.1.8", "chance": "1.1.8",
"ioredis-mock": "8.9.0", "ioredis-mock": "8.9.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-node": "29.7.0",
"jest-serial-runner": "1.2.1", "jest-serial-runner": "1.2.1",
"pino-pretty": "10.0.0", "pino-pretty": "10.0.0",
"pouchdb-adapter-memory": "7.2.2", "pouchdb-adapter-memory": "7.2.2",

View file

@ -69,7 +69,7 @@ async function populateUsersFromDB(
export async function getUser( export async function getUser(
userId: string, userId: string,
tenantId?: string, tenantId?: string,
populateUser?: any populateUser?: (userId: string, tenantId: string) => Promise<User>
) { ) {
if (!populateUser) { if (!populateUser) {
populateUser = populateFromDB populateUser = populateFromDB
@ -83,7 +83,7 @@ export async function getUser(
} }
const client = await redis.getUserClient() const client = await redis.getUserClient()
// try cache // try cache
let user = await client.get(userId) let user: User = await client.get(userId)
if (!user) { if (!user) {
user = await populateUser(userId, tenantId) user = await populateUser(userId, tenantId)
await client.store(userId, user, EXPIRY_SECONDS) await client.store(userId, user, EXPIRY_SECONDS)

View file

@ -281,7 +281,7 @@ export function doInScimContext(task: any) {
return newContext(updates, task) return newContext(updates, task)
} }
export async function ensureSnippetContext() { export async function ensureSnippetContext(enabled = !env.isTest()) {
const ctx = getCurrentContext() const ctx = getCurrentContext()
// If we've already added snippets to context, continue // If we've already added snippets to context, continue
@ -292,7 +292,7 @@ export async function ensureSnippetContext() {
// Otherwise get snippets for this app and update context // Otherwise get snippets for this app and update context
let snippets: Snippet[] | undefined let snippets: Snippet[] | undefined
const db = getAppDB() const db = getAppDB()
if (db && !env.isTest()) { if (db && enabled) {
const app = await db.get<App>(DocumentType.APP_METADATA) const app = await db.get<App>(DocumentType.APP_METADATA)
snippets = app.snippets snippets = app.snippets
} }

View file

@ -3,11 +3,11 @@ import {
AllDocsResponse, AllDocsResponse,
AnyDocument, AnyDocument,
Database, Database,
DatabaseOpts,
DatabaseQueryOpts,
DatabasePutOpts,
DatabaseCreateIndexOpts, DatabaseCreateIndexOpts,
DatabaseDeleteIndexOpts, DatabaseDeleteIndexOpts,
DatabaseOpts,
DatabasePutOpts,
DatabaseQueryOpts,
Document, Document,
isDocument, isDocument,
RowResponse, RowResponse,
@ -17,7 +17,7 @@ import {
import { getCouchInfo } from "./connections" import { getCouchInfo } from "./connections"
import { directCouchUrlCall } from "./utils" import { directCouchUrlCall } from "./utils"
import { getPouchDB } from "./pouchDB" import { getPouchDB } from "./pouchDB"
import { WriteStream, ReadStream } from "fs" import { ReadStream, WriteStream } from "fs"
import { newid } from "../../docIds/newid" import { newid } from "../../docIds/newid"
import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { SQLITE_DESIGN_DOC_ID } from "../../constants"
import { DDInstrumentedDatabase } from "../instrumentation" import { DDInstrumentedDatabase } from "../instrumentation"
@ -38,6 +38,39 @@ function buildNano(couchInfo: { url: string; cookie: string }) {
type DBCall<T> = () => Promise<T> type DBCall<T> = () => Promise<T>
class CouchDBError extends Error {
status: number
statusCode: number
reason: string
name: string
errid: string
error: string
description: string
constructor(
message: string,
info: {
status: number | undefined
statusCode: number | undefined
name: string
errid: string
description: string
reason: string
error: string
}
) {
super(message)
const statusCode = info.status || info.statusCode || 500
this.status = statusCode
this.statusCode = statusCode
this.reason = info.reason
this.name = info.name
this.errid = info.errid
this.description = info.description
this.error = info.error
}
}
export function DatabaseWithConnection( export function DatabaseWithConnection(
dbName: string, dbName: string,
connection: string, connection: string,
@ -119,7 +152,7 @@ export class DatabaseImpl implements Database {
} catch (err: any) { } catch (err: any) {
// Handling race conditions // Handling race conditions
if (err.statusCode !== 412) { if (err.statusCode !== 412) {
throw err throw new CouchDBError(err.message, err)
} }
} }
} }
@ -138,10 +171,9 @@ export class DatabaseImpl implements Database {
if (err.statusCode === 404 && err.reason === DATABASE_NOT_FOUND) { if (err.statusCode === 404 && err.reason === DATABASE_NOT_FOUND) {
await this.checkAndCreateDb() await this.checkAndCreateDb()
return await this.performCall(call) return await this.performCall(call)
} else if (err.statusCode) {
err.status = err.statusCode
} }
throw err // stripping the error down the props which are safe/useful, drop everything else
throw new CouchDBError(`CouchDB error: ${err.message}`, err)
} }
} }
@ -288,7 +320,7 @@ export class DatabaseImpl implements Database {
if (err.statusCode === 404) { if (err.statusCode === 404) {
return return
} else { } else {
throw { ...err, status: err.statusCode } throw new CouchDBError(err.message, err)
} }
} }
} }

View file

@ -12,6 +12,10 @@ import { dataFilters } from "@budibase/shared-core"
export const removeKeyNumbering = dataFilters.removeKeyNumbering export const removeKeyNumbering = dataFilters.removeKeyNumbering
function isEmpty(value: any) {
return value == null || value === ""
}
/** /**
* Class to build lucene query URLs. * Class to build lucene query URLs.
* Optionally takes a base lucene query object. * Optionally takes a base lucene query object.
@ -282,15 +286,14 @@ export class QueryBuilder<T> {
} }
const equal = (key: string, value: any) => { const equal = (key: string, value: any) => {
// 0 evaluates to false, which means we would return all rows if we don't check it if (isEmpty(value)) {
if (!value && value !== 0) {
return null return null
} }
return `${key}:${builder.preprocess(value, allPreProcessingOpts)}` return `${key}:${builder.preprocess(value, allPreProcessingOpts)}`
} }
const contains = (key: string, value: any, mode = "AND") => { const contains = (key: string, value: any, mode = "AND") => {
if (!value || (Array.isArray(value) && value.length === 0)) { if (isEmpty(value)) {
return null return null
} }
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@ -306,7 +309,7 @@ export class QueryBuilder<T> {
} }
const fuzzy = (key: string, value: any) => { const fuzzy = (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
value = builder.preprocess(value, { value = builder.preprocess(value, {
@ -328,7 +331,7 @@ export class QueryBuilder<T> {
} }
const oneOf = (key: string, value: any) => { const oneOf = (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return `*:*` return `*:*`
} }
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
@ -386,7 +389,7 @@ export class QueryBuilder<T> {
// Construct the actual lucene search query string from JSON structure // Construct the actual lucene search query string from JSON structure
if (this.#query.string) { if (this.#query.string) {
build(this.#query.string, (key: string, value: any) => { build(this.#query.string, (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
value = builder.preprocess(value, { value = builder.preprocess(value, {
@ -399,7 +402,7 @@ export class QueryBuilder<T> {
} }
if (this.#query.range) { if (this.#query.range) {
build(this.#query.range, (key: string, value: any) => { build(this.#query.range, (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
if (value.low == null || value.low === "") { if (value.low == null || value.low === "") {
@ -421,7 +424,7 @@ export class QueryBuilder<T> {
} }
if (this.#query.notEqual) { if (this.#query.notEqual) {
build(this.#query.notEqual, (key: string, value: any) => { build(this.#query.notEqual, (key: string, value: any) => {
if (!value) { if (isEmpty(value)) {
return null return null
} }
if (typeof value === "boolean") { if (typeof value === "boolean") {
@ -431,10 +434,28 @@ export class QueryBuilder<T> {
}) })
} }
if (this.#query.empty) { if (this.#query.empty) {
build(this.#query.empty, (key: string) => `(*:* -${key}:["" TO *])`) build(this.#query.empty, (key: string) => {
// Because the structure of an empty filter looks like this:
// { empty: { someKey: null } }
//
// The check inside of `build` does not set `allFiltersEmpty`, which results
// in weird behaviour when the empty filter is the only filter. We get around
// this by setting `allFiltersEmpty` to false here.
allFiltersEmpty = false
return `(*:* -${key}:["" TO *])`
})
} }
if (this.#query.notEmpty) { if (this.#query.notEmpty) {
build(this.#query.notEmpty, (key: string) => `${key}:["" TO *]`) build(this.#query.notEmpty, (key: string) => {
// Because the structure of a notEmpty filter looks like this:
// { notEmpty: { someKey: null } }
//
// The check inside of `build` does not set `allFiltersEmpty`, which results
// in weird behaviour when the empty filter is the only filter. We get around
// this by setting `allFiltersEmpty` to false here.
allFiltersEmpty = false
return `${key}:["" TO *]`
})
} }
if (this.#query.oneOf) { if (this.#query.oneOf) {
build(this.#query.oneOf, oneOf) build(this.#query.oneOf, oneOf)

View file

@ -13,7 +13,7 @@ import { getGlobalDB, doInTenant } from "../context"
import { decrypt } from "../security/encryption" import { decrypt } from "../security/encryption"
import * as identity from "../context/identity" import * as identity from "../context/identity"
import env from "../environment" import env from "../environment"
import { Ctx, EndpointMatcher, SessionCookie } from "@budibase/types" import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types"
import { InvalidAPIKeyError, ErrorCode } from "../errors" import { InvalidAPIKeyError, ErrorCode } from "../errors"
import tracer from "dd-trace" import tracer from "dd-trace"
@ -41,7 +41,10 @@ function finalise(ctx: any, opts: FinaliseOpts = {}) {
ctx.version = opts.version ctx.version = opts.version
} }
async function checkApiKey(apiKey: string, populateUser?: Function) { async function checkApiKey(
apiKey: string,
populateUser?: (userId: string, tenantId: string) => Promise<User>
) {
// check both the primary and the fallback internal api keys // check both the primary and the fallback internal api keys
// this allows for rotation // this allows for rotation
if (isValidInternalAPIKey(apiKey)) { if (isValidInternalAPIKey(apiKey)) {
@ -128,6 +131,7 @@ export default function (
} else { } else {
user = await getUser(userId, session.tenantId) user = await getUser(userId, session.tenantId)
} }
// @ts-ignore
user.csrfToken = session.csrfToken user.csrfToken = session.csrfToken
if (session?.lastAccessedAt < timeMinusOneMinute()) { if (session?.lastAccessedAt < timeMinusOneMinute()) {
@ -167,19 +171,25 @@ export default function (
authenticated = false authenticated = false
} }
if (user) { const isUser = (
user: any
): user is User & { budibaseAccess?: string } => {
return user && user.email
}
if (isUser(user)) {
tracer.setUser({ tracer.setUser({
id: user?._id, id: user._id!,
tenantId: user?.tenantId, tenantId: user.tenantId,
budibaseAccess: user?.budibaseAccess, budibaseAccess: user.budibaseAccess,
status: user?.status, status: user.status,
}) })
} }
// isAuthenticated is a function, so use a variable to be able to check authed state // isAuthenticated is a function, so use a variable to be able to check authed state
finalise(ctx, { authenticated, user, internal, version, publicEndpoint }) finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
if (user && user.email) { if (isUser(user)) {
return identity.doInUserContext(user, ctx, next) return identity.doInUserContext(user, ctx, next)
} else { } else {
return next() return next()

View file

@ -13,13 +13,14 @@ import { bucketTTLConfig, budibaseTempDir } from "./utils"
import { v4 } from "uuid" import { v4 } from "uuid"
import { APP_PREFIX, APP_DEV_PREFIX } from "../db" import { APP_PREFIX, APP_DEV_PREFIX } from "../db"
import fsp from "fs/promises" import fsp from "fs/promises"
import { HeadObjectOutput } from "aws-sdk/clients/s3"
const streamPipeline = promisify(stream.pipeline) const streamPipeline = promisify(stream.pipeline)
// use this as a temporary store of buckets that are being created // use this as a temporary store of buckets that are being created
const STATE = { const STATE = {
bucketCreationPromises: {}, bucketCreationPromises: {},
} }
const signedFilePrefix = "/files/signed" export const SIGNED_FILE_PREFIX = "/files/signed"
type ListParams = { type ListParams = {
ContinuationToken?: string ContinuationToken?: string
@ -40,8 +41,13 @@ type UploadParams = BaseUploadParams & {
path?: string | PathLike path?: string | PathLike
} }
type StreamUploadParams = BaseUploadParams & { export type StreamTypes =
stream: ReadStream | ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes
} }
const CONTENT_TYPE_MAP: any = { const CONTENT_TYPE_MAP: any = {
@ -83,7 +89,7 @@ export function ObjectStore(
bucket: string, bucket: string,
opts: { presigning: boolean } = { presigning: false } opts: { presigning: boolean } = { presigning: false }
) { ) {
const config: any = { const config: AWS.S3.ClientConfiguration = {
s3ForcePathStyle: true, s3ForcePathStyle: true,
signatureVersion: "v4", signatureVersion: "v4",
apiVersion: "2006-03-01", apiVersion: "2006-03-01",
@ -174,11 +180,9 @@ export async function upload({
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) { if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl) let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) { await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
} }
let contentType = type let contentType = type
@ -222,11 +226,9 @@ export async function streamUpload({
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
const bucketCreated = await createBucketIfNotExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && (bucketCreated.created || bucketCreated.exists)) { if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl) let ttlConfig = bucketTTLConfig(bucketName, ttl)
if (objectStore.putBucketLifecycleConfiguration) { await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
} }
// Set content type for certain known extensions // Set content type for certain known extensions
@ -333,7 +335,7 @@ export function getPresignedUrl(
const signedUrl = new URL(url) const signedUrl = new URL(url)
const path = signedUrl.pathname const path = signedUrl.pathname
const query = signedUrl.search const query = signedUrl.search
return `${signedFilePrefix}${path}${query}` return `${SIGNED_FILE_PREFIX}${path}${query}`
} }
} }
@ -521,6 +523,26 @@ export async function getReadStream(
return client.getObject(params).createReadStream() return client.getObject(params).createReadStream()
} }
export async function getObjectMetadata(
bucket: string,
path: string
): Promise<HeadObjectOutput> {
bucket = sanitizeBucket(bucket)
path = sanitizeKey(path)
const client = ObjectStore(bucket)
const params = {
Bucket: bucket,
Key: path,
}
try {
return await client.headObject(params).promise()
} catch (err: any) {
throw new Error("Unable to retrieve metadata from object")
}
}
/* /*
Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract Given a signed url like '/files/signed/tmp-files-attachments/app_123456/myfile.txt' extract
the bucket and the path from it the bucket and the path from it
@ -530,7 +552,9 @@ export function extractBucketAndPath(
): { bucket: string; path: string } | null { ): { bucket: string; path: string } | null {
const baseUrl = url.split("?")[0] const baseUrl = url.split("?")[0]
const regex = new RegExp(`^${signedFilePrefix}/(?<bucket>[^/]+)/(?<path>.+)$`) const regex = new RegExp(
`^${SIGNED_FILE_PREFIX}/(?<bucket>[^/]+)/(?<path>.+)$`
)
const match = baseUrl.match(regex) const match = baseUrl.match(regex)
if (match && match.groups) { if (match && match.groups) {

View file

@ -1,9 +1,14 @@
import { join } from "path" import path, { join } from "path"
import { tmpdir } from "os" import { tmpdir } from "os"
import fs from "fs" import fs from "fs"
import env from "../environment" import env from "../environment"
import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3" import { PutBucketLifecycleConfigurationRequest } from "aws-sdk/clients/s3"
import * as objectStore from "./objectStore"
import {
AutomationAttachment,
AutomationAttachmentContent,
BucketedContent,
} from "@budibase/types"
/**************************************************** /****************************************************
* NOTE: When adding a new bucket - name * * NOTE: When adding a new bucket - name *
* sure that S3 usages (like budibase-infra) * * sure that S3 usages (like budibase-infra) *
@ -55,3 +60,50 @@ export const bucketTTLConfig = (
return params return params
} }
async function processUrlAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent> {
const response = await fetch(attachment.url)
if (!response.ok || !response.body) {
throw new Error(`Unexpected response ${response.statusText}`)
}
const fallbackFilename = path.basename(new URL(attachment.url).pathname)
return {
filename: attachment.filename || fallbackFilename,
content: response.body,
}
}
export async function processObjectStoreAttachment(
attachment: AutomationAttachment
): Promise<BucketedContent> {
const result = objectStore.extractBucketAndPath(attachment.url)
if (result === null) {
throw new Error("Invalid signed URL")
}
const { bucket, path: objectPath } = result
const readStream = await objectStore.getReadStream(bucket, objectPath)
const fallbackFilename = path.basename(objectPath)
return {
bucket,
path: objectPath,
filename: attachment.filename || fallbackFilename,
content: readStream,
}
}
export async function processAutomationAttachment(
attachment: AutomationAttachment
): Promise<AutomationAttachmentContent | BucketedContent> {
const isFullyFormedUrl =
attachment.url?.startsWith("http://") ||
attachment.url?.startsWith("https://")
if (isFullyFormedUrl) {
return await processUrlAttachment(attachment)
} else {
return await processObjectStoreAttachment(attachment)
}
}

View file

@ -4,6 +4,3 @@ export { generator } from "./structures"
export * as testContainerUtils from "./testContainerUtils" export * as testContainerUtils from "./testContainerUtils"
export * as utils from "./utils" export * as utils from "./utils"
export * from "./jestUtils" export * from "./jestUtils"
import * as minio from "./minio"
export const objectStoreTestProviders = { minio }

View file

@ -1,34 +0,0 @@
import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"
import { AbstractWaitStrategy } from "testcontainers/build/wait-strategies/wait-strategy"
import env from "../../../src/environment"
let container: StartedTestContainer | undefined
class ObjectStoreWaitStrategy extends AbstractWaitStrategy {
async waitUntilReady(container: any, boundPorts: any, startTime?: Date) {
const logs = Wait.forListeningPorts()
await logs.waitUntilReady(container, boundPorts, startTime)
}
}
export async function start(): Promise<void> {
container = await new GenericContainer("minio/minio")
.withExposedPorts(9000)
.withCommand(["server", "/data"])
.withEnvironment({
MINIO_ACCESS_KEY: "budibase",
MINIO_SECRET_KEY: "budibase",
})
.withWaitStrategy(new ObjectStoreWaitStrategy().withStartupTimeout(30000))
.start()
const port = container.getMappedPort(9000)
env._set("MINIO_URL", `http://0.0.0.0:${port}`)
}
export async function stop() {
if (container) {
await container.stop()
container = undefined
}
}

View file

@ -86,10 +86,18 @@ export function setupEnv(...envs: any[]) {
throw new Error("CouchDB SQL port not found") throw new Error("CouchDB SQL port not found")
} }
const minio = getContainerByImage("minio/minio")
const minioPort = getExposedV4Port(minio, 9000)
if (!minioPort) {
throw new Error("Minio port not found")
}
const configs = [ const configs = [
{ key: "COUCH_DB_PORT", value: `${couchPort}` }, { key: "COUCH_DB_PORT", value: `${couchPort}` },
{ key: "COUCH_DB_URL", value: `http://127.0.0.1:${couchPort}` }, { key: "COUCH_DB_URL", value: `http://127.0.0.1:${couchPort}` },
{ key: "COUCH_DB_SQL_URL", value: `http://127.0.0.1:${couchSqlPort}` }, { key: "COUCH_DB_SQL_URL", value: `http://127.0.0.1:${couchSqlPort}` },
{ key: "MINIO_URL", value: `http://127.0.0.1:${minioPort}` },
] ]
for (const config of configs.filter(x => !!x.value)) { for (const config of configs.filter(x => !!x.value)) {

View file

@ -8,6 +8,8 @@
export let size = "S" export let size = "S"
export let extraButtonText export let extraButtonText
export let extraButtonAction export let extraButtonAction
export let extraLinkText
export let extraLinkAction
export let showCloseButton = true export let showCloseButton = true
let show = true let show = true
@ -28,8 +30,13 @@
<use xlink:href="#spectrum-icon-18-{icon}" /> <use xlink:href="#spectrum-icon-18-{icon}" />
</svg> </svg>
<div class="spectrum-Toast-body"> <div class="spectrum-Toast-body">
<div class="spectrum-Toast-content"> <div class="spectrum-Toast-content row-content">
<slot /> <slot />
{#if extraLinkText}
<button class="link" on:click={extraLinkAction}>
<u>{extraLinkText}</u>
</button>
{/if}
</div> </div>
{#if extraButtonText && extraButtonAction} {#if extraButtonText && extraButtonAction}
<button <button
@ -73,4 +80,23 @@
.spectrum-Button { .spectrum-Button {
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
} }
.row-content {
display: flex;
}
.link {
background: none;
border: none;
margin: 0;
margin-left: 0.5em;
padding: 0;
cursor: pointer;
color: white;
font-weight: 600;
}
u {
font-weight: 600;
}
</style> </style>

View file

@ -11,6 +11,7 @@
export let error = null export let error = null
export let validate = null export let validate = null
export let suffix = null export let suffix = null
export let validateOn = "change"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -24,7 +25,16 @@
const newValue = e.target.value const newValue = e.target.value
dispatch("change", newValue) dispatch("change", newValue)
value = newValue value = newValue
if (validate) { if (validate && (error || validateOn === "change")) {
error = validate(newValue)
}
}
const onBlur = e => {
focused = false
const newValue = e.target.value
dispatch("blur", newValue)
if (validate && validateOn === "blur") {
error = validate(newValue) error = validate(newValue)
} }
} }
@ -61,7 +71,7 @@
type={type || "text"} type={type || "text"}
on:input={onChange} on:input={onChange}
on:focus={() => (focused = true)} on:focus={() => (focused = true)}
on:blur={() => (focused = false)} on:blur={onBlur}
class:placeholder class:placeholder
bind:this={ref} bind:this={ref}
/> />

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 804 KiB

View file

@ -93,7 +93,6 @@
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "29.7.0", "jest": "29.7.0",
"jsdom": "^21.1.1", "jsdom": "^21.1.1",
"ncp": "^2.0.0",
"svelte-jester": "^1.3.2", "svelte-jester": "^1.3.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-static-copy": "^0.17.0", "vite-plugin-static-copy": "^0.17.0",

View file

@ -48,6 +48,7 @@
import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { TriggerStepID, ActionStepID } from "constants/backend/automations"
import { onMount } from "svelte" import { onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { FIELDS } from "constants/backend"
export let block export let block
export let testData export let testData
@ -228,6 +229,10 @@
categoryName, categoryName,
bindingName bindingName
) => { ) => {
const field = Object.values(FIELDS).find(
field => field.type === value.type && field.subtype === value.subtype
)
return { return {
readableBinding: bindingName readableBinding: bindingName
? `${bindingName}.${name}` ? `${bindingName}.${name}`
@ -238,7 +243,7 @@
icon, icon,
category: categoryName, category: categoryName,
display: { display: {
type: value.type, type: field?.name || value.type,
name, name,
rank: isLoopBlock ? idx + 1 : idx - loopBlockCount, rank: isLoopBlock ? idx + 1 : idx - loopBlockCount,
}, },
@ -282,6 +287,7 @@
for (const key in table?.schema) { for (const key in table?.schema) {
schema[key] = { schema[key] = {
type: table.schema[key].type, type: table.schema[key].type,
subtype: table.schema[key].subtype,
} }
} }
// remove the original binding // remove the original binding
@ -358,7 +364,8 @@
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" && value.customType !== "triggerSchema" &&
value.customType !== "automationFields" && value.customType !== "automationFields" &&
value.type !== "attachment" value.type !== "attachment" &&
value.type !== "attachment_single"
) )
} }

View file

@ -2,6 +2,8 @@
import { tables } from "stores/builder" import { tables } from "stores/builder"
import { Select, Checkbox, Label } from "@budibase/bbui" import { Select, Checkbox, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { FieldType } from "@budibase/types"
import RowSelectorTypes from "./RowSelectorTypes.svelte" import RowSelectorTypes from "./RowSelectorTypes.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte" import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
@ -14,7 +16,6 @@
export let bindings export let bindings
export let isTestModal export let isTestModal
export let isUpdateRow export let isUpdateRow
$: parsedBindings = bindings.map(binding => { $: parsedBindings = bindings.map(binding => {
let clone = Object.assign({}, binding) let clone = Object.assign({}, binding)
clone.icon = "ShareAndroid" clone.icon = "ShareAndroid"
@ -26,15 +27,19 @@
$: { $: {
table = $tables.list.find(table => table._id === value?.tableId) table = $tables.list.find(table => table._id === value?.tableId)
schemaFields = Object.entries(table?.schema ?? {})
// surface the schema so the user can see it in the json // Just sorting attachment types to the bottom here for a cleaner UX
schemaFields.map(([, schema]) => { schemaFields = Object.entries(table?.schema ?? {}).sort(
([, schemaA], [, schemaB]) =>
(schemaA.type === "attachment") - (schemaB.type === "attachment")
)
schemaFields.forEach(([, schema]) => {
if (!schema.autocolumn && !value[schema.name]) { if (!schema.autocolumn && !value[schema.name]) {
value[schema.name] = "" value[schema.name] = ""
} }
}) })
} }
const onChangeTable = e => { const onChangeTable = e => {
value["tableId"] = e.detail value["tableId"] = e.detail
dispatch("change", value) dispatch("change", value)
@ -114,10 +119,16 @@
</div> </div>
{#if schemaFields.length} {#if schemaFields.length}
{#each schemaFields as [field, schema]} {#each schemaFields as [field, schema]}
{#if !schema.autocolumn && schema.type !== "attachment"} {#if !schema.autocolumn}
<div class="schema-fields"> <div
class:schema-fields={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
<Label>{field}</Label> <Label>{field}</Label>
<div class="field-width"> <div
class:field-width={schema.type !== FieldType.ATTACHMENTS &&
schema.type !== FieldType.ATTACHMENT_SINGLE}
>
{#if isTestModal} {#if isTestModal}
<RowSelectorTypes <RowSelectorTypes
{isTestModal} {isTestModal}

View file

@ -1,10 +1,12 @@
<script> <script>
import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui" import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte" import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let onChange export let onChange
export let field export let field
@ -22,6 +24,27 @@
function schemaHasOptions(schema) { function schemaHasOptions(schema) {
return !!schema.constraints?.inclusion?.length return !!schema.constraints?.inclusion?.length
} }
const handleAttachmentParams = keyValuObj => {
let params = {}
if (
schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(keyValuObj).length === 0
) {
return []
}
if (!Array.isArray(keyValuObj)) {
keyValuObj = [keyValuObj]
}
if (keyValuObj.length) {
for (let param of keyValuObj) {
params[param.url] = param.filename
}
}
return params
}
</script> </script>
{#if schemaHasOptions(schema) && schema.type !== "array"} {#if schemaHasOptions(schema) && schema.type !== "array"}
@ -77,6 +100,35 @@
on:change={e => onChange(e, field)} on:change={e => onChange(e, field)}
useLabel={false} useLabel={false}
/> />
{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE}
<div class="attachment-field-spacinng">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail:
schema.type === FieldType.ATTACHMENT_SINGLE
? e.detail.length > 0
? { url: e.detail[0].name, filename: e.detail[0].value }
: {}
: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
field
)}
object={handleAttachmentParams(value[field])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
actionButtonDisabled={schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(value[field]).length >= 1}
/>
</div>
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)} {:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)}
<svelte:component <svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput} this={isTestModal ? ModalBindableInput : DrawerBindableInput}
@ -90,3 +142,10 @@
title={schema.name} title={schema.name}
/> />
{/if} {/if}
<style>
.attachment-field-spacinng {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-l);
}
</style>

View file

@ -1,7 +1,9 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { ActionButton, Modal, ModalContent } from "@budibase/bbui" import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates"
export let schema export let schema
export let filters export let filters
@ -10,7 +12,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let modal let drawer
$: tempValue = filters || [] $: tempValue = filters || []
$: schemaFields = Object.entries(schema || {}).map( $: schemaFields = Object.entries(schema || {}).map(
@ -22,37 +24,53 @@
$: text = getText(filters) $: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0 $: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0
$: bindings = [
{
type: "context",
runtimeBinding: `${makePropSafe("now")}`,
readableBinding: `Date`,
category: "Date",
icon: "Date",
display: {
name: "Server date",
},
},
...getUserBindings(),
]
const getText = filters => { const getText = filters => {
const count = filters?.filter(filter => filter.field)?.length const count = filters?.filter(filter => filter.field)?.length
return count ? `Filter (${count})` : "Filter" return count ? `Filter (${count})` : "Filter"
} }
</script> </script>
<ActionButton icon="Filter" quiet {disabled} on:click={modal.show} {selected}> <ActionButton icon="Filter" quiet {disabled} on:click={drawer.show} {selected}>
{text} {text}
</ActionButton> </ActionButton>
<Modal bind:this={modal}>
<ModalContent
title="Filter"
confirmText="Save"
size="XL"
onConfirm={() => dispatch("change", tempValue)}
>
<div class="wrapper">
<FilterBuilder
allowBindings={false}
{filters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)}
/>
</div>
</ModalContent>
</Modal>
<style> <Drawer
.wrapper :global(.main) { bind:this={drawer}
padding: 0; title="Filtering"
} on:drawerHide
</style> on:drawerShow
forceModal
>
<Button
cta
slot="buttons"
on:click={() => {
dispatch("change", tempValue)
drawer.hide()
}}
>
Save
</Button>
<DrawerContent slot="body">
<FilterBuilder
{filters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)}
{bindings}
/>
</DrawerContent>
</Drawer>

View file

@ -55,7 +55,7 @@ export function getBindings({
) )
} }
const field = Object.values(FIELDS).find( const field = Object.values(FIELDS).find(
field => field.type === schema.type field => field.type === schema.type && field.subtype === schema.subtype
) )
const label = path == null ? column : `${path}.0.${column}` const label = path == null ? column : `${path}.0.${column}`

View file

@ -12,8 +12,13 @@
OptionSelectDnD, OptionSelectDnD,
Layout, Layout,
AbsTooltip, AbsTooltip,
ProgressCircle,
} from "@budibase/bbui" } from "@budibase/bbui"
import { SWITCHABLE_TYPES, ValidColumnNameRegex } from "@budibase/shared-core" import {
SWITCHABLE_TYPES,
ValidColumnNameRegex,
helpers,
} from "@budibase/shared-core"
import { createEventDispatcher, getContext, onMount } from "svelte" import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/builder" import { tables, datasources } from "stores/builder"
@ -30,8 +35,8 @@
import { getBindings } from "components/backend/DataTable/formula" import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte" import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { import {
FieldType,
BBReferenceFieldSubType, BBReferenceFieldSubType,
FieldType,
SourceName, SourceName,
} from "@budibase/types" } from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte" import RelationshipSelector from "components/common/RelationshipSelector.svelte"
@ -67,7 +72,6 @@
let savingColumn let savingColumn
let deleteColName let deleteColName
let jsonSchemaModal let jsonSchemaModal
let allowedTypes = []
let editableColumn = { let editableColumn = {
type: FIELDS.STRING.type, type: FIELDS.STRING.type,
constraints: FIELDS.STRING.constraints, constraints: FIELDS.STRING.constraints,
@ -175,6 +179,11 @@
SWITCHABLE_TYPES[field.type] && SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn) !editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
const fieldDefinitions = Object.values(FIELDS).reduce( const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id // Storing the fields by complex field id
(acc, field) => ({ (acc, field) => ({
@ -188,7 +197,10 @@
// don't make field IDs for auto types // don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) { if (type === AUTO_TYPE || autocolumn) {
return type.toUpperCase() return type.toUpperCase()
} else if (type === FieldType.BB_REFERENCE) { } else if (
type === FieldType.BB_REFERENCE ||
type === FieldType.BB_REFERENCE_SINGLE
) {
return `${type}${subtype || ""}`.toUpperCase() return `${type}${subtype || ""}`.toUpperCase()
} else { } else {
return type.toUpperCase() return type.toUpperCase()
@ -226,11 +238,6 @@
editableColumn.subtype, editableColumn.subtype,
editableColumn.autocolumn editableColumn.autocolumn
) )
allowedTypes = getAllowedTypes().map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
} }
} }
@ -245,11 +252,11 @@
} }
async function saveColumn() { async function saveColumn() {
savingColumn = true
if (errors?.length) { if (errors?.length) {
return return
} }
savingColumn = true
let saveColumn = cloneDeep(editableColumn) let saveColumn = cloneDeep(editableColumn)
delete saveColumn.fieldId delete saveColumn.fieldId
@ -264,13 +271,6 @@
if (saveColumn.type !== LINK_TYPE) { if (saveColumn.type !== LINK_TYPE) {
delete saveColumn.fieldName delete saveColumn.fieldName
} }
if (isUsersColumn(saveColumn)) {
if (saveColumn.subtype === BBReferenceFieldSubType.USER) {
saveColumn.relationshipType = RelationshipType.ONE_TO_MANY
} else if (saveColumn.subtype === BBReferenceFieldSubType.USERS) {
saveColumn.relationshipType = RelationshipType.MANY_TO_MANY
}
}
try { try {
await tables.saveField({ await tables.saveField({
@ -289,6 +289,8 @@
} }
} catch (err) { } catch (err) {
notifications.error(`Error saving column: ${err.message}`) notifications.error(`Error saving column: ${err.message}`)
} finally {
savingColumn = false
} }
} }
@ -363,20 +365,36 @@
deleteColName = "" deleteColName = ""
} }
function getAllowedTypes() { function getAllowedTypes(datasource) {
if (originalName) { if (originalName) {
const possibleTypes = SWITCHABLE_TYPES[field.type] || [ let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type]
editableColumn.type, if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) {
] // This will handle old single users columns
return [
{
...FIELDS.USER,
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
]
} else if (
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === BBReferenceFieldSubType.USERS
) {
// This will handle old multi users columns
return [
{
...FIELDS.USERS,
subtype: BBReferenceFieldSubType.USERS,
},
]
}
return Object.entries(FIELDS) return Object.entries(FIELDS)
.filter(([_, field]) => possibleTypes.includes(field.type)) .filter(([_, field]) => possibleTypes.includes(field.type))
.map(([_, fieldDefinition]) => fieldDefinition) .map(([_, fieldDefinition]) => fieldDefinition)
} }
const isUsers =
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === BBReferenceFieldSubType.USERS
if (!externalTable) { if (!externalTable) {
return [ return [
FIELDS.STRING, FIELDS.STRING,
@ -393,7 +411,8 @@
FIELDS.LINK, FIELDS.LINK,
FIELDS.FORMULA, FIELDS.FORMULA,
FIELDS.JSON, FIELDS.JSON,
isUsers ? FIELDS.USERS : FIELDS.USER, FIELDS.USER,
FIELDS.USERS,
FIELDS.AUTO, FIELDS.AUTO,
] ]
} else { } else {
@ -407,8 +426,12 @@
FIELDS.BOOLEAN, FIELDS.BOOLEAN,
FIELDS.FORMULA, FIELDS.FORMULA,
FIELDS.BIGINT, FIELDS.BIGINT,
isUsers ? FIELDS.USERS : FIELDS.USER, FIELDS.USER,
] ]
if (datasource && datasource.source !== SourceName.GOOGLE_SHEETS) {
fields.push(FIELDS.USERS)
}
// no-sql or a spreadsheet // no-sql or a spreadsheet
if (!externalTable || table.sql) { if (!externalTable || table.sql) {
fields = [...fields, FIELDS.LINK, FIELDS.ARRAY] fields = [...fields, FIELDS.LINK, FIELDS.ARRAY]
@ -482,15 +505,6 @@
return newError return newError
} }
function isUsersColumn(column) {
return (
column.type === FieldType.BB_REFERENCE &&
[BBReferenceFieldSubType.USER, BBReferenceFieldSubType.USERS].includes(
column.subtype
)
)
}
onMount(() => { onMount(() => {
mounted = true mounted = true
}) })
@ -689,22 +703,6 @@
<Button primary text on:click={openJsonSchemaEditor} <Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button >Open schema editor</Button
> >
{:else if isUsersColumn(editableColumn) && datasource?.source !== SourceName.GOOGLE_SHEETS}
<Toggle
value={editableColumn.subtype === BBReferenceFieldSubType.USERS}
on:change={e =>
handleTypeChange(
makeFieldId(
FieldType.BB_REFERENCE,
e.detail
? BBReferenceFieldSubType.USERS
: BBReferenceFieldSubType.USER
)
)}
disabled={!isCreating}
thin
text="Allow multiple users"
/>
{/if} {/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn} {#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select <Select
@ -739,7 +737,20 @@
<Button quiet warning text on:click={confirmDelete}>Delete</Button> <Button quiet warning text on:click={confirmDelete}>Delete</Button>
{/if} {/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button> <Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
<Button disabled={invalid} newStyles cta on:click={saveColumn}>Save</Button> <Button
disabled={invalid || savingColumn}
newStyles
cta
on:click={saveColumn}
>
{#if savingColumn}
<div class="save-loading">
<ProgressCircle overBackground={true} size="S" />
</div>
{:else}
Save
{/if}
</Button>
</div> </div>
<Modal bind:this={jsonSchemaModal}> <Modal bind:this={jsonSchemaModal}>
<JSONSchemaModal <JSONSchemaModal
@ -804,4 +815,9 @@
cursor: pointer; cursor: pointer;
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
} }
.save-loading {
display: flex;
justify-content: center;
}
</style> </style>

View file

@ -13,7 +13,9 @@
onMount(() => subscribe("edit-column", editColumn)) onMount(() => subscribe("edit-column", editColumn))
</script> </script>
<CreateEditColumn {#if editableColumn}
field={editableColumn} <CreateEditColumn
on:updatecolumns={rows.actions.refreshData} field={editableColumn}
/> on:updatecolumns={rows.actions.refreshData}
/>
{/if}

View file

@ -59,13 +59,17 @@
value: FieldType.ATTACHMENTS, value: FieldType.ATTACHMENTS,
}, },
{ {
label: "User", label: "Users",
value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USER}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USER}`,
}, },
{ {
label: "Users", label: "Users",
value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USERS}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USERS}`,
}, },
{
label: "User",
value: `${FieldType.BB_REFERENCE_SINGLE}${BBReferenceFieldSubType.USER}`,
},
] ]
$: { $: {

View file

@ -23,6 +23,7 @@
faQuestionCircle, faQuestionCircle,
faCircleCheck, faCircleCheck,
faGear, faGear,
faRectangleList,
} from "@fortawesome/free-solid-svg-icons" } from "@fortawesome/free-solid-svg-icons"
import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons" import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons"
@ -37,6 +38,7 @@
faFileArrowUp, faFileArrowUp,
faChevronLeft, faChevronLeft,
faCircleInfo, faCircleInfo,
faRectangleList,
// -- Required for easyMDE use in the builder. // -- Required for easyMDE use in the builder.
faBold, faBold,

View file

@ -4,6 +4,7 @@
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
import { licensing } from "stores/portal" import { licensing } from "stores/portal"
import { isPremiumOrAbove } from "helpers/planTitle" import { isPremiumOrAbove } from "helpers/planTitle"
import { ChangelogURL } from "constants"
$: premiumOrAboveLicense = isPremiumOrAbove($licensing?.license?.plan?.type) $: premiumOrAboveLicense = isPremiumOrAbove($licensing?.license?.plan?.type)
@ -30,6 +31,13 @@
<Body size="S">Help docs</Body> <Body size="S">Help docs</Body>
</a> </a>
<div class="divider" /> <div class="divider" />
<a target="_blank" href={ChangelogURL}>
<div class="icon">
<FontAwesomeIcon name="fa-solid fa-rectangle-list" />
</div>
<Body size="S">Changelog</Body>
</a>
<div class="divider" />
<a <a
target="_blank" target="_blank"
href="https://github.com/Budibase/budibase/discussions" href="https://github.com/Budibase/budibase/discussions"

View file

@ -4,6 +4,7 @@
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "dataBinding" } from "dataBinding"
import { FieldType } from "@budibase/types"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte" import { createEventDispatcher, setContext } from "svelte"
@ -102,6 +103,8 @@
longform: value => !isJSBinding(value), longform: value => !isJSBinding(value),
json: value => !isJSBinding(value), json: value => !isJSBinding(value),
boolean: isValidBoolean, boolean: isValidBoolean,
attachment: false,
attachment_single: false,
} }
const isValid = value => { const isValid = value => {
@ -116,7 +119,16 @@
if (type === "json" && !isJSBinding(value)) { if (type === "json" && !isJSBinding(value)) {
return "json-slot-icon" return "json-slot-icon"
} }
if (!["string", "number", "bigint", "barcodeqr"].includes(type)) { if (
![
"string",
"number",
"bigint",
"barcodeqr",
"attachment",
"attachment_single",
].includes(type)
) {
return "slot-icon" return "slot-icon"
} }
return "" return ""
@ -157,7 +169,7 @@
{updateOnChange} {updateOnChange}
/> />
{/if} {/if}
{#if !disabled && type !== "formula"} {#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
<div <div
class={`icon ${getIconClass(value, type)}`} class={`icon ${getIconClass(value, type)}`}
on:click={() => { on:click={() => {

View file

@ -7,10 +7,12 @@
Body, Body,
Button, Button,
StatusLight, StatusLight,
Link,
} from "@budibase/bbui" } from "@budibase/bbui"
import { appStore, initialise } from "stores/builder" import { appStore, initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import RevertModalVersionSelect from "./RevertModalVersionSelect.svelte" import RevertModalVersionSelect from "./RevertModalVersionSelect.svelte"
import { ChangelogURL } from "constants"
export function show() { export function show() {
updateModal.show() updateModal.show()
@ -106,6 +108,10 @@
latest version available. latest version available.
</Body> </Body>
{/if} {/if}
<Body size="S">
Find the changelog for the latest release
<Link href={ChangelogURL} target="_blank">here</Link>
</Body>
{#if revertAvailable} {#if revertAvailable}
<Body size="S"> <Body size="S">
You can revert this app to version You can revert this app to version

View file

@ -49,17 +49,20 @@
}, },
] ]
$: tables = findAllMatchingComponents($selectedScreen?.props, component => $: components = findAllMatchingComponents(
component._component.endsWith("table")
)
$: tableBlocks = findAllMatchingComponents(
$selectedScreen?.props, $selectedScreen?.props,
component => component._component.endsWith("tableblock") component => {
const type = component._component
return (
type.endsWith("/table") ||
type.endsWith("/tableblock") ||
type.endsWith("/gridblock")
)
}
) )
$: components = tables.concat(tableBlocks)
$: componentOptions = components.map(table => ({ $: componentOptions = components.map(table => ({
label: table._instanceName, label: table._instanceName,
value: table._component.includes("tableblock") value: table._component.endsWith("/tableblock")
? `${table._id}-table` ? `${table._id}-table`
: table._id, : table._id,
})) }))
@ -69,6 +72,7 @@
$: selectedTable = components.find( $: selectedTable = components.find(
component => component._id === selectedTableId component => component._id === selectedTableId
) )
$: parameters.rows = `{{ literal [${parameters.tableComponentId}].[selectedRows] }}`
onMount(() => { onMount(() => {
if (!parameters.type) { if (!parameters.type) {

View file

@ -47,4 +47,5 @@ export const FieldTypeToComponentMap = {
[FieldType.JSON]: "jsonfield", [FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner", [FieldType.BARCODEQR]: "codescanner",
[FieldType.BB_REFERENCE]: "bbreferencefield", [FieldType.BB_REFERENCE]: "bbreferencefield",
[FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield",
} }

View file

@ -37,6 +37,7 @@
export let customButtonText = null export let customButtonText = null
export let keyBindings = false export let keyBindings = false
export let allowJS = false export let allowJS = false
export let actionButtonDisabled = false
export let compare = (option, value) => option === value export let compare = (option, value) => option === value
let fields = Object.entries(object || {}).map(([name, value]) => ({ let fields = Object.entries(object || {}).map(([name, value]) => ({
@ -189,7 +190,14 @@
{/if} {/if}
{#if !readOnly && !noAddButton} {#if !readOnly && !noAddButton}
<div> <div>
<ActionButton icon="Add" secondary thin outline on:click={addEntry}> <ActionButton
disabled={actionButtonDisabled}
icon="Add"
secondary
thin
outline
on:click={addEntry}
>
{#if customButtonText} {#if customButtonText}
{customButtonText} {customButtonText}
{:else} {:else}

View file

@ -0,0 +1,43 @@
<script>
import "@spectrum-css/toast/dist/index-vars.css"
import Portal from "svelte-portal"
import { fly } from "svelte/transition"
import { Banner, BANNER_TYPES } from "@budibase/bbui"
import { licensing } from "stores/portal"
export let show = true
const oneDayInSeconds = 86400
$: license = $licensing.license
function daysUntilCancel() {
const cancelAt = license?.billing?.subscription?.cancelAt
const diffTime = Math.abs(cancelAt - new Date().getTime()) / 1000
return Math.floor(diffTime / oneDayInSeconds)
}
</script>
<Portal target=".banner-container">
<div class="banner">
{#if show}
<div transition:fly={{ y: -30 }}>
<Banner
type={BANNER_TYPES.INFO}
extraLinkText={"Please select a plan."}
extraLinkAction={$licensing.goToUpgradePage}
showCloseButton={false}
>
Your free trial will end in {daysUntilCancel()} days.
</Banner>
</div>
{/if}
</div>
</Portal>
<style>
.banner {
pointer-events: none;
width: 100%;
}
</style>

View file

@ -12,7 +12,7 @@ const defaultCacheFn = key => {
const upgradeAction = key => { const upgradeAction = key => {
return defaultNavigateAction( return defaultNavigateAction(
key, key,
"Upgrade Plan", "Upgrade",
`${get(admin).accountPortalUrl}/portal/upgrade` `${get(admin).accountPortalUrl}/portal/upgrade`
) )
} }

View file

@ -0,0 +1,66 @@
<script>
import { Modal, ModalContent } from "@budibase/bbui"
import FreeTrial from "../../../../assets/FreeTrial.svelte"
import { get } from "svelte/store"
import { auth, licensing } from "stores/portal"
import { API } from "api"
import { PlanType } from "@budibase/types"
import { sdk } from "@budibase/shared-core"
let freeTrialModal
$: planType = $licensing?.license?.plan?.type
$: showFreeTrialModal(planType, freeTrialModal)
const showFreeTrialModal = (planType, freeTrialModal) => {
if (
planType === PlanType.ENTERPRISE_BASIC_TRIAL &&
!$auth.user?.freeTrialConfirmedAt &&
sdk.users.isAdmin($auth.user)
) {
freeTrialModal?.show()
}
}
</script>
<Modal bind:this={freeTrialModal} disableCancel={true}>
<ModalContent
confirmText="Get started"
size="M"
showCancelButton={false}
showCloseIcon={false}
onConfirm={async () => {
if (get(auth).user) {
try {
await API.updateSelf({
freeTrialConfirmedAt: new Date().toISOString(),
})
// Update the cached user
await auth.getSelf()
} finally {
freeTrialModal.hide()
}
}
}}
>
<h1>Experience all of Budibase with a free 14-day trial</h1>
<div class="free-trial-text">
We've upgraded you to a free 14-day trial that allows you to try all our
features before deciding which plan is right for you.
<p>
At the end of your trial, we'll automatically downgrade you to the Free
plan unless you choose to upgrade.
</p>
</div>
<FreeTrial />
</ModalContent>
</Modal>
<style>
h1 {
font-size: 26px;
}
.free-trial-text {
font-size: 16px;
}
</style>

View file

@ -159,15 +159,17 @@ export const FIELDS = {
}, },
USER: { USER: {
name: "User", name: "User",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.USER], icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][
BBReferenceFieldSubType.USER
],
}, },
USERS: { USERS: {
name: "Users", name: "User List",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.USERS], icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER],
constraints: { constraints: {
type: "array", type: "array",
}, },

View file

@ -70,3 +70,5 @@ export const PlanModel = {
PER_USER: "perUser", PER_USER: "perUser",
DAY_PASS: "dayPass", DAY_PASS: "dayPass",
} }
export const ChangelogURL = "https://docs.budibase.com/changelog"

View file

@ -29,6 +29,7 @@ import { JSONUtils, Constants } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json" import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal" import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils" import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
import { FIELDS } from "constants/backend"
const { ContextScopes } = Constants const { ContextScopes } = Constants
@ -491,7 +492,7 @@ const generateComponentContextBindings = (asset, componentContext) => {
icon: bindingCategory.icon, icon: bindingCategory.icon,
display: { display: {
name: `${fieldSchema.name || key}`, name: `${fieldSchema.name || key}`,
type: fieldSchema.type, type: fieldSchema.display?.type || fieldSchema.type,
}, },
}) })
}) })
@ -1019,15 +1020,23 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// are objects // are objects
let fixedSchema = {} let fixedSchema = {}
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => { Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
const field = Object.values(FIELDS).find(
field =>
field.type === fieldSchema.type &&
field.subtype === fieldSchema.subtype
)
if (typeof fieldSchema === "string") { if (typeof fieldSchema === "string") {
fixedSchema[fieldName] = { fixedSchema[fieldName] = {
type: fieldSchema, type: fieldSchema,
name: fieldName, name: fieldName,
display: { type: fieldSchema },
} }
} else { } else {
fixedSchema[fieldName] = { fixedSchema[fieldName] = {
...fieldSchema, ...fieldSchema,
name: fieldName, name: fieldName,
display: { type: field?.name || fieldSchema.type },
} }
} }
}) })

View file

@ -20,6 +20,9 @@ export function getFormattedPlanName(userPlanType) {
case PlanType.ENTERPRISE: case PlanType.ENTERPRISE:
planName = "Enterprise" planName = "Enterprise"
break break
case PlanType.ENTERPRISE_BASIC_TRIAL:
planName = "Trial"
break
default: default:
planName = "Free" // Default to "Free" if the type is not explicitly handled planName = "Free" // Default to "Free" if the type is not explicitly handled
} }

View file

@ -32,6 +32,7 @@
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import PreviewOverlay from "./_components/PreviewOverlay.svelte" import PreviewOverlay from "./_components/PreviewOverlay.svelte"
import EnterpriseBasicTrialModal from "components/portal/onboarding/EnterpriseBasicTrialModal.svelte"
export let application export let application
@ -103,6 +104,10 @@
} }
onMount(async () => { onMount(async () => {
document.fonts.onloadingdone = e => {
builderStore.loadFonts(e.fontfaces)
}
if (!hasSynced && application) { if (!hasSynced && application) {
try { try {
await API.syncApp(application) await API.syncApp(application)
@ -143,17 +148,19 @@
/> />
</span> </span>
<Tabs {selected} size="M"> <Tabs {selected} size="M">
{#each $layout.children as { path, title }} {#key $builderStore?.fonts}
<TourWrap stepKeys={[`builder-${title}-section`]}> {#each $layout.children as { path, title }}
<Tab <TourWrap stepKeys={[`builder-${title}-section`]}>
quiet <Tab
selected={$isActive(path)} quiet
on:click={topItemNavigate(path)} selected={$isActive(path)}
title={capitalise(title)} on:click={topItemNavigate(path)}
id={`builder-${title}-tab`} title={capitalise(title)}
/> id={`builder-${title}-tab`}
</TourWrap> />
{/each} </TourWrap>
{/each}
{/key}
</Tabs> </Tabs>
</div> </div>
<div class="topcenternav"> <div class="topcenternav">
@ -192,6 +199,8 @@
<CommandPalette /> <CommandPalette />
</Modal> </Modal>
<EnterpriseBasicTrialModal />
<style> <style>
.back-to-apps { .back-to-apps {
display: contents; display: contents;

View file

@ -71,6 +71,7 @@
"multifieldselect", "multifieldselect",
"s3upload", "s3upload",
"codescanner", "codescanner",
"bbreferencesinglefield",
"bbreferencefield" "bbreferencefield"
] ]
}, },

View file

@ -98,14 +98,22 @@
}) })
} }
async function fetchBackups(filters, page, dateRange) { async function fetchBackups(filters, page, dateRange = []) {
const response = await backups.searchBackups({ const body = {
appId: $appStore.appId, appId: $appStore.appId,
...filters, ...filters,
page, page,
startDate: dateRange[0], }
endDate: dateRange[1],
}) const [startDate, endDate] = dateRange
if (startDate) {
body.startDate = startDate
}
if (endDate) {
body.endDate = endDate
}
const response = await backups.searchBackups(body)
pageInfo.fetched(response.hasNextPage, response.nextPage) pageInfo.fetched(response.hasNextPage, response.nextPage)
// flatten so we have an easier structure to use for the table schema // flatten so we have an easier structure to use for the table schema
@ -120,7 +128,7 @@
}) })
await fetchBackups(filterOpt, page) await fetchBackups(filterOpt, page)
notifications.success(response.message) notifications.success(response.message)
} catch { } catch (err) {
notifications.error("Unable to create backup") notifications.error("Unable to create backup")
} }
} }

View file

@ -6,7 +6,7 @@
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
</script> </script>
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan} {#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING) && !$licensing.isEnterprisePlan && !$licensing.isEnterpriseTrial}
{#if $admin.cloud && $auth?.user?.accountPortalAccess} {#if $admin.cloud && $auth?.user?.accountPortalAccess}
<Button <Button
cta cta

View file

@ -1,7 +1,7 @@
<script> <script>
import { isActive, redirect, goto, url } from "@roxi/routify" import { isActive, redirect, goto, url } from "@roxi/routify"
import { Icon, notifications, Tabs, Tab } from "@budibase/bbui" import { Icon, notifications, Tabs, Tab } from "@budibase/bbui"
import { organisation, auth, menu, appsStore } from "stores/portal" import { organisation, auth, menu, appsStore, licensing } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import UpgradeButton from "./_components/UpgradeButton.svelte" import UpgradeButton from "./_components/UpgradeButton.svelte"
import MobileMenu from "./_components/MobileMenu.svelte" import MobileMenu from "./_components/MobileMenu.svelte"
@ -10,6 +10,8 @@
import HelpMenu from "components/common/HelpMenu.svelte" import HelpMenu from "components/common/HelpMenu.svelte"
import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte" import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import EnterpriseBasicTrialBanner from "components/portal/licensing/EnterpriseBasicTrialBanner.svelte"
import { Constants } from "@budibase/frontend-core"
let loaded = false let loaded = false
let mobileMenuVisible = false let mobileMenuVisible = false
@ -33,6 +35,14 @@
const showMobileMenu = () => (mobileMenuVisible = true) const showMobileMenu = () => (mobileMenuVisible = true)
const hideMobileMenu = () => (mobileMenuVisible = false) const hideMobileMenu = () => (mobileMenuVisible = false)
const showFreeTrialBanner = () => {
return (
$licensing.license?.plan?.type ===
Constants.PlanType.ENTERPRISE_BASIC_TRIAL &&
sdk.users.isAdmin($auth.user)
)
}
onMount(async () => { onMount(async () => {
// Prevent non-builders from accessing the portal // Prevent non-builders from accessing the portal
if ($auth.user) { if ($auth.user) {
@ -58,6 +68,7 @@
<HelpMenu /> <HelpMenu />
<div class="container"> <div class="container">
<VerificationPromptBanner /> <VerificationPromptBanner />
<EnterpriseBasicTrialBanner show={showFreeTrialBanner()} />
<div class="nav"> <div class="nav">
<div class="branding"> <div class="branding">
<Logo /> <Logo />

View file

@ -29,6 +29,7 @@
const manageUrl = `${$admin.accountPortalUrl}/portal/billing` const manageUrl = `${$admin.accountPortalUrl}/portal/billing`
const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"] const WARN_USAGE = ["Queries", "Automations", "Rows", "Day Passes", "Users"]
const oneDayInSeconds = 86400
const EXCLUDE_QUOTAS = { const EXCLUDE_QUOTAS = {
Queries: () => true, Queries: () => true,
@ -104,24 +105,17 @@
if (!timestamp) { if (!timestamp) {
return return
} }
const now = new Date() const diffTime = Math.abs(timestamp - new Date().getTime()) / 1000
now.setHours(0) return Math.floor(diffTime / oneDayInSeconds)
now.setMinutes(0)
const thenDate = new Date(timestamp)
thenDate.setHours(0)
thenDate.setMinutes(0)
const difference = thenDate.getTime() - now
// return the difference in days
return (difference / (1000 * 3600 * 24)).toFixed(0)
} }
const setTextRows = () => { const setTextRows = () => {
textRows = [] textRows = []
if (cancelAt && !usesInvoicing) { if (cancelAt && !usesInvoicing) {
textRows.push({ message: "Subscription has been cancelled" }) if (plan?.type !== Constants.PlanType.ENTERPRISE_BASIC_TRIAL) {
textRows.push({ message: "Subscription has been cancelled" })
}
textRows.push({ textRows.push({
message: `${getDaysRemaining(cancelAt)} days remaining`, message: `${getDaysRemaining(cancelAt)} days remaining`,
tooltip: new Date(cancelAt), tooltip: new Date(cancelAt),

View file

@ -166,10 +166,16 @@ const automationActions = store => ({
await store.actions.save(newAutomation) await store.actions.save(newAutomation)
}, },
test: async (automation, testData) => { test: async (automation, testData) => {
const result = await API.testAutomation({ let result
automationId: automation?._id, try {
testData, result = await API.testAutomation({
}) automationId: automation?._id,
testData,
})
} catch (err) {
const message = err.message || err.status || JSON.stringify(err)
throw `Automation test failed - ${message}`
}
if (!result?.trigger && !result?.steps?.length) { if (!result?.trigger && !result?.steps?.length) {
if (result?.err?.code === "usage_limit_exceeded") { if (result?.err?.code === "usage_limit_exceeded") {
throw "You have exceeded your automation quota" throw "You have exceeded your automation quota"

View file

@ -14,6 +14,7 @@ export const INITIAL_BUILDER_STATE = {
tourKey: null, tourKey: null,
tourStepKey: null, tourStepKey: null,
hoveredComponentId: null, hoveredComponentId: null,
fonts: null,
} }
export class BuilderStore extends BudiStore { export class BuilderStore extends BudiStore {
@ -36,6 +37,16 @@ export class BuilderStore extends BudiStore {
this.websocket this.websocket
} }
loadFonts(fontFaces) {
const ff = fontFaces.map(
fontFace => `${fontFace.family}-${fontFace.weight}`
)
this.update(state => ({
...state,
fonts: [...(state.fonts || []), ...ff],
}))
}
init(app) { init(app) {
if (!app?.appId) { if (!app?.appId) {
console.error("BuilderStore: No appId supplied for websocket") console.error("BuilderStore: No appId supplied for websocket")

View file

@ -440,6 +440,8 @@ export class ComponentStore extends BudiStore {
return state return state
}) })
componentTreeNodesStore.makeNodeVisible(componentInstance._id)
// Log event // Log event
analytics.captureEvent(Events.COMPONENT_CREATED, { analytics.captureEvent(Events.COMPONENT_CREATED, {
name: componentInstance._component, name: componentInstance._component,

View file

@ -103,6 +103,8 @@ export const createLicensingStore = () => {
const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE const isEnterprisePlan = planType === Constants.PlanType.ENTERPRISE
const isFreePlan = planType === Constants.PlanType.FREE const isFreePlan = planType === Constants.PlanType.FREE
const isBusinessPlan = planType === Constants.PlanType.BUSINESS const isBusinessPlan = planType === Constants.PlanType.BUSINESS
const isEnterpriseTrial =
planType === Constants.PlanType.ENTERPRISE_BASIC_TRIAL
const groupsEnabled = license.features.includes( const groupsEnabled = license.features.includes(
Constants.Features.USER_GROUPS Constants.Features.USER_GROUPS
) )
@ -143,6 +145,7 @@ export const createLicensingStore = () => {
isEnterprisePlan, isEnterprisePlan,
isFreePlan, isFreePlan,
isBusinessPlan, isBusinessPlan,
isEnterpriseTrial,
groupsEnabled, groupsEnabled,
backupsEnabled, backupsEnabled,
brandingEnabled, brandingEnabled,

View file

@ -4226,8 +4226,8 @@
] ]
}, },
"attachmentfield": { "attachmentfield": {
"name": "Attachment list", "name": "Attachment List",
"icon": "Attach", "icon": "DocumentFragmentGroup",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"editable": true, "editable": true,
@ -4324,7 +4324,7 @@
}, },
"attachmentsinglefield": { "attachmentsinglefield": {
"name": "Single Attachment", "name": "Single Attachment",
"icon": "Attach", "icon": "DocumentFragment",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"editable": true, "editable": true,
@ -7023,16 +7023,28 @@
] ]
} }
], ],
"context": { "context": [
"type": "schema", {
"scope": "local" "type": "schema",
}, "scope": "local"
},
{
"type": "static",
"values": [
{
"label": "Selected rows",
"key": "selectedRows",
"type": "array"
}
]
}
],
"actions": ["RefreshDatasource"] "actions": ["RefreshDatasource"]
}, },
"bbreferencefield": { "bbreferencefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels", "devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field", "name": "User List Field",
"icon": "User", "icon": "UserGroup",
"styles": ["size"], "styles": ["size"],
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"editable": true, "editable": true,
@ -7136,5 +7148,113 @@
] ]
} }
] ]
},
"bbreferencesinglefield": {
"devComment": "As bb reference is only used for user subtype for now, we are using user for icon and labels",
"name": "User Field",
"icon": "User",
"styles": ["size"],
"requiredAncestors": ["form"],
"editable": true,
"size": {
"width": 400,
"height": 50
},
"settings": [
{
"type": "field/bb_reference_single",
"label": "Field",
"key": "field",
"required": true
},
{
"type": "text",
"label": "Label",
"key": "label"
},
{
"type": "text",
"label": "Placeholder",
"key": "placeholder"
},
{
"type": "text",
"label": "Default value",
"key": "defaultValue"
},
{
"type": "text",
"label": "Help text",
"key": "helpText"
},
{
"type": "event",
"label": "On change",
"key": "onChange",
"context": [
{
"label": "Field Value",
"key": "value"
}
]
},
{
"type": "validation/link",
"label": "Validation",
"key": "validation"
},
{
"type": "boolean",
"label": "Search",
"key": "autocomplete",
"defaultValue": true
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false
},
{
"type": "boolean",
"label": "Read only",
"key": "readonly",
"defaultValue": false,
"dependsOn": {
"setting": "disabled",
"value": true,
"invert": true
}
},
{
"type": "select",
"label": "Layout",
"key": "span",
"defaultValue": 6,
"hidden": true,
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "1 column",
"value": 6,
"barIcon": "Stop",
"barTitle": "1 column"
},
{
"label": "2 columns",
"value": 3,
"barIcon": "ColumnTwoA",
"barTitle": "2 columns"
},
{
"label": "3 columns",
"value": 2,
"barIcon": "ViewColumn",
"barTitle": "3 columns"
}
]
}
]
} }
} }

View file

@ -1,8 +1,8 @@
<script> <script>
// NOTE: this is not a block - it's just named as such to avoid confusing users, // NOTE: this is not a block - it's just named as such to avoid confusing users,
// because it functions similarly to one // because it functions similarly to one
import { getContext } from "svelte" import { getContext, onMount } from "svelte"
import { get } from "svelte/store" import { get, derived, readable } from "svelte/store"
import { Grid } from "@budibase/frontend-core" import { Grid } from "@budibase/frontend-core"
// table is actually any datasource, but called table for legacy compatibility // table is actually any datasource, but called table for legacy compatibility
@ -19,7 +19,6 @@
export let columns = null export let columns = null
export let onRowClick = null export let onRowClick = null
export let buttons = null export let buttons = null
export let repeat = null
const context = getContext("context") const context = getContext("context")
const component = getContext("component") const component = getContext("component")
@ -36,23 +35,24 @@
} = getContext("sdk") } = getContext("sdk")
let grid let grid
let gridContext
let resizedColumns = {} let resizedColumns = {}
$: columnWhitelist = parsedColumns
?.filter(col => col.active)
?.map(col => col.field)
$: enrichedButtons = enrichButtons(buttons)
$: parsedColumns = getParsedColumns(columns) $: parsedColumns = getParsedColumns(columns)
$: columnWhitelist = parsedColumns.filter(x => x.active).map(x => x.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns, resizedColumns) $: schemaOverrides = getSchemaOverrides(parsedColumns, resizedColumns)
$: enrichedButtons = enrichButtons(buttons)
$: selectedRows = deriveSelectedRows(gridContext)
$: height = $component.styles?.normal?.height || "408px"
$: styles = getSanitisedStyles($component.styles)
$: data = { selectedRows: $selectedRows }
$: actions = [ $: actions = [
{ {
type: ActionTypes.RefreshDatasource, type: ActionTypes.RefreshDatasource,
callback: () => grid?.getContext()?.rows.actions.refreshData(), callback: () => gridContext?.rows.actions.refreshData(),
metadata: { dataSource: table }, metadata: { dataSource: table },
}, },
] ]
$: height = $component.styles?.normal?.height || "408px"
$: styles = getSanitisedStyles($component.styles)
// Provide additional data context for live binding eval // Provide additional data context for live binding eval
export const getAdditionalDataContext = () => { export const getAdditionalDataContext = () => {
@ -69,11 +69,14 @@
// Parses columns to fix older formats // Parses columns to fix older formats
const getParsedColumns = columns => { const getParsedColumns = columns => {
if (!columns?.length) {
return []
}
// If the first element has an active key all elements should be in the new format // If the first element has an active key all elements should be in the new format
if (columns?.length && columns[0]?.active !== undefined) { if (columns[0].active !== undefined) {
return columns return columns
} }
return columns?.map(column => ({ return columns.map(column => ({
label: column.displayName || column.name, label: column.displayName || column.name,
field: column.name, field: column.name,
active: true, active: true,
@ -82,7 +85,7 @@
const getSchemaOverrides = (columns, resizedColumns) => { const getSchemaOverrides = (columns, resizedColumns) => {
let overrides = {} let overrides = {}
columns?.forEach(column => { columns.forEach(column => {
overrides[column.field] = { overrides[column.field] = {
displayName: column.label, displayName: column.label,
} }
@ -115,6 +118,23 @@
})) }))
} }
const deriveSelectedRows = gridContext => {
if (!gridContext) {
return readable([])
}
return derived(
[gridContext.selectedRows, gridContext.rowLookupMap, gridContext.rows],
([$selectedRows, $rowLookupMap, $rows]) => {
return Object.entries($selectedRows || {})
.filter(([_, selected]) => selected)
.map(([rowId]) => {
const idx = $rowLookupMap[rowId]
return gridContext.rows.actions.cleanRow($rows[idx])
})
}
)
}
const getSanitisedStyles = styles => { const getSanitisedStyles = styles => {
return { return {
...styles, ...styles,
@ -131,41 +151,45 @@
const { column } = e.detail const { column } = e.detail
resizedColumns = { ...resizedColumns, [column]: true } resizedColumns = { ...resizedColumns, [column]: true }
} }
onMount(() => {
gridContext = grid.getContext()
})
</script> </script>
<div use:styleable={styles} class:in-builder={$builderStore.inBuilder}> <div use:styleable={styles} class:in-builder={$builderStore.inBuilder}>
<span style="--height:{height};"> <span style="--height:{height};">
<Provider {actions}> <Grid
<Grid bind:this={grid}
bind:this={grid} datasource={table}
datasource={table} {API}
{API} {stripeRows}
{stripeRows} {quiet}
{quiet} {initialFilter}
{initialFilter} {initialSortColumn}
{initialSortColumn} {initialSortOrder}
{initialSortOrder} {fixedRowHeight}
{fixedRowHeight} {columnWhitelist}
{columnWhitelist} {schemaOverrides}
{schemaOverrides} canAddRows={allowAddRows}
{repeat} canEditRows={allowEditRows}
canAddRows={allowAddRows} canDeleteRows={allowDeleteRows}
canEditRows={allowEditRows} canEditColumns={false}
canDeleteRows={allowDeleteRows} canExpandRows={false}
canEditColumns={false} canSaveSchema={false}
canExpandRows={false} canSelectRows={true}
canSaveSchema={false} showControls={false}
showControls={false} notifySuccess={notificationStore.actions.success}
notifySuccess={notificationStore.actions.success} notifyError={notificationStore.actions.error}
notifyError={notificationStore.actions.error} buttons={enrichedButtons}
buttons={enrichedButtons} on:rowclick={e => onRowClick?.({ row: e.detail })}
on:rowclick={e => onRowClick?.({ row: e.detail })} on:columnresize={onColumnResize}
on:columnresize={onColumnResize} />
/>
</Provider>
</span> </span>
</div> </div>
<Provider {data} {actions} />
<style> <style>
div { div {
display: flex; display: flex;

View file

@ -21,6 +21,7 @@
[FieldType.JSON]: "jsonfield", [FieldType.JSON]: "jsonfield",
[FieldType.BARCODEQR]: "codescanner", [FieldType.BARCODEQR]: "codescanner",
[FieldType.BB_REFERENCE]: "bbreferencefield", [FieldType.BB_REFERENCE]: "bbreferencefield",
[FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield",
} }
const getFieldSchema = field => { const getFieldSchema = field => {

View file

@ -1,8 +1,10 @@
<script> <script>
import RelationshipField from "./RelationshipField.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { FieldType } from "@budibase/types"
import RelationshipField from "./RelationshipField.svelte"
export let defaultValue export let defaultValue
export let type = FieldType.BB_REFERENCE
function updateUserIDs(value) { function updateUserIDs(value) {
if (Array.isArray(value)) { if (Array.isArray(value)) {
@ -22,6 +24,7 @@
<RelationshipField <RelationshipField
{...$$props} {...$$props}
{type}
datasourceType={"user"} datasourceType={"user"}
primaryDisplay={"email"} primaryDisplay={"email"}
defaultValue={updateReferences(defaultValue)} defaultValue={updateReferences(defaultValue)}

View file

@ -0,0 +1,6 @@
<script>
import { FieldType } from "@budibase/types"
import BBReferenceField from "./BBReferenceField.svelte"
</script>
<BBReferenceField {...$$restProps} type={FieldType.BB_REFERENCE_SINGLE} />

View file

@ -1,9 +1,9 @@
<script> <script>
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core" import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext, onMount } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants"
const { API } = getContext("sdk") const { API } = getContext("sdk")
@ -21,6 +21,7 @@
export let primaryDisplay export let primaryDisplay
export let span export let span
export let helpText = null export let helpText = null
export let type = FieldType.LINK
let fieldState let fieldState
let fieldApi let fieldApi
@ -28,12 +29,10 @@
let tableDefinition let tableDefinition
let searchTerm let searchTerm
let open let open
let initialValue
$: type = $: multiselect =
datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE [FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many"
$: multiselect = fieldSchema?.relationshipType !== "one-to-many"
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = fieldSchema?.tableId
$: fetch = fetchData({ $: fetch = fetchData({
API, API,
@ -52,18 +51,19 @@
? flatten(fieldState?.value) ?? [] ? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0] : flatten(fieldState?.value)?.[0]
$: component = multiselect ? CoreMultiselect : CoreSelect $: component = multiselect ? CoreMultiselect : CoreSelect
$: expandedDefaultValue = expand(defaultValue)
$: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay $: primaryDisplay = primaryDisplay || tableDefinition?.primaryDisplay
let optionsObj = {} let optionsObj
let initialValuesProcessed
$: { $: {
if (!initialValuesProcessed && primaryDisplay) { if (primaryDisplay && fieldState && !optionsObj) {
// Persist the initial values as options, allowing them to be present in the dropdown, // Persist the initial values as options, allowing them to be present in the dropdown,
// even if they are not in the inital fetch results // even if they are not in the inital fetch results
initialValuesProcessed = true let valueAsSafeArray = fieldState.value || []
optionsObj = (fieldState?.value || []).reduce((accumulator, value) => { if (!Array.isArray(valueAsSafeArray)) {
valueAsSafeArray = [fieldState.value]
}
optionsObj = valueAsSafeArray.reduce((accumulator, value) => {
// fieldState has to be an array of strings to be valid for an update // fieldState has to be an array of strings to be valid for an update
// therefore we cannot guarantee value will be an object // therefore we cannot guarantee value will be an object
// https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for // https://linear.app/budibase/issue/BUDI-7577/refactor-the-relationshipfield-component-to-have-better-support-for
@ -75,7 +75,7 @@
[primaryDisplay]: value.primaryDisplay, [primaryDisplay]: value.primaryDisplay,
} }
return accumulator return accumulator
}, optionsObj) }, {})
} }
} }
@ -86,7 +86,7 @@
accumulator[row._id] = row accumulator[row._id] = row
} }
return accumulator return accumulator
}, optionsObj) }, optionsObj || {})
return Object.values(result) return Object.values(result)
} }
@ -110,17 +110,10 @@
} }
$: forceFetchRows(filter) $: forceFetchRows(filter)
$: debouncedFetchRows( $: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
searchTerm,
primaryDisplay,
initialValue || defaultValue
)
const forceFetchRows = async () => { const forceFetchRows = async () => {
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
optionsObj = {}
fieldApi?.setValue([]) fieldApi?.setValue([])
selectedValue = []
debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
} }
const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => { const fetchRows = async (searchTerm, primaryDisplay, defaultVal) => {
@ -136,7 +129,7 @@
if (defaultVal && !Array.isArray(defaultVal)) { if (defaultVal && !Array.isArray(defaultVal)) {
defaultVal = defaultVal.split(",") defaultVal = defaultVal.split(",")
} }
if (defaultVal && defaultVal.some(val => !optionsObj[val])) { if (defaultVal && optionsObj && defaultVal.some(val => !optionsObj[val])) {
await fetch.update({ await fetch.update({
query: { oneOf: { _id: defaultVal } }, query: { oneOf: { _id: defaultVal } },
}) })
@ -162,16 +155,13 @@
if (!values) { if (!values) {
return [] return []
} }
if (!Array.isArray(values)) { if (!Array.isArray(values)) {
values = [values] values = [values]
} }
values = values.map(value => values = values.map(value =>
typeof value === "object" ? value._id : value typeof value === "object" ? value._id : value
) )
// Make sure field state is valid
if (values?.length > 0) {
fieldApi.setValue(values)
}
return values return values
} }
@ -179,25 +169,20 @@
return row?.[primaryDisplay] || "-" return row?.[primaryDisplay] || "-"
} }
const singleHandler = e => { const handleChange = e => {
handleChange(e.detail == null ? [] : [e.detail]) let value = e.detail
} if (!multiselect) {
value = value == null ? [] : [value]
const multiHandler = e => { }
handleChange(e.detail)
} if (
type === FieldType.BB_REFERENCE_SINGLE &&
const expand = values => { value &&
if (!values) { Array.isArray(value)
return [] ) {
value = value[0] || null
} }
if (Array.isArray(values)) {
return values
}
return values.split(",").map(value => value.trim())
}
const handleChange = value => {
const changed = fieldApi.setValue(value) const changed = fieldApi.setValue(value)
if (onChange && changed) { if (onChange && changed) {
onChange({ onChange({
@ -211,16 +196,6 @@
fetch.nextPage() fetch.nextPage()
} }
} }
onMount(() => {
// if the form is in 'Update' mode, then we need to fetch the matching row so that the value is correctly set
if (fieldState?.value) {
initialValue =
fieldSchema?.relationshipType !== "one-to-many"
? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0]
}
})
</script> </script>
<Field <Field
@ -229,7 +204,7 @@
{disabled} {disabled}
{readonly} {readonly}
{validation} {validation}
defaultValue={expandedDefaultValue} {defaultValue}
{type} {type}
{span} {span}
{helpText} {helpText}
@ -243,7 +218,7 @@
options={enrichedOptions} options={enrichedOptions}
{autocomplete} {autocomplete}
value={selectedValue} value={selectedValue}
on:change={multiselect ? multiHandler : singleHandler} on:change={handleChange}
on:loadMore={loadMore} on:loadMore={loadMore}
id={fieldState.fieldId} id={fieldState.fieldId}
disabled={fieldState.disabled} disabled={fieldState.disabled}

View file

@ -17,3 +17,4 @@ export { default as jsonfield } from "./JSONField.svelte"
export { default as s3upload } from "./S3Upload.svelte" export { default as s3upload } from "./S3Upload.svelte"
export { default as codescanner } from "./CodeScannerField.svelte" export { default as codescanner } from "./CodeScannerField.svelte"
export { default as bbreferencefield } from "./BBReferenceField.svelte" export { default as bbreferencefield } from "./BBReferenceField.svelte"
export { default as bbreferencesinglefield } from "./BBReferenceSingleField.svelte"

View file

@ -15,7 +15,7 @@ const createRowSelectionStore = () => {
const componentId = Object.keys(selection).find( const componentId = Object.keys(selection).find(
componentId => componentId === tableComponentId componentId => componentId === tableComponentId
) )
return selection[componentId] || {} return selection[componentId]
} }
return { return {

View file

@ -333,31 +333,59 @@ const s3UploadHandler = async action => {
} }
} }
/**
* For new configs, "rows" is defined and enriched to be the array of rows to
* export. For old configs it will be undefined and we need to use the legacy
* row selection store in combination with the tableComponentId parameter.
*/
const exportDataHandler = async action => { const exportDataHandler = async action => {
let selection = rowSelectionStore.actions.getSelection( let { tableComponentId, rows, type, columns, delimiter, customHeaders } =
action.parameters.tableComponentId action.parameters
) let tableId
if (selection.selectedRows && selection.selectedRows.length > 0) {
// Handle legacy configs using the row selection store
if (!rows?.length) {
const selection = rowSelectionStore.actions.getSelection(tableComponentId)
if (selection?.selectedRows?.length) {
rows = selection.selectedRows
tableId = selection.tableId
}
}
// Get table ID from first row if needed
if (!tableId) {
tableId = rows?.[0]?.tableId
}
// Handle no rows selected
if (!rows?.length) {
notificationStore.actions.error("Please select at least one row")
}
// Handle case where we're not using a DS+
else if (!tableId) {
notificationStore.actions.error(
"You can only export data from table datasources"
)
}
// Happy path when we have both rows and table ID
else {
try { try {
// Flatten rows if required
if (typeof rows[0] !== "string") {
rows = rows.map(row => row._id)
}
const data = await API.exportRows({ const data = await API.exportRows({
tableId: selection.tableId, tableId,
rows: selection.selectedRows, rows,
format: action.parameters.type, format: type,
columns: action.parameters.columns?.map( columns: columns?.map(column => column.name || column),
column => column.name || column delimiter,
), customHeaders,
delimiter: action.parameters.delimiter,
customHeaders: action.parameters.customHeaders,
}) })
download( download(new Blob([data], { type: "text/plain" }), `${tableId}.${type}`)
new Blob([data], { type: "text/plain" }),
`${selection.tableId}.${action.parameters.type}`
)
} catch (error) { } catch (error) {
notificationStore.actions.error("There was an error exporting the data") notificationStore.actions.error("There was an error exporting the data")
} }
} else {
notificationStore.actions.error("Please select at least one row")
} }
} }

View file

@ -125,6 +125,7 @@
filter.type = fieldSchema?.type filter.type = fieldSchema?.type
filter.subtype = fieldSchema?.subtype filter.subtype = fieldSchema?.subtype
filter.formulaType = fieldSchema?.formulaType filter.formulaType = fieldSchema?.formulaType
filter.constraints = fieldSchema?.constraints
// Update external type based on field // Update external type based on field
filter.externalType = getSchema(filter)?.externalType filter.externalType = getSchema(filter)?.externalType
@ -281,7 +282,7 @@
timeOnly={getSchema(filter)?.timeOnly} timeOnly={getSchema(filter)?.timeOnly}
bind:value={filter.value} bind:value={filter.value}
/> />
{:else if filter.type === FieldType.BB_REFERENCE} {:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)}
<FilterUsers <FilterUsers
bind:value={filter.value} bind:value={filter.value}
multiselect={[ multiselect={[
@ -289,6 +290,7 @@
OperatorOptions.ContainsAny.value, OperatorOptions.ContainsAny.value,
].includes(filter.operator)} ].includes(filter.operator)}
disabled={filter.noValue} disabled={filter.noValue}
type={filter.valueType}
/> />
{:else} {:else}
<Input disabled /> <Input disabled />
@ -325,8 +327,6 @@
<style> <style>
.container { .container {
width: 100%; width: 100%;
max-width: 1000px;
margin: 0 auto;
} }
.fields { .fields {
display: grid; display: grid;

View file

@ -4,6 +4,7 @@
import { createAPIClient } from "../api" import { createAPIClient } from "../api"
export let API = createAPIClient() export let API = createAPIClient()
export let value = null export let value = null
export let disabled export let disabled
export let multiselect = false export let multiselect = false
@ -23,12 +24,14 @@
$: component = multiselect ? Multiselect : Select $: component = multiselect ? Multiselect : Select
</script> </script>
<svelte:component <div class="user-control">
this={component} <svelte:component
bind:value this={component}
autocomplete bind:value
{options} autocomplete
getOptionLabel={option => option.email} {options}
getOptionValue={option => option._id} getOptionLabel={option => option.email}
{disabled} getOptionValue={option => option._id}
/> {disabled}
/>
</div>

View file

@ -1,19 +1,27 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { helpers } from "@budibase/shared-core"
import RelationshipCell from "./RelationshipCell.svelte" import RelationshipCell from "./RelationshipCell.svelte"
import { BBReferenceFieldSubType, RelationshipType } from "@budibase/types" import {
BBReferenceFieldSubType,
FieldType,
RelationshipType,
} from "@budibase/types"
export let api export let api
export let hideCounter = false
export let schema
const { API } = getContext("grid") const { API } = getContext("grid")
const { subtype } = $$props.schema const { type, subtype } = schema
const schema = { $: schema = {
...$$props.schema, ...$$props.schema,
// This is not really used, just adding some content to be able to render the relationship cell // This is not really used, just adding some content to be able to render the relationship cell
tableId: "external", tableId: "external",
relationshipType: relationshipType:
subtype === BBReferenceFieldSubType.USER type === FieldType.BB_REFERENCE_SINGLE ||
helpers.schema.isDeprecatedSingleUserColumn(schema)
? RelationshipType.ONE_TO_MANY ? RelationshipType.ONE_TO_MANY
: RelationshipType.MANY_TO_MANY, : RelationshipType.MANY_TO_MANY,
} }
@ -44,8 +52,9 @@
<RelationshipCell <RelationshipCell
bind:api bind:api
{...$$props} {...$$restProps}
{schema} {schema}
{searchFunction} {searchFunction}
primaryDisplay={"email"} primaryDisplay={"email"}
{hideCounter}
/> />

View file

@ -0,0 +1,22 @@
<script>
import BbReferenceCell from "./BBReferenceCell.svelte"
export let value
export let onChange
export let api
$: arrayValue = (!Array.isArray(value) && value ? [value] : value) || []
$: onValueChange = value => {
value = value[0] || null
onChange(value)
}
</script>
<BbReferenceCell
bind:api
{...$$restProps}
value={arrayValue}
onChange={onValueChange}
hideCounter={true}
/>

View file

@ -16,6 +16,8 @@
const { config, dispatch, selectedRows } = getContext("grid") const { config, dispatch, selectedRows } = getContext("grid")
const svelteDispatch = createEventDispatcher() const svelteDispatch = createEventDispatcher()
$: selectionEnabled = $config.canSelectRows || $config.canDeleteRows
const select = e => { const select = e => {
e.stopPropagation() e.stopPropagation()
svelteDispatch("select") svelteDispatch("select")
@ -52,7 +54,7 @@
<div <div
on:click={select} on:click={select}
class="checkbox" class="checkbox"
class:visible={$config.canDeleteRows && class:visible={selectionEnabled &&
(disableNumber || rowSelected || rowHovered || rowFocused)} (disableNumber || rowSelected || rowHovered || rowFocused)}
> >
<Checkbox value={rowSelected} {disabled} /> <Checkbox value={rowSelected} {disabled} />
@ -60,7 +62,7 @@
{#if !disableNumber} {#if !disableNumber}
<div <div
class="number" class="number"
class:visible={!$config.canDeleteRows || class:visible={!selectionEnabled ||
!(rowSelected || rowHovered || rowFocused)} !(rowSelected || rowHovered || rowFocused)}
> >
{row.__idx + 1} {row.__idx + 1}
@ -117,19 +119,11 @@
.expand { .expand {
margin-right: 4px; margin-right: 4px;
} }
.expand { .expand:not(.visible),
.expand:not(.visible) :global(*) {
opacity: 0; opacity: 0;
pointer-events: none !important;
} }
.expand :global(.spectrum-Icon) {
pointer-events: none;
}
.expand.visible {
opacity: 1;
}
.expand.visible :global(.spectrum-Icon) {
pointer-events: all;
}
.delete:hover { .delete:hover {
cursor: pointer; cursor: pointer;
} }

View file

@ -17,6 +17,7 @@
export let contentLines = 1 export let contentLines = 1
export let searchFunction = API.searchTable export let searchFunction = API.searchTable
export let primaryDisplay export let primaryDisplay
export let hideCounter = false
const color = getColor(0) const color = getColor(0)
@ -263,7 +264,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if value?.length} {#if !hideCounter && value?.length}
<div class="count"> <div class="count">
{value?.length || 0} {value?.length || 0}
</div> </div>

View file

@ -7,11 +7,6 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
import { ValidColumnNameRegex } from "@budibase/shared-core" import { ValidColumnNameRegex } from "@budibase/shared-core"
import {
BBReferenceFieldSubType,
FieldType,
RelationshipType,
} from "@budibase/types"
const { API, definition, rows } = getContext("grid") const { API, definition, rows } = getContext("grid")
@ -33,20 +28,11 @@
} }
const migrateUserColumn = async () => { const migrateUserColumn = async () => {
let subtype = BBReferenceFieldSubType.USERS
if (column.schema.relationshipType === RelationshipType.ONE_TO_MANY) {
subtype = BBReferenceFieldSubType.USER
}
try { try {
await API.migrateColumn({ await API.migrateColumn({
tableId: $definition._id, tableId: $definition._id,
oldColumn: column.schema, oldColumn: column.schema.name,
newColumn: { newColumn: newColumnName,
name: newColumnName,
type: FieldType.BB_REFERENCE,
subtype,
},
}) })
notifications.success("Column migrated") notifications.success("Column migrated")
} catch (e) { } catch (e) {

View file

@ -41,6 +41,7 @@
export let canDeleteRows = true export let canDeleteRows = true
export let canEditColumns = true export let canEditColumns = true
export let canSaveSchema = true export let canSaveSchema = true
export let canSelectRows = false
export let stripeRows = false export let stripeRows = false
export let quiet = false export let quiet = false
export let collaboration = true export let collaboration = true
@ -94,6 +95,7 @@
canDeleteRows, canDeleteRows,
canEditColumns, canEditColumns,
canSaveSchema, canSaveSchema,
canSelectRows,
stripeRows, stripeRows,
quiet, quiet,
collaboration, collaboration,

View file

@ -1,6 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import DataCell from "../cells/DataCell.svelte" import DataCell from "../cells/DataCell.svelte"
import { getCellID } from "../lib/utils"
export let row export let row
export let top = false export let top = false
@ -38,7 +39,7 @@
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))} on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
> >
{#each $visibleColumns as column, columnIdx} {#each $visibleColumns as column, columnIdx}
{@const cellId = `${row._id}-${column.name}`} {@const cellId = getCellID(row._id, column.name)}
<DataCell <DataCell
{cellId} {cellId}
{column} {column}

View file

@ -7,6 +7,7 @@
import { GutterWidth, NewRowID } from "../lib/constants" import { GutterWidth, NewRowID } from "../lib/constants"
import GutterCell from "../cells/GutterCell.svelte" import GutterCell from "../cells/GutterCell.svelte"
import KeyboardShortcut from "./KeyboardShortcut.svelte" import KeyboardShortcut from "./KeyboardShortcut.svelte"
import { getCellID } from "../lib/utils"
const { const {
hoveredRowId, hoveredRowId,
@ -70,7 +71,7 @@
// Select the first cell if possible // Select the first cell if possible
if (firstColumn) { if (firstColumn) {
$focusedCellId = `${savedRow._id}-${firstColumn.name}` $focusedCellId = getCellID(savedRow._id, firstColumn.name)
} }
} }
isAdding = false isAdding = false
@ -118,7 +119,7 @@
visible = true visible = true
$hoveredRowId = NewRowID $hoveredRowId = NewRowID
if (firstColumn) { if (firstColumn) {
$focusedCellId = `${NewRowID}-${firstColumn.name}` $focusedCellId = getCellID(NewRowID, firstColumn.name)
} }
// Attach key listener // Attach key listener
@ -194,7 +195,7 @@
{/if} {/if}
</GutterCell> </GutterCell>
{#if $stickyColumn} {#if $stickyColumn}
{@const cellId = `${NewRowID}-${$stickyColumn.name}`} {@const cellId = getCellID(NewRowID, $stickyColumn.name)}
<DataCell <DataCell
{cellId} {cellId}
rowFocused rowFocused

View file

@ -8,6 +8,7 @@
import { GutterWidth, BlankRowID } from "../lib/constants" import { GutterWidth, BlankRowID } from "../lib/constants"
import GutterCell from "../cells/GutterCell.svelte" import GutterCell from "../cells/GutterCell.svelte"
import KeyboardShortcut from "./KeyboardShortcut.svelte" import KeyboardShortcut from "./KeyboardShortcut.svelte"
import { getCellID } from "../lib/utils"
const { const {
rows, rows,
@ -71,7 +72,7 @@
{@const rowSelected = !!$selectedRows[row._id]} {@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id} {@const rowHovered = $hoveredRowId === row._id}
{@const rowFocused = $focusedRow?._id === row._id} {@const rowFocused = $focusedRow?._id === row._id}
{@const cellId = `${row._id}-${$stickyColumn?.name}`} {@const cellId = getCellID(row._id, $stickyColumn?.name)}
<div <div
class="row" class="row"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}

View file

@ -13,6 +13,7 @@ import JSONCell from "../cells/JSONCell.svelte"
import AttachmentCell from "../cells/AttachmentCell.svelte" import AttachmentCell from "../cells/AttachmentCell.svelte"
import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte" import AttachmentSingleCell from "../cells/AttachmentSingleCell.svelte"
import BBReferenceCell from "../cells/BBReferenceCell.svelte" import BBReferenceCell from "../cells/BBReferenceCell.svelte"
import BBReferenceSingleCell from "../cells/BBReferenceSingleCell.svelte"
const TypeComponentMap = { const TypeComponentMap = {
[FieldType.STRING]: TextCell, [FieldType.STRING]: TextCell,
@ -29,6 +30,7 @@ const TypeComponentMap = {
[FieldType.FORMULA]: FormulaCell, [FieldType.FORMULA]: FormulaCell,
[FieldType.JSON]: JSONCell, [FieldType.JSON]: JSONCell,
[FieldType.BB_REFERENCE]: BBReferenceCell, [FieldType.BB_REFERENCE]: BBReferenceCell,
[FieldType.BB_REFERENCE_SINGLE]: BBReferenceSingleCell,
} }
export const getCellRenderer = column => { export const getCellRenderer = column => {
return TypeComponentMap[column?.schema?.type] || TextCell return TypeComponentMap[column?.schema?.type] || TextCell

View file

@ -1,5 +1,23 @@
import { helpers } from "@budibase/shared-core"
import { TypeIconMap } from "../../../constants" import { TypeIconMap } from "../../../constants"
// we can't use "-" for joining the ID/field, as this can be present in the ID or column name
// using something very unusual to avoid this problem
const JOINING_CHARACTER = "‽‽"
export const parseCellID = cellId => {
if (!cellId) {
return { id: undefined, field: undefined }
}
const parts = cellId.split(JOINING_CHARACTER)
const field = parts.pop()
return { id: parts.join(JOINING_CHARACTER), field }
}
export const getCellID = (rowId, fieldName) => {
return `${rowId}${JOINING_CHARACTER}${fieldName}`
}
export const getColor = (idx, opacity = 0.3) => { export const getColor = (idx, opacity = 0.3) => {
if (idx == null || idx === -1) { if (idx == null || idx === -1) {
idx = 0 idx = 0
@ -11,8 +29,12 @@ export const getColumnIcon = column => {
if (column.schema.autocolumn) { if (column.schema.autocolumn) {
return "MagicWand" return "MagicWand"
} }
const { type, subtype } = column.schema
if (helpers.schema.isDeprecatedSingleUserColumn(column.schema)) {
return "User"
}
const { type, subtype } = column.schema
const result = const result =
typeof TypeIconMap[type] === "object" && subtype typeof TypeIconMap[type] === "object" && subtype
? TypeIconMap[type][subtype] ? TypeIconMap[type][subtype]

View file

@ -2,6 +2,7 @@
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { debounce } from "../../../utils/utils" import { debounce } from "../../../utils/utils"
import { NewRowID } from "../lib/constants" import { NewRowID } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
const { const {
rows, rows,
@ -154,7 +155,7 @@
if (!firstColumn) { if (!firstColumn) {
return return
} }
focusedCellId.set(`${firstRow._id}-${firstColumn.name}`) focusedCellId.set(getCellID(firstRow._id, firstColumn.name))
} }
// Changes the focused cell by moving it left or right to a different column // Changes the focused cell by moving it left or right to a different column
@ -163,8 +164,7 @@
return return
} }
const cols = $visibleColumns const cols = $visibleColumns
const split = $focusedCellId.split("-") const { id, field: columnName } = parseCellID($focusedCellId)
const columnName = split[1]
let newColumnName let newColumnName
if (columnName === $stickyColumn?.name) { if (columnName === $stickyColumn?.name) {
const index = delta - 1 const index = delta - 1
@ -178,7 +178,7 @@
} }
} }
if (newColumnName) { if (newColumnName) {
$focusedCellId = `${split[0]}-${newColumnName}` $focusedCellId = getCellID(id, newColumnName)
} }
} }
@ -189,8 +189,8 @@
} }
const newRow = $rows[$focusedRow.__idx + delta] const newRow = $rows[$focusedRow.__idx + delta]
if (newRow) { if (newRow) {
const split = $focusedCellId.split("-") const { field } = parseCellID($focusedCellId)
$focusedCellId = `${newRow._id}-${split[1]}` $focusedCellId = getCellID(newRow._id, field)
} }
} }

View file

@ -3,6 +3,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { NewRowID } from "../lib/constants" import { NewRowID } from "../lib/constants"
import GridPopover from "./GridPopover.svelte" import GridPopover from "./GridPopover.svelte"
import { getCellID } from "../lib/utils"
const { const {
focusedRow, focusedRow,
@ -41,7 +42,7 @@
const newRow = await rows.actions.duplicateRow($focusedRow) const newRow = await rows.actions.duplicateRow($focusedRow)
if (newRow) { if (newRow) {
const column = $stickyColumn?.name || $columns[0].name const column = $stickyColumn?.name || $columns[0].name
$focusedCellId = `${newRow._id}-${column}` $focusedCellId = getCellID(newRow._id, column)
} }
} }

View file

@ -1,6 +1,7 @@
import { writable, derived, get } from "svelte/store" import { writable, derived, get } from "svelte/store"
import { fetchData } from "../../../fetch" import { fetchData } from "../../../fetch"
import { NewRowID, RowPageSize } from "../lib/constants" import { NewRowID, RowPageSize } from "../lib/constants"
import { getCellID, parseCellID } from "../lib/utils"
import { tick } from "svelte" import { tick } from "svelte"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
@ -206,7 +207,7 @@ export const createActions = context => {
// If the server doesn't reply with a valid error, assume that the source // If the server doesn't reply with a valid error, assume that the source
// of the error is the focused cell's column // of the error is the focused cell's column
if (!error?.json?.validationErrors && errorString) { if (!error?.json?.validationErrors && errorString) {
const focusedColumn = get(focusedCellId)?.split("-")[1] const { field: focusedColumn } = parseCellID(get(focusedCellId))
if (focusedColumn) { if (focusedColumn) {
error = { error = {
json: { json: {
@ -245,7 +246,7 @@ export const createActions = context => {
} }
// Set error against the cell // Set error against the cell
validation.actions.setError( validation.actions.setError(
`${rowId}-${column}`, getCellID(rowId, column),
Helpers.capitalise(err) Helpers.capitalise(err)
) )
// Ensure the column is visible // Ensure the column is visible
@ -265,7 +266,7 @@ export const createActions = context => {
// Focus the first cell with an error // Focus the first cell with an error
if (erroredColumns.length) { if (erroredColumns.length) {
focusedCellId.set(`${rowId}-${erroredColumns[0]}`) focusedCellId.set(getCellID(rowId, erroredColumns[0]))
} }
} else { } else {
get(notifications).error(errorString || "An unknown error occurred") get(notifications).error(errorString || "An unknown error occurred")
@ -571,9 +572,10 @@ export const initialise = context => {
return return
} }
// Stop if we changed row // Stop if we changed row
const oldRowId = id.split("-")[0] const split = parseCellID(id)
const oldColumn = id.split("-")[1] const oldRowId = split.id
const newRowId = get(focusedCellId)?.split("-")[0] const oldColumn = split.field
const { id: newRowId } = parseCellID(get(focusedCellId))
if (oldRowId !== newRowId) { if (oldRowId !== newRowId) {
return return
} }

View file

@ -1,6 +1,7 @@
import { writable, derived, get } from "svelte/store" import { writable, derived, get } from "svelte/store"
import { tick } from "svelte" import { tick } from "svelte"
import { Padding, GutterWidth, FocusedCellMinOffset } from "../lib/constants" import { Padding, GutterWidth, FocusedCellMinOffset } from "../lib/constants"
import { parseCellID } from "../lib/utils"
export const createStores = () => { export const createStores = () => {
const scroll = writable({ const scroll = writable({
@ -176,7 +177,7 @@ export const initialise = context => {
// Ensure horizontal position is viewable // Ensure horizontal position is viewable
// Check horizontal position of columns next // Check horizontal position of columns next
const $visibleColumns = get(visibleColumns) const $visibleColumns = get(visibleColumns)
const columnName = $focusedCellId?.split("-")[1] const { field: columnName } = parseCellID($focusedCellId)
const column = $visibleColumns.find(col => col.name === columnName) const column = $visibleColumns.find(col => col.name === columnName)
if (!column) { if (!column) {
return return

View file

@ -7,6 +7,7 @@ import {
MediumRowHeight, MediumRowHeight,
NewRowID, NewRowID,
} from "../lib/constants" } from "../lib/constants"
import { parseCellID } from "../lib/utils"
export const createStores = context => { export const createStores = context => {
const { props } = context const { props } = context
@ -25,7 +26,7 @@ export const createStores = context => {
const focusedRowId = derived( const focusedRowId = derived(
focusedCellId, focusedCellId,
$focusedCellId => { $focusedCellId => {
return $focusedCellId?.split("-")[0] return parseCellID($focusedCellId)?.id
}, },
null null
) )
@ -72,7 +73,7 @@ export const deriveStores = context => {
const focusedRow = derived( const focusedRow = derived(
[focusedCellId, rowLookupMap, rows], [focusedCellId, rowLookupMap, rows],
([$focusedCellId, $rowLookupMap, $rows]) => { ([$focusedCellId, $rowLookupMap, $rows]) => {
const rowId = $focusedCellId?.split("-")[0] const rowId = parseCellID($focusedCellId)?.id
// Edge case for new rows // Edge case for new rows
if (rowId === NewRowID) { if (rowId === NewRowID) {
@ -109,12 +110,11 @@ export const deriveStores = context => {
} }
export const createActions = context => { export const createActions = context => {
const { focusedCellId, selectedRows, hoveredRowId } = context const { focusedCellId, hoveredRowId } = context
// Callback when leaving the grid, deselecting all focussed or selected items // Callback when leaving the grid, deselecting all focussed or selected items
const blur = () => { const blur = () => {
focusedCellId.set(null) focusedCellId.set(null)
selectedRows.set({})
hoveredRowId.set(null) hoveredRowId.set(null)
} }
@ -152,7 +152,7 @@ export const initialise = context => {
const hasRow = rows.actions.hasRow const hasRow = rows.actions.hasRow
// Check selected cell // Check selected cell
const selectedRowId = $focusedCellId?.split("-")[0] const selectedRowId = parseCellID($focusedCellId)?.id
if (selectedRowId && !hasRow(selectedRowId)) { if (selectedRowId && !hasRow(selectedRowId)) {
focusedCellId.set(null) focusedCellId.set(null)
} }

View file

@ -1,4 +1,5 @@
import { writable, get, derived } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { getCellID, parseCellID } from "../lib/utils"
// Normally we would break out actions into the explicit "createActions" // Normally we would break out actions into the explicit "createActions"
// function, but for validation all these actions are pure so can go into // function, but for validation all these actions are pure so can go into
@ -12,7 +13,7 @@ export const createStores = () => {
Object.entries($validation).forEach(([key, error]) => { Object.entries($validation).forEach(([key, error]) => {
// Extract row ID from all errored cell IDs // Extract row ID from all errored cell IDs
if (error) { if (error) {
map[key.split("-")[0]] = true map[parseCellID(key).id] = true
} }
}) })
return map return map
@ -53,10 +54,10 @@ export const initialise = context => {
const $stickyColumn = get(stickyColumn) const $stickyColumn = get(stickyColumn)
validation.update(state => { validation.update(state => {
$columns.forEach(column => { $columns.forEach(column => {
state[`${id}-${column.name}`] = null state[getCellID(id, column.name)] = null
}) })
if ($stickyColumn) { if ($stickyColumn) {
state[`${id}-${$stickyColumn.name}`] = null state[getCellID(id, stickyColumn.name)] = null
} }
return state return state
}) })

View file

@ -57,6 +57,7 @@ export const PlanType = {
PRO: "pro", PRO: "pro",
BUSINESS: "business", BUSINESS: "business",
ENTERPRISE: "enterprise", ENTERPRISE: "enterprise",
ENTERPRISE_BASIC_TRIAL: "enterprise_basic_trial",
} }
/** /**
@ -124,17 +125,18 @@ export const TypeIconMap = {
[FieldType.ARRAY]: "Duplicate", [FieldType.ARRAY]: "Duplicate",
[FieldType.NUMBER]: "123", [FieldType.NUMBER]: "123",
[FieldType.BOOLEAN]: "Boolean", [FieldType.BOOLEAN]: "Boolean",
[FieldType.ATTACHMENTS]: "Attach", [FieldType.ATTACHMENTS]: "DocumentFragmentGroup",
[FieldType.ATTACHMENT_SINGLE]: "Attach", [FieldType.ATTACHMENT_SINGLE]: "DocumentFragment",
[FieldType.LINK]: "DataCorrelated", [FieldType.LINK]: "DataCorrelated",
[FieldType.FORMULA]: "Calculator", [FieldType.FORMULA]: "Calculator",
[FieldType.JSON]: "Brackets", [FieldType.JSON]: "Brackets",
[FieldType.BIGINT]: "TagBold", [FieldType.BIGINT]: "TagBold",
[FieldType.AUTO]: "MagicWand", [FieldType.AUTO]: "MagicWand",
[FieldType.USER]: "User",
[FieldType.USERS]: "UserGroup",
[FieldType.BB_REFERENCE]: { [FieldType.BB_REFERENCE]: {
[BBReferenceFieldSubType.USER]: "User", [BBReferenceFieldSubType.USER]: "UserGroup",
[BBReferenceFieldSubType.USERS]: "UserGroup", [BBReferenceFieldSubType.USERS]: "UserGroup",
}, },
[FieldType.BB_REFERENCE_SINGLE]: {
[BBReferenceFieldSubType.USER]: "User",
},
} }

@ -1 +1 @@
Subproject commit 479879246aac5dd3073cc695945c62c41fae5b0e Subproject commit d3c3077011a8e20ed3c48dcd6301caca4120b6ac

View file

@ -101,7 +101,6 @@
"mysql2": "3.9.7", "mysql2": "3.9.7",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"object-sizeof": "2.6.1", "object-sizeof": "2.6.1",
"open": "8.4.0",
"openai": "^3.2.1", "openai": "^3.2.1",
"openapi-types": "9.3.1", "openapi-types": "9.3.1",
"pg": "8.10.0", "pg": "8.10.0",
@ -113,12 +112,8 @@
"server-destroy": "1.0.1", "server-destroy": "1.0.1",
"snowflake-promise": "^4.5.0", "snowflake-promise": "^4.5.0",
"socket.io": "4.6.1", "socket.io": "4.6.1",
"sqlite3": "5.1.6",
"swagger-parser": "10.0.3",
"tar": "6.1.15", "tar": "6.1.15",
"to-json-schema": "0.2.5", "to-json-schema": "0.2.5",
"undici": "^6.0.1",
"undici-types": "^6.0.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"validate.js": "0.13.1", "validate.js": "0.13.1",
"worker-farm": "1.7.0", "worker-farm": "1.7.0",
@ -144,16 +139,13 @@
"@types/supertest": "2.0.14", "@types/supertest": "2.0.14",
"@types/tar": "6.1.5", "@types/tar": "6.1.5",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
"apidoc": "0.50.4",
"copyfiles": "2.4.1", "copyfiles": "2.4.1",
"docker-compose": "0.23.17", "docker-compose": "0.23.17",
"jest": "29.7.0", "jest": "29.7.0",
"jest-openapi": "0.14.2", "jest-openapi": "0.14.2",
"jest-runner": "29.7.0",
"nock": "13.5.4", "nock": "13.5.4",
"nodemon": "2.0.15", "nodemon": "2.0.15",
"openapi-typescript": "5.2.0", "openapi-typescript": "5.2.0",
"path-to-regexp": "6.2.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"supertest": "6.3.3", "supertest": "6.3.3",
"swagger-jsdoc": "6.1.0", "swagger-jsdoc": "6.1.0",

View file

@ -279,8 +279,7 @@ export async function trigger(ctx: UserCtx) {
{ {
fields: ctx.request.body.fields, fields: ctx.request.body.fields,
timeout: timeout:
ctx.request.body.timeout * 1000 || ctx.request.body.timeout * 1000 || env.AUTOMATION_THREAD_TIMEOUT,
env.getDefaults().AUTOMATION_SYNC_TIMEOUT,
}, },
{ getResponses: true } { getResponses: true }
) )

View file

@ -2,7 +2,7 @@ import stream from "stream"
import archiver from "archiver" import archiver from "archiver"
import { quotas } from "@budibase/pro" import { quotas } from "@budibase/pro"
import { objectStore } from "@budibase/backend-core" import { objectStore, context } from "@budibase/backend-core"
import * as internal from "./internal" import * as internal from "./internal"
import * as external from "./external" import * as external from "./external"
import { isExternalTableID } from "../../../integrations/utils" import { isExternalTableID } from "../../../integrations/utils"
@ -198,8 +198,18 @@ export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) { export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
await context.ensureSnippetContext(true)
const enrichedQuery = await utils.enrichSearchContext(
{ ...ctx.request.body.query },
{
user: sdk.users.getUserContextBindings(ctx.user),
}
)
const searchParams: RowSearchParams = { const searchParams: RowSearchParams = {
...ctx.request.body, ...ctx.request.body,
query: enrichedQuery,
tableId, tableId,
} }

View file

@ -1,20 +1,11 @@
import { getRowParams } from "../../../db/utils" import { getRowParams } from "../../../db/utils"
import { import {
outputProcessing, outputProcessing,
processAutoColumn,
processFormulas, processFormulas,
} from "../../../utilities/rowProcessor" } from "../../../utilities/rowProcessor"
import { context, locks } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { import { Table, Row, FormulaType, FieldType } from "@budibase/types"
Table,
Row,
LockType,
LockName,
FormulaType,
FieldType,
} from "@budibase/types"
import * as linkRows from "../../../db/linkedRows" import * as linkRows from "../../../db/linkedRows"
import sdk from "../../../sdk"
import isEqual from "lodash/isEqual" import isEqual from "lodash/isEqual"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -151,30 +142,7 @@ export async function finaliseRow(
// if another row has been written since processing this will // if another row has been written since processing this will
// handle the auto ID clash // handle the auto ID clash
if (oldTable && !isEqual(oldTable, table)) { if (oldTable && !isEqual(oldTable, table)) {
try { await db.put(table)
await db.put(table)
} catch (err: any) {
if (err.status === 409) {
// Some conflicts with the autocolumns occurred, we need to refetch the table and recalculate
await locks.doWithLock(
{
type: LockType.AUTO_EXTEND,
name: LockName.PROCESS_AUTO_COLUMNS,
resource: table._id,
},
async () => {
const latestTable = await sdk.tables.getTable(table._id!)
let response = processAutoColumn(null, latestTable, row, {
reprocessing: true,
})
await db.put(response.table)
row = response.row
}
)
} else {
throw err
}
}
} }
const response = await db.put(row) const response = await db.put(row)
// for response, calculate the formulas for the enriched row // for response, calculate the formulas for the enriched row

View file

@ -1,5 +1,6 @@
// need to handle table name + field or just field, depending on if relationships used // need to handle table name + field or just field, depending on if relationships used
import { FieldType, Row, Table } from "@budibase/types" import { FieldType, Row, Table } from "@budibase/types"
import { helpers } from "@budibase/shared-core"
import { generateRowIdField } from "../../../../integrations/utils" import { generateRowIdField } from "../../../../integrations/utils"
import { CONSTANT_INTERNAL_ROW_COLS } from "../../../../db/utils" import { CONSTANT_INTERNAL_ROW_COLS } from "../../../../db/utils"
@ -73,12 +74,15 @@ export function basicProcessing({
// filter the row down to what is actually the row (not joined) // filter the row down to what is actually the row (not joined)
for (let field of Object.values(table.schema)) { for (let field of Object.values(table.schema)) {
const fieldName = field.name const fieldName = field.name
const value = extractFieldValue({ let value = extractFieldValue({
row, row,
tableName: table.name, tableName: table.name,
fieldName, fieldName,
isLinked, isLinked,
}) })
if (value instanceof Buffer) {
value = value.toString()
}
// all responses include "select col as table.col" so that overlaps are handled // all responses include "select col as table.col" so that overlaps are handled
if (value != null) { if (value != null) {
thisRow[fieldName] = value thisRow[fieldName] = value
@ -104,12 +108,17 @@ export function basicProcessing({
export function fixArrayTypes(row: Row, table: Table) { export function fixArrayTypes(row: Row, table: Table) {
for (let [fieldName, schema] of Object.entries(table.schema)) { for (let [fieldName, schema] of Object.entries(table.schema)) {
if (schema.type === FieldType.ARRAY && typeof row[fieldName] === "string") { if (
[FieldType.ARRAY, FieldType.BB_REFERENCE].includes(schema.type) &&
typeof row[fieldName] === "string"
) {
try { try {
row[fieldName] = JSON.parse(row[fieldName]) row[fieldName] = JSON.parse(row[fieldName])
} catch (err) { } catch (err) {
// couldn't convert back to array, ignore if (!helpers.schema.isDeprecatedSingleUserColumn(schema)) {
delete row[fieldName] // couldn't convert back to array, ignore
delete row[fieldName]
}
} }
} }
} }

View file

@ -22,7 +22,7 @@ import {
getInternalRowId, getInternalRowId,
} from "./basic" } from "./basic"
import sdk from "../../../../sdk" import sdk from "../../../../sdk"
import { processStringSync } from "@budibase/string-templates"
import validateJs from "validate.js" import validateJs from "validate.js"
validateJs.extend(validateJs.validators.datetime, { validateJs.extend(validateJs.validators.datetime, {
@ -117,6 +117,19 @@ export async function validate(
}) })
} }
function fixBooleanFields({ row, table }: { row: Row; table: Table }) {
for (let col of Object.values(table.schema)) {
if (col.type === FieldType.BOOLEAN) {
if (row[col.name] === 1) {
row[col.name] = true
} else if (row[col.name] === 0) {
row[col.name] = false
}
}
}
return row
}
export async function sqlOutputProcessing( export async function sqlOutputProcessing(
rows: DatasourcePlusQueryResponse, rows: DatasourcePlusQueryResponse,
table: Table, table: Table,
@ -161,7 +174,9 @@ export async function sqlOutputProcessing(
if (thisRow._id == null) { if (thisRow._id == null) {
throw new Error("Unable to generate row ID for SQL rows") throw new Error("Unable to generate row ID for SQL rows")
} }
finalRows[thisRow._id] = thisRow
finalRows[thisRow._id] = fixBooleanFields({ row: thisRow, table })
// do this at end once its been added to the final rows // do this at end once its been added to the final rows
finalRows = await updateRelationshipColumns( finalRows = await updateRelationshipColumns(
table, table,
@ -189,3 +204,63 @@ export async function sqlOutputProcessing(
export function isUserMetadataTable(tableId: string) { export function isUserMetadataTable(tableId: string) {
return tableId === InternalTables.USER_METADATA return tableId === InternalTables.USER_METADATA
} }
export async function enrichArrayContext(
fields: any[],
inputs = {},
helpers = true
): Promise<any[]> {
const map: Record<string, any> = {}
for (let index in fields) {
map[index] = fields[index]
}
const output = await enrichSearchContext(map, inputs, helpers)
const outputArray: any[] = []
for (let [key, value] of Object.entries(output)) {
outputArray[parseInt(key)] = value
}
return outputArray
}
export async function enrichSearchContext(
fields: Record<string, any>,
inputs = {},
helpers = true
): Promise<Record<string, any>> {
const enrichedQuery: Record<string, any> = {}
if (!fields || !inputs) {
return enrichedQuery
}
const parameters = { ...inputs }
if (Array.isArray(fields)) {
return enrichArrayContext(fields, inputs, helpers)
}
// enrich the fields with dynamic parameters
for (let key of Object.keys(fields)) {
if (fields[key] == null) {
enrichedQuery[key] = null
continue
}
if (typeof fields[key] === "object") {
// enrich nested fields object
enrichedQuery[key] = await enrichSearchContext(
fields[key],
parameters,
helpers
)
} else if (typeof fields[key] === "string") {
// enrich string value as normal
enrichedQuery[key] = processStringSync(fields[key], parameters, {
noEscaping: true,
noHelpers: !helpers,
escapeNewlines: true,
})
} else {
enrichedQuery[key] = fields[key]
}
}
return enrichedQuery
}

View file

@ -9,7 +9,8 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { dataFilters } from "@budibase/shared-core" import { dataFilters } from "@budibase/shared-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { db } from "@budibase/backend-core" import { db, context } from "@budibase/backend-core"
import { enrichSearchContext } from "./utils"
export async function searchView( export async function searchView(
ctx: UserCtx<SearchViewRowRequest, SearchRowResponse> ctx: UserCtx<SearchViewRowRequest, SearchRowResponse>
@ -56,10 +57,16 @@ export async function searchView(
}) })
} }
await context.ensureSnippetContext(true)
const enrichedQuery = await enrichSearchContext(query, {
user: sdk.users.getUserContextBindings(ctx.user),
})
const searchOptions: RequiredKeys<SearchViewRowRequest> & const searchOptions: RequiredKeys<SearchViewRowRequest> &
RequiredKeys<Pick<RowSearchParams, "tableId" | "query" | "fields">> = { RequiredKeys<Pick<RowSearchParams, "tableId" | "query" | "fields">> = {
tableId: view.tableId, tableId: view.tableId,
query, query: enrichedQuery,
fields: viewFields, fields: viewFields,
...getSortOptions(body, view), ...getSortOptions(body, view),
limit: body.limit, limit: body.limit,

View file

@ -180,5 +180,5 @@ export async function migrate(ctx: UserCtx<MigrateRequest, MigrateResponse>) {
} }
ctx.status = 200 ctx.status = 200
ctx.body = { message: `Column ${oldColumn.name} migrated.` } ctx.body = { message: `Column ${oldColumn} migrated.` }
} }

View file

@ -4,10 +4,12 @@ import { APIError } from "@budibase/types"
describe("/api/applications/:appId/sync", () => { describe("/api/applications/:appId/sync", () => {
let config = setup.getConfig() let config = setup.getConfig()
afterAll(setup.afterAll)
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
}) })
afterAll(async () => {
setup.afterAll()
})
describe("/api/attachments/process", () => { describe("/api/attachments/process", () => {
it("should accept an image file upload", async () => { it("should accept an image file upload", async () => {
@ -18,7 +20,8 @@ describe("/api/applications/:appId/sync", () => {
expect(resp.length).toBe(1) expect(resp.length).toBe(1)
let upload = resp[0] let upload = resp[0]
expect(upload.url.endsWith(".jpg")).toBe(true)
expect(upload.url.split("?")[0].endsWith(".jpg")).toBe(true)
expect(upload.extension).toBe("jpg") expect(upload.extension).toBe("jpg")
expect(upload.size).toBe(1) expect(upload.size).toBe(1)
expect(upload.name).toBe("1px.jpg") expect(upload.name).toBe("1px.jpg")

View file

@ -1,16 +1,18 @@
import { mocks } from "@budibase/backend-core/tests"
import tk from "timekeeper" import tk from "timekeeper"
import * as setup from "./utilities" import * as setup from "./utilities"
import { events } from "@budibase/backend-core" import { events } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { checkBuilderEndpoint } from "./utilities/TestFunctions" import { checkBuilderEndpoint } from "./utilities/TestFunctions"
import { mocks } from "@budibase/backend-core/tests"
mocks.licenses.useBackups() mocks.licenses.useBackups()
describe("/backups", () => { describe("/backups", () => {
let config = setup.getConfig() let config = setup.getConfig()
afterAll(setup.afterAll) afterAll(async () => {
setup.afterAll()
})
beforeEach(async () => { beforeEach(async () => {
tk.reset() tk.reset()

View file

@ -16,6 +16,7 @@ import {
SourceName, SourceName,
Table, Table,
TableSchema, TableSchema,
SupportedSqlTypes,
} from "@budibase/types" } from "@budibase/types"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import { tableForDatasource } from "../../../tests/utilities/structures" import { tableForDatasource } from "../../../tests/utilities/structures"
@ -261,20 +262,6 @@ describe("/datasources", () => {
}) })
) )
type SupportedSqlTypes =
| FieldType.STRING
| FieldType.BARCODEQR
| FieldType.LONGFORM
| FieldType.OPTIONS
| FieldType.DATETIME
| FieldType.NUMBER
| FieldType.BOOLEAN
| FieldType.FORMULA
| FieldType.BIGINT
| FieldType.BB_REFERENCE
| FieldType.LINK
| FieldType.ARRAY
const fullSchema: { const fullSchema: {
[type in SupportedSqlTypes]: FieldSchema & { type: type } [type in SupportedSqlTypes]: FieldSchema & { type: type }
} = { } = {
@ -337,7 +324,12 @@ describe("/datasources", () => {
[FieldType.BB_REFERENCE]: { [FieldType.BB_REFERENCE]: {
name: "bb_reference", name: "bb_reference",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS, subtype: BBReferenceFieldSubType.USER,
},
[FieldType.BB_REFERENCE_SINGLE]: {
name: "bb_reference_single",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
}, },
} }

View file

@ -32,8 +32,6 @@ import * as uuid from "uuid"
const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString() const timestamp = new Date("2023-01-26T11:48:57.597Z").toISOString()
tk.freeze(timestamp) tk.freeze(timestamp)
jest.unmock("mssql")
describe.each([ describe.each([
["internal", undefined], ["internal", undefined],
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)], [DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
@ -131,7 +129,13 @@ describe.each([
const assertRowUsage = async (expected: number) => { const assertRowUsage = async (expected: number) => {
const usage = await getRowUsage() const usage = await getRowUsage()
expect(usage).toBe(expected)
// Because our quota tracking is not perfect, we allow a 10% margin of
// error. This is to account for the fact that parallel writes can result
// in some quota updates getting lost. We don't have any need to solve this
// right now, so we just allow for some error.
expect(usage).toBeGreaterThan(expected * 0.9)
expect(usage).toBeLessThan(expected * 1.1)
} }
const defaultRowFields = isInternal const defaultRowFields = isInternal
@ -194,39 +198,99 @@ describe.each([
await assertRowUsage(rowUsage) await assertRowUsage(rowUsage)
}) })
it("increment row autoId per create row request", async () => { isInternal &&
const rowUsage = await getRowUsage() it("increment row autoId per create row request", async () => {
const rowUsage = await getRowUsage()
const newTable = await config.api.table.save( const newTable = await config.api.table.save(
saveTableRequest({ saveTableRequest({
schema: { schema: {
"Row ID": { "Row ID": {
name: "Row ID", name: "Row ID",
type: FieldType.NUMBER, type: FieldType.NUMBER,
subtype: AutoFieldSubType.AUTO_ID, subtype: AutoFieldSubType.AUTO_ID,
icon: "ri-magic-line", icon: "ri-magic-line",
autocolumn: true, autocolumn: true,
constraints: { constraints: {
type: "number", type: "number",
presence: true, presence: true,
numericality: { numericality: {
greaterThanOrEqualTo: "", greaterThanOrEqualTo: "",
lessThanOrEqualTo: "", lessThanOrEqualTo: "",
},
}, },
}, },
}, },
}, })
}) )
)
let previousId = 0 let previousId = 0
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
const row = await config.api.row.save(newTable._id!, {}) const row = await config.api.row.save(newTable._id!, {})
expect(row["Row ID"]).toBeGreaterThan(previousId) expect(row["Row ID"]).toBeGreaterThan(previousId)
previousId = row["Row ID"] previousId = row["Row ID"]
} }
await assertRowUsage(rowUsage + 10) await assertRowUsage(rowUsage + 10)
}) })
isInternal &&
it("should increment auto ID correctly when creating rows in parallel", async () => {
const table = await config.api.table.save(
saveTableRequest({
schema: {
"Row ID": {
name: "Row ID",
type: FieldType.NUMBER,
subtype: AutoFieldSubType.AUTO_ID,
icon: "ri-magic-line",
autocolumn: true,
constraints: {
type: "number",
presence: true,
numericality: {
greaterThanOrEqualTo: "",
lessThanOrEqualTo: "",
},
},
},
},
})
)
const sequence = Array(50)
.fill(0)
.map((_, i) => i + 1)
// This block of code is simulating users creating auto ID rows at the
// same time. It's expected that this operation will sometimes return
// a document conflict error (409), but the idea is to retry in those
// situations. The code below does this a large number of times with
// small, random delays between them to try and get through the list
// as quickly as possible.
await Promise.all(
sequence.map(async () => {
const attempts = 20
for (let attempt = 0; attempt < attempts; attempt++) {
try {
await config.api.row.save(table._id!, {})
return
} catch (e) {
await new Promise(r => setTimeout(r, Math.random() * 15))
}
}
throw new Error(`Failed to create row after ${attempts} attempts`)
})
)
const rows = await config.api.row.fetch(table._id!)
expect(rows).toHaveLength(50)
// The main purpose of this test is to ensure that even under pressure,
// we maintain data integrity. An auto ID column should hand out
// monotonically increasing unique integers no matter what.
const ids = rows.map(r => r["Row ID"])
expect(ids).toEqual(expect.arrayContaining(sequence))
})
isInternal && isInternal &&
it("row values are coerced", async () => { it("row values are coerced", async () => {
@ -856,7 +920,7 @@ describe.each([
await config.withEnv({ SELF_HOSTED: "true" }, async () => { await config.withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => { return context.doInAppContext(config.getAppId(), async () => {
const enriched = await outputProcessing(table, [row]) const enriched = await outputProcessing(table, [row])
expect((enriched as Row[])[0].attachment.url).toBe( expect((enriched as Row[])[0].attachment.url.split("?")[0]).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` `/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
) )
}) })
@ -889,7 +953,7 @@ describe.each([
await config.withEnv({ SELF_HOSTED: "true" }, async () => { await config.withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => { return context.doInAppContext(config.getAppId(), async () => {
const enriched = await outputProcessing(table, [row]) const enriched = await outputProcessing(table, [row])
expect((enriched as Row[])[0].attachment[0].url).toBe( expect((enriched as Row[])[0].attachment[0].url.split("?")[0]).toBe(
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}` `/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
) )
}) })

View file

@ -1,11 +1,13 @@
import { tableForDatasource } from "../../../tests/utilities/structures" import { tableForDatasource } from "../../../tests/utilities/structures"
import { DatabaseName, getDatasource } from "../../../integrations/tests/utils" import { DatabaseName, getDatasource } from "../../../integrations/tests/utils"
import { db as dbCore } from "@budibase/backend-core"
import * as setup from "./utilities" import * as setup from "./utilities"
import { import {
AutoFieldSubType, AutoFieldSubType,
Datasource, Datasource,
EmptyFilterOption, EmptyFilterOption,
BBReferenceFieldSubType,
FieldType, FieldType,
RowSearchParams, RowSearchParams,
SearchFilters, SearchFilters,
@ -13,10 +15,14 @@ import {
SortType, SortType,
Table, Table,
TableSchema, TableSchema,
User,
} from "@budibase/types" } from "@budibase/types"
import _ from "lodash" import _ from "lodash"
import tk from "timekeeper"
import { encodeJSBinding } from "@budibase/string-templates"
jest.unmock("mssql") const serverTime = new Date("2024-05-06T00:00:00.000Z")
tk.freeze(serverTime)
describe.each([ describe.each([
["lucene", undefined], ["lucene", undefined],
@ -35,11 +41,25 @@ describe.each([
let datasource: Datasource | undefined let datasource: Datasource | undefined
let table: Table let table: Table
const snippets = [
{
name: "WeeksAgo",
code: `return function (weeks) {\n const currentTime = new Date(${Date.now()});\n currentTime.setDate(currentTime.getDate()-(7 * (weeks || 1)));\n return currentTime.toISOString();\n}`,
},
]
beforeAll(async () => { beforeAll(async () => {
if (isSqs) { if (isSqs) {
envCleanup = config.setEnv({ SQS_SEARCH_ENABLE: "true" }) envCleanup = config.setEnv({ SQS_SEARCH_ENABLE: "true" })
} }
await config.init() await config.init()
if (config.app?.appId) {
config.app = await config.api.application.update(config.app?.appId, {
snippets,
})
}
if (dsProvider) { if (dsProvider) {
datasource = await config.createDatasource({ datasource = await config.createDatasource({
datasource: await dsProvider, datasource: await dsProvider,
@ -67,6 +87,22 @@ describe.each([
class SearchAssertion { class SearchAssertion {
constructor(private readonly query: RowSearchParams) {} constructor(private readonly query: RowSearchParams) {}
private findRow(expectedRow: any, foundRows: any[]) {
const row = foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
if (!row) {
const fields = Object.keys(expectedRow)
// To make the error message more readable, we only include the fields
// that are present in the expected row.
const searchedObjects = foundRows.map(row => _.pick(row, fields))
throw new Error(
`Failed to find row: ${JSON.stringify(
expectedRow
)} in ${JSON.stringify(searchedObjects)}`
)
}
return row
}
// Asserts that the query returns rows matching exactly the set of rows // Asserts that the query returns rows matching exactly the set of rows
// passed in. The order of the rows matters. Rows returned in an order // passed in. The order of the rows matters. Rows returned in an order
// different to the one passed in will cause the assertion to fail. Extra // different to the one passed in will cause the assertion to fail. Extra
@ -82,9 +118,7 @@ describe.each([
// eslint-disable-next-line jest/no-standalone-expect // eslint-disable-next-line jest/no-standalone-expect
expect(foundRows).toEqual( expect(foundRows).toEqual(
expectedRows.map((expectedRow: any) => expectedRows.map((expectedRow: any) =>
expect.objectContaining( expect.objectContaining(this.findRow(expectedRow, foundRows))
foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
)
) )
) )
} }
@ -104,9 +138,7 @@ describe.each([
expect(foundRows).toEqual( expect(foundRows).toEqual(
expect.arrayContaining( expect.arrayContaining(
expectedRows.map((expectedRow: any) => expectedRows.map((expectedRow: any) =>
expect.objectContaining( expect.objectContaining(this.findRow(expectedRow, foundRows))
foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
)
) )
) )
) )
@ -125,9 +157,7 @@ describe.each([
expect(foundRows).toEqual( expect(foundRows).toEqual(
expect.arrayContaining( expect.arrayContaining(
expectedRows.map((expectedRow: any) => expectedRows.map((expectedRow: any) =>
expect.objectContaining( expect.objectContaining(this.findRow(expectedRow, foundRows))
foundRows.find(foundRow => _.isMatch(foundRow, expectedRow))
)
) )
) )
) )
@ -156,7 +186,406 @@ describe.each([
return expectSearch({ query }) return expectSearch({ query })
} }
describe("strings", () => { describe("boolean", () => {
beforeAll(async () => {
await createTable({
isTrue: { name: "isTrue", type: FieldType.BOOLEAN },
})
await createRows([{ isTrue: true }, { isTrue: false }])
})
describe("equal", () => {
it("successfully finds true row", () =>
expectQuery({ equal: { isTrue: true } }).toMatchExactly([
{ isTrue: true },
]))
it("successfully finds false row", () =>
expectQuery({ equal: { isTrue: false } }).toMatchExactly([
{ isTrue: false },
]))
})
describe("notEqual", () => {
it("successfully finds false row", () =>
expectQuery({ notEqual: { isTrue: true } }).toContainExactly([
{ isTrue: false },
]))
it("successfully finds true row", () =>
expectQuery({ notEqual: { isTrue: false } }).toContainExactly([
{ isTrue: true },
]))
})
describe("oneOf", () => {
it("successfully finds true row", () =>
expectQuery({ oneOf: { isTrue: [true] } }).toContainExactly([
{ isTrue: true },
]))
it("successfully finds false row", () =>
expectQuery({ oneOf: { isTrue: [false] } }).toContainExactly([
{ isTrue: false },
]))
})
describe("sort", () => {
it("sorts ascending", () =>
expectSearch({
query: {},
sort: "isTrue",
sortOrder: SortOrder.ASCENDING,
}).toMatchExactly([{ isTrue: false }, { isTrue: true }]))
it("sorts descending", () =>
expectSearch({
query: {},
sort: "isTrue",
sortOrder: SortOrder.DESCENDING,
}).toMatchExactly([{ isTrue: true }, { isTrue: false }]))
})
})
// Ensure all bindings resolve and perform as expected
describe("bindings", () => {
let globalUsers: any = []
const future = new Date(serverTime.getTime())
future.setDate(future.getDate() + 30)
const rows = (currentUser: User) => {
return [
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
{ name: currentUser.firstName, appointment: future.toISOString() },
{ name: "serverDate", appointment: serverTime.toISOString() },
{
name: "single user, session user",
single_user: JSON.stringify(currentUser),
},
{
name: "single user",
single_user: JSON.stringify(globalUsers[0]),
},
{
name: "deprecated single user, session user",
deprecated_single_user: JSON.stringify([currentUser]),
},
{
name: "deprecated single user",
deprecated_single_user: JSON.stringify([globalUsers[0]]),
},
{
name: "multi user",
multi_user: JSON.stringify(globalUsers),
},
{
name: "multi user with session user",
multi_user: JSON.stringify([...globalUsers, currentUser]),
},
{
name: "deprecated multi user",
deprecated_multi_user: JSON.stringify(globalUsers),
},
{
name: "deprecated multi user with session user",
deprecated_multi_user: JSON.stringify([...globalUsers, currentUser]),
},
]
}
beforeAll(async () => {
// Set up some global users
globalUsers = await Promise.all(
Array(2)
.fill(0)
.map(async () => {
const globalUser = await config.globalUser()
const userMedataId = globalUser._id
? dbCore.generateUserMetadataID(globalUser._id)
: null
return {
_id: globalUser._id,
_meta: userMedataId,
}
})
)
await createTable({
name: { name: "name", type: FieldType.STRING },
appointment: { name: "appointment", type: FieldType.DATETIME },
single_user: {
name: "single_user",
type: FieldType.BB_REFERENCE_SINGLE,
subtype: BBReferenceFieldSubType.USER,
},
deprecated_single_user: {
name: "deprecated_single_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
},
multi_user: {
name: "multi_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USER,
constraints: {
type: "array",
},
},
deprecated_multi_user: {
name: "deprecated_multi_user",
type: FieldType.BB_REFERENCE,
subtype: BBReferenceFieldSubType.USERS,
constraints: {
type: "array",
},
},
})
await createRows(rows(config.getUser()))
})
// !! Current User is auto generated per run
it("should return all rows matching the session user firstname", async () => {
await expectQuery({
equal: { name: "{{ [user].firstName }}" },
}).toContainExactly([
{
name: config.getUser().firstName,
appointment: future.toISOString(),
},
])
})
it("should parse the date binding and return all rows after the resolved value", async () => {
await expectQuery({
range: {
appointment: {
low: "{{ [now] }}",
high: "9999-00-00T00:00:00.000Z",
},
},
}).toContainExactly([
{
name: config.getUser().firstName,
appointment: future.toISOString(),
},
{ name: "serverDate", appointment: serverTime.toISOString() },
])
})
it("should parse the date binding and return all rows before the resolved value", async () => {
await expectQuery({
range: {
appointment: {
low: "0000-00-00T00:00:00.000Z",
high: "{{ [now] }}",
},
},
}).toContainExactly([
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
{ name: "serverDate", appointment: serverTime.toISOString() },
])
})
it("should parse the encoded js snippet. Return rows with appointments up to 1 week in the past", async () => {
const jsBinding = "return snippets.WeeksAgo();"
const encodedBinding = encodeJSBinding(jsBinding)
await expectQuery({
range: {
appointment: {
low: "0000-00-00T00:00:00.000Z",
high: encodedBinding,
},
},
}).toContainExactly([
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
])
})
it("should parse the encoded js binding. Return rows with appointments 2 weeks in the past", async () => {
const jsBinding =
"const currentTime = new Date()\ncurrentTime.setDate(currentTime.getDate()-14);\nreturn currentTime.toISOString();"
const encodedBinding = encodeJSBinding(jsBinding)
await expectQuery({
range: {
appointment: {
low: "0000-00-00T00:00:00.000Z",
high: encodedBinding,
},
},
}).toContainExactly([
{ name: "foo", appointment: "1982-01-05T00:00:00.000Z" },
{ name: "bar", appointment: "1995-05-06T00:00:00.000Z" },
])
})
it("should match a single user row by the session user id", async () => {
await expectQuery({
equal: { single_user: "{{ [user]._id }}" },
}).toContainExactly([
{
name: "single user, session user",
single_user: { _id: config.getUser()._id },
},
])
})
it("should match a deprecated single user row by the session user id", async () => {
await expectQuery({
equal: { deprecated_single_user: "{{ [user]._id }}" },
}).toContainExactly([
{
name: "deprecated single user, session user",
deprecated_single_user: [{ _id: config.getUser()._id }],
},
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should match the session user id in a multi user field", async () => {
const allUsers = [...globalUsers, config.getUser()].map((user: any) => {
return { _id: user._id }
})
await expectQuery({
contains: { multi_user: ["{{ [user]._id }}"] },
}).toContainExactly([
{
name: "multi user with session user",
multi_user: allUsers,
},
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should match the session user id in a deprecated multi user field", async () => {
const allUsers = [...globalUsers, config.getUser()].map((user: any) => {
return { _id: user._id }
})
await expectQuery({
contains: { deprecated_multi_user: ["{{ [user]._id }}"] },
}).toContainExactly([
{
name: "deprecated multi user with session user",
deprecated_multi_user: allUsers,
},
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should not match the session user id in a multi user field", async () => {
await expectQuery({
notContains: { multi_user: ["{{ [user]._id }}"] },
notEmpty: { multi_user: true },
}).toContainExactly([
{
name: "multi user",
multi_user: globalUsers.map((user: any) => {
return { _id: user._id }
}),
},
])
})
// TODO(samwho): fix for SQS
!isSqs &&
it("should not match the session user id in a deprecated multi user field", async () => {
await expectQuery({
notContains: { deprecated_multi_user: ["{{ [user]._id }}"] },
notEmpty: { deprecated_multi_user: true },
}).toContainExactly([
{
name: "deprecated multi user",
deprecated_multi_user: globalUsers.map((user: any) => {
return { _id: user._id }
}),
},
])
})
it("should match the session user id and a user table row id using helpers, user binding and a static user id.", async () => {
await expectQuery({
oneOf: {
single_user: [
"{{ default [user]._id '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "single user, session user",
single_user: { _id: config.getUser()._id },
},
{
name: "single user",
single_user: { _id: globalUsers[0]._id },
},
])
})
it("should match the session user id and a user table row id using helpers, user binding and a static user id. (deprecated single user)", async () => {
await expectQuery({
oneOf: {
deprecated_single_user: [
"{{ default [user]._id '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "deprecated single user, session user",
deprecated_single_user: [{ _id: config.getUser()._id }],
},
{
name: "deprecated single user",
deprecated_single_user: [{ _id: globalUsers[0]._id }],
},
])
})
it("should resolve 'default' helper to '_empty_' when binding resolves to nothing", async () => {
await expectQuery({
oneOf: {
single_user: [
"{{ default [user]._idx '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "single user",
single_user: { _id: globalUsers[0]._id },
},
])
})
it("should resolve 'default' helper to '_empty_' when binding resolves to nothing (deprecated single user)", async () => {
await expectQuery({
oneOf: {
deprecated_single_user: [
"{{ default [user]._idx '_empty_' }}",
globalUsers[0]._id,
],
},
}).toContainExactly([
{
name: "deprecated single user",
deprecated_single_user: [{ _id: globalUsers[0]._id }],
},
])
})
})
describe.each([FieldType.STRING, FieldType.LONGFORM])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ await createTable({
name: { name: "name", type: FieldType.STRING }, name: { name: "name", type: FieldType.STRING },
@ -252,6 +681,31 @@ describe.each([
}).toFindNothing()) }).toFindNothing())
}) })
describe("empty", () => {
it("finds no empty rows", () =>
expectQuery({ empty: { name: null } }).toFindNothing())
it("should not be affected by when filter empty behaviour", () =>
expectQuery({
empty: { name: null },
onEmptyFilter: EmptyFilterOption.RETURN_ALL,
}).toFindNothing())
})
describe("notEmpty", () => {
it("finds all non-empty rows", () =>
expectQuery({ notEmpty: { name: null } }).toContainExactly([
{ name: "foo" },
{ name: "bar" },
]))
it("should not be affected by when filter empty behaviour", () =>
expectQuery({
notEmpty: { name: null },
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}).toContainExactly([{ name: "foo" }, { name: "bar" }]))
})
describe("sort", () => { describe("sort", () => {
it("sorts ascending", () => it("sorts ascending", () =>
expectSearch({ expectSearch({
@ -508,7 +962,7 @@ describe.each([
}) })
}) })
describe("array of strings", () => { describe.each([FieldType.ARRAY, FieldType.OPTIONS])("%s", () => {
beforeAll(async () => { beforeAll(async () => {
await createTable({ await createTable({
numbers: { numbers: {

View file

@ -1,3 +1,13 @@
// Directly mock the AWS SDK
jest.mock("aws-sdk", () => ({
S3: jest.fn(() => ({
getSignedUrl: jest.fn(
(operation, params) => `http://example.com/${params.Bucket}/${params.Key}`
),
upload: jest.fn(() => ({ Contents: {} })),
})),
}))
const setup = require("./utilities") const setup = require("./utilities")
const { constants } = require("@budibase/backend-core") const { constants } = require("@budibase/backend-core")

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