diff --git a/.eslintrc.json b/.eslintrc.json index 2c810eecc5..d475bba8d1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,5 @@ { + "root": true, "env": { "browser": true, "es6": true, @@ -53,7 +54,8 @@ "ignoreRestSiblings": true } ], - "local-rules/no-budibase-imports": "error" + "no-redeclare": "off", + "@typescript-eslint/no-redeclare": "error" } }, { diff --git a/.github/workflows/budibase_ci.yml b/.github/workflows/budibase_ci.yml index fd4d8cf7c8..7f1e08601a 100644 --- a/.github/workflows/budibase_ci.yml +++ b/.github/workflows/budibase_ci.yml @@ -92,8 +92,6 @@ jobs: test-libraries: runs-on: ubuntu-latest - env: - REUSE_CONTAINERS: true steps: - name: Checkout repo uses: actions/checkout@v4 @@ -150,8 +148,6 @@ jobs: test-server: runs-on: budi-tubby-tornado-quad-core-150gb - env: - REUSE_CONTAINERS: true steps: - name: Checkout repo uses: actions/checkout@v4 @@ -174,7 +170,8 @@ jobs: docker pull mongo:7.0-jammy & docker pull mariadb:lts & 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 & wait $(jobs -p) diff --git a/.prettierignore b/.prettierignore index 87f0191a94..72cdc75a23 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,4 +12,5 @@ packages/pro/coverage packages/account-portal/packages/ui/build packages/account-portal/packages/ui/.routify packages/account-portal/packages/server/build +packages/account-portal/packages/server/coverage **/*.ivm.bundle.js \ No newline at end of file diff --git a/charts/budibase/templates/app-service-deployment.yaml b/charts/budibase/templates/app-service-deployment.yaml index b380908dd1..ed7166ec5d 100644 --- a/charts/budibase/templates/app-service-deployment.yaml +++ b/charts/budibase/templates/app-service-deployment.yaml @@ -106,6 +106,8 @@ spec: value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: BACKUPS_BUCKET_NAME value: {{ .Values.services.objectStore.backupsBucketName | quote }} + - name: TEMP_BUCKET_NAME + value: {{ .Values.globals.tempBucketName | quote }} - name: PORT value: {{ .Values.services.apps.port | quote }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }} diff --git a/charts/budibase/templates/automation-worker-service-deployment.yaml b/charts/budibase/templates/automation-worker-service-deployment.yaml index 51fa9ee4bb..3c6f94ae9e 100644 --- a/charts/budibase/templates/automation-worker-service-deployment.yaml +++ b/charts/budibase/templates/automation-worker-service-deployment.yaml @@ -107,6 +107,8 @@ spec: value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: BACKUPS_BUCKET_NAME value: {{ .Values.services.objectStore.backupsBucketName | quote }} + - name: TEMP_BUCKET_NAME + value: {{ .Values.globals.tempBucketName | quote }} - name: PORT value: {{ .Values.services.automationWorkers.port | quote }} {{ if .Values.services.worker.publicApiRateLimitPerSecond }} diff --git a/charts/budibase/templates/automation-worker-service-hpa.yaml b/charts/budibase/templates/automation-worker-service-hpa.yaml index f29223b61b..18f9690c00 100644 --- a/charts/budibase/templates/automation-worker-service-hpa.yaml +++ b/charts/budibase/templates/automation-worker-service-hpa.yaml @@ -2,7 +2,7 @@ apiVersion: {{ ternary "autoscaling/v2" "autoscaling/v2beta2" (.Capabilities.APIVersions.Has "autoscaling/v2") }} kind: HorizontalPodAutoscaler metadata: - name: {{ include "budibase.fullname" . }}-apps + name: {{ include "budibase.fullname" . }}-automation-worker labels: {{- include "budibase.labels" . | nindent 4 }} spec: diff --git a/charts/budibase/templates/worker-service-deployment.yaml b/charts/budibase/templates/worker-service-deployment.yaml index e37b2bc0e4..66a9bb6c14 100644 --- a/charts/budibase/templates/worker-service-deployment.yaml +++ b/charts/budibase/templates/worker-service-deployment.yaml @@ -106,6 +106,8 @@ spec: value: {{ .Values.services.objectStore.globalBucketName | quote }} - name: BACKUPS_BUCKET_NAME value: {{ .Values.services.objectStore.backupsBucketName | quote }} + - name: TEMP_BUCKET_NAME + value: {{ .Values.globals.tempBucketName | quote }} - name: PORT value: {{ .Values.services.worker.port | quote }} - name: MULTI_TENANCY diff --git a/charts/budibase/values.yaml b/charts/budibase/values.yaml index 9ace768625..27037cdaa8 100644 --- a/charts/budibase/values.yaml +++ b/charts/budibase/values.yaml @@ -121,6 +121,9 @@ globals: # to the old value for the duration of the rotation. jwtSecretFallback: "" + ## -- If using S3 the bucket name to be used for storing temporary files + tempBucketName: "" + smtp: # -- Whether to enable SMTP or not. enabled: false diff --git a/globalSetup.ts b/globalSetup.ts index 115796c395..dd1454b6e1 100644 --- a/globalSetup.ts +++ b/globalSetup.ts @@ -1,19 +1,52 @@ -import { GenericContainer, Wait } from "testcontainers" +import { + GenericContainer, + Wait, + getContainerRuntimeClient, +} from "testcontainers" +import { ContainerInfo } from "dockerode" import path from "path" 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() { 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 - // shared container to get started multiple times despite having an - // identical reuse hash. To avoid that, we do a filesystem-based lock so - // that only one globalSetup.ts is running at a time. - lockfile.lockSync(lockPath) - } + // 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 + // identical reuse hash. To avoid that, we do a filesystem-based lock so + // that only one globalSetup.ts is running at a time. + 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 { - let couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs") + const couchdb = new GenericContainer("budibase/couchdb:v3.2.1-sqs") .withExposedPorts(5984, 4984) .withEnvironment({ COUCHDB_PASSWORD: "budibase", @@ -28,20 +61,29 @@ export default async function setup() { target: "/opt/couchdb/etc/local.d/test-couchdb.ini", }, ]) + .withLabels({ "com.budibase": "true" }) + .withReuse() .withWaitStrategy( Wait.forSuccessfulCommand( "curl http://budibase:budibase@localhost:5984/_up" ).withStartupTimeout(20000) ) - if (process.env.REUSE_CONTAINERS) { - couchdb = couchdb.withReuse() - } + const minio = new GenericContainer("minio/minio") + .withExposedPorts(9000) + .withCommand(["server", "/data"]) + .withEnvironment({ + MINIO_ACCESS_KEY: "budibase", + MINIO_SECRET_KEY: "budibase", + }) + .withLabels({ "com.budibase": "true" }) + .withReuse() + .withWaitStrategy( + Wait.forHttp("/minio/health/ready", 9000).withStartupTimeout(10000) + ) - await couchdb.start() + await Promise.all([couchdb.start(), minio.start()]) } finally { - if (process.env.REUSE_CONTAINERS) { - lockfile.unlockSync(lockPath) - } + lockfile.unlockSync(lockPath) } } diff --git a/hosting/couchdb/runner.v2.sh b/hosting/couchdb/runner.v2.sh index 7ee24327a1..f8cbe49b8f 100644 --- a/hosting/couchdb/runner.v2.sh +++ b/hosting/couchdb/runner.v2.sh @@ -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 & # Start CouchDB. -/docker-entrypoint.sh /opt/couchdb/bin/couchdb & +/docker-entrypoint.sh /opt/couchdb/bin/couchdb > /dev/stdout 2>&1 & -# Start SQS. -/opt/sqs/sqs --server "http://localhost:5984" --data-dir ${DATA_DIR}/sqs --bind-address=0.0.0.0 & +# Start SQS. Use 127.0.0.1 instead of localhost to avoid IPv6 issues. +/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. while [[ $(curl -s -w "%{http_code}\n" http://localhost:5984/_up -o /dev/null) -ne 200 ]]; do diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index be01056b53..e8ac306c51 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -19,9 +19,6 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN ./scripts/removeWorkspaceDependencies.sh packages/server/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 ./scripts/removeWorkspaceDependencies.sh package.json RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile diff --git a/i18n/README.it.md b/i18n/README.it.md new file mode 100644 index 0000000000..8972fcdb18 --- /dev/null +++ b/i18n/README.it.md @@ -0,0 +1,213 @@ +

+ + Budibase + +

+

+ Budibase +

+ +

+ La piattaforma low-code che amerai utilizzare +

+

+ Budibase è una piattaforma low-code open source ed è il modo più semplice per creare strumenti interni che migliorano la produttività. +

+ +

+ 🤖 🎨 🚀 +

+
+ +

+ Budibase design ui +

+ +

+ + GitHub tutte le release + + + GitHub release (ordine cronologico) + + + Segui @budibase + + Codice di condotta + + + +

+ +

+ Inizia + · + Documentazione + · + Richieste di miglioramento + · + Segnala un bug + · + Supporto: Discussioni +

+ +

+## ✨ 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. +

+ +### 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. +

+ +### 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). + +

+ Dati Budibase +

+

+ +### 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). + +

+ Design Budibase +

+

+ +### 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). + +

+ Automazioni Budibase +

+

+ +### 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. + +

+ Integrazioni Budibase +

+

+ +### 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 + +


+ +## 🏁 Inizio + + + +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) + + +

+ +## 🎓 Imparare Budibase + +La documentazione Budibase [è qui](https://docs.budibase.com). +
+ + +

+ +## 💬 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) + +


+ + +## ❗ 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. +
+ + +

+ + +## 🙌 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) + +

+ + +## 📝 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. + +

+ +## ⭐ 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. + +

+ +## Contributeurs ✨ + +Grazie a queste meravigliose persone ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + + + + + + + + + + + + + + +

Martin McKeaveney

💻 📖 ⚠️ 🚇

Michael Drury

📖 💻 ⚠️ 🚇

Andrew Kingston

📖 💻 ⚠️ 🎨

Michael Shanks

📖 💻 ⚠️

Kevin Åberg Kultalahti

📖 💻 ⚠️

Joe

📖 💻 🖋 🎨

Rory Powell

💻 📖 ⚠️

Peter Clement

💻 📖 ⚠️

Conor Mack

💻 📖

Dominic Cave

💻 📖

Rabonaire

💻 📖 🐛

Alex Yeung

📖 💻

Sam Woods

📖 💻 🚇

ScooterSwope

💻 📖
+ + + + + +Questo progetto segue il [convenant del contribuente](https://github.com/Budibase/budibase/blob/master/CODE_OF_CONDUCT.md). Ogni contributo è il benvenuto! + + + + + diff --git a/i18n/README.por.md b/i18n/README.por.md new file mode 100644 index 0000000000..b8954dbe22 --- /dev/null +++ b/i18n/README.por.md @@ -0,0 +1,211 @@ +

+ + Budibase + +

+

+ Budibase +

+ +

+ A plataforma low-code que você vai adorar usar +

+

+ Budibase é uma plataforma low-code de código aberto e é a maneira mais fácil de criar ferramentas internas que melhoram a produtividade. +

+ +

+ 🤖 🎨 🚀 +

+
+ +

+ Budibase design ui +

+ +

+ + GitHub todos os releases + + + GitHub release (por ordem cronológica) + + + Siga @budibase + + Código de Conduta + + + +

+ +

+ Começar + · + Documentação + · + Solicitar melhorias + · + Reportar um bug + · + Suporte: Discussões +

+ +

+## ✨ 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. +

+ +### 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. +

+ +### 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). + +

+ Dados Budibase +

+

+ +### 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). + +

+ Design Budibase +

+

+ +### 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). + +

+ Automações Budibase +

+

+ +### 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. + +

+ Integrações Budibase +

+

+ +### 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 + +


+ +## 🏁 Começar + + + +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) + + +

+ +## 🎓 Aprenda Budibase + +A documentação Budibase [está aqui](https://docs.budibase.com). +
+ + +

+ +## 💬 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) + +


+ + +## ❗ 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. +
+ + +

+ + +## 🙌 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) + +

+ + +## 📝 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. + +

+ +## ⭐ 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. + +

+ +## Contribuidores ✨ + +Agradecimentos a estas pessoas maravilhosas ([chave de emoji](https://allcontributors.org/docs/fr/emoji-key)): + + + + + + + + + + + + + + + + + + + + + + +

Martin McKeaveney

💻 📖 ⚠️ 🚇

Michael Drury

📖 💻 ⚠️ 🚇

Andrew Kingston

📖 💻 ⚠️ 🎨

Michael Shanks

📖 💻 ⚠️

Kevin Åberg Kultalahti

📖 💻 ⚠️

Joe

📖 💻 🖋 🎨

Rory Powell

💻 📖 ⚠️

Peter Clement

💻 📖 ⚠️

Conor Mack

💻 📖 ⚠️

Grays-world

💻

Sylvain Galand

💻 📖

John Mullins

💻

Jakeboyd

💻

Steve Bridle

💻
+ + + +

+ + +## Licença + +Distribuído sob a licença GPL v3.0. Veja `LICENSE` para mais informações. + +

diff --git a/i18n/README.ru.md b/i18n/README.ru.md new file mode 100644 index 0000000000..b235828a40 --- /dev/null +++ b/i18n/README.ru.md @@ -0,0 +1,207 @@ +

+ + Budibase + +

+

+ Budibase +

+ +

+ Низкокодовая платформа, которую вы полюбите использовать +

+

+ Budibase - это открытая низкокодовая платформа, которая представляет собой самый простой способ создания внутренних инструментов, повышающих производительность. +

+ +

+ 🤖 🎨 🚀 +

+
+ +

+ Budibase design ui +

+ +

+ + GitHub все релизы + + + GitHub релизы (в хронологическом порядке) + + + Подписаться на @budibase + + Кодекс поведения + + + +

+ +

+ Начать + · + Документация + · + Запросы на улучшения + · + Сообщить об ошибке + · + Поддержка: Обсуждения +

+ +

+## ✨ Функциональные возможности + +### Строим и развертываем настоящее программное обеспечение +В отличие от других платформ, с помощью Budibase вы создаете и развертываете одностраничные приложения. Приложения Budibase имеют высокую производительность и могут быть адаптированы для разных устройств, обеспечивая вашим пользователям удивительный опыт. +

+ +### Открытый и расширяемый исходный код +Budibase - это свободное программное обеспечение под лицензией GPL v3. Это должно вас уверить в том, что Budibase всегда будет здесь. Вы также можете писать код в Budibase или форкнуть его и вносить изменения по своему усмотрению, что сделает его дружелюбным для разработчиков. +

+ +### Импорт данных или начало с нуля +Budibase может получать данные из различных источников, включая MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB или REST API. И в отличие от других платформ, с помощью Budibase вы можете начать с нуля и создавать бизнес-приложения без каких-либо источников данных. [Запросить новый источник данных](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). + +

+ Budibase data +

+

+ +### Проектирование и создание приложений с использованием предварительно определенных компонентов. + +Budibase поставляется с красиво оформленными и мощными компонентами, которые вы можете использовать как строительные блоки для создания вашего пользовательского интерфейса. Мы также предоставляем множество ваших любимых опций стилей CSS, чтобы вы могли проявить больше креативности. [Запросить новый компонент](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). + +

+ Budibase design +

+

+ +### Автоматизация процессов, интеграция с другими инструментами и подключение к вебхукам +Экономьте время, автоматизируя ручные процессы и рабочие потоки. Будь то подключение к вебхукам или автоматизация отправки электронных писем, просто скажите Budibase, что он должен делать, и позвольте ему работать за вас. Вы можете легко [создать новую автоматизацию для Budibase здесь](https://github.com/Budibase/automations) или [Запросить новую автоматизацию](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). + +

+ Budibase automations +

+

+ +### Интеграция с вашими любимыми инструментами +Budibase интегрируется с рядом популярных инструментов, что позволяет вам создавать приложения, которые идеально вписываются в вашу технологическую стопку. + +

+ Budibase integrations +

+

+ +### Рай для админов +Budibase разработан для масштабирования. С Budibase вы можете самостоятельно размещать его на своей собственной инфраструктуре и глобально управлять пользователями, доменами, SMTP, приложениями, группами, внешним видом и многим другим. Вы также можете предоставить пользователям/группам портал приложений и поручить управление пользователями руководителю группы. + +- Смотрите промо-видео: https://youtu.be/xoljVpty_Kw + +


+ +## 🏁 Начало работы + + + +Разверните Budibase на своей собственной инфраструктуре с использованием Docker, Kubernetes и Digital Ocean. +Или используйте Budibase Cloud, если вам не нужно самостоятельно размещаться, и вы хотите быстро начать. + +### [Начать работу с Budibase](https://budibase.com) + + +

+ +## 🎓 Изучение Budibase + +Документация Budibase [здесь](https://docs.budibase.com). +
+ + +

+ +## 💬 Сообщество + +Если у вас есть вопросы или вы хотите обсудить что-то с другими пользователями Budibase и присоединиться к нашему сообществу, пожалуйста, перейдите по следующей ссылке: [Обсуждения на GitHub](https://github.com/Budibase/budibase/discussions) + +


+ + +## ❗ Кодекс поведения + +Budibase обязуется обеспечить каждому дружелюбный, разнообразный и безопасный опыт. Мы ожидаем, что все члены сообщества Budibase будут следовать принципам нашего [**Кодекса поведения**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md). Спасибо за внимание. +
+ + +

+ + +## 🙌 Вклад в 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) + +

+ + +## 📝 Лицензия + +Budibase является проектом с открытым исходным кодом, лицензированным по [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). Клиентские библиотеки и компоненты лицензируются по [MPL](https://directory.fsf.org/wiki/License:MPL-2.0), так что приложения, которые вы создаете, могут использоваться под любой лицензией, как вам угодно. + +

+ +## ⭐ Старгейзеры во времени + +[![Stargazers во времени](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase) + +Если у вас возникли проблемы между обновлениями билдера, пожалуйста, используйте следующее руководство [здесь](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting), чтобы очистить ваше окружение. + +

+ +## Участники ✨ + +Благодарим этих замечательных людей ([ключи эмодзи](https://allcontributors.org/docs/ru/emoji-key)): + + + + + + + + + + + + + + + + + + + + + + + +

Martin McKeaveney

💻 📖 ⚠️ 🚇

Michael Drury

📖 💻 ⚠️ 🚇

Andrew Kingston

📖 💻 ⚠️ 🎨

Michael Shanks

📖 💻 ⚠️

Kevin Åberg Kultalahti

📖 💻 ⚠️

Joe

📓

Nico Kleynhans

🎨

Keith Lee

🎨

Ben-Shabs

📓

Reece King

🎨

Gunjan Chhabra

📓

Stavros Liaskos

🎨

theshu8

📓

Kleebster

📓
+ + + + +

diff --git a/lerna.json b/lerna.json index e8bcd4429c..1e49def198 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.23.10", + "version": "2.26.2", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/package.json b/package.json index e60a086e17..98524e0ee4 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "yargs": "^17.7.2" }, "scripts": { - "preinstall": "node scripts/syncProPackage.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", "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:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream dev:built", "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:prettier": "prettier --check \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\"", "lint": "yarn run lint:eslint && yarn run lint:prettier", @@ -107,6 +107,7 @@ "@budibase/shared-core": "0.0.0", "@budibase/string-templates": "0.0.0", "@budibase/types": "0.0.0", + "@budibase/pro": "npm:@budibase/pro@latest", "tough-cookie": "4.1.3", "node-fetch": "2.6.7", "semver": "7.5.3", diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index 030fec8728..ff35ccee22 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -73,7 +73,6 @@ "chance": "1.1.8", "ioredis-mock": "8.9.0", "jest": "29.7.0", - "jest-environment-node": "29.7.0", "jest-serial-runner": "1.2.1", "pino-pretty": "10.0.0", "pouchdb-adapter-memory": "7.2.2", diff --git a/packages/backend-core/src/cache/user.ts b/packages/backend-core/src/cache/user.ts index ecfa20f99e..d319c5dcb6 100644 --- a/packages/backend-core/src/cache/user.ts +++ b/packages/backend-core/src/cache/user.ts @@ -69,7 +69,7 @@ async function populateUsersFromDB( export async function getUser( userId: string, tenantId?: string, - populateUser?: any + populateUser?: (userId: string, tenantId: string) => Promise ) { if (!populateUser) { populateUser = populateFromDB @@ -83,7 +83,7 @@ export async function getUser( } const client = await redis.getUserClient() // try cache - let user = await client.get(userId) + let user: User = await client.get(userId) if (!user) { user = await populateUser(userId, tenantId) await client.store(userId, user, EXPIRY_SECONDS) diff --git a/packages/backend-core/src/context/mainContext.ts b/packages/backend-core/src/context/mainContext.ts index 6cea7efeba..4beb02c9c7 100644 --- a/packages/backend-core/src/context/mainContext.ts +++ b/packages/backend-core/src/context/mainContext.ts @@ -281,7 +281,7 @@ export function doInScimContext(task: any) { return newContext(updates, task) } -export async function ensureSnippetContext() { +export async function ensureSnippetContext(enabled = !env.isTest()) { const ctx = getCurrentContext() // 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 let snippets: Snippet[] | undefined const db = getAppDB() - if (db && !env.isTest()) { + if (db && enabled) { const app = await db.get(DocumentType.APP_METADATA) snippets = app.snippets } diff --git a/packages/backend-core/src/db/couch/DatabaseImpl.ts b/packages/backend-core/src/db/couch/DatabaseImpl.ts index d220d0a8ac..ef351f7d4d 100644 --- a/packages/backend-core/src/db/couch/DatabaseImpl.ts +++ b/packages/backend-core/src/db/couch/DatabaseImpl.ts @@ -3,11 +3,11 @@ import { AllDocsResponse, AnyDocument, Database, - DatabaseOpts, - DatabaseQueryOpts, - DatabasePutOpts, DatabaseCreateIndexOpts, DatabaseDeleteIndexOpts, + DatabaseOpts, + DatabasePutOpts, + DatabaseQueryOpts, Document, isDocument, RowResponse, @@ -17,7 +17,7 @@ import { import { getCouchInfo } from "./connections" import { directCouchUrlCall } from "./utils" import { getPouchDB } from "./pouchDB" -import { WriteStream, ReadStream } from "fs" +import { ReadStream, WriteStream } from "fs" import { newid } from "../../docIds/newid" import { SQLITE_DESIGN_DOC_ID } from "../../constants" import { DDInstrumentedDatabase } from "../instrumentation" @@ -38,6 +38,39 @@ function buildNano(couchInfo: { url: string; cookie: string }) { type DBCall = () => Promise +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( dbName: string, connection: string, @@ -119,7 +152,7 @@ export class DatabaseImpl implements Database { } catch (err: any) { // Handling race conditions 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) { await this.checkAndCreateDb() 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) { return } else { - throw { ...err, status: err.statusCode } + throw new CouchDBError(err.message, err) } } } diff --git a/packages/backend-core/src/db/lucene.ts b/packages/backend-core/src/db/lucene.ts index d9dddd0097..f5ad7e6433 100644 --- a/packages/backend-core/src/db/lucene.ts +++ b/packages/backend-core/src/db/lucene.ts @@ -12,6 +12,10 @@ import { dataFilters } from "@budibase/shared-core" export const removeKeyNumbering = dataFilters.removeKeyNumbering +function isEmpty(value: any) { + return value == null || value === "" +} + /** * Class to build lucene query URLs. * Optionally takes a base lucene query object. @@ -282,15 +286,14 @@ export class QueryBuilder { } const equal = (key: string, value: any) => { - // 0 evaluates to false, which means we would return all rows if we don't check it - if (!value && value !== 0) { + if (isEmpty(value)) { return null } return `${key}:${builder.preprocess(value, allPreProcessingOpts)}` } const contains = (key: string, value: any, mode = "AND") => { - if (!value || (Array.isArray(value) && value.length === 0)) { + if (isEmpty(value)) { return null } if (!Array.isArray(value)) { @@ -306,7 +309,7 @@ export class QueryBuilder { } const fuzzy = (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } value = builder.preprocess(value, { @@ -328,7 +331,7 @@ export class QueryBuilder { } const oneOf = (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return `*:*` } if (!Array.isArray(value)) { @@ -386,7 +389,7 @@ export class QueryBuilder { // Construct the actual lucene search query string from JSON structure if (this.#query.string) { build(this.#query.string, (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } value = builder.preprocess(value, { @@ -399,7 +402,7 @@ export class QueryBuilder { } if (this.#query.range) { build(this.#query.range, (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } if (value.low == null || value.low === "") { @@ -421,7 +424,7 @@ export class QueryBuilder { } if (this.#query.notEqual) { build(this.#query.notEqual, (key: string, value: any) => { - if (!value) { + if (isEmpty(value)) { return null } if (typeof value === "boolean") { @@ -431,10 +434,28 @@ export class QueryBuilder { }) } 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) { - 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) { build(this.#query.oneOf, oneOf) diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index 8dbc904643..9ade81b9d7 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -29,6 +29,7 @@ const DefaultBucketName = { TEMPLATES: "templates", GLOBAL: "global", PLUGINS: "plugins", + TEMP: "tmp-file-attachments", } const selfHosted = !!parseInt(process.env.SELF_HOSTED || "") @@ -146,6 +147,7 @@ const environment = { process.env.GLOBAL_BUCKET_NAME || DefaultBucketName.GLOBAL, PLUGIN_BUCKET_NAME: process.env.PLUGIN_BUCKET_NAME || DefaultBucketName.PLUGINS, + TEMP_BUCKET_NAME: process.env.TEMP_BUCKET_NAME || DefaultBucketName.TEMP, USE_COUCH: process.env.USE_COUCH || true, MOCK_REDIS: process.env.MOCK_REDIS, DEFAULT_LICENSE: process.env.DEFAULT_LICENSE, diff --git a/packages/backend-core/src/middleware/authenticated.ts b/packages/backend-core/src/middleware/authenticated.ts index 69dba27c43..51cd4ec2af 100644 --- a/packages/backend-core/src/middleware/authenticated.ts +++ b/packages/backend-core/src/middleware/authenticated.ts @@ -13,7 +13,7 @@ import { getGlobalDB, doInTenant } from "../context" import { decrypt } from "../security/encryption" import * as identity from "../context/identity" 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 tracer from "dd-trace" @@ -41,7 +41,10 @@ function finalise(ctx: any, opts: FinaliseOpts = {}) { ctx.version = opts.version } -async function checkApiKey(apiKey: string, populateUser?: Function) { +async function checkApiKey( + apiKey: string, + populateUser?: (userId: string, tenantId: string) => Promise +) { // check both the primary and the fallback internal api keys // this allows for rotation if (isValidInternalAPIKey(apiKey)) { @@ -128,6 +131,7 @@ export default function ( } else { user = await getUser(userId, session.tenantId) } + // @ts-ignore user.csrfToken = session.csrfToken if (session?.lastAccessedAt < timeMinusOneMinute()) { @@ -167,19 +171,25 @@ export default function ( authenticated = false } - if (user) { + const isUser = ( + user: any + ): user is User & { budibaseAccess?: string } => { + return user && user.email + } + + if (isUser(user)) { tracer.setUser({ - id: user?._id, - tenantId: user?.tenantId, - budibaseAccess: user?.budibaseAccess, - status: user?.status, + id: user._id!, + tenantId: user.tenantId, + budibaseAccess: user.budibaseAccess, + status: user.status, }) } // isAuthenticated is a function, so use a variable to be able to check authed state finalise(ctx, { authenticated, user, internal, version, publicEndpoint }) - if (user && user.email) { + if (isUser(user)) { return identity.doInUserContext(user, ctx, next) } else { return next() diff --git a/packages/backend-core/src/objectStore/objectStore.ts b/packages/backend-core/src/objectStore/objectStore.ts index 8d18fb97fd..0ac2c35179 100644 --- a/packages/backend-core/src/objectStore/objectStore.ts +++ b/packages/backend-core/src/objectStore/objectStore.ts @@ -7,31 +7,47 @@ import tar from "tar-fs" import zlib from "zlib" import { promisify } from "util" import { join } from "path" -import fs, { ReadStream } from "fs" +import fs, { PathLike, ReadStream } from "fs" import env from "../environment" -import { budibaseTempDir } from "./utils" +import { bucketTTLConfig, budibaseTempDir } from "./utils" import { v4 } from "uuid" 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) // use this as a temporary store of buckets that are being created const STATE = { bucketCreationPromises: {}, } +export const SIGNED_FILE_PREFIX = "/files/signed" type ListParams = { ContinuationToken?: string } -type UploadParams = { +type BaseUploadParams = { bucket: string filename: string - path: string type?: string | null - // can be undefined, we will remove it - metadata?: { - [key: string]: string | undefined - } + metadata?: { [key: string]: string | undefined } + body?: ReadableStream | Buffer + ttl?: number + addTTL?: boolean + extra?: any +} + +type UploadParams = BaseUploadParams & { + path?: string | PathLike +} + +export type StreamTypes = + | ReadStream + | NodeJS.ReadableStream + | ReadableStream + +export type StreamUploadParams = BaseUploadParams & { + stream?: StreamTypes } const CONTENT_TYPE_MAP: any = { @@ -41,6 +57,8 @@ const CONTENT_TYPE_MAP: any = { js: "application/javascript", json: "application/json", gz: "application/gzip", + svg: "image/svg+xml", + form: "multipart/form-data", } const STRING_CONTENT_TYPES = [ @@ -71,7 +89,7 @@ export function ObjectStore( bucket: string, opts: { presigning: boolean } = { presigning: false } ) { - const config: any = { + const config: AWS.S3.ClientConfiguration = { s3ForcePathStyle: true, signatureVersion: "v4", 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, * 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) try { await client @@ -113,15 +134,16 @@ export async function makeSureBucketExists(client: any, bucketName: string) { Bucket: bucketName, }) .promise() + return { created: false, exists: true } } catch (err: any) { const promises: any = STATE.bucketCreationPromises const doesntExist = err.statusCode === 404, noAccess = err.statusCode === 403 if (promises[bucketName]) { await promises[bucketName] + return { created: false, exists: true } } else if (doesntExist || noAccess) { if (doesntExist) { - // bucket doesn't exist create it promises[bucketName] = client .createBucket({ Bucket: bucketName, @@ -129,13 +151,15 @@ export async function makeSureBucketExists(client: any, bucketName: string) { .promise() await promises[bucketName] delete promises[bucketName] + return { created: true, exists: false } + } else { + throw new Error("Access denied to object store bucket." + err) } } else { throw new Error("Unable to write to object store bucket.") } } } - /** * Uploads the contents of a file given the required parameters, useful when * temp files in use (for example file uploaded as an attachment). @@ -146,12 +170,20 @@ export async function upload({ path, type, metadata, + body, + ttl, }: UploadParams) { const extension = filename.split(".").pop() - const fileBytes = fs.readFileSync(path) + + const fileBytes = path ? (await fsp.open(path)).createReadStream() : body 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 if (!contentType) { @@ -174,6 +206,7 @@ export async function upload({ } config.Metadata = metadata } + 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 * through to the object store. */ -export async function streamUpload( - bucketName: string, - filename: string, - stream: ReadStream | ReadableStream, - extra = {} -) { +export async function streamUpload({ + bucket: bucketName, + stream, + filename, + type, + extra, + ttl, +}: StreamUploadParams) { + const extension = filename.split(".").pop() 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 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 = { Bucket: sanitizeBucket(bucketName), Key: sanitizeKey(filename), Body: stream, + ContentType: contentType, ...extra, } return objectStore.upload(params).promise() @@ -286,7 +335,7 @@ export function getPresignedUrl( const signedUrl = new URL(url) const path = signedUrl.pathname 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) { const objectStore = ObjectStore(bucketName) - await makeSureBucketExists(objectStore, bucketName) + await createBucketIfNotExists(objectStore, bucketName) const params = { Bucket: bucketName, Key: sanitizeKey(filepath), @@ -351,7 +400,7 @@ export async function deleteFile(bucketName: string, filepath: string) { export async function deleteFiles(bucketName: string, filepaths: string[]) { const objectStore = ObjectStore(bucketName) - await makeSureBucketExists(objectStore, bucketName) + await createBucketIfNotExists(objectStore, bucketName) const params = { Bucket: bucketName, Delete: { @@ -412,7 +461,13 @@ export async function uploadDirectory( if (file.isDirectory()) { uploads.push(uploadDirectory(bucketName, local, path)) } else { - uploads.push(streamUpload(bucketName, path, fs.createReadStream(local))) + uploads.push( + streamUpload({ + bucket: bucketName, + filename: path, + stream: fs.createReadStream(local), + }) + ) } } await Promise.all(uploads) @@ -467,3 +522,45 @@ export async function getReadStream( } return client.getObject(params).createReadStream() } + +export async function getObjectMetadata( + bucket: string, + path: string +): Promise { + 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}/(?[^/]+)/(?.+)$` + ) + const match = baseUrl.match(regex) + + if (match && match.groups) { + const { bucket, path } = match.groups + return { bucket, path } + } + + return null +} diff --git a/packages/backend-core/src/objectStore/utils.ts b/packages/backend-core/src/objectStore/utils.ts index 4c3a84ba91..30c2fefbf1 100644 --- a/packages/backend-core/src/objectStore/utils.ts +++ b/packages/backend-core/src/objectStore/utils.ts @@ -1,7 +1,16 @@ -import { join } from "path" +import path, { join } from "path" import { tmpdir } from "os" import fs from "fs" 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 * @@ -15,6 +24,7 @@ export const ObjectStoreBuckets = { TEMPLATES: env.TEMPLATES_BUCKET_NAME, GLOBAL: env.GLOBAL_BUCKET_NAME, PLUGINS: env.PLUGIN_BUCKET_NAME, + TEMP: env.TEMP_BUCKET_NAME, } const bbTmp = join(tmpdir(), ".budibase") @@ -29,3 +39,75 @@ try { export function budibaseTempDir() { 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 { + 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 { + 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 { + const isFullyFormedUrl = + attachment.url?.startsWith("http://") || + attachment.url?.startsWith("https://") + if (isFullyFormedUrl) { + return await processUrlAttachment(attachment) + } else { + return await processObjectStoreAttachment(attachment) + } +} diff --git a/packages/backend-core/tests/core/utilities/testContainerUtils.ts b/packages/backend-core/tests/core/utilities/testContainerUtils.ts index d0dd2c9b4d..1a25bb28f4 100644 --- a/packages/backend-core/tests/core/utilities/testContainerUtils.ts +++ b/packages/backend-core/tests/core/utilities/testContainerUtils.ts @@ -28,7 +28,11 @@ function getTestcontainers(): ContainerInfo[] { .split("\n") .filter(x => x.length > 0) .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) { @@ -82,10 +86,18 @@ export function setupEnv(...envs: any[]) { 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 = [ { key: "COUCH_DB_PORT", value: `${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: "MINIO_URL", value: `http://127.0.0.1:${minioPort}` }, ] for (const config of configs.filter(x => !!x.value)) { diff --git a/packages/bbui/package.json b/packages/bbui/package.json index a1baa2a38b..1b3510cf3b 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -43,6 +43,7 @@ "@spectrum-css/avatar": "3.0.2", "@spectrum-css/button": "3.0.1", "@spectrum-css/buttongroup": "3.0.2", + "@spectrum-css/calendar": "3.2.7", "@spectrum-css/checkbox": "3.0.2", "@spectrum-css/dialog": "3.0.1", "@spectrum-css/divider": "1.0.3", @@ -82,7 +83,6 @@ "dayjs": "^1.10.8", "easymde": "^2.16.1", "svelte-dnd-action": "^0.9.8", - "svelte-flatpickr": "3.2.3", "svelte-portal": "^1.0.0" }, "resolutions": { diff --git a/packages/bbui/src/ActionMenu/ActionMenu.svelte b/packages/bbui/src/ActionMenu/ActionMenu.svelte index c55d1cb43d..75ddd679da 100644 --- a/packages/bbui/src/ActionMenu/ActionMenu.svelte +++ b/packages/bbui/src/ActionMenu/ActionMenu.svelte @@ -38,7 +38,15 @@
- + diff --git a/packages/bbui/src/Actions/click_outside.js b/packages/bbui/src/Actions/click_outside.js index eafca657f3..124f43ff04 100644 --- a/packages/bbui/src/Actions/click_outside.js +++ b/packages/bbui/src/Actions/click_outside.js @@ -1,13 +1,24 @@ +// These class names will never trigger a callback if clicked, no matter what const ignoredClasses = [ - ".flatpickr-calendar", - ".spectrum-Popover", ".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 candidateTarget -/** - * Handle a body click event - */ +// Processes a "click outside" event and invokes callbacks if our source element +// is valid const handleClick = event => { // Ignore click if this is an ignored class if (event.target.closest('[data-ignore-click-outside="true"]')) { @@ -21,41 +32,60 @@ const handleClick = event => { // Process handlers clickHandlers.forEach(handler => { + // Check that the click isn't inside the target if (handler.element.contains(event.target)) { return } - // Ignore clicks for modals, unless the handler is registered from a modal - const sourceInModal = handler.anchor.closest(".spectrum-Underlay") != null - const clickInModal = event.target.closest(".spectrum-Underlay") != null - if (clickInModal && !sourceInModal) { - 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 + // Ignore clicks for certain classes unless we're nested inside them + for (let className of conditionallyIgnoredClasses) { + const sourceInside = handler.anchor.closest(className) != null + const clickInside = event.target.closest(className) != null + if (clickInside && !sourceInside) { + return + } } 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 */ -const updateHandler = (id, element, anchor, callback, allowedType) => { +const updateHandler = (id, element, anchor, callback) => { let existingHandler = clickHandlers.find(x => x.id === id) if (!existingHandler) { - clickHandlers.push({ id, element, anchor, callback, allowedType }) + clickHandlers.push({ id, element, anchor, callback }) } else { existingHandler.callback = callback } @@ -82,8 +112,7 @@ export default (element, opts) => { const callback = newOpts?.callback || (typeof newOpts === "function" ? newOpts : null) const anchor = newOpts?.anchor || element - const allowedType = newOpts?.allowedType || "click" - updateHandler(id, element, anchor, callback, allowedType) + updateHandler(id, element, anchor, callback) } update(opts) return { diff --git a/packages/bbui/src/Actions/position_dropdown.js b/packages/bbui/src/Actions/position_dropdown.js index 770d1bd507..6c4fcab757 100644 --- a/packages/bbui/src/Actions/position_dropdown.js +++ b/packages/bbui/src/Actions/position_dropdown.js @@ -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) { let resizeObserver let latestOpts = opts @@ -19,6 +38,8 @@ export default function positionDropdown(element, opts) { useAnchorWidth, offset = 5, customUpdate, + resizable, + wrap, } = opts if (!anchor) { return @@ -27,56 +48,159 @@ export default function positionDropdown(element, opts) { // Compute bounds const anchorBounds = anchor.getBoundingClientRect() const elementBounds = element.getBoundingClientRect() + const winWidth = window.innerWidth + const winHeight = window.innerHeight + const screenOffset = 8 let styles = { - maxHeight: null, - minWidth, - maxWidth, + maxHeight, + minWidth: useAnchorWidth ? anchorBounds.width : minWidth, + maxWidth: useAnchorWidth ? anchorBounds.width : maxWidth, left: null, top: null, } + // Ignore all our logic for custom logic if (typeof customUpdate === "function") { styles = customUpdate(anchorBounds, elementBounds, { ...styles, offset: opts.offset, }) - } else { - // Determine vertical styles - if (align === "right-outside" || align === "left-outside") { - styles.top = - anchorBounds.top + anchorBounds.height / 2 - elementBounds.height / 2 - styles.maxHeight = maxHeight - if (styles.top + elementBounds.height > window.innerHeight) { - styles.top = window.innerHeight - elementBounds.height - } - } else if ( - window.innerHeight - anchorBounds.bottom < - (maxHeight || 100) - ) { - 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 + } + + // Otherwise position ourselves as normal + else { + // Checks if we overflow off the screen. We only report that we overflow + // when the alternative dimension is larger than the one we are checking. + const doesXOverflow = () => { + const overflows = styles.left + elementBounds.width > winWidth + return overflows && anchorBounds.left > winWidth - anchorBounds.right + } + const doesYOverflow = () => { + const overflows = styles.top + elementBounds.height > winHeight + return overflows && anchorBounds.top > winHeight - anchorBounds.bottom } - // Determine horizontal styles - if (!maxWidth && useAnchorWidth) { - styles.maxWidth = anchorBounds.width + // Applies a dynamic max height constraint if appropriate + const applyMaxHeight = height => { + 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") { - styles.left = - anchorBounds.left + anchorBounds.width - elementBounds.width + applyXStrategy(Strategies.EndToEnd) } else if (align === "right-outside") { - styles.left = anchorBounds.right + offset + applyXStrategy(Strategies.StartToEnd) } else if (align === "left-outside") { - styles.left = anchorBounds.left - elementBounds.width - offset + applyXStrategy(Strategies.EndToStart) } 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) + } + } } } diff --git a/packages/bbui/src/Banner/Banner.svelte b/packages/bbui/src/Banner/Banner.svelte index a04d469cc7..2ce9795d70 100644 --- a/packages/bbui/src/Banner/Banner.svelte +++ b/packages/bbui/src/Banner/Banner.svelte @@ -8,6 +8,8 @@ export let size = "S" export let extraButtonText export let extraButtonAction + export let extraLinkText + export let extraLinkAction export let showCloseButton = true let show = true @@ -28,8 +30,13 @@
-
+
+ {#if extraLinkText} + + {/if}
{#if extraButtonText && extraButtonAction} -
- -{/key} -{#if open} - -
-{/if} - - diff --git a/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte b/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte new file mode 100644 index 0000000000..d7056db6d6 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/Calendar.svelte @@ -0,0 +1,252 @@ + + +
+
+
+
+ +
+ {#if !disabled && !readonly} + + {/if} +
+ + diff --git a/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte new file mode 100644 index 0000000000..f5189c9edd --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/DatePicker.svelte @@ -0,0 +1,83 @@ + + + + + + {#if isOpen} + + {/if} + diff --git a/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte b/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte new file mode 100644 index 0000000000..f1bb809b29 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/DatePickerPopoverContents.svelte @@ -0,0 +1,102 @@ + + +
+ {#if showCalendar} + handleChange(e.detail)} + bind:this={calendar} + /> + {/if} + +
+ + diff --git a/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte new file mode 100644 index 0000000000..dc4886d28d --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/NumberInput.svelte @@ -0,0 +1,54 @@ + + + + + diff --git a/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte new file mode 100644 index 0000000000..047e5a4f08 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/TimePicker.svelte @@ -0,0 +1,59 @@ + + +
+ + : + +
+ + diff --git a/packages/bbui/src/Form/Core/DatePicker/utils.js b/packages/bbui/src/Form/Core/DatePicker/utils.js new file mode 100644 index 0000000000..953c3eb6c0 --- /dev/null +++ b/packages/bbui/src/Form/Core/DatePicker/utils.js @@ -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 + } + } +} diff --git a/packages/bbui/src/Form/Core/DateRangePicker.svelte b/packages/bbui/src/Form/Core/DateRangePicker.svelte new file mode 100644 index 0000000000..9084942ba7 --- /dev/null +++ b/packages/bbui/src/Form/Core/DateRangePicker.svelte @@ -0,0 +1,69 @@ + + +
+ (fromDate = e.detail)} + enableTime={false} + /> +
+ +
+ (toDate = e.detail)} + enableTime={false} + /> +
+ + diff --git a/packages/bbui/src/Form/Core/Picker.svelte b/packages/bbui/src/Form/Core/Picker.svelte index ef514a8a8b..c6d4ed3353 100644 --- a/packages/bbui/src/Form/Core/Picker.svelte +++ b/packages/bbui/src/Form/Core/Picker.svelte @@ -157,6 +157,7 @@ useAnchorWidth={!autoWidth} maxWidth={autoWidth ? 400 : null} customHeight={customPopoverHeight} + maxHeight={240} >
import Field from "./Field.svelte" - import DatePicker from "./Core/DatePicker.svelte" + import DatePicker from "./Core/DatePicker/DatePicker.svelte" import { createEventDispatcher } from "svelte" export let value = null @@ -11,22 +11,15 @@ export let error = null export let enableTime = true export let timeOnly = false - export let time24hr = false export let placeholder = null export let appendTo = undefined export let ignoreTimezones = false - export let range = false export let helpText = null + const dispatch = createEventDispatcher() const onChange = e => { - if (range) { - // 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 - } + value = e.detail dispatch("change", e.detail) } @@ -40,10 +33,8 @@ {placeholder} {enableTime} {timeOnly} - {time24hr} {appendTo} {ignoreTimezones} - {range} on:change={onChange} /> diff --git a/packages/bbui/src/Form/DateRangePicker.svelte b/packages/bbui/src/Form/DateRangePicker.svelte new file mode 100644 index 0000000000..39c2acb96a --- /dev/null +++ b/packages/bbui/src/Form/DateRangePicker.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/bbui/src/Layout/Page.svelte b/packages/bbui/src/Layout/Page.svelte index 2169a12459..62dd9cc909 100644 --- a/packages/bbui/src/Layout/Page.svelte +++ b/packages/bbui/src/Layout/Page.svelte @@ -7,11 +7,11 @@ export let narrower = false export let noPadding = false - let sidePanelVisble = false + let sidePanelVisible = false setContext("side-panel", { - open: () => (sidePanelVisble = true), - close: () => (sidePanelVisble = false), + open: () => (sidePanelVisible = true), + close: () => (sidePanelVisible = false), }) @@ -24,9 +24,9 @@
{ - sidePanelVisble = false + sidePanelVisible = false }} > diff --git a/packages/bbui/src/Popover/Popover.svelte b/packages/bbui/src/Popover/Popover.svelte index c51af48300..aa811afe1e 100644 --- a/packages/bbui/src/Popover/Popover.svelte +++ b/packages/bbui/src/Popover/Popover.svelte @@ -18,13 +18,15 @@ export let open = false export let useAnchorWidth = false export let dismissible = true - export let offset = 5 + export let offset = 4 export let customHeight export let animate = true export let customZindex export let handlePostionUpdate export let showPopover = true export let clickOutsideOverride = false + export let resizable = true + export let wrap = false $: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum" @@ -91,6 +93,8 @@ useAnchorWidth, offset, customUpdate: handlePostionUpdate, + resizable, + wrap, }} use:clickOutside={{ callback: dismissible ? handleOutsideClick : () => {}, @@ -116,12 +120,11 @@ min-width: var(--spectrum-global-dimension-size-2000); border-color: var(--spectrum-global-color-gray-300); overflow: auto; - transition: opacity 260ms ease-out, transform 260ms ease-out; + transition: opacity 260ms ease-out; } .hidden { opacity: 0; pointer-events: none; - transform: translateY(-20px); } .customZindex { z-index: var(--customZindex) !important; diff --git a/packages/bbui/src/helpers.js b/packages/bbui/src/helpers.js index abb7c9a8aa..90b447f3c1 100644 --- a/packages/bbui/src/helpers.js +++ b/packages/bbui/src/helpers.js @@ -1,4 +1,5 @@ import { helpers } from "@budibase/shared-core" +import dayjs from "dayjs" 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`) + } +} diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js index 174d500c87..e746e3a72b 100644 --- a/packages/bbui/src/index.js +++ b/packages/bbui/src/index.js @@ -3,13 +3,34 @@ import "./bbui.css" // Spectrum icons import "@spectrum-css/icon/dist/index-vars.css" -// Components +// Form components export { default as Input } from "./Form/Input.svelte" export { default as Stepper } from "./Form/Stepper.svelte" export { default as TextArea } from "./Form/TextArea.svelte" export { default as Select } from "./Form/Select.svelte" export { default as Combobox } from "./Form/Combobox.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 DrawerContent } from "./Drawer/DrawerContent.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 Icon } from "./Icon/Icon.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 Popover } from "./Popover/Popover.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 Tooltip } from "./Tooltip/Tooltip.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 ContextTooltip } from "./Tooltip/Context.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 SideNavigation } from "./SideNavigation/Navigation.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 Table } from "./Table/Table.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 TreeItem } from "./TreeView/Item.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 Badge } from "./Badge/Badge.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 MarkdownEditor } from "./Markdown/MarkdownEditor.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 ListItem } from "./List/ListItem.svelte" export { default as IconSideNav } from "./IconSideNav/IconSideNav.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 File } from "./Form/File.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 export { default as BoldRenderer } from "./Table/BoldRenderer.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 Code } from "./Typography/Code.svelte" -// Core form components to be used elsewhere (standard components) -export * from "./Form/Core" - // Actions export { default as autoResizeTextArea } from "./Actions/autoresize_textarea" export { default as positionDropdown } from "./Actions/position_dropdown" @@ -111,6 +115,3 @@ export { banner, BANNER_TYPES } from "./Stores/banner" // Helpers export * as Helpers from "./helpers" - -// Fancy form components -export * from "./FancyForm" diff --git a/packages/builder/assets/FreeTrial.svelte b/packages/builder/assets/FreeTrial.svelte new file mode 100644 index 0000000000..79a722d90c --- /dev/null +++ b/packages/builder/assets/FreeTrial.svelte @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + diff --git a/packages/builder/package.json b/packages/builder/package.json index f29ae3f7f2..a00936bdca 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -93,7 +93,6 @@ "identity-obj-proxy": "^3.0.0", "jest": "29.7.0", "jsdom": "^21.1.1", - "ncp": "^2.0.0", "svelte-jester": "^1.3.2", "vite": "^4.5.0", "vite-plugin-static-copy": "^0.17.0", diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 6434c7710d..879927343f 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -32,6 +32,7 @@ import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.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 { bindingsToCompletions, @@ -47,6 +48,7 @@ import { TriggerStepID, ActionStepID } from "constants/backend/automations" import { onMount } from "svelte" import { cloneDeep } from "lodash/fp" + import { FIELDS } from "constants/backend" export let block export let testData @@ -227,6 +229,10 @@ categoryName, bindingName ) => { + const field = Object.values(FIELDS).find( + field => field.type === value.type && field.subtype === value.subtype + ) + return { readableBinding: bindingName ? `${bindingName}.${name}` @@ -237,7 +243,7 @@ icon, category: categoryName, display: { - type: value.type, + type: field?.name || value.type, name, rank: isLoopBlock ? idx + 1 : idx - loopBlockCount, }, @@ -281,6 +287,7 @@ for (const key in table?.schema) { schema[key] = { type: table.schema[key].type, + subtype: table.schema[key].subtype, } } // remove the original binding @@ -356,7 +363,9 @@ value.customType !== "queryParams" && value.customType !== "cron" && 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}` } + function handleAttachmentParams(keyValueObj) { + let params = {} + if (keyValueObj?.length) { + for (let param of keyValueObj) { + params[param.url] = param.filename + } + } + return params + } + onMount(async () => { try { await environment.loadVariables() @@ -437,6 +456,33 @@ value={inputData[key]} options={Object.keys(table?.schema || {})} /> + {:else if value.type === "attachment"} +
+
+ +
+
+ + 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"} + /> +
+
{:else if value.customType === "filters"} Define filters @@ -651,14 +697,22 @@ } .block-field { - display: flex; /* Use Flexbox */ + display: flex; justify-content: space-between; - flex-direction: row; /* Arrange label and field side by side */ - align-items: center; /* Align vertically in the center */ - gap: 10px; /* Add some space between label and field */ + flex-direction: row; + align-items: center; + gap: 10px; flex: 1; } + .attachment-field-width { + margin-top: var(--spacing-xs); + } + + .label-wrapper { + margin-top: var(--spacing-s); + } + .test :global(.drawer) { width: 10000px !important; } diff --git a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte index 0d15df6c87..ab020aad08 100644 --- a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte +++ b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte @@ -2,6 +2,8 @@ import { tables } from "stores/builder" import { Select, Checkbox, Label } from "@budibase/bbui" import { createEventDispatcher } from "svelte" + import { FieldType } from "@budibase/types" + import RowSelectorTypes from "./RowSelectorTypes.svelte" import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" @@ -14,7 +16,6 @@ export let bindings export let isTestModal export let isUpdateRow - $: parsedBindings = bindings.map(binding => { let clone = Object.assign({}, binding) clone.icon = "ShareAndroid" @@ -26,15 +27,19 @@ $: { 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 - schemaFields.map(([, schema]) => { + + // Just sorting attachment types to the bottom here for a cleaner UX + schemaFields = Object.entries(table?.schema ?? {}).sort( + ([, schemaA], [, schemaB]) => + (schemaA.type === "attachment") - (schemaB.type === "attachment") + ) + + schemaFields.forEach(([, schema]) => { if (!schema.autocolumn && !value[schema.name]) { value[schema.name] = "" } }) } - const onChangeTable = e => { value["tableId"] = e.detail dispatch("change", value) @@ -114,10 +119,16 @@
{#if schemaFields.length} {#each schemaFields as [field, schema]} - {#if !schema.autocolumn && schema.type !== "attachment"} -
+ {#if !schema.autocolumn} +
-
+
{#if isTestModal} import { Select, DatePicker, Multiselect, TextArea } from "@budibase/bbui" + import { FieldType } from "@budibase/types" import LinkedRowSelector from "components/common/LinkedRowSelector.svelte" import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte" import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte" import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte" import Editor from "components/integration/QueryEditor.svelte" + import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" export let onChange export let field @@ -22,6 +24,27 @@ function schemaHasOptions(schema) { 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 + } {#if schemaHasOptions(schema) && schema.type !== "array"} @@ -77,6 +100,35 @@ on:change={e => onChange(e, field)} useLabel={false} /> +{:else if schema.type === FieldType.ATTACHMENTS || schema.type === FieldType.ATTACHMENT_SINGLE} +
+ + 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} + /> +
{:else if ["string", "number", "bigint", "barcodeqr", "array"].includes(schema.type)} {/if} + + diff --git a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte index 6a5cd2f282..77229f3a17 100644 --- a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte @@ -106,6 +106,5 @@ display: flex; flex-direction: column; background: var(--background); - overflow: hidden; } diff --git a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte index e3937ab772..decf77069f 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte @@ -1,7 +1,9 @@ - + {text} - - dispatch("change", tempValue)} - > -
- (tempValue = e.detail)} - /> -
-
-
- + + + + (tempValue = e.detail)} + {bindings} + /> + + diff --git a/packages/builder/src/components/backend/DataTable/formula.js b/packages/builder/src/components/backend/DataTable/formula.js index b339729391..c59fb9c536 100644 --- a/packages/builder/src/components/backend/DataTable/formula.js +++ b/packages/builder/src/components/backend/DataTable/formula.js @@ -55,7 +55,7 @@ export function getBindings({ ) } 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}` diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index d271462f3e..622da2173d 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -12,8 +12,13 @@ OptionSelectDnD, Layout, AbsTooltip, + ProgressCircle, } 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 { cloneDeep } from "lodash/fp" import { tables, datasources } from "stores/builder" @@ -29,7 +34,11 @@ import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import { getBindings } from "components/backend/DataTable/formula" 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 { RowUtils } from "@budibase/frontend-core" import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte" @@ -41,8 +50,6 @@ const NUMBER_TYPE = FieldType.NUMBER const JSON_TYPE = FieldType.JSON const DATE_TYPE = FieldType.DATETIME - const USER_TYPE = FieldSubtype.USER - const USERS_TYPE = FieldSubtype.USERS const dispatch = createEventDispatcher() const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"] @@ -65,7 +72,6 @@ let savingColumn let deleteColName let jsonSchemaModal - let allowedTypes = [] let editableColumn = { type: FIELDS.STRING.type, constraints: FIELDS.STRING.constraints, @@ -173,6 +179,11 @@ SWITCHABLE_TYPES[field.type] && !editableColumn?.autocolumn) + $: allowedTypes = getAllowedTypes(datasource).map(t => ({ + fieldId: makeFieldId(t.type, t.subtype), + ...t, + })) + const fieldDefinitions = Object.values(FIELDS).reduce( // Storing the fields by complex field id (acc, field) => ({ @@ -186,7 +197,10 @@ // don't make field IDs for auto types if (type === AUTO_TYPE || autocolumn) { return type.toUpperCase() - } else if (type === FieldType.BB_REFERENCE) { + } else if ( + type === FieldType.BB_REFERENCE || + type === FieldType.BB_REFERENCE_SINGLE + ) { return `${type}${subtype || ""}`.toUpperCase() } else { return type.toUpperCase() @@ -224,11 +238,6 @@ editableColumn.subtype, editableColumn.autocolumn ) - - allowedTypes = getAllowedTypes().map(t => ({ - fieldId: makeFieldId(t.type, t.subtype), - ...t, - })) } } @@ -243,11 +252,11 @@ } async function saveColumn() { - savingColumn = true if (errors?.length) { return } + savingColumn = true let saveColumn = cloneDeep(editableColumn) delete saveColumn.fieldId @@ -262,13 +271,6 @@ if (saveColumn.type !== LINK_TYPE) { 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 { await tables.saveField({ @@ -287,6 +289,8 @@ } } catch (err) { notifications.error(`Error saving column: ${err.message}`) + } finally { + savingColumn = false } } @@ -361,22 +365,36 @@ deleteColName = "" } - function getAllowedTypes() { + function getAllowedTypes(datasource) { if (originalName) { - const possibleTypes = ( - SWITCHABLE_TYPES[field.type] || [editableColumn.type] - ).map(t => t.toLowerCase()) + let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type] + if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) { + // This will handle old single users columns + return [ + { + ...FIELDS.USER, + type: FieldType.BB_REFERENCE, + subtype: BBReferenceFieldSubType.USER, + }, + ] + } else if ( + editableColumn.type === FieldType.BB_REFERENCE && + editableColumn.subtype === BBReferenceFieldSubType.USERS + ) { + // This will handle old multi users columns + return [ + { + ...FIELDS.USERS, + subtype: BBReferenceFieldSubType.USERS, + }, + ] + } + return Object.entries(FIELDS) - .filter(([fieldType]) => - possibleTypes.includes(fieldType.toLowerCase()) - ) + .filter(([_, field]) => possibleTypes.includes(field.type)) .map(([_, fieldDefinition]) => fieldDefinition) } - const isUsers = - editableColumn.type === FieldType.BB_REFERENCE && - editableColumn.subtype === FieldSubtype.USERS - if (!externalTable) { return [ FIELDS.STRING, @@ -393,7 +411,8 @@ FIELDS.LINK, FIELDS.FORMULA, FIELDS.JSON, - isUsers ? FIELDS.USERS : FIELDS.USER, + FIELDS.USER, + FIELDS.USERS, FIELDS.AUTO, ] } else { @@ -407,8 +426,12 @@ FIELDS.BOOLEAN, FIELDS.FORMULA, 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 if (!externalTable || table.sql) { fields = [...fields, FIELDS.LINK, FIELDS.ARRAY] @@ -482,13 +505,6 @@ return newError } - function isUsersColumn(column) { - return ( - column.type === FieldType.BB_REFERENCE && - [FieldSubtype.USER, FieldSubtype.USERS].includes(column.subtype) - ) - } - onMount(() => { mounted = true }) @@ -513,6 +529,7 @@ /> {/if} Delete {/if} - +
diff --git a/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte b/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte index 020c58d19f..2ecb7dfd59 100644 --- a/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/grid/GridEditColumnModal.svelte @@ -13,7 +13,9 @@ onMount(() => subscribe("edit-column", editColumn)) - +{#if editableColumn} + +{/if} diff --git a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte index 6901503071..318f459f46 100644 --- a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte +++ b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte @@ -1,5 +1,5 @@ + +
+ + +
+ + diff --git a/packages/builder/src/components/common/FontAwesomeIcon.svelte b/packages/builder/src/components/common/FontAwesomeIcon.svelte index 16c065cfaa..0e10dfc86c 100644 --- a/packages/builder/src/components/common/FontAwesomeIcon.svelte +++ b/packages/builder/src/components/common/FontAwesomeIcon.svelte @@ -23,6 +23,7 @@ faQuestionCircle, faCircleCheck, faGear, + faRectangleList, } from "@fortawesome/free-solid-svg-icons" import { faGithub, faDiscord } from "@fortawesome/free-brands-svg-icons" @@ -37,6 +38,7 @@ faFileArrowUp, faChevronLeft, faCircleInfo, + faRectangleList, // -- Required for easyMDE use in the builder. faBold, diff --git a/packages/builder/src/components/common/HelpMenu.svelte b/packages/builder/src/components/common/HelpMenu.svelte index 63156676d2..0ce5ab9c8e 100644 --- a/packages/builder/src/components/common/HelpMenu.svelte +++ b/packages/builder/src/components/common/HelpMenu.svelte @@ -4,6 +4,7 @@ import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags" import { licensing } from "stores/portal" import { isPremiumOrAbove } from "helpers/planTitle" + import { ChangelogURL } from "constants" $: premiumOrAboveLicense = isPremiumOrAbove($licensing?.license?.plan?.type) @@ -30,6 +31,13 @@ Help docs
+ +
+ +
+ Changelog +
+
diff --git a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte index 8ce9dda209..fb448cca8d 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableSlot.svelte @@ -4,6 +4,7 @@ readableToRuntimeBinding, runtimeToReadableBinding, } from "dataBinding" + import { FieldType } from "@budibase/types" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import { createEventDispatcher, setContext } from "svelte" @@ -102,6 +103,8 @@ longform: value => !isJSBinding(value), json: value => !isJSBinding(value), boolean: isValidBoolean, + attachment: false, + attachment_single: false, } const isValid = value => { @@ -116,7 +119,16 @@ if (type === "json" && !isJSBinding(value)) { 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 "" @@ -157,7 +169,7 @@ {updateOnChange} /> {/if} - {#if !disabled && type !== "formula"} + {#if !disabled && type !== "formula" && !disabled && type !== FieldType.ATTACHMENTS && !disabled && type !== FieldType.ATTACHMENT_SINGLE}
{ diff --git a/packages/builder/src/components/deploy/VersionModal.svelte b/packages/builder/src/components/deploy/VersionModal.svelte index 316a981325..653aa09fa6 100644 --- a/packages/builder/src/components/deploy/VersionModal.svelte +++ b/packages/builder/src/components/deploy/VersionModal.svelte @@ -7,10 +7,12 @@ Body, Button, StatusLight, + Link, } from "@budibase/bbui" import { appStore, initialise } from "stores/builder" import { API } from "api" import RevertModalVersionSelect from "./RevertModalVersionSelect.svelte" + import { ChangelogURL } from "constants" export function show() { updateModal.show() @@ -106,6 +108,10 @@ latest version available. {/if} + + Find the changelog for the latest release + here + {#if revertAvailable} You can revert this app to version diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte index 096341783d..a857bc7ede 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/ExportData.svelte @@ -49,17 +49,20 @@ }, ] - $: tables = findAllMatchingComponents($selectedScreen?.props, component => - component._component.endsWith("table") - ) - $: tableBlocks = findAllMatchingComponents( + $: components = findAllMatchingComponents( $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 => ({ label: table._instanceName, - value: table._component.includes("tableblock") + value: table._component.endsWith("/tableblock") ? `${table._id}-table` : table._id, })) @@ -69,6 +72,7 @@ $: selectedTable = components.find( component => component._id === selectedTableId ) + $: parameters.rows = `{{ literal [${parameters.tableComponentId}].[selectedRows] }}` onMount(() => { if (!parameters.type) { diff --git a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte index 85d395e4f4..b808733d08 100644 --- a/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte +++ b/packages/builder/src/components/design/settings/controls/ButtonActionEditor/actions/PromptUser.svelte @@ -1,8 +1,10 @@ + + + + + + diff --git a/packages/builder/src/components/portal/licensing/licensingBanners.js b/packages/builder/src/components/portal/licensing/licensingBanners.js index 34558e98e2..34b22c934b 100644 --- a/packages/builder/src/components/portal/licensing/licensingBanners.js +++ b/packages/builder/src/components/portal/licensing/licensingBanners.js @@ -12,7 +12,7 @@ const defaultCacheFn = key => { const upgradeAction = key => { return defaultNavigateAction( key, - "Upgrade Plan", + "Upgrade", `${get(admin).accountPortalUrl}/portal/upgrade` ) } diff --git a/packages/builder/src/components/portal/onboarding/EnterpriseBasicTrialModal.svelte b/packages/builder/src/components/portal/onboarding/EnterpriseBasicTrialModal.svelte new file mode 100644 index 0000000000..6652bd4104 --- /dev/null +++ b/packages/builder/src/components/portal/onboarding/EnterpriseBasicTrialModal.svelte @@ -0,0 +1,66 @@ + + + + { + if (get(auth).user) { + try { + await API.updateSelf({ + freeTrialConfirmedAt: new Date().toISOString(), + }) + // Update the cached user + await auth.getSelf() + } finally { + freeTrialModal.hide() + } + } + }} + > +

Experience all of Budibase with a free 14-day trial

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

+ At the end of your trial, we'll automatically downgrade you to the Free + plan unless you choose to upgrade. +

+
+ +
+
+ + diff --git a/packages/builder/src/constants/backend/index.js b/packages/builder/src/constants/backend/index.js index 84975d93e2..75f6a053b5 100644 --- a/packages/builder/src/constants/backend/index.js +++ b/packages/builder/src/constants/backend/index.js @@ -1,6 +1,6 @@ import { FieldType, - FieldSubtype, + BBReferenceFieldSubType, INTERNAL_TABLE_SOURCE_ID, AutoFieldSubType, Hosting, @@ -159,15 +159,17 @@ export const FIELDS = { }, USER: { name: "User", - type: FieldType.BB_REFERENCE, - subtype: FieldSubtype.USER, - icon: TypeIconMap[FieldType.USER], + type: FieldType.BB_REFERENCE_SINGLE, + subtype: BBReferenceFieldSubType.USER, + icon: TypeIconMap[FieldType.BB_REFERENCE_SINGLE][ + BBReferenceFieldSubType.USER + ], }, USERS: { - name: "Users", + name: "User List", type: FieldType.BB_REFERENCE, - subtype: FieldSubtype.USERS, - icon: TypeIconMap[FieldType.USERS], + subtype: BBReferenceFieldSubType.USER, + icon: TypeIconMap[FieldType.BB_REFERENCE][BBReferenceFieldSubType.USER], constraints: { type: "array", }, @@ -253,6 +255,7 @@ export const SchemaTypeOptions = [ { label: "Number", value: FieldType.NUMBER }, { label: "Boolean", value: FieldType.BOOLEAN }, { label: "Datetime", value: FieldType.DATETIME }, + { label: "JSON", value: FieldType.JSON }, ] export const SchemaTypeOptionsExpanded = SchemaTypeOptions.map(el => ({ diff --git a/packages/builder/src/constants/index.js b/packages/builder/src/constants/index.js index f556ee4b05..44c71f2e3b 100644 --- a/packages/builder/src/constants/index.js +++ b/packages/builder/src/constants/index.js @@ -70,3 +70,5 @@ export const PlanModel = { PER_USER: "perUser", DAY_PASS: "dayPass", } + +export const ChangelogURL = "https://docs.budibase.com/changelog" diff --git a/packages/builder/src/dataBinding.js b/packages/builder/src/dataBinding.js index 5efbb79611..af229ce7e4 100644 --- a/packages/builder/src/dataBinding.js +++ b/packages/builder/src/dataBinding.js @@ -29,6 +29,7 @@ import { JSONUtils, Constants } from "@budibase/frontend-core" import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json" import { environment, licensing } from "stores/portal" import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils" +import { FIELDS } from "constants/backend" const { ContextScopes } = Constants @@ -491,7 +492,7 @@ const generateComponentContextBindings = (asset, componentContext) => { icon: bindingCategory.icon, display: { 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 let fixedSchema = {} 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") { fixedSchema[fieldName] = { type: fieldSchema, name: fieldName, + display: { type: fieldSchema }, } } else { fixedSchema[fieldName] = { ...fieldSchema, name: fieldName, + display: { type: field?.name || fieldSchema.type }, } } }) @@ -1106,50 +1115,51 @@ export const getAllStateVariables = () => { getAllAssets().forEach(asset => { findAllMatchingComponents(asset.props, 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) => { + if (!settings?.length) { + return + } + + // Extract top level event settings settings .filter(setting => setting.type === "event") .forEach(setting => { eventSettings.push(comp[setting.key]) }) - } - const parseComponentSettings = (settings, component) => { - // Parse the nested button configurations + // Recurse into any nested instance types settings - .filter(setting => setting.type === "buttonConfiguration") + .filter(setting => nestedTypes.includes(setting.type)) .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)) { - buttonConfig.forEach(button => { - const nestedSettings = componentStore.getComponentSettings( - button._component - ) - parseEventSettings(nestedSettings, button) + // Backwards compatibility for multi-step from blocks which + // didn't set a proper component type previously. + if (setting.type === "stepConfiguration" && !type) { + type = "@budibase/standard-components/multistepformblockstep" + } + + // Parsed nested component instances inside this setting + const nestedSettings = componentStore.getComponentSettings(type) + parseEventSettings(nestedSettings, instance) }) } }) - - parseEventSettings(settings, component) } - // Parse the base component settings - parseComponentSettings(settings, component) - - // Parse step configuration - const stepSetting = settings.find( - setting => setting.type === "stepConfiguration" - ) - const steps = stepSetting ? component[stepSetting.key] : [] - const stepDefinition = componentStore.getComponentSettings( - "@budibase/standard-components/multistepformblockstep" - ) - - steps?.forEach(step => { - parseComponentSettings(stepDefinition, step) - }) + parseEventSettings(settings, component) }) }) diff --git a/packages/builder/src/helpers/planTitle.js b/packages/builder/src/helpers/planTitle.js index 79f2bc2382..c08b8bf3fe 100644 --- a/packages/builder/src/helpers/planTitle.js +++ b/packages/builder/src/helpers/planTitle.js @@ -20,6 +20,9 @@ export function getFormattedPlanName(userPlanType) { case PlanType.ENTERPRISE: planName = "Enterprise" break + case PlanType.ENTERPRISE_BASIC_TRIAL: + planName = "Trial" + break default: planName = "Free" // Default to "Free" if the type is not explicitly handled } diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte index fd6a97560d..f100260343 100644 --- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte @@ -32,6 +32,7 @@ import { UserAvatars } from "@budibase/frontend-core" import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import PreviewOverlay from "./_components/PreviewOverlay.svelte" + import EnterpriseBasicTrialModal from "components/portal/onboarding/EnterpriseBasicTrialModal.svelte" export let application @@ -103,6 +104,10 @@ } onMount(async () => { + document.fonts.onloadingdone = e => { + builderStore.loadFonts(e.fontfaces) + } + if (!hasSynced && application) { try { await API.syncApp(application) @@ -143,17 +148,19 @@ /> - {#each $layout.children as { path, title }} - - - - {/each} + {#key $builderStore?.fonts} + {#each $layout.children as { path, title }} + + + + {/each} + {/key}
@@ -192,6 +199,8 @@ + + diff --git a/packages/client/src/components/app/Layout.svelte b/packages/client/src/components/app/Layout.svelte index 8508e943ff..72da3e9012 100644 --- a/packages/client/src/components/app/Layout.svelte +++ b/packages/client/src/components/app/Layout.svelte @@ -73,7 +73,10 @@ $context.device.width, $context.device.height ) - $: autoCloseSidePanel = !$builderStore.inBuilder && $sidePanelStore.open + $: autoCloseSidePanel = + !$builderStore.inBuilder && + $sidePanelStore.open && + !$sidePanelStore.ignoreClicksOutside $: screenId = $builderStore.inBuilder ? `${$builderStore.screen?._id}-screen` : "screen" @@ -191,6 +194,11 @@ } return url } + + const handleClickLink = () => { + mobileOpen = false + sidePanelStore.actions.close() + } @@ -281,7 +289,7 @@ url={navItem.url} subLinks={navItem.subLinks} internalLink={navItem.internalLink} - on:clickLink={() => (mobileOpen = false)} + on:clickLink={handleClickLink} leftNav={navigation === "Left"} {mobile} {navStateStore} @@ -316,10 +324,7 @@
diff --git a/packages/client/src/components/app/SidePanel.svelte b/packages/client/src/components/app/SidePanel.svelte index 825b401bb8..bff5a78837 100644 --- a/packages/client/src/components/app/SidePanel.svelte +++ b/packages/client/src/components/app/SidePanel.svelte @@ -5,6 +5,9 @@ const { styleable, sidePanelStore, builderStore, dndIsDragging } = getContext("sdk") + export let onClose + export let ignoreClicksOutside + // Automatically show and hide the side panel when inside the builder. // For some unknown reason, svelte reactivity breaks if we reference the // reactive variable "open" inside the following expression, or if we define @@ -26,6 +29,10 @@ } } + // $: { + + // } + // Derive visibility $: open = $sidePanelStore.contentId === $component.id @@ -36,10 +43,17 @@ let renderKey = null $: { if (open) { + sidePanelStore.actions.setIgnoreClicksOutside(ignoreClicksOutside) renderKey = Math.random() } } + const handleSidePanelClose = async () => { + if (onClose) { + await onClose() + } + } + const showInSidePanel = (el, visible) => { const update = visible => { const target = document.getElementById("side-panel-container") @@ -51,6 +65,7 @@ } else { if (target.contains(node)) { target.removeChild(node) + handleSidePanelClose() } } } diff --git a/packages/client/src/components/app/blocks/FormBlockComponent.svelte b/packages/client/src/components/app/blocks/FormBlockComponent.svelte index 968ed36b8b..31610f79ac 100644 --- a/packages/client/src/components/app/blocks/FormBlockComponent.svelte +++ b/packages/client/src/components/app/blocks/FormBlockComponent.svelte @@ -21,6 +21,7 @@ [FieldType.JSON]: "jsonfield", [FieldType.BARCODEQR]: "codescanner", [FieldType.BB_REFERENCE]: "bbreferencefield", + [FieldType.BB_REFERENCE_SINGLE]: "bbreferencesinglefield", } const getFieldSchema = field => { diff --git a/packages/client/src/components/app/forms/BBReferenceField.svelte b/packages/client/src/components/app/forms/BBReferenceField.svelte index 5f00c503c2..c266268275 100644 --- a/packages/client/src/components/app/forms/BBReferenceField.svelte +++ b/packages/client/src/components/app/forms/BBReferenceField.svelte @@ -1,8 +1,10 @@ + + diff --git a/packages/client/src/components/app/forms/DateTimeField.svelte b/packages/client/src/components/app/forms/DateTimeField.svelte index 8ab8e65a15..499f0443cb 100644 --- a/packages/client/src/components/app/forms/DateTimeField.svelte +++ b/packages/client/src/components/app/forms/DateTimeField.svelte @@ -49,7 +49,6 @@ readonly={fieldState.readonly} error={fieldState.error} id={fieldState.fieldId} - appendTo={document.getElementById("flatpickr-root")} {enableTime} {timeOnly} {time24hr} diff --git a/packages/client/src/components/app/forms/RelationshipField.svelte b/packages/client/src/components/app/forms/RelationshipField.svelte index 1fbd0df522..a6d0564f7f 100644 --- a/packages/client/src/components/app/forms/RelationshipField.svelte +++ b/packages/client/src/components/app/forms/RelationshipField.svelte @@ -1,9 +1,9 @@ { const componentId = Object.keys(selection).find( componentId => componentId === tableComponentId ) - return selection[componentId] || {} + return selection[componentId] } return { diff --git a/packages/client/src/stores/sidePanel.js b/packages/client/src/stores/sidePanel.js index 3b3b9f5f4d..b25914c484 100644 --- a/packages/client/src/stores/sidePanel.js +++ b/packages/client/src/stores/sidePanel.js @@ -3,6 +3,7 @@ import { writable, derived } from "svelte/store" export const createSidePanelStore = () => { const initialState = { contentId: null, + ignoreClicksOutside: true, } const store = writable(initialState) const derivedStore = derived(store, $store => { @@ -32,11 +33,18 @@ export const createSidePanelStore = () => { }, 50) } + const setIgnoreClicksOutside = bool => { + store.update(state => { + state.ignoreClicksOutside = bool + return state + }) + } return { subscribe: derivedStore.subscribe, actions: { open, close, + setIgnoreClicksOutside, }, } } diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index d883ee1b55..0c3866768e 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -240,6 +240,7 @@ const triggerAutomationHandler = async action => { const navigationHandler = action => { const { url, peek, externalNewTab } = action.parameters routeStore.actions.navigate(url, peek, externalNewTab) + closeSidePanelHandler() } const queryExecutionHandler = async action => { @@ -332,31 +333,59 @@ const s3UploadHandler = async action => { } } +/** + * For new configs, "rows" is defined and enriched to be the array of rows to + * export. For old configs it will be undefined and we need to use the legacy + * row selection store in combination with the tableComponentId parameter. + */ const exportDataHandler = async action => { - let selection = rowSelectionStore.actions.getSelection( - action.parameters.tableComponentId - ) - if (selection.selectedRows && selection.selectedRows.length > 0) { + let { tableComponentId, rows, type, columns, delimiter, customHeaders } = + action.parameters + let tableId + + // Handle legacy configs using the row selection store + if (!rows?.length) { + const selection = rowSelectionStore.actions.getSelection(tableComponentId) + if (selection?.selectedRows?.length) { + rows = selection.selectedRows + tableId = selection.tableId + } + } + + // Get table ID from first row if needed + if (!tableId) { + tableId = rows?.[0]?.tableId + } + + // Handle no rows selected + if (!rows?.length) { + notificationStore.actions.error("Please select at least one row") + } + // Handle case where we're not using a DS+ + else if (!tableId) { + notificationStore.actions.error( + "You can only export data from table datasources" + ) + } + // Happy path when we have both rows and table ID + else { try { + // Flatten rows if required + if (typeof rows[0] !== "string") { + rows = rows.map(row => row._id) + } const data = await API.exportRows({ - tableId: selection.tableId, - rows: selection.selectedRows, - format: action.parameters.type, - columns: action.parameters.columns?.map( - column => column.name || column - ), - delimiter: action.parameters.delimiter, - customHeaders: action.parameters.customHeaders, + tableId, + rows, + format: type, + columns: columns?.map(column => column.name || column), + delimiter, + customHeaders, }) - download( - new Blob([data], { type: "text/plain" }), - `${selection.tableId}.${action.parameters.type}` - ) + download(new Blob([data], { type: "text/plain" }), `${tableId}.${type}`) } catch (error) { notificationStore.actions.error("There was an error exporting the data") } - } else { - notificationStore.actions.error("Please select at least one row") } } @@ -541,16 +570,22 @@ export const enrichButtonActions = (actions, context) => { // then execute the rest of the actions in the chain const result = await callback() if (result !== false) { - // Generate a new total context to pass into the next enrichment + // Generate a new total context for the next enrichment buttonContext.push(result) const newContext = { ...context, actions: buttonContext } - // Enrich and call the next button action if there is more than one action remaining + // Enrich and call the next button action if there is more + // than one action remaining const next = enrichButtonActions( actions.slice(i + 1), newContext ) - resolve(typeof next === "function" ? await next() : true) + if (typeof next === "function") { + // Pass the event context back into the new action chain + resolve(await next(eventContext)) + } else { + resolve(true) + } } else { resolve(false) } diff --git a/packages/frontend-core/src/api/backups.js b/packages/frontend-core/src/api/backups.js index 7663ae09af..40546b6f66 100644 --- a/packages/frontend-core/src/api/backups.js +++ b/packages/frontend-core/src/api/backups.js @@ -1,7 +1,4 @@ export const buildBackupsEndpoints = API => ({ - /** - * Gets a list of users in the current tenant. - */ searchBackups: async ({ appId, trigger, type, page, startDate, endDate }) => { const opts = {} if (page) { diff --git a/packages/frontend-core/src/components/FilterBuilder.svelte b/packages/frontend-core/src/components/FilterBuilder.svelte index 074c2dbd9b..fb7aa98405 100644 --- a/packages/frontend-core/src/components/FilterBuilder.svelte +++ b/packages/frontend-core/src/components/FilterBuilder.svelte @@ -124,6 +124,8 @@ const fieldSchema = schemaFields.find(x => x.name === filter.field) filter.type = fieldSchema?.type filter.subtype = fieldSchema?.subtype + filter.formulaType = fieldSchema?.formulaType + filter.constraints = fieldSchema?.constraints // Update external type based on field filter.externalType = getSchema(filter)?.externalType @@ -280,7 +282,7 @@ timeOnly={getSchema(filter)?.timeOnly} bind:value={filter.value} /> - {:else if filter.type === FieldType.BB_REFERENCE} + {:else if [FieldType.BB_REFERENCE, FieldType.BB_REFERENCE_SINGLE].includes(filter.type)} {:else} @@ -324,8 +327,6 @@ diff --git a/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte b/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte index 48b1279346..5d98ba903b 100644 --- a/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/BBReferenceCell.svelte @@ -1,25 +1,36 @@ + + diff --git a/packages/frontend-core/src/components/grid/cells/DateCell.svelte b/packages/frontend-core/src/components/grid/cells/DateCell.svelte index 53b159ee30..3c86fe3605 100644 --- a/packages/frontend-core/src/components/grid/cells/DateCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DateCell.svelte @@ -1,7 +1,8 @@ -
+ + +
- {#if value} - {displayValue} - {/if} + {displayValue}
{#if editable} {/if}
-{#if editable} -
- onChange(e.detail)} - appendTo={document.documentElement} - enableTime={!dateOnly} - {timeOnly} - time24hr - ignoreTimezones={schema.ignoreTimezones} - bind:flatpickr - on:open={() => (isOpen = true)} - on:close={() => (isOpen = false)} +{#if isOpen} + + (value = e.detail)} + {enableTime} + {timeOnly} + {ignoreTimezones} /> -
+ {/if} diff --git a/packages/frontend-core/src/components/grid/cells/GridCell.svelte b/packages/frontend-core/src/components/grid/cells/GridCell.svelte index 74d98ec130..32a9dea83b 100644 --- a/packages/frontend-core/src/components/grid/cells/GridCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/GridCell.svelte @@ -43,6 +43,9 @@ on:mouseup on:click on:contextmenu + on:touchstart + on:touchend + on:touchcancel {style} > {#if error} diff --git a/packages/frontend-core/src/components/grid/cells/GutterCell.svelte b/packages/frontend-core/src/components/grid/cells/GutterCell.svelte index 377405786d..60b41a2b87 100644 --- a/packages/frontend-core/src/components/grid/cells/GutterCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/GutterCell.svelte @@ -16,6 +16,8 @@ const { config, dispatch, selectedRows } = getContext("grid") const svelteDispatch = createEventDispatcher() + $: selectionEnabled = $config.canSelectRows || $config.canDeleteRows + const select = e => { e.stopPropagation() svelteDispatch("select") @@ -52,7 +54,7 @@
@@ -60,7 +62,7 @@ {#if !disableNumber}
{row.__idx + 1} @@ -117,19 +119,11 @@ .expand { margin-right: 4px; } - .expand { + .expand:not(.visible), + .expand:not(.visible) :global(*) { opacity: 0; + pointer-events: none !important; } - .expand :global(.spectrum-Icon) { - pointer-events: none; - } - .expand.visible { - opacity: 1; - } - .expand.visible :global(.spectrum-Icon) { - pointer-events: all; - } - .delete:hover { cursor: pointer; } diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 657f618759..bb38c5094f 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -1,30 +1,22 @@ @@ -258,6 +258,9 @@
- - {#if editIsOpen} -
{ - editIsOpen = false - }} - class="content" - > - -
- {:else} - - - Edit column - - - Duplicate column - - - Use as display column - - - Sort {sortingLabels.ascending} - - - Sort {sortingLabels.descending} - - - Move left - - - Move right - - - Hide column - - {#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS} - - Migrate to user column +{#if open} + + {#if editIsOpen} +
+ +
+ {:else} + + + Edit column - {/if} - - {/if} - + + Duplicate column + + + Use as display column + + + Sort {sortingLabels.ascending} + + + Sort {sortingLabels.descending} + + + Move left + + + Move right + + + Hide column + + {#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS} + + Migrate to user column + + {/if} +
+ {/if} + +{/if} diff --git a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte index bf1fe92ef0..f5a8351604 100644 --- a/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/RelationshipCell.svelte @@ -1,10 +1,11 @@ + + +
dispatch("close")} + on:wheel={e => e.stopPropagation()} + > + +
+
+ + diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte index ed09301bb9..78cb186f29 100644 --- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte +++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte @@ -2,6 +2,7 @@ import { getContext, onMount } from "svelte" import { debounce } from "../../../utils/utils" import { NewRowID } from "../lib/constants" + import { getCellID, parseCellID } from "../lib/utils" const { rows, @@ -20,6 +21,7 @@ const ignoredOriginSelectors = [ ".spectrum-Modal", + ".date-time-popover", "#builder-side-panel-container", "[data-grid-ignore]", ] @@ -153,7 +155,7 @@ if (!firstColumn) { return } - focusedCellId.set(`${firstRow._id}-${firstColumn.name}`) + focusedCellId.set(getCellID(firstRow._id, firstColumn.name)) } // Changes the focused cell by moving it left or right to a different column @@ -162,8 +164,7 @@ return } const cols = $visibleColumns - const split = $focusedCellId.split("-") - const columnName = split[1] + const { id, field: columnName } = parseCellID($focusedCellId) let newColumnName if (columnName === $stickyColumn?.name) { const index = delta - 1 @@ -177,7 +178,7 @@ } } if (newColumnName) { - $focusedCellId = `${split[0]}-${newColumnName}` + $focusedCellId = getCellID(id, newColumnName) } } @@ -188,8 +189,8 @@ } const newRow = $rows[$focusedRow.__idx + delta] if (newRow) { - const split = $focusedCellId.split("-") - $focusedCellId = `${newRow._id}-${split[1]}` + const { field } = parseCellID($focusedCellId) + $focusedCellId = getCellID(newRow._id, field) } } diff --git a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte index 55308d4c6e..4a413d7dde 100644 --- a/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/MenuOverlay.svelte @@ -1,7 +1,9 @@ +