merge master

This commit is contained in:
Gerard Burns 2024-05-14 13:38:28 +01:00
commit 693e3373a6
246 changed files with 7064 additions and 3905 deletions

View File

@ -1,4 +1,5 @@
{ {
"root": true,
"env": { "env": {
"browser": true, "browser": true,
"es6": true, "es6": true,
@ -53,7 +54,8 @@
"ignoreRestSiblings": true "ignoreRestSiblings": true
} }
], ],
"local-rules/no-budibase-imports": "error" "no-redeclare": "off",
"@typescript-eslint/no-redeclare": "error"
} }
}, },
{ {

View File

@ -92,8 +92,6 @@ jobs:
test-libraries: test-libraries:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
REUSE_CONTAINERS: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -150,8 +148,6 @@ jobs:
test-server: test-server:
runs-on: budi-tubby-tornado-quad-core-150gb runs-on: budi-tubby-tornado-quad-core-150gb
env:
REUSE_CONTAINERS: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -174,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

@ -12,4 +12,5 @@ packages/pro/coverage
packages/account-portal/packages/ui/build packages/account-portal/packages/ui/build
packages/account-portal/packages/ui/.routify packages/account-portal/packages/ui/.routify
packages/account-portal/packages/server/build packages/account-portal/packages/server/build
packages/account-portal/packages/server/coverage
**/*.ivm.bundle.js **/*.ivm.bundle.js

View File

@ -106,6 +106,8 @@ spec:
value: {{ .Values.services.objectStore.globalBucketName | quote }} value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: BACKUPS_BUCKET_NAME - name: BACKUPS_BUCKET_NAME
value: {{ .Values.services.objectStore.backupsBucketName | quote }} value: {{ .Values.services.objectStore.backupsBucketName | quote }}
- name: TEMP_BUCKET_NAME
value: {{ .Values.globals.tempBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.apps.port | quote }} value: {{ .Values.services.apps.port | quote }}
{{ if .Values.services.worker.publicApiRateLimitPerSecond }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }}

View File

@ -107,6 +107,8 @@ spec:
value: {{ .Values.services.objectStore.globalBucketName | quote }} value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: BACKUPS_BUCKET_NAME - name: BACKUPS_BUCKET_NAME
value: {{ .Values.services.objectStore.backupsBucketName | quote }} value: {{ .Values.services.objectStore.backupsBucketName | quote }}
- name: TEMP_BUCKET_NAME
value: {{ .Values.globals.tempBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.automationWorkers.port | quote }} value: {{ .Values.services.automationWorkers.port | quote }}
{{ if .Values.services.worker.publicApiRateLimitPerSecond }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }}

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

@ -106,6 +106,8 @@ spec:
value: {{ .Values.services.objectStore.globalBucketName | quote }} value: {{ .Values.services.objectStore.globalBucketName | quote }}
- name: BACKUPS_BUCKET_NAME - name: BACKUPS_BUCKET_NAME
value: {{ .Values.services.objectStore.backupsBucketName | quote }} value: {{ .Values.services.objectStore.backupsBucketName | quote }}
- name: TEMP_BUCKET_NAME
value: {{ .Values.globals.tempBucketName | quote }}
- name: PORT - name: PORT
value: {{ .Values.services.worker.port | quote }} value: {{ .Values.services.worker.port | quote }}
- name: MULTI_TENANCY - name: MULTI_TENANCY

View File

@ -121,6 +121,9 @@ globals:
# to the old value for the duration of the rotation. # to the old value for the duration of the rotation.
jwtSecretFallback: "" jwtSecretFallback: ""
## -- If using S3 the bucket name to be used for storing temporary files
tempBucketName: ""
smtp: smtp:
# -- Whether to enable SMTP or not. # -- Whether to enable SMTP or not.
enabled: false enabled: false

View File

@ -1,19 +1,52 @@
import { GenericContainer, Wait } from "testcontainers" import {
GenericContainer,
Wait,
getContainerRuntimeClient,
} from "testcontainers"
import { ContainerInfo } from "dockerode"
import path from "path" import path from "path"
import lockfile from "proper-lockfile" import lockfile from "proper-lockfile"
async function getBudibaseContainers() {
const client = await getContainerRuntimeClient()
const conatiners = await client.container.list()
return conatiners.filter(
container =>
container.Labels["com.budibase"] === "true" &&
container.Labels["org.testcontainers"] === "true"
)
}
async function killContainers(containers: ContainerInfo[]) {
const client = await getContainerRuntimeClient()
for (const container of containers) {
const c = client.container.getById(container.Id)
await c.kill()
await c.remove()
}
}
export default async function setup() { export default async function setup() {
const lockPath = path.resolve(__dirname, "globalSetup.ts") const lockPath = path.resolve(__dirname, "globalSetup.ts")
if (process.env.REUSE_CONTAINERS) { // If you run multiple tests at the same time, it's possible for the CouchDB
// If you run multiple tests at the same time, it's possible for the CouchDB // shared container to get started multiple times despite having an
// shared container to get started multiple times despite having an // identical reuse hash. To avoid that, we do a filesystem-based lock so
// identical reuse hash. To avoid that, we do a filesystem-based lock so // that only one globalSetup.ts is running at a time.
// that only one globalSetup.ts is running at a time. lockfile.lockSync(lockPath)
lockfile.lockSync(lockPath)
} // Remove any containers that are older than 24 hours. This is to prevent
// containers getting full volumes or accruing any other problems from being
// left up for very long periods of time.
const threshold = new Date(Date.now() - 1000 * 60 * 60 * 24)
const containers = (await getBudibaseContainers()).filter(container => {
const created = new Date(container.Created * 1000)
return created < threshold
})
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",
@ -28,20 +61,29 @@ export default async function setup() {
target: "/opt/couchdb/etc/local.d/test-couchdb.ini", target: "/opt/couchdb/etc/local.d/test-couchdb.ini",
}, },
]) ])
.withLabels({ "com.budibase": "true" })
.withReuse()
.withWaitStrategy( .withWaitStrategy(
Wait.forSuccessfulCommand( Wait.forSuccessfulCommand(
"curl http://budibase:budibase@localhost:5984/_up" "curl http://budibase:budibase@localhost:5984/_up"
).withStartupTimeout(20000) ).withStartupTimeout(20000)
) )
if (process.env.REUSE_CONTAINERS) { const minio = new GenericContainer("minio/minio")
couchdb = couchdb.withReuse() .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 couchdb.start() await Promise.all([couchdb.start(), minio.start()])
} finally { } finally {
if (process.env.REUSE_CONTAINERS) { 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

View File

@ -19,9 +19,6 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh packages/server/package.json RUN ./scripts/removeWorkspaceDependencies.sh packages/server/package.json
RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
# We will never want to sync pro, but the script is still required
RUN echo '' > scripts/syncProPackage.js
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile

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.23.10", "version": "2.26.2",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -32,7 +32,6 @@
"yargs": "^17.7.2" "yargs": "^17.7.2"
}, },
"scripts": { "scripts": {
"preinstall": "node scripts/syncProPackage.js",
"get-past-client-version": "node scripts/getPastClientVersion.js", "get-past-client-version": "node scripts/getPastClientVersion.js",
"setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev", "setup": "git config submodule.recurse true && git submodule update && node ./hosting/scripts/setup.js && yarn && yarn build && yarn dev",
"build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream", "build": "NODE_OPTIONS=--max-old-space-size=1500 lerna run build --stream",
@ -60,7 +59,8 @@
"dev:all": "yarn run kill-all && lerna run --stream dev", "dev:all": "yarn run kill-all && lerna run --stream dev",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built",
"dev:docker": "./scripts/devDocker.sh", "dev:docker": "./scripts/devDocker.sh",
"test": "REUSE_CONTAINERS=1 lerna run --concurrency 1 --stream test --stream", "test": "lerna run --concurrency 1 --stream test --stream",
"test:containers:kill": "./scripts/killTestcontainers.sh",
"lint:eslint": "eslint packages --max-warnings=0", "lint:eslint": "eslint packages --max-warnings=0",
"lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", "lint:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"",
"lint": "yarn run lint:eslint && yarn run lint:prettier", "lint": "yarn run lint:eslint && yarn run lint:prettier",
@ -107,6 +107,7 @@
"@budibase/shared-core": "0.0.0", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@budibase/pro": "npm:@budibase/pro@latest",
"tough-cookie": "4.1.3", "tough-cookie": "4.1.3",
"node-fetch": "2.6.7", "node-fetch": "2.6.7",
"semver": "7.5.3", "semver": "7.5.3",

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

@ -29,6 +29,7 @@ const DefaultBucketName = {
TEMPLATES: "templates", TEMPLATES: "templates",
GLOBAL: "global", GLOBAL: "global",
PLUGINS: "plugins", PLUGINS: "plugins",
TEMP: "tmp-file-attachments",
} }
const selfHosted = !!parseInt(process.env.SELF_HOSTED || "") const selfHosted = !!parseInt(process.env.SELF_HOSTED || "")
@ -146,6 +147,7 @@ const environment = {
process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL, process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL,
PLUGIN_BUCKET_NAME: PLUGIN_BUCKET_NAME:
process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS,
TEMP_BUCKET_NAME: process.env.TEMP_BUCKET_NAME || DefaultBucketName.TEMP,
USE_COUCH: process.env.USE_COUCH || true, USE_COUCH: process.env.USE_COUCH || true,
MOCK_REDIS: process.env.MOCK_REDIS, MOCK_REDIS: process.env.MOCK_REDIS,
DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE,

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

@ -7,31 +7,47 @@ import tar from "tar-fs"
import zlib from "zlib" import zlib from "zlib"
import { promisify } from "util" import { promisify } from "util"
import { join } from "path" import { join } from "path"
import fs, { ReadStream } from "fs" import fs, { PathLike, ReadStream } from "fs"
import env from "../environment" import env from "../environment"
import { budibaseTempDir } from "./utils" 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 { 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: {},
} }
export const SIGNED_FILE_PREFIX = "/files/signed"
type ListParams = { type ListParams = {
ContinuationToken?: string ContinuationToken?: string
} }
type UploadParams = { type BaseUploadParams = {
bucket: string bucket: string
filename: string filename: string
path: string
type?: string | null type?: string | null
// can be undefined, we will remove it metadata?: { [key: string]: string | undefined }
metadata?: { body?: ReadableStream | Buffer
[key: string]: string | undefined ttl?: number
} addTTL?: boolean
extra?: any
}
type UploadParams = BaseUploadParams & {
path?: string | PathLike
}
export type StreamTypes =
| ReadStream
| NodeJS.ReadableStream
| ReadableStream<Uint8Array>
export type StreamUploadParams = BaseUploadParams & {
stream?: StreamTypes
} }
const CONTENT_TYPE_MAP: any = { const CONTENT_TYPE_MAP: any = {
@ -41,6 +57,8 @@ const CONTENT_TYPE_MAP: any = {
js: "application/javascript", js: "application/javascript",
json: "application/json", json: "application/json",
gz: "application/gzip", gz: "application/gzip",
svg: "image/svg+xml",
form: "multipart/form-data",
} }
const STRING_CONTENT_TYPES = [ const STRING_CONTENT_TYPES = [
@ -71,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",
@ -105,7 +123,10 @@ export function ObjectStore(
* Given an object store and a bucket name this will make sure the bucket exists, * Given an object store and a bucket name this will make sure the bucket exists,
* if it does not exist then it will create it. * if it does not exist then it will create it.
*/ */
export async function makeSureBucketExists(client: any, bucketName: string) { export async function createBucketIfNotExists(
client: any,
bucketName: string
): Promise<{ created: boolean; exists: boolean }> {
bucketName = sanitizeBucket(bucketName) bucketName = sanitizeBucket(bucketName)
try { try {
await client await client
@ -113,15 +134,16 @@ export async function makeSureBucketExists(client: any, bucketName: string) {
Bucket: bucketName, Bucket: bucketName,
}) })
.promise() .promise()
return { created: false, exists: true }
} catch (err: any) { } catch (err: any) {
const promises: any = STATE.bucketCreationPromises const promises: any = STATE.bucketCreationPromises
const doesntExist = err.statusCode === 404, const doesntExist = err.statusCode === 404,
noAccess = err.statusCode === 403 noAccess = err.statusCode === 403
if (promises[bucketName]) { if (promises[bucketName]) {
await promises[bucketName] await promises[bucketName]
return { created: false, exists: true }
} else if (doesntExist || noAccess) { } else if (doesntExist || noAccess) {
if (doesntExist) { if (doesntExist) {
// bucket doesn't exist create it
promises[bucketName] = client promises[bucketName] = client
.createBucket({ .createBucket({
Bucket: bucketName, Bucket: bucketName,
@ -129,13 +151,15 @@ export async function makeSureBucketExists(client: any, bucketName: string) {
.promise() .promise()
await promises[bucketName] await promises[bucketName]
delete promises[bucketName] delete promises[bucketName]
return { created: true, exists: false }
} else {
throw new Error("Access denied to object store bucket." + err)
} }
} else { } else {
throw new Error("Unable to write to object store bucket.") throw new Error("Unable to write to object store bucket.")
} }
} }
} }
/** /**
* Uploads the contents of a file given the required parameters, useful when * Uploads the contents of a file given the required parameters, useful when
* temp files in use (for example file uploaded as an attachment). * temp files in use (for example file uploaded as an attachment).
@ -146,12 +170,20 @@ export async function upload({
path, path,
type, type,
metadata, metadata,
body,
ttl,
}: UploadParams) { }: UploadParams) {
const extension = filename.split(".").pop() const extension = filename.split(".").pop()
const fileBytes = fs.readFileSync(path)
const fileBytes = path ? (await fsp.open(path)).createReadStream() : body
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
let contentType = type let contentType = type
if (!contentType) { if (!contentType) {
@ -174,6 +206,7 @@ export async function upload({
} }
config.Metadata = metadata config.Metadata = metadata
} }
return objectStore.upload(config).promise() return objectStore.upload(config).promise()
} }
@ -181,14 +214,22 @@ export async function upload({
* Similar to the upload function but can be used to send a file stream * Similar to the upload function but can be used to send a file stream
* through to the object store. * through to the object store.
*/ */
export async function streamUpload( export async function streamUpload({
bucketName: string, bucket: bucketName,
filename: string, stream,
stream: ReadStream | ReadableStream, filename,
extra = {} type,
) { extra,
ttl,
}: StreamUploadParams) {
const extension = filename.split(".").pop()
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) const bucketCreated = await createBucketIfNotExists(objectStore, bucketName)
if (ttl && bucketCreated.created) {
let ttlConfig = bucketTTLConfig(bucketName, ttl)
await objectStore.putBucketLifecycleConfiguration(ttlConfig).promise()
}
// Set content type for certain known extensions // Set content type for certain known extensions
if (filename?.endsWith(".js")) { if (filename?.endsWith(".js")) {
@ -203,10 +244,18 @@ export async function streamUpload(
} }
} }
let contentType = type
if (!contentType) {
contentType = extension
? CONTENT_TYPE_MAP[extension.toLowerCase()]
: CONTENT_TYPE_MAP.txt
}
const params = { const params = {
Bucket: sanitizeBucket(bucketName), Bucket: sanitizeBucket(bucketName),
Key: sanitizeKey(filename), Key: sanitizeKey(filename),
Body: stream, Body: stream,
ContentType: contentType,
...extra, ...extra,
} }
return objectStore.upload(params).promise() return objectStore.upload(params).promise()
@ -286,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 `/files/signed${path}${query}` return `${SIGNED_FILE_PREFIX}${path}${query}`
} }
} }
@ -341,7 +390,7 @@ export async function retrieveDirectory(bucketName: string, path: string) {
*/ */
export async function deleteFile(bucketName: string, filepath: string) { export async function deleteFile(bucketName: string, filepath: string) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await createBucketIfNotExists(objectStore, bucketName)
const params = { const params = {
Bucket: bucketName, Bucket: bucketName,
Key: sanitizeKey(filepath), Key: sanitizeKey(filepath),
@ -351,7 +400,7 @@ export async function deleteFile(bucketName: string, filepath: string) {
export async function deleteFiles(bucketName: string, filepaths: string[]) { export async function deleteFiles(bucketName: string, filepaths: string[]) {
const objectStore = ObjectStore(bucketName) const objectStore = ObjectStore(bucketName)
await makeSureBucketExists(objectStore, bucketName) await createBucketIfNotExists(objectStore, bucketName)
const params = { const params = {
Bucket: bucketName, Bucket: bucketName,
Delete: { Delete: {
@ -412,7 +461,13 @@ export async function uploadDirectory(
if (file.isDirectory()) { if (file.isDirectory()) {
uploads.push(uploadDirectory(bucketName, local, path)) uploads.push(uploadDirectory(bucketName, local, path))
} else { } else {
uploads.push(streamUpload(bucketName, path, fs.createReadStream(local))) uploads.push(
streamUpload({
bucket: bucketName,
filename: path,
stream: fs.createReadStream(local),
})
)
} }
} }
await Promise.all(uploads) await Promise.all(uploads)
@ -467,3 +522,45 @@ 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
the bucket and the path from it
*/
export function extractBucketAndPath(
url: string
): { bucket: string; path: string } | null {
const baseUrl = url.split("?")[0]
const regex = new RegExp(
`^${SIGNED_FILE_PREFIX}/(?<bucket>[^/]+)/(?<path>.+)$`
)
const match = baseUrl.match(regex)
if (match && match.groups) {
const { bucket, path } = match.groups
return { bucket, path }
}
return null
}

View File

@ -1,7 +1,16 @@
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 * as objectStore from "./objectStore"
import {
AutomationAttachment,
AutomationAttachmentContent,
BucketedContent,
} from "@budibase/types"
import stream from "stream"
import streamWeb from "node:stream/web"
/**************************************************** /****************************************************
* NOTE: When adding a new bucket - name * * NOTE: When adding a new bucket - name *
@ -15,6 +24,7 @@ export const ObjectStoreBuckets = {
TEMPLATES: env.TEMPLATES_BUCKET_NAME, TEMPLATES: env.TEMPLATES_BUCKET_NAME,
GLOBAL: env.GLOBAL_BUCKET_NAME, GLOBAL: env.GLOBAL_BUCKET_NAME,
PLUGINS: env.PLUGIN_BUCKET_NAME, PLUGINS: env.PLUGIN_BUCKET_NAME,
TEMP: env.TEMP_BUCKET_NAME,
} }
const bbTmp = join(tmpdir(), ".budibase") const bbTmp = join(tmpdir(), ".budibase")
@ -29,3 +39,75 @@ try {
export function budibaseTempDir() { export function budibaseTempDir() {
return bbTmp return bbTmp
} }
export const bucketTTLConfig = (
bucketName: string,
days: number
): PutBucketLifecycleConfigurationRequest => {
const lifecycleRule = {
ID: `${bucketName}-ExpireAfter${days}days`,
Prefix: "",
Status: "Enabled",
Expiration: {
Days: days,
},
}
const lifecycleConfiguration = {
Rules: [lifecycleRule],
}
return {
Bucket: bucketName,
LifecycleConfiguration: lifecycleConfiguration,
}
}
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)
if (!response.body) {
throw new Error("No response received for attachment")
}
return {
filename: attachment.filename || fallbackFilename,
content: stream.Readable.fromWeb(response.body as streamWeb.ReadableStream),
}
}
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

@ -28,7 +28,11 @@ function getTestcontainers(): ContainerInfo[] {
.split("\n") .split("\n")
.filter(x => x.length > 0) .filter(x => x.length > 0)
.map(x => JSON.parse(x) as ContainerInfo) .map(x => JSON.parse(x) as ContainerInfo)
.filter(x => x.Labels.includes("org.testcontainers=true")) .filter(
x =>
x.Labels.includes("org.testcontainers=true") &&
x.Labels.includes("com.budibase=true")
)
} }
export function getContainerByImage(image: string) { export function getContainerByImage(image: string) {
@ -82,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

@ -43,6 +43,7 @@
"@spectrum-css/avatar": "3.0.2", "@spectrum-css/avatar": "3.0.2",
"@spectrum-css/button": "3.0.1", "@spectrum-css/button": "3.0.1",
"@spectrum-css/buttongroup": "3.0.2", "@spectrum-css/buttongroup": "3.0.2",
"@spectrum-css/calendar": "3.2.7",
"@spectrum-css/checkbox": "3.0.2", "@spectrum-css/checkbox": "3.0.2",
"@spectrum-css/dialog": "3.0.1", "@spectrum-css/dialog": "3.0.1",
"@spectrum-css/divider": "1.0.3", "@spectrum-css/divider": "1.0.3",
@ -82,7 +83,6 @@
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"easymde": "^2.16.1", "easymde": "^2.16.1",
"svelte-dnd-action": "^0.9.8", "svelte-dnd-action": "^0.9.8",
"svelte-flatpickr": "3.2.3",
"svelte-portal": "^1.0.0" "svelte-portal": "^1.0.0"
}, },
"resolutions": { "resolutions": {

View File

@ -38,7 +38,15 @@
<div use:getAnchor on:click={openMenu}> <div use:getAnchor on:click={openMenu}>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover bind:this={dropdown} {anchor} {align} {portalTarget} on:open on:close> <Popover
bind:this={dropdown}
{anchor}
{align}
{portalTarget}
resizable={false}
on:open
on:close
>
<Menu> <Menu>
<slot /> <slot />
</Menu> </Menu>

View File

@ -1,13 +1,24 @@
// These class names will never trigger a callback if clicked, no matter what
const ignoredClasses = [ const ignoredClasses = [
".flatpickr-calendar",
".spectrum-Popover",
".download-js-link", ".download-js-link",
".spectrum-Menu",
".date-time-popover",
]
// These class names will only trigger a callback when clicked if the registered
// component is not nested inside them. For example, clicking inside a modal
// will not close the modal, or clicking inside a popover will not close the
// popover.
const conditionallyIgnoredClasses = [
".spectrum-Underlay",
".drawer-wrapper",
".spectrum-Popover",
] ]
let clickHandlers = [] let clickHandlers = []
let candidateTarget
/** // Processes a "click outside" event and invokes callbacks if our source element
* Handle a body click event // is valid
*/
const handleClick = event => { const handleClick = event => {
// Ignore click if this is an ignored class // Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) { if (event.target.closest('[data-ignore-click-outside="true"]')) {
@ -21,41 +32,60 @@ const handleClick = event => {
// Process handlers // Process handlers
clickHandlers.forEach(handler => { clickHandlers.forEach(handler => {
// Check that the click isn't inside the target
if (handler.element.contains(event.target)) { if (handler.element.contains(event.target)) {
return return
} }
// Ignore clicks for modals, unless the handler is registered from a modal // Ignore clicks for certain classes unless we're nested inside them
const sourceInModal = handler.anchor.closest(".spectrum-Underlay") != null for (let className of conditionallyIgnoredClasses) {
const clickInModal = event.target.closest(".spectrum-Underlay") != null const sourceInside = handler.anchor.closest(className) != null
if (clickInModal && !sourceInModal) { const clickInside = event.target.closest(className) != null
return if (clickInside && !sourceInside) {
} return
}
// Ignore clicks for drawers, unless the handler is registered from a drawer
const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
const clickInDrawer = event.target.closest(".drawer-wrapper") != null
if (clickInDrawer && !sourceInDrawer) {
return
}
if (handler.allowedType && event.type !== handler.allowedType) {
return
} }
handler.callback?.(event) handler.callback?.(event)
}) })
} }
document.documentElement.addEventListener("click", handleClick, true)
document.documentElement.addEventListener("mousedown", handleClick, true) // On mouse up we only trigger a "click outside" callback if we targetted the
// same element that we did on mouse down. This fixes all sorts of issues where
// we get annoying callbacks firing when we drag to select text.
const handleMouseUp = e => {
if (candidateTarget === e.target) {
handleClick(e)
}
candidateTarget = null
}
// On mouse down we store which element was targetted for comparison later
const handleMouseDown = e => {
// Only handle the primary mouse button here.
// We handle context menu (right click) events in another handler.
if (e.button !== 0) {
return
}
candidateTarget = e.target
// Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener
document.removeEventListener("mouseup", handleMouseUp)
document.addEventListener("mouseup", handleMouseUp, true)
}
// Global singleton listeners for our events
document.addEventListener("mousedown", handleMouseDown)
document.addEventListener("contextmenu", handleClick)
/** /**
* Adds or updates a click handler * Adds or updates a click handler
*/ */
const updateHandler = (id, element, anchor, callback, allowedType) => { const updateHandler = (id, element, anchor, callback) => {
let existingHandler = clickHandlers.find(x => x.id === id) let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) { if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback, allowedType }) clickHandlers.push({ id, element, anchor, callback })
} else { } else {
existingHandler.callback = callback existingHandler.callback = callback
} }
@ -82,8 +112,7 @@ export default (element, opts) => {
const callback = const callback =
newOpts?.callback || (typeof newOpts === "function" ? newOpts : null) newOpts?.callback || (typeof newOpts === "function" ? newOpts : null)
const anchor = newOpts?.anchor || element const anchor = newOpts?.anchor || element
const allowedType = newOpts?.allowedType || "click" updateHandler(id, element, anchor, callback)
updateHandler(id, element, anchor, callback, allowedType)
} }
update(opts) update(opts)
return { return {

View File

@ -1,3 +1,22 @@
/**
* Valid alignment options are
* - left
* - right
* - left-outside
* - right-outside
**/
// Strategies are defined as [Popover]To[Anchor].
// They can apply for both horizontal and vertical alignment.
const Strategies = {
StartToStart: "StartToStart", // e.g. left alignment
EndToEnd: "EndToEnd", // e.g. right alignment
StartToEnd: "StartToEnd", // e.g. right-outside alignment
EndToStart: "EndToStart", // e.g. left-outside alignment
MidPoint: "MidPoint", // centers relative to midpoints
ScreenEdge: "ScreenEdge", // locks to screen edge
}
export default function positionDropdown(element, opts) { export default function positionDropdown(element, opts) {
let resizeObserver let resizeObserver
let latestOpts = opts let latestOpts = opts
@ -19,6 +38,8 @@ export default function positionDropdown(element, opts) {
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate, customUpdate,
resizable,
wrap,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -27,56 +48,159 @@ export default function positionDropdown(element, opts) {
// Compute bounds // Compute bounds
const anchorBounds = anchor.getBoundingClientRect() const anchorBounds = anchor.getBoundingClientRect()
const elementBounds = element.getBoundingClientRect() const elementBounds = element.getBoundingClientRect()
const winWidth = window.innerWidth
const winHeight = window.innerHeight
const screenOffset = 8
let styles = { let styles = {
maxHeight: null, maxHeight,
minWidth, minWidth: useAnchorWidth ? anchorBounds.width : minWidth,
maxWidth, maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth,
left: null, left: null,
top: null, top: null,
} }
// Ignore all our logic for custom logic
if (typeof customUpdate === "function") { if (typeof customUpdate === "function") {
styles = customUpdate(anchorBounds, elementBounds, { styles = customUpdate(anchorBounds, elementBounds, {
...styles, ...styles,
offset: opts.offset, offset: opts.offset,
}) })
} else { }
// Determine vertical styles
if (align === "right-outside" || align === "left-outside") { // Otherwise position ourselves as normal
styles.top = else {
anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2 // Checks if we overflow off the screen. We only report that we overflow
styles.maxHeight = maxHeight // when the alternative dimension is larger than the one we are checking.
if (styles.top + elementBounds.height > window.innerHeight) { const doesXOverflow = () => {
styles.top = window.innerHeight - elementBounds.height const overflows = styles.left + elementBounds.width > winWidth
} return overflows && anchorBounds.left > winWidth - anchorBounds.right
} else if ( }
window.innerHeight - anchorBounds.bottom < const doesYOverflow = () => {
(maxHeight || 100) const overflows = styles.top + elementBounds.height > winHeight
) { return overflows && anchorBounds.top > winHeight - anchorBounds.bottom
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240
} else {
styles.top = anchorBounds.bottom + offset
styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20
} }
// Determine horizontal styles // Applies a dynamic max height constraint if appropriate
if (!maxWidth && useAnchorWidth) { const applyMaxHeight = height => {
styles.maxWidth = anchorBounds.width if (!styles.maxHeight && resizable) {
styles.maxHeight = height
}
} }
if (useAnchorWidth) {
styles.minWidth = anchorBounds.width // Applies the X strategy to our styles
const applyXStrategy = strategy => {
switch (strategy) {
case Strategies.StartToStart:
default:
styles.left = anchorBounds.left
break
case Strategies.EndToEnd:
styles.left = anchorBounds.right - elementBounds.width
break
case Strategies.StartToEnd:
styles.left = anchorBounds.right + offset
break
case Strategies.EndToStart:
styles.left = anchorBounds.left - elementBounds.width - offset
break
case Strategies.MidPoint:
styles.left =
anchorBounds.left +
anchorBounds.width / 2 -
elementBounds.width / 2
break
case Strategies.ScreenEdge:
styles.left = winWidth - elementBounds.width - screenOffset
break
}
} }
// Applies the Y strategy to our styles
const applyYStrategy = strategy => {
switch (strategy) {
case Strategies.StartToStart:
styles.top = anchorBounds.top
applyMaxHeight(winHeight - anchorBounds.top - screenOffset)
break
case Strategies.EndToEnd:
styles.top = anchorBounds.bottom - elementBounds.height
applyMaxHeight(anchorBounds.bottom - screenOffset)
break
case Strategies.StartToEnd:
default:
styles.top = anchorBounds.bottom + offset
applyMaxHeight(winHeight - anchorBounds.bottom - screenOffset)
break
case Strategies.EndToStart:
styles.top = anchorBounds.top - elementBounds.height - offset
applyMaxHeight(anchorBounds.top - screenOffset)
break
case Strategies.MidPoint:
styles.top =
anchorBounds.top +
anchorBounds.height / 2 -
elementBounds.height / 2
break
case Strategies.ScreenEdge:
styles.top = winHeight - elementBounds.height - screenOffset
applyMaxHeight(winHeight - 2 * screenOffset)
break
}
}
// Determine X strategy
if (align === "right") { if (align === "right") {
styles.left = applyXStrategy(Strategies.EndToEnd)
anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") { } else if (align === "right-outside") {
styles.left = anchorBounds.right + offset applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") { } else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset applyXStrategy(Strategies.EndToStart)
} else { } else {
styles.left = anchorBounds.left applyXStrategy(Strategies.StartToStart)
}
// Determine Y strategy
if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint)
} else {
applyYStrategy(Strategies.StartToEnd)
}
// Handle screen overflow
if (doesXOverflow()) {
// Swap left to right
if (align === "left") {
applyXStrategy(Strategies.EndToEnd)
}
// Swap right-outside to left-outside
else if (align === "right-outside") {
applyXStrategy(Strategies.EndToStart)
}
}
if (doesYOverflow()) {
// If wrapping, lock to the bottom of the screen and also reposition to
// the side to not block the anchor
if (wrap) {
applyYStrategy(Strategies.MidPoint)
if (doesYOverflow()) {
applyYStrategy(Strategies.ScreenEdge)
}
applyXStrategy(Strategies.StartToEnd)
if (doesXOverflow()) {
applyXStrategy(Strategies.EndToStart)
}
}
// Othewise invert as normal
else {
// If using an outside strategy then lock to the bottom of the screen
if (align === "left-outside" || align === "right-outside") {
applyYStrategy(Strategies.ScreenEdge)
}
// Otherwise flip above
else {
applyYStrategy(Strategies.EndToStart)
}
}
} }
} }

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}
/> />

View File

@ -1,268 +0,0 @@
<script>
import Flatpickr from "svelte-flatpickr"
import "flatpickr/dist/flatpickr.css"
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/textfield/dist/index-vars.css"
import "@spectrum-css/picker/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import { uuid } from "../../helpers"
export let id = null
export let disabled = false
export let readonly = false
export let enableTime = true
export let value = null
export let placeholder = null
export let appendTo = undefined
export let timeOnly = false
export let ignoreTimezones = false
export let time24hr = false
export let range = false
export let flatpickr
export let useKeyboardShortcuts = true
const dispatch = createEventDispatcher()
const flatpickrId = `${uuid()}-wrapper`
let open = false
let flatpickrOptions
// Another classic flatpickr issue. Errors were randomly being thrown due to
// flatpickr internal code. Making sure that "destroy" is a valid function
// fixes it. The sooner we remove flatpickr the better.
$: {
if (flatpickr && !flatpickr.destroy) {
flatpickr.destroy = () => {}
}
}
const resolveTimeStamp = timestamp => {
let maskedDate = new Date(`0-${timestamp}`)
if (maskedDate instanceof Date && !isNaN(maskedDate.getTime())) {
return maskedDate
} else {
return null
}
}
$: flatpickrOptions = {
element: `#${flatpickrId}`,
enableTime: timeOnly || enableTime || false,
noCalendar: timeOnly || false,
altInput: true,
time_24hr: time24hr || false,
altFormat: timeOnly ? "H:i" : enableTime ? "F j Y, H:i" : "F j, Y",
wrap: true,
mode: range ? "range" : "single",
appendTo,
disableMobile: "true",
onReady: () => {
let timestamp = resolveTimeStamp(value)
if (timeOnly && timestamp) {
dispatch("change", timestamp.toISOString())
}
},
onOpen: () => dispatch("open"),
onClose: () => dispatch("close"),
}
$: redrawOptions = {
timeOnly,
enableTime,
time24hr,
disabled,
}
const handleChange = event => {
const [dates] = event.detail
const noTimezone = enableTime && !timeOnly && ignoreTimezones
let newValue = dates[0]
if (newValue) {
newValue = newValue.toISOString()
}
// If time only set date component to 2000-01-01
if (timeOnly) {
newValue = `2000-01-01T${newValue.split("T")[1]}`
}
// For date-only fields, construct a manual timestamp string without a time
// or time zone
else if (!enableTime) {
const year = dates[0].getFullYear()
const month = `${dates[0].getMonth() + 1}`.padStart(2, "0")
const day = `${dates[0].getDate()}`.padStart(2, "0")
newValue = `${year}-${month}-${day}T00:00:00.000`
}
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
// time picked, without timezone
else if (noTimezone) {
const offset = dates[0].getTimezoneOffset() * 60000
newValue = new Date(dates[0].getTime() - offset)
.toISOString()
.slice(0, -1)
}
if (range) {
dispatch("change", event.detail)
} else {
dispatch("change", newValue)
}
}
const clearDateOnBackspace = event => {
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
dispatch("change", "")
flatpickr.close()
}
}
const onOpen = () => {
open = true
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
}
const onClose = () => {
open = false
if (useKeyboardShortcuts) {
document.removeEventListener("keyup", clearDateOnBackspace)
}
// Manually blur all input fields since flatpickr creates a second
// duplicate input field.
// We need to blur both because the focus styling does not get properly
// applied.
const els = document.querySelectorAll(`#${flatpickrId} input`)
els.forEach(el => el.blur())
}
const parseDate = val => {
if (!val) {
return null
}
let date
let time
// it is a string like 00:00:00, just time
let ts = resolveTimeStamp(val)
if (timeOnly && ts) {
date = ts
} else if (val instanceof Date) {
// Use real date obj if already parsed
date = val
} else if (isNaN(val)) {
// Treat as date string of some sort
date = new Date(val)
} else {
// Treat as numerical timestamp
date = new Date(parseInt(val))
}
time = date.getTime()
if (isNaN(time)) {
return null
}
// By rounding to the nearest second we avoid locking up in an endless
// loop in the builder, caused by potentially enriching {{ now }} to every
// millisecond.
return new Date(Math.floor(time / 1000) * 1000)
}
</script>
{#key redrawOptions}
<Flatpickr
bind:flatpickr
value={range ? value : parseDate(value)}
on:open={onOpen}
on:close={onClose}
options={flatpickrOptions}
on:change={handleChange}
element={`#${flatpickrId}`}
>
<div
id={flatpickrId}
class:is-disabled={disabled || readonly}
class="flatpickr spectrum-InputGroup spectrum-Datepicker"
class:is-focused={open}
aria-readonly="false"
aria-required="false"
aria-haspopup="true"
>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
on:click={flatpickr?.open}
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={disabled}
>
<input
{disabled}
{readonly}
data-input
type="text"
class="spectrum-Textfield-input spectrum-InputGroup-input"
class:is-disabled={disabled}
{placeholder}
{id}
{value}
/>
</div>
<button
type="button"
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
tabindex="-1"
class:is-disabled={disabled}
on:click={flatpickr?.open}
>
<svg
class="spectrum-Icon spectrum-Icon--sizeM"
focusable="false"
aria-hidden="true"
aria-label="Calendar"
>
<use xlink:href="#spectrum-icon-18-Calendar" />
</svg>
</button>
</div>
</Flatpickr>
{/key}
{#if open}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="overlay" on:mousedown|self={flatpickr?.close} />
{/if}
<style>
.spectrum-Textfield-input {
pointer-events: none;
}
.spectrum-Textfield:not(.is-disabled):hover {
cursor: pointer;
}
.flatpickr {
width: 100%;
overflow: hidden;
}
.flatpickr .spectrum-Textfield {
width: 100%;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
max-height: 100%;
}
:global(.flatpickr-calendar) {
font-family: var(--font-sans);
}
.is-disabled {
pointer-events: none !important;
}
</style>

View File

@ -0,0 +1,252 @@
<script>
import { cleanInput } from "./utils"
import Select from "../../Select.svelte"
import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
import isoWeek from "dayjs/plugin/isoWeek"
dayjs.extend(isoWeek)
export let value
const dispatch = createEventDispatcher()
const DaysOfWeek = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
const MonthsOfYear = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
]
const now = dayjs()
let calendarDate
$: calendarDate = dayjs(value || dayjs()).startOf("month")
$: mondays = getMondays(calendarDate)
const getMondays = monthStart => {
if (!monthStart?.isValid()) {
return []
}
let monthEnd = monthStart.endOf("month")
let calendarStart = monthStart.startOf("isoWeek")
const numWeeks = Math.ceil((monthEnd.diff(calendarStart, "day") + 1) / 7)
let mondays = []
for (let i = 0; i < numWeeks; i++) {
mondays.push(calendarStart.add(i, "weeks"))
}
return mondays
}
const handleCalendarYearChange = e => {
calendarDate = calendarDate.year(parseInt(e.target.value))
}
const handleDateChange = date => {
const base = value || now
dispatch(
"change",
base.year(date.year()).month(date.month()).date(date.date())
)
}
export const setDate = date => {
calendarDate = date
}
const cleanYear = cleanInput({ max: 9999, pad: 0, fallback: now.year() })
</script>
<div class="spectrum-Calendar">
<div class="spectrum-Calendar-header">
<div
class="spectrum-Calendar-title"
aria-live="assertive"
aria-atomic="true"
>
<div class="month-selector">
<Select
autoWidth
placeholder={null}
options={MonthsOfYear.map((m, idx) => ({ label: m, value: idx }))}
value={calendarDate.month()}
on:change={e => (calendarDate = calendarDate.month(e.detail))}
/>
</div>
<NumberInput
value={calendarDate.year()}
min={0}
max={9999}
width={64}
on:change={handleCalendarYearChange}
on:input={cleanYear}
/>
</div>
<button
aria-label="Previous"
title="Previous"
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-prevMonth"
on:click={() => (calendarDate = calendarDate.subtract(1, "month"))}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronLeft100"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
<button
aria-label="Next"
title="Next"
class="spectrum-ActionButton spectrum-ActionButton--quiet spectrum-Calendar-nextMonth"
on:click={() => (calendarDate = calendarDate.add(1, "month"))}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronRight100"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
</div>
<div
class="spectrum-Calendar-body"
aria-readonly="true"
aria-disabled="false"
>
<table role="presentation" class="spectrum-Calendar-table">
<thead role="presentation">
<tr>
{#each DaysOfWeek as day}
<th scope="col" class="spectrum-Calendar-tableCell">
<abbr class="spectrum-Calendar-dayOfWeek" title={day}>
{day[0]}
</abbr>
</th>
{/each}
</tr>
</thead>
<tbody role="presentation">
{#each mondays as monday}
<tr>
{#each [0, 1, 2, 3, 4, 5, 6] as dayOffset}
{@const date = monday.add(dayOffset, "days")}
{@const outsideMonth = date.month() !== calendarDate.month()}
<td
class="spectrum-Calendar-tableCell"
aria-disabled="true"
aria-selected="false"
aria-invalid="false"
title={date.format("dddd, MMMM D, YYYY")}
on:click={() => handleDateChange(date)}
>
<span
role="presentation"
class="spectrum-Calendar-date"
class:is-outsideMonth={outsideMonth}
class:is-today={!outsideMonth && date.isSame(now, "day")}
class:is-selected={date.isSame(value, "day")}
>
{date.date()}
</span>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
/* Calendar overrides */
.spectrum-Calendar {
width: auto;
}
.spectrum-Calendar-header {
width: auto;
}
.spectrum-Calendar-title {
display: flex;
justify-content: flex-start;
align-items: stretch;
flex: 1 1 auto;
}
.spectrum-Calendar-header button {
border-radius: 4px;
}
.spectrum-Calendar-date.is-outsideMonth {
visibility: visible;
color: var(--spectrum-global-color-gray-400);
}
.spectrum-Calendar-date.is-today,
.spectrum-Calendar-date.is-today::before {
border-color: var(--spectrum-global-color-gray-400);
}
.spectrum-Calendar-date.is-today.is-selected,
.spectrum-Calendar-date.is-today.is-selected::before {
border-color: var(
--primaryColorHover,
var(--spectrum-global-color-blue-700)
);
}
.spectrum-Calendar-date.is-selected:not(.is-range-selection) {
background: var(--primaryColor, var(--spectrum-global-color-blue-400));
}
.spectrum-Calendar tr {
box-sizing: content-box;
height: 40px;
}
.spectrum-Calendar-tableCell {
box-sizing: content-box;
}
.spectrum-Calendar-nextMonth,
.spectrum-Calendar-prevMonth {
order: 1;
padding: 4px;
}
.spectrum-Calendar-date {
color: var(--spectrum-alias-text-color);
}
.spectrum-Calendar-date.is-selected {
color: white;
}
.spectrum-Calendar-dayOfWeek {
color: var(--spectrum-global-color-gray-600);
}
/* Style select */
.month-selector :global(.spectrum-Picker) {
background: none;
border: none;
padding: 4px 6px;
}
.month-selector :global(.spectrum-Picker:hover),
.month-selector :global(.spectrum-Picker.is-open) {
background: var(--spectrum-global-color-gray-200);
}
.month-selector :global(.spectrum-Picker-label) {
font-size: 18px;
font-weight: bold;
}
</style>

View File

@ -0,0 +1,94 @@
<script>
import Icon from "../../../Icon/Icon.svelte"
import { getDateDisplayValue } from "../../../helpers"
export let anchor
export let disabled
export let readonly
export let error
export let focused
export let placeholder
export let id
export let value
export let icon
export let enableTime
export let timeOnly
$: displayValue = getDateDisplayValue(value, { enableTime, timeOnly })
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
bind:this={anchor}
class:is-disabled={disabled || readonly}
class:is-invalid={!!error}
class:is-focused={focused}
class="spectrum-InputGroup spectrum-Datepicker"
aria-readonly={readonly}
aria-required="false"
aria-haspopup="true"
on:click
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={disabled}
class:is-invalid={!!error}
>
{#if !!error}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Textfield-validationIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-Alert" />
</svg>
{/if}
<input
{disabled}
{readonly}
data-input
type="text"
class="spectrum-Textfield-input spectrum-InputGroup-input"
class:is-disabled={disabled}
{placeholder}
{id}
value={displayValue}
/>
</div>
{#if !disabled && !readonly}
<button
type="button"
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
tabindex="-1"
class:is-invalid={!!error}
>
<Icon name={icon} />
</button>
{/if}
</div>
<style>
/* Date label overrides */
.spectrum-Textfield-input {
pointer-events: none;
}
.spectrum-Textfield:not(.is-disabled):hover {
cursor: pointer;
}
.spectrum-Datepicker {
width: 100%;
overflow: hidden;
}
.spectrum-Datepicker .spectrum-Textfield {
width: 100%;
}
.is-disabled {
pointer-events: none !important;
}
input:read-only {
border-right-width: 1px;
border-top-right-radius: var(--spectrum-textfield-border-radius);
border-bottom-right-radius: var(--spectrum-textfield-border-radius);
}
</style>

View File

@ -0,0 +1,83 @@
<script>
import "@spectrum-css/calendar/dist/index-vars.css"
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/textfield/dist/index-vars.css"
import Popover from "../../../Popover/Popover.svelte"
import { onMount } from "svelte"
import DateInput from "./DateInput.svelte"
import { parseDate } from "../../../helpers"
import DatePickerPopoverContents from "./DatePickerPopoverContents.svelte"
export let id = null
export let disabled = false
export let readonly = false
export let error = null
export let enableTime = true
export let value = null
export let placeholder = null
export let timeOnly = false
export let ignoreTimezones = false
export let useKeyboardShortcuts = true
export let appendTo = null
export let api = null
export let align = "left"
let isOpen = false
let anchor
let popover
$: parsedValue = parseDate(value, { timeOnly, enableTime })
const onOpen = () => {
isOpen = true
}
const onClose = () => {
isOpen = false
}
onMount(() => {
api = {
open: () => popover?.show(),
close: () => popover?.hide(),
}
})
</script>
<DateInput
bind:anchor
{disabled}
{readonly}
{error}
{placeholder}
{id}
{enableTime}
{timeOnly}
focused={isOpen}
value={parsedValue}
on:click={popover?.show}
icon={timeOnly ? "Clock" : "Calendar"}
/>
<Popover
bind:this={popover}
on:open
on:close
on:open={onOpen}
on:close={onClose}
portalTarget={appendTo}
{anchor}
{align}
resizable={false}
>
{#if isOpen}
<DatePickerPopoverContents
{useKeyboardShortcuts}
{ignoreTimezones}
{enableTime}
{timeOnly}
value={parsedValue}
on:change
/>
{/if}
</Popover>

View File

@ -0,0 +1,102 @@
<script>
import dayjs from "dayjs"
import TimePicker from "./TimePicker.svelte"
import Calendar from "./Calendar.svelte"
import ActionButton from "../../../ActionButton/ActionButton.svelte"
import { createEventDispatcher, onMount } from "svelte"
import { stringifyDate } from "../../../helpers"
export let useKeyboardShortcuts = true
export let ignoreTimezones
export let enableTime
export let timeOnly
export let value
const dispatch = createEventDispatcher()
let calendar
$: showCalendar = !timeOnly
$: showTime = enableTime || timeOnly
const setToNow = () => {
const now = dayjs()
calendar?.setDate(now)
handleChange(now)
}
const handleChange = date => {
dispatch(
"change",
stringifyDate(date, { enableTime, timeOnly, ignoreTimezones })
)
}
const clearDateOnBackspace = event => {
// Ignore if we're typing a value
if (document.activeElement?.tagName.toLowerCase() === "input") {
return
}
if (["Backspace", "Clear", "Delete"].includes(event.key)) {
dispatch("change", null)
}
}
onMount(() => {
if (useKeyboardShortcuts) {
document.addEventListener("keyup", clearDateOnBackspace)
}
return () => {
document.removeEventListener("keyup", clearDateOnBackspace)
}
})
</script>
<div class="date-time-popover">
{#if showCalendar}
<Calendar
{value}
on:change={e => handleChange(e.detail)}
bind:this={calendar}
/>
{/if}
<div class="footer" class:spaced={showCalendar}>
{#if showTime}
<TimePicker {value} on:change={e => handleChange(e.detail)} />
{/if}
<div class="actions">
<ActionButton
disabled={!value}
size="S"
on:click={() => dispatch("change", null)}
>
Clear
</ActionButton>
<ActionButton size="S" on:click={setToNow}>
{showTime ? "Now" : "Today"}
</ActionButton>
</div>
</div>
</div>
<style>
.date-time-popover {
padding: 8px;
overflow: hidden;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 60px;
}
.footer.spaced {
padding-top: 14px;
}
.actions {
padding: 4px 0;
flex: 1 1 auto;
display: flex;
justify-content: flex-end;
gap: 6px;
}
</style>

View File

@ -0,0 +1,54 @@
<script>
export let value
export let min
export let max
export let hideArrows = false
export let width
$: style = width ? `width:${width}px;` : ""
</script>
<input
class:hide-arrows={hideArrows}
type="number"
{style}
{value}
{min}
{max}
onclick="this.select()"
on:change
on:input
/>
<style>
input {
background: none;
border: none;
outline: none;
color: var(--spectrum-alias-text-color);
padding: 4px 6px 5px 6px;
border-radius: 4px;
transition: background 130ms ease-out;
font-size: 18px;
font-weight: bold;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
box-sizing: content-box !important;
}
input:focus,
input:hover {
--space: 30px;
background: var(--spectrum-global-color-gray-200);
z-index: 1;
}
/* Hide built-in arrows */
input.hide-arrows::-webkit-outer-spin-button,
input.hide-arrows::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.hide-arrows {
-moz-appearance: textfield;
}
</style>

View File

@ -0,0 +1,59 @@
<script>
import { cleanInput } from "./utils"
import dayjs from "dayjs"
import NumberInput from "./NumberInput.svelte"
import { createEventDispatcher } from "svelte"
export let value
const dispatch = createEventDispatcher()
$: displayValue = value || dayjs()
const handleHourChange = e => {
dispatch("change", displayValue.hour(parseInt(e.target.value)))
}
const handleMinuteChange = e => {
dispatch("change", displayValue.minute(parseInt(e.target.value)))
}
const cleanHour = cleanInput({ max: 23, pad: 2, fallback: "00" })
const cleanMinute = cleanInput({ max: 59, pad: 2, fallback: "00" })
</script>
<div class="time-picker">
<NumberInput
hideArrows
value={displayValue.hour().toString().padStart(2, "0")}
min={0}
max={23}
width={20}
on:input={cleanHour}
on:change={handleHourChange}
/>
<span>:</span>
<NumberInput
hideArrows
value={displayValue.minute().toString().padStart(2, "0")}
min={0}
max={59}
width={20}
on:input={cleanMinute}
on:change={handleMinuteChange}
/>
</div>
<style>
.time-picker {
display: flex;
flex-direction: row;
align-items: center;
}
.time-picker span {
font-weight: bold;
font-size: 18px;
z-index: 0;
margin-bottom: 1px;
}
</style>

View File

@ -0,0 +1,14 @@
export const cleanInput = ({ max, pad, fallback }) => {
return e => {
if (e.target.value) {
const value = parseInt(e.target.value)
if (isNaN(value)) {
e.target.value = fallback
} else {
e.target.value = Math.min(max, value).toString().padStart(pad, "0")
}
} else {
e.target.value = fallback
}
}
}

View File

@ -0,0 +1,69 @@
<script>
import CoreDatePicker from "./DatePicker/DatePicker.svelte"
import Icon from "../../Icon/Icon.svelte"
export let value = null
export let disabled = false
export let readonly = false
export let error = null
export let appendTo = undefined
export let ignoreTimezones = false
let fromDate
let toDate
</script>
<div class="date-range">
<CoreDatePicker
value={fromDate}
on:change={e => (fromDate = e.detail)}
enableTime={false}
/>
<div class="arrow">
<Icon name="ChevronRight" />
</div>
<CoreDatePicker
value={toDate}
on:change={e => (toDate = e.detail)}
enableTime={false}
/>
</div>
<style>
.date-range {
display: flex;
flex-direction: row;
border: 1px solid var(--spectrum-alias-border-color);
border-radius: 4px;
}
.date-range :global(.spectrum-InputGroup),
.date-range :global(.spectrum-Textfield),
.date-range :global(input) {
min-width: 0 !important;
width: 150px !important;
}
.date-range :global(input) {
border: none;
text-align: center;
}
.date-range :global(button) {
display: none;
}
.date-range :global(> :first-child input),
.date-range :global(> :first-child) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.date-range :global(> :last-child input),
.date-range :global(> :last-child) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.arrow {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
z-index: 1;
}
</style>

View File

@ -157,6 +157,7 @@
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
maxWidth={autoWidth ? 400 : null} maxWidth={autoWidth ? 400 : null}
customHeight={customPopoverHeight} customHeight={customPopoverHeight}
maxHeight={240}
> >
<div <div
class="popover-content" class="popover-content"

View File

@ -8,7 +8,9 @@ export { default as CoreTextArea } from "./TextArea.svelte"
export { default as CoreCombobox } from "./Combobox.svelte" export { default as CoreCombobox } from "./Combobox.svelte"
export { default as CoreSwitch } from "./Switch.svelte" export { default as CoreSwitch } from "./Switch.svelte"
export { default as CoreSearch } from "./Search.svelte" export { default as CoreSearch } from "./Search.svelte"
export { default as CoreDatePicker } from "./DatePicker.svelte" export { default as CoreDatePicker } from "./DatePicker/DatePicker.svelte"
export { default as CoreDatePickerPopoverContents } from "./DatePicker/DatePickerPopoverContents.svelte"
export { default as CoreDateRangePicker } from "./DateRangePicker.svelte"
export { default as CoreDropzone } from "./Dropzone.svelte" export { default as CoreDropzone } from "./Dropzone.svelte"
export { default as CoreStepper } from "./Stepper.svelte" export { default as CoreStepper } from "./Stepper.svelte"
export { default as CoreRichTextField } from "./RichTextField.svelte" export { default as CoreRichTextField } from "./RichTextField.svelte"

View File

@ -1,6 +1,6 @@
<script> <script>
import Field from "./Field.svelte" import Field from "./Field.svelte"
import DatePicker from "./Core/DatePicker.svelte" import DatePicker from "./Core/DatePicker/DatePicker.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = null export let value = null
@ -11,22 +11,15 @@
export let error = null export let error = null
export let enableTime = true export let enableTime = true
export let timeOnly = false export let timeOnly = false
export let time24hr = false
export let placeholder = null export let placeholder = null
export let appendTo = undefined export let appendTo = undefined
export let ignoreTimezones = false export let ignoreTimezones = false
export let range = false
export let helpText = null export let helpText = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
if (range) { value = e.detail
// Flatpickr cant take two dates and work out what to display, needs to be provided a string.
// Like - "Date1 to Date2". Hence passing in that specifically from the array
value = e?.detail[1]
} else {
value = e.detail
}
dispatch("change", e.detail) dispatch("change", e.detail)
} }
</script> </script>
@ -40,10 +33,8 @@
{placeholder} {placeholder}
{enableTime} {enableTime}
{timeOnly} {timeOnly}
{time24hr}
{appendTo} {appendTo}
{ignoreTimezones} {ignoreTimezones}
{range}
on:change={onChange} on:change={onChange}
/> />
</Field> </Field>

View File

@ -0,0 +1,34 @@
<script>
import Field from "./Field.svelte"
import DateRangePicker from "./Core/DateRangePicker.svelte"
import { createEventDispatcher } from "svelte"
export let value = null
export let label = null
export let labelPosition = "above"
export let disabled = false
export let readonly = false
export let error = null
export let helpText = null
export let appendTo = undefined
export let ignoreTimezones = false
const dispatch = createEventDispatcher()
const onChange = e => {
value = e.detail
dispatch("change", e.detail)
}
</script>
<Field {helpText} {label} {labelPosition} {error}>
<DateRangePicker
{error}
{disabled}
{readonly}
{value}
{appendTo}
{ignoreTimezones}
on:change={onChange}
/>
</Field>

View File

@ -7,11 +7,11 @@
export let narrower = false export let narrower = false
export let noPadding = false export let noPadding = false
let sidePanelVisble = false let sidePanelVisible = false
setContext("side-panel", { setContext("side-panel", {
open: () => (sidePanelVisble = true), open: () => (sidePanelVisible = true),
close: () => (sidePanelVisble = false), close: () => (sidePanelVisible = false),
}) })
</script> </script>
@ -24,9 +24,9 @@
</div> </div>
<div <div
id="side-panel" id="side-panel"
class:visible={sidePanelVisble} class:visible={sidePanelVisible}
use:clickOutside={() => { use:clickOutside={() => {
sidePanelVisble = false sidePanelVisible = false
}} }}
> >
<slot name="side-panel" /> <slot name="side-panel" />

View File

@ -18,13 +18,15 @@
export let open = false export let open = false
export let useAnchorWidth = false export let useAnchorWidth = false
export let dismissible = true export let dismissible = true
export let offset = 5 export let offset = 4
export let customHeight export let customHeight
export let animate = true export let animate = true
export let customZindex export let customZindex
export let handlePostionUpdate export let handlePostionUpdate
export let showPopover = true export let showPopover = true
export let clickOutsideOverride = false export let clickOutsideOverride = false
export let resizable = true
export let wrap = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -91,6 +93,8 @@
useAnchorWidth, useAnchorWidth,
offset, offset,
customUpdate: handlePostionUpdate, customUpdate: handlePostionUpdate,
resizable,
wrap,
}} }}
use:clickOutside={{ use:clickOutside={{
callback: dismissible ? handleOutsideClick : () => {}, callback: dismissible ? handleOutsideClick : () => {},
@ -116,12 +120,11 @@
min-width: var(--spectrum-global-dimension-size-2000); min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
overflow: auto; overflow: auto;
transition: opacity 260ms ease-out, transform 260ms ease-out; transition: opacity 260ms ease-out;
} }
.hidden { .hidden {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
transform: translateY(-20px);
} }
.customZindex { .customZindex {
z-index: var(--customZindex) !important; z-index: var(--customZindex) !important;

View File

@ -1,4 +1,5 @@
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import dayjs from "dayjs"
export const deepGet = helpers.deepGet export const deepGet = helpers.deepGet
@ -115,3 +116,110 @@ export const copyToClipboard = value => {
} }
}) })
} }
// Parsed a date value. This is usually an ISO string, but can be a
// bunch of different formats and shapes depending on schema flags.
export const parseDate = (value, { enableTime = true }) => {
// If empty then invalid
if (!value) {
return null
}
// Certain string values need transformed
if (typeof value === "string") {
// Check for time only values
if (!isNaN(new Date(`0-${value}`))) {
value = `0-${value}`
}
// If date only, check for cases where we received a UTC string
else if (!enableTime && value.endsWith("Z")) {
value = value.split("Z")[0]
}
}
// Parse value and check for validity
const parsedDate = dayjs(value)
if (!parsedDate.isValid()) {
return null
}
// By rounding to the nearest second we avoid locking up in an endless
// loop in the builder, caused by potentially enriching {{ now }} to every
// millisecond.
return dayjs(Math.floor(parsedDate.valueOf() / 1000) * 1000)
}
// Stringifies a dayjs object to create an ISO string that respects the various
// schema flags
export const stringifyDate = (
value,
{ enableTime = true, timeOnly = false, ignoreTimezones = false } = {}
) => {
if (!value) {
return null
}
// Time only fields always ignore timezones, otherwise they make no sense.
// For non-timezone-aware fields, create an ISO 8601 timestamp of the exact
// time picked, without timezone
const offsetForTimezone = (enableTime && ignoreTimezones) || timeOnly
if (offsetForTimezone) {
// Ensure we use the correct offset for the date
const referenceDate = timeOnly ? new Date() : value.toDate()
const offset = referenceDate.getTimezoneOffset() * 60000
return new Date(value.valueOf() - offset).toISOString().slice(0, -1)
}
// For date-only fields, construct a manual timestamp string without a time
// or time zone
else if (!enableTime) {
const year = value.year()
const month = `${value.month() + 1}`.padStart(2, "0")
const day = `${value.date()}`.padStart(2, "0")
return `${year}-${month}-${day}T00:00:00.000`
}
// Otherwise use a normal ISO string with time and timezone
else {
return value.toISOString()
}
}
// Determine the dayjs-compatible format of the browser's default locale
const getPatternForPart = part => {
switch (part.type) {
case "day":
return "D".repeat(part.value.length)
case "month":
return "M".repeat(part.value.length)
case "year":
return "Y".repeat(part.value.length)
case "literal":
return part.value
default:
console.log("Unsupported date part", part)
return ""
}
}
const localeDateFormat = new Intl.DateTimeFormat()
.formatToParts(new Date("2021-01-01"))
.map(getPatternForPart)
.join("")
// Formats a dayjs date according to schema flags
export const getDateDisplayValue = (
value,
{ enableTime = true, timeOnly = false } = {}
) => {
if (!value?.isValid()) {
return ""
}
if (timeOnly) {
return value.format("HH:mm")
} else if (!enableTime) {
return value.format(localeDateFormat)
} else {
return value.format(`${localeDateFormat} HH:mm`)
}
}

View File

@ -3,13 +3,34 @@ import "./bbui.css"
// Spectrum icons // Spectrum icons
import "@spectrum-css/icon/dist/index-vars.css" import "@spectrum-css/icon/dist/index-vars.css"
// Components // Form components
export { default as Input } from "./Form/Input.svelte" export { default as Input } from "./Form/Input.svelte"
export { default as Stepper } from "./Form/Stepper.svelte" export { default as Stepper } from "./Form/Stepper.svelte"
export { default as TextArea } from "./Form/TextArea.svelte" export { default as TextArea } from "./Form/TextArea.svelte"
export { default as Select } from "./Form/Select.svelte" export { default as Select } from "./Form/Select.svelte"
export { default as Combobox } from "./Form/Combobox.svelte" export { default as Combobox } from "./Form/Combobox.svelte"
export { default as Dropzone } from "./Form/Dropzone.svelte" export { default as Dropzone } from "./Form/Dropzone.svelte"
export { default as DatePicker } from "./Form/DatePicker.svelte"
export { default as DateRangePicker } from "./Form/DateRangePicker.svelte"
export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
export { default as Multiselect } from "./Form/Multiselect.svelte"
export { default as Search } from "./Form/Search.svelte"
export { default as RichTextField } from "./Form/RichTextField.svelte"
export { default as Slider } from "./Form/Slider.svelte"
export { default as File } from "./Form/File.svelte"
// Core form components to be used elsewhere (standard components)
export * from "./Form/Core"
// Fancy form components
export * from "./FancyForm"
// Components
export { default as Drawer } from "./Drawer/Drawer.svelte" export { default as Drawer } from "./Drawer/Drawer.svelte"
export { default as DrawerContent } from "./Drawer/DrawerContent.svelte" export { default as DrawerContent } from "./Drawer/DrawerContent.svelte"
export { default as Avatar } from "./Avatar/Avatar.svelte" export { default as Avatar } from "./Avatar/Avatar.svelte"
@ -21,12 +42,6 @@ export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon } from "./Icon/Icon.svelte" export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte" export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as InputDropdown } from "./Form/InputDropdown.svelte"
export { default as PickerDropdown } from "./Form/PickerDropdown.svelte"
export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte" export { default as Popover } from "./Popover/Popover.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte" export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
@ -37,11 +52,6 @@ export { default as Page } from "./Layout/Page.svelte"
export { default as Link } from "./Link/Link.svelte" export { default as Link } from "./Link/Link.svelte"
export { default as Tooltip } from "./Tooltip/Tooltip.svelte" export { default as Tooltip } from "./Tooltip/Tooltip.svelte"
export { default as TempTooltip } from "./Tooltip/TempTooltip.svelte" export { default as TempTooltip } from "./Tooltip/TempTooltip.svelte"
export {
default as AbsTooltip,
TooltipPosition,
TooltipType,
} from "./Tooltip/AbsTooltip.svelte"
export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte" export { default as TooltipWrapper } from "./Tooltip/TooltipWrapper.svelte"
export { default as ContextTooltip } from "./Tooltip/Context.svelte" export { default as ContextTooltip } from "./Tooltip/Context.svelte"
export { default as Menu } from "./Menu/Menu.svelte" export { default as Menu } from "./Menu/Menu.svelte"
@ -54,8 +64,6 @@ export { default as NotificationDisplay } from "./Notification/NotificationDispl
export { default as Notification } from "./Notification/Notification.svelte" export { default as Notification } from "./Notification/Notification.svelte"
export { default as SideNavigation } from "./SideNavigation/Navigation.svelte" export { default as SideNavigation } from "./SideNavigation/Navigation.svelte"
export { default as SideNavigationItem } from "./SideNavigation/Item.svelte" export { default as SideNavigationItem } from "./SideNavigation/Item.svelte"
export { default as DatePicker } from "./Form/DatePicker.svelte"
export { default as Multiselect } from "./Form/Multiselect.svelte"
export { default as Context } from "./context" export { default as Context } from "./context"
export { default as Table } from "./Table/Table.svelte" export { default as Table } from "./Table/Table.svelte"
export { default as Tabs } from "./Tabs/Tabs.svelte" export { default as Tabs } from "./Tabs/Tabs.svelte"
@ -65,7 +73,6 @@ export { default as Tag } from "./Tags/Tag.svelte"
export { default as TreeView } from "./TreeView/Tree.svelte" export { default as TreeView } from "./TreeView/Tree.svelte"
export { default as TreeItem } from "./TreeView/Item.svelte" export { default as TreeItem } from "./TreeView/Item.svelte"
export { default as Divider } from "./Divider/Divider.svelte" export { default as Divider } from "./Divider/Divider.svelte"
export { default as Search } from "./Form/Search.svelte"
export { default as Pagination } from "./Pagination/Pagination.svelte" export { default as Pagination } from "./Pagination/Pagination.svelte"
export { default as Badge } from "./Badge/Badge.svelte" export { default as Badge } from "./Badge/Badge.svelte"
export { default as StatusLight } from "./StatusLight/StatusLight.svelte" export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
@ -77,15 +84,15 @@ export { default as CopyInput } from "./Input/CopyInput.svelte"
export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte" export { default as BannerDisplay } from "./Banner/BannerDisplay.svelte"
export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte" export { default as MarkdownEditor } from "./Markdown/MarkdownEditor.svelte"
export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte" export { default as MarkdownViewer } from "./Markdown/MarkdownViewer.svelte"
export { default as RichTextField } from "./Form/RichTextField.svelte"
export { default as List } from "./List/List.svelte" export { default as List } from "./List/List.svelte"
export { default as ListItem } from "./List/ListItem.svelte" export { default as ListItem } from "./List/ListItem.svelte"
export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte" export { default as IconSideNav } from "./IconSideNav/IconSideNav.svelte"
export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte" export { default as IconSideNavItem } from "./IconSideNav/IconSideNavItem.svelte"
export { default as Slider } from "./Form/Slider.svelte"
export { default as Accordion } from "./Accordion/Accordion.svelte" export { default as Accordion } from "./Accordion/Accordion.svelte"
export { default as File } from "./Form/File.svelte"
export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte" export { default as OptionSelectDnD } from "./OptionSelectDnD/OptionSelectDnD.svelte"
export { default as AbsTooltip } from "./Tooltip/AbsTooltip.svelte"
export { TooltipPosition, TooltipType } from "./Tooltip/AbsTooltip.svelte"
// Renderers // Renderers
export { default as BoldRenderer } from "./Table/BoldRenderer.svelte" export { default as BoldRenderer } from "./Table/BoldRenderer.svelte"
export { default as CodeRenderer } from "./Table/CodeRenderer.svelte" export { default as CodeRenderer } from "./Table/CodeRenderer.svelte"
@ -97,9 +104,6 @@ export { default as Heading } from "./Typography/Heading.svelte"
export { default as Detail } from "./Typography/Detail.svelte" export { default as Detail } from "./Typography/Detail.svelte"
export { default as Code } from "./Typography/Code.svelte" export { default as Code } from "./Typography/Code.svelte"
// Core form components to be used elsewhere (standard components)
export * from "./Form/Core"
// Actions // Actions
export { default as autoResizeTextArea } from "./Actions/autoresize_textarea" export { default as autoResizeTextArea } from "./Actions/autoresize_textarea"
export { default as positionDropdown } from "./Actions/position_dropdown" export { default as positionDropdown } from "./Actions/position_dropdown"
@ -111,6 +115,3 @@ export { banner, BANNER_TYPES } from "./Stores/banner"
// Helpers // Helpers
export * as Helpers from "./helpers" export * as Helpers from "./helpers"
// Fancy form components
export * from "./FancyForm"

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

@ -32,6 +32,7 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte" import BindingSidePanel from "components/common/bindings/BindingSidePanel.svelte"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { BindingHelpers, BindingType } from "components/common/bindings/utils" import { BindingHelpers, BindingType } from "components/common/bindings/utils"
import { import {
bindingsToCompletions, bindingsToCompletions,
@ -47,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
@ -227,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}`
@ -237,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,
}, },
@ -281,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
@ -356,7 +363,9 @@
value.customType !== "queryParams" && value.customType !== "queryParams" &&
value.customType !== "cron" && value.customType !== "cron" &&
value.customType !== "triggerSchema" && value.customType !== "triggerSchema" &&
value.customType !== "automationFields" value.customType !== "automationFields" &&
value.type !== "attachment" &&
value.type !== "attachment_single"
) )
} }
@ -365,6 +374,16 @@
return `${value.title || (key === "row" ? "Table" : key)} ${requiredSuffix}` return `${value.title || (key === "row" ? "Table" : key)} ${requiredSuffix}`
} }
function handleAttachmentParams(keyValueObj) {
let params = {}
if (keyValueObj?.length) {
for (let param of keyValueObj) {
params[param.url] = param.filename
}
}
return params
}
onMount(async () => { onMount(async () => {
try { try {
await environment.loadVariables() await environment.loadVariables()
@ -437,6 +456,33 @@
value={inputData[key]} value={inputData[key]}
options={Object.keys(table?.schema || {})} options={Object.keys(table?.schema || {})}
/> />
{:else if value.type === "attachment"}
<div class="attachment-field-wrapper">
<div class="label-wrapper">
<Label>{label}</Label>
</div>
<div class="attachment-field-width">
<KeyValueBuilder
on:change={e =>
onChange(
{
detail: e.detail.map(({ name, value }) => ({
url: name,
filename: value,
})),
},
key
)}
object={handleAttachmentParams(inputData[key])}
allowJS
{bindings}
keyBindings
customButtonText={"Add attachment"}
keyPlaceholder={"URL"}
valuePlaceholder={"Filename"}
/>
</div>
</div>
{:else if value.customType === "filters"} {:else if value.customType === "filters"}
<ActionButton on:click={drawer.show}>Define filters</ActionButton> <ActionButton on:click={drawer.show}>Define filters</ActionButton>
<Drawer bind:this={drawer} title="Filtering"> <Drawer bind:this={drawer} title="Filtering">
@ -651,14 +697,22 @@
} }
.block-field { .block-field {
display: flex; /* Use Flexbox */ display: flex;
justify-content: space-between; justify-content: space-between;
flex-direction: row; /* Arrange label and field side by side */ flex-direction: row;
align-items: center; /* Align vertically in the center */ align-items: center;
gap: 10px; /* Add some space between label and field */ gap: 10px;
flex: 1; flex: 1;
} }
.attachment-field-width {
margin-top: var(--spacing-xs);
}
.label-wrapper {
margin-top: var(--spacing-s);
}
.test :global(.drawer) { .test :global(.drawer) {
width: 10000px !important; width: 10000px !important;
} }

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
} }
function handleAttachmentParams(keyValueObj) {
let params = {}
if (
schema.type === FieldType.ATTACHMENT_SINGLE &&
Object.keys(keyValueObj).length === 0
) {
return []
}
if (!Array.isArray(keyValueObj) && keyValueObj) {
keyValueObj = [keyValueObj]
}
if (keyValueObj.length) {
for (let param of keyValueObj) {
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

@ -106,6 +106,5 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--background); background: var(--background);
overflow: hidden;
} }
</style> </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"
@ -29,7 +34,11 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
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 { FieldType, FieldSubtype, SourceName } from "@budibase/types" import {
BBReferenceFieldSubType,
FieldType,
SourceName,
} from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte" import RelationshipSelector from "components/common/RelationshipSelector.svelte"
import { RowUtils } from "@budibase/frontend-core" import { RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
@ -41,8 +50,6 @@
const NUMBER_TYPE = FieldType.NUMBER const NUMBER_TYPE = FieldType.NUMBER
const JSON_TYPE = FieldType.JSON const JSON_TYPE = FieldType.JSON
const DATE_TYPE = FieldType.DATETIME const DATE_TYPE = FieldType.DATETIME
const USER_TYPE = FieldSubtype.USER
const USERS_TYPE = FieldSubtype.USERS
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
@ -65,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,
@ -173,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) => ({
@ -186,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()
@ -224,11 +238,6 @@
editableColumn.subtype, editableColumn.subtype,
editableColumn.autocolumn editableColumn.autocolumn
) )
allowedTypes = getAllowedTypes().map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
}))
} }
} }
@ -243,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
@ -262,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 === USER_TYPE) {
saveColumn.relationshipType = RelationshipType.ONE_TO_MANY
} else if (saveColumn.subtype === USERS_TYPE) {
saveColumn.relationshipType = RelationshipType.MANY_TO_MANY
}
}
try { try {
await tables.saveField({ await tables.saveField({
@ -287,6 +289,8 @@
} }
} catch (err) { } catch (err) {
notifications.error(`Error saving column: ${err.message}`) notifications.error(`Error saving column: ${err.message}`)
} finally {
savingColumn = false
} }
} }
@ -361,22 +365,36 @@
deleteColName = "" deleteColName = ""
} }
function getAllowedTypes() { function getAllowedTypes(datasource) {
if (originalName) { if (originalName) {
const possibleTypes = ( let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type]
SWITCHABLE_TYPES[field.type] || [editableColumn.type] if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) {
).map(t => t.toLowerCase()) // 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(([fieldType]) => .filter(([_, field]) => possibleTypes.includes(field.type))
possibleTypes.includes(fieldType.toLowerCase())
)
.map(([_, fieldDefinition]) => fieldDefinition) .map(([_, fieldDefinition]) => fieldDefinition)
} }
const isUsers =
editableColumn.type === FieldType.BB_REFERENCE &&
editableColumn.subtype === FieldSubtype.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,13 +505,6 @@
return newError return newError
} }
function isUsersColumn(column) {
return (
column.type === FieldType.BB_REFERENCE &&
[FieldSubtype.USER, FieldSubtype.USERS].includes(column.subtype)
)
}
onMount(() => { onMount(() => {
mounted = true mounted = true
}) })
@ -513,6 +529,7 @@
/> />
{/if} {/if}
<Select <Select
placeholder={null}
disabled={!typeEnabled} disabled={!typeEnabled}
bind:value={editableColumn.fieldId} bind:value={editableColumn.fieldId}
on:change={onHandleTypeChange} on:change={onHandleTypeChange}
@ -686,20 +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 === FieldSubtype.USERS}
on:change={e =>
handleTypeChange(
makeFieldId(
FieldType.BB_REFERENCE,
e.detail ? FieldSubtype.USERS : FieldSubtype.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
@ -734,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
@ -799,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

@ -1,5 +1,5 @@
<script> <script>
import { FieldType, FieldSubtype } from "@budibase/types" import { FieldType, BBReferenceFieldSubType } from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui" import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { DB_TYPE_INTERNAL } from "constants/backend" import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api" import { API } from "api"
@ -59,12 +59,16 @@
value: FieldType.ATTACHMENTS, value: FieldType.ATTACHMENTS,
}, },
{ {
label: "User", label: "Users",
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USER}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USER}`,
}, },
{ {
label: "Users", label: "Users",
value: `${FieldType.BB_REFERENCE}${FieldSubtype.USERS}`, value: `${FieldType.BB_REFERENCE}${BBReferenceFieldSubType.USERS}`,
},
{
label: "User",
value: `${FieldType.BB_REFERENCE_SINGLE}${BBReferenceFieldSubType.USER}`,
}, },
] ]

View File

@ -0,0 +1,89 @@
<script>
import { DatePicker } from "@budibase/bbui"
import dayjs from "dayjs"
import { createEventDispatcher } from "svelte"
import { memo } from "@budibase/frontend-core"
export let value
const dispatch = createEventDispatcher()
const valueStore = memo(value)
let date1
let date2
$: valueStore.set(value)
$: parseValue($valueStore)
const parseValue = value => {
if (!Array.isArray(value) || !value[0] || !value[1]) {
date1 = null
date2 = null
} else {
date1 = value[0]
date2 = value[1]
}
}
const onChangeDate1 = e => {
date1 = e.detail ? dayjs(e.detail).startOf("day") : null
if (date1 && (!date2 || date1.isAfter(date2))) {
date2 = date1.endOf("day")
} else if (!date1) {
date2 = null
}
broadcastChange()
}
const onChangeDate2 = e => {
date2 = e.detail ? dayjs(e.detail).endOf("day") : null
if (date2 && (!date1 || date2.isBefore(date1))) {
date1 = date2.startOf("day")
} else if (!date2) {
date1 = null
}
broadcastChange()
}
const broadcastChange = () => {
dispatch("change", [date1, date2])
}
</script>
<div class="date-range-picker">
<DatePicker
value={date1}
label="Date range"
enableTime={false}
on:change={onChangeDate1}
/>
<DatePicker value={date2} enableTime={false} on:change={onChangeDate2} />
</div>
<style>
.date-range-picker {
display: flex;
flex-direction: row;
align-items: flex-end;
}
/* Overlap date pickers to remove double border, but put the focused one on top */
.date-range-picker :global(.spectrum-InputGroup.is-focused) {
z-index: 1;
}
.date-range-picker :global(> :last-child) {
margin-left: -1px;
}
/* Remove border radius at the join */
.date-range-picker :global(> :first-child .spectrum-InputGroup),
.date-range-picker :global(> :first-child .spectrum-Picker) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.date-range-picker :global(> :last-child .spectrum-InputGroup),
.date-range-picker :global(> :last-child .spectrum-Textfield-input) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

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

@ -75,14 +75,12 @@
.relationship-container { .relationship-container {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 20px; gap: var(--spacing-m);
} }
.relationship-part { .relationship-part {
flex-basis: 70%; flex: 1 1 auto;
} }
.relationship-type { .relationship-type {
flex-basis: 30%; flex: 0 0 128px;
} }
</style> </style>

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

@ -1,8 +1,10 @@
<script> <script>
import { Body, Label, Input } from "@budibase/bbui" import { Body, Label } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings
onMount(() => { onMount(() => {
if (!parameters.confirm) { if (!parameters.confirm) {
@ -15,11 +17,18 @@
<Body size="S">Enter the message you wish to display to the user.</Body> <Body size="S">Enter the message you wish to display to the user.</Body>
<div class="params"> <div class="params">
<Label small>Title</Label> <Label small>Title</Label>
<Input placeholder="Prompt User" bind:value={parameters.customTitleText} /> <DrawerBindableInput
placeholder="Title"
value={parameters.customTitleText}
on:change={e => (parameters.customTitleText = e.detail)}
{bindings}
/>
<Label small>Message</Label> <Label small>Message</Label>
<Input <DrawerBindableInput
placeholder="Are you sure you want to continue?" placeholder="Are you sure you want to continue?"
bind:value={parameters.confirmText} value={parameters.confirmText}
on:change={e => (parameters.confirmText = e.detail)}
{bindings}
/> />
</div> </div>
</div> </div>

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

@ -21,26 +21,24 @@
const currentStep = derived(multiStepStore, state => state.currentStep) const currentStep = derived(multiStepStore, state => state.currentStep)
const componentType = "@budibase/standard-components/multistepformblockstep" const componentType = "@budibase/standard-components/multistepformblockstep"
setContext("multi-step-form-block", multiStepStore)
let cachedValue let cachedValue
let cachedInstance = {} let cachedInstance = {}
$: if (!isEqual(cachedValue, value)) { $: if (!isEqual(cachedValue, value)) {
cachedValue = value cachedValue = value
} }
$: if (!isEqual(componentInstance, cachedInstance)) { $: if (!isEqual(componentInstance, cachedInstance)) {
cachedInstance = componentInstance cachedInstance = componentInstance
} }
setContext("multi-step-form-block", multiStepStore)
$: stepCount = cachedValue?.length || 0 $: stepCount = cachedValue?.length || 0
$: updateStore(stepCount) $: updateStore(stepCount)
$: dataSource = getDatasourceForProvider($selectedScreen, cachedInstance) $: dataSource = getDatasourceForProvider($selectedScreen, cachedInstance)
$: emitCurrentStep($currentStep) $: emitCurrentStep($currentStep)
$: stepLabel = getStepLabel($multiStepStore) $: stepLabel = getStepLabel($multiStepStore)
$: stepDef = getDefinition(stepLabel) $: stepDef = getDefinition(stepLabel)
$: stepSettings = cachedValue?.[$currentStep] || {} $: savedInstance = cachedValue?.[$currentStep] || {}
$: defaults = Utils.buildMultiStepFormBlockDefaultProps({ $: defaults = Utils.buildMultiStepFormBlockDefaultProps({
_id: cachedInstance._id, _id: cachedInstance._id,
stepCount: $multiStepStore.stepCount, stepCount: $multiStepStore.stepCount,
@ -48,14 +46,16 @@
actionType: cachedInstance.actionType, actionType: cachedInstance.actionType,
dataSource: cachedInstance.dataSource, dataSource: cachedInstance.dataSource,
}) })
// For backwards compatibility we need to sometimes manually set base
// properties like _id and _component as we didn't used to save these
$: stepInstance = { $: stepInstance = {
_id: Helpers.uuid(), _id: savedInstance._id || Helpers.uuid(),
_component: componentType, _component: savedInstance._component || componentType,
_instanceName: `Step ${currentStep + 1}`, _instanceName: `Step ${currentStep + 1}`,
title: stepSettings.title ?? defaults?.title, title: savedInstance.title ?? defaults?.title,
buttons: stepSettings.buttons || defaults?.buttons, buttons: savedInstance.buttons || defaults?.buttons,
fields: stepSettings.fields, fields: savedInstance.fields,
desc: stepSettings.desc, desc: savedInstance.desc,
// Needed for field configuration // Needed for field configuration
dataSource, dataSource,
@ -92,7 +92,8 @@
} }
const addStep = () => { const addStep = () => {
value = value.toSpliced($currentStep + 1, 0, {}) const newInstance = componentStore.createInstance(componentType)
value = value.toSpliced($currentStep + 1, 0, newInstance)
dispatch("change", value) dispatch("change", value)
multiStepStore.update(state => ({ multiStepStore.update(state => ({
...state, ...state,

View File

@ -1,6 +1,6 @@
<script> <script>
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { ActionButton } from "@budibase/bbui" import { ActionButton, AbsTooltip } from "@budibase/bbui"
const multiStepStore = getContext("multi-step-form-block") const multiStepStore = getContext("multi-step-form-block")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -28,45 +28,49 @@
</div> </div>
{:else} {:else}
<div class="step-actions"> <div class="step-actions">
<ActionButton <AbsTooltip text="Previous step" noWrap>
size="S" <ActionButton
secondary size="S"
icon="ChevronLeft" secondary
disabled={currentStep === 0} icon="ChevronLeft"
on:click={() => { disabled={currentStep === 0}
stepAction("previousStep") on:click={() => {
}} stepAction("previousStep")
tooltip={"Previous step"} }}
/> />
<ActionButton </AbsTooltip>
size="S" <AbsTooltip text="Next step" noWrap>
secondary <ActionButton
disabled={currentStep === stepCount - 1} size="S"
icon="ChevronRight" secondary
on:click={() => { disabled={currentStep === stepCount - 1}
stepAction("nextStep") icon="ChevronRight"
}} on:click={() => {
tooltip={"Next step"} stepAction("nextStep")
/> }}
<ActionButton />
size="S" </AbsTooltip>
secondary <AbsTooltip text="Remove step" noWrap>
icon="Close" <ActionButton
disabled={stepCount === 1} size="S"
on:click={() => { secondary
stepAction("removeStep") icon="Close"
}} disabled={stepCount === 1}
tooltip={"Remove step"} on:click={() => {
/> stepAction("removeStep")
<ActionButton }}
size="S" />
secondary </AbsTooltip>
icon="MultipleAdd" <AbsTooltip text="Add step" noWrap>
on:click={() => { <ActionButton
stepAction("addStep") size="S"
}} secondary
tooltip={"Add step"} icon="MultipleAdd"
/> on:click={() => {
stepAction("addStep")
}}
/>
</AbsTooltip>
</div> </div>
{/if} {/if}

View File

@ -75,6 +75,7 @@ const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
return createComponent( return createComponent(
"@budibase/standard-components/labelfield", "@budibase/standard-components/labelfield",
{ {
_id: column.field,
_instanceName: column.field, _instanceName: column.field,
active: column.active, active: column.active,
field: column.field, field: column.field,

View File

@ -65,6 +65,7 @@ describe("getColumns", () => {
it("returns the selected and unselected fields in the modern format, respecting the original order", ctx => { it("returns the selected and unselected fields in the modern format, respecting the original order", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -73,6 +74,7 @@ describe("getColumns", () => {
label: "three label", label: "three label",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -81,6 +83,7 @@ describe("getColumns", () => {
label: "two label", label: "two label",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -91,6 +94,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -115,6 +119,7 @@ describe("getColumns", () => {
it("returns all columns, with non-hidden columns automatically selected", ctx => { it("returns all columns, with non-hidden columns automatically selected", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -123,6 +128,7 @@ describe("getColumns", () => {
label: "two", label: "two",
}, },
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -131,6 +137,7 @@ describe("getColumns", () => {
label: "three", label: "three",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -141,6 +148,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -173,6 +181,7 @@ describe("getColumns", () => {
it("returns all columns, including those missing from the initial data", ctx => { it("returns all columns, including those missing from the initial data", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -181,6 +190,7 @@ describe("getColumns", () => {
label: "three label", label: "three label",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -189,6 +199,7 @@ describe("getColumns", () => {
label: "two", label: "two",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -199,6 +210,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -228,6 +240,7 @@ describe("getColumns", () => {
it("returns all valid columns, excluding those that aren't valid for the schema", ctx => { it("returns all valid columns, excluding those that aren't valid for the schema", ctx => {
expect(ctx.columns.sortable).toEqual([ expect(ctx.columns.sortable).toEqual([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -236,6 +249,7 @@ describe("getColumns", () => {
label: "three label", label: "three label",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -244,6 +258,7 @@ describe("getColumns", () => {
label: "two", label: "two",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: false, active: false,
columnType: "foo", columnType: "foo",
@ -254,6 +269,7 @@ describe("getColumns", () => {
]) ])
expect(ctx.columns.primary).toEqual({ expect(ctx.columns.primary).toEqual({
_id: "four",
_instanceName: "four", _instanceName: "four",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -318,6 +334,7 @@ describe("getColumns", () => {
beforeEach(ctx => { beforeEach(ctx => {
ctx.updateSortable([ ctx.updateSortable([
{ {
_id: "three",
_instanceName: "three", _instanceName: "three",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -326,6 +343,7 @@ describe("getColumns", () => {
label: "three", label: "three",
}, },
{ {
_id: "one",
_instanceName: "one", _instanceName: "one",
active: true, active: true,
columnType: "foo", columnType: "foo",
@ -334,6 +352,7 @@ describe("getColumns", () => {
label: "one", label: "one",
}, },
{ {
_id: "two",
_instanceName: "two", _instanceName: "two",
active: false, active: false,
columnType: "foo", columnType: "foo",

View File

@ -35,6 +35,9 @@
export let bindingDrawerLeft export let bindingDrawerLeft
export let allowHelpers = true export let allowHelpers = true
export let customButtonText = null export let customButtonText = null
export let keyBindings = 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]) => ({
@ -116,12 +119,23 @@
class:readOnly-menu={readOnly && showMenu} class:readOnly-menu={readOnly && showMenu}
> >
{#each fields as field, idx} {#each fields as field, idx}
<Input {#if keyBindings}
placeholder={keyPlaceholder} <DrawerBindableInput
readonly={readOnly} {bindings}
bind:value={field.name} placeholder={keyPlaceholder}
on:blur={changed} on:blur={e => {
/> field.name = e.detail
changed()
}}
disabled={readOnly}
value={field.name}
{allowJS}
{allowHelpers}
drawerLeft={bindingDrawerLeft}
/>
{:else}
<Input readonly={readOnly} bind:value={field.name} on:blur={changed} />
{/if}
{#if isJsonArray(field.value)} {#if isJsonArray(field.value)}
<Select readonly={true} value="Array" options={["Array"]} /> <Select readonly={true} value="Array" options={["Array"]} />
{:else if options} {:else if options}
@ -134,14 +148,14 @@
{:else if bindings && bindings.length} {:else if bindings && bindings.length}
<DrawerBindableInput <DrawerBindableInput
{bindings} {bindings}
placeholder="Value" placeholder={valuePlaceholder}
on:blur={e => { on:blur={e => {
field.value = e.detail field.value = e.detail
changed() changed()
}} }}
disabled={readOnly} disabled={readOnly}
value={field.value} value={field.value}
allowJS={false} {allowJS}
{allowHelpers} {allowHelpers}
drawerLeft={bindingDrawerLeft} drawerLeft={bindingDrawerLeft}
/> />
@ -176,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

@ -25,6 +25,6 @@
name="field" name="field"
headings headings
options={SchemaTypeOptionsExpanded} options={SchemaTypeOptionsExpanded}
compare={(option, value) => option.type === value.type} compare={(option, value) => option.type === value?.type}
/> />
{/key} {/key}

View File

@ -695,7 +695,7 @@
menuItems={schemaMenuItems} menuItems={schemaMenuItems}
showMenu={!schemaReadOnly} showMenu={!schemaReadOnly}
readOnly={schemaReadOnly} readOnly={schemaReadOnly}
compare={(option, value) => option.type === value.type} compare={(option, value) => option.type === value?.type}
/> />
</Tab> </Tab>
{/if} {/if}

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

@ -1,6 +1,6 @@
import { import {
FieldType, FieldType,
FieldSubtype, BBReferenceFieldSubType,
INTERNAL_TABLE_SOURCE_ID, INTERNAL_TABLE_SOURCE_ID,
AutoFieldSubType, AutoFieldSubType,
Hosting, Hosting,
@ -159,15 +159,17 @@ export const FIELDS = {
}, },
USER: { USER: {
name: "User", name: "User",
type: FieldType.BB_REFERENCE, type: FieldType.BB_REFERENCE_SINGLE,
subtype: FieldSubtype.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: FieldSubtype.USERS, subtype: BBReferenceFieldSubType.USER,
icon: TypeIconMap[FieldType.USERS], icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER],
constraints: { constraints: {
type: "array", type: "array",
}, },
@ -253,6 +255,7 @@ export const SchemaTypeOptions = [
{ label: "Number", value: FieldType.NUMBER }, { label: "Number", value: FieldType.NUMBER },
{ label: "Boolean", value: FieldType.BOOLEAN }, { label: "Boolean", value: FieldType.BOOLEAN },
{ label: "Datetime", value: FieldType.DATETIME }, { label: "Datetime", value: FieldType.DATETIME },
{ label: "JSON", value: FieldType.JSON },
] ]
export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({ export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({

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 },
} }
} }
}) })
@ -1106,50 +1115,51 @@ export const getAllStateVariables = () => {
getAllAssets().forEach(asset => { getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => { findAllMatchingComponents(asset.props, component => {
const settings = componentStore.getComponentSettings(component._component) const settings = componentStore.getComponentSettings(component._component)
const nestedTypes = [
"buttonConfiguration",
"fieldConfiguration",
"stepConfiguration",
]
// Extracts all event settings from a component instance.
// Recurses into nested types to find all event-like settings at any
// depth.
const parseEventSettings = (settings, comp) => { const parseEventSettings = (settings, comp) => {
if (!settings?.length) {
return
}
// Extract top level event settings
settings settings
.filter(setting => setting.type === "event") .filter(setting => setting.type === "event")
.forEach(setting => { .forEach(setting => {
eventSettings.push(comp[setting.key]) eventSettings.push(comp[setting.key])
}) })
}
const parseComponentSettings = (settings, component) => { // Recurse into any nested instance types
// Parse the nested button configurations
settings settings
.filter(setting => setting.type === "buttonConfiguration") .filter(setting => nestedTypes.includes(setting.type))
.forEach(setting => { .forEach(setting => {
const buttonConfig = component[setting.key] const instances = comp[setting.key]
if (Array.isArray(instances) && instances.length) {
instances.forEach(instance => {
let type = instance?._component
if (Array.isArray(buttonConfig)) { // Backwards compatibility for multi-step from blocks which
buttonConfig.forEach(button => { // didn't set a proper component type previously.
const nestedSettings = componentStore.getComponentSettings( if (setting.type === "stepConfiguration" && !type) {
button._component type = "@budibase/standard-components/multistepformblockstep"
) }
parseEventSettings(nestedSettings, button)
// Parsed nested component instances inside this setting
const nestedSettings = componentStore.getComponentSettings(type)
parseEventSettings(nestedSettings, instance)
}) })
} }
}) })
parseEventSettings(settings, component)
} }
// Parse the base component settings parseEventSettings(settings, component)
parseComponentSettings(settings, component)
// Parse step configuration
const stepSetting = settings.find(
setting => setting.type === "stepConfiguration"
)
const steps = stepSetting ? component[stepSetting.key] : []
const stepDefinition = componentStore.getComponentSettings(
"@budibase/standard-components/multistepformblockstep"
)
steps?.forEach(step => {
parseComponentSettings(stepDefinition, step)
})
}) })
}) })

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

@ -1,7 +1,6 @@
<script> <script>
import { import {
Button, Button,
DatePicker,
Divider, Divider,
Layout, Layout,
notifications, notifications,
@ -25,13 +24,13 @@
import BackupsDefault from "assets/backups-default.png" import BackupsDefault from "assets/backups-default.png"
import { BackupTrigger, BackupType } from "constants/backend/backups" import { BackupTrigger, BackupType } from "constants/backend/backups"
import { onMount } from "svelte" import { onMount } from "svelte"
import DateRangePicker from "components/common/DateRangePicker.svelte"
let loading = true let loading = true
let backupData = null let backupData = null
let pageInfo = createPaginationStore() let pageInfo = createPaginationStore()
let filterOpt = null let filterOpt = null
let startDate = null let dateRange = []
let endDate = null
let filters = [ let filters = [
{ {
label: "Manual backup", label: "Manual backup",
@ -52,7 +51,7 @@
] ]
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchBackups(filterOpt, page, startDate, endDate) $: fetchBackups(filterOpt, page, dateRange)
let schema = { let schema = {
type: { type: {
@ -99,14 +98,22 @@
}) })
} }
async function fetchBackups(filters, page, startDate, endDate) { async function fetchBackups(filters, page, dateRange = []) {
const response = await backups.searchBackups({ const body = {
appId: $appStore.appId, appId: $appStore.appId,
...filters, ...filters,
page, page,
startDate, }
endDate,
}) 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
@ -121,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")
} }
} }
@ -165,7 +172,7 @@
} }
onMount(async () => { onMount(async () => {
await fetchBackups(filterOpt, page, startDate, endDate) await fetchBackups(filterOpt, page, dateRange)
loading = false loading = false
}) })
</script> </script>
@ -207,7 +214,7 @@
View plans View plans
</Button> </Button>
</div> </div>
{:else if !backupData?.length && !loading && !filterOpt && !startDate} {:else if !backupData?.length && !loading && !filterOpt && !dateRange?.length}
<div class="center"> <div class="center">
<Layout noPadding gap="S" justifyItems="center"> <Layout noPadding gap="S" justifyItems="center">
<img height="130px" src={BackupsDefault} alt="BackupsDefault" /> <img height="130px" src={BackupsDefault} alt="BackupsDefault" />
@ -236,21 +243,15 @@
bind:value={filterOpt} bind:value={filterOpt}
/> />
</div> </div>
<DatePicker <DateRangePicker
range={true} value={dateRange}
label="Date Range" on:change={e => (dateRange = e.detail)}
on:change={e => {
if (e.detail[0].length > 1) {
startDate = e.detail[0][0].toISOString()
endDate = e.detail[0][1].toISOString()
}
}}
/> />
</div> </div>
<div> <div>
<Button cta disabled={loading} on:click={createManualBackup} <Button cta disabled={loading} on:click={createManualBackup}>
>Create new backup</Button Create new backup
> </Button>
</div> </div>
</div> </div>
<div class="table"> <div class="table">

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

@ -12,7 +12,6 @@
Icon, Icon,
clickOutside, clickOutside,
CoreTextArea, CoreTextArea,
DatePicker,
Pagination, Pagination,
Helpers, Helpers,
Divider, Divider,
@ -27,6 +26,8 @@
import TimeRenderer from "./_components/TimeRenderer.svelte" import TimeRenderer from "./_components/TimeRenderer.svelte"
import AppColumnRenderer from "./_components/AppColumnRenderer.svelte" import AppColumnRenderer from "./_components/AppColumnRenderer.svelte"
import { cloneDeep } from "lodash" import { cloneDeep } from "lodash"
import DateRangePicker from "components/common/DateRangePicker.svelte"
import dayjs from "dayjs"
const schema = { const schema = {
date: { width: "0.8fr" }, date: { width: "0.8fr" },
@ -69,16 +70,13 @@
let sidePanelVisible = false let sidePanelVisible = false
let wideSidePanel = false let wideSidePanel = false
let timer let timer
let startDate = new Date() let dateRange = [dayjs().subtract(30, "days"), dayjs()]
startDate.setDate(startDate.getDate() - 30)
let endDate = new Date()
$: fetchUsers(userPage, userSearchTerm) $: fetchUsers(userPage, userSearchTerm)
$: fetchLogs({ $: fetchLogs({
logsPage, logsPage,
logSearchTerm, logSearchTerm,
startDate, dateRange,
endDate,
selectedUsers, selectedUsers,
selectedApps, selectedApps,
selectedEvents, selectedEvents,
@ -136,8 +134,7 @@
const fetchLogs = async ({ const fetchLogs = async ({
logsPage, logsPage,
logSearchTerm, logSearchTerm,
startDate, dateRange,
endDate,
selectedUsers, selectedUsers,
selectedApps, selectedApps,
selectedEvents, selectedEvents,
@ -155,8 +152,8 @@
logsPageInfo.loading() logsPageInfo.loading()
await auditLogs.search({ await auditLogs.search({
bookmark: logsPage, bookmark: logsPage,
startDate, startDate: dateRange[0],
endDate, endDate: dateRange[1],
fullSearch: logSearchTerm, fullSearch: logSearchTerm,
userIds: selectedUsers, userIds: selectedUsers,
appIds: selectedApps, appIds: selectedApps,
@ -214,8 +211,8 @@
const downloadLogs = async () => { const downloadLogs = async () => {
try { try {
window.location = auditLogs.getDownloadUrl({ window.location = auditLogs.getDownloadUrl({
startDate, startDate: dateRange[0],
endDate, endDate: dateRange[1],
fullSearch: logSearchTerm, fullSearch: logSearchTerm,
userIds: selectedUsers, userIds: selectedUsers,
appIds: selectedApps, appIds: selectedApps,
@ -302,22 +299,9 @@
</div> </div>
<div class="date-picker"> <div class="date-picker">
<DatePicker <DateRangePicker
value={[startDate, endDate]} value={dateRange}
placeholder="Choose date range" on:change={e => (dateRange = e.detail)}
range={true}
on:change={e => {
if (e.detail[0]?.length === 1) {
startDate = e.detail[0][0].toISOString()
endDate = ""
} else if (e.detail[0]?.length > 1) {
startDate = e.detail[0][0].toISOString()
endDate = e.detail[0][1].toISOString()
} else {
startDate = ""
endDate = ""
}
}}
/> />
</div> </div>
<div class="freeSearch"> <div class="freeSearch">
@ -488,7 +472,7 @@
flex-direction: row; flex-direction: row;
gap: var(--spacing-l); gap: var(--spacing-l);
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: flex-end;
} }
.side-panel-icons { .side-panel-icons {
@ -505,6 +489,13 @@
.date-picker { .date-picker {
flex-basis: calc(70% - 32px); flex-basis: calc(70% - 32px);
min-width: 100px; min-width: 100px;
display: flex;
flex-direction: row;
}
.date-picker :global(.date-range-picker),
.date-picker :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
} }
.freeSearch { .freeSearch {

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

@ -441,6 +441,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

@ -9,7 +9,7 @@ const {
ObjectStore, ObjectStore,
retrieve, retrieve,
uploadDirectory, uploadDirectory,
makeSureBucketExists, createBucketIfNotExists,
} = objectStore } = objectStore
const bucketList = Object.values(ObjectStoreBuckets) const bucketList = Object.values(ObjectStoreBuckets)
@ -61,7 +61,7 @@ export async function importObjects() {
let count = 0 let count = 0
for (let bucket of buckets) { for (let bucket of buckets) {
const client = ObjectStore(bucket) const client = ObjectStore(bucket)
await makeSureBucketExists(client, bucket) await createBucketIfNotExists(client, bucket)
const files = await uploadDirectory(bucket, join(path, bucket), "/") const files = await uploadDirectory(bucket, join(path, bucket), "/")
count += files.length count += files.length
bar.update(count) bar.update(count)

View File

@ -3991,12 +3991,6 @@
"key": "timeOnly", "key": "timeOnly",
"defaultValue": false "defaultValue": false
}, },
{
"type": "boolean",
"label": "24-hour time",
"key": "time24hr",
"defaultValue": false
},
{ {
"type": "boolean", "type": "boolean",
"label": "Ignore time zones", "label": "Ignore time zones",
@ -4348,8 +4342,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,
@ -4446,7 +4440,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,
@ -6925,7 +6919,20 @@
"illegalChildren": ["section", "sidepanel"], "illegalChildren": ["section", "sidepanel"],
"showEmptyState": false, "showEmptyState": false,
"draggable": false, "draggable": false,
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action." "info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action.",
"settings": [
{
"type": "boolean",
"key": "ignoreClicksOutside",
"label": "Ignore clicks outside",
"defaultValue": false
},
{
"type": "event",
"key": "onClose",
"label": "On close"
}
]
}, },
"rowexplorer": { "rowexplorer": {
"block": true, "block": true,
@ -7212,16 +7219,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,
@ -7325,5 +7344,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

@ -24,14 +24,7 @@
"@budibase/shared-core": "0.0.0", "@budibase/shared-core": "0.0.0",
"@budibase/string-templates": "0.0.0", "@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0", "@budibase/types": "0.0.0",
"@spectrum-css/button": "^3.0.3", "@spectrum-css/card": "3.0.3",
"@spectrum-css/card": "^3.0.3",
"@spectrum-css/divider": "^1.0.3",
"@spectrum-css/link": "^3.1.3",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/tag": "^3.1.4",
"@spectrum-css/typography": "^3.0.2",
"@spectrum-css/vars": "^3.0.1",
"apexcharts": "^3.48.0", "apexcharts": "^3.48.0",
"dayjs": "^1.10.8", "dayjs": "^1.10.8",
"downloadjs": "1.4.7", "downloadjs": "1.4.7",
@ -40,7 +33,6 @@
"sanitize-html": "^2.7.0", "sanitize-html": "^2.7.0",
"screenfull": "^6.0.1", "screenfull": "^6.0.1",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-flatpickr": "^3.3.4",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -206,13 +206,6 @@
/> />
{/key} {/key}
<!--
Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with.
-->
<div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top --> <!-- Modal container to ensure they sit on top -->
<div class="modal-container" /> <div class="modal-container" />

View File

@ -60,16 +60,6 @@
--spectrum-link-primary-m-text-color-hover: var(--primaryColorHover); --spectrum-link-primary-m-text-color-hover: var(--primaryColorHover);
} }
/* Theme flatpickr */
:global(.flatpickr-day.selected) {
background: var(--primaryColor);
border-color: var(--primaryColor);
}
:global(.flatpickr-day.selected:hover) {
background: var(--primaryColorHover);
border-color: var(--primaryColorHover);
}
/* Custom scrollbars */ /* Custom scrollbars */
:global(::-webkit-scrollbar) { :global(::-webkit-scrollbar) {
width: 8px; width: 8px;

View File

@ -38,10 +38,8 @@
if (!field || !value) { if (!field || !value) {
return null return null
} }
let low = dayjs.utc().subtract(1, "year") let low = dayjs.utc().subtract(1, "year")
let high = dayjs.utc().add(1, "day") let high = dayjs.utc().add(1, "day")
if (value === "Last 1 day") { if (value === "Last 1 day") {
low = dayjs.utc().subtract(1, "day") low = dayjs.utc().subtract(1, "day")
} else if (value === "Last 7 days") { } else if (value === "Last 7 days") {
@ -53,7 +51,6 @@
} else if (value === "Last 6 months") { } else if (value === "Last 6 months") {
low = dayjs.utc().subtract(6, "months") low = dayjs.utc().subtract(6, "months")
} }
return { return {
range: { range: {
[field]: { [field]: {

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