Compare commits

...

40 Commits

Author SHA1 Message Date
Sid
0a188575a0 fix: update request cancel icon 2025-11-17 13:14:09 +05:30
Sid
76a1532695 Merge branch 'main' into feature/http-stream-internal 2025-11-14 17:01:09 +05:30
Siddharth Gelera (reaper)
efad149afc HTTP stream enhancements (#6077)
* feat: add stop request button in api url bar

* docs: add farsi translation

* fix: handle escaped forward slashes by fast-json-format library upgrade

* refactor: change ui to use one from Websockets

* chore: cleanup

* fix: lint issues

* Replace IconPlayerStop with IconSquareRoundedX

* update json request and response formatting logic

* chore: format changes

* chore: remove un-needed diffs

* chore: sanitize

* bugfix(#5939): curl import fails for custom content-types

* chore: remove un-needed diffs

* chore: enhance response handling for streaming

* fix: disable requestid check for tests and assertions to be updated after streaming result

* chore: housekeeping

* fix: streamline loading and cancel request icon logic

* chore: formatting

* fix: multiple co-pilot changes

* fix: handle in folders

* feat: add WaitGroup utility for managing concurrent tasks

* refactor: remove WaitGroup utility and clean up network IPC logic

* refactor: remove unused setTimeout import and clean up post script execution

* refactor: clean up post-response script execution logic

* undiff

* re-align

* refactor: streamline post-response script execution

- Cleaned up formatting and improved readability of the post-response script execution logic.
- Consolidated parameters in function calls for consistency.

* fix: keep original dataBuffer for saving response

---------

Co-authored-by: adarshajit <adarshajit@gmail.com>
Co-authored-by: sajadoncode <sajadoncode@gmail.com>
Co-authored-by: lohit-bruno <lohit@usebruno.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
Co-authored-by: Anoop M D <anoop@usebruno.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
Co-authored-by: Dawid Góra <dawidgora@icloud.com>
2025-11-14 16:57:29 +05:30
Sid
2d2a17c90f Merge pull request #6070 from usebruno/feature/collection-test-results-and-filtering-internal
Feature/collection test results and filtering internal
2025-11-14 13:46:32 +05:30
Sid
3d8d93f20d fix: add missing newline at end of file in RunnerResults component 2025-11-14 13:19:39 +05:30
Bijin A B
94c33e6833 Merge pull request #5653 from sanish-bruno/feat/support-grpc-v1-reflection
feat: support v1 reflection for grpc server reflection
2025-11-13 20:12:26 +05:30
sanish-bruno
2ef451c80b rm: adding metadata to credentials 2025-11-13 19:04:34 +05:30
sanish-bruno
044fcce49f fix 2025-11-13 19:04:34 +05:30
sanish-bruno
dffb600dab fix: set metadata 2025-11-13 19:04:34 +05:30
sanish-bruno
99478b7068 fix: add metadata for insecure methods calls 2025-11-13 19:04:34 +05:30
sanish-bruno
252fd386b7 add: metadata to creds 2025-11-13 19:04:33 +05:30
sanish-bruno
b982f6db16 refactor: replace grpc-reflection-js with grpc-js-reflection-client in grpc-client implementation
rm: comment

fix: type generation

feat: implement reflection client support for gRPC v1 and v1alpha in grpc-client

refactor: simplify reflection client handling in grpc-client by removing service list retrieval

refactor: enhance reflection client return structure in grpc-client to include service list

fix: lint
2025-11-13 19:04:33 +05:30
Bijin A B
3b4e5686b8 Merge pull request #5800 from sanish-bruno/add/grpc-make-request-tests
add: tests for grpc requests
2025-11-13 18:36:11 +05:30
Bijin Bruno
2ef1a1948b chore: minor folder structure refactor 2025-11-13 18:23:34 +05:30
sanish-bruno
f2273821b0 add: tests for grpc requests
feat: add common selectors to locator.ts

fix: add dataTestId prop

update locator
2025-11-13 18:10:30 +05:30
Bijin A B
8a22f6acb8 Merge pull request #6083 from dawidgora/bugfix/5939_curl-import-fails-for-custom-content-types
bugfix(#5939): curl import fails for custom content-types
2025-11-13 17:26:49 +05:30
Chirag Chandrashekhar
6049530634 refactor: update runner tests to use new filter implementation and reusable helpers (#6085) 2025-11-13 15:56:33 +05:30
Dawid Góra
5784b04129 bugfix(#5939): curl import fails for custom content-types 2025-11-13 08:50:29 +01:00
Anoop M D
fec37f43e0 Merge pull request #5993 from adarshajit/feature/stop-request-button-in-api-url-bar
feat: add stop request button in api url bar
2025-11-13 12:14:48 +05:30
Bijin A B
b8fef7b796 Merge pull request #6079 from lohit-bruno/json_response_formatting
fix: update json request and response formatting logic
2025-11-12 23:12:40 +05:30
lohit-bruno
04f8dba1b1 update json request and response formatting logic 2025-11-12 22:47:35 +05:30
Anoop M D
cd1500bd01 Replace IconPlayerStop with IconSquareRoundedX 2025-11-12 19:48:09 +05:30
Anoop M D
e8a8b5d220 Merge pull request #6027 from sajadoncode/feature/add-farsi-readme
docs: add farsi translation
2025-11-12 19:04:11 +05:30
Pragadesh-45
bc3dfc59f6 fix: lint issues 2025-11-12 18:35:14 +05:45
Bijin A B
2c399ca33c Merge pull request #6075 from lohit-bruno/upgrade_fast_json_format_library
fix: handle `escaped forward slashes` by `fast-json-format` library upgrade
2025-11-12 18:06:09 +05:30
lohit-bruno
ccac4d6112 fix: handle escaped forward slashes by fast-json-format library upgrade 2025-11-12 16:38:35 +05:30
DaviXavier
fc5093eab4 fix: #1884 - Fixes infinite loading issue for text/event-stream requests (#4472)
* #1884 - Add support for text/event-stream content-type

* #1884 - Fix bugs with streaming

Fix bug when streaming response is not ok
Fix bug when clearing response of streaming request
Show text signaling that the response is being streamed in the reponse status
Update response size when new data is streamed in

* #1884 - Fix multiple requests when spamming send button

* #1884 - Add time counter for streamed response and fix final time

* #1884 - Run post script only at end of streamed request

* #1884 - add support for automatic "upgrade" to streaming data

* #1884 - adjustments for stopwatch in stream implementation and remove unused imports

* #1884 - fix imports indentation in useIpcEvents.js

* #1884 - remove stream data ended export function from collections

---------

Co-authored-by: Siddharth Gelera <ahoy@barelyhuman.dev>
2025-11-12 15:56:26 +05:30
Bijin A B
631b05330d Merge pull request #6071 from barelyhuman/fix/restore-duplicate-hasher
test: Add test for restoring duplicate hashes in patternHasher
2025-11-12 15:45:17 +05:30
reaper
be34c86c47 fix: replace regex with replaceAll for secure string replace 2025-11-12 15:12:51 +05:30
reaper
67c9f1373e test: Add test for restoring duplicate hashes in patternHasher 2025-11-12 14:40:30 +05:30
Sid
6628f95677 fix: add missing newline at end of file in RunnerResults component 2025-11-12 14:15:06 +05:30
Chirag Chandrashekhar
44ed0b01d8 Test Runner UI Revamp (#6011)
* Moved collection results to runner title bar so they are move visible.
Added breakdown of test results within collection.
Added filtering based on passing/failing requests and tests by click on results text.

* feat: revamp Test Runner UI with unified filter and improved layout

- Add unified filter bar (All/Passed/Failed/Skipped) with counts and active indicator
- Implement filtering that filters both requests and tests within requests
- Move action buttons to top bar, prevent filter wrapping
- Add close button and placeholder to response view
- Update styling for light/dark modes with proper colors and typography

* refactored the RunnerResults component to be more clear and readable

* refactor: revert formatting changes while preserving new UI and filtering logic

- Restore original function formatting with return statements and braces
- Restore removed input attributes (autoCorrect, autoCapitalize, spellCheck)
- Revert ternary operator changes to match original code style
- Restore original variable names (savedConfiguration) and comments
- Restore original test results rendering structure
- Preserve new filter bar UI, filtering logic, and response view improvements

* fix: implement smart auto-scroll behavior in test runner

- Only auto-scroll when user is near the bottom (within 100px)
- Preserve user's scroll position if they've scrolled up to view content
- Move ref to actual scrollable container for proper scroll detection

* Update RunnerResults component

* chore: reformat

---------

Co-authored-by: Morgan English <morgan.english@canterbury.ac.nz>
Co-authored-by: Sid <siddharth@usebruno.com>
2025-11-12 14:10:36 +05:30
morgan-se
45cfbc5c49 Moved collection results to runner title bar so they are move visible. (#3808)
Added breakdown of test results within collection.
Added filtering based on passing/failing requests and tests by click on results text.

Co-authored-by: Morgan English <morgan.english@canterbury.ac.nz>
Co-authored-by: Sid <siddharth@usebruno.com>
2025-11-12 13:48:31 +05:30
Pooja
14bece8696 feat: Add tabs component for pre-request and post-response scripts (#5926) 2025-11-12 12:53:32 +05:30
Pooja
9e19244665 move: import setting into import collection modal (#5929) 2025-11-12 11:36:21 +05:30
Pooja
f439f2de9a add: draft for collection and folder settings (#5947) 2025-11-12 11:11:12 +05:30
Bijin A B
e844d35b03 Merge pull request #6051 from abhishek-bruno/fix/postman-export-missing-tests
fix: modify brunoToPostman function to include tests in event section
2025-11-11 14:22:12 +05:30
Abhishek S Lal
26e140aca0 feat(converters): add test scripts in bruno to postman export 2025-11-11 12:56:45 +05:30
sajadoncode
b15c421270 docs: add farsi translation 2025-11-07 09:36:09 +01:00
adarshajit
1656e951fb feat: add stop request button in api url bar 2025-11-05 17:08:19 +05:30
164 changed files with 4957 additions and 1728 deletions

View File

@@ -16,6 +16,7 @@
| [日本語](docs/contributing/contributing_ja.md)
| [हिंदी](docs/contributing/contributing_hi.md)
| [Dutch](docs/contributing/contributing_nl.md)
| [فارسی](docs/contributing/contributing_fa.md)
## Let's make Bruno better, together!!
@@ -74,6 +75,7 @@ npm run build:bruno-filestore
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
```
##### Option 2
```bash
@@ -94,18 +96,22 @@ npm run dev:electron
```
##### Option 2
```bash
# run electron and react app concurrently
npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
This will create a `bruno-test` folder on your Desktop and use it as the `userData` path.
### Troubleshooting

View File

@@ -0,0 +1,92 @@
[English](../../contributing.md)
## با هم، Bruno را بهتر می‌کنیم!
خوشحالم که قصد دارید Bruno را بهبود ببخشید. در ادامه قوانین و راهنماها برای راه‌اندازی Bruno روی سیستم شما آورده شده است.
### فناوری‌های استفاده‌شده
به فارسی برونو Bruno با استفاده از Next.js و React ساخته شده است. همچنین از Electron برای بسته‌بندی نسخه دسکتاپ (که امکان مجموعه‌های محلی را فراهم می‌کند) استفاده می‌کنیم.
کتابخانه‌هایی که استفاده می‌کنیم:
- CSS - Tailwind استایل
- Codemirror - ویرایشگر کد
- Redux - مدیریت وضعیت
- Tabler Icons - آیکون‌ها
- formik - فرم‌ها
- Yup اعتبارسنجی اسکیمـا
- axios - کلاینت درخواست
- chokidar - پایش‌گر سیستم فایل
### پیش‌نیازها
شما به [نود v20.x یا اخرین نسخه پایدار](https://nodejs.org/en/) و npm 8.x نیاز دارید. در این پروژه از فضای کاری npm (npm workspaces) استفاده می‌کنیم.
### شروع به کدنویسی
برای راه‌اندازی محیط توسعه محلی به فایل [مستندات توسعه](docs/development_fa.md) مراجعه کنید:
### ارسال Pull Request
1 - لطفاً Pull Requestها (PR) را کوتاه و متمرکز نگه دارید و تنها یک هدف مشخص را دنبال کنند. </br>
2 - لطفاً از فرمت نام‌گذاری شاخه‌ها استفاده کنید:
- feature/[name]: این شاخه باید شامل یک قابلیت مشخص باشد.
- feature/dark-mode : مثال
- bugfix/[name]: این شاخه باید تنها شامل رفع یک باگ مشخص باشد.
- bugfix/bug-1 : مثال
## توسعه
به فارسی برونو یا Bruno به‌صورت یک اپلیکیشن «سنگین» توسعه داده می‌شود. برای اجرا باید ابتدا Next.js را در یک پنجره ترمینال اجرا کنید و سپس اپلیکیشن Electron را در پنجره ترمینال دیگری راه‌اندازی نمایید.
### نیازمندی توسعه
- NodeJS v18
### اجرای محلی
```bash
# از ورژن NodeJS 18 استفاده کنید
nvm use
# نصب وابستگی‌ها
npm i --legacy-peer-deps
# ساخت مستندات GraphQL
npm run build:graphql-docs
# ساخت bruno-query
npm run build:bruno-query
# اجرای اپ Next (ترمینال 1)
npm run dev:web
# اجرای اپ Electron (ترمینال 2)
npm run dev:electron
```
### عیب‌یابی
ممکن است هنگام اجرای `npm install` خطای `Unsupported platform` ببینید. برای رفع این مشکل، پوشه `node_modules` و فایل `package-lock.json` را حذف کرده و سپس دوباره `npm install` را اجرا کنید. این کار معمولاً همه پکیج‌های لازم را نصب می‌کند.
```shell
# حذف پوشه node_modules در زیردایرکتوری‌ها
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
done
# حذف فایل package-lock.json در زیردایرکتوری‌ها
find . -type f -name "package-lock.json" -delete
```
### تست‌ها
```bash
# اجرای تست‌های schema مربوط به bruno
npm test --workspace=packages/bruno-schema
# اجرای تست‌ها در همه فضاهای کاری (در صورت وجود)
npm test --workspaces --if-present
```

View File

@@ -0,0 +1,8 @@
[English](../../publishing.md)
### انتشار Bruno در یک پکیج منیجر جدید
اگرچه کد ما متن‌باز است و همه می‌توانند از آن استفاده کنند، لطفاً قبل از انتشار Bruno در مدیر بسته‌های جدید با ما تماس بگیرید. به عنوان سازنده Bruno، علامت تجاری `Bruno` را برای این پروژه دارم و مایلم توزیع آن را مدیریت کنم. اگر دوست دارید Bruno را در یک مدیر بسته جدید ببینید، لطفاً یک issue در گیت‌هاب ثبت کنید.
اگرچه بیشتر قابلیت‌های ما رایگان و متن‌باز هستند (شامل REST و GraphQL Apis)،
ما تلاش می‌کنیم بین اصول متن‌باز و توسعه پایدار تعادل مناسبی برقرار کنیم - https://github.com/usebruno/bruno/discussions/269

143
docs/readme/readme_fa.md Normal file
View File

@@ -0,0 +1,143 @@
<br />
<img src="../../assets/images/logo-transparent.png" width="80"/>
### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها
[![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)
| [Українська](./readme_ua.md)
| [Русский](./readme_ru.md)
| [Türkçe](./readme_tr.md)
| [Deutsch](./readme_de.md)
| [Français](./readme_fr.md)
| [Português (BR)](./readme_pt_br.md)
| [한국어](./readme_kr.md)
| [বাংলা](./readme_bn.md)
| [Español](./readme_es.md)
| **فارسی**
| [Română](./readme_ro.md)
| [Polski](./readme_pl.md)
| [简体中文](./readme_cn.md)
| [正體中文](./readme_zhtw.md)
| [العربية](./readme_ar.md)
| [日本語](./readme_ja.md)
| [ქართული](./readme_ka.md)
برونو یک کلاینت API جدید و نوآورانه است که هدفش تغییر وضعیت فعلی ابزارهایی مانند Postman و سایر ابزارهای مشابه است.
برونو مجموعه‌های شما را مستقیماً در یک پوشه روی فایل‌سیستم شما ذخیره می‌کند. ما از یک زبان نشانه‌گذاری ساده به نام Bru برای ذخیره اطلاعات درخواست‌های API استفاده می‌کنیم.
شما می‌توانید برای همکاری روی مجموعه‌های API خود، از Git یا هر سیستم کنترل نسخه دلخواهتان استفاده کنید.
برونو فقط به صورت آفلاین کار می‌کند. هیچ برنامه‌ای برای اضافه کردن همگام‌سازی ابری به برونو در آینده وجود ندارد. ما به حریم خصوصی داده‌های شما اهمیت می‌دهیم و معتقدیم که باید روی دستگاه خودتان باقی بمانند. می‌توانید چشم‌انداز بلندمدت ما را مطالعه کنید. [اینجا (به انگلیسی)](https://github.com/usebruno/bruno/discussions/269)
📢 جدیدترین ارائه ما را در کنفرانس India FOSS 3.0 تماشا کنید.
[اینجا](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](/assets/images/landing-2.png) <br /><br />
### نصب
برونو به صورت یک فایل باینری برای دانلود در دسترس است. [بر روی وبسایت ما](https://www.usebruno.com/downloads) برای مک لینکوس و ویندوز.
همچنین می‌توانید برونو را از طریق مدیر بسته‌هایی مانند Homebrew، Chocolatey، Snap و Apt نصب کنید.
```sh
# بر روی مک از طریق brew
brew install bruno
# بر روی ویندوز از طریق Chocolatey
choco install bruno
# بر روی لینوکس از طریق Snap
snap install bruno
# بر روی لینوکس از طریق Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
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
```
### روی پلتفرم‌های مختلف کار می‌کند 🖥️
![bruno](/assets/images/run-anywhere.png) <br /><br />
### همکاری از طریق گیت 👩‍💻🧑‍💻
یا هر سیستم کنترل نسخه‌ای که ترجیح می‌دهید
![bruno](/assets/images/version-control.png) <br /><br />
### لینک‌های مهم 📌
- [آخرین نسخه پایدار ما](https://github.com/usebruno/bruno/discussions/269)
- [نقشه راه](https://github.com/usebruno/bruno/discussions/384)
- [مستندات](https://docs.usebruno.com)
- [وبسایت](https://www.usebruno.com)
- [اشتراک ها](https://www.usebruno.com/pricing)
- [دانلود](https://www.usebruno.com/downloads)
### ویدیوها 🎥
- [تجربه ها](https://github.com/usebruno/bruno/discussions/343)
- [مرکز دانش](https://github.com/usebruno/bruno/discussions/386)
- [اسکریپ مانیا](https://github.com/usebruno/bruno/discussions/385)
### حمایت ❤️
جوون! اگر این پروژه را دوست دارید، روی دکمه ⭐ کلیک کنید!
### تجربه‌های به اشتراک گذاشته‌شده 📣
اگر برونو به شما یا تیمتان کمک کرده است، لطفاً فراموش نکنید تجربه‌های خود را به اشتراک بگذارید. [تجربه‌های خود را در بحث گیت‌هاب ما به اشتراک بگذارید](https://github.com/usebruno/bruno/discussions/343).
### انتشار برونو در یک پکیچ منیجر جدید
لطفا چک بکنید [اینجارو](../../publishing.md) برای اطلاعات بیشتر.
### مشارکت 👩‍💻🧑‍💻
خوشحالم که می‌خواهید برونو را بهتر کنید. لطفا [راهنمای مشارکت را بررسی کنید](../contributing/contributing_fa.md).
حتی اگر نمی‌توانید از طریق کدنویسی مشارکت کنید، در گزارش باگ‌ها و درخواست قابلیت‌های جدید که به حل نیازهای شما کمک می‌کند تردید نکنید.
### نویسنده ها
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### در ارتباط باشید 🌐
[𝕏 (تویتر)](https://twitter.com/use_bruno) <br />
[وبسایت](https://www.usebruno.com) <br />
[دیسکورد](https://discord.com/invite/KgcZUncpjq) <br />
[لینکدین](https://www.linkedin.com/company/usebruno)
### برند
**نام**
به فارسی برونو - `Bruno` یک علامت تجاری ثبت‌شده متعلق به [Anoop M D](https://www.helloanoop.com/)
**لوگو**
لوگو توسط [OpenMoji](https://openmoji.org/library/emoji-1F436/) ساخته شده است. مجوز: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
### مجوز 📄
[MIT](../../license.md)

63
package-lock.json generated
View File

@@ -8232,12 +8232,6 @@
"@types/node": "*"
}
},
"node_modules/@types/google-protobuf": {
"version": "3.15.12",
"resolved": "https://registry.npmjs.org/@types/google-protobuf/-/google-protobuf-3.15.12.tgz",
"integrity": "sha512-40um9QqwHjRS92qnOaDpL7RmDK15NuZYo9HihiJRbYkMQZlWnuH8AdvbMy8/o6lgLmKbDUKa+OALCltHdbOTpQ==",
"license": "MIT"
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -8373,9 +8367,9 @@
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.13",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz",
"integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==",
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
@@ -8388,15 +8382,6 @@
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.set": {
"version": "4.3.9",
"resolved": "https://registry.npmjs.org/@types/lodash.set/-/lodash.set-4.3.9.tgz",
"integrity": "sha512-KOxyNkZpbaggVmqbpr82N2tDVTx05/3/j0f50Es1prxrWB0XYf9p3QNxqcbWb7P1Q9wlvsUSlCFnwlPCIJ46PQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/markdown-it": {
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
@@ -14235,9 +14220,9 @@
}
},
"node_modules/fast-json-format": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.3.0.tgz",
"integrity": "sha512-B95psGYXJ5XItmxLR6JFcQRQafDyfy8ecHiV/jWCJF9oCIA9/o+wt89cGW61D04xf07yCpIaevvCQbgeJ9w8lQ==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/fast-json-format/-/fast-json-format-0.4.0.tgz",
"integrity": "sha512-HEomBtr2fYaVX3iaRdcVLU7Qd3SQhCYvXlMMM9RNaihfIaj5bIC7ADqw/bAPSg/uyX6FIBPq69ioXq0B4Cb6eA==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
@@ -15183,9 +15168,9 @@
}
},
"node_modules/google-protobuf": {
"version": "3.21.4",
"resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.4.tgz",
"integrity": "sha512-MnG7N936zcKTco4Jd2PX2U96Kf9PxygAPKBug+74LHzmHXmceN16MmRcdgZv+DGef/S9YvQAfRsNCn4cjf9yyQ==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-4.0.0.tgz",
"integrity": "sha512-b8wmenhUMf2WNL+xIJ/slvD/hEE6V3nRnG86O2bzkBrMweM9gnqZE1dfXlDjibY3aXJXDNbAHepevYyQ7qWKsQ==",
"license": "(BSD-3-Clause AND Apache-2.0)"
},
"node_modules/gopd": {
@@ -15312,20 +15297,18 @@
"node": ">= 6"
}
},
"node_modules/grpc-reflection-js": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/grpc-reflection-js/-/grpc-reflection-js-0.3.0.tgz",
"integrity": "sha512-3lhTlQluPxVgbowCXA3tAZC3RJW+GSOUkguLNYl1QffYRiutUB3RDfPkQFTcrCFJgNiIIxx+iJkr8s3uSp3zWA==",
"node_modules/grpc-js-reflection-client": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/grpc-js-reflection-client/-/grpc-js-reflection-client-1.3.0.tgz",
"integrity": "sha512-eJ5/m1pXpcheSjOGExktU69WPUKnL4Su3IxGJYYYjy3/w19vE8dH7Wi46G5T92bpM0eZWftjiM5HduX8CjPq9w==",
"license": "MIT",
"dependencies": {
"@types/google-protobuf": "^3.7.2",
"@types/lodash.set": "^4.3.6",
"google-protobuf": "^3.12.2",
"lodash.set": "^4.3.2",
"protobufjs": "^7.2.2"
"@types/lodash": "^4.17.15",
"lodash": "^4.17.21",
"protobufjs": "^7.4.0"
},
"peerDependencies": {
"@grpc/grpc-js": "^1.0.0"
"@grpc/grpc-js": "^1.12.6"
}
},
"node_modules/har-schema": {
@@ -18634,12 +18617,6 @@
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
"integrity": "sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg==",
"license": "MIT"
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz",
@@ -26874,7 +26851,7 @@
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
"fast-json-format": "~0.3.0",
"fast-json-format": "~0.4.0",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@@ -26883,6 +26860,7 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"idb": "^7.0.0",
@@ -32129,7 +32107,8 @@
"@types/qs": "^6.9.18",
"axios": "^1.9.0",
"debug": "^4.4.3",
"grpc-reflection-js": "^0.3.0",
"google-protobuf": "^4.0.0",
"grpc-js-reflection-client": "^1.3.0",
"is-ip": "^5.0.1",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",

View File

@@ -28,7 +28,7 @@
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
"fast-json-format": "~0.3.0",
"fast-json-format": "~0.4.0",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@@ -37,6 +37,7 @@
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"idb": "^7.0.0",

View File

@@ -4,7 +4,7 @@ import { AccordionItem, AccordionHeader, AccordionContent } from './styledWrappe
const AccordionContext = createContext();
const Accordion = ({ children, defaultIndex }) => {
const Accordion = ({ children, defaultIndex, dataTestId }) => {
const [openIndex, setOpenIndex] = useState(defaultIndex);
const toggleItem = (index) => {
@@ -13,7 +13,7 @@ const Accordion = ({ children, defaultIndex }) => {
return (
<AccordionContext.Provider value={{ openIndex, toggleItem }}>
<div>{children}</div>
<div data-testid={dataTestId}>{children}</div>
</AccordionContext.Provider>
);
};

View File

@@ -6,7 +6,7 @@ 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 { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
@@ -16,9 +16,9 @@ const ApiKeyAuth = ({ collection }) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = get(collection, 'root.request.auth.apikey', {});
const apikeyAuth = collection.draft?.root ? get(collection, 'draft.root.request.auth.apikey', {}) : get(collection, 'root.request.auth.apikey', {});
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const Icon = forwardRef((props, ref) => {
return (

View File

@@ -11,7 +11,7 @@ const AuthMode = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = get(collection, 'root.request.auth.mode');
const authMode = collection.draft?.root ? get(collection, 'draft.root.request.auth.mode') : get(collection, 'root.request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (

View File

@@ -6,18 +6,18 @@ 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 { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const AwsV4Auth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = get(collection, 'root.request.auth.awsv4', {});
const awsv4Auth = collection.draft?.root ? get(collection, 'draft.root.request.auth.awsv4', {}) : get(collection, 'root.request.auth.awsv4', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(awsv4Auth?.secretAccessKey);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(

View File

@@ -6,18 +6,18 @@ 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 { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = get(collection, 'root.request.auth.basic', {});
const basicAuth = collection.draft?.root ? get(collection, 'draft.root.request.auth.basic', {}) : get(collection, 'root.request.auth.basic', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(basicAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -6,18 +6,18 @@ 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 { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = get(collection, 'root.request.auth.bearer.token', '');
const bearerToken = collection.draft?.root ? get(collection, 'draft.root.request.auth.bearer.token', '') : get(collection, 'root.request.auth.bearer.token', '');
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(bearerToken);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleTokenChange = (token) => {
dispatch(

View File

@@ -6,18 +6,18 @@ 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 { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = get(collection, 'root.request.auth.digest', {});
const digestAuth = collection.draft?.root ? get(collection, 'draft.root.request.auth.digest', {}) : get(collection, 'root.request.auth.digest', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(digestAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -6,7 +6,7 @@ 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 { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
@@ -19,11 +19,11 @@ const NTLMAuth = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const ntlmAuth = get(collection, 'root.request.auth.ntlm', {});
const ntlmAuth = collection.draft?.root ? get(collection, 'draft.root.request.auth.ntlm', {}) : get(collection, 'root.request.auth.ntlm', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(ntlmAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleUsernameChange = (username) => {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/index';
import { useDispatch } from 'react-redux';
@@ -14,10 +14,10 @@ const GrantTypeComponentMap = ({collection }) => {
const dispatch = useDispatch();
const save = () => {
dispatch(saveCollectionRoot(collection.uid));
dispatch(saveCollectionSettings(collection.uid));
};
let request = collection.draft ? get(collection, 'draft.request', {}) : get(collection, 'root.request', {});
let request = collection.draft?.root ? get(collection, 'draft.root.request', {}) : get(collection, 'root.request', {});
const grantType = get(request, 'auth.oauth2.grantType', {});
switch (grantType) {
@@ -40,7 +40,7 @@ const GrantTypeComponentMap = ({collection }) => {
};
const OAuth2 = ({ collection }) => {
let request = collection.draft ? get(collection, 'draft.request', {}) : get(collection, 'root.request', {});
let request = collection.draft?.root ? get(collection, 'draft.root.request', {}) : get(collection, 'root.request', {});
return (
<StyledWrapper className="mt-2 w-full">

View File

@@ -6,18 +6,18 @@ 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 { saveCollectionSettings } 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 wsseAuth = collection.draft?.root ? get(collection, 'draft.root.request.auth.wsse', {}) : get(collection, 'root.request.auth.wsse', {});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(wsseAuth?.password);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleUserChange = (username) => {
dispatch(

View File

@@ -8,17 +8,17 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import ApiKeyAuth from './ApiKeyAuth/';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
import NTLMAuth from './NTLMAuth';
const Auth = ({ collection }) => {
const authMode = get(collection, 'root.request.auth.mode');
const authMode = collection.draft?.root ? get(collection, 'draft.root.request.auth.mode') : get(collection, 'root.request.auth.mode');
const dispatch = useDispatch();
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const getAuthView = () => {
switch (authMode) {

View File

@@ -9,8 +9,18 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index';
import { useTheme } from 'styled-components';
import { useDispatch } from 'react-redux';
import { updateCollectionClientCertificates } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import get from 'lodash/get';
const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }) => {
const ClientCertSettings = ({ collection }) => {
const dispatch = useDispatch();
// Get client certs from draft if exists, otherwise from brunoConfig
const clientCertConfig = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.clientCertificates.certs', [])
: get(collection, 'brunoConfig.clientCertificates.certs', []);
const certFilePathInputRef = useRef();
const keyFilePathInputRef = useRef();
const pfxFilePathInputRef = useRef();
@@ -63,7 +73,19 @@ const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }
passphrase: values.passphrase
};
}
onUpdate(relevantValues);
// Add the new cert to the existing certs in draft
const updatedCerts = [...clientCertConfig, relevantValues];
const clientCertificates = {
enabled: true,
certs: updatedCerts
};
dispatch(updateCollectionClientCertificates({
collectionUid: collection.uid,
clientCertificates
}));
formik.resetForm();
resetFileInputFields();
}
@@ -81,9 +103,15 @@ const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }
};
const resetFileInputFields = () => {
certFilePathInputRef.current.value = '';
keyFilePathInputRef.current.value = '';
pfxFilePathInputRef.current.value = '';
if (certFilePathInputRef.current) {
certFilePathInputRef.current.value = '';
}
if (keyFilePathInputRef.current) {
keyFilePathInputRef.current.value = '';
}
if (pfxFilePathInputRef.current) {
pfxFilePathInputRef.current.value = '';
}
};
const handleTypeChange = (e) => {
@@ -99,6 +127,21 @@ const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }
}
};
const handleRemove = (indexToRemove) => {
const updatedCerts = clientCertConfig.filter((cert, index) => index !== indexToRemove);
const clientCertificates = {
enabled: true,
certs: updatedCerts
};
dispatch(updateCollectionClientCertificates({
collectionUid: collection.uid,
clientCertificates
}));
};
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
return (
<StyledWrapper className="w-full h-full">
<div className="text-xs mb-4 text-muted">Add client certificates to be used for specific domains.</div>
@@ -118,9 +161,9 @@ const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }
<IconCertificate className="mr-2 flex-shrink-0" size={18} strokeWidth={1.5} />
{clientCert.type === 'cert' ? clientCert.certFilePath : clientCert.pfxFilePath}
</div>
<button onClick={() => onRemove(clientCert)} className="remove-certificate ml-2">
<button onClick={() => handleRemove(index)} className="remove-certificate ml-2">
<IconTrash size={18} strokeWidth={1.5} />
</button>
</button>
</div>
</li>
))}
@@ -329,10 +372,14 @@ const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }
<div className="ml-1 text-red-500">{formik.errors.passphrase}</div>
) : null}
</div>
<div className="mt-6">
<div className="mt-6 flex flex-row gap-2 items-center">
<button type="submit" className="submit btn btn-sm btn-secondary">
Add
</button>
<div className="h-4 border-l border-gray-600"></div>
<button type="button" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</form>
</StyledWrapper>

View File

@@ -1,10 +1,10 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import { updateCollectionDocs } from 'providers/ReduxStore/slices/collections';
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
@@ -14,7 +14,7 @@ const Docs = ({ collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const [isEditing, setIsEditing] = useState(false);
const docs = get(collection, 'root.docs', '');
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
const preferences = useSelector((state) => state.app.preferences);
const toggleViewMode = () => {
@@ -31,17 +31,17 @@ const Docs = ({ collection }) => {
};
const handleDiscardChanges = () => {
dispatch(
dispatch((
updateCollectionDocs({
collectionUid: collection.uid,
docs: docs
})
}))
);
toggleViewMode();
}
const onSave = () => {
dispatch(saveCollectionRoot(collection.uid));
dispatch(saveCollectionSettings(collection.uid));
toggleViewMode();
}

View File

@@ -10,7 +10,7 @@ import {
deleteCollectionHeader,
setCollectionHeaders
} from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
@@ -21,7 +21,7 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(collection, 'root.request.headers', []);
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {
@@ -40,7 +40,7 @@ const Headers = ({ collection }) => {
);
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleHeaderValueChange = (e, _header, type) => {
const header = cloneDeep(_header);
switch (type) {

View File

@@ -1,37 +1,44 @@
import React from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const {
brunoConfig: { presets: presets = {} }
} = collection;
const initialPresets = { requestType: 'http', requestUrl: '' };
const formik = useFormik({
enableReinitialize: true,
initialValues: {
requestType: presets.requestType || 'http',
requestUrl: presets.requestUrl || ''
},
onSubmit: (newPresets) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.presets = newPresets;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
toast.success('Collection presets updated');
}
});
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
const currentPresets = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.presets', initialPresets)
: get(collection, 'brunoConfig.presets', initialPresets);
// Helper to update presets config
const updatePresets = (updates) => {
const updatedPresets = { ...currentPresets, ...updates };
dispatch(updateCollectionPresets({
collectionUid: collection.uid,
presets: updatedPresets
}));
};
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleRequestTypeChange = (e) => {
updatePresets({ requestType: e.target.value });
};
const handleRequestUrlChange = (e) => {
updatePresets({ requestUrl: e.target.value });
};
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">
These presets will be used as the default values for new requests in this collection.
</div>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="bruno-form">
<div className="mb-3 flex items-center">
<label className="settings-label flex items-center" htmlFor="enabled">
Request Type
@@ -42,9 +49,9 @@ const PresetsSettings = ({ collection }) => {
className="cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
onChange={handleRequestTypeChange}
value="http"
checked={formik.values.requestType === 'http'}
checked={(currentPresets.requestType || 'http') === 'http'}
/>
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
HTTP
@@ -55,9 +62,9 @@ const PresetsSettings = ({ collection }) => {
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
onChange={handleRequestTypeChange}
value="graphql"
checked={formik.values.requestType === 'graphql'}
checked={(currentPresets.requestType || 'http') === 'graphql'}
/>
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
GraphQL
@@ -68,9 +75,9 @@ const PresetsSettings = ({ collection }) => {
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
onChange={handleRequestTypeChange}
value="grpc"
checked={formik.values.requestType === 'grpc'}
checked={(currentPresets.requestType || 'http') === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
@@ -93,8 +100,8 @@ const PresetsSettings = ({ collection }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.requestUrl || ''}
onChange={handleRequestUrlChange}
value={currentPresets.requestUrl || ''}
style={{ width: '100%' }}
/>
</div>
@@ -102,11 +109,11 @@ const PresetsSettings = ({ collection }) => {
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</form>
</div>
</StyledWrapper>
);
};

View File

@@ -1,4 +1,5 @@
import React, { useRef } from 'react';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import {
IconTrash,
@@ -10,8 +11,10 @@ import {
import { getBasename } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import useProtoFileManagement from '../../../hooks/useProtoFileManagement';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
const ProtobufSettings = ({ collection }) => {
const dispatch = useDispatch();
const {
protoFiles,
importPaths,
@@ -27,6 +30,8 @@ const ProtobufSettings = ({ collection }) => {
} = useProtoFileManagement(collection);
const fileInputRef = useRef(null);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
// Get file path using the ipcRenderer
const getProtoFile = async (event) => {
const files = event?.files;
@@ -164,7 +169,7 @@ const ProtobufSettings = ({ collection }) => {
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center">
<IconFile size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
<span className="text-sm font-medium text-gray-900 dark:text-gray-100" data-testid="protobuf-proto-file-name">
{getBasename(collection.pathname, file.path)}
</span>
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
@@ -329,6 +334,12 @@ const ProtobufSettings = ({ collection }) => {
</div>
</div>
<div className="mt-6">
<button type="button" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</StyledWrapper>
);
};

View File

@@ -1,106 +1,155 @@
import React, { useEffect } from 'react';
import { useFormik } from 'formik';
import React from 'react';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { useState } from 'react';
import { useDispatch } from 'react-redux';
import { updateCollectionProxy } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
import toast from 'react-hot-toast';
const ProxySettings = ({ proxyConfig, onUpdate }) => {
const proxySchema = Yup.object({
enabled: Yup.string().oneOf(['global', 'true', 'false']),
protocol: Yup.string().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string()
.when('enabled', {
is: 'true',
then: (hostname) => hostname.required('Specify the hostname for your proxy.'),
otherwise: (hostname) => hostname.nullable()
})
.max(1024),
port: Yup.number()
.min(1)
.max(65535)
.typeError('Specify port between 1 and 65535')
.nullable()
.transform((_, val) => (val ? Number(val) : null)),
auth: Yup.object()
.when('enabled', {
is: 'true',
then: Yup.object({
enabled: Yup.boolean(),
username: Yup.string()
.when('enabled', {
is: true,
then: (username) => username.required('Specify username for proxy authentication.')
})
.max(1024),
password: Yup.string()
.when('enabled', {
is: true,
then: (password) => password.required('Specify password for proxy authentication.')
})
.max(1024)
})
})
.optional(),
bypassProxy: Yup.string().optional().max(1024)
});
const ProxySettings = ({ collection }) => {
const dispatch = useDispatch();
const initialProxyConfig = { enabled: 'global', protocol: 'http', hostname: '', port: '', auth: { enabled: false, username: '', password: '' }, bypassProxy: '' };
const formik = useFormik({
initialValues: {
enabled: proxyConfig.enabled || 'global',
protocol: proxyConfig.protocol || 'http',
hostname: proxyConfig.hostname || '',
port: proxyConfig.port || '',
auth: {
enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false,
username: proxyConfig.auth ? proxyConfig.auth.username || '' : '',
password: proxyConfig.auth ? proxyConfig.auth.password || '' : ''
},
bypassProxy: proxyConfig.bypassProxy || ''
},
validationSchema: proxySchema,
onSubmit: (values) => {
proxySchema
.validate(values, { abortEarly: true })
.then((validatedProxy) => {
// serialize 'enabled' to boolean
if (validatedProxy.enabled === 'true') {
validatedProxy.enabled = true;
} else if (validatedProxy.enabled === 'false') {
validatedProxy.enabled = false;
}
// Get proxy from draft.brunoConfig if it exists, otherwise from brunoConfig
const currentProxyConfig = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.proxy', initialProxyConfig)
: get(collection, 'brunoConfig.proxy', initialProxyConfig);
onUpdate(validatedProxy);
})
.catch((error) => {
let errMsg = error.message || 'Preferences validation error';
toast.error(errMsg);
});
}
});
const [passwordVisible, setPasswordVisible] = useState(false);
useEffect(() => {
formik.setValues({
enabled: proxyConfig.enabled === true ? 'true' : proxyConfig.enabled === false ? 'false' : 'global',
protocol: proxyConfig.protocol || 'http',
hostname: proxyConfig.hostname || '',
port: proxyConfig.port || '',
const validateHostnameOnChange = (hostname) => {
if (hostname && hostname.length > 1024) {
toast.error('Hostname must be less than 1024 characters');
return false;
}
return true;
};
const validatePortOnChange = (port) => {
if (!port || port === '') {
return true; // Allow empty port during typing
}
const portNum = Number(port);
if (isNaN(portNum)) {
toast.error('Port must be a valid number');
return false;
}
if (portNum < 1 || portNum > 65535) {
toast.error('Port must be between 1 and 65535');
return false;
}
return true;
};
const validateAuthUsernameOnChange = (username) => {
if (username && username.length > 1024) {
toast.error('Username must be less than 1024 characters');
return false;
}
return true;
};
const validateAuthPasswordOnChange = (password) => {
if (password && password.length > 1024) {
toast.error('Password must be less than 1024 characters');
return false;
}
return true;
};
const validateBypassProxyOnChange = (bypassProxy) => {
if (bypassProxy && bypassProxy.length > 1024) {
toast.error('Bypass proxy must be less than 1024 characters');
return false;
}
return true;
};
// Helper to update proxy config
const updateProxy = (updates) => {
const updatedProxy = { ...currentProxyConfig, ...updates };
dispatch(updateCollectionProxy({
collectionUid: collection.uid,
proxy: updatedProxy
}));
};
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleEnabledChange = (e) => {
const value = e.target.value;
// Convert string to boolean or keep as 'global'
const enabled = value === 'true' ? true : value === 'false' ? false : 'global';
updateProxy({ enabled });
};
const handleProtocolChange = (e) => {
updateProxy({ protocol: e.target.value });
};
const handleHostnameChange = (e) => {
const hostname = e.target.value;
if (validateHostnameOnChange(hostname)) {
updateProxy({ hostname });
}
};
const handlePortChange = (e) => {
const port = e.target.value ? Number(e.target.value) : '';
if (validatePortOnChange(port)) {
updateProxy({ port });
}
};
const handleAuthEnabledChange = (e) => {
updateProxy({
auth: {
enabled: proxyConfig.auth ? proxyConfig.auth.enabled || false : false,
username: proxyConfig.auth ? proxyConfig.auth.username || '' : '',
password: proxyConfig.auth ? proxyConfig.auth.password || '' : ''
},
bypassProxy: proxyConfig.bypassProxy || ''
...currentProxyConfig.auth,
enabled: e.target.checked
}
});
}, [proxyConfig]);
};
const handleAuthUsernameChange = (e) => {
const username = e.target.value;
if (validateAuthUsernameOnChange(username)) {
updateProxy({
auth: {
...currentProxyConfig.auth,
username
}
});
}
};
const handleAuthPasswordChange = (e) => {
const password = e.target.value;
if (validateAuthPasswordOnChange(password)) {
updateProxy({
auth: {
...currentProxyConfig.auth,
password
}
});
}
};
const handleBypassProxyChange = (e) => {
const bypassProxy = e.target.value;
if (validateBypassProxyOnChange(bypassProxy)) {
updateProxy({ bypassProxy });
}
};
const enabledValue = currentProxyConfig.enabled === true ? 'true' : currentProxyConfig.enabled === false ? 'false' : 'global';
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 text-muted">Configure proxy settings for this collection.</div>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="bruno-form">
<div className="mb-3 flex items-center">
<label className="settings-label flex items-center" htmlFor="enabled">
Config
@@ -120,8 +169,8 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
type="radio"
name="enabled"
value="global"
checked={formik.values.enabled === 'global'}
onChange={formik.handleChange}
checked={enabledValue === 'global'}
onChange={handleEnabledChange}
className="mr-1"
/>
global
@@ -130,9 +179,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
<input
type="radio"
name="enabled"
value={'true'}
checked={formik.values.enabled === 'true'}
onChange={formik.handleChange}
value="true"
checked={enabledValue === 'true'}
onChange={handleEnabledChange}
className="mr-1"
/>
enabled
@@ -141,9 +190,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
<input
type="radio"
name="enabled"
value={'false'}
checked={formik.values.enabled === 'false'}
onChange={formik.handleChange}
value="false"
checked={enabledValue === 'false'}
onChange={handleEnabledChange}
className="mr-1"
/>
disabled
@@ -160,8 +209,8 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
type="radio"
name="protocol"
value="http"
checked={formik.values.protocol === 'http'}
onChange={formik.handleChange}
checked={(currentProxyConfig.protocol || 'http') === 'http'}
onChange={handleProtocolChange}
className="mr-1"
/>
HTTP
@@ -171,8 +220,8 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
type="radio"
name="protocol"
value="https"
checked={formik.values.protocol === 'https'}
onChange={formik.handleChange}
checked={(currentProxyConfig.protocol || 'http') === 'https'}
onChange={handleProtocolChange}
className="mr-1"
/>
HTTPS
@@ -182,8 +231,8 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
type="radio"
name="protocol"
value="socks4"
checked={formik.values.protocol === 'socks4'}
onChange={formik.handleChange}
checked={(currentProxyConfig.protocol || 'http') === 'socks4'}
onChange={handleProtocolChange}
className="mr-1"
/>
SOCKS4
@@ -193,8 +242,8 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
type="radio"
name="protocol"
value="socks5"
checked={formik.values.protocol === 'socks5'}
onChange={formik.handleChange}
checked={(currentProxyConfig.protocol || 'http') === 'socks5'}
onChange={handleProtocolChange}
className="mr-1"
/>
SOCKS5
@@ -214,12 +263,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.hostname || ''}
onChange={handleHostnameChange}
value={currentProxyConfig.hostname || ''}
/>
{formik.touched.hostname && formik.errors.hostname ? (
<div className="ml-3 text-red-500">{formik.errors.hostname}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="port">
@@ -234,12 +280,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.port}
onChange={handlePortChange}
value={currentProxyConfig.port || ''}
/>
{formik.touched.port && formik.errors.port ? (
<div className="ml-3 text-red-500">{formik.errors.port}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.enabled">
@@ -248,8 +291,8 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
<input
type="checkbox"
name="auth.enabled"
checked={formik.values.auth.enabled}
onChange={formik.handleChange}
checked={currentProxyConfig.auth?.enabled || false}
onChange={handleAuthEnabledChange}
/>
</div>
<div>
@@ -266,12 +309,9 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.username}
onChange={formik.handleChange}
value={currentProxyConfig.auth?.username || ''}
onChange={handleAuthUsernameChange}
/>
{formik.touched.auth?.username && formik.errors.auth?.username ? (
<div className="ml-3 text-red-500">{formik.errors.auth.username}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.password">
@@ -287,8 +327,8 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.password}
onChange={formik.handleChange}
value={currentProxyConfig.auth?.password || ''}
onChange={handleAuthPasswordChange}
/>
<button
type="button"
@@ -298,9 +338,6 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
</button>
</div>
{formik.touched.auth?.password && formik.errors.auth?.password ? (
<div className="ml-3 text-red-500">{formik.errors.auth.password}</div>
) : null}
</div>
</div>
<div className="mb-3 flex items-center">
@@ -316,19 +353,16 @@ const ProxySettings = ({ proxyConfig, onUpdate }) => {
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.bypassProxy || ''}
onChange={handleBypassProxyChange}
value={currentProxyConfig.bypassProxy || ''}
/>
{formik.touched.bypassProxy && formik.errors.bypassProxy ? (
<div className="ml-3 text-red-500">{formik.errors.bypassProxy}</div>
) : null}
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</form>
</div>
</StyledWrapper>
);
};

View File

@@ -1,20 +1,37 @@
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StyledWrapper from './StyledWrapper';
const Script = ({ collection }) => {
const dispatch = useDispatch();
const requestScript = get(collection, 'root.request.script.req', '');
const responseScript = get(collection, 'root.request.script.res', '');
const [activeTab, setActiveTab] = useState('pre-request');
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
}
}, 0);
return () => clearTimeout(timer);
}, [activeTab]);
const onRequestScriptEdit = (value) => {
dispatch(
updateCollectionRequestScript({
@@ -34,42 +51,51 @@ const Script = ({ collection }) => {
};
const handleSave = () => {
dispatch(saveCollectionRoot(collection.uid));
dispatch(saveCollectionSettings(collection.uid));
};
return (
<StyledWrapper className="w-full flex flex-col h-full">
<StyledWrapper className="w-full flex flex-col h-full pt-4">
<div className="text-xs mb-4 text-muted">
Write pre and post-request scripts that will run before and after any request in this collection is sent.
</div>
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Pre Request</div>
<CodeEditor
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex-1 mt-6">
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<CodeEditor
collection={collection}
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
<TabsTrigger value="post-response">Post Response</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
</Tabs>
<div className="mt-12">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>

View File

@@ -3,13 +3,13 @@ import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
const tests = get(collection, 'root.request.tests', '');
const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
@@ -23,7 +23,7 @@ const Tests = ({ collection }) => {
);
};
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
return (
<StyledWrapper className="w-full flex flex-col h-full">

View File

@@ -3,7 +3,7 @@ import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
@@ -28,7 +28,7 @@ const VarsTable = ({ collection, vars, varType }) => {
);
};
const onSave = () => dispatch(saveCollectionRoot(collection.uid));
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleVarChange = (e, v, type) => {
const _var = cloneDeep(v);
switch (type) {

View File

@@ -2,14 +2,14 @@ import React from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">

View File

@@ -1,9 +1,6 @@
import React from 'react';
import classnames from 'classnames';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import { updateSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import ProxySettings from './ProxySettings';
@@ -31,65 +28,26 @@ const CollectionSettings = ({ collection }) => {
);
};
const root = collection?.root;
const root = collection?.draft?.root || collection?.root;
const hasScripts = root?.request?.script?.res || root?.request?.script?.req;
const hasTests = root?.request?.tests;
const hasDocs = root?.docs;
const headers = get(collection, 'root.request.headers', []);
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
const activeHeadersCount = headers.filter((header) => header.enabled).length;
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const authMode = (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {})).mode || 'none';
const presets = get(collection, 'brunoConfig.presets', []);
const presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', []) : get(collection, 'brunoConfig.presets', []);
const hasPresets = presets && presets.requestUrl !== '';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const proxyConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.proxy', {}) : get(collection, 'brunoConfig.proxy', {});
const proxyEnabled = proxyConfig.hostname ? true : false;
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const protobufConfig = get(collection, 'brunoConfig.protobuf', {});
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.proxy = config;
dispatch(updateBrunoConfig(brunoConfig, collection.uid))
.then(() => {
toast.success('Collection settings updated successfully.');
})
.catch((err) => console.log(err) && toast.error('Failed to update collection settings'));
};
const onClientCertSettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.clientCertificates) {
brunoConfig.clientCertificates = {
enabled: true,
certs: [config]
};
} else {
brunoConfig.clientCertificates.certs.push(config);
}
dispatch(updateBrunoConfig(brunoConfig, collection.uid))
.then(() => {
toast.success('Collection settings updated successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to update collection settings'));
};
const onClientCertSettingsRemove = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.clientCertificates.certs = brunoConfig.clientCertificates.certs.filter(
(item) => item.domain != config.domain
);
dispatch(updateBrunoConfig(brunoConfig, collection.uid))
.then(() => {
toast.success('Collection settings updated successfully');
})
.catch((err) => console.log(err) && toast.error('Failed to update collection settings'));
};
const clientCertConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.clientCertificates.certs', []) : get(collection, 'brunoConfig.clientCertificates.certs', []);
const protobufConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.protobuf', {}) : get(collection, 'brunoConfig.protobuf', {});
const getTabPanel = (tab) => {
switch (tab) {
@@ -115,15 +73,12 @@ const CollectionSettings = ({ collection }) => {
return <Presets collection={collection} />;
}
case 'proxy': {
return <ProxySettings proxyConfig={proxyConfig} onUpdate={onProxySettingsUpdate} />;
return <ProxySettings collection={collection} />;
}
case 'clientCert': {
return (
<ClientCertSettings
collection={collection}
clientCertConfig={clientCertConfig}
onUpdate={onClientCertSettingsUpdate}
onRemove={onClientCertSettingsRemove}
/>
);
}

View File

@@ -26,7 +26,8 @@ const GrantTypeComponentMap = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
let request = get(folder, 'root.request', {});
const folderRoot = folder?.draft || folder?.root;
let request = get(folderRoot, 'request', {});
const grantType = get(request, 'auth.oauth2.grantType', 'authorization_code');
switch (grantType) {
@@ -45,13 +46,15 @@ const GrantTypeComponentMap = ({ collection, folder }) => {
const Auth = ({ collection, folder }) => {
const dispatch = useDispatch();
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const folderRoot = folder?.draft || folder?.root;
let request = get(folderRoot, 'request', {});
const authMode = get(folderRoot, 'request.auth.mode');
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
@@ -66,7 +69,8 @@ const Auth = ({ collection, folder }) => {
for (let i = 0; i < folderTreePath.length - 1; i++) {
const parentFolder = folderTreePath[i];
if (parentFolder.type === 'folder') {
const folderAuth = get(parentFolder, 'root.request.auth');
const parentFolderRoot = parentFolder?.draft || parentFolder?.root;
const folderAuth = get(parentFolderRoot, 'request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',

View File

@@ -11,7 +11,7 @@ const AuthMode = ({ collection, folder }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = get(folder, 'root.request.auth.mode');
const authMode = folder.draft ? get(folder, 'draft.request.auth.mode') : get(folder, 'root.request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (

View File

@@ -14,7 +14,7 @@ const Documentation = ({ collection, folder }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [isEditing, setIsEditing] = useState(false);
const docs = get(folder, 'root.docs', '');
const docs = folder.draft ? get(folder, 'draft.docs', '') : get(folder, 'root.docs', '');
const toggleViewMode = () => {
setIsEditing((prev) => !prev);

View File

@@ -16,7 +16,7 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(folder, 'root.request.headers', []);
const headers = folder.draft ? get(folder, 'draft.request.headers', []) : get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const toggleBulkEditMode = () => {

View File

@@ -1,20 +1,37 @@
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StyledWrapper from './StyledWrapper';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
const requestScript = get(folder, 'root.request.script.req', '');
const responseScript = get(folder, 'root.request.script.res', '');
const [activeTab, setActiveTab] = useState('pre-request');
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
}
}, 0);
return () => clearTimeout(timer);
}, [activeTab]);
const onRequestScriptEdit = (value) => {
dispatch(
updateFolderRequestScript({
@@ -40,38 +57,47 @@ const Script = ({ collection, folder }) => {
};
return (
<StyledWrapper className="w-full flex flex-col h-full">
<StyledWrapper className="w-full flex flex-col h-full pt-4">
<div className="text-xs mb-4 text-muted">
Pre and post-request scripts that will run before and after any request inside this folder is sent.
</div>
<div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="title text-xs">Pre Request</div>
<CodeEditor
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="title text-xs">Post Response</div>
<CodeEditor
collection={collection}
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
<TabsTrigger value="post-response">Post Response</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
</Tabs>
<div className="mt-12">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>

View File

@@ -9,7 +9,7 @@ import StyledWrapper from './StyledWrapper';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
const tests = get(folder, 'root.request.tests', '');
const tests = folder.draft ? get(folder, 'draft.request.tests', '') : get(folder, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);

View File

@@ -7,8 +7,8 @@ import { useDispatch } from 'react-redux';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
const requestVars = get(folder, 'root.request.vars.req', []);
const responseVars = get(folder, 'root.request.vars.res', []);
const requestVars = folder.draft ? get(folder, 'draft.request.vars.req', []) : get(folder, 'root.request.vars.req', []);
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
return (
<StyledWrapper className="w-full flex flex-col">

View File

@@ -20,7 +20,7 @@ const FolderSettings = ({ collection, folder }) => {
tab = folderLevelSettingsSelectedTab[folder?.uid];
}
const folderRoot = folder?.root;
const folderRoot = folder?.draft || folder?.root;
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
const hasTests = folderRoot?.request?.tests;

View File

@@ -45,7 +45,8 @@ const Auth = ({ item, collection }) => {
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',

View File

@@ -6,9 +6,9 @@ import { updateRequestGraphqlVariables } from 'providers/ReduxStore/slices/colle
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { format, applyEdits } from 'jsonc-parser';
import { IconWand } from '@tabler/icons';
import toast from 'react-hot-toast';
import { prettifyJsonString } from 'utils/common/index';
const GraphQLVariables = ({ variables, item, collection }) => {
const dispatch = useDispatch();
@@ -19,8 +19,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onPrettify = () => {
if (!variables) return;
try {
const edits = format(variables, undefined, { tabSize: 2, insertSpaces: true });
const prettyVariables = applyEdits(variables, edits);
const prettyVariables = prettifyJsonString(variables);
dispatch(
updateRequestGraphqlVariables({
variables: prettyVariables,

View File

@@ -12,9 +12,9 @@ import StyledWrapper from './StyledWrapper';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
import { format, applyEdits } from 'jsonc-parser';
import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
import { prettifyJsonString } from 'utils/common/index';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
@@ -130,8 +130,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const prettyBodyJson = prettifyJsonString(content);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
@@ -187,6 +186,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCol
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
data-testid={`grpc-send-message-${index}`}
>
<IconSend
size={16}
@@ -300,6 +300,7 @@ const GrpcBody = ({ item, collection, handleRun }) => {
<div
ref={messagesContainerRef}
id="grpc-messages-container"
data-testid="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${canClientSendMultipleMessages && 'pb-16'}`}
>
{body.grpc
@@ -326,6 +327,7 @@ const GrpcBody = ({ item, collection, handleRun }) => {
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
data-testid="grpc-add-message-button"
>
<IconPlus size={16} strokeWidth={1.5} className="text-neutral-700 dark:text-neutral-300" />
<span className="font-medium text-sm text-neutral-700 dark:text-neutral-300">Add Message</span>

View File

@@ -73,7 +73,7 @@ const MethodDropdown = ({
const MethodsDropdownIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center ml-2 cursor-pointer select-none">
<div ref={ref} className="flex items-center justify-center ml-2 cursor-pointer select-none" data-testid="grpc-method-dropdown-trigger">
{selectedGrpcMethod && <div className="mr-2">{getIconForMethodType(selectedGrpcMethod.type)}</div>}
<span className="text-xs">
{selectedGrpcMethod ? (

View File

@@ -39,7 +39,7 @@ const ProtoFileDropdown = ({
return;
}
const { success: addSuccess, relativePath, alreadyExists, error: addError } = await protoFileManagement.addProtoFileToCollection(filePath);
const { success: addSuccess, relativePath, alreadyExists, error: addError } = await protoFileManagement.addProtoFileFromRequest(filePath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add proto file: ${addError.message}`);
@@ -91,7 +91,7 @@ const ProtoFileDropdown = ({
return;
}
const { success: addSuccess, error: addError } = await protoFileManagement.addImportPathToCollection(directoryPath);
const { success: addSuccess, error: addError } = await protoFileManagement.addImportPathFromRequest(directoryPath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add import path: ${addError.message}`);
@@ -103,7 +103,7 @@ const ProtoFileDropdown = ({
};
const handleToggleImportPath = async (index) => {
const { success, enabled, error } = await protoFileManagement.toggleImportPath(index);
const { success, enabled, error } = await protoFileManagement.toggleImportPathFromRequest(index);
if (!success) {
if (error) {
toast.error(`Failed to toggle import path: ${error.message}`);

View File

@@ -389,19 +389,21 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
{isConnectionActive && isStreamingMethod && (
<div className="connection-controls relative flex items-center h-full gap-3">
<div className="infotip" onClick={handleCancelConnection}>
<div className="infotip" onClick={handleCancelConnection} data-testid="grpc-cancel-connection-button">
<IconX color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
<span className="infotip-text text-xs">Cancel</span>
</div>
{isClientStreamingMethod && <div onClick={handleEndConnection}>
<IconCheck
color={theme.colors.text.green}
strokeWidth={2}
size={22}
className="cursor-pointer"
/>
</div>}
{isClientStreamingMethod && (
<div onClick={handleEndConnection} data-testid="grpc-end-connection-button">
<IconCheck
color={theme.colors.text.green}
strokeWidth={2}
size={22}
className="cursor-pointer"
/>
</div>
)}
</div>
)}

View File

@@ -48,7 +48,8 @@ const GrpcAuth = ({ item, collection }) => {
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',

View File

@@ -2,10 +2,10 @@ import React, { useState, useEffect, useRef, useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { requestUrlChanged, updateRequestMethod } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { cancelRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import HttpMethodSelector from './HttpMethodSelector';
import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode } from '@tabler/icons';
import { IconDeviceFloppy, IconArrowRight, IconCode, IconSquareRoundedX } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
@@ -22,6 +22,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const editorRef = useRef(null);
const isGrpc = item.type === 'grpc-request';
const isLoading = ['queued', 'sending'].includes(item.requestState);
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
@@ -40,9 +41,9 @@ const QueryUrl = ({ item, collection, handleRun }) => {
if (!editorRef.current?.editor) return;
const editor = editorRef.current.editor;
const cursor = editor.getCursor();
const finalUrl = value?.trim() ?? value;
dispatch(
requestUrlChanged({
itemUid: item.uid,
@@ -50,7 +51,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
url: finalUrl
})
);
// Restore cursor position only if URL was trimmed
if (finalUrl !== value) {
setTimeout(() => {
@@ -80,6 +81,12 @@ const QueryUrl = ({ item, collection, handleRun }) => {
}
};
const handleCancelRequest = (e) => {
e.preventDefault();
e.stopPropagation();
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
<StyledWrapper className="flex items-center">
<div className="flex flex-1 items-center h-full method-selector-container">
@@ -87,7 +94,6 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<div className="flex items-center justify-center h-full w-16">
<span className="text-xs text-indigo-500 font-bold">gRPC</span>
</div>
) : (
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
)}
@@ -120,15 +126,8 @@ const QueryUrl = ({ item, collection, handleRun }) => {
handleGenerateCode(e);
}}
>
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={'cursor-pointer'}
/>
<span className="infotiptext text-xs">
Generate Code
</span>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={22} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
@@ -149,11 +148,30 @@ const QueryUrl = ({ item, collection, handleRun }) => {
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={22} data-testid="send-arrow-icon" />
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={22}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={22}
data-testid="send-arrow-icon"
/>
)}
</div>
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem
collectionUid={collection.uid}
item={item}
onClose={() => setGenerateCodeItemModalOpen(false)}
/>
)}
</StyledWrapper>
);

View File

@@ -1,20 +1,37 @@
import React from 'react';
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
const Script = ({ item, collection }) => {
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('pre-request');
const preRequestEditorRef = useRef(null);
const postResponseEditorRef = useRef(null);
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
// Small delay to ensure DOM is updated
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
}
}, 0);
return () => clearTimeout(timer);
}, [activeTab]);
const onRequestScriptEdit = (value) => {
dispatch(
updateRequestScript({
@@ -39,38 +56,46 @@ const Script = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="title text-xs">Pre Request</div>
<CodeEditor
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onRequestScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex flex-col flex-1 mt-2 gap-y-2">
<div className="title text-xs">Post Response</div>
<CodeEditor
collection={collection}
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onResponseScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
/>
</div>
</StyledWrapper>
<div className="w-full h-full flex flex-col pt-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">Pre Request</TabsTrigger>
<TabsTrigger value="post-response">Post Response</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onRequestScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onResponseScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
/>
</TabsContent>
</Tabs>
</div>
);
};

View File

@@ -40,7 +40,8 @@ const WSAuth = ({ item, collection }) => {
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',

View File

@@ -10,7 +10,7 @@ import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import Welcome from 'components/Welcome';
import { findItemInCollection } from 'utils/collections';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
@@ -263,11 +263,17 @@ const RequestTabPanel = () => {
return;
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
);
if (item.response?.stream?.running) {
dispatch(cancelRequest(item.cancelTokenUid, item, collection)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
}));
} else if (item.requestState !== 'sending' && item.requestState !== 'queued') {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
}));
}
};
// TODO: reaper, improve selection of panes

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
const ConfirmCollectionClose = ({ collection, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
return (
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<div className="font-normal mt-4">
You have unsaved changes in <span className="font-semibold">{collection.name}</span> collection settings.
</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
Save
</button>
</div>
</div>
</Modal>
);
};
export default ConfirmCollectionClose;

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
const ConfirmFolderClose = ({ folder, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
return (
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<div className="font-normal mt-4">
You have unsaved changes in <span className="font-semibold">{folder.name}</span> folder settings.
</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
Save
</button>
</div>
</div>
</Modal>
);
};
export default ConfirmFolderClose;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
const getTabInfo = (type, tabName) => {
switch (type) {
case 'collection-settings': {
@@ -60,7 +61,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
<>
<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)}>
<CloseTabIcon />
{hasDraft ? <DraftTabIcon /> : <CloseTabIcon />}
</div>
</>
);

View File

@@ -1,14 +1,16 @@
import React, { useCallback, useState, useRef, Fragment, useMemo } from 'react';
import get from 'lodash/get';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import darkTheme from 'themes/dark';
import lightTheme from 'themes/light';
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
import ConfirmRequestClose from './ConfirmRequestClose';
import ConfirmCollectionClose from './ConfirmCollectionClose';
import ConfirmFolderClose from './ConfirmFolderClose';
import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
@@ -26,6 +28,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const { storedTheme } = useTheme();
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
const [showConfirmClose, setShowConfirmClose] = useState(false);
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
@@ -77,17 +81,97 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
return colorMap[method.toLocaleLowerCase()];
};
const handleCloseCollectionSettings = (event) => {
if (!collection.draft) {
return handleCloseClick(event);
}
event.stopPropagation();
event.preventDefault();
setShowConfirmCollectionClose(true);
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
const handleCloseFolderSettings = (event) => {
if (!folder?.draft) {
return handleCloseClick(event);
}
event.stopPropagation();
event.preventDefault();
setShowConfirmFolderClose(true);
};
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
<ConfirmCollectionClose
collection={collection}
onCancel={() => setShowConfirmCollectionClose(false)}
onCloseWithoutSave={() => {
dispatch(deleteCollectionDraft({
collectionUid: collection.uid
}));
dispatch(closeTabs({
tabUids: [tab.uid]
}));
setShowConfirmCollectionClose(false);
}}
onSaveAndClose={() => {
dispatch(saveCollectionRoot(collection.uid))
.then(() => {
dispatch(closeTabs({
tabUids: [tab.uid]
}));
setShowConfirmCollectionClose(false);
})
.catch((err) => {
console.log('err', err);
});
}}
/>
)}
{showConfirmFolderClose && tab.type === 'folder-settings' && (
<ConfirmFolderClose
folder={folder}
onCancel={() => setShowConfirmFolderClose(false)}
onCloseWithoutSave={() => {
dispatch(deleteFolderDraft({
collectionUid: collection.uid,
folderUid: folder.uid
}));
dispatch(closeTabs({
tabUids: [tab.uid]
}));
setShowConfirmFolderClose(false);
}}
onSaveAndClose={() => {
dispatch(saveFolderRoot(collection.uid, folder.uid))
.then(() => {
dispatch(closeTabs({
tabUids: [tab.uid]
}));
setShowConfirmFolderClose(false);
})
.catch((err) => {
console.log('err', err);
});
}}
/>
)}
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
<SpecialTab handleCloseClick={handleCloseFolderSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} />
) : tab.type === 'collection-settings' ? (
<SpecialTab handleCloseClick={handleCloseCollectionSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={collection?.name} hasDraft={hasDraft} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}

View File

@@ -61,13 +61,13 @@ const GrpcQueryResult = ({ item, collection }) => {
}
return (
<StyledWrapper className="w-full h-full relative flex flex-col mt-2">
<StyledWrapper className="w-full h-full relative flex flex-col mt-2" data-testid="grpc-response-content">
{hasError && showErrorMessage && <GrpcError error={errorMessage} onClose={() => setShowErrorMessage(false)} />}
{hasResponses && (
<div className={`overflow-y-auto ${responsesList.length === 1 ? 'flex-1' : ''}`}>
<div className={`overflow-y-auto ${responsesList.length === 1 ? 'flex-1' : ''}`} data-testid="grpc-responses-container">
{responsesList.length === 1 ? (
// Single message - render directly without accordion
<div className="h-full">
<div className="h-full" data-testid="grpc-single-response">
<CodeEditor
collection={collection}
font={get(preferences, 'font.codeFont', 'default')}
@@ -80,13 +80,13 @@ const GrpcQueryResult = ({ item, collection }) => {
</div>
) : (
// Multiple messages - use accordion
<Accordion defaultIndex={0}>
<Accordion defaultIndex={0} dataTestId="grpc-responses-accordion">
{reversedResponsesList.map((response, index) => {
// Calculate the original response number (for display purposes)
const originalIndex = responsesList.length - index - 1;
return (
<Accordion.Item key={originalIndex} index={index}>
<Accordion.Item key={originalIndex} index={index} data-testid={`grpc-response-item-${originalIndex}`}>
<Accordion.Header index={index} style={{ padding: '8px 12px', minHeight: '40px' }}>
<div className="flex justify-between w-full">
<div className="font-medium">

View File

@@ -108,7 +108,7 @@ const GrpcResponsePane = ({ item, collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist" data-testid="grpc-response-tabs">
{tabConfig.map((tab) => (
<Tab
key={tab.name}

View File

@@ -12,12 +12,25 @@ import { getInitialExampleName } from 'utils/collections/index';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
if (isStreamingResponse) {
return 'Response Examples aren\'t supported in streaming responses yet.';
}
if (isResponseTooLarge) {
return 'Response size exceeds 5MB limit. Cannot save as example.';
}
return 'Save current response as example';
};
const ResponseBookmark = ({ item, collection, responseSize }) => {
const dispatch = useDispatch();
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
const response = item.response || {};
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
const isStreamingResponse = response.stream;
// Only show for HTTP requests
if (item.type !== 'http-request') {
@@ -96,19 +109,22 @@ const ResponseBookmark = ({ item, collection, responseSize }) => {
toast.success(`Example "${name}" created successfully`);
};
const disabledMessage = getTitleText({
isResponseTooLarge,
isStreamingResponse
});
return (
<>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={handleSaveClick}
disabled={isResponseTooLarge}
disabled={isResponseTooLarge || isStreamingResponse}
title={
isResponseTooLarge
? 'Response size exceeds 5MB limit. Cannot save as example.'
: 'Save current response as example'
disabledMessage
}
className={classnames('p-1', {
'opacity-50 cursor-not-allowed': isResponseTooLarge
'opacity-50 cursor-not-allowed': isResponseTooLarge || isStreamingResponse
})}
data-testid="response-bookmark-btn"
>

View File

@@ -0,0 +1,10 @@
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 0.75rem;
font-weight: 600;
color: ${(props) => props.theme.requestTabPanel.responseStatus};
text-align: center;
`;
export default Wrapper;

View File

@@ -0,0 +1,27 @@
import React, { useState, useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseStopWatch = ({ startMillis }) => {
const [milliseconds, setMilliseconds] = useState(startMillis);
const tickInterval = 100;
const tick = () => {
setMilliseconds((_milliseconds) => _milliseconds + tickInterval);
};
useEffect(() => {
let timerID = setInterval(() => {
tick();
}, tickInterval);
return () => {
clearInterval(timerID);
};
}, []);
let seconds = milliseconds / 1000;
let secondsFormatted = `${seconds.toFixed(1)}s`;
let width = secondsFormatted.length * 0.4; // Calculate width manually to stop parent layout from "flickering" by changing width too fast
return <StyledWrapper className="ml-4" style={{ width: `${width}rem` }}>{secondsFormatted}</StyledWrapper>;
};
export default React.memo(ResponseStopWatch);

View File

@@ -4,7 +4,7 @@ import statusCodePhraseMap from './get-status-code-phrase';
import StyledWrapper from './StyledWrapper';
// Todo: text-error class is not getting pulled in for 500 errors
const StatusCode = ({ status, statusText }) => {
const StatusCode = ({ status, statusText, isStreaming }) => {
const getTabClassname = (status) => {
return classnames('ml-2', {
'text-ok': status >= 100 && status < 200,
@@ -17,7 +17,7 @@ const StatusCode = ({ status, statusText }) => {
return (
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`} data-testid="response-status-code">
{status} {statusText || statusCodePhraseMap[status]}
{status} {statusText || statusCodePhraseMap[status]} {isStreaming ? ' - STREAMING' : null}
</StyledWrapper>
);
};

View File

@@ -9,7 +9,8 @@ const getEffectiveAuthSource = (collection, item) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
uid: collection.uid,

View File

@@ -195,7 +195,7 @@ const WSMessagesList = ({ order = -1, messages = [] }) => {
<StyledWrapper className="ws-messages-list mt-1 flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem inFocus={inFocus} id={idx} message={msg} />;
return <WSMessageItem key={msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
})}
</StyledWrapper>
);

View File

@@ -23,6 +23,8 @@ import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import ResponseLayoutToggle from './ResponseLayoutToggle';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponseStopWatch from 'components/ResponsePane/ResponseStopWatch';
import WSMessagesList from './WsResponsePane/WSMessagesList';
const ResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -71,6 +73,10 @@ const ResponsePane = ({ item, collection }) => {
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
const isStream = item.response?.stream ?? false;
if (isStream) {
return <WSMessagesList order={-1} messages={item.response.data} />;
}
return (
<QueryResult
item={item}
@@ -184,8 +190,10 @@ const ResponsePane = ({ item, collection }) => {
<ResponseClear item={item} collection={collection} />
<ResponseSave item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<StatusCode status={response.status} isStreaming={item.response?.stream?.running} />
{item.response?.stream?.running
? <ResponseStopWatch startMillis={response.duration} />
: <ResponseTime duration={response.duration} />}
<ResponseSize size={responseSize} />
</>
) : null}

View File

@@ -3,16 +3,13 @@ import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash';
import { runCollectionFolder, cancelRunnerExecution, mountCollection, updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconLoader2 } from '@tabler/icons';
import { resetCollectionRunner, updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection, areItemsLoading, getRequestItemsForCollectionRun } from 'utils/collections';
import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconExternalLink } from '@tabler/icons';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
import RunnerTags from './RunnerTags/index';
import RunConfigurationPanel from './RunConfigurationPanel';
import { getRequestItemsForCollectionRun } from 'utils/collections/index';
import { updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections/index';
const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
@@ -42,49 +39,61 @@ const anyTestFailed = (item) => {
item.postResponseTestStatus === 'fail';
};
// === Centralized filters definition ===
const FILTERS = {
all: {
label: 'All',
predicate: () => true,
resultFilter: (results) => results
},
passed: {
label: 'Passed',
predicate: (item) => allTestsPassed(item),
resultFilter: (results) => results?.filter((r) => r.status === 'pass')
},
failed: {
label: 'Failed',
predicate: (item) => anyTestFailed(item),
resultFilter: (results) => results?.filter((r) => ['fail', 'error'].includes(r.status))
},
skipped: {
label: 'Skipped',
predicate: (item) => item.status === 'skipped',
resultFilter: (results) => results
}
};
// === Reusable filter button ===
const FilterButton = ({ label, count, active, onClick }) => (
<button
onClick={onClick}
className={`font-medium transition-colors cursor-pointer flex items-center gap-1.5 border-b-2 pb-2 ${
active
? 'text-[#343434] dark:text-[#CCCCCC] border-[#F59E0B]'
: 'text-[#989898] dark:text-[#CCCCCC80] border-transparent'
}`}
style={{ fontFamily: 'Inter', fontSize: '14px', fontWeight: 500, lineHeight: '100%', letterSpacing: '0%' }}
>
{label}
<span
className="px-[4.5px] py-[2px] rounded-[2px] bg-[#F7F7F7] dark:bg-[#242424] border border-[#EFEFEF] dark:border-[#92929233] text-[#989898] dark:text-inherit"
style={{ borderWidth: '1px', fontFamily: 'Inter', fontSize: '10px', fontWeight: 500, lineHeight: '100%', letterSpacing: '0%' }}
>
{count}
</span>
</button>
);
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
const [activeFilter, setActiveFilter] = useState('all');
const [selectedRequestItems, setSelectedRequestItems] = useState([]);
const [configureMode, setConfigureMode] = useState(false);
// ref for the runner output body
const runnerBodyRef = useRef();
const autoScrollRunnerBody = () => {
if (runnerBodyRef?.current) {
// mimics the native terminal scroll style
runnerBodyRef.current.scrollTo(0, 100000);
}
};
useEffect(() => {
if (!collection.runnerResult) {
setSelectedItem(null);
}
autoScrollRunnerBody();
}, [collection, setSelectedItem]);
useEffect(() => {
const runnerInfo = get(collection, 'runnerResult.info', {});
if (runnerInfo.status === 'running') {
setConfigureMode(false);
}
}, [collection.runnerResult]);
useEffect(() => {
const savedConfiguration = get(collection, 'runnerConfiguration', null);
if (savedConfiguration) {
if (savedConfiguration.selectedRequestItems && configureMode) {
setSelectedRequestItems(savedConfiguration.selectedRequestItems);
}
if (savedConfiguration.delay !== undefined && delay === null) {
setDelay(savedConfiguration.delay);
}
}
}, [collection.runnerConfiguration, configureMode, delay]);
const collectionCopy = cloneDeep(collection);
const runnerInfo = get(collection, 'runnerResult.info', {});
@@ -126,6 +135,63 @@ export default function RunnerResults({ collection }) {
})
.filter(Boolean);
const activeFilterConfig = FILTERS[activeFilter];
const filteredItems = items.filter(activeFilterConfig.predicate);
const filterTestResults = (results) => {
if (!results || !Array.isArray(results)) return [];
return activeFilterConfig.resultFilter(results);
};
const autoScrollRunnerBody = () => {
if (runnerBodyRef?.current) {
const element = runnerBodyRef.current;
const scrollThreshold = 100; // pixels from bottom to consider "at bottom"
const isNearBottom
= element.scrollHeight - element.scrollTop - element.clientHeight < scrollThreshold;
// Only auto-scroll if user is already near the bottom
if (isNearBottom) {
// mimics the native terminal scroll style
element.scrollTo(0, 100000);
}
}
};
useEffect(() => {
if (!collection.runnerResult) {
setSelectedItem(null);
}
autoScrollRunnerBody();
}, [collection, setSelectedItem]);
useEffect(() => {
// Auto-scroll when items are added or updated during execution
// Only scrolls if user is already at/near the bottom
if (filteredItems.length > 0) {
autoScrollRunnerBody();
}
}, [filteredItems]);
useEffect(() => {
const runnerInfo = get(collection, 'runnerResult.info', {});
if (runnerInfo.status === 'running') {
setConfigureMode(false);
}
}, [collection.runnerResult]);
useEffect(() => {
const savedConfiguration = get(collection, 'runnerConfiguration', null);
if (savedConfiguration) {
if (savedConfiguration.selectedRequestItems && configureMode) {
setSelectedRequestItems(savedConfiguration.selectedRequestItems);
}
if (savedConfiguration.delay !== undefined && delay === null) {
setDelay(savedConfiguration.delay);
}
}
}, [collection.runnerConfiguration, configureMode, delay]);
const ensureCollectionIsMounted = () => {
if(collection.mountStatus === 'mounted'){
return;
@@ -192,14 +258,14 @@ export default function RunnerResults({ collection }) {
}, [tagsEnabled]);
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const passedRequests = items.filter(allTestsPassed);
const failedRequests = items.filter(anyTestFailed);
const filterCounts = {
all: items.length,
passed: items.filter(allTestsPassed).length,
failed: items.filter(anyTestFailed).length,
skipped: items.filter((i) => i.status === 'skipped').length
};
const skippedRequests = items.filter((item) => {
return item.status === 'skipped';
});
let isCollectionLoading = areItemsLoading(collection);
if (!items || !items.length) {
return (
<StyledWrapper className="pl-4 overflow-hidden h-full">
@@ -285,27 +351,57 @@ export default function RunnerResults({ collection }) {
return (
<StyledWrapper className="px-4 pb-4 flex flex-grow flex-col relative overflow-auto">
<div className="flex items-center my-6 flex-row">
<div className="font-medium title flex items-center">
Runner
<IconRun size={20} strokeWidth={1.5} className="ml-2" />
{/* Filter Bar and Actions */}
<div className="flex items-center justify-between mb-4 pt-[14px] gap-4">
<div className="flex items-stretch rounded-lg border border-[#EFEFEF] dark:border-[#92929233] max-h-[35px] flex-shrink-0" style={{ borderWidth: '1px' }}>
<div className="flex items-center px-3 py-2 rounded-l-lg bg-[#F3F3F3] dark:bg-[#2B2D2F]">
<span className="text-gray-600 dark:text-gray-400" style={{ fontFamily: 'Inter', fontSize: '14px', fontWeight: 400 }}>
Filter by:
</span>
</div>
<div className="flex items-center gap-5 px-3 pt-2 pb-0 rounded-r-lg bg-transparent dark:bg-transparent">
{Object.entries(FILTERS).map(([key, { label }]) => (
<FilterButton
key={key}
label={label}
count={filterCounts[key]}
active={activeFilter === key}
onClick={() => setActiveFilter(key)}
/>
))}
</div>
</div>
{runnerInfo.status !== 'ended' && runnerInfo.cancelTokenUid && (
<button className="btn btn-sm btn-danger" onClick={cancelExecution}>
Cancel Execution
</button>
)}
{runnerInfo.status !== 'ended' && runnerInfo.cancelTokenUid ? (
<div className="flex items-center flex-shrink-0">
<button className="btn btn-sm btn-danger" onClick={cancelExecution}>Cancel Execution</button>
</div>
) : runnerInfo.status === 'ended' ? (
<div className="flex items-center gap-3 flex-shrink-0">
<button
type="button"
className="px-3 py-1.5 rounded-md bg-transparent border border-[#989898] dark:border-[#444444] text-[#989898] hover:opacity-80 transition-colors"
style={{ fontFamily: 'Inter', fontSize: '12px', fontWeight: 500 }}
onClick={runAgain}
>
Run Again
</button>
<button
type="button"
className="px-3 py-1.5 rounded-md bg-transparent border border-[#989898] dark:border-[#444444] text-[#989898] hover:opacity-80 transition-colors"
style={{ fontFamily: 'Inter', fontSize: '12px', fontWeight: 500 }}
onClick={resetRunner}
>
Reset
</button>
</div>
) : null}
</div>
<div className="flex gap-4 h-[calc(100vh_-_10rem)] overflow-hidden">
<div
className={`flex flex-col overflow-y-auto ${selectedItem || (configureMode && !selectedItem && !runnerInfo.status === 'running') ? 'w-1/2' : 'w-full'}`}
ref={runnerBodyRef}
className="flex flex-col w-1/2"
>
<div className="pb-2 font-medium test-summary">
Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '}
{skippedRequests.length}
</div>
{tagsEnabled && areTagsAdded && (
<div className="pb-2 text-xs flex flex-row gap-1">
Tags:
@@ -326,8 +422,8 @@ export default function RunnerResults({ collection }) {
: null}
{/* Items list */}
<div className="overflow-y-auto flex-1">
{items.map((item) => {
<div className="overflow-y-auto flex-1 " ref={runnerBodyRef}>
{filteredItems.map((item) => {
return (
<div key={item.uid}>
<div className="item-path mt-2">
@@ -371,7 +467,7 @@ export default function RunnerResults({ collection }) {
<ul className="pl-8">
{item.preRequestTestResults
? item.preRequestTestResults.map((result) => (
? filterTestResults(item.preRequestTestResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -391,7 +487,7 @@ export default function RunnerResults({ collection }) {
))
: null}
{item.postResponseTestResults
? item.postResponseTestResults.map((result) => (
? filterTestResults(item.postResponseTestResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -411,7 +507,7 @@ export default function RunnerResults({ collection }) {
))
: null}
{item.testResults
? item.testResults.map((result) => (
? filterTestResults(item.testResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -430,7 +526,7 @@ export default function RunnerResults({ collection }) {
</li>
))
: null}
{item.assertionResults?.map((result) => (
{filterTestResults(item.assertionResults).map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
@@ -454,42 +550,50 @@ export default function RunnerResults({ collection }) {
);
})}
</div>
{runnerInfo.status === 'ended' ? (
<div className="mt-2 mb-4">
<button type="submit" className="submit btn btn-sm btn-secondary mt-6" onClick={runAgain}>
Run Again
</button>
<button type="submit" className="submit btn btn-sm btn-secondary mt-6 ml-3" disabled={shouldDisableCollectionRun} onClick={runCollection}>
Run Collection
</button>
<button className="btn btn-sm btn-close mt-6 ml-3" onClick={resetRunner}>
Reset
</button>
</div>
) : null}
</div>
{selectedItem ? (
<div className="flex flex-1 w-[50%] overflow-y-auto">
<div className="flex flex-col w-full overflow-hidden">
<div className="flex items-center mb-4 font-medium">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{allTestsPassed(selectedItem) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(selectedItem) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
</span>
<div className="flex items-center justify-between mb-4 font-medium">
<div className="flex items-center">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{allTestsPassed(selectedItem)
? <IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{anyTestFailed(selectedItem)
? <IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped'
? <IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
: null}
</span>
</div>
<button
onClick={() => setSelectedItem(null)}
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer flex items-center justify-center"
title="Close"
aria-label="Close response view"
>
<IconX size={16} strokeWidth={1.5} />
</button>
</div>
<ResponsePane item={selectedItem} collection={collection} />
</div>
</div>
) : null}
) : (
<div className="flex flex-1 w-[50%] overflow-y-auto">
<div className="flex flex-col w-full h-full items-center justify-center text-center">
<div className="mb-4 text-gray-400 dark:text-gray-500">
<IconExternalLink size={64} strokeWidth={1.5} />
</div>
<p className="text-gray-600 dark:text-gray-400 text-sm">
Click on the status code to view the response
</p>
</div>
</div>
)}
</div>
</StyledWrapper>
);

View File

@@ -21,13 +21,14 @@ export const resolveInheritedAuth = (item, collection) => {
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
const folderAuth = i?.draft ? get(i, 'draft.request.auth') : get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
break;

View File

@@ -2,13 +2,14 @@ import { buildHarRequest } from 'utils/codegenerator/har';
import { getAuthHeaders } from 'utils/codegenerator/auth';
import { getAllVariables, getTreePathFromCollectionToItem } from 'utils/collections/index';
import { interpolateHeaders, interpolateBody } from './interpolation';
import { get } from 'lodash';
// Merge headers from collection, folders, and request
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
// Add collection headers first
const collectionHeaders = collection?.root?.request?.headers || [];
const collectionHeaders = collection?.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
@@ -19,7 +20,7 @@ const mergeHeaders = (collection, request, requestTreePath) => {
if (requestTreePath && requestTreePath.length > 0) {
for (let i of requestTreePath) {
if (i.type === 'folder') {
const folderHeaders = i?.root?.request?.headers || [];
const folderHeaders = i?.draft ? get(i, 'draft.request.headers', []) : get(i, 'root.request.headers', []);
folderHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header);
@@ -56,7 +57,7 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
// Add auth headers if needed
if (request.auth && request.auth.mode !== 'none') {
const collectionAuth = collection?.root?.request?.auth || null;
const collectionAuth = collection?.draft?.root ? get(collection, 'draft.root.request.auth', null) : get(collection, 'root.request.auth', null);
const authHeaders = getAuthHeaders(collectionAuth, request.auth);
headers = [...headers, ...authHeaders];
}

View File

@@ -3,13 +3,11 @@ import { IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import jsyaml from 'js-yaml';
import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isPostmanCollection } from 'utils/importers/postman-collection';
import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { wsdlToBruno } from '@usebruno/converters';
import ImportSettings from 'components/Sidebar/ImportSettings';
import { isBrunoCollection } from 'utils/importers/bruno-collection';
import FullscreenLoader from './FullscreenLoader/index';
const convertFileToObject = async (file) => {
@@ -38,9 +36,6 @@ const convertFileToObject = async (file) => {
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [showImportSettings, setShowImportSettings] = useState(false);
const [openApiData, setOpenApiData] = useState(null);
const [groupingType, setGroupingType] = useState('tags');
const fileInputRef = useRef(null);
const handleDrag = (e) => {
@@ -58,16 +53,6 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
}
};
const handleImportSettings = () => {
try {
const collection = convertOpenapiToBruno(openApiData, { groupBy: groupingType });
handleSubmit({ collection });
} catch (err) {
console.error(err);
toastError(err, 'Failed to process OpenAPI specification');
}
};
const processFile = async (file) => {
setIsLoading(true);
try {
@@ -77,26 +62,23 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
throw new Error('Failed to parse file content');
}
// Check if it's an OpenAPI spec and show settings
let type = null;
if (isOpenApiSpec(data)) {
setOpenApiData(data);
setIsLoading(false);
setShowImportSettings(true);
return;
}
let collection;
if (isWSDLCollection(data)) {
collection = await wsdlToBruno(data);
type = 'openapi';
} else if (isWSDLCollection(data)) {
type = 'wsdl';
} else if (isPostmanCollection(data)) {
collection = await postmanToBruno(data);
type = 'postman';
} else if (isInsomniaCollection(data)) {
collection = convertInsomniaToBruno(data);
type = 'insomnia';
} else if (isBrunoCollection(data)) {
type = 'bruno';
} else {
collection = await processBrunoCollection(data);
throw new Error('Unsupported collection format');
}
handleSubmit({ collection });
handleSubmit({ rawData: data, type });
} catch (err) {
toastError(err, 'Import collection failed');
} finally {
@@ -140,17 +122,6 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
'application/xml'
];
if (showImportSettings) {
return (
<ImportSettings
groupingType={groupingType}
setGroupingType={setGroupingType}
onClose={onClose}
onConfirm={handleImportSettings}
/>
);
}
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<div className="flex flex-col">

View File

@@ -1,15 +1,94 @@
import React, { useRef, useEffect, useState } from 'react';
import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useDispatch } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { IconCaretDown } from '@tabler/icons';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { postmanToBruno } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { wsdlToBruno } from '@usebruno/converters';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import Help from 'components/Help';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
// Extract collection name from raw data
const getCollectionName = (format, rawData) => {
if (!rawData) return 'Collection';
const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) => {
switch (format) {
case 'openapi':
return rawData.info?.title || 'OpenAPI Collection';
case 'postman':
return rawData.info?.name || rawData.collection?.info?.name || 'Postman Collection';
case 'insomnia':
// For Insomnia v4 format, name is in the workspace resource
if (rawData.resources && Array.isArray(rawData.resources)) {
const workspace = rawData.resources.find((r) => r._type === 'workspace');
if (workspace?.name) {
return workspace.name;
}
}
// Fallback to root name property
return rawData.name || 'Insomnia Collection';
case 'bruno':
return rawData.name || 'Bruno Collection';
case 'wsdl':
return 'WSDL Collection';
default:
return 'Collection';
}
};
// Convert raw data to Bruno collection format
const convertCollection = async (format, rawData, groupingType) => {
try {
let collection;
switch (format) {
case 'openapi':
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
break;
case 'wsdl':
collection = await wsdlToBruno(rawData);
break;
case 'postman':
collection = await postmanToBruno(rawData);
break;
case 'insomnia':
collection = convertInsomniaToBruno(rawData);
break;
case 'bruno':
collection = await processBrunoCollection(rawData);
break;
default:
throw new Error('Unknown collection format');
}
return collection;
} catch (err) {
console.error('Conversion error:', err);
toastError(err, 'Failed to convert collection');
throw err;
}
};
const groupingOptions = [
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
];
const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [groupingType, setGroupingType] = useState('tags');
const dropdownTippyRef = useRef();
const isOpenApi = format === 'openapi';
const collectionName = getCollectionName(format, rawData);
const formik = useFormik({
enableReinitialize: true,
@@ -22,10 +101,27 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
.max(500, 'must be 500 characters or less')
.required('Location is required')
}),
onSubmit: (values) => {
handleSubmit(values.collectionLocation);
onSubmit: async (values) => {
const convertedCollection = await convertCollection(format, rawData, groupingType);
handleSubmit(convertedCollection, values.collectionLocation);
}
});
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const GroupingDropdownIcon = forwardRef((props, ref) => {
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
return (
<div ref={ref} className="flex items-center justify-between w-full current-group" data-testid="grouping-dropdown">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
</div>
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
</div>
);
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
@@ -48,53 +144,89 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
const onSubmit = () => formik.handleSubmit();
return (
<Modal size="sm" title="Import Collection" confirmText="Import" handleConfirm={onSubmit} handleCancel={onClose} dataTestId="import-collection-location-modal">
<form className="bruno-form" onSubmit={e => e.preventDefault()}>
<div>
<label htmlFor="collectionName" className="block font-semibold">
Name
</label>
<div className="mt-2">{collectionName}</div>
<>
<label htmlFor="collectionLocation" className="block font-semibold mt-3 flex items-center">
Location
<Help>
<p>
Bruno stores your collections on your computer's filesystem.
</p>
<p className="mt-2">
Choose the location where you want to store this collection.
</p>
</Help>
<StyledWrapper>
<Modal
size="sm"
title="Import Collection"
confirmText="Import"
handleConfirm={onSubmit}
handleCancel={onClose}
dataTestId="import-collection-location-modal"
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="collectionName" className="block font-semibold">
Name
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
</>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
) : null}
<div className="mt-2">{collectionName}</div>
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
<>
<label htmlFor="collectionLocation" className="font-semibold mt-4 flex items-center">
Location
<Help>
<p>Bruno stores your collections on your computer's filesystem.</p>
<p className="mt-2">Choose the location where you want to store this collection.</p>
</Help>
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={(e) => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
</>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
</div>
</form>
</Modal>
{isOpenApi && (
<div className="mt-4 flex gap-4 items-center">
<div>
<label htmlFor="groupingType" className="block font-semibold mt-4">
Folder arrangement
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 mb-2">
Select whether to create folders according to the spec's paths or tags.
</p>
</div>
<div className="relative">
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
{groupingOptions.map((option) => (
<div
key={option.value}
className="dropdown-item"
data-testid={option.testId}
onClick={() => {
dropdownTippyRef?.current?.hide();
setGroupingType(option.value);
}}
>
{option.label}
</div>
))}
</Dropdown>
</div>
</div>
)}
</form>
</Modal>
</StyledWrapper>
);
};

View File

@@ -1,84 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import StyledWrapper from './StyledWrapper';
const groupingOptions = [
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
];
const ImportSettings = ({
groupingType,
setGroupingType,
onClose,
onConfirm
}) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const GroupingDropdownIcon = forwardRef((props, ref) => {
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
return (
<div
ref={ref}
className="flex items-center justify-between w-full current-group"
data-testid="grouping-dropdown"
>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
</div>
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
</div>
);
});
return (
<Portal>
<Modal
size="sm"
title="OpenAPI Import Settings"
handleCancel={onClose}
handleConfirm={onConfirm}
confirmText="Import"
dataTestId="import-settings-modal"
>
<StyledWrapper>
<div className="flex items-center">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Folder arrangement</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Select whether to create folders according to the spec's paths or tags.
</p>
</div>
<div className="relative">
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
{groupingOptions.map((option) => (
<div
key={option.value}
className="dropdown-item"
data-testid={option.testId}
onClick={() => {
dropdownTippyRef?.current?.hide();
setGroupingType(option.value);
}}
>
{option.label}
</div>
))}
</Dropdown>
</div>
</div>
</StyledWrapper>
</Modal>
</Portal>
);
};
export default ImportSettings;

View File

@@ -15,24 +15,24 @@ import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const TitleBar = () => {
const [importedCollection, setImportedCollection] = useState(null);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [importData, setImportData] = useState(null);
const dispatch = useDispatch();
const { ipcRenderer } = window;
const handleImportCollection = ({ collection }) => {
setImportedCollection(collection);
const handleImportCollection = ({ rawData, type }) => {
setImportData({ rawData, type });
setImportCollectionModalOpen(false);
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (collectionLocation) => {
dispatch(importCollection(importedCollection, collectionLocation))
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
dispatch(importCollection(convertedCollection, collectionLocation))
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportedCollection(null);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
@@ -72,9 +72,10 @@ const TitleBar = () => {
{importCollectionModalOpen ? (
<ImportCollection onClose={() => setImportCollectionModalOpen(false)} handleSubmit={handleImportCollection} />
) : null}
{importCollectionLocationModalOpen ? (
{importCollectionLocationModalOpen && importData ? (
<ImportCollectionLocation
collectionName={importedCollection.name}
rawData={importData.rawData}
format={importData.type}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>

View File

@@ -11,10 +11,11 @@ const Tab = ({ name, label, isActive, onClick, count = 0, className = '', ...pro
className={tabClassName}
role="tab"
onClick={() => onClick(name)}
data-testid={`tab-${name}`}
{...props}
>
{label}
{count > 0 && <sup className="ml-1 font-medium">{count}</sup>}
{count > 0 && <sup className="ml-1 font-medium" data-testid={`tab-${name}-count`}>{count}</sup>}
</div>
);
};

View File

@@ -0,0 +1,59 @@
import React, { createContext, useContext } from 'react';
import { useTheme } from 'providers/Theme';
const TabsContext = createContext();
export const Tabs = ({ value, onValueChange, children, className = '' }) => {
return (
<TabsContext.Provider value={{ value, onValueChange }}>
<div className={`flex flex-col h-full flex-1 ${className}`}>{children}</div>
</TabsContext.Provider>
);
};
export const TabsList = ({ children, className = '' }) => {
const { theme } = useTheme();
return (
<div
className={`inline-flex h-8 w-fit justify-center rounded-md p-0.5 gap-[2px] ${className}`}
style={{ background: theme.tabs.secondary.inactive.bg }}
>
{children}
</div>
);
};
export const TabsTrigger = ({ value: triggerValue, children, className = '' }) => {
const { value, onValueChange } = useContext(TabsContext);
const { theme } = useTheme();
const isActive = value === triggerValue;
return (
<button
onClick={() => onValueChange(triggerValue)}
className={`inline-flex items-center justify-center rounded-[4px] p-[8px] text-sm whitespace-nowrap transition-all cursor-pointer border border-transparent hover:opacity-90 ${className}`}
style={{
background: isActive ? theme.tabs.secondary.active.bg : 'transparent',
color: isActive ? theme.tabs.secondary.active.color : theme.tabs.secondary.inactive.color
}}
>
{children}
</button>
);
};
export const TabsContent = ({ value: contentValue, children, className = '', dataTestId = '' }) => {
const { value } = useContext(TabsContext);
const isActive = value === contentValue;
return (
<div
className={`outline-none flex flex-col h-full flex-1 ${className}`}
data-testid={dataTestId}
style={{ display: isActive ? 'flex' : 'none' }}
>
{children}
</div>
);
};

View File

@@ -17,7 +17,7 @@ const Welcome = () => {
const { t } = useTranslation();
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const collections = useSelector((state) => state.collections.collections);
const [importedCollection, setImportedCollection] = useState(null);
const [importData, setImportData] = useState(null);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
@@ -30,17 +30,17 @@ const Welcome = () => {
});
};
const handleImportCollection = ({ collection }) => {
setImportedCollection(collection);
const handleImportCollection = ({ rawData, type }) => {
setImportData({ rawData, type });
setImportCollectionModalOpen(false);
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (collectionLocation) => {
dispatch(importCollection(importedCollection, collectionLocation))
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
dispatch(importCollection(convertedCollection, collectionLocation))
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportedCollection(null);
setImportData(null);
toast.success(t('WELCOME.COLLECTION_IMPORT_SUCCESS'));
})
.catch((err) => {
@@ -56,9 +56,10 @@ const Welcome = () => {
{importCollectionModalOpen ? (
<ImportCollection onClose={() => setImportCollectionModalOpen(false)} handleSubmit={handleImportCollection} />
) : null}
{importCollectionLocationModalOpen ? (
{importCollectionLocationModalOpen && importData ? (
<ImportCollectionLocation
collectionName={importedCollection.name}
rawData={importData.rawData}
format={importData.type}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>

View File

@@ -1,11 +1,13 @@
import { useState, useRef, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { browseFiles, updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import { updateCollectionProtobuf } from 'providers/ReduxStore/slices/collections';
import { getRelativePath, getAbsoluteFilePath } from 'utils/common/path';
import { browseDirectory } from 'utils/filesystem';
import { loadGrpcMethodsFromProtoFile } from 'utils/network/index';
import useLocalStorage from 'hooks/useLocalStorage/index';
import { cloneDeep } from 'lodash';
import get from 'lodash/get';
/**
* Custom hook for managing protofile data and collection configuration
@@ -18,8 +20,13 @@ export default function useProtoFileManagement(collection) {
const [protofileCache, setProtofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const [isLoadingMethods, setIsLoadingMethods] = useState(false);
const collectionProtoFiles = useMemo(() => collection?.brunoConfig?.protobuf?.protoFiles || [], [collection?.brunoConfig?.protobuf?.protoFiles]);
const collectionImportPaths = useMemo(() => collection?.brunoConfig?.protobuf?.importPaths || [], [collection?.brunoConfig?.protobuf?.importPaths]);
// Get protobuf config from draft if exists, otherwise from brunoConfig
const protobufConfig = collection?.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.protobuf', {})
: get(collection, 'brunoConfig.protobuf', {});
const collectionProtoFiles = useMemo(() => protobufConfig?.protoFiles || [], [protobufConfig?.protoFiles]);
const collectionImportPaths = useMemo(() => protobufConfig?.importPaths || [], [protobufConfig?.importPaths]);
const protoFilesWithExistence = useMemo(() =>
collectionProtoFiles.map((protoFile) => ({
@@ -78,6 +85,39 @@ export default function useProtoFileManagement(collection) {
return { success: true, relativePath, alreadyExists: true };
}
try {
const protoFileObj = {
path: relativePath,
type: 'file',
exists: true
};
const updatedProtobuf = {
...protobufConfig,
protoFiles: [...collectionProtoFiles, protoFileObj]
};
dispatch(updateCollectionProtobuf({
collectionUid: collection.uid,
protobuf: updatedProtobuf
}));
return { success: true, relativePath };
} catch (error) {
console.error('Error adding proto file to collection:', error);
return { success: false, error };
}
};
const addProtoFileFromRequest = async (filePath) => {
const relativePath = getRelativePath(collection.pathname, filePath, true);
const exists = collectionProtoFiles.some((pf) => pf.path === relativePath);
if (exists) {
return { success: true, relativePath, alreadyExists: true };
}
try {
const protoFileObj = {
path: relativePath,
@@ -104,6 +144,38 @@ export default function useProtoFileManagement(collection) {
};
const addImportPathToCollection = async (directoryPath) => {
const relativePath = getRelativePath(collection.pathname, directoryPath, true);
const importPathObj = {
path: relativePath,
enabled: true,
exists: true
};
const exists = collectionImportPaths.some((ip) => ip.path === importPathObj.path);
if (exists) {
return { success: false, error: new Error('Import path already exists') };
}
try {
const updatedProtobuf = {
...protobufConfig,
importPaths: [...collectionImportPaths, importPathObj]
};
dispatch(updateCollectionProtobuf({
collectionUid: collection.uid,
protobuf: updatedProtobuf
}));
return { success: true, relativePath };
} catch (error) {
console.error('Error adding import path:', error);
return { success: false, error };
}
};
const addImportPathFromRequest = async (directoryPath) => {
const relativePath = getRelativePath(collection.pathname, directoryPath, true);
const importPathObj = {
path: relativePath,
@@ -137,6 +209,34 @@ export default function useProtoFileManagement(collection) {
};
const toggleImportPath = async (index) => {
try {
const updatedImportPaths = [...collectionImportPaths];
updatedImportPaths[index] = {
...updatedImportPaths[index],
enabled: !updatedImportPaths[index].enabled
};
const updatedProtobuf = {
...protobufConfig,
importPaths: updatedImportPaths
};
dispatch(updateCollectionProtobuf({
collectionUid: collection.uid,
protobuf: updatedProtobuf
}));
return {
success: true,
enabled: updatedImportPaths[index].enabled
};
} catch (error) {
console.error('Error toggling import path:', error);
return { success: false, error };
}
};
const toggleImportPathFromRequest = async (index) => {
try {
const updatedImportPaths = [...collectionImportPaths];
updatedImportPaths[index] = {
@@ -195,13 +295,15 @@ export default function useProtoFileManagement(collection) {
const updatedProtoFiles = [...collectionProtoFiles];
updatedProtoFiles.splice(index, 1);
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.protoFiles = updatedProtoFiles;
const updatedProtobuf = {
...protobufConfig,
protoFiles: updatedProtoFiles
};
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
dispatch(updateCollectionProtobuf({
collectionUid: collection.uid,
protobuf: updatedProtobuf
}));
return { success: true };
} catch (error) {
@@ -215,13 +317,15 @@ export default function useProtoFileManagement(collection) {
const updatedImportPaths = [...collectionImportPaths];
updatedImportPaths.splice(index, 1);
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.importPaths = updatedImportPaths;
const updatedProtobuf = {
...protobufConfig,
importPaths: updatedImportPaths
};
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
dispatch(updateCollectionProtobuf({
collectionUid: collection.uid,
protobuf: updatedProtobuf
}));
return { success: true };
} catch (error) {
@@ -236,16 +340,19 @@ export default function useProtoFileManagement(collection) {
const updatedImportPaths = [...collectionImportPaths];
updatedImportPaths[index] = {
...updatedImportPaths[index],
path: relativePath
path: relativePath,
exists: true
};
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.importPaths = updatedImportPaths;
const updatedProtobuf = {
...protobufConfig,
importPaths: updatedImportPaths
};
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
dispatch(updateCollectionProtobuf({
collectionUid: collection.uid,
protobuf: updatedProtobuf
}));
return { success: true };
} catch (error) {
@@ -261,16 +368,19 @@ export default function useProtoFileManagement(collection) {
updatedProtoFiles[index] = {
...updatedProtoFiles[index],
path: relativePath,
type: 'file'
type: 'file',
exists: true
};
const brunoConfig = cloneDeep(collection.brunoConfig);
if (!brunoConfig.protobuf) {
brunoConfig.protobuf = {};
}
brunoConfig.protobuf.protoFiles = updatedProtoFiles;
const updatedProtobuf = {
...protobufConfig,
protoFiles: updatedProtoFiles
};
await dispatch(updateBrunoConfig(brunoConfig, collection.uid));
dispatch(updateCollectionProtobuf({
collectionUid: collection.uid,
protobuf: updatedProtobuf
}));
return { success: true };
} catch (error) {
@@ -286,12 +396,15 @@ export default function useProtoFileManagement(collection) {
loadMethodsFromProtoFile,
addProtoFileToCollection,
addImportPathToCollection,
addImportPathFromRequest,
toggleImportPath,
toggleImportPathFromRequest,
browseForProtoFile,
browseForImportDirectory,
removeProtoFileFromCollection,
removeImportPathFromCollection,
replaceImportPathInCollection,
replaceProtoFileInCollection
replaceProtoFileInCollection,
addProtoFileFromRequest
};
}

View File

@@ -7,55 +7,106 @@ import { useDispatch } from 'react-redux';
import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections';
import { pluralizeWord } from 'utils/common';
import { completeQuitFlow } from 'providers/ReduxStore/slices/app';
import { saveMultipleRequests } from 'providers/ReduxStore/slices/collections/actions';
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders } from 'providers/ReduxStore/slices/collections/actions';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
const SaveRequestsModal = ({ onClose }) => {
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
const MAX_UNSAVED_ITEMS_TO_SHOW = 5;
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
const dispatch = useDispatch();
const currentDrafts = useMemo(() => {
const drafts = [];
const allDrafts = useMemo(() => {
const requestDrafts = [];
const collectionDrafts = [];
const folderDrafts = [];
const tabsByCollection = groupBy(tabs, (t) => t.collectionUid);
Object.keys(tabsByCollection).forEach((collectionUid) => {
const collection = findCollectionByUid(collections, collectionUid);
if (collection) {
// Check for collection draft
if (collection.draft) {
collectionDrafts.push({
type: 'collection',
name: collection.name,
collectionUid: collectionUid
});
}
// Check for request and folder drafts
const items = flattenItems(collection.items);
const collectionDrafts = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
each(collectionDrafts, (draft) => {
drafts.push({
// Request drafts
const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
each(requests, (draft) => {
requestDrafts.push({
type: 'request',
...draft,
collectionUid: collectionUid
});
});
// Folder drafts
const folders = filter(items, (item) => item.type === 'folder' && item.draft);
each(folders, (folder) => {
folderDrafts.push({
type: 'folder',
name: folder.name,
folderUid: folder.uid,
collectionUid: collectionUid
});
});
}
});
return drafts;
return [...collectionDrafts, ...folderDrafts, ...requestDrafts];
}, [collections, tabs]);
const totalDraftsCount = allDrafts.length;
useEffect(() => {
if (currentDrafts.length === 0) {
if (totalDraftsCount === 0) {
return dispatch(completeQuitFlow());
}
}, [currentDrafts, dispatch]);
}, [totalDraftsCount, dispatch]);
const closeWithoutSave = () => {
dispatch(completeQuitFlow());
onClose();
};
const closeWithSave = () => {
dispatch(saveMultipleRequests(currentDrafts))
.then(() => dispatch(completeQuitFlow()))
.then(() => onClose());
const closeWithSave = async () => {
try {
// Separate drafts by type
const collectionDrafts = allDrafts.filter((d) => d.type === 'collection');
const folderDrafts = allDrafts.filter((d) => d.type === 'folder');
const requestDrafts = allDrafts.filter((d) => d.type === 'request');
// Save all collection drafts
if (collectionDrafts.length > 0) {
await dispatch(saveMultipleCollections(collectionDrafts));
}
// Save all folder drafts
if (folderDrafts.length > 0) {
await dispatch(saveMultipleFolders(folderDrafts));
}
// Save all request drafts
if (requestDrafts.length > 0) {
await dispatch(saveMultipleRequests(requestDrafts));
}
dispatch(completeQuitFlow());
onClose();
} catch (error) {
console.error('Error saving drafts:', error);
}
};
if (!currentDrafts.length) {
if (totalDraftsCount === 0) {
return null;
}
@@ -77,23 +128,30 @@ const SaveRequestsModal = ({ onClose }) => {
</div>
<p className="mt-4">
Do you want to save the changes you made to the following{' '}
<span className="font-medium">{currentDrafts.length}</span> {pluralizeWord('request', currentDrafts.length)}?
<span className="font-medium">{totalDraftsCount}</span> {pluralizeWord('item', totalDraftsCount)}?
</p>
<ul className="mt-4">
{currentDrafts.slice(0, MAX_UNSAVED_REQUESTS_TO_SHOW).map((item) => {
{allDrafts.slice(0, MAX_UNSAVED_ITEMS_TO_SHOW).map((item, index) => {
const prefix
= item.type === 'collection'
? 'Collection: '
: item.type === 'folder'
? 'Folder: '
: 'Request: ';
return (
<li key={item.uid} className="mt-1 text-xs">
{item.filename}
<li key={`${item.type}-${item.collectionUid || item.uid}-${index}`} className="mt-1 text-xs">
{prefix}
{item.name || item.filename}
</li>
);
})}
</ul>
{currentDrafts.length > MAX_UNSAVED_REQUESTS_TO_SHOW && (
{totalDraftsCount > MAX_UNSAVED_ITEMS_TO_SHOW && (
<p className="mt-1 text-xs">
...{currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW} additional{' '}
{pluralizeWord('request', currentDrafts.length - MAX_UNSAVED_REQUESTS_TO_SHOW)} not shown
...{totalDraftsCount - MAX_UNSAVED_ITEMS_TO_SHOW} additional{' '}
{pluralizeWord('item', totalDraftsCount - MAX_UNSAVED_ITEMS_TO_SHOW)} not shown
</p>
)}
@@ -108,7 +166,7 @@ const SaveRequestsModal = ({ onClose }) => {
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={closeWithSave}>
{currentDrafts.length > 1 ? 'Save All' : 'Save'}
{totalDraftsCount > 1 ? 'Save All' : 'Save'}
</button>
</div>
</div>

View File

@@ -15,9 +15,11 @@ import {
collectionUnlinkEnvFileEvent,
collectionUnlinkFileEvent,
processEnvUpdateEvent,
requestCancelled,
runFolderEvent,
runRequestEvent,
scriptEnvironmentUpdateEvent
scriptEnvironmentUpdateEvent,
streamDataReceived
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
@@ -137,8 +139,8 @@ const useIpcEvents = () => {
dispatch(processEnvUpdateEvent(val));
});
const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => {
console[val.type](...val.args);
const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => {
console[val.type](...val.args);
dispatch(addLog({
type: val.type,
args: val.args,
@@ -188,6 +190,14 @@ const useIpcEvents = () => {
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
const removeHttpStreamNewDataListener = ipcRenderer.on('main:http-stream-new-data', (val) => {
dispatch(streamDataReceived(val));
});
const removeHttpStreamEndListener = ipcRenderer.on('main:http-stream-end', (val) => {
dispatch(requestCancelled(val));
});
const removeCollectionLoadingStateListener = ipcRenderer.on('main:collection-loading-state-updated', (val) => {
dispatch(updateCollectionLoadingState(val));
});
@@ -212,6 +222,8 @@ const useIpcEvents = () => {
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeHttpStreamNewDataListener();
removeHttpStreamEndListener();
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();

View File

@@ -11,7 +11,8 @@ import {
sendRequest,
saveRequest,
saveCollectionRoot,
saveFolderRoot
saveFolderRoot,
saveCollectionSettings
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
@@ -57,7 +58,7 @@ export const HotkeysProvider = (props) => {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionRoot(collection.uid));
dispatch(saveCollectionSettings(collection.uid));
}
}
}

View File

@@ -1,6 +1,7 @@
import { handleMakeTabParmanent } from "./utils";
const actionsToIntercept = [
// Request-level actions
'collections/requestUrlChanged',
'collections/updateAuth',
'collections/addQueryParam',
@@ -37,14 +38,39 @@ const actionsToIntercept = [
'collections/updateVar',
'collections/deleteVar',
'collections/moveVar',
'collections/updateRequestDocs',
'collections/runRequestEvent', // TODO: This doesn't necessarily related to a draft state, need to rethink.
// Folder-level actions
'collections/addFolderHeader',
'collections/updateFolderHeader',
'collections/deleteFolderHeader',
'collections/addFolderVar',
'collections/updateFolderVar',
'collections/deleteFolderVar',
'collections/updateRequestDocs',
'collections/runRequestEvent', // TODO: This doesn't necessarily related to a draft state, need to rethink.
'collections/updateFolderRequestScript',
'collections/updateFolderResponseScript',
'collections/updateFolderTests',
'collections/updateFolderAuth',
'collections/updateFolderAuthMode',
'collections/updateFolderDocs',
// Collection-level actions
'collections/addCollectionHeader',
'collections/updateCollectionHeader',
'collections/deleteCollectionHeader',
'collections/addCollectionVar',
'collections/updateCollectionVar',
'collections/deleteCollectionVar',
'collections/updateCollectionAuth',
'collections/updateCollectionAuthMode',
'collections/updateCollectionRequestScript',
'collections/updateCollectionResponseScript',
'collections/updateCollectionTests',
'collections/updateCollectionDocs',
'collections/updateCollectionClientCertificates',
'collections/updateCollectionProtobuf',
'collections/updateCollectionProxy'
];
export const draftDetectMiddleware = ({ dispatch, getState }) => (next) => (action) => {

View File

@@ -6,14 +6,35 @@ function handleMakeTabParmanent(state, action, dispatch) {
const tabs = state.tabs.tabs;
const activeTabUid = state.tabs.activeTabUid;
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const itemUid = action.payload.itemUid || action.payload.folderUid
const collection = findCollectionByUid(state.collections.collections, action.payload.collectionUid);
if (collection) {
if (!focusedTab || focusedTab.preview !== true) {
return;
}
const { itemUid, folderUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return;
}
// Handle request-level changes
if (itemUid) {
const item = findItemInCollection(collection, itemUid);
if (item && focusedTab.preview == true) {
if (item) {
dispatch(makeTabPermanent({ uid: itemUid }));
}
}
// Handle folder-level changes (folder settings tab)
else if (folderUid) {
const folder = findItemInCollection(collection, folderUid);
if (folder) {
dispatch(makeTabPermanent({ uid: folderUid }));
}
} else if (collectionUid) {
// Handle collection-level changes (collection settings tab)
dispatch(makeTabPermanent({ uid: collectionUid }));
}
}
export {

View File

@@ -17,7 +17,8 @@ import {
isItemAFolder,
refreshUidsInItem,
isItemARequest,
transformRequestToSaveToFilesystem
transformRequestToSaveToFilesystem,
transformCollectionRootToSave
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
import { cancelNetworkRequest, connectWS, sendGrpcRequest, sendNetworkRequest, sendWsRequest } from 'utils/network/index';
@@ -43,7 +44,9 @@ import {
updateRunnerConfiguration as _updateRunnerConfiguration,
updateActiveConnections,
saveRequest as _saveRequest,
saveEnvironment as _saveEnvironment
saveEnvironment as _saveEnvironment,
saveCollectionDraft,
saveFolderDraft
} from './index';
import { each } from 'lodash';
@@ -58,7 +61,8 @@ import {
getReorderedItemsInTargetDirectory,
resetSequencesInFolder,
getReorderedItemsInSourceDirectory,
calculateDraggedItemNewPathname
calculateDraggedItemNewPathname,
transformFolderRootToSave
} from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { buildPersistedEnvVariables } from 'utils/environments';
@@ -160,11 +164,18 @@ export const saveCollectionRoot = (collectionUid) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
// Transform collection root (uses draft if exists)
const collectionRootToSave = transformCollectionRootToSave(collectionCopy);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:save-collection-root', collection.pathname, collection.root)
.then(() => toast.success('Collection Settings saved successfully'))
.invoke('renderer:save-collection-root', collectionCopy.pathname, collectionRootToSave)
.then(() => {
toast.success('Collection Settings saved successfully');
dispatch(saveCollectionDraft({ collectionUid }));
})
.then(resolve)
.catch((err) => {
toast.error('Failed to save collection settings!');
@@ -189,15 +200,107 @@ export const saveFolderRoot = (collectionUid, folderUid) => (dispatch, getState)
const { ipcRenderer } = window;
// Use draft if it exists, otherwise use root
const folderRootToSave = transformFolderRootToSave(folder);
const folderData = {
name: folder.name,
pathname: folder.pathname,
root: folder.root
root: folderRootToSave
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(() => toast.success('Folder Settings saved successfully'))
.then(() => {
toast.success('Folder Settings saved successfully');
// If there was a draft, save it to root and clear the draft
if (folder.draft) {
dispatch(saveFolderDraft({ collectionUid, folderUid }));
}
})
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
});
};
export const saveMultipleCollections = (collectionDrafts) => (dispatch, getState) => {
const state = getState();
const { collections } = state.collections;
return new Promise((resolve, reject) => {
const savePromises = [];
each(collectionDrafts, (collectionDraft) => {
const collection = findCollectionByUid(collections, collectionDraft.collectionUid);
if (collection) {
const collectionCopy = cloneDeep(collection);
const collectionRootToSave = transformCollectionRootToSave(collectionCopy);
const { ipcRenderer } = window;
let savePromises = [];
savePromises.push(ipcRenderer.invoke('renderer:save-collection-root', collectionCopy.pathname, collectionRootToSave));
if (collectionCopy.draft?.brunoConfig) {
savePromises.push(ipcRenderer.invoke('renderer:update-bruno-config', collectionCopy.draft.brunoConfig, collectionCopy.pathname, collectionDraft.collectionUid));
}
Promise.all(savePromises)
.then(() => {
dispatch(saveCollectionDraft({ collectionUid: collectionDraft.collectionUid }));
})
.catch((err) => {
toast.error('Failed to save collection settings!');
reject(err);
});
}
});
Promise.all(savePromises)
.then(resolve)
.catch((err) => {
toast.error('Failed to save collection settings!');
reject(err);
});
});
};
export const saveMultipleFolders = (folderDrafts) => (dispatch, getState) => {
const state = getState();
const { collections } = state.collections;
return new Promise((resolve, reject) => {
const savePromises = [];
each(folderDrafts, (folderDraft) => {
const collection = findCollectionByUid(collections, folderDraft.collectionUid);
const folder = collection ? findItemInCollection(collection, folderDraft.folderUid) : null;
if (collection && folder) {
const folderRootToSave = transformFolderRootToSave(folder);
const folderData = {
name: folder.name,
pathname: folder.pathname,
root: folderRootToSave
};
const { ipcRenderer } = window;
const savePromise = ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(() => {
if (folder.draft) {
dispatch(saveFolderDraft({ collectionUid: folderDraft.collectionUid, folderUid: folderDraft.folderUid }));
}
});
savePromises.push(savePromise);
}
});
Promise.all(savePromises)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
@@ -1642,6 +1745,45 @@ export const browseFiles = (filters, properties) => (_dispatch, _getState) => {
});
};
export const saveCollectionSettings = (collectionUid, brunoConfig = null) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
// Transform collection root (uses draft if exists)
const collectionRootToSave = transformCollectionRootToSave(collectionCopy);
const { ipcRenderer } = window;
const savePromises = [];
// Save collection.bru file
savePromises.push(ipcRenderer.invoke('renderer:save-collection-root', collectionCopy.pathname, collectionRootToSave));
// Save bruno.json if brunoConfig is provided or if there's a brunoConfig draft
const brunoConfigToSave = brunoConfig || (collectionCopy.draft && collectionCopy.draft.brunoConfig);
if (brunoConfigToSave) {
savePromises.push(ipcRenderer.invoke('renderer:update-bruno-config', brunoConfigToSave, collectionCopy.pathname, collectionUid));
}
Promise.all(savePromises)
.then(() => {
toast.success('Collection Settings saved successfully');
dispatch(saveCollectionDraft({ collectionUid }));
})
.then(resolve)
.catch((err) => {
toast.error('Failed to save collection settings!');
reject(err);
});
});
};
export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getState) => {
const state = getState();

View File

@@ -2,6 +2,7 @@ import { parseQueryParams, buildQueryString as stringifyQueryParams } from '@use
import { uuid } from 'utils/common';
import { find, map, forOwn, concat, filter, each, cloneDeep, get, set, findIndex } from 'lodash';
import { createSlice } from '@reduxjs/toolkit';
import { hexy as hexdump } from 'hexy';
import {
addDepth,
areItemsTheSameExceptSeqUpdate,
@@ -380,9 +381,17 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
item.response = null;
if (item.response?.stream?.running) {
item.response.stream.running = null;
const startTimestamp = item.requestSent.timestamp;
item.response.duration = startTimestamp ? Date.now() - startTimestamp : item.response.duration;
item.response.data = [{ type: 'info', timestamp: Date.now(), message: 'Connection Closed' }].concat(item.response.data);
} else {
item.response = null;
item.requestUid = null;
}
item.cancelTokenUid = null;
item.requestUid = null;
item.requestStartTime = null;
}
}
@@ -395,7 +404,7 @@ export const collectionsSlice = createSlice({
if (item) {
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = null;
item.cancelTokenUid = item.response.stream?.running ? item.cancelTokenUid : null;
item.requestStartTime = null;
if (!collection.timeline) {
@@ -590,6 +599,11 @@ export const collectionsSlice = createSlice({
if (collection) {
const item = findItemInCollection(collection, action.payload.itemUid);
if (item) {
if (item.response && item.response.stream?.running) {
item.response.data = '';
item.response.size = 0;
return;
}
item.response = null;
}
}
@@ -637,6 +651,43 @@ export const collectionsSlice = createSlice({
}
}
},
saveCollectionDraft: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection && collection.draft) {
if (collection.draft.root) {
collection.root = collection.draft.root;
}
if (collection.draft.brunoConfig) {
collection.brunoConfig = collection.draft.brunoConfig;
}
collection.draft = null;
}
},
saveFolderDraft: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder && folder.draft) {
folder.root = folder.draft;
folder.draft = null;
}
},
deleteCollectionDraft: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection && collection.draft) {
collection.draft = null;
}
},
deleteFolderDraft: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder && folder.draft) {
folder.draft = null;
}
},
newEphemeralHttpRequest: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
@@ -1142,7 +1193,13 @@ export const collectionsSlice = createSlice({
return;
}
collection.root.request.headers = map(headers, ({name = '', value = '', enabled = true}) => ({
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
collection.draft.root.request.headers = map(headers, ({ name = '', value = '', enabled = true }) => ({
uid: uuid(),
name: name,
value: value,
@@ -1797,40 +1854,50 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
set(collection, 'root.request.auth', {});
set(collection, 'root.request.auth.mode', action.payload.mode);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
set(collection, 'draft.root.request.auth', {});
set(collection, 'draft.root.request.auth.mode', action.payload.mode);
}
},
updateCollectionAuth: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
set(collection, 'root.request.auth', {});
set(collection, 'root.request.auth.mode', action.payload.mode);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
set(collection, 'draft.root.request.auth', {});
set(collection, 'draft.root.request.auth.mode', action.payload.mode);
switch (action.payload.mode) {
case 'awsv4':
set(collection, 'root.request.auth.awsv4', action.payload.content);
set(collection, 'draft.root.request.auth.awsv4', action.payload.content);
break;
case 'bearer':
set(collection, 'root.request.auth.bearer', action.payload.content);
set(collection, 'draft.root.request.auth.bearer', action.payload.content);
break;
case 'basic':
set(collection, 'root.request.auth.basic', action.payload.content);
set(collection, 'draft.root.request.auth.basic', action.payload.content);
break;
case 'digest':
set(collection, 'root.request.auth.digest', action.payload.content);
set(collection, 'draft.root.request.auth.digest', action.payload.content);
break;
case 'ntlm':
set(collection, 'root.request.auth.ntlm', action.payload.content);
set(collection, 'draft.root.request.auth.ntlm', action.payload.content);
break;
case 'oauth2':
set(collection, 'root.request.auth.oauth2', action.payload.content);
set(collection, 'draft.root.request.auth.oauth2', action.payload.content);
break;
case 'wsse':
set(collection, 'root.request.auth.wsse', action.payload.content);
set(collection, 'draft.root.request.auth.wsse', action.payload.content);
break;
case 'apikey':
set(collection, 'root.request.auth.apikey', action.payload.content);
set(collection, 'draft.root.request.auth.apikey', action.payload.content);
break;
}
}
@@ -1839,35 +1906,122 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
set(collection, 'root.request.script.req', action.payload.script);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
set(collection, 'draft.root.request.script.req', action.payload.script);
}
},
updateCollectionResponseScript: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
set(collection, 'root.request.script.res', action.payload.script);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
set(collection, 'draft.root.request.script.res', action.payload.script);
}
},
updateCollectionTests: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
set(collection, 'root.request.tests', action.payload.tests);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
set(collection, 'draft.root.request.tests', action.payload.tests);
}
},
updateCollectionDocs: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
set(collection, 'root.docs', action.payload.docs);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
set(collection, 'draft.root.docs', action.payload.docs);
}
},
updateCollectionProxy: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root),
brunoConfig: cloneDeep(collection.brunoConfig)
};
}
if (!collection.draft.brunoConfig) {
collection.draft.brunoConfig = cloneDeep(collection.brunoConfig);
}
set(collection, 'draft.brunoConfig.proxy', action.payload.proxy);
}
},
updateCollectionClientCertificates: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root),
brunoConfig: cloneDeep(collection.brunoConfig)
};
}
if (!collection.draft.brunoConfig) {
collection.draft.brunoConfig = cloneDeep(collection.brunoConfig);
}
set(collection, 'draft.brunoConfig.clientCertificates', action.payload.clientCertificates);
}
},
updateCollectionPresets: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root),
brunoConfig: cloneDeep(collection.brunoConfig)
};
}
if (!collection.draft.brunoConfig) {
collection.draft.brunoConfig = cloneDeep(collection.brunoConfig);
}
set(collection, 'draft.brunoConfig.presets', action.payload.presets);
}
},
updateCollectionProtobuf: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root),
brunoConfig: cloneDeep(collection.brunoConfig)
};
}
if (!collection.draft.brunoConfig) {
collection.draft.brunoConfig = cloneDeep(collection.brunoConfig);
}
set(collection, 'draft.brunoConfig.protobuf', action.payload.protobuf);
}
},
addFolderHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
const headers = get(folder, 'root.request.headers', []);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
const headers = get(folder, 'draft.request.headers', []);
headers.push({
uid: uuid(),
name: '',
@@ -1875,14 +2029,17 @@ export const collectionsSlice = createSlice({
description: '',
enabled: true
});
set(folder, 'root.request.headers', headers);
set(folder, 'draft.request.headers', headers);
}
},
updateFolderHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
const headers = get(folder, 'root.request.headers', []);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
const headers = get(folder, 'draft.request.headers', []);
const header = find(headers, (h) => h.uid === action.payload.header.uid);
if (header) {
header.name = action.payload.header.name;
@@ -1896,9 +2053,12 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
let headers = get(folder, 'root.request.headers', []);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
let headers = get(folder, 'draft.request.headers', []);
headers = filter(headers, (h) => h.uid !== action.payload.headerUid);
set(folder, 'root.request.headers', headers);
set(folder, 'draft.request.headers', headers);
}
},
addFolderVar: (state, action) => {
@@ -1906,24 +2066,27 @@ export const collectionsSlice = createSlice({
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
const type = action.payload.type;
if (folder) {
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
if (type === 'request') {
const vars = get(folder, 'root.request.vars.req', []);
const vars = get(folder, 'draft.request.vars.req', []);
vars.push({
uid: uuid(),
name: '',
value: '',
enabled: true
});
set(folder, 'root.request.vars.req', vars);
set(folder, 'draft.request.vars.req', vars);
} else if (type === 'response') {
const vars = get(folder, 'root.request.vars.res', []);
const vars = get(folder, 'draft.request.vars.res', []);
vars.push({
uid: uuid(),
name: '',
value: '',
enabled: true
});
set(folder, 'root.request.vars.res', vars);
set(folder, 'draft.request.vars.res', vars);
}
}
},
@@ -1932,8 +2095,11 @@ export const collectionsSlice = createSlice({
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
const type = action.payload.type;
if (folder) {
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
if (type === 'request') {
let vars = get(folder, 'root.request.vars.req', []);
let vars = get(folder, 'draft.request.vars.req', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
@@ -1941,9 +2107,9 @@ export const collectionsSlice = createSlice({
_var.description = action.payload.var.description;
_var.enabled = action.payload.var.enabled;
}
set(folder, 'root.request.vars.req', vars);
set(folder, 'draft.request.vars.req', vars);
} else if (type === 'response') {
let vars = get(folder, 'root.request.vars.res', []);
let vars = get(folder, 'draft.request.vars.res', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
@@ -1951,7 +2117,7 @@ export const collectionsSlice = createSlice({
_var.description = action.payload.var.description;
_var.enabled = action.payload.var.enabled;
}
set(folder, 'root.request.vars.res', vars);
set(folder, 'draft.request.vars.res', vars);
}
}
},
@@ -1960,14 +2126,17 @@ export const collectionsSlice = createSlice({
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
const type = action.payload.type;
if (folder) {
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
if (type === 'request') {
let vars = get(folder, 'root.request.vars.req', []);
let vars = get(folder, 'draft.request.vars.req', []);
vars = filter(vars, (h) => h.uid !== action.payload.varUid);
set(folder, 'root.request.vars.req', vars);
set(folder, 'draft.request.vars.req', vars);
} else if (type === 'response') {
let vars = get(folder, 'root.request.vars.res', []);
let vars = get(folder, 'draft.request.vars.res', []);
vars = filter(vars, (h) => h.uid !== action.payload.varUid);
set(folder, 'root.request.vars.res', vars);
set(folder, 'draft.request.vars.res', vars);
}
}
},
@@ -1975,21 +2144,30 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
set(folder, 'root.request.script.req', action.payload.script);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
set(folder, 'draft.request.script.req', action.payload.script);
}
},
updateFolderResponseScript: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
set(folder, 'root.request.script.res', action.payload.script);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
set(folder, 'draft.request.script.res', action.payload.script);
}
},
updateFolderTests: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
set(folder, 'root.request.tests', action.payload.tests);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
set(folder, 'draft.request.tests', action.payload.tests);
}
},
updateFolderAuth: (state, action) => {
@@ -2000,35 +2178,38 @@ export const collectionsSlice = createSlice({
if (!folder) return;
if (folder) {
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
set(folder, 'draft.request.auth', {});
set(folder, 'draft.request.auth.mode', action.payload.mode);
switch (action.payload.mode) {
case 'oauth2':
set(folder, 'root.request.auth.oauth2', action.payload.content);
set(folder, 'draft.request.auth.oauth2', action.payload.content);
break;
case 'basic':
set(folder, 'root.request.auth.basic', action.payload.content);
set(folder, 'draft.request.auth.basic', action.payload.content);
break;
case 'bearer':
set(folder, 'root.request.auth.bearer', action.payload.content);
set(folder, 'draft.request.auth.bearer', action.payload.content);
break;
case 'digest':
set(folder, 'root.request.auth.digest', action.payload.content);
set(folder, 'draft.request.auth.digest', action.payload.content);
break;
case 'ntlm':
set(folder, 'root.request.auth.ntlm', action.payload.content);
set(folder, 'draft.request.auth.ntlm', action.payload.content);
break;
case 'apikey':
set(folder, 'root.request.auth.apikey', action.payload.content);
set(folder, 'draft.request.auth.apikey', action.payload.content);
break;
case 'awsv4':
set(folder, 'root.request.auth.awsv4', action.payload.content);
set(folder, 'draft.request.auth.awsv4', action.payload.content);
break;
case 'wsse':
set(folder, 'root.request.auth.wsse', action.payload.content);
set(folder, 'draft.request.auth.wsse', action.payload.content);
break;
case 'ws':
set(folder, 'root.request.auth.ws', action.payload.content);
set(folder, 'draft.request.auth.ws', action.payload.content);
break;
}
}
@@ -2037,7 +2218,12 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const headers = get(collection, 'root.request.headers', []);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
const headers = get(collection, 'draft.root.request.headers', []);
headers.push({
uid: uuid(),
name: '',
@@ -2045,14 +2231,19 @@ export const collectionsSlice = createSlice({
description: '',
enabled: true
});
set(collection, 'root.request.headers', headers);
set(collection, 'draft.root.request.headers', headers);
}
},
updateCollectionHeader: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
const headers = get(collection, 'root.request.headers', []);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
const headers = get(collection, 'draft.root.request.headers', []);
const header = find(headers, (h) => h.uid === action.payload.header.uid);
if (header) {
header.name = action.payload.header.name;
@@ -2066,73 +2257,95 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
let headers = get(collection, 'root.request.headers', []);
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
let headers = get(collection, 'draft.root.request.headers', []);
headers = filter(headers, (h) => h.uid !== action.payload.headerUid);
set(collection, 'root.request.headers', headers);
set(collection, 'draft.root.request.headers', headers);
}
},
addCollectionVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
if (collection) {
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
if (type === 'request') {
const vars = get(collection, 'root.request.vars.req', []);
const vars = get(collection, 'draft.root.request.vars.req', []);
vars.push({
uid: uuid(),
name: '',
value: '',
enabled: true
});
set(collection, 'root.request.vars.req', vars);
set(collection, 'draft.root.request.vars.req', vars);
} else if (type === 'response') {
const vars = get(collection, 'root.request.vars.res', []);
const vars = get(collection, 'draft.root.request.vars.res', []);
vars.push({
uid: uuid(),
name: '',
value: '',
enabled: true
});
set(collection, 'root.request.vars.res', vars);
set(collection, 'draft.root.request.vars.res', vars);
}
}
},
updateCollectionVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
if (type === 'request') {
let vars = get(collection, 'root.request.vars.req', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
_var.value = action.payload.var.value;
_var.description = action.payload.var.description;
_var.enabled = action.payload.var.enabled;
if (collection) {
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
set(collection, 'root.request.vars.req', vars);
} else if (type === 'response') {
let vars = get(collection, 'root.request.vars.res', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
_var.value = action.payload.var.value;
_var.description = action.payload.var.description;
_var.enabled = action.payload.var.enabled;
if (type === 'request') {
let vars = get(collection, 'draft.root.request.vars.req', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
_var.value = action.payload.var.value;
_var.description = action.payload.var.description;
_var.enabled = action.payload.var.enabled;
}
set(collection, 'draft.root.request.vars.req', vars);
} else if (type === 'response') {
let vars = get(collection, 'draft.root.request.vars.res', []);
const _var = find(vars, (h) => h.uid === action.payload.var.uid);
if (_var) {
_var.name = action.payload.var.name;
_var.value = action.payload.var.value;
_var.description = action.payload.var.description;
_var.enabled = action.payload.var.enabled;
}
set(collection, 'draft.root.request.vars.res', vars);
}
set(collection, 'root.request.vars.res', vars);
}
},
deleteCollectionVar: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const type = action.payload.type;
if (collection) {
if (!collection.draft) {
collection.draft = {
root: cloneDeep(collection.root)
};
}
if (type === 'request') {
let vars = get(collection, 'root.request.vars.req', []);
let vars = get(collection, 'draft.root.request.vars.req', []);
vars = filter(vars, (h) => h.uid !== action.payload.varUid);
set(collection, 'root.request.vars.req', vars);
set(collection, 'draft.root.request.vars.req', vars);
} else if (type === 'response') {
let vars = get(collection, 'root.request.vars.res', []);
let vars = get(collection, 'draft.root.request.vars.res', []);
vars = filter(vars, (h) => h.uid !== action.payload.varUid);
set(collection, 'root.request.vars.res', vars);
set(collection, 'draft.root.request.vars.res', vars);
}
}
},
@@ -2636,7 +2849,10 @@ export const collectionsSlice = createSlice({
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
if (isItemAFolder(folder)) {
set(folder, 'root.docs', action.payload.docs);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
set(folder, 'draft.docs', action.payload.docs);
}
}
},
@@ -2727,11 +2943,32 @@ export const collectionsSlice = createSlice({
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (folder) {
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
if (!folder.draft) {
folder.draft = cloneDeep(folder.root);
}
set(folder, 'draft.request.auth', {});
set(folder, 'draft.request.auth.mode', action.payload.mode);
}
},
streamDataReceived: (state, action) => {
const { itemUid, collectionUid, data } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (data.data) {
item.response.data ||= [];
item.response.data = [{
type: 'incoming',
message: data.data,
messageHexdump: hexdump(data.data),
timestamp: Date.now()
}].concat(item.response.data);
}
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);
item.response.size = data.data?.length + (item.response.size || 0);
}
},
addRequestTag: (state, action) => {
const { tag, collectionUid, itemUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -2998,6 +3235,10 @@ export const {
clearRequestTimeline,
saveRequest,
deleteRequestDraft,
saveCollectionDraft,
saveFolderDraft,
deleteCollectionDraft,
deleteFolderDraft,
newEphemeralHttpRequest,
collapseFullCollection,
toggleCollection,
@@ -3068,6 +3309,10 @@ export const {
updateCollectionResponseScript,
updateCollectionTests,
updateCollectionDocs,
updateCollectionProxy,
updateCollectionClientCertificates,
updateCollectionPresets,
updateCollectionProtobuf,
collectionAddFileEvent,
collectionAddDirectoryEvent,
collectionChangeFileEvent,
@@ -3085,6 +3330,7 @@ export const {
updateRequestDocs,
updateFolderDocs,
moveCollection,
streamDataReceived,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,

View File

@@ -115,6 +115,7 @@ const darkTheme = {
url: {
bg: '#3D3D3D',
icon: 'rgb(204, 204, 204)',
iconDanger: '#fa5343',
errorHoverBg: '#4a2a2a'
},
dragbar: {
@@ -241,6 +242,16 @@ const darkTheme = {
active: {
color: '#CCCCCC',
border: '#F59E0B'
},
secondary: {
active: {
bg: '#2D2D2D',
color: '#CCCCCC'
},
inactive: {
bg: '#3F3F3F',
color: '#CCCCCC'
}
}
},

View File

@@ -115,6 +115,7 @@ const lightTheme = {
url: {
bg: '#f3f3f3',
icon: '#515151',
iconDanger: '#d91f11',
errorHoverBg: '#fef2f2'
},
dragbar: {
@@ -242,6 +243,16 @@ const lightTheme = {
active: {
color: '#343434',
border: '#D97706'
},
secondary: {
active: {
bg: '#FFFFFF',
color: '#343434'
},
inactive: {
bg: '#ECECEE',
color: '#989898'
}
}
},

View File

@@ -748,6 +748,60 @@ export const transformRequestToSaveToFilesystem = (item) => {
return itemToSave;
};
export const transformCollectionRootToSave = (collection) => {
const _collection = collection.draft?.root ? collection.draft.root : collection.root;
const collectionRootToSave = {
docs: _collection?.docs,
meta: _collection?.meta,
request: {
auth: _collection?.request?.auth,
headers: [],
script: _collection?.request?.script,
vars: _collection?.request?.vars,
tests: _collection?.request?.tests
}
};
each(_collection?.request?.headers, (header) => {
collectionRootToSave.request.headers.push({
uid: header.uid,
name: header.name,
value: header.value,
description: header.description,
enabled: header.enabled
});
});
return collectionRootToSave;
};
export const transformFolderRootToSave = (folder) => {
const _folder = folder.draft ? folder.draft : folder.root;
const folderRootToSave = {
docs: _folder.docs,
request: {
auth: _folder?.request?.auth,
headers: [],
script: _folder?.request?.script,
vars: _folder?.request?.vars,
tests: _folder?.request?.tests
}
};
each(_folder.request.headers, (header) => {
folderRootToSave.request.headers.push({
uid: header.uid,
name: header.name,
value: header.value,
description: header.description,
enabled: header.enabled
});
});
return folderRootToSave;
};
// todo: optimize this
export const deleteItemInCollection = (itemUid, collection) => {
collection.items = filter(collection.items, (i) => i.uid !== itemUid);
@@ -1177,7 +1231,8 @@ const mergeVars = (collection, requestTreePath = []) => {
let collectionVariables = {};
let folderVariables = {};
let requestVariables = {};
let collectionRequestVars = get(collection, 'root.request.vars.req', []);
const collectionRoot = collection?.draft?.root || collection?.root || {};
let collectionRequestVars = get(collectionRoot, 'request.vars.req', []);
collectionRequestVars.forEach((_var) => {
if (_var.enabled) {
collectionVariables[_var.name] = _var.value;

View File

@@ -2,6 +2,7 @@ import { customAlphabet } from 'nanoid';
import xmlFormat from 'xml-formatter';
import { JSONPath } from 'jsonpath-plus';
import fastJsonFormat from 'fast-json-format';
import { format, applyEdits } from 'jsonc-parser';
import { patternHasher } from '@usebruno/common/utils';
// a customized version of nanoid without using _ and -
@@ -294,7 +295,7 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
}
try {
return prettifyJsonString(rawData);
return fastJsonFormat(rawData);
} catch (error) {}
if (typeof data === 'string') {
@@ -326,9 +327,11 @@ export const formatResponse = (data, dataBufferString, mode, filter, bufferThres
export const prettifyJsonString = (jsonDataString) => {
if (typeof jsonDataString !== 'string') return jsonDataString;
try {
const { hashed, restore } = patternHasher(jsonDataString);
const formattedJsonDataStringHashed = fastJsonFormat(hashed);
const edits = format(hashed, undefined, { tabSize: 2, insertSpaces: true });
const formattedJsonDataStringHashed = applyEdits(hashed, edits);
const formattedJsonDataString = restore(formattedJsonDataStringHashed);
return formattedJsonDataString;
} catch (error) {

View File

@@ -218,16 +218,16 @@ describe('common utils', () => {
});
test('should format complex json string', () => {
const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with "quotes" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets "}`;
const input = `{"id": 123456789123456789123456789,"name": "Test 'JSON' Data with \"quotes\" — Pretty Print ","active": true,"price": 199.9999999,"decimals": 1.00,"nullValue": null,"unicodeText": "こんにちは世界 ","escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style","nestedObject": { "level1": { "level2": { "emptyArray": [], "specialChars": "@#$%^&*()_+-=[]{}|;':,./<>?~", "booleanValues": [ true, false, true ], "numbers": [ 0, -1, 1.23e10, 3.1415926535 ] } }},"mixedArray": [ "string with 'apostrophe'", 42, false, null, { "innerObj": { "keyWithQuotes": "value containing \`backticks\` and 'single quotes'", "nestedArray": [ { "a": "O'Reilly" }{ "b": "'inline code'" }, [ "deep", "array", { "c": "contains 'quotes'" } ] ] } }],"nonStringVariable": {{nonStringVar}},"withBrunoVariable": "{{string}} '{{with}}' "{{variety}}" of '{{variables}}'","dateExample": "2025-11-07T12:34:56Z","regexExample": "^([a-z0-9_\.-]+)@([\da-z\.-]+)\.([a-z\.]{2,6})$","urls": { "website": "https://example.com?param='value'&flag='true'", "escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"},"multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets "}`;
const expectedOutput = `{
"id": 123456789123456789123456789,
"name": "Test 'JSON' Data with "quotes" — Pretty Print ",
"name": "Test 'JSON' Data with \"quotes\" — Pretty Print ",
"active": true,
"price": 199.9999999,
"decimals": 1.00,
"nullValue": null,
"unicodeText": "こんにちは世界 ",
"escapedCharacters": "Line1\nLine2\tTabbed\"Quoted\" and 'single quoted' with 'code' style",
"escapedCharacters": "Line1\\nLine2\\tTabbed\"Quoted\" and 'single quoted' with 'code' style",
"nestedObject": {
"level1": {
"level2": {
@@ -280,7 +280,7 @@ describe('common utils', () => {
"website": "https://example.com?param='value'&flag='true'",
"escapedURL": "https:\/\/escaped-url.com\/path\?q='search'\&debug='on'"
},
"multiLineString": "This is a long text\nthat spans multiple\nlines with \`backticks\` 'quotes' and 'code' snippets "
"multiLineString": "This is a long text\\nthat spans multiple\\nlines with \`backticks\` 'quotes' and 'code' snippets "
}`;
expect(prettifyJsonString(input)).toBe(expectedOutput);
});

View File

@@ -0,0 +1,29 @@
const normalizeContentType = (contentType) => {
if (!contentType || typeof contentType !== 'string') {
return '';
}
return contentType.toLowerCase();
};
export const isJsonLikeContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('application/json') || normalized.includes('+json');
};
export const isXmlLikeContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('application/xml') || normalized.includes('+xml') || normalized.includes('text/xml');
};
export const isPlainTextContentType = (contentType) => {
const normalized = normalizeContentType(contentType);
return normalized.includes('text/plain');
};
export const isStructuredContentType = (contentType) => {
return isJsonLikeContentType(contentType) || isXmlLikeContentType(contentType) || isPlainTextContentType(contentType);
};

View File

@@ -10,6 +10,7 @@ import parseCurlCommand from './parse-curl';
import * as querystring from 'query-string';
import * as jsesc from 'jsesc';
import { buildQueryString } from '@usebruno/common/utils';
import { isStructuredContentType } from './content-type';
function getContentType(headers = {}) {
const contentType = Object.keys(headers).find((key) => key.toLowerCase() === 'content-type');
@@ -34,7 +35,7 @@ function getDataString(request) {
const contentType = getContentType(request.headers);
if (contentType && (contentType.includes('application/json') || contentType.includes('application/xml') || contentType.includes('text/plain'))) {
if (isStructuredContentType(contentType)) {
return { data: request.data };
}

View File

@@ -120,4 +120,37 @@ describe('curlToJson', () => {
]
});
});
it('should parse custom json content-types', () => {
const curlCommand = `curl 'https://api.example.com/test'
-H 'content-type: application/x.custom+json;version=1'
--data-raw '{"test":"data"}'
`;
const result = curlToJson(curlCommand);
expect(result).toEqual({
url: 'https://api.example.com/test',
raw_url: 'https://api.example.com/test',
method: 'post',
headers: {
'content-type': 'application/x.custom+json;version=1'
},
data: '{"test":"data"}'
});
});
it('should parse vendor tree json content-types', () => {
const curlCommand = `curl --request POST \\
--url https://api.example.com/orders/42/preferences \\
--header 'accept: */*' \\
--header 'content-type: application/vnd.vendor+json' \\
--data '{\\n "data": {\\n "type": "order-preferences",\\n "attributes": {\\n "notes": "Leave at door",\\n "priority": true\\n }\\n }\\n}'`;
const result = curlToJson(curlCommand);
expect(result.data).toContain('"type": "order-preferences"');
expect(result.data).toContain('"notes": "Leave at door"');
expect(result.data).toContain('"priority": true');
expect(result.headers['content-type']).toBe('application/vnd.vendor+json');
});
});

View File

@@ -1,6 +1,7 @@
import { forOwn } from 'lodash';
import curlToJson from './curl-to-json';
import { prettifyJsonString } from 'utils/common/index';
import { isJsonLikeContentType, isPlainTextContentType, isXmlLikeContentType } from './content-type';
export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-request') => {
const parseFormData = (parsedBody) => {
@@ -59,25 +60,27 @@ export const getRequestFromCurlCommand = (curlCommand, requestType = 'http-reque
};
if (parsedBody && contentType && typeof contentType === 'string') {
if (requestType === 'graphql-request' && (contentType.includes('application/json') || contentType.includes('application/graphql'))) {
const normalizedContentType = contentType.toLowerCase();
if (requestType === 'graphql-request' && (isJsonLikeContentType(contentType) || normalizedContentType.includes('application/graphql'))) {
body.mode = 'graphql';
body.graphql = parseGraphQL(parsedBody);
} else if (requestType === 'http-request' && request.isDataBinary) {
body.mode = 'file';
body.file = parsedBody;
}else if (contentType.includes('application/json')) {
} else if (isJsonLikeContentType(contentType)) {
body.mode = 'json';
body.json = prettifyJsonString(parsedBody);
} else if (contentType.includes('xml')) {
} else if (isXmlLikeContentType(contentType) || normalizedContentType.includes('xml')) {
body.mode = 'xml';
body.xml = parsedBody;
} else if (contentType.includes('application/x-www-form-urlencoded')) {
} else if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
body.mode = 'formUrlEncoded';
body.formUrlEncoded = parseFormData(parsedBody);
} else if (contentType.includes('multipart/form-data')) {
} else if (normalizedContentType.includes('multipart/form-data')) {
body.mode = 'multipartForm';
body.multipartForm = parsedBody;
} else if (contentType.includes('text/plain')) {
} else if (isPlainTextContentType(contentType)) {
body.mode = 'text';
body.text = parsedBody;
}

View File

@@ -14,3 +14,27 @@ export const processBrunoCollection = async (jsonData) => {
throw new BrunoError('Import collection failed');
}
};
export const isBrunoCollection = (data) => {
// Check for Bruno collection format
if (typeof data !== 'object' || data === null) {
return false;
}
// Must have a version field that is a non-empty string
if (typeof data.version !== 'string' || !data.version.trim()) {
return false;
}
// Must have a name field that is a non-empty string
if (typeof data.name !== 'string' || !data.name.trim()) {
return false;
}
// Must have an items array
if (!Array.isArray(data.items)) {
return false;
}
return true;
};

View File

@@ -29,13 +29,19 @@ const isPostmanCollection = (data) => {
}
const schema = info.schema;
// Accept schemas hosted at schema.getpostman.com or schema.postman.com
const schemaRegex = /^https:\/\/schema\.(?:getpostman|postman)\.com\//;
if (typeof schema === 'string' && schemaRegex.test(schema)) {
return true;
if (typeof schema !== 'string') {
return false;
}
return false;
// Only accept supported Postman v2.0 and v2.1 schemas
const supportedSchemas = [
'https://schema.getpostman.com/json/collection/v2.0.0/collection.json',
'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
'https://schema.postman.com/json/collection/v2.0.0/collection.json',
'https://schema.postman.com/json/collection/v2.1.0/collection.json'
];
return supportedSchemas.includes(schema);
};
export { postmanToBruno, readFile, isPostmanCollection };

View File

@@ -10,6 +10,7 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
if (response?.error) {
resolve(response)
}
resolve({
state: 'success',
data: response.data,
@@ -20,7 +21,8 @@ export const sendNetworkRequest = async (item, collection, environment, runtimeV
status: response.status,
statusText: response.statusText,
duration: response.duration,
timeline: response.timeline
timeline: response.timeline,
stream: response.stream
});
})
.catch((err) => reject(err));

View File

@@ -14,7 +14,7 @@ const STREAMING_FILE_SIZE_THRESHOLD = 20 * 1024 * 1024; // 20MB
const prepareRequest = async (item = {}, collection = {}) => {
const request = item?.request;
const brunoConfig = get(collection, 'brunoConfig', {});
const brunoConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig', {}) : get(collection, 'brunoConfig', {});
const collectionPath = collection?.pathname;
const headers = {};
let contentTypeDefined = false;
@@ -48,7 +48,8 @@ const prepareRequest = async (item = {}, collection = {}) => {
responseType: 'arraybuffer'
};
const collectionAuth = get(collection, 'root.request.auth');
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
if (collectionAuth && request.auth?.mode === 'inherit') {
if (collectionAuth.mode === 'basic') {
axiosRequest.basicAuth = {

View File

@@ -123,7 +123,8 @@ const getFolderRoot = (dir) => {
const mergeHeaders = (collection, request, requestTreePath) => {
let headers = new Map();
let collectionHeaders = get(collection, 'root.request.headers', []);
const collectionRoot = collection?.draft?.root || collection?.root || {};
let collectionHeaders = get(collectionRoot, 'request.headers', []);
collectionHeaders.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
@@ -132,7 +133,8 @@ const mergeHeaders = (collection, request, requestTreePath) => {
for (let i of requestTreePath) {
if (i.type === 'folder') {
let _headers = get(i, 'root.request.headers', []);
const folderRoot = i?.draft || i?.root;
let _headers = get(folderRoot, 'request.headers', []);
_headers.forEach((header) => {
if (header.enabled) {
headers.set(header.name, header.value);
@@ -153,7 +155,8 @@ const mergeHeaders = (collection, request, requestTreePath) => {
const mergeVars = (collection, request, requestTreePath) => {
let reqVars = new Map();
let collectionRequestVars = get(collection, 'root.request.vars.req', []);
const collectionRoot = collection?.draft?.root || collection?.root || {};
let collectionRequestVars = get(collectionRoot, 'request.vars.req', []);
let collectionVariables = {};
collectionRequestVars.forEach((_var) => {
if (_var.enabled) {
@@ -165,7 +168,8 @@ const mergeVars = (collection, request, requestTreePath) => {
let requestVariables = {};
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.req', []);
const folderRoot = i?.draft || i?.root;
let vars = get(folderRoot, 'request.vars.req', []);
vars.forEach((_var) => {
if (_var.enabled) {
reqVars.set(_var.name, _var.value);
@@ -197,7 +201,7 @@ const mergeVars = (collection, request, requestTreePath) => {
}
let resVars = new Map();
let collectionResponseVars = get(collection, 'root.request.vars.res', []);
let collectionResponseVars = get(collectionRoot, 'request.vars.res', []);
collectionResponseVars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
@@ -205,7 +209,8 @@ const mergeVars = (collection, request, requestTreePath) => {
});
for (let i of requestTreePath) {
if (i.type === 'folder') {
let vars = get(i, 'root.request.vars.res', []);
const folderRoot = i?.draft || i?.root;
let vars = get(folderRoot, 'request.vars.res', []);
vars.forEach((_var) => {
if (_var.enabled) {
resVars.set(_var.name, _var.value);
@@ -232,26 +237,28 @@ const mergeVars = (collection, request, requestTreePath) => {
};
const mergeScripts = (collection, request, requestTreePath, scriptFlow) => {
let collectionPreReqScript = get(collection, 'root.request.script.req', '');
let collectionPostResScript = get(collection, 'root.request.script.res', '');
let collectionTests = get(collection, 'root.request.tests', '');
const collectionRoot = collection?.draft?.root || collection?.root || {};
let collectionPreReqScript = get(collectionRoot, 'request.script.req', '');
let collectionPostResScript = get(collectionRoot, 'request.script.res', '');
let collectionTests = get(collectionRoot, 'request.tests', '');
let combinedPreReqScript = [];
let combinedPostResScript = [];
let combinedTests = [];
for (let i of requestTreePath) {
if (i.type === 'folder') {
let preReqScript = get(i, 'root.request.script.req', '');
const folderRoot = i?.draft || i?.root;
let preReqScript = get(folderRoot, 'request.script.req', '');
if (preReqScript && preReqScript.trim() !== '') {
combinedPreReqScript.push(preReqScript);
}
let postResScript = get(i, 'root.request.script.res', '');
let postResScript = get(folderRoot, 'request.script.res', '');
if (postResScript && postResScript.trim() !== '') {
combinedPostResScript.push(postResScript);
}
let tests = get(i, 'root.request.tests', '');
let tests = get(folderRoot, 'request.tests', '');
if (tests && tests?.trim?.() !== '') {
combinedTests.push(tests);
}
@@ -320,12 +327,14 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
};
const mergeAuth = (collection, request, requestTreePath) => {
let collectionAuth = collection?.root?.request?.auth || { mode: 'none' };
const collectionRoot = collection?.draft?.root || collection?.root || {};
let collectionAuth = collectionRoot?.request?.auth || { mode: 'none' };
let effectiveAuth = collectionAuth;
for (let i of requestTreePath) {
if (i.type === 'folder') {
const folderAuth = i?.root?.request?.auth;
const folderRoot = i?.draft || i?.root;
const folderAuth = get(folderRoot, 'request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
}

View File

@@ -29,4 +29,10 @@ describe('patternHasher', () => {
expect(hashed).toMatchInlineSnapshot(`"$name.example.com"`);
expect(restore(hashed)).toEqual(originalUrl);
});
it('verify restoring duplicate hashes', () => {
const originalJSON = `{"name":"{{name}}","x":"{{name}}", "y":"{{name}}"}`;
const { hashed, restore } = patternHasher(originalJSON);
expect(restore(hashed)).toEqual(originalJSON);
});
});

View File

@@ -41,7 +41,7 @@ export function patternHasher(input: string, pattern: string | RegExp = VARIABLE
let clone = current;
for (const hash in hashToOriginal) {
const value = hashToOriginal[hash];
clone = clone.replace(hash, value);
clone = clone.replaceAll(hash, value);
}
return clone;
}

View File

@@ -3,6 +3,7 @@
"target": "ES6",
"esModuleInterop": true,
"strict": true,
"lib": ["es2021"],
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",

View File

@@ -149,9 +149,11 @@ export const brunoToPostman = (collection) => {
const generateEventSection = (item) => {
const eventArray = [];
// Request: item.script, Folder: item.root.request.script, Collection: item.request.script
const scriptBlock = item?.script || item?.root?.request?.script || item?.request?.script;
// Tests: item.tests, Folder: item.root.request.tests, Collection: item.request.tests
const scriptBlock = item?.script || item?.root?.request?.script || item?.request?.script || {};
const testsBlock = item?.tests || item?.root?.request?.tests || item?.request?.tests;
if (scriptBlock?.req) {
if (scriptBlock.req && typeof scriptBlock.req === 'string') {
eventArray.push({
listen: 'prerequest',
script: {
@@ -162,14 +164,27 @@ export const brunoToPostman = (collection) => {
}
});
}
if (scriptBlock?.res) {
// testsBlock is added in the post response script since postman only supports tests in the post response script
if (scriptBlock.res || testsBlock) {
const exec = [];
if (scriptBlock.res && typeof scriptBlock.res === 'string') {
exec.push(...scriptBlock.res.split('\n'));
}
if (testsBlock && typeof testsBlock === 'string') {
if (exec.length > 0) {
exec.push('');
}
exec.push('// Tests');
exec.push(...testsBlock.split('\n'));
}
eventArray.push({
listen: 'test',
script: {
type: 'text/javascript',
packages: {},
requests: {},
exec: scriptBlock.res.split('\n')
exec: exec
}
});
}

View File

@@ -0,0 +1,293 @@
import { brunoToPostman } from '../../src/postman/bruno-to-postman';
describe('Bruno to Postman Converter with Tests and Scripts', () => {
const brunoCollection = {
name: 'Script and Tests Collection',
version: '1',
items: [
{
name: 'Request With Scripts and Tests',
type: 'http',
filename: 'request-with-scripts.bru',
seq: 1,
settings: {
encodeUrl: true,
timeout: 0
},
tags: [],
examples: [],
request: {
url: 'https://echo.usebruno.com',
method: 'POST',
headers: [],
params: [],
body: {
mode: 'json',
json: '{\n "location": "root-request"\n}',
formUrlEncoded: [],
multipartForm: [],
file: []
},
script: {
req: 'console.log("root-request script line 1");\nconsole.log("root-request script line 2")',
res: 'console.log("root-request script line 1");\nconsole.log("root-request script line 2")'
},
vars: {},
assertions: [],
tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});',
docs: '',
auth: {
mode: 'none'
}
}
},
{
type: 'folder',
name: 'Scripts Folder',
filename: 'scripts-folder',
seq: 2,
examples: [],
root: {
request: {
auth: {
mode: 'none'
},
script: {
req: 'console.log("scripts-folder script line 1");\nconsole.log("scripts-folder script line 2")',
res: 'console.log("scripts-folder script line 1");\nconsole.log("scripts-folder script line 2")'
},
tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});'
},
meta: {
name: 'Scripts Folder',
seq: 2
}
},
items: [
{
type: 'http',
name: 'Request In Scripts Folder',
filename: 'scripts-folder-echo.bru',
seq: 1,
settings: {
encodeUrl: true,
timeout: 0
},
tags: [],
examples: [],
request: {
url: 'https://echo.usebruno.com',
method: 'POST',
headers: [],
params: [],
body: {
mode: 'json',
json: '{\n "location": "folder-request"\n}',
formUrlEncoded: [],
multipartForm: [],
file: []
},
script: {
req: 'console.log("scripts-folder-request script line 1");\nconsole.log("scripts-folder-request script line 2")',
res: 'console.log("scripts-folder-request script line 1");\nconsole.log("scripts-folder-request script line 2")'
},
vars: {},
assertions: [],
tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});',
docs: '',
auth: {
mode: 'none'
}
}
},
{
type: 'folder',
name: 'Scripts Inner Folder',
filename: 'scripts-inner-folder',
seq: 2,
examples: [],
root: {
request: {
auth: {
mode: 'none'
},
script: {
req: 'console.log("scripts-inner-folder script line 1");\nconsole.log("scripts-inner-folder script line 2")',
res: 'console.log("scripts-inner-folder script line 1");\nconsole.log("scripts-inner-folder script line 2")'
},
tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});'
},
meta: {
name: 'Scripts Inner Folder',
seq: 2
}
},
items: [
{
type: 'http',
name: 'Request In Scripts Inner Folder',
filename: 'scripts-inner-folder-echo.bru',
seq: 2,
settings: {
encodeUrl: true,
timeout: 0
},
tags: [],
examples: [],
request: {
url: 'https://echo.usebruno.com',
method: 'POST',
headers: [],
params: [],
body: {
mode: 'json',
json: '{\n "location": "inner-folder-request"\n}',
formUrlEncoded: [],
multipartForm: [],
file: []
},
script: {
req: 'console.log("scripts-inner-folder-request script line 1");\nconsole.log("scripts-inner-folder-request script line 2")',
res: 'console.log("scripts-inner-folder-request script line 1");\nconsole.log("scripts-inner-folder-request script line 2")'
},
vars: {},
assertions: [],
tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});',
docs: '',
auth: {
mode: 'none'
}
}
}
]
}
]
}
],
environments: [],
root: {
request: {
script: {
req: 'console.log("root-request script line 1");\nconsole.log("root-request script line 2")',
res: 'console.log("root-request script line 1");\nconsole.log("root-request script line 2")'
},
tests: 'test("Status code is 200", () => {\n expect(res.status).to.eql(200);\n});\ntest("Body is not empty", () => {\n expect(res.text).not.to.eql("");\n});'
}
},
brunoConfig: {
version: '1',
name: 'Script and Tests Collection',
type: 'collection',
ignore: [
'node_modules',
'.git'
],
size: 0.0020351409912109375,
filesCount: 6
}
};
it('should convert Bruno request scripts and tests to Postman event scripts', () => {
const postmanCollection = brunoToPostman(brunoCollection);
// Root request events
const rootRequest = postmanCollection.item.find((i) => i.name === 'Request With Scripts and Tests');
const rootPre = rootRequest.event.find((e) => e.listen === 'prerequest');
const rootTest = rootRequest.event.find((e) => e.listen === 'test');
expect(rootPre).toBeDefined();
expect(rootTest).toBeDefined();
expect(rootPre.script.exec).toEqual([
'console.log("root-request script line 1");',
'console.log("root-request script line 2")'
]);
expect(rootTest.script.exec).toEqual([
'console.log("root-request script line 1");',
'console.log("root-request script line 2")',
'',
'// Tests',
'test("Status code is 200", () => {',
' expect(res.status).to.eql(200);',
'});',
'test("Body is not empty", () => {',
' expect(res.text).not.to.eql("");',
'});'
]);
});
it('should convert Bruno folder scripts and tests to Postman event scripts', () => {
const postmanCollection = brunoToPostman(brunoCollection);
// Folder events
const folder = postmanCollection.item.find((i) => i.name === 'Scripts Folder');
const folderPre = folder.event.find((e) => e.listen === 'prerequest');
const folderTest = folder.event.find((e) => e.listen === 'test');
expect(folderPre).toBeDefined();
expect(folderTest).toBeDefined();
expect(folderPre.script.exec).toEqual([
'console.log("scripts-folder script line 1");',
'console.log("scripts-folder script line 2")'
]);
expect(folderTest.script.exec).toEqual([
'console.log("scripts-folder script line 1");',
'console.log("scripts-folder script line 2")',
'',
'// Tests',
'test("Status code is 200", () => {',
' expect(res.status).to.eql(200);',
'});',
'test("Body is not empty", () => {',
' expect(res.text).not.to.eql("");',
'});'
]);
});
it('should convert Bruno inner folder scripts and tests to Postman event scripts', () => {
const postmanCollection = brunoToPostman(brunoCollection);
const folder = postmanCollection.item.find((i) => i.name === 'Scripts Folder');
// Inner folder events
const innerFolder = folder.item.find((i) => i.name === 'Scripts Inner Folder');
const innerFolderPre = innerFolder.event.find((e) => e.listen === 'prerequest');
const innerFolderTest = innerFolder.event.find((e) => e.listen === 'test');
expect(innerFolderPre).toBeDefined();
expect(innerFolderTest).toBeDefined();
expect(innerFolderPre.script.exec).toEqual([
'console.log("scripts-inner-folder script line 1");',
'console.log("scripts-inner-folder script line 2")'
]);
expect(innerFolderTest.script.exec).toEqual([
'console.log("scripts-inner-folder script line 1");',
'console.log("scripts-inner-folder script line 2")',
'',
'// Tests',
'test("Status code is 200", () => {',
' expect(res.status).to.eql(200);',
'});',
'test("Body is not empty", () => {',
' expect(res.text).not.to.eql("");',
'});'
]);
});
it('should convert Bruno collection scripts and tests to Postman event scripts', () => {
const postmanCollection = brunoToPostman(brunoCollection);
// Collection events
const collectionPre = postmanCollection.event.find((e) => e.listen === 'prerequest');
const collectionTest = postmanCollection.event.find((e) => e.listen === 'test');
expect(collectionPre).toBeDefined();
expect(collectionTest).toBeDefined();
expect(collectionPre.script.exec).toEqual([
'console.log("root-request script line 1");',
'console.log("root-request script line 2")'
]);
expect(collectionTest.script.exec).toEqual([
'console.log("root-request script line 1");',
'console.log("root-request script line 2")',
'',
'// Tests',
'test("Status code is 200", () => {',
' expect(res.status).to.eql(200);',
'});',
'test("Body is not empty", () => {',
' expect(res.text).not.to.eql("");',
'});'
]);
});
});

View File

@@ -1062,6 +1062,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
interpolateVars(requestCopy, envVars, runtimeVariables, processEnvVars);
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
collection,
request: requestCopy,
envVars,
runtimeVariables,
@@ -1195,6 +1196,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
const certsAndProxyConfig = await getCertsAndProxyConfig({
collectionUid,
collection,
request: requestCopy,
envVars,
runtimeVariables,

View File

@@ -11,6 +11,7 @@ const { interpolateString } = require('./interpolate-string');
*/
const getCertsAndProxyConfig = async ({
collectionUid,
collection,
request,
envVars,
runtimeVariables,
@@ -40,7 +41,7 @@ const getCertsAndProxyConfig = async ({
httpsAgentRequestFields['caCertificatesCount'] = caCertificatesCount;
httpsAgentRequestFields['ca'] = caCertificates || [];
const brunoConfig = getBrunoConfig(collectionUid);
const brunoConfig = getBrunoConfig(collectionUid, collection);
const interpolationOptions = {
globalEnvironmentVariables,
envVars,

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