Merge branch 'main' into feature/add-raw-file-request-body-option

This commit is contained in:
zachary-berdell-elliott 2024-09-23 08:46:32 -06:00 committed by GitHub
commit a8e6fcf7a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
133 changed files with 2415 additions and 795 deletions

View File

@ -15,6 +15,7 @@
| [正體中文](docs/contributing/contributing_zhtw.md) | [正體中文](docs/contributing/contributing_zhtw.md)
| [日本語](docs/contributing/contributing_ja.md) | [日本語](docs/contributing/contributing_ja.md)
| [हिंदी](docs/contributing/contributing_hi.md) | [हिंदी](docs/contributing/contributing_hi.md)
| [Nederlands](docs/contributing/contributing_nl.md)
## Let's make Bruno better, together!! ## Let's make Bruno better, together!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| **বাংলা**
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## আসুন ব্রুনোকে আরও ভালো করি, একসাথে!! ## আসুন ব্রুনোকে আরও ভালো করি, একসাথে!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| **简体中文**
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## 让我们一起改进 Bruno ## 让我们一起改进 Bruno

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| **Deutsch**
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Lass uns Bruno noch besser machen, gemeinsam!! ## Lass uns Bruno noch besser machen, gemeinsam!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| **Español**
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## ¡Juntos, hagamos a Bruno mejor! ## ¡Juntos, hagamos a Bruno mejor!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| **Français**
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Ensemble, améliorons Bruno ! ## Ensemble, améliorons Bruno !

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| **हिंदी**
## आइए मिलकर Bruno को बेहतर बनाएं !! ## आइए मिलकर Bruno को बेहतर बनाएं !!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| **Italiano**
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Insieme, miglioriamo Bruno! ## Insieme, miglioriamo Bruno!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| **日本語**
| [हिंदी](./contributing_hi.md)
## 一緒に Bruno をよりよいものにしていきましょう!! ## 一緒に Bruno をよりよいものにしていきましょう!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| **한국어**
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## 함께 Bruno를 더 좋게 만들어요!! ## 함께 Bruno를 더 좋게 만들어요!!

View File

@ -0,0 +1,82 @@
[English](../../contributing.md)
## Laten we Bruno samen beter maken !!
We zijn blij dat je Bruno wilt verbeteren. Hieronder staan de richtlijnen om Bruno op je computer op te zetten.
### Technologiestack
Bruno is gebouwd met Next.js en React. We gebruiken ook Electron om een desktopversie te leveren (die lokale collecties ondersteunt).
Bibliotheken die we gebruiken:
- CSS - Tailwind
- Code Editors - Codemirror
- State Management - Redux
- Iconen - Tabler Icons
- Formulieren - formik
- Schema Validatie - Yup
- Request Client - axios
- Bestandsysteem Watcher - chokidar
### Afhankelijkheden
Je hebt [Node v18.x of de nieuwste LTS-versie](https://nodejs.org/en/) en npm 8.x nodig. We gebruiken npm workspaces in het project.
## Ontwikkeling
Bruno wordt ontwikkeld als een desktop-app. Je moet de app laden door de Next.js app in één terminal te draaien en daarna de Electron app in een andere terminal te draaien.
### Lokale Ontwikkeling
```bash
# gebruik voorgeschreven node versie
nvm use
# installeer afhankelijkheden
npm i --legacy-peer-deps
# build pakketten
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
# draai next app (terminal 1)
npm run dev:web
# draai electron app (terminal 2)
npm run dev:electron
```
### Problemen oplossen
Je kunt een `Unsupported platform`-fout tegenkomen wanneer je `npm install` uitvoert. Om dit te verhelpen, moet je `node_modules` en `package-lock.json` verwijderen en `npm install` uitvoeren. Dit zou alle benodigde afhankelijkheden moeten installeren om de app te draaien.
```shell
# Verwijder node_modules in subdirectories
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
done
# Verwijder package-lock in subdirectories
find . -type f -name "package-lock.json" -delete
```
### Testen
```bash
# bruno-schema
npm test --workspace=packages/bruno-schema
# bruno-lang
npm test --workspace=packages/bruno-lang
```
### Pull Requests indienen
- Houd de PR's klein en gefocust op één ding
- Volg het formaat voor het aanmaken van branches
- feature/[feature naam]: Deze branch moet wijzigingen voor een specifieke functie bevatten
- Voorbeeld: feature/dark-mode
- bugfix/[bug naam]: Deze branch moet alleen bugfixes voor een specifieke bug bevatten
- Voorbeeld: bugfix/bug-1

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| **Polski**
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Wspólnie uczynijmy Bruno lepszym !! ## Wspólnie uczynijmy Bruno lepszym !!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| **Português (BR)**
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Vamos tornar o Bruno melhor, juntos!! ## Vamos tornar o Bruno melhor, juntos!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| **Română**
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Haideţi să îmbunătățim Bruno, împreună!! ## Haideţi să îmbunătățim Bruno, împreună!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| **Русский**
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Давайте вместе сделаем Бруно лучше!!! ## Давайте вместе сделаем Бруно лучше!!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| **Türkçe**
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Bruno'yu birlikte daha iyi hale getirelim!!! ## Bruno'yu birlikte daha iyi hale getirelim!!!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| **Українська**
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| [正體中文](./contributing_zhtw.md)
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## Давайте зробимо Bruno краще, разом !! ## Давайте зробимо Bruno краще, разом !!

View File

@ -1,20 +1,4 @@
[English](../../contributing.md) [English](../../contributing.md)
| [Українська](./contributing_ua.md)
| [Русский](./contributing_ru.md)
| [Türkçe](./contributing_tr.md)
| [Deutsch](./contributing_de.md)
| [Français](./contributing_fr.md)
| [Português (BR)](./contributing_pt_br.md)
| [한국어](./contributing_kr.md)
| [বাংলা](./contributing_bn.md)
| [Español](./contributing_es.md)
| [Italiano](./contributing_it.md)
| [Română](./contributing_ro.md)
| [Polski](./contributing_pl.md)
| [简体中文](./contributing_cn.md)
| **正體中文**
| [日本語](./contributing_ja.md)
| [हिंदी](./contributing_hi.md)
## 讓我們一起來讓 Bruno 變得更好! ## 讓我們一起來讓 Bruno 變得更好!

View File

@ -0,0 +1,7 @@
[English](../../publishing.md)
### Bruno publiceren naar een nieuwe pakketbeheerder
Hoewel onze code open source is en beschikbaar voor iedereen, verzoeken we je vriendelijk om contact met ons op te nemen voordat je publicatie overweegt op nieuwe pakketbeheerders. Als de maker van Bruno houd ik het handelsmerk `Bruno` voor dit project en wil ik het distributieproces beheren. Als je Bruno op een nieuwe pakketbeheerder wilt zien, dien dan een GitHub-issue in.
Hoewel de meerderheid van onze functies gratis en open source zijn (die REST en GraphQL API's dekken), streven we ernaar een harmonieuze balans te vinden tussen open-source principes en duurzaamheid - https://github.com/usebruno/bruno/discussions/269

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| **বাংলা**
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### ব্রুনোকে নতুন প্যাকেজ ম্যানেজারে প্রকাশ করা ### ব্রুনোকে নতুন প্যাকেজ ম্যানেজারে প্রকাশ করা

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| **简体中文**
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### 将 Bruno 发布到新的包管理器 ### 将 Bruno 发布到新的包管理器

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| **Deutsch**
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### Veröffentlichung von Bruno über neue Paket-Manager ### Veröffentlichung von Bruno über neue Paket-Manager

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| **Français**
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### Publier Bruno dans un nouveau gestionnaire de paquets ### Publier Bruno dans un nouveau gestionnaire de paquets

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| **日本語**
### Bruno を新しいパッケージマネージャに公開する場合の注意 ### Bruno を新しいパッケージマネージャに公開する場合の注意

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| **Polski**
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### Publikowanie Bruno w nowym menedżerze pakietów ### Publikowanie Bruno w nowym menedżerze pakietów

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| **Português (BR)**
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### Publicando Bruno em um novo gerenciador de pacotes ### Publicando Bruno em um novo gerenciador de pacotes

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| **Română**
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### Publicarea lui Bruno la un gestionar de pachete nou ### Publicarea lui Bruno la un gestionar de pachete nou

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| **Türkçe**
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| [正體中文](./publishing_zhtw.md)
| [日本語](./publishing_ja.md)
### Bruno'yu yeni bir paket yöneticisine yayınlama ### Bruno'yu yeni bir paket yöneticisine yayınlama

View File

@ -1,14 +1,4 @@
[English](../../publishing.md) [English](../../publishing.md)
| [Türkçe](./publishing_tr.md)
| [Deutsch](./publishing_de.md)
| [Français](./publishing_fr.md)
| [Português (BR)](./publishing_pt_br.md)
| [বাংলা](./publishing_bn.md)
| [Română](./publishing_ro.md)
| [Polski](./publishing_pl.md)
| [简体中文](./publishing_cn.md)
| **正體中文**
| [日本語](./publishing_ja.md)
### 將 Bruno 發佈到新的套件管理器 ### 將 Bruno 發佈到新的套件管理器

157
docs/readme/readme_nl.md Normal file
View File

@ -0,0 +1,157 @@
<br />
<img src="../../assets/images/logo-transparent.png" width="80"/>
### Bruno - Open source IDE voor het verkennen en testen van API's.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
[![Website](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![Download](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
[English](../../readme.md) | [Українська](docs/readme/readme_ua.md) | [Русский](docs/readme/readme_ru.md) | [Türkçe](docs/readme/readme_tr.md) | [Deutsch](docs/readme/readme_de.md) | ** Nederlands ** | [Français](docs/readme/readme_fr.md) | [Português (BR)](docs/readme/readme_pt_br.md) | [한국어](docs/readme/readme_kr.md) | [বাংলা](docs/readme/readme_bn.md) | [Español](docs/readme/readme_es.md) | [Italiano](docs/readme/readme_it.md) | [Română](docs/readme/readme_ro.md) | [Polski](docs/readme/readme_pl.md) | [简体中文](docs/readme/readme_cn.md) | [正體中文](docs/readme/readme_zhtw.md) | [العربية](docs/readme/readme_ar.md) | [日本語](docs/readme/readme_ja.md)
Bruno is een nieuwe en innovatieve API-client, gericht op het revolutioneren van de status quo die wordt vertegenwoordigd door Postman en vergelijkbare tools.
Bruno slaat je collecties direct op in een map op je bestandssysteem. We gebruiken een platte tekst opmaaktaal, Bru, om informatie over API-verzoeken op te slaan.
Je kunt Git of elke versiebeheertool naar keuze gebruiken om samen te werken aan je API-collecties.
Bruno is uitsluitend offline. Er zijn geen plannen om ooit cloud-synchronisatie aan Bruno toe te voegen. We waarderen je gegevensprivacy en geloven dat deze op je apparaat moet blijven. Lees onze langetermijnvisie [hier](https://github.com/usebruno/bruno/discussions/269)
[Download Bruno](https://www.usebruno.com/downloads)
📢 Bekijk onze recente presentatie op de India FOSS 3.0 Conference [hier](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](/assets/images/landing-2.png) <br /><br />
### Golden Edition ✨
De meeste van onze functies zijn gratis en open source.
We streven naar een harmonieuze balans tussen [open-source principes en duurzaamheid](https://github.com/usebruno/bruno/discussions/269).
Je kunt de [Golden Edition](https://www.usebruno.com/pricing) kopen voor een eenmalige betaling van **$19**! <br/>
### Installatie
Bruno is beschikbaar als binaire download [op onze website](https://www.usebruno.com/downloads) voor Mac, Windows en Linux.
Je kunt Bruno ook installeren via pakketbeheerders zoals Homebrew, Chocolatey, Scoop, Snap, Flatpak en Apt.
```sh
# Op Mac via Homebrew
brew install bruno
# Op Windows via Chocolatey
choco install bruno
# Op Windows via Scoop
scoop bucket add extras
scoop install bruno
# Op Windows via winget
winget install Bruno.Bruno
# Op Linux via Snap
snap install bruno
# Op Linux via Flatpak
flatpak install com.usebruno.Bruno
# Op Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
```
### Draai op meerdere platformen 🖥️
![bruno](/assets/images/run-anywhere.png) <br /><br />
### Samenwerken via Git 👩‍💻🧑‍💻
Of elk versiebeheersysteem naar keuze
![bruno](/assets/images/version-control.png) <br /><br />
### Sponsors
#### Gouden Sponsors
<img src="../../assets/images/sponsors/samagata.png" width="150"/>
#### Zilveren Sponsors
<img src="../../assets/images/sponsors/commit-company.png" width="70"/>
#### Bronzen Sponsors
<a href="https://zuplo.link/bruno">
<img src="../../assets/images/sponsors/zuplo.png" width="120"/>
</a>
### Belangrijke Links 📌
- [Onze Langetermijnvisie](https://github.com/usebruno/bruno/discussions/269)
- [Roadmap](https://github.com/usebruno/bruno/discussions/384)
- [Documentatie](https://docs.usebruno.com)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
- [Website](https://www.usebruno.com)
- [Prijzen](https://www.usebruno.com/pricing)
- [Download](https://www.usebruno.com/downloads)
- [GitHub Sponsors](https://github.com/sponsors/helloanoop)
### Showcase 🎥
- [Getuigenissen](https://github.com/usebruno/bruno/discussions/343)
- [Kenniscentrum](https://github.com/usebruno/bruno/discussions/386)
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
### Ondersteuning ❤️
Als je Bruno leuk vindt en ons open-source werk wilt ondersteunen, overweeg dan om ons te sponsoren via [GitHub Sponsors](https://github.com/sponsors/helloanoop).
### Deel Getuigenissen 📣
Als Bruno je heeft geholpen op je werk en in je teams, deel dan je [getuigenissen op onze GitHub-discussie](https://github.com/usebruno/bruno/discussions/343).
### Blijf in contact 🌐
[𝕏 (Twitter)](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq) <br />
[LinkedIn](https://www.linkedin.com/company/usebruno)
### Handelsmerk
**Naam**
`Bruno` is een handelsmerk in bezit van [Anoop M D](https://www.helloanoop.com/).
**Logo**
Het logo is afkomstig van [OpenMoji](https://openmoji.org/library/emoji-1F436/). Licentie: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
### Bijdragen 👩‍💻🧑‍💻
Ik ben blij dat je Bruno wilt verbeteren. Bekijk de [bijdragegids](contributing.md).
Zelfs als je geen bijdragen via code kunt leveren, aarzel dan niet om bugs en functieverzoeken in te dienen die moeten worden geïmplementeerd om jouw gebruiksscenario op te lossen.
### Auteurs
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### Licentie 📄
[MIT](../../license.md)

View File

@ -6,7 +6,7 @@
*/ */
import React from 'react'; import React from 'react';
import isEqual from 'lodash/isEqual'; import { isEqual, escapeRegExp } from 'lodash';
import { getEnvironmentVariables } from 'utils/collections'; import { getEnvironmentVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror'; import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
@ -61,6 +61,8 @@ if (!SERVER_RENDERED) {
'bru.getProcessEnv(key)', 'bru.getProcessEnv(key)',
'bru.hasEnvVar(key)', 'bru.hasEnvVar(key)',
'bru.getEnvVar(key)', 'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setEnvVar(key,value)', 'bru.setEnvVar(key,value)',
'bru.hasVar(key)', 'bru.hasVar(key)',
'bru.getVar(key)', 'bru.getVar(key)',
@ -406,7 +408,8 @@ export default class CodeEditor extends React.Component {
const searchInput = document.querySelector('.CodeMirror-search-field'); const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput && searchInput.value.length > 0) { if (searchInput && searchInput.value.length > 0) {
const text = new RegExp(searchInput.value, 'gi'); // Escape special characters in search input to prevent RegExp crashes. Fixes #3051
const text = new RegExp(escapeRegExp(searchInput.value), 'gi');
const matches = this.editor.getValue().match(text); const matches = this.editor.getValue().match(text);
count = matches ? matches.length : 0; count = matches ? matches.length : 0;
} }

View File

@ -0,0 +1,56 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.auth-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
.dropdown {
width: fit-content;
div[data-tippy-root] {
width: fit-content;
}
.tippy-box {
width: fit-content;
max-width: none !important;
.tippy-content: {
width: fit-content;
max-width: none !important;
}
}
}
.auth-type-label {
width: fit-content;
color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default Wrapper;

View File

@ -0,0 +1,109 @@
import React, { useRef, forwardRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = get(collection, 'root.request.auth.apikey', {});
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
{humanizeRequestAPIKeyPlacement(apikeyAuth?.placement)}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleAuthChange = (property, value) => {
dispatch(
updateCollectionAuth({
mode: 'apikey',
collectionUid: collection.uid,
content: {
...apikeyAuth,
[property]: value
}
})
);
};
useEffect(() => {
!apikeyAuth?.placement &&
dispatch(
updateCollectionAuth({
mode: 'apikey',
collectionUid: collection.uid,
content: {
placement: 'header'
}
})
);
}, [apikeyAuth]);
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Key</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.key || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('key', val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Value</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('value', val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Add To</label>
<div className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default ApiKeyAuth;

View File

@ -52,6 +52,15 @@ const AuthMode = ({ collection }) => {
> >
Basic Auth Basic Auth
</div> </div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div <div
className="dropdown-item" className="dropdown-item"
onClick={() => { onClick={() => {
@ -79,6 +88,15 @@ const AuthMode = ({ collection }) => {
> >
Oauth2 Oauth2
</div> </div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div <div
className="dropdown-item" className="dropdown-item"
onClick={() => { onClick={() => {

View File

@ -0,0 +1,16 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,71 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = get(collection, 'root.request.auth.wsse', {});
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleUserChange = (username) => {
dispatch(
updateCollectionAuth({
mode: 'wsse',
collectionUid: collection.uid,
content: {
username,
password: wsseAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateCollectionAuth({
mode: 'wsse',
collectionUid: collection.uid,
content: {
username: wsseAuth.username,
password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={wsseAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUserChange(val)}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default WsseAuth;

View File

@ -6,6 +6,8 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth'; import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth'; import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth'; import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import ApiKeyAuth from './ApiKeyAuth/';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions'; import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2'; import OAuth2 from './OAuth2';
@ -33,6 +35,12 @@ const Auth = ({ collection }) => {
case 'oauth2': { case 'oauth2': {
return <OAuth2 collection={collection} />; return <OAuth2 collection={collection} />;
} }
case 'wsse': {
return <WsseAuth collection={collection} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} />;
}
} }
}; };

View File

@ -44,7 +44,7 @@ const CopyEnvironment = ({ collection, environment, onClose }) => {
return ( return (
<Portal> <Portal>
<Modal size="sm" title={'Copy Environment'} confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="sm" title={'Copy Environment'} confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
New Environment Name New Environment Name

View File

@ -50,7 +50,7 @@ const CreateEnvironment = ({ collection, onClose }) => {
handleConfirm={onSubmit} handleConfirm={onSubmit}
handleCancel={onClose} handleCancel={onClose}
> >
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
Environment Name Environment Name

View File

@ -1,4 +1,5 @@
import React from 'react'; import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons'; import { IconTrash } from '@tabler/icons';
import { useTheme } from 'providers/Theme'; import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
@ -9,12 +10,12 @@ import { useFormik } from 'formik';
import * as Yup from 'yup'; import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex'; import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions'; import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables }) => { const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { storedTheme } = useTheme(); const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const formik = useFormik({ const formik = useFormik({
enableReinitialize: true, enableReinitialize: true,
@ -85,6 +86,14 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
formik.setValues(formik.values.filter((variable) => variable.uid !== id)); formik.setValues(formik.values.filter((variable) => variable.uid !== id));
}; };
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [formik.values, formik.dirty]);
const handleReset = () => { const handleReset = () => {
formik.resetForm({ originalEnvironmentVariables }); formik.resetForm({ originalEnvironmentVariables });
}; };
@ -159,11 +168,15 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
))} ))}
</tbody> </tbody>
</table> </table>
</div> <div>
<div> <button
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={addVariable}> ref={addButtonRef}
+ Add Variable className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
</button> onClick={addVariable}
>
+ Add Variable
</button>
</div>
</div> </div>
<div> <div>

View File

@ -50,7 +50,7 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
handleConfirm={onSubmit} handleConfirm={onSubmit}
handleCancel={onClose} handleCancel={onClose}
> >
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
Environment Name Environment Name

View File

@ -2,6 +2,7 @@ import MarkdownIt from 'markdown-it';
import * as MarkdownItReplaceLink from 'markdown-it-replace-link'; import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import React from 'react'; import React from 'react';
import { isValidUrl } from 'utils/url/index';
const Markdown = ({ collectionPath, onDoubleClick, content }) => { const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = { const markdownItOptions = {
@ -15,7 +16,7 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
if (target.tagName === 'A') { if (target.tagName === 'A') {
event.preventDefault(); event.preventDefault();
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (href) { if (href && isValidUrl(href)) {
window.open(href, '_blank'); window.open(href, '_blank');
return; return;
} }

View File

@ -1,5 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import useFocusTrap from 'hooks/useFocusTrap';
const ESC_KEY_CODE = 27;
const ENTER_KEY_CODE = 13;
const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => ( const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
<div className="bruno-modal-header"> <div className="bruno-modal-header">
@ -69,25 +73,35 @@ const Modal = ({
onClick, onClick,
closeModalFadeTimeout = 500 closeModalFadeTimeout = 500
}) => { }) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const escFunction = (event) => {
const escKeyCode = 27; const handleKeydown = (event) => {
if (event.keyCode === escKeyCode) { const { keyCode, shiftKey, ctrlKey, altKey, metaKey } = event;
closeModal({ type: 'esc' }); switch (keyCode) {
case ESC_KEY_CODE: {
if (disableEscapeKey) return;
return closeModal({ type: 'esc' });
}
case ENTER_KEY_CODE: {
if (!shiftKey && !ctrlKey && !altKey && !metaKey && handleConfirm) {
return handleConfirm();
}
}
} }
}; };
useFocusTrap(modalRef);
const closeModal = (args) => { const closeModal = (args) => {
setIsClosing(true); setIsClosing(true);
setTimeout(() => handleCancel(args), closeModalFadeTimeout); setTimeout(() => handleCancel(args), closeModalFadeTimeout);
}; };
useEffect(() => { useEffect(() => {
if (disableEscapeKey) return; document.addEventListener('keydown', handleKeydown, false);
document.addEventListener('keydown', escFunction, false);
return () => { return () => {
document.removeEventListener('keydown', escFunction, false); document.removeEventListener('keydown', handleKeydown);
}; };
}, [disableEscapeKey, document]); }, [disableEscapeKey, document]);
@ -100,7 +114,13 @@ const Modal = ({
} }
return ( return (
<StyledWrapper className={classes} onClick={onClick ? (e) => onClick(e) : null}> <StyledWrapper className={classes} onClick={onClick ? (e) => onClick(e) : null}>
<div className={`bruno-modal-card modal-${size}`}> <div
className={`bruno-modal-card modal-${size}`}
ref={modalRef}
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<ModalHeader <ModalHeader
title={title} title={title}
hideClose={hideClose} hideClose={hideClose}

View File

@ -93,10 +93,12 @@ const Notifications = () => {
dispatch(fetchNotifications()); dispatch(fetchNotifications());
setShowNotificationsModal(true); setShowNotificationsModal(true);
}} }}
aria-label="Check all Notifications"
> >
<ToolHint text="Notifications" toolhintId="Notifications" offset={8} > <ToolHint text="Notifications" toolhintId="Notifications" offset={8}>
<IconBell <IconBell
size={18} size={18}
aria-hidden
strokeWidth={1.5} strokeWidth={1.5}
className={`mr-2 ${unreadNotifications?.length > 0 ? 'bell' : ''}`} className={`mr-2 ${unreadNotifications?.length > 0 ? 'bell' : ''}`}
/> />
@ -133,8 +135,9 @@ const Notifications = () => {
{notifications?.slice(notificationsStartIndex, notificationsEndIndex)?.map((notification) => ( {notifications?.slice(notificationsStartIndex, notificationsEndIndex)?.map((notification) => (
<li <li
key={notification.id} key={notification.id}
className={`p-4 flex flex-col justify-center ${selectedNotification?.id == notification.id ? 'active' : notification.read ? 'read' : '' className={`p-4 flex flex-col justify-center ${
}`} selectedNotification?.id == notification.id ? 'active' : notification.read ? 'read' : ''
}`}
onClick={handleNotificationItemClick(notification)} onClick={handleNotificationItemClick(notification)}
> >
<div className="notification-title w-full">{notification?.title}</div> <div className="notification-title w-full">{notification?.title}</div>
@ -144,8 +147,9 @@ const Notifications = () => {
</ul> </ul>
<div className="w-full pagination flex flex-row gap-4 justify-center p-2 items-center text-xs"> <div className="w-full pagination flex flex-row gap-4 justify-center p-2 items-center text-xs">
<button <button
className={`pl-2 pr-2 py-3 select-none ${pageNumber <= 1 ? 'opacity-50' : 'text-link cursor-pointer hover:underline' className={`pl-2 pr-2 py-3 select-none ${
}`} pageNumber <= 1 ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
}`}
onClick={handlePrev} onClick={handlePrev}
> >
{'Prev'} {'Prev'}
@ -161,8 +165,9 @@ const Notifications = () => {
</div> </div>
</div> </div>
<button <button
className={`pl-2 pr-2 py-3 select-none ${pageNumber == totalPages ? 'opacity-50' : 'text-link cursor-pointer hover:underline' className={`pl-2 pr-2 py-3 select-none ${
}`} pageNumber == totalPages ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
}`}
onClick={handleNext} onClick={handleNext}
> >
{'Next'} {'Next'}

View File

@ -39,7 +39,7 @@ const Font = ({ close }) => {
<StyledWrapper> <StyledWrapper>
<div className="flex flex-row gap-2 w-full"> <div className="flex flex-row gap-2 w-full">
<div className="w-4/5"> <div className="w-4/5">
<label className="block font-medium">Code Editor Font</label> <label className="block">Code Editor Font</label>
<input <input
type="text" type="text"
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"
@ -52,7 +52,7 @@ const Font = ({ close }) => {
/> />
</div> </div>
<div className="w-1/5"> <div className="w-1/5">
<label className="block font-medium">Font Size</label> <label className="block">Font Size</label>
<input <input
type="number" type="number"
className="block textbox mt-2 w-full" className="block textbox mt-2 w-full"

View File

@ -0,0 +1,22 @@
import React from 'react';
import Font from './Font/index';
import Theme from './Theme/index';
const Display = ({ close }) => {
return (
<div className="flex flex-col my-2 gap-10 w-full">
<div className='w-full flex flex-col gap-2'>
<span>
Theme
</span>
<Theme close={close} />
</div>
<div className='h-[1px] bg-[#aaa5] w-full'></div>
<div className='w-fit flex flex-col gap-2'>
<Font close={close} />
</div>
</div>
);
};
export default Display;

View File

@ -100,7 +100,7 @@ const General = ({ close }) => {
return ( return (
<StyledWrapper> <StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="flex items-center mt-2"> <div className="flex items-center my-2">
<input <input
id="sslVerification" id="sslVerification"
type="checkbox" type="checkbox"

View File

@ -113,7 +113,7 @@ const ProxySettings = ({ close }) => {
return ( return (
<StyledWrapper> <StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3 flex items-center"> <div className="mb-3 flex items-center mt-2">
<label className="settings-label" htmlFor="protocol"> <label className="settings-label" htmlFor="protocol">
Mode Mode
</label> </label>

View File

@ -2,13 +2,12 @@ import styled from 'styled-components';
const StyledWrapper = styled.div` const StyledWrapper = styled.div`
div.tabs { div.tabs {
margin-top: -0.5rem;
div.tab { div.tab {
padding: 6px 0px; width: 100%;
min-width: 120px;
padding: 7px 10px;
border: none; border: none;
border-bottom: solid 2px transparent; border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive); color: var(--color-tab-inactive);
cursor: pointer; cursor: pointer;
@ -22,8 +21,12 @@ const StyledWrapper = styled.div`
} }
&.active { &.active {
color: ${(props) => props.theme.tabs.active.color} !important; color: ${(props) => props.theme.sidebar.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important; background: ${(props) => props.theme.sidebar.collection.item.bg};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.bg} !important;
}
} }
} }
} }

View File

@ -3,10 +3,9 @@ import classnames from 'classnames';
import React, { useState } from 'react'; import React, { useState } from 'react';
import Support from './Support'; import Support from './Support';
import General from './General'; import General from './General';
import Font from './Font';
import Theme from './Theme';
import Proxy from './ProxySettings'; import Proxy from './ProxySettings';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import Display from './Display/index';
const Preferences = ({ onClose }) => { const Preferences = ({ onClose }) => {
const [tab, setTab] = useState('general'); const [tab, setTab] = useState('general');
@ -27,41 +26,36 @@ const Preferences = ({ onClose }) => {
return <Proxy close={onClose} />; return <Proxy close={onClose} />;
} }
case 'theme': { case 'display': {
return <Theme close={onClose} />; return <Display close={onClose} />;
} }
case 'support': { case 'support': {
return <Support />; return <Support />;
} }
case 'font': {
return <Font close={onClose} />;
}
} }
}; };
return ( return (
<StyledWrapper> <StyledWrapper>
<Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}> <Modal size="lg" title="Preferences" handleCancel={onClose} hideFooter={true}>
<div className="flex items-center px-2 tabs" role="tablist"> <div className='flex flex-row gap-2 mx-[-1rem] !my-[-1.5rem]'>
<div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}> <div className="flex flex-col items-center tabs" role="tablist">
General <div className={getTabClassname('general')} role="tab" onClick={() => setTab('general')}>
</div> General
<div className={getTabClassname('theme')} role="tab" onClick={() => setTab('theme')}> </div>
Theme <div className={getTabClassname('display')} role="tab" onClick={() => setTab('display')}>
</div> Display
<div className={getTabClassname('font')} role="tab" onClick={() => setTab('font')}> </div>
Font <div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
</div> Proxy
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}> </div>
Proxy <div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
</div> Support
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}> </div>
Support
</div> </div>
<section className="flex flex-grow px-2 pt-2 pb-6 tab-panel">{getTabPanel(tab)}</section>
</div> </div>
<section className="flex flex-grow px-2 mt-4 tab-panel">{getTabPanel(tab)}</section>
</Modal> </Modal>
</StyledWrapper> </StyledWrapper>
); );

View File

@ -0,0 +1,57 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.auth-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
.dropdown {
width: fit-content;
div[data-tippy-root] {
width: fit-content;
}
.tippy-box {
width: fit-content;
max-width: none !important;
.tippy-content: {
width: fit-content;
max-width: none !important;
}
}
}
.auth-type-label {
width: fit-content;
color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default Wrapper;

View File

@ -0,0 +1,114 @@
import React, { useRef, forwardRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
{humanizeRequestAPIKeyPlacement(apikeyAuth?.placement)}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleAuthChange = (property, value) => {
dispatch(
updateAuth({
mode: 'apikey',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...apikeyAuth,
[property]: value
}
})
);
};
useEffect(() => {
!apikeyAuth?.placement &&
dispatch(
updateAuth({
mode: 'apikey',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
placement: 'header'
}
})
);
}, [apikeyAuth]);
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Key</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.key || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('key', val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Value</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={apikeyAuth.value || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleAuthChange('value', val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Add To</label>
<div className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'header');
}}
>
Header
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
</div>
</Dropdown>
</div>
</StyledWrapper>
);
};
export default ApiKeyAuth;

View File

@ -30,7 +30,6 @@ const AuthMode = ({ item, collection }) => {
}) })
); );
}; };
return ( return (
<StyledWrapper> <StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector"> <div className="inline-flex items-center cursor-pointer auth-mode-selector">
@ -80,6 +79,24 @@ const AuthMode = ({ item, collection }) => {
> >
OAuth 2.0 OAuth 2.0
</div> </div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div <div
className="dropdown-item" className="dropdown-item"
onClick={() => { onClick={() => {

View File

@ -0,0 +1,17 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
`;
export default Wrapper;

View File

@ -0,0 +1,76 @@
import React from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleUserChange = (username) => {
dispatch(
updateAuth({
mode: 'wsse',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username,
password: wsseAuth.password
}
})
);
};
const handlePasswordChange = (password) => {
dispatch(
updateAuth({
mode: 'wsse',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: wsseAuth.username,
password
}
})
);
};
return (
<StyledWrapper className="mt-2 w-full">
<label className="block font-medium mb-2">Username</label>
<div className="single-line-editor-wrapper mb-2">
<SingleLineEditor
value={wsseAuth.username || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
<label className="block font-medium mb-2">Password</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={wsseAuth.password || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
/>
</div>
</StyledWrapper>
);
};
export default WsseAuth;

View File

@ -5,6 +5,8 @@ import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth'; import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth'; import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth'; import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections/index'; import { humanizeRequestAuthMode } from 'utils/collections/index';
import OAuth2 from './OAuth2/index'; import OAuth2 from './OAuth2/index';
@ -32,6 +34,12 @@ const Auth = ({ item, collection }) => {
case 'oauth2': { case 'oauth2': {
return <OAuth2 collection={collection} item={item} />; return <OAuth2 collection={collection} item={item} />;
} }
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
}
case 'inherit': { case 'inherit': {
return ( return (
<div className="flex flex-row w-full mt-2 gap-2"> <div className="flex flex-row w-full mt-2 gap-2">

View File

@ -1,14 +1,16 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get'; import get from 'lodash/get';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections'; import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector'; import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme'; import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight } from '@tabler/icons'; import { IconDeviceFloppy, IconArrowRight, IconCode } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor'; import SingleLineEditor from 'components/SingleLineEditor';
import { isMacOS } from 'utils/common/platform'; import { isMacOS } from 'utils/common/platform';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
import GenerateCodeItem from 'components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index';
import toast from 'react-hot-toast';
const QueryUrl = ({ item, collection, handleRun }) => { const QueryUrl = ({ item, collection, handleRun }) => {
const { theme, storedTheme } = useTheme(); const { theme, storedTheme } = useTheme();
@ -17,26 +19,43 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''); const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const isMac = isMacOS(); const isMac = isMacOS();
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S'; const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const editorRef = useRef(null);
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90); const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
const el = document.querySelector('.method-selector-container'); const el = document.querySelector('.method-selector-container');
setMethodSelectorWidth(el.offsetWidth); setMethodSelectorWidth(el.offsetWidth);
}, [method]); }, [method]);
const onSave = () => { const onSave = (finalValue) => {
dispatch(saveRequest(item.uid, collection.uid)); dispatch(saveRequest(item.uid, collection.uid));
}; };
const onUrlChange = (value) => { const onUrlChange = (value) => {
if (!editorRef.current?.editor) return;
const editor = editorRef.current.editor;
const cursor = editor.getCursor();
const finalUrl = value?.trim() ?? value;
dispatch( dispatch(
requestUrlChanged({ requestUrlChanged({
itemUid: item.uid, itemUid: item.uid,
collectionUid: collection.uid, collectionUid: collection.uid,
url: value && typeof value === 'string' ? value.trim() : value url: finalUrl
}) })
); );
// Restore cursor position only if URL was trimmed
if (finalUrl !== value) {
setTimeout(() => {
if (editor) {
editor.setCursor(cursor);
}
}, 0);
}
}; };
const onMethodSelect = (verb) => { const onMethodSelect = (verb) => {
@ -49,6 +68,15 @@ const QueryUrl = ({ item, collection, handleRun }) => {
); );
}; };
const handleGenerateCode = (e) => {
e.stopPropagation();
if (item.request.url !== '' || (item.draft?.request.url !== undefined && item.draft?.request.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
return ( return (
<StyledWrapper className="flex items-center"> <StyledWrapper className="flex items-center">
<div className="flex items-center h-full method-selector-container"> <div className="flex items-center h-full method-selector-container">
@ -63,8 +91,9 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}} }}
> >
<SingleLineEditor <SingleLineEditor
ref={editorRef}
value={url} value={url}
onSave={onSave} onSave={(finalValue) => onSave(finalValue)}
theme={storedTheme} theme={storedTheme}
onChange={(newValue) => onUrlChange(newValue)} onChange={(newValue) => onUrlChange(newValue)}
onRun={handleRun} onRun={handleRun}
@ -73,6 +102,22 @@ const QueryUrl = ({ item, collection, handleRun }) => {
item={item} item={item}
/> />
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}> <div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<div
className="infotip mr-3"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={'cursor-pointer'}
/>
<span className="infotiptext text-xs">
Generate Code
</span>
</div>
<div <div
className="infotip mr-3" className="infotip mr-3"
onClick={(e) => { onClick={(e) => {
@ -94,6 +139,9 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} /> <IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} />
</div> </div>
</div> </div>
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
</StyledWrapper> </StyledWrapper>
); );
}; };

View File

@ -0,0 +1,10 @@
const CloseTabIcon = () => (
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon">
<path
fill="currentColor"
d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"
></path>
</svg>
);
export default CloseTabIcon;

View File

@ -0,0 +1,15 @@
const DraftTabIcon = () => (
<svg
focusable="false"
xmlns="http://www.w3.org/2000/svg"
width="8"
height="16"
fill="#cc7b1b"
className="has-changes-icon"
viewBox="0 0 8 8"
>
<circle cx="4" cy="4" r="3" />
</svg>
);
export default DraftTabIcon;

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { IconAlertTriangle } from '@tabler/icons'; import { IconAlertTriangle } from '@tabler/icons';
import CloseTabIcon from './CloseTabIcon';
const RequestTabNotFound = ({ handleCloseClick }) => { const RequestTabNotFound = ({ handleCloseClick }) => {
const [showErrorMessage, setShowErrorMessage] = useState(false); const [showErrorMessage, setShowErrorMessage] = useState(false);
@ -28,12 +29,7 @@ const RequestTabNotFound = ({ handleCloseClick }) => {
) : null} ) : null}
</div> </div>
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}> <div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon"> <CloseTabIcon />
<path
fill="currentColor"
d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"
></path>
</svg>
</div> </div>
</> </>
); );

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import CloseTabIcon from './CloseTabIcon';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons'; import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName }) => { const SpecialTab = ({ handleCloseClick, type, tabName }) => {
@ -51,12 +52,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
<> <>
<div className="flex items-center tab-label pl-2">{getTabInfo(type, tabName)}</div> <div className="flex items-center tab-label pl-2">{getTabInfo(type, tabName)}</div>
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}> <div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon"> <CloseTabIcon />
<path
fill="currentColor"
d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"
></path>
</svg>
</div> </div>
</> </>
); );

View File

@ -15,6 +15,9 @@ import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown'; import Dropdown from 'components/Dropdown';
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index'; import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
import NewRequest from 'components/Sidebar/NewRequest/index'; import NewRequest from 'components/Sidebar/NewRequest/index';
import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import { flattenItems } from 'utils/collections/index';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => { const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -49,9 +52,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const handleMouseUp = (e) => { const handleMouseUp = (e) => {
if (e.button === 1) { if (e.button === 1) {
e.stopPropagation();
e.preventDefault(); e.preventDefault();
e.stopPropagation();
// Close the tab
dispatch( dispatch(
closeTabs({ closeTabs({
tabUids: [tab.uid] tabUids: [tab.uid]
@ -68,7 +72,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const folder = folderUid ? findItemInCollection(collection, folderUid) : null; const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) { if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return ( return (
<StyledWrapper className="flex items-center justify-between tab-container px-1"> <StyledWrapper
className="flex items-center justify-between tab-container px-1"
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{tab.type === 'folder-settings' ? ( {tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} type={tab.type} tabName={folder?.name} /> <SpecialTab handleCloseClick={handleCloseClick} type={tab.type} tabName={folder?.name} />
) : ( ) : (
@ -82,7 +89,17 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
if (!item) { if (!item) {
return ( return (
<StyledWrapper className="flex items-center justify-between tab-container px-1"> <StyledWrapper
className="flex items-center justify-between tab-container px-1"
onMouseUp={(e) => {
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
}}
>
<RequestTabNotFound handleCloseClick={handleCloseClick} /> <RequestTabNotFound handleCloseClick={handleCloseClick} />
</StyledWrapper> </StyledWrapper>
); );
@ -166,24 +183,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}} }}
> >
{!item.draft ? ( {!item.draft ? (
<svg focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" className="close-icon"> <CloseTabIcon />
<path
fill="currentColor"
d="M207.6 256l107.72-107.72c6.23-6.23 6.23-16.34 0-22.58l-25.03-25.03c-6.23-6.23-16.34-6.23-22.58 0L160 208.4 52.28 100.68c-6.23-6.23-16.34-6.23-22.58 0L4.68 125.7c-6.23 6.23-6.23 16.34 0 22.58L112.4 256 4.68 363.72c-6.23 6.23-6.23 16.34 0 22.58l25.03 25.03c6.23 6.23 16.34 6.23 22.58 0L160 303.6l107.72 107.72c6.23 6.23 16.34 6.23 22.58 0l25.03-25.03c6.23-6.23 6.23-16.34 0-22.58L207.6 256z"
></path>
</svg>
) : ( ) : (
<svg <DraftTabIcon />
focusable="false"
xmlns="http://www.w3.org/2000/svg"
width="8"
height="16"
fill="#cc7b1b"
className="has-changes-icon"
viewBox="0 0 8 8"
>
<circle cx="4" cy="4" r="3" />
</svg>
)} )}
</div> </div>
</StyledWrapper> </StyledWrapper>
@ -245,8 +247,9 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
function handleCloseSavedTabs(event) { function handleCloseSavedTabs(event) {
event.stopPropagation(); event.stopPropagation();
const savedTabs = collection.items.filter((item) => !item.draft); const items = flattenItems(collection?.items);
const savedTabIds = savedTabs.map((item) => item.uid) || []; const savedTabs = items?.filter?.((item) => !item.draft);
const savedTabIds = savedTabs?.map((item) => item.uid) || [];
dispatch(closeTabs({ tabUids: savedTabIds })); dispatch(closeTabs({ tabUids: savedTabIds }));
} }

View File

@ -29,7 +29,7 @@ const QueryResultPreview = ({
setNumPages(numPages); setNumPages(numPages);
} }
// Fail safe, so we don't render anything with an invalid tab // Fail safe, so we don't render anything with an invalid tab
if (!allowedPreviewModes.includes(previewTab)) { if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
return null; return null;
} }
@ -40,7 +40,7 @@ const QueryResultPreview = ({
dispatch(sendRequest(item, collection.uid)); dispatch(sendRequest(item, collection.uid));
}; };
switch (previewTab) { switch (previewTab?.mode) {
case 'preview-web': { case 'preview-web': {
const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`); const webViewSrc = data.replace('<head>', `<head><base href="${item.requestSent?.url || ''}">`);
return ( return (

View File

@ -12,18 +12,35 @@ import { useState } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTheme } from 'providers/Theme/index'; import { useTheme } from 'providers/Theme/index';
import { uuid } from 'utils/common/index';
const formatResponse = (data, mode, filter) => { const formatResponse = (data, mode, filter) => {
if (data === undefined) { if (data === undefined) {
return ''; return '';
} }
if (data === null) {
return data;
}
if (mode.includes('json')) { if (mode.includes('json')) {
let isValidJSON = false;
try {
isValidJSON = typeof JSON.parse(JSON.stringify(data)) === 'object';
} catch (error) {
console.log('Error parsing JSON: ', error.message);
}
if (!isValidJSON && typeof data === 'string') {
return data;
}
if (filter) { if (filter) {
try { try {
data = JSONPath({ path: filter, json: data }); data = JSONPath({ path: filter, json: data });
} catch (e) { } catch (e) {
console.warn('Could not filter with JSONPath.', e.message); console.warn('Could not apply JSONPath filter:', e.message);
} }
} }
@ -35,7 +52,6 @@ const formatResponse = (data, mode, filter) => {
if (typeof parsed === 'string') { if (typeof parsed === 'string') {
return parsed; return parsed;
} }
return safeStringifyJSON(parsed, true); return safeStringifyJSON(parsed, true);
} }
@ -43,7 +59,7 @@ const formatResponse = (data, mode, filter) => {
return data; return data;
} }
return safeStringifyJSON(data); return safeStringifyJSON(data, true);
}; };
const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => { const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
@ -59,18 +75,18 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
const allowedPreviewModes = useMemo(() => { const allowedPreviewModes = useMemo(() => {
// Always show raw // Always show raw
const allowedPreviewModes = ['raw']; const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
if (mode.includes('html') && typeof data === 'string') { if (mode.includes('html') && typeof data === 'string') {
allowedPreviewModes.unshift('preview-web'); allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
} else if (mode.includes('image')) { } else if (mode.includes('image')) {
allowedPreviewModes.unshift('preview-image'); allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
} else if (contentType.includes('pdf')) { } else if (contentType.includes('pdf')) {
allowedPreviewModes.unshift('preview-pdf'); allowedPreviewModes.unshift({ mode: 'preview-pdf', name: 'PDF', uid: uuid() });
} else if (contentType.includes('audio')) { } else if (contentType.includes('audio')) {
allowedPreviewModes.unshift('preview-audio'); allowedPreviewModes.unshift({ mode: 'preview-audio', name: 'Audio', uid: uuid() });
} else if (contentType.includes('video')) { } else if (contentType.includes('video')) {
allowedPreviewModes.unshift('preview-video'); allowedPreviewModes.unshift({ mode: 'preview-video', name: 'Video', uid: uuid() });
} }
return allowedPreviewModes; return allowedPreviewModes;
@ -79,7 +95,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]); const [previewTab, setPreviewTab] = useState(allowedPreviewModes[0]);
// Ensure the active Tab is always allowed // Ensure the active Tab is always allowed
useEffect(() => { useEffect(() => {
if (!allowedPreviewModes.includes(previewTab)) { if (!allowedPreviewModes.find((previewMode) => previewMode?.uid == previewTab?.uid)) {
setPreviewTab(allowedPreviewModes[0]); setPreviewTab(allowedPreviewModes[0]);
} }
}, [previewTab, allowedPreviewModes]); }, [previewTab, allowedPreviewModes]);
@ -91,12 +107,15 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
return allowedPreviewModes.map((previewMode) => ( return allowedPreviewModes.map((previewMode) => (
<div <div
className={classnames('select-none capitalize', previewMode === previewTab ? 'active' : 'cursor-pointer')} className={classnames(
'select-none capitalize',
previewMode?.uid === previewTab?.uid ? 'active' : 'cursor-pointer'
)}
role="tab" role="tab"
onClick={() => setPreviewTab(previewMode)} onClick={() => setPreviewTab(previewMode)}
key={previewMode} key={previewMode?.uid}
> >
{previewMode.replace(/-(.*)/, ' ')} {previewMode?.name}
</div> </div>
)); ));
}, [allowedPreviewModes, previewTab]); }, [allowedPreviewModes, previewTab]);

View File

@ -15,7 +15,7 @@ import StyledWrapper from './StyledWrapper';
const ResponsePane = ({ rightPaneWidth, item, collection }) => { const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response'); const [selectedTab, setSelectedTab] = useState('response');
const { requestSent, responseReceived, testResults, assertionResults } = item; const { requestSent, responseReceived, testResults, assertionResults, error } = item;
const headers = get(item, 'responseReceived.headers', []); const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0); const status = get(item, 'responseReceived.status', 0);
@ -36,6 +36,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
data={responseReceived.data} data={responseReceived.data}
dataBuffer={responseReceived.dataBuffer} dataBuffer={responseReceived.dataBuffer}
headers={responseReceived.headers} headers={responseReceived.headers}
error={error}
key={item.filename} key={item.filename}
/> />
); );

View File

@ -41,7 +41,7 @@ const CloneCollection = ({ onClose, collection }) => {
) )
) )
.then(() => { .then(() => {
toast.success('Collection created'); toast.success('Collection created!');
onClose(); onClose();
}) })
.catch((e) => toast.error('An error occurred while creating the collection - ' + e)); .catch((e) => toast.error('An error occurred while creating the collection - ' + e));
@ -72,7 +72,7 @@ const CloneCollection = ({ onClose, collection }) => {
return ( return (
<Modal size="sm" title="Clone Collection" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="sm" title="Clone Collection" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="collection-name" className="flex items-center font-semibold"> <label htmlFor="collection-name" className="flex items-center font-semibold">
Name Name

View File

@ -25,6 +25,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
onSubmit: (values) => { onSubmit: (values) => {
dispatch(cloneItem(values.name, item.uid, collection.uid)) dispatch(cloneItem(values.name, item.uid, collection.uid))
.then(() => { .then(() => {
toast.success('Request cloned!');
onClose(); onClose();
}) })
.catch((err) => { .catch((err) => {
@ -49,7 +50,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
handleConfirm={onSubmit} handleConfirm={onSubmit}
handleCancel={onClose} handleCancel={onClose}
> >
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name {isFolder ? 'Folder' : 'Request'} Name

View File

@ -8,8 +8,9 @@ const StyledWrapper = styled.div`
.generate-code-sidebar { .generate-code-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg}; background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight}; border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
min-height: 400px; max-height: 80vh;
height: 100%; height: 100%;
overflow-y: auto;
} }
.generate-code-item { .generate-code-item {

View File

@ -6,56 +6,11 @@ import { isValidUrl } from 'utils/url';
import { find, get } from 'lodash'; import { find, get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections'; import { findEnvironmentInCollection } from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index'; import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
const languages = [
{
name: 'HTTP',
target: 'http',
client: 'http1.1'
},
{
name: 'JavaScript-Fetch',
target: 'javascript',
client: 'fetch'
},
{
name: 'Javascript-jQuery',
target: 'javascript',
client: 'jquery'
},
{
name: 'Javascript-axios',
target: 'javascript',
client: 'axios'
},
{
name: 'Python-Python3',
target: 'python',
client: 'python3'
},
{
name: 'Python-Requests',
target: 'python',
client: 'requests'
},
{
name: 'PHP',
target: 'php',
client: 'curl'
},
{
name: 'Shell-curl',
target: 'shell',
client: 'curl'
},
{
name: 'Shell-httpie',
target: 'shell',
client: 'httpie'
}
];
const GenerateCodeItem = ({ collection, item, onClose }) => { const GenerateCodeItem = ({ collection, item, onClose }) => {
const languages = getLanguages();
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid); const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
let envVars = {}; let envVars = {};
if (environment) { if (environment) {

View File

@ -5,6 +5,7 @@ import Modal from 'components/Modal';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs'; import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
const RenameCollectionItem = ({ collection, item, onClose }) => { const RenameCollectionItem = ({ collection, item, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
@ -27,8 +28,14 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
if (!isFolder && item.draft) { if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true)); await dispatch(saveRequest(item.uid, collection.uid, true));
} }
dispatch(renameItem(values.name, item.uid, collection.uid)); dispatch(renameItem(values.name, item.uid, collection.uid))
onClose(); .then(() => {
toast.success('Request renamed!');
onClose();
})
.catch((err) => {
toast.error(err ? err.message : 'An error occurred while renaming the request');
});
} }
}); });
@ -48,7 +55,7 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
handleConfirm={onSubmit} handleConfirm={onSubmit}
handleCancel={onClose} handleCancel={onClose}
> >
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
{isFolder ? 'Folder' : 'Request'} Name {isFolder ? 'Folder' : 'Request'} Name

View File

@ -23,43 +23,53 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
onClose(); onClose();
}; };
const runLength = item ? get(item, 'items.length', 0) : get(collection, 'items.length', 0); const getRequestsCount = (items) => {
const items = flattenItems(item ? item.items : collection.items); const requestTypes = ['http-request', 'graphql-request']
const requestItems = items.filter((item) => item.type !== 'folder'); return items.filter(req => requestTypes.includes(req.type)).length;
const recursiveRunLength = requestItems.length; }
const runLength = item ? getRequestsCount(item.items) : get(collection, 'items.length', 0);
const flattenedItems = flattenItems(item ? item.items : collection.items);
const recursiveRunLength = getRequestsCount(flattenedItems);
return ( return (
<StyledWrapper> <StyledWrapper>
<Modal size="md" title="Collection Runner" hideFooter={true} handleCancel={onClose}> <Modal size="md" title="Collection Runner" hideFooter={true} handleCancel={onClose}>
<div className="mb-1"> {!runLength && !recursiveRunLength ? (
<span className="font-medium">Run</span> <div className="mb-8">No request found in this folder.</div>
<span className="ml-1 text-xs">({runLength} requests)</span> ) : (
</div> <div>
<div className="mb-8">This will only run the requests in this folder.</div> <div className="mb-1">
<span className="font-medium">Run</span>
<span className="ml-1 text-xs">({runLength} requests)</span>
</div>
<div className="mb-8">This will only run the requests in this folder.</div>
<div className="mb-1"> <div className="mb-1">
<span className="font-medium">Recursive Run</span> <span className="font-medium">Recursive Run</span>
<span className="ml-1 text-xs">({recursiveRunLength} requests)</span> <span className="ml-1 text-xs">({recursiveRunLength} requests)</span>
</div> </div>
<div className="mb-8">This will run all the requests in this folder and all its subfolders.</div> <div className="mb-8">This will run all the requests in this folder and all its subfolders.</div>
<div className="flex justify-end bruno-modal-footer"> <div className="flex justify-end bruno-modal-footer">
<span className="mr-3"> <span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close"> <button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel Cancel
</button> </button>
</span> </span>
<span> <span>
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}> <button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run Recursive Run
</button> </button>
</span> </span>
<span> <span>
<button type="submit" className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}> <button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run Run
</button> </button>
</span> </span>
</div> </div>
</div>
)}
</Modal> </Modal>
</StyledWrapper> </StyledWrapper>
); );

View File

@ -21,9 +21,14 @@ const RenameCollection = ({ collection, onClose }) => {
.required('name is required') .required('name is required')
}), }),
onSubmit: (values) => { onSubmit: (values) => {
dispatch(renameCollection(values.name, collection.uid)); dispatch(renameCollection(values.name, collection.uid))
toast.success('Collection renamed!'); .then(() => {
onClose(); toast.success('Collection renamed!');
onClose();
})
.catch((err) => {
toast.error(err ? err.message : 'An error occurred while renaming the collection');
});
} }
}); });
@ -37,7 +42,7 @@ const RenameCollection = ({ collection, onClose }) => {
return ( return (
<Modal size="sm" title="Rename Collection" confirmText="Rename" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="sm" title="Rename Collection" confirmText="Rename" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="name" className="block font-semibold"> <label htmlFor="name" className="block font-semibold">
Name Name

View File

@ -34,7 +34,7 @@ const CreateCollection = ({ onClose }) => {
onSubmit: (values) => { onSubmit: (values) => {
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation)) dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
.then(() => { .then(() => {
toast.success('Collection created'); toast.success('Collection created!');
onClose(); onClose();
}) })
.catch((e) => toast.error('An error occurred while creating the collection - ' + e)); .catch((e) => toast.error('An error occurred while creating the collection - ' + e));
@ -65,7 +65,7 @@ const CreateCollection = ({ onClose }) => {
return ( return (
<Modal size="sm" title="Create Collection" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="sm" title="Create Collection" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="collection-name" className="flex items-center font-semibold"> <label htmlFor="collection-name" className="flex items-center font-semibold">
Name Name

View File

@ -144,7 +144,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
return ( return (
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="collectionName" className="block font-semibold"> <label htmlFor="collectionName" className="block font-semibold">
Name Name

View File

@ -32,7 +32,10 @@ const NewFolder = ({ collection, item, onClose }) => {
}), }),
onSubmit: (values) => { onSubmit: (values) => {
dispatch(newFolder(values.folderName, collection.uid, item ? item.uid : null)) dispatch(newFolder(values.folderName, collection.uid, item ? item.uid : null))
.then(() => onClose()) .then(() => {
toast.success('New folder created!');
onClose()
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the folder')); .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the folder'));
} }
}); });
@ -47,7 +50,7 @@ const NewFolder = ({ collection, item, onClose }) => {
return ( return (
<Modal size="sm" title="New Folder" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="sm" title="New Folder" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={formik.handleSubmit}> <form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div> <div>
<label htmlFor="folderName" className="block font-semibold"> <label htmlFor="folderName" className="block font-semibold">
Folder Name Folder Name

View File

@ -113,7 +113,10 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
auth: request.auth auth: request.auth
}) })
) )
.then(() => onClose()) .then(() => {
toast.success('New request created!');
onClose()
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else { } else {
dispatch( dispatch(
@ -126,7 +129,10 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
itemUid: item ? item.uid : null itemUid: item ? item.uid : null
}) })
) )
.then(() => onClose()) .then(() => {
toast.success('New request created!');
onClose()
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request')); .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} }
} }
@ -162,16 +168,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
return ( return (
<StyledWrapper> <StyledWrapper>
<Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}> <Modal size="md" title="New Request" confirmText="Create" handleConfirm={onSubmit} handleCancel={onClose}>
<form <form className="bruno-form" onSubmit={e => e.preventDefault()}>
className="bruno-form"
onSubmit={formik.handleSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.handleSubmit();
}
}}
>
<div> <div>
<label htmlFor="requestName" className="block font-semibold"> <label htmlFor="requestName" className="block font-semibold">
Type Type

View File

@ -82,16 +82,12 @@ const TitleBar = () => {
) : null} ) : null}
<div className="flex items-center"> <div className="flex items-center">
<div className="flex items-center cursor-pointer" onClick={handleTitleClick}> <button className="flex items-center gap-2 text-sm font-medium" onClick={handleTitleClick}>
<Bruno width={30} /> <span aria-hidden>
</div> <Bruno width={30} />
<div </span>
onClick={handleTitleClick}
className="flex items-center font-medium select-none cursor-pointer"
style={{ fontSize: 14, paddingLeft: 6, position: 'relative', top: -1 }}
>
bruno bruno
</div> </button>
<div className="collection-dropdown flex flex-grow items-center justify-end"> <div className="collection-dropdown flex flex-grow items-center justify-end">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start"> <Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div <div

View File

@ -20,7 +20,7 @@ const MAX_LEFT_SIDEBAR_WIDTH = 600;
const Sidebar = () => { const Sidebar = () => {
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth); const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const preferencesOpen = useSelector((state) => state.app.showPreferences); const preferencesOpen = useSelector((state) => state.app.showPreferences);
const [goldenEditonOpen, setGoldenEditonOpen] = useState(false); const [goldenEditionOpen, setGoldenEditionOpen] = useState(false);
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth); const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
const [cookiesOpen, setCookiesOpen] = useState(false); const [cookiesOpen, setCookiesOpen] = useState(false);
@ -83,10 +83,43 @@ const Sidebar = () => {
return ( return (
<StyledWrapper className="flex relative h-screen"> <StyledWrapper className="flex relative h-screen">
<aside> <aside>
{goldenEditonOpen && <GoldenEdition onClose={() => setGoldenEditonOpen(false)} />} {goldenEditionOpen && (
<GoldenEdition
onClose={() => {
setGoldenEditionOpen(false);
document.querySelector('[data-trigger="golden-edition"]').focus();
}}
aria-modal="true"
role="dialog"
aria-labelledby="golden-edition-title"
aria-describedby="golden-edition-description"
/>
)}
<div className="flex flex-row h-screen w-full"> <div className="flex flex-row h-screen w-full">
{preferencesOpen && <Preferences onClose={() => dispatch(showPreferences(false))} />} {preferencesOpen && (
{cookiesOpen && <Cookies onClose={() => setCookiesOpen(false)} />} <Preferences
onClose={() => {
dispatch(showPreferences(false));
document.querySelector('[data-trigger="preferences"]').focus();
}}
aria-modal="true"
role="dialog"
aria-labelledby="preferences-title"
aria-describedby="preferences-description"
/>
)}
{cookiesOpen && (
<Cookies
onClose={() => {
setCookiesOpen(false);
document.querySelector('[data-trigger="cookies"]').focus();
}}
aria-modal="true"
role="dialog"
aria-labelledby="cookies-title"
aria-describedby="cookies-description"
/>
)}
<div className="flex flex-col w-full" style={{ width: asideWidth }}> <div className="flex flex-col w-full" style={{ width: asideWidth }}>
<div className="flex flex-col flex-grow"> <div className="flex flex-col flex-grow">
@ -96,30 +129,50 @@ const Sidebar = () => {
<div className="footer flex px-1 py-2 absolute bottom-0 left-0 right-0 items-center select-none"> <div className="footer flex px-1 py-2 absolute bottom-0 left-0 right-0 items-center select-none">
<div className="flex items-center ml-1 text-xs "> <div className="flex items-center ml-1 text-xs ">
<a className="mr-2 cursor-pointer" onClick={() => dispatch(showPreferences(true))}> <ul role="menubar" className="flex space-x-2">
<ToolHint text="Preferences" toolhintId="Preferences" effect='float' place='top-start' offset={8}> <li role="menuitem">
<IconSettings size={18} strokeWidth={1.5} /> <a
</ToolHint> className="cursor-pointer"
</a> data-trigger="preferences"
<a onClick={() => dispatch(showPreferences(true))}
className="mr-2 cursor-pointer" tabIndex={0}
onClick={() => setCookiesOpen(true)} aria-label="Open Preferences"
> >
<ToolHint text="Cookies" toolhintId="Cookies" offset={8}> <ToolHint text="Preferences" toolhintId="Preferences" effect="float" place="top-start" offset={8}>
<IconCookie size={18} strokeWidth={1.5} /> <IconSettings size={18} strokeWidth={1.5} aria-hidden="true" />
</ToolHint> </ToolHint>
</a> </a>
<a </li>
className="mr-2 cursor-pointer" <li role="menuitem">
onClick={() => setGoldenEditonOpen(true)} <a
> className="cursor-pointer"
<ToolHint text="Golden Edition" toolhintId="Golden Edition" offset={8} > data-trigger="cookies"
<IconHeart size={18} strokeWidth={1.5} /> onClick={() => setCookiesOpen(true)}
</ToolHint> tabIndex={0}
</a> aria-label="Open Cookies Settings"
<a> >
<Notifications /> <ToolHint text="Cookies" toolhintId="Cookies" offset={8}>
</a> <IconCookie size={18} strokeWidth={1.5} aria-hidden="true" />
</ToolHint>
</a>
</li>
<li role="menuitem">
<a
className="cursor-pointer"
data-trigger="golden-edition"
onClick={() => setGoldenEditionOpen(true)}
tabIndex={0}
aria-label="Open Golden Edition"
>
<ToolHint text="Golden Edition" toolhintId="Golden Edition" offset={8}>
<IconHeart size={18} strokeWidth={1.5} aria-hidden="true" />
</ToolHint>
</a>
</li>
<li role="menuitem">
<Notifications />
</li>
</ul>
</div> </div>
<div className="pl-1" style={{ position: 'relative', top: '3px' }}> <div className="pl-1" style={{ position: 'relative', top: '3px' }}>
{/* This will get moved to home page */} {/* This will get moved to home page */}
@ -129,18 +182,19 @@ const Sidebar = () => {
data-show-count="true" data-show-count="true"
aria-label="Star usebruno/bruno on GitHub" aria-label="Star usebruno/bruno on GitHub"
> >
Star Star
</GitHubButton> */} </GitHubButton> */}
</div> </div>
<div className="flex flex-grow items-center justify-end text-xs mr-2">v1.28.0</div> <div className="flex flex-grow items-center justify-end text-xs mr-2">v1.30.1</div>
</div> </div>
</div> </div>
</div> </div>
</aside> </aside>
<div className="absolute drag-sidebar h-full" onMouseDown={handleDragbarMouseDown}> <div className="absolute drag-sidebar h-full" onMouseDown={handleDragbarMouseDown}>
<div className="drag-request-border" /> <div className="drag-request-border" />
</div> </div>
</StyledWrapper > </StyledWrapper>
); );
}; };

View File

@ -1,25 +1,23 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
const StopWatch = ({ requestTimestamp }) => { const StopWatch = () => {
const [milliseconds, setMilliseconds] = useState(0); const [milliseconds, setMilliseconds] = useState(0);
const tickInterval = 200; const tickInterval = 100;
const tick = () => { const tick = () => {
setMilliseconds(milliseconds + tickInterval); setMilliseconds(_milliseconds => _milliseconds + tickInterval);
}; };
useEffect(() => { useEffect(() => {
let timerID = setInterval(() => tick(), tickInterval); let timerID = setInterval(() => {
tick()
}, tickInterval);
return () => { return () => {
clearInterval(timerID); clearTimeout(timerID);
}; };
}); }, []);
useEffect(() => { if (milliseconds < 250) {
setMilliseconds(Date.now() - requestTimestamp);
}, [requestTimestamp]);
if (milliseconds < 1000) {
return 'Loading...'; return 'Loading...';
} }
@ -27,4 +25,4 @@ const StopWatch = ({ requestTimestamp }) => {
return <span>{seconds.toFixed(1)}s</span>; return <span>{seconds.toFixed(1)}s</span>;
}; };
export default StopWatch; export default React.memo(StopWatch);

View File

@ -27,6 +27,7 @@ const StyledWrapper = styled.div`
table th { table th {
position: relative; position: relative;
border-bottom: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder}77;
} }
table tr td { table tr td {

View File

@ -21,9 +21,7 @@ const Welcome = () => {
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false); const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const handleOpenCollection = () => { const handleOpenCollection = () => {
dispatch(openCollection()).catch( dispatch(openCollection()).catch((err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR')));
(err) => console.log(err) && toast.error(t('WELCOME.COLLECTION_OPEN_ERROR'))
);
}; };
const handleImportCollection = ({ collection, translationLog }) => { const handleImportCollection = ({ collection, translationLog }) => {
@ -64,7 +62,7 @@ const Welcome = () => {
/> />
) : null} ) : null}
<div> <div aria-hidden className="">
<Bruno width={50} /> <Bruno width={50} />
</div> </div>
<div className="text-xl font-semibold select-none">bruno</div> <div className="text-xl font-semibold select-none">bruno</div>
@ -72,40 +70,69 @@ const Welcome = () => {
<div className="uppercase font-semibold heading mt-10">{t('COMMON.COLLECTIONS')}</div> <div className="uppercase font-semibold heading mt-10">{t('COMMON.COLLECTIONS')}</div>
<div className="mt-4 flex items-center collection-options select-none"> <div className="mt-4 flex items-center collection-options select-none">
<div className="flex items-center" onClick={() => setCreateCollectionModalOpen(true)}> <button
<IconPlus size={18} strokeWidth={2} /> className="flex items-center"
onClick={() => setCreateCollectionModalOpen(true)}
aria-label={t('WELCOME.CREATE_COLLECTION')}
>
<IconPlus aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2" id="create-collection"> <span className="label ml-2" id="create-collection">
{t('WELCOME.CREATE_COLLECTION')} {t('WELCOME.CREATE_COLLECTION')}
</span> </span>
</div> </button>
<div className="flex items-center ml-6" onClick={handleOpenCollection}>
<IconFolders size={18} strokeWidth={2} /> <button className="flex items-center ml-6" onClick={handleOpenCollection} aria-label="Open Collection">
<IconFolders aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('WELCOME.OPEN_COLLECTION')}</span> <span className="label ml-2">{t('WELCOME.OPEN_COLLECTION')}</span>
</div> </button>
<div className="flex items-center ml-6" onClick={() => setImportCollectionModalOpen(true)}>
<IconDownload size={18} strokeWidth={2} /> <button
className="flex items-center ml-6"
onClick={() => setImportCollectionModalOpen(true)}
aria-label={t('WELCOME.IMPORT_COLLECTION')}
>
<IconDownload aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2" id="import-collection"> <span className="label ml-2" id="import-collection">
{t('WELCOME.IMPORT_COLLECTION')} {t('WELCOME.IMPORT_COLLECTION')}
</span> </span>
</div> </button>
</div> </div>
<div className="uppercase font-semibold heading mt-10 pt-6">{t('WELCOME.LINKS')}</div> <div className="uppercase font-semibold heading mt-10 pt-6">{t('WELCOME.LINKS')}</div>
<div className="mt-4 flex flex-col collection-options select-none"> <div className="mt-4 flex flex-col collection-options select-none">
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://docs.usebruno.com" target="_blank" className="inline-flex items-center"> <a
<IconBook size={18} strokeWidth={2} /> href="https://docs.usebruno.com"
aria-label="Read documentation"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<IconBook aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span> <span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span>
</a> </a>
</div> </div>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno/issues" target="_blank" className="inline-flex items-center"> <a
<IconSpeakerphone size={18} strokeWidth={2} /> href="https://github.com/usebruno/bruno/issues"
aria-label="Report issues on GitHub"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<IconSpeakerphone aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('COMMON.REPORT_ISSUES')}</span> <span className="label ml-2">{t('COMMON.REPORT_ISSUES')}</span>
</a> </a>
</div> </div>
<div className="flex items-center mt-2"> <div className="flex items-center mt-2">
<a href="https://github.com/usebruno/bruno" target="_blank" className="flex items-center"> <a
<IconBrandGithub size={18} strokeWidth={2} /> href="https://github.com/usebruno/bruno"
aria-label="Go to GitHub repository"
target="_blank"
rel="noopener noreferrer"
className="flex items-center"
>
<IconBrandGithub aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2">{t('COMMON.GITHUB')}</span> <span className="label ml-2">{t('COMMON.GITHUB')}</span>
</a> </a>
</div> </div>

View File

@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react';
const useFocusTrap = (modalRef) => {
// refer to this implementation for modal focus: https://stackoverflow.com/a/38865836
const focusableSelector = 'a[href], area[href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, *[tabindex]:not([tabindex="-1"]), *[contenteditable]';
useEffect(() => {
const modalElement = modalRef.current;
if (!modalElement) return;
const focusableElements = Array.from(document.querySelectorAll(focusableSelector));
const modalFocusableElements = Array.from(modalElement.querySelectorAll(focusableSelector));
const elementsToHide = focusableElements.filter(el => !modalFocusableElements.includes(el));
// Hide elements outside the modal
elementsToHide.forEach(el => {
const originalTabIndex = el.getAttribute('tabindex');
el.setAttribute('data-tabindex', originalTabIndex || 'inline');
el.setAttribute('tabindex', -1);
});
// Set focus to the first focusable element in the modal
const firstElement = modalFocusableElements[0];
const lastElement = modalFocusableElements[modalFocusableElements.length - 1];
const handleKeyDown = (event) => {
if (event.key === 'Tab') {
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
modalElement.addEventListener('keydown', handleKeyDown);
return () => {
modalElement.removeEventListener('keydown', handleKeyDown);
// Restore original tabindex values
elementsToHide.forEach(el => {
const originalTabIndex = el.getAttribute('data-tabindex');
el.setAttribute('tabindex', originalTabIndex === 'inline' ? '' : originalTabIndex);
});
};
}, [modalRef]);
};
export default useFocusTrap;

View File

@ -60,7 +60,7 @@ const trackStart = () => {
event: 'start', event: 'start',
properties: { properties: {
os: platformLib.os.family, os: platformLib.os.family,
version: '1.28.0' version: '1.30.1'
} }
}); });
}; };

View File

@ -7,7 +7,7 @@ import SaveRequest from 'components/RequestPane/SaveRequest';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings'; import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import NetworkError from 'components/ResponsePane/NetworkError'; import NetworkError from 'components/ResponsePane/NetworkError';
import NewRequest from 'components/Sidebar/NewRequest'; import NewRequest from 'components/Sidebar/NewRequest';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { sendRequest, saveRequest, saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections'; import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; import { closeTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
@ -54,6 +54,8 @@ export const HotkeysProvider = (props) => {
const item = findItemInCollection(collection, activeTab.uid); const item = findItemInCollection(collection, activeTab.uid);
if (item && item.uid) { if (item && item.uid) {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid)); dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionRoot(collection.uid));
} else { } else {
// todo: when ephermal requests go live // todo: when ephermal requests go live
// setShowSaveRequestModal(true); // setShowSaveRequestModal(true);

View File

@ -477,6 +477,14 @@ export const collectionsSlice = createSlice({
item.draft.request.auth.mode = 'oauth2'; item.draft.request.auth.mode = 'oauth2';
item.draft.request.auth.oauth2 = action.payload.content; item.draft.request.auth.oauth2 = action.payload.content;
break; break;
case 'wsse':
item.draft.request.auth.mode = 'wsse';
item.draft.request.auth.wsse = action.payload.content;
break;
case 'apikey':
item.draft.request.auth.mode = 'apikey';
item.draft.request.auth.apikey = action.payload.content;
break;
} }
} }
} }
@ -1141,6 +1149,12 @@ export const collectionsSlice = createSlice({
case 'oauth2': case 'oauth2':
set(collection, 'root.request.auth.oauth2', action.payload.content); set(collection, 'root.request.auth.oauth2', action.payload.content);
break; break;
case 'wsse':
set(collection, 'root.request.auth.wsse', action.payload.content);
break;
case 'apikey':
set(collection, 'root.request.auth.apikey', action.payload.content);
break;
} }
} }
}, },

View File

@ -0,0 +1,31 @@
import { targets } from 'httpsnippet';
export const getLanguages = () => {
const allLanguages = [];
for (const target of Object.values(targets)) {
const { key, title } = target.info;
const clients = Object.keys(target.clientsById);
const languages =
(clients.length === 1)
? [{
name: title,
target: key,
client: clients[0]
}]
: clients.map(client => ({
name: `${title}-${client}`,
target: key,
client
}));
allLanguages.push(...languages);
// Move "Shell-curl" to the top of the array
const shellCurlIndex = allLanguages.findIndex(lang => lang.name === "Shell-curl");
if (shellCurlIndex !== -1) {
const [shellCurl] = allLanguages.splice(shellCurlIndex, 1);
allLanguages.unshift(shellCurl);
}
}
return allLanguages;
};

View File

@ -373,6 +373,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
break; break;
} }
break; break;
case 'apikey':
di.request.auth.apikey = {
key: get(si.request, 'auth.apikey.key', ''),
value: get(si.request, 'auth.apikey.value', ''),
placement: get(si.request, 'auth.apikey.placement', 'header')
};
break;
case 'wsse':
di.request.auth.wsse = {
username: get(si.request, 'auth.wsse.username', ''),
password: get(si.request, 'auth.wsse.password', '')
};
break;
default: default:
break; break;
} }
@ -666,6 +679,30 @@ export const humanizeRequestAuthMode = (mode) => {
label = 'OAuth 2.0'; label = 'OAuth 2.0';
break; break;
} }
case 'wsse': {
label = 'WSSE Auth';
break;
}
case 'apikey': {
label = 'API Key';
break;
}
}
return label;
};
export const humanizeRequestAPIKeyPlacement = (placement) => {
let label = 'Header';
switch (placement) {
case 'header': {
label = 'Header';
break;
}
case 'queryparams': {
label = 'Query Params';
break;
}
} }
return label; return label;

View File

@ -1,6 +1,105 @@
import map from 'lodash/map'; import map from 'lodash/map';
import * as FileSaver from 'file-saver'; import * as FileSaver from 'file-saver';
import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from 'utils/collections/export'; import { deleteSecretsInEnvs, deleteUidsInEnvs, deleteUidsInItems } from '../collections/export';
/**
* Transforms a given URL string into an object representing the protocol, host, path, query, and variables.
*
* @param {string} url - The raw URL to be transformed.
* @param {Object} params - The params object.
* @returns {Object|null} An object containing the URL's protocol, host, path, query, and variables, or {} if an error occurs.
*/
export const transformUrl = (url, params) => {
if (typeof url !== 'string' || !url.trim()) {
throw new Error("Invalid URL input");
}
const urlRegexPatterns = {
protocolAndRestSeparator: /:\/\//,
hostAndPathSeparator: /\/(.+)/,
domainSegmentSeparator: /\./,
pathSegmentSeparator: /\//,
queryStringSeparator: /\?/
};
const postmanUrl = { raw: url };
/**
* Splits a URL into its protocol, host and path.
*
* @param {string} url - The URL to be split.
* @returns {Object} An object containing the protocol and the raw host/path string.
*/
const splitUrl = (url) => {
const urlParts = url.split(urlRegexPatterns.protocolAndRestSeparator);
if (urlParts.length === 1) {
return { protocol: '', rawHostAndPath: urlParts[0] };
} else if (urlParts.length === 2) {
const [hostAndPath, _] = urlParts[1].split(urlRegexPatterns.queryStringSeparator);
return { protocol: urlParts[0], rawHostAndPath: hostAndPath };
} else {
throw new Error(`Invalid URL format: ${url}`);
}
};
/**
* Splits the host and path from a raw host/path string.
*
* @param {string} rawHostAndPath - The raw host and path string to be split.
* @returns {Object} An object containing the host and path.
*/
const splitHostAndPath = (rawHostAndPath) => {
const [host, path = ''] = rawHostAndPath.split(urlRegexPatterns.hostAndPathSeparator);
return { host, path };
};
try {
const { protocol, rawHostAndPath } = splitUrl(url);
postmanUrl.protocol = protocol;
const { host, path } = splitHostAndPath(rawHostAndPath);
postmanUrl.host = host ? host.split(urlRegexPatterns.domainSegmentSeparator) : [];
postmanUrl.path = path ? path.split(urlRegexPatterns.pathSegmentSeparator) : [];
} catch (error) {
console.error(error.message);
return {};
}
// Construct query params.
postmanUrl.query = params
.filter((param) => param.type === 'query')
.map(({ name, value, description }) => ({ key: name, value, description }));
// Construct path params.
postmanUrl.variable = params
.filter((param) => param.type === 'path')
.map(({ name, value, description }) => ({ key: name, value, description }));
return postmanUrl;
};
/**
* Collapses multiple consecutive slashes (`//`) into a single slash, while skipping the protocol (e.g., `http://` or `https://`).
*
* @param {String} url - A URL string
* @returns {String} The sanitized URL
*
*/
const collapseDuplicateSlashes = (url) => {
return url.replace(/(?<!:)\/{2,}/g, '/');
};
/**
* Replaces all `\\` (backslashes) with `//` (forward slashes) and collapses multiple slashes into one.
*
* @param {string} url - The URL to sanitize.
* @returns {string} The sanitized URL.
*
*/
export const sanitizeUrl = (url) => {
let sanitizedUrl = collapseDuplicateSlashes(url.replace(/\\/g, '//'));
return sanitizedUrl;
};
export const exportCollection = (collection) => { export const exportCollection = (collection) => {
delete collection.uid; delete collection.uid;
@ -147,7 +246,7 @@ export const exportCollection = (collection) => {
}; };
const generateAuth = (itemAuth) => { const generateAuth = (itemAuth) => {
switch (itemAuth) { switch (itemAuth?.mode) {
case 'bearer': case 'bearer':
return { return {
type: 'bearer', type: 'bearer',
@ -174,52 +273,41 @@ export const exportCollection = (collection) => {
] ]
}; };
} }
case 'apikey': {
return {
type: 'apikey',
apikey: [
{
key: 'key',
value: itemAuth.apikey.key,
type: 'string'
},
{
key: 'value',
value: itemAuth.apikey.value,
type: 'string'
}
]
};
}
default: {
console.error('Unsupported auth mode:', itemAuth.mode);
return null;
}
} }
}; };
const generateHost = (url) => {
try {
const { hostname } = new URL(url);
return hostname.split('.');
} catch (error) {
console.error(`Invalid URL: ${url}`, error);
return [];
}
};
const generatePathParams = (params) => {
return params.filter((param) => param.type === 'path').map((param) => `:${param.name}`);
};
const generateQueryParams = (params) => {
return params
.filter((param) => param.type === 'query')
.map(({ name, value, description }) => ({ key: name, value, description }));
};
const generateVariables = (params) => {
return params
.filter((param) => param.type === 'path')
.map(({ name, value, description }) => ({ key: name, value, description }));
};
const generateRequestSection = (itemRequest) => { const generateRequestSection = (itemRequest) => {
const requestObject = { const requestObject = {
method: itemRequest.method, method: itemRequest.method,
header: generateHeaders(itemRequest.headers), header: generateHeaders(itemRequest.headers),
auth: generateAuth(itemRequest.auth), auth: generateAuth(itemRequest.auth),
description: itemRequest.docs, description: itemRequest.docs,
url: { // We sanitize the URL to make sure it's in the right format before passing it to the transformUrl func. This means changing backslashes to forward slashes and reducing multiple slashes to a single one, except in the protocol part.
raw: itemRequest.url, url: transformUrl(sanitizeUrl(itemRequest.url), itemRequest.params)
host: generateHost(itemRequest.url),
path: generatePathParams(itemRequest.params),
query: generateQueryParams(itemRequest.params),
variable: generateVariables(itemRequest.params)
},
auth: generateAuth(itemRequest.auth)
}; };
if (itemRequest.body.mode != 'none') { if (itemRequest.body.mode !== 'none') {
requestObject.body = generateBody(itemRequest.body); requestObject.body = generateBody(itemRequest.body);
} }
return requestObject; return requestObject;

View File

@ -0,0 +1,81 @@
const { sanitizeUrl, transformUrl } = require('./postman-collection');
describe('transformUrl', () => {
it('should handle basic URL with path variables', () => {
const url = 'https://example.com/{{username}}/api/resource/:id';
const params = [
{ name: 'id', value: '123', type: 'path' },
];
const result = transformUrl(url, params);
expect(result).toEqual({
raw: 'https://example.com/{{username}}/api/resource/:id',
protocol: 'https',
host: ['example', 'com'],
path: ['{{username}}', 'api', 'resource', ':id'],
query: [],
variable: [
{ key: 'id', value: '123' },
]
});
});
it('should handle URL with query parameters', () => {
const url = 'https://example.com/api/resource?limit=10&offset=20';
const params = [
{ name: 'limit', value: '10', type: 'query' },
{ name: 'offset', value: '20', type: 'query' }
];
const result = transformUrl(url, params);
expect(result).toEqual({
raw: 'https://example.com/api/resource?limit=10&offset=20',
protocol: 'https',
host: ['example', 'com'],
path: ['api', 'resource'],
query: [
{ key: 'limit', value: '10' },
{ key: 'offset', value: '20' }
],
variable: []
});
});
it('should handle URL without protocol', () => {
const url = 'example.com/api/resource';
const params = [];
const result = transformUrl(url, params);
expect(result).toEqual({
raw: 'example.com/api/resource',
protocol: '',
host: ['example', 'com'],
path: ['api', 'resource'],
query: [],
variable: []
});
});
});
describe('sanitizeUrl', () => {
it('should replace backslashes with slashes', () => {
const input = 'http:\\\\example.com\\path\\to\\file';
const expected = 'http://example.com/path/to/file';
expect(sanitizeUrl(input)).toBe(expected);
});
it('should collapse multiple slashes into a single slash', () => {
const input = 'http://example.com//path///to////file';
const expected = 'http://example.com/path/to/file';
expect(sanitizeUrl(input)).toBe(expected);
});
it('should handle URLs with mixed slashes', () => {
const input = 'http:\\example.com//path\\to//file';
const expected = 'http://example.com/path/to/file';
expect(sanitizeUrl(input)).toBe(expected);
});
})

View File

@ -174,7 +174,7 @@ const transformInsomniaRequestItem = (request, index, allRequests) => {
} else if (mimeType === 'text/plain') { } else if (mimeType === 'text/plain') {
brunoRequestItem.request.body.mode = 'text'; brunoRequestItem.request.body.mode = 'text';
brunoRequestItem.request.body.text = request.body.text; brunoRequestItem.request.body.text = request.body.text;
} else if (mimeType === 'text/xml') { } else if (mimeType === 'text/xml' || mimeType === 'application/xml') {
brunoRequestItem.request.body.mode = 'xml'; brunoRequestItem.request.body.mode = 'xml';
brunoRequestItem.request.body.xml = request.body.text; brunoRequestItem.request.body.xml = request.body.text;
} else if (mimeType === 'application/graphql') { } else if (mimeType === 'application/graphql') {

View File

@ -283,11 +283,16 @@ const groupRequestsByTags = (requests) => {
each(requests, (request) => { each(requests, (request) => {
let tags = request.operationObject.tags || []; let tags = request.operationObject.tags || [];
if (tags.length > 0) { if (tags.length > 0) {
let tag = tags[0]; // take first tag let tag = tags[0].trim(); // take first tag and trim whitespace
if (!_groups[tag]) {
_groups[tag] = []; if (tag) {
if (!_groups[tag]) {
_groups[tag] = [];
}
_groups[tag].push(request);
} else {
ungrouped.push(request);
} }
_groups[tag].push(request);
} else { } else {
ungrouped.push(request); ungrouped.push(request);
} }

View File

@ -1,10 +1,10 @@
import each from 'lodash/each';
import get from 'lodash/get'; import get from 'lodash/get';
import fileDialog from 'file-dialog'; import fileDialog from 'file-dialog';
import { uuid } from 'utils/common'; import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error'; import { BrunoError } from 'utils/common/error';
import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common'; import { validateSchema, transformItemsInCollection, hydrateSeqInCollection } from './common';
import { postmanTranslation } from 'utils/importers/translators/postman_translation'; import { postmanTranslation } from 'utils/importers/translators/postman_translation';
import each from 'lodash/each';
const readFile = (files) => { const readFile = (files) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -23,7 +23,7 @@ const parseGraphQLRequest = (graphqlSource) => {
}; };
if (typeof graphqlSource === 'string') { if (typeof graphqlSource === 'string') {
graphqlSource = JSON.parse(text); graphqlSource = JSON.parse(graphqlSource);
} }
if (graphqlSource.hasOwnProperty('variables') && graphqlSource.variables !== '') { if (graphqlSource.hasOwnProperty('variables') && graphqlSource.variables !== '') {
@ -54,11 +54,53 @@ const convertV21Auth = (array) => {
}, {}); }, {});
}; };
const constructUrlFromParts = (url) => {
const { protocol = 'http', host, path, port, query, hash } = url || {};
const hostStr = Array.isArray(host) ? host.filter(Boolean).join('.') : host || '';
const pathStr = Array.isArray(path) ? path.filter(Boolean).join('/') : path || '';
const portStr = port ? `:${port}` : '';
const queryStr =
query && Array.isArray(query) && query.length > 0
? `?${query
.filter((q) => q.key)
.map((q) => `${q.key}=${q.value || ''}`)
.join('&')}`
: '';
const urlStr = `${protocol}://${hostStr}${portStr}${pathStr ? `/${pathStr}` : ''}${queryStr}`;
return urlStr;
};
const constructUrl = (url) => {
if (!url) return '';
if (typeof url === 'string') {
return url;
}
if (typeof url === 'object') {
const { raw } = url;
if (raw && typeof raw === 'string') {
// If the raw URL contains url-fragments remove it
if (raw.includes('#')) {
return raw.split('#')[0]; // Returns the part of raw URL without the url-fragment part.
}
return raw;
}
// If no raw value exists, construct the URL from parts
return constructUrlFromParts(url);
}
return '';
};
let translationLog = {}; let translationLog = {};
const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => { const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) => {
brunoParent.items = brunoParent.items || []; brunoParent.items = brunoParent.items || [];
const folderMap = {}; const folderMap = {};
const requestMap = {};
each(item, (i) => { each(item, (i) => {
if (isItemAFolder(i)) { if (isItemAFolder(i)) {
@ -84,20 +126,24 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
} }
} else { } else {
if (i.request) { if (i.request) {
let url = ''; const baseRequestName = i.name;
if (typeof i.request.url === 'string') { let requestName = baseRequestName;
url = i.request.url; let count = 1;
} else {
url = get(i, 'request.url.raw') || ''; while (requestMap[requestName]) {
requestName = `${baseRequestName}_${count}`;
count++;
} }
const url = constructUrl(i.request.url);
const brunoRequestItem = { const brunoRequestItem = {
uid: uuid(), uid: uuid(),
name: i.name, name: requestName,
type: 'http-request', type: 'http-request',
request: { request: {
url: url, url: url,
method: i.request.method, method: i?.request?.method?.toUpperCase(),
auth: { auth: {
mode: 'none', mode: 'none',
basic: null, basic: null,
@ -282,6 +328,13 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
region: authValues.region, region: authValues.region,
profileName: '' profileName: ''
}; };
} else if (auth.type === 'apikey'){
brunoRequestItem.request.auth.mode = 'apikey';
brunoRequestItem.request.auth.apikey = {
key: authValues.key,
value: authValues.value,
placement: "header" //By default we are placing the apikey values in headers!
}
} }
} }
@ -296,18 +349,24 @@ const importPostmanV2CollectionItem = (brunoParent, item, parentAuth, options) =
}); });
}); });
each(get(i, 'request.url.variable'), (param) => { each(get(i, 'request.url.variable', []), (param) => {
if (!param.key) {
// If no key, skip this iteration and discard the param
return;
}
brunoRequestItem.request.params.push({ brunoRequestItem.request.params.push({
uid: uuid(), uid: uuid(),
name: param.key, name: param.key,
value: param.value, value: param.value ?? '',
description: param.description, description: param.description ?? '',
type: 'path', type: 'path',
enabled: true enabled: true
}); });
}); });
brunoParent.items.push(brunoRequestItem); brunoParent.items.push(brunoRequestItem);
requestMap[requestName] = brunoRequestItem;
} }
} }
}); });

View File

@ -42,15 +42,15 @@ export const parsePathParams = (url) => {
uri = `http://${uri}`; uri = `http://${uri}`;
} }
let paths;
try { try {
uri = new URL(uri); uri = new URL(uri);
paths = uri.pathname.split('/');
} catch (e) { } catch (e) {
// URL is non-parsable, is it incomplete? Ignore. paths = uri.split('/');
return [];
} }
let paths = uri.pathname.split('/');
paths = paths.reduce((acc, path) => { paths = paths.reduce((acc, path) => {
if (path !== '' && path[0] === ':') { if (path !== '' && path[0] === ':') {
let name = path.slice(1, path.length); let name = path.slice(1, path.length);
@ -63,7 +63,6 @@ export const parsePathParams = (url) => {
} }
return acc; return acc;
}, []); }, []);
return paths; return paths;
}; };

View File

@ -235,6 +235,18 @@ const builder = async (yargs) => {
default: 'json', default: 'json',
type: 'string' type: 'string'
}) })
.option('reporter-json', {
describe: 'Path to write json file results to',
type: 'string'
})
.option('reporter-junit', {
describe: 'Path to write junit file results to',
type: 'string'
})
.option('reporter-html', {
describe: 'Path to write html file results to',
type: 'string'
})
.option('insecure', { .option('insecure', {
type: 'boolean', type: 'boolean',
description: 'Allow insecure server connections' description: 'Allow insecure server connections'
@ -267,6 +279,10 @@ const builder = async (yargs) => {
'$0 run request.bru --output results.html --format html', '$0 run request.bru --output results.html --format html',
'Run a request and write the results to results.html in html format in the current directory' 'Run a request and write the results to results.html in html format in the current directory'
) )
.example(
'$0 run request.bru --reporter-junit results.xml --reporter-html results.html',
'Run a request and write the results to results.html in html format and results.xml in junit format in the current directory'
)
.example('$0 run request.bru --tests-only', 'Run all requests that have a test') .example('$0 run request.bru --tests-only', 'Run all requests that have a test')
.example( .example(
@ -291,6 +307,9 @@ const handler = async function (argv) {
r: recursive, r: recursive,
output: outputPath, output: outputPath,
format, format,
reporterJson,
reporterJunit,
reporterHtml,
sandbox, sandbox,
testsOnly, testsOnly,
bail bail
@ -392,6 +411,25 @@ const handler = async function (argv) {
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT); process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT);
} }
let formats = {};
// Maintains back compat with --format and --output
if (outputPath && outputPath.length) {
formats[format] = outputPath;
}
if (reporterHtml && reporterHtml.length) {
formats['html'] = reporterHtml;
}
if (reporterJson && reporterJson.length) {
formats['json'] = reporterJson;
}
if (reporterJunit && reporterJunit.length) {
formats['junit'] = reporterJunit;
}
// load .env file at root of collection if it exists // load .env file at root of collection if it exists
const dotEnvPath = path.join(collectionPath, '.env'); const dotEnvPath = path.join(collectionPath, '.env');
const dotEnvExists = await exists(dotEnvPath); const dotEnvExists = await exists(dotEnvPath);
@ -524,28 +562,45 @@ const handler = async function (argv) {
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0); const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);
console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`))); console.log(chalk.dim(chalk.grey(`Ran all requests - ${totalTime} ms`)));
if (outputPath && outputPath.length) { const formatKeys = Object.keys(formats);
const outputDir = path.dirname(outputPath); if (formatKeys && formatKeys.length > 0) {
const outputDirExists = await exists(outputDir);
if (!outputDirExists) {
console.error(chalk.red(`Output directory ${outputDir} does not exist`));
process.exit(constants.EXIT_STATUS.ERROR_MISSING_OUTPUT_DIR);
}
const outputJson = { const outputJson = {
summary, summary,
results results
}; };
if (format === 'json') { const reporters = {
fs.writeFileSync(outputPath, JSON.stringify(outputJson, null, 2)); 'json': (path) => fs.writeFileSync(path, JSON.stringify(outputJson, null, 2)),
} else if (format === 'junit') { 'junit': (path) => makeJUnitOutput(results, path),
makeJUnitOutput(results, outputPath); 'html': (path) => makeHtmlOutput(outputJson, path),
} else if (format === 'html') {
makeHtmlOutput(outputJson, outputPath);
} }
console.log(chalk.dim(chalk.grey(`Wrote results to ${outputPath}`))); for (const formatter of Object.keys(formats))
{
const reportPath = formats[formatter];
const reporter = reporters[formatter];
// Skip formatters lacking an output path.
if (!reportPath || reportPath.length === 0) {
continue;
}
const outputDir = path.dirname(reportPath);
const outputDirExists = await exists(outputDir);
if (!outputDirExists) {
console.error(chalk.red(`Output directory ${outputDir} does not exist`));
process.exit(constants.EXIT_STATUS.ERROR_MISSING_OUTPUT_DIR);
}
if (!reporter) {
console.error(chalk.red(`Reporter ${formatter} does not exist`));
process.exit(constants.EXIT_STATUS.ERROR_INCORRECT_OUTPUT_FORMAT);
}
reporter(reportPath);
console.log(chalk.dim(chalk.grey(`Wrote ${formatter} results to ${reportPath}`)));
}
} }
if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) { if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) {

View File

@ -1,5 +1,6 @@
const { interpolate } = require('@usebruno/common'); const { interpolate } = require('@usebruno/common');
const { each, forOwn, cloneDeep, find } = require('lodash'); const { each, forOwn, cloneDeep, find } = require('lodash');
const FormData = require('form-data');
const getContentType = (headers = {}) => { const getContentType = (headers = {}) => {
let contentType = ''; let contentType = '';
@ -78,6 +79,14 @@ const interpolateVars = (request, envVars = {}, runtimeVariables = {}, processEn
request.data = JSON.parse(parsed); request.data = JSON.parse(parsed);
} catch (err) {} } catch (err) {}
} }
} else if (contentType === 'multipart/form-data') {
if (typeof request.data === 'object' && !(request?.data instanceof FormData)) {
try {
let parsed = JSON.stringify(request.data);
parsed = _interpolate(parsed);
request.data = JSON.parse(parsed);
} catch (err) {}
}
} else { } else {
request.data = _interpolate(request.data); request.data = _interpolate(request.data);
} }

View File

@ -2,6 +2,7 @@ const { get, each, filter } = require('lodash');
const fs = require('fs'); const fs = require('fs');
var JSONbig = require('json-bigint'); var JSONbig = require('json-bigint');
const decomment = require('decomment'); const decomment = require('decomment');
const crypto = require('node:crypto');
const prepareRequest = (request, collectionRoot) => { const prepareRequest = (request, collectionRoot) => {
const headers = {}; const headers = {};
@ -69,6 +70,24 @@ const prepareRequest = (request, collectionRoot) => {
if (request.auth.mode === 'bearer') { if (request.auth.mode === 'bearer') {
axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`; axiosRequest.headers['Authorization'] = `Bearer ${get(request, 'auth.bearer.token')}`;
} }
if (request.auth.mode === 'wsse') {
const username = get(request, 'auth.wsse.username', '');
const password = get(request, 'auth.wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('base64');
// Create the password digest using SHA-256
const hash = crypto.createHash('sha256');
hash.update(nonce + ts + password);
const digest = hash.digest('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Created="${ts}", Nonce="${nonce}"`;
}
} }
request.body = request.body || {}; request.body = request.body || {};
@ -120,16 +139,10 @@ const prepareRequest = (request, collectionRoot) => {
} }
if (request.body.mode === 'multipartForm') { if (request.body.mode === 'multipartForm') {
axiosRequest.headers['content-type'] = 'multipart/form-data';
const params = {}; const params = {};
const enabledParams = filter(request.body.multipartForm, (p) => p.enabled); const enabledParams = filter(request.body.multipartForm, (p) => p.enabled);
each(enabledParams, (p) => { each(enabledParams, (p) => (params[p.name] = p.value));
if (p.type === 'file') {
params[p.name] = p.value.map((path) => fs.createReadStream(path));
} else {
params[p.name] = p.value;
}
});
axiosRequest.headers['content-type'] = 'multipart/form-data';
axiosRequest.data = params; axiosRequest.data = params;
} }

View File

@ -19,6 +19,7 @@ const { makeAxiosInstance } = require('../utils/axios-instance');
const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper'); const { addAwsV4Interceptor, resolveAwsV4Credentials } = require('./awsv4auth-helper');
const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util'); const { shouldUseProxy, PatchedHttpsProxyAgent } = require('../utils/proxy-util');
const path = require('path'); const path = require('path');
const { createFormData } = require('../utils/common');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/; const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const onConsoleLog = (type, args) => { const onConsoleLog = (type, args) => {
@ -45,21 +46,6 @@ const runSingleRequest = async function (
const scriptingConfig = get(brunoConfig, 'scripts', {}); const scriptingConfig = get(brunoConfig, 'scripts', {});
scriptingConfig.runtime = runtime; scriptingConfig.runtime = runtime;
// make axios work in node using form data
// reference: https://github.com/axios/axios/issues/1006#issuecomment-320165427
if (request.headers && request.headers['content-type'] === 'multipart/form-data') {
const form = new FormData();
forOwn(request.data, (value, key) => {
if (value instanceof Array) {
each(value, (v) => form.append(key, v));
} else {
form.append(key, value);
}
});
extend(request.headers, form.getHeaders());
request.data = form;
}
// run pre request script // run pre request script
const requestScriptFile = compact([ const requestScriptFile = compact([
get(collectionRoot, 'request.script.req'), get(collectionRoot, 'request.script.req'),
@ -195,6 +181,14 @@ const runSingleRequest = async function (
request.data = qs.stringify(request.data); request.data = qs.stringify(request.data);
} }
if (request?.headers?.['content-type'] === 'multipart/form-data') {
if (!(request?.data instanceof FormData)) {
let form = createFormData(request.data, collectionPath);
request.data = form;
extend(request.headers, form.getHeaders());
}
}
let response, responseTime; let response, responseTime;
try { try {
// run request // run request

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