Compare commits

..

57 Commits

Author SHA1 Message Date
Bijin A B
7bd6a9a915 Merge pull request #6054 from barelyhuman/fix/regex-index
fix: remove global flag from TEMPLATE_VAR_PATTERN to avoid falsey matches
2025-11-10 19:25:17 +05:30
Siddharth Gelera
e1045372d5 fix: reset TEMPLATE_VAR_PATTERN lastIndex in URL validatio 2025-11-10 19:24:14 +05:30
Siddharth Gelera
914b858024 fix: update TEMPLATE_VAR_PATTERN to remove global flag 2025-11-10 18:15:57 +05:30
Siddharth Gelera
36e9a9c137 fix: reset TEMPLATE_VAR_PATTERN lastIndex in URL validation 2025-11-10 18:14:00 +05:30
Bijin A B
995899dedb Merge pull request #6053 from lohit-bruno/upgrade_fast_json_format_library
chore: `fast-json-format` library upgrade
2025-11-10 17:35:18 +05:30
lohit-bruno
408dd6bccf chore: fast-json-format library upgrade 2025-11-10 16:58:14 +05:30
Anoop M D
ab7ead91d5 chore: remove references to Golden Edition from multiple language readme files 2025-11-09 17:43:34 +05:30
Abhishek S Lal
a186df3ac4 refactor: update UI interactions and improve test stability (#6042) 2025-11-08 01:54:50 +05:30
Abhishek S Lal
3fe5299d8e fix: httpbun dependencies removed (#6041)
* fix: standardize URL formatting in insomnia test files

* feat: add mix router for handling custom redirects and cookies

* fix: add validation for redirect count to prevent infinite loops

* fix: update test URLs to use local server and add query parameters for improved testing
2025-11-07 21:55:33 +05:30
lohit
57c08350b6 fix: handle prettifying json data with bruno variables (#6038) 2025-11-07 21:07:30 +05:30
Siddharth Gelera (reaper)
68b2625259 feat(common): add patternHasher utility for hashing and restoring string from special characters (#6032)
* feat: add patternHasher utility for variable hashing

This utility function hashes input strings containing variables and allows for restoration of the original string. It includes support for custom matchers and handles cases where no variables are matched.

* Update packages/bruno-common/src/utils/template-hasher.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Sid <siddharth@usebruno.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 19:09:55 +05:30
Dann
1719cee440 fix: request URL input field overflow issue (#6020) 2025-11-07 16:27:11 +05:30
lohit
ab4e9eb3bd fix: use fast-json-format for prettifying json (#6026) 2025-11-07 16:20:12 +05:30
Siddharth Gelera (reaper)
24a36bc355 fix: ensure protocolVersion is a number in WebSocket options (#6013) 2025-11-06 19:05:35 +05:30
lohit
1ac128e35c fix(bru-2079): validations for global environments get/set functions (#6009) 2025-11-06 18:12:39 +05:30
sanish chirayath
4510cc3414 fix: assert value input is disabled (#6012) 2025-11-06 17:22:27 +05:30
sanish chirayath
f3cb0d4bae Fix/example naming (#6002)
* fix: example naming

* fix: request not being saved when initialized with empty url

* remove check for method

* fix: improve the logic for get initial name

* fix test
2025-11-05 22:56:03 +05:30
lohit
7b183887ce fix(bru-2096): handle options prop for req body apis in safe mode (#6001) 2025-11-05 20:41:01 +05:30
sanish chirayath
fa28ab9b50 fix: response is unable to be copied (#5995)
* fix: response is unable to be copied

* fix: enable selection of text on readonly

* fix: no cursor when readonly
2025-11-05 20:17:09 +05:30
Abhishek S Lal
60b437ef9d fix: update test URLs having httpbin. Add redirect chain endpoint to test server (#5989)
* fix: update test URLs from httpbin to echo.usebruno.com across multiple test files

* fix: standardize URL formatting in insomnia test files

* chore: standardize URL formatting in insomnia test files
2025-11-05 20:16:07 +05:30
sanish chirayath
1ef8852a01 fix: grpc timeline crash (#5999) 2025-11-05 20:14:11 +05:30
Siddharth Gelera (reaper)
17cc70f36e fix: improve URL validation in GenerateCodeItem (#5998)
* fix: improve URL validation in GenerateCodeItem

Replaced direct URL validation with a new function to check for valid URLs and missing interpolations.

* Update packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-05 20:11:00 +05:30
DrChiodo
2deee11718 fix: restore text selection and copy in read-only CodeEditor (#5983)
Fixes #5982

Changed CodeMirror readOnly option from 'nocursor' to boolean true
to allow text selection while maintaining read-only behavior.
Removed CSS rules that prevented text selection (user-select: none)
in read-only mode.

This restores the ability to copy text from the Response panel using
Ctrl+C or right-click context menu, which was broken in nightly builds
after v2.13.2.
2025-11-05 19:17:20 +05:30
sanish chirayath
bdc8f391b7 Feat/response examples testing fixes (#5974)
* fix: cloning

feat: add response saving limit

fix: status code stays OK when i save an example

* fix: lint

* fix: newly created examples are not opening in new tab

* fix: playwright tests

* fix: use itemUId to fins item

* fix: response save
2025-11-05 19:13:21 +05:30
sanish chirayath
d8adb59d04 chore: rename crashing (#5985) 2025-11-05 14:13:49 +05:30
lohit
de05fb6137 fix(bru-1142): import environment functionality validations and fixes (#5964) 2025-11-04 18:05:25 +05:30
Kyle Bloom
3e3884a6af chore: Minimal fix for start time (#5961) 2025-11-04 18:01:50 +05:30
Bijin A B
23843bb621 fix: flaky large response test and update app preferences for few tests (#5963)
* fix: flaky large response test and update app preferences for few tests

* refactor: update createCollection function to accept sandbox mode
2025-11-02 11:35:09 +05:30
Bijin A B
c85a1ec1a5 chore: refactor few flaky tests (#5958) 2025-11-01 08:05:53 +05:30
sanish chirayath
68cbb7d9df Feat/add import export support for examples (#5936)
* feat: enhance Bru grammar to support response blocks and examples

- Added new grammar rules for response headers, status, and body types (JSON, XML, text).
- Introduced parsing logic for example blocks, allowing multiple examples with various body types.
- Implemented tests for example parsing, including edge cases and complex examples with authentication.
- Created fixture files for simple and complex examples to validate parsing functionality.

feat: extend jsonToBru functionality to support response handling and examples

- Updated jsonToBru to include parsing for response headers, status, and body types (JSON, XML, text).
- Enhanced example handling to support multiple examples with various body types.
- Added comprehensive tests for example parsing, including edge cases and complex scenarios with authentication.
- Created fixture files for testing the new features and validating parsing functionality.

move: files to fixtures folder

refactor: simplify response body handling in Bru grammar and JSON conversion

- Removed specific body type handling (JSON, XML, text) from grammar and semantics.
- Updated response body parsing in jsonToBru to handle a unified response body format.
- Adjusted tests and fixtures to reflect changes in response body structure, ensuring compatibility with the new format.

feat: add response bookmarking functionality to ResponsePane

- Introduced ResponseBookmark component to allow users to save responses as examples.
- Added NameExampleModal for naming saved examples.
- Updated ResponsePane to include the new bookmarking feature.
- Implemented Redux actions to manage response examples in the collections state.
- Enhanced CollectionItem to display saved examples and allow for expansion.

fix: remove unnecessary padding from ExampleItem component

feat: implement delete and rename functionality for examples in ExampleItem component

- Added DeleteExampleModal for confirming deletion of examples.
- Integrated modal for renaming examples with state management.
- Enhanced ExampleItem to handle example deletion and renaming through modals.
- Updated Redux actions to support example updates and deletions in the collections state.

fix: example writing to  disc properly

fix: example parsing errors

fix: request with example parsing error

fix: handle examples in collections and requests

feat: implement response example functionality in the application

- Added ResponseExample component to handle displaying and editing response examples.
- Integrated ResponseExampleRequestPane and ResponseExampleResponsePane for structured request and response handling.
- Enhanced RequestTabPanel and RequestTab components to support response-example tabs.
- Introduced new styled components for better UI/UX in response examples.
- Updated theme files to include styles for response examples.
- Implemented URL bar for editing request URLs in response examples.
- Added functionality for managing headers and parameters in response examples.
- Improved overall structure and organization of response example components.

add styles for example url bar

feat: add Checkbox component and Table-v2 for enhanced UI

- Introduced a new Checkbox component for better user interaction in forms.
- Added Table-v2 component to improve table rendering and resizing functionality.
- Updated existing components to utilize the new Checkbox and Table-v2 for managing headers and parameters in response examples.
- Enhanced styling for better visual consistency across components.
- Updated theme files to include styles for the new components.

feat: implement custom scrollbar styles for response example components

fix: features

add actions , view more

feat: enhance response example functionality

- Added GenerateCodeItem component for generating code snippets from response examples.
- Integrated modal for code generation within ResponseExample component.
- Updated ResponseExampleTopBar to handle example name and description editing.
- Improved state management for response examples, including new actions for updating names and descriptions.
- Enhanced ResponseExampleRequestPane to support editing and saving request details.
- Refactored URL handling in ResponseExampleUrlBar to utilize example-specific data.
- Improved overall user experience with better UI elements and state management.

feat: enhance response example management and UI components

feat: enhance editing capabilities in response example components

feat: update multipart form parameter handling in response examples

feat: refactor response example parameter handling and enhance UI interactions

feat: introduce RadioButton component and update Checkbox usage in response examples

fix: styles

fix radio button styling

fixed radio button styles

feat: add create example from sidebar

feat: enhance ResponseExample components with layout adjustments and new HeightBoundContainer

feat: add Checkbox and RadioButton components with comprehensive tests for rendering, user interactions, and accessibility

feat: playwright test csaes

rm: comments

fix: linting

fix: tests

refactor: update response example tests and enhance functionality

fix: tests

fix: e2e-tests

refactor: implement hasRequestChanges utility for better change detection

rm: console

rm: consoles

fix: lint

fix: tests

fix: response header disabled by default issue

Feat/with bru example parser (#5892)

* fix: response header disabled by default issue

feat: new parsing logic

fix: change test cases to accomodate new brulang

add: path params features

rm:consoles

six: make tab permanent on double click

fix width

feat: add status editing

feat: review fixes

review fixes

fix: review fixes

fix: post review

mv: test files

fix: review

* fix: lint

* fix: review comments

* fix: icons folder strcuture

fix: tests

fix: lint

fix: unit tests

feat: body mode selector

fix: close all collections

rm: example

feat added tests. lang change

feat: add custom status text

fix: status update

feat: add body mode, update tests

add default name prefilled for example

fix: active tab styles, prefilled name, text fixes

fix : pkg lock

fix: review

fix: review comments

fix: hide cursor when readonly

fix: height

fix: null body

fix: response body parsing

fix: test cases

feat: add method support for examples

fix: reponse parsing

fix: update response body type when content type is updated

rm : commented code

feat: update parser logic

fix: organize files

feat: enhance examples handling in collection export and import

feat: postman imports fro examples

feat: enhance OpenAPI import functionality to support examples

feat: support postman export

fix: postman export import

fix: open api tests, remove requestbody related logic

rm: examples

fix:  move common attributes files

ui fixes

fix: clone issue

fix: create example from request menu

review fixes

more review fixes

mv: files, fix mode req error

organize files

fix:tests

fix: save dot issue

fix: bugs

fix: postman export

fix: import path params

* chore:improve modal handling in environment and response example tests

fix: test issues resolved

* chore: update response example tests to use new fixture files and improve cleanup logic

---------

Co-authored-by: Abhishek S Lal <abhishek@usebruno.com>
Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-11-01 05:56:11 +05:30
Abhishek S Lal
396ff2b196 test: playwright tests for create request with http, gRPC, ws, graphql (#5952)
* feat: added tests for request creation

* fix: add folder.bru

* add: common locators to locators.ts file

* refactor: update locators for request actions and improve test assertions across GraphQL, gRPC, HTTP, and WebSocket request tests

* fix: updated locator logic for folder requests.

* chore: implement cleanup logic for GraphQL, gRPC, HTTP, and WebSocket request tests

---------

Co-authored-by: sanish-bruno <sanish@usebruno.com>
Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-10-31 22:04:35 +05:30
lohit
6826e98945 fix(bru-1447): json response formatting using fast-json-format library (#5932) 2025-10-31 20:34:36 +05:30
Siddharth Gelera (reaper)
e47d1ed353 Fix: safe serialise TypedArrays to avoid loosing constructor information (#5941)
* fix: enhance cleanJson to support serialization of typed arrays

* fix: correctness of inference based checks

* fix: remove duplicate Uint8Array reference

* fix: correct export syntax for mixinTypedArrays

Updated the export statement to use 'exports' instead of 'export' for proper module export functionality.

* chore: code cleanup

* test: add basics tests for cleanJson
2025-10-31 17:23:48 +05:30
sanish chirayath
08c182a875 Feat: support bin header in gRPC (#5869)
* Fix -bin header handling in grpc

* fix: bin-header, tests

rm: tests

rm: unused

fix: bin header

fix: test

fix: test

rm: un-necessarycode

---------

Co-authored-by: Juan Pablo Orsay <jporsay@gmail.com>
2025-10-31 17:07:12 +05:30
Abhishek S Lal
f3afb4bf84 Fix/export folder and collection level scripts (#5942)
* fix: bruno to postman export - export pre and post request scripts on folder/collection level

* refactor: removed redundant code.

* fix: lint error - file should end with a new line

---------

Co-authored-by: Jakub Sadowski <jakubsadowski08@gmail.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-10-30 19:54:07 +05:30
lohit
21e8615247 fix(bru-1142): collection and global environments import and export functionality updates (#5910) 2025-10-30 19:37:44 +05:30
Abhishek S Lal
6e8751a27a Fix/client cert passphrase issues (#5898)
* fix: added interpolation, warning and syntax highlight for passphrase input

Changes:
1) When users add plain text in passphrase, warning message will be shown.
2) Passphrase will be interpolated from environment
3) Syntax highlighting for variables added.

Closes #2685

* fix: global environment variables interpolation in cert passphrase implemented.

* refactor: indentation refactoring
2025-10-30 18:14:39 +05:30
Abhishek S Lal
c9a96ee94f feat: fuzzy search for grpc methods list (#5940)
* feat: implemented fuzzy search in grpc methods

Changes:
1) New SearchInput reusable component created.
2) Search input box added in grpc methods list.
3) Fuzzy search and keyboard navigation functionality implemented.

Closes #5683

* feat: e2e test cases added for new grpc method searchbox

* fix: package-lock json update

* fix: added missing collection files for testing

* fix: fixed lint issue

* chore: update package-lock json file

* fix: improve keyboard navigation and search handling in MethodDropdown

Changes:
1) Adjusted focused index logic for ArrowUp key to remove focus after first item.
2) Enhanced handleSearchChange logic to highlight first item when search text is not empty.

* feat: implemented fuzzy search in grpc methods

Changes:
1) New SearchInput reusable component created.
2) Search input box added in grpc methods list.
3) Fuzzy search and keyboard navigation functionality implemented.

Closes #5683

* feat: e2e test cases added for new grpc method searchbox

* fix: added missing collection files for testing

* fix: fixed lint issue

* chore: update package-lock json file

* fix: improve keyboard navigation and search handling in MethodDropdown

Changes:
1) Adjusted focused index logic for ArrowUp key to remove focus after first item.
2) Enhanced handleSearchChange logic to highlight first item when search text is not empty.

* test: updated test description and some code optimisation
2025-10-30 17:56:49 +05:30
naman-bruno
b69db7b44b fix: High CPU due to WMI queries (#5924)
* fix: High CPU due to WMI queries
2025-10-30 15:32:21 +05:30
naman-bruno
73caaef42b fix: crash on viewing large responses (#5647)
* fix: crash on viewing large responses
2025-10-30 13:29:53 +05:30
Sanjai Kumar
e68b2ae3b7 feat: Import Insomnia environments (#5716)
* feat: Implement environment conversion utilities for Insomnia to Bruno migration

fix tests

fix: test

feat: updated `toBrunoEnv` and merging functions to flatten environment data using dot-notation keys. added tests for `buildV5Environments` and `buildV4Environments` to verify flattened key behavior and shallow overrides.

chore: update package-lock.json

refactor: replace `flat` library with custom `flattenObject` utility for improved environment data flattening

chore: remove package-lock.json updates

feat: update `toBrunoEnv` to convert environment values to strings and adjust tests for flattened key behavior in Insomnia environment imports

refactor: update flattening logic to use JavaScript-style square bracket notation for arrays and adjust related tests

feat: enhance insomnia-to-bruno conversion by normalizing variables in requests, and add tests for v4 and v5 environment imports

refactor: improve variable naming and streamline environment building logic in `buildV5Environments` and `buildV4Environments` functions

test: add cleanup step to environment import tests and update expected version for new feature

* revert package-lock.json changes

* test: Add data-testid attributes to environment variable rows in EnvironmentVariables component
2025-10-29 19:04:09 +05:30
Pooja
cc7f1ea58f feat: add copy and paste functionality for requests (#5907) 2025-10-29 17:24:09 +05:30
Sid
6e8cd55b76 Refactor: Change how test runner handles pageWithUserData tests (#5922)
* refactor: change how test runner opens pageWithUserData instances

* fix: test move tabs

* fix: custom ca cert tests

* fix: update file patterns and improve error messages

* fix: improve electron app launch logic

* fix: update temporary directory handling for Electron app

* fix: ensure newline at end of file in index.ts

This change adds a newline at the end of the file to comply with coding standards.

* fix: improve error handling in recursiveCopy function

- Simplified error message when source path does not exist.
- Enhanced error handling to provide clearer guidance on usage of `page` fixture.

* fix(e2e): close collections after each tests

* fix: reuse the worker instance per file instead of per user data dir

* fix: revert ssl tests as serial run is fixed

* fix: change afterEach to afterAll for cleanup

fix: change afterEach to afterAll for cleanup

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-10-29 14:32:45 +05:30
Tanishq Singla
384aabf2af Bugfix: Error importing curl with no space in header (#5897)
* fix: error importing curl when there is no space in header
2025-10-29 14:11:46 +05:30
Abhishek S Lal
a15dcdb133 fix: removed unwanted logging of global environment variables in the console (#5904) 2025-10-29 13:57:57 +05:30
Abhishek S Lal
18848cdb26 fix: added xml-formatter package in package.json of bruno-js (#5920)
* fix: added xml-formatter package in package.json of bruno-js

* fix: package lock json update
2025-10-28 18:42:34 +05:30
Siddharth Gelera (reaper)
29b90a7e0d Fix: Multi sub protocol support for web sockets (#5903)
* fix: manually split sub proto for `ws` compat

* feat: force persist multi map for protocols

* fix: remove unnecessary pause in subprotocol tests
2025-10-28 11:34:24 +05:30
Bijin A B
4fbe371eb0 fix: revert increasing playwright worker count (#5906)
* fix: revert increasing playwright worker count
2025-10-27 22:54:54 +05:30
Pragadesh-45
6fd2b8be6d fix: enhance cleanup process in MaskedEditor by adding destroy method and improving disable logic (#5748) 2025-10-27 18:00:52 +05:30
Chirag Chandrashekhar
be7f92d77f Resolved issue: https://github.com/usebruno/bruno/issues/4672 (#5900)
- Added interpolation to setVar method's value field.
- Added playwright test to test the fix.
- Added jest test to test out the fix.

---
Playwright - PASS
Jest - PASS
---
2025-10-27 16:26:49 +05:30
Sanjai Kumar
c5325c732f feat: enhance environment variable persistence handling (#5783)
* feat: enhance environment variable persistence handling

* feat: experiment playwright with multiple workers

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-10-25 19:02:45 +05:30
Anton
a538b27f24 Import WSDL to collection (#5015)
* Import WSDL to bruno collection

* feat(wsdl-import): remove unused code and minor refactor

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-10-25 15:20:18 +05:30
Anoop M D
77bb8f40fe Update readme.md (#5883)
Fix broken anchors for contribute and git headers.
2025-10-24 17:56:21 +05:30
kosarinin
8f1f5e3861 Update readme.md
Fix broken anchors for contribute and git headers.
2025-10-24 15:12:40 +03:00
Bijin A B
e9251a1f3f fix: add missing jsonwebtoken in package-lock (#5882) 2025-10-24 17:25:11 +05:30
Sid
3a011b2a18 Merge pull request #5881 from usebruno/bugfix/incorrect-space-encode-internal
Fixes: Generate Code does urlencoding twice
2025-10-24 17:17:40 +05:30
Siddharth Gelera (reaper)
77681ca51e fix: inherit vars and headers from the collection (#5876)
* fix: inherit vars and headers from the collection
2025-10-24 15:08:10 +05:30
414 changed files with 23775 additions and 1473 deletions

View File

@@ -41,13 +41,6 @@
![bruno](/assets/images/landing-2.png) <br /><br />
### الطبعة الذهبية ✨
غالبية ميزاتنا مجانية ومفتوحة المصدر.
نحن نسعى لتحقيق توازن متناغم بين [مبادئ الشفافية والاستدامة](https://github.com/usebruno/bruno/discussions/269)
طلبات الشراء لـ [الطبعة الذهبية](https://www.usebruno.com/pricing) ستطلق قريبًا بسعر ~~$19~~ **$9** ! <br/>
[اشترك هنا](https://usebruno.ck.page/4c65576bd4) لتصلك إشعارات عند الإطلاق.
### التثبيت

View File

@@ -43,13 +43,6 @@ Bruno ist ein reines Offline-Tool. Es gibt keine Pläne, Bruno um eine Cloud-Syn
![bruno](/assets/images/landing-2.png) <br /><br />
### Golden Edition ✨
Die meisten unserer Funktionen sind kostenlos und quelloffen.
Wir bemühen uns um ein Gleichgewicht zwischen [Open-Source-Prinzipien und Nachhaltigkeit](https://github.com/usebruno/bruno/discussions/269)
Du kannst die [Golden Edition](https://www.usebruno.com/pricing) bestellen **$19**! <br/>
### Installation
Bruno ist als Download [auf unserer Website](https://www.usebruno.com/downloads) für Mac, Windows und Linux verfügbar.

View File

@@ -43,13 +43,6 @@ Bruno funciona sin conexión a internet. No tenemos intenciones de añadir sincr
![bruno](/assets/images/landing-2.png) <br /><br />
### Golden Edition ✨
La mayoría de nuestras funcionalidades son gratis y de código abierto.
Queremos alcanzar un equilibrio en armonía entre los [principios open-source y la sostenibilidad](https://github.com/usebruno/bruno/discussions/269).
¡Puedes reservar la [Golden Edition](https://www.usebruno.com/pricing) por ~~$19~~ **$9**! <br/>
### Instalación
Bruno está disponible para su descarga [en nuestro sitio web](https://www.usebruno.com/downloads) para Mac, Windows y Linux.

View File

@@ -43,13 +43,6 @@ Bruno はオフラインのみで利用できます。Bruno にクラウド同
![bruno](/assets/images/landing-2.png) <br /><br />
### ゴールデンエディション ✨
機能のほとんどが無料で使用でき、オープンソースとなっています。
私たちは[オープンソースの原則と長期的な維持](https://github.com/usebruno/bruno/discussions/269)の間でうまくバランスを取ろうと努力しています。
[ゴールデンエディション](https://www.usebruno.com/pricing)を **19 ドル** (買い切り)で購入できます!
### インストール方法
Bruno は[私たちのウェブサイト](https://www.usebruno.com/downloads)からバイナリをダウンロードできます。Mac, Windows, Linux に対応しています。

View File

@@ -43,12 +43,6 @@
![bruno](../../assets/images/landing-2.png) <br /><br />
### ოქროს გამოცემა ✨
მთავარი ფუნქციების უმეტესობა უფასოა და ღია წყაროა. ჩვენ ვცდილობთ ჰარმონიული ბალანსის დაცვას [ღია წყაროების პრინციპებსა და მდგრადობას შორის](https://github.com/usebruno/bruno/discussions/269)
თქვენ შეგიძლიათ შეიძინოთ [ოქროს გამოცემა](https://www.usebruno.com/pricing) ერთჯერადი გადახდით **19 დოლარად**! <br/>
### ინსტალაცია
ბრუნო ხელმისაწვდომია როგორც ბინარული ჩამოტვირთვა [ჩვენ的网站上](https://www.usebruno.com/downloads) Mac-ის, Windows-ისა და Linux-ისთვის.

View File

@@ -26,13 +26,6 @@ Bruno is uitsluitend offline. Er zijn geen plannen om ooit cloud-synchronisatie
![bruno](/assets/images/landing-2.png) <br /><br />
### Golden Edition ✨
De meeste van onze functies zijn gratis en open source.
We streven naar een harmonieuze balans tussen [open-source principes en duurzaamheid](https://github.com/usebruno/bruno/discussions/269).
Je kunt de [Golden Edition](https://www.usebruno.com/pricing) kopen voor een eenmalige betaling van **$19**! <br/>
### Installatie
Bruno is beschikbaar als binaire download [op onze website](https://www.usebruno.com/downloads) voor Mac, Windows en Linux.

View File

@@ -41,13 +41,6 @@ Bruno é totalmente offline. Não há planos de adicionar sincronização em nuv
![bruno](../../assets/images/landing-2.png) <br /><br />
### Golden Edition ✨
A grande maioria dos nossos recursos são gratuitos e de código aberto.
Nós nos esforçamos para encontrar um equilíbrio harmônico entre [princípios de código aberto e sustentabilidade](https://github.com/usebruno/bruno/discussions/269)
Você pode pré encomendar o plano [Golden Edition](https://www.usebruno.com/pricing) por ~~USD $19~~ **USD $9**! <br/>
### Instalação
Bruno está disponível para download como binário [em nosso site](https://www.usebruno.com/downloads) para Mac, Windows e Linux.

View File

@@ -7,14 +7,14 @@ const eslintPluginDiff = require('eslint-plugin-diff');
let stylistic;
const runESMImports = async () => {
stylistic = await import('@stylistic/eslint-plugin').then(d => d.default);
stylistic = await import('@stylistic/eslint-plugin').then((d) => d.default);
};
module.exports = runESMImports().then(() => defineConfig([
{
plugins: {
'diff': fixupPluginRules(eslintPluginDiff),
'@stylistic': stylistic,
'@stylistic': stylistic
},
languageOptions: {
parser: require('@typescript-eslint/parser'),
@@ -26,6 +26,7 @@ module.exports = runESMImports().then(() => defineConfig([
files: [
'./eslint.config.js',
'tests/**/*.{ts,js}',
'playwright/**/*.{js,ts}',
'packages/bruno-app/**/*.{js,jsx,ts}',
'packages/bruno-app/src/test-utils/mocks/codemirror.js',
'packages/bruno-cli/**/*.js',
@@ -37,6 +38,7 @@ module.exports = runESMImports().then(() => defineConfig([
'packages/bruno-lang/**/*.js',
'packages/bruno-requests/**/*.ts',
'packages/bruno-requests/**/*.js',
'packages/bruno-tests/**/*.{js,ts}'
],
processor: 'diff/diff',
rules: {
@@ -44,7 +46,7 @@ module.exports = runESMImports().then(() => defineConfig([
indent: 2,
quotes: 'single',
semi: true,
jsx: true,
jsx: true
}).rules,
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }],
@@ -52,7 +54,7 @@ module.exports = runESMImports().then(() => defineConfig([
'@stylistic/curly-newline': ['error', {
multiline: true,
minElements: 2,
consistent: true,
consistent: true
}],
'@stylistic/function-paren-newline': ['error', 'never'],
'@stylistic/array-bracket-spacing': ['error', 'never'],
@@ -63,7 +65,7 @@ module.exports = runESMImports().then(() => defineConfig([
'@stylistic/semi-style': ['error', 'last'],
'@stylistic/max-len': ['off'],
'@stylistic/jsx-one-expression-per-line': ['off']
},
}
},
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],

85
package-lock.json generated
View File

@@ -14209,6 +14209,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-fuzzy": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/fast-fuzzy/-/fast-fuzzy-1.12.0.tgz",
"integrity": "sha512-sXxGgHS+ubYpsdLnvOvJ9w5GYYZrtL9mkosG3nfuD446ahvoWEsSKBP7ieGmWIKVLnaxRDgUJkZMdxRgA2Ni+Q==",
"license": "ISC",
"dependencies": {
"graphemesplit": "^2.4.1"
}
},
"node_modules/fast-glob": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
@@ -14225,6 +14234,12 @@
"node": ">=8.6.0"
}
},
"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==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -15217,6 +15232,16 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/graphemesplit": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/graphemesplit/-/graphemesplit-2.6.0.tgz",
"integrity": "sha512-rG9w2wAfkpg0DILa1pjnjNfucng3usON360shisqIMUBw/87pojcBSrHmeE4UwryAuBih7g8m1oilf5/u8EWdQ==",
"license": "MIT",
"dependencies": {
"js-base64": "^3.6.0",
"unicode-trie": "^2.0.0"
}
},
"node_modules/graphiql": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/graphiql/-/graphiql-3.7.1.tgz",
@@ -17760,6 +17785,12 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==",
"license": "BSD-3-Clause"
},
"node_modules/js-md4": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz",
@@ -20297,18 +20328,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidusage": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz",
"integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pify": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
@@ -25490,6 +25509,12 @@
"node": ">=0.6.0"
}
},
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -25915,6 +25940,22 @@
"node": ">=4"
}
},
"node_modules/unicode-trie": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
"license": "MIT",
"dependencies": {
"pako": "^0.2.5",
"tiny-inflate": "^1.0.0"
}
},
"node_modules/unicode-trie/node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
@@ -26832,6 +26873,8 @@
"cookie": "0.7.1",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
"fast-json-format": "~0.3.0",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@@ -26842,7 +26885,6 @@
"graphql-request": "^3.7.0",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"iconv-lite": "^0.6.3",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
@@ -30130,7 +30172,8 @@
"js-yaml": "^4.1.0",
"jscodeshift": "^17.3.0",
"lodash": "^4.17.21",
"nanoid": "3.3.8"
"nanoid": "3.3.8",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
@@ -30271,7 +30314,6 @@
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"pidusage": "^4.0.1",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
@@ -31996,6 +32038,7 @@
"quickjs-emscripten": "^0.29.2",
"tv4": "^1.3.0",
"uuid": "^9.0.0",
"xml-formatter": "^3.5.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
@@ -32037,6 +32080,18 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"packages/bruno-js/node_modules/xml-formatter": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/xml-formatter/-/xml-formatter-3.5.0.tgz",
"integrity": "sha512-9ij/f2PLIPv+YDywtdztq7U82kYMDa5yPYwpn0TnXnqJRH6Su8RC/oaw91erHe3aSEbfgBaA1hDzReDFb1SVXw==",
"license": "MIT",
"dependencies": {
"xml-parser-xo": "^4.1.0"
},
"engines": {
"node": ">= 14"
}
},
"packages/bruno-lang": {
"name": "@usebruno/lang",
"version": "0.12.0",

View File

@@ -27,6 +27,8 @@
"cookie": "0.7.1",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
"fast-json-format": "~0.3.0",
"file": "^0.2.2",
"file-dialog": "^0.0.8",
"file-saver": "^2.0.5",
@@ -37,7 +39,6 @@
"graphql-request": "^3.7.0",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
"iconv-lite": "^0.6.3",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",

View File

@@ -0,0 +1,83 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { humanizeRequestBodyMode } from 'utils/collections';
const DEFAULT_MODES = [
{ key: 'multipartForm', label: 'Multipart Form', category: 'Form' },
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form' },
{ key: 'json', label: 'JSON', category: 'Raw' },
{ key: 'xml', label: 'XML', category: 'Raw' },
{ key: 'text', label: 'TEXT', category: 'Raw' },
{ key: 'sparql', label: 'SPARQL', category: 'Raw' },
{ key: 'file', label: 'File / Binary', category: 'Other' },
{ key: 'none', label: 'None', category: 'Other' }
];
const BodyModeSelector = ({
currentMode,
onModeChange,
modes = DEFAULT_MODES,
disabled = false,
className = '',
wrapperClassName = '',
showCategories = true,
placement = 'bottom-end'
}) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(currentMode)}
{' '}
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
const onModeSelect = (mode) => {
dropdownTippyRef.current.hide();
onModeChange(mode);
};
// Group modes by category for rendering
const groupedModes = modes.reduce((acc, mode) => {
const category = mode.category || 'Other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(mode);
return acc;
}, {});
return (
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'} ${wrapperClassName}`}>
<Dropdown
onCreate={onDropdownCreate}
icon={<Icon />}
placement={placement}
disabled={disabled}
className={className}
>
{Object.entries(groupedModes).map(([category, categoryModes]) => (
<React.Fragment key={category}>
{showCategories && <div className="label-item font-medium">{category}</div>}
{categoryModes.map((mode) => (
<div
key={mode.key}
className="dropdown-item"
onClick={() => onModeSelect(mode.key)}
>
{mode.label}
</div>
))}
</React.Fragment>
))}
</Dropdown>
</div>
);
};
export default BodyModeSelector;

View File

@@ -0,0 +1,79 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.checkbox-container {
width: 1rem;
height: 1rem;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.checkbox-checkmark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
visibility: ${(props) => props.checked ? 'visible' : 'hidden'};
pointer-events: none;
}
.checkbox-input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 1rem;
height: 1rem;
border: 2px solid ${(props) => {
if (props.checked && props.disabled) {
return props.theme.colors.text.muted;
}
if (props.checked && !props.disabled) {
return props.theme.colors.text.yellow;
}
return props.theme.colors.text.muted;
}};
border-radius: 4px;
background-color: ${(props) => {
if (props.checked && !props.disabled) {
return props.theme.colors.text.yellow;
}
if (props.checked && props.disabled) {
return props.theme.colors.text.muted;
}
return 'transparent';
}};
cursor: pointer;
position: relative;
transition: all 0.2s ease;
outline: none;
box-shadow: none;
&:hover:not(:disabled) {
opacity: 0.8;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&:focus {
outline: none;
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.yellow}40;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,45 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
import IconCheckMark from 'components/Icons/IconCheckMark';
import { useTheme } from 'providers/Theme';
const Checkbox = ({
checked = false,
disabled = false,
onChange,
className = '',
id,
name,
value,
dataTestId = 'checkbox'
}) => {
const { theme } = useTheme();
const handleChange = (e) => {
if (!disabled && onChange) {
onChange(e);
}
};
return (
<StyledWrapper checked={checked} disabled={disabled} className={className}>
<div className="checkbox-container">
<input
type="checkbox"
id={id}
name={name}
value={value}
checked={checked}
disabled={disabled}
onChange={handleChange}
className="checkbox-input"
data-testid={dataTestId}
/>
<IconCheckMark className="checkbox-checkmark" color={theme.examples.checkbox.color} size={14} />
</div>
</StyledWrapper>
);
};
export default Checkbox;

View File

@@ -1,6 +1,12 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
&.read-only {
div.CodeMirror .CodeMirror-cursor {
display: none !important;
}
}
div.CodeMirror {
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};

View File

@@ -245,6 +245,10 @@ export default class CodeEditor extends React.Component {
this.editor.setOption('mode', this.props.mode);
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly);
}
this.ignoreChangeEvent = false;
}
@@ -262,7 +266,7 @@ export default class CodeEditor extends React.Component {
}
return (
<StyledWrapper
className="h-full w-full flex flex-col relative graphiql-container"
className={`h-full w-full flex flex-col relative graphiql-container ${this.props.readOnly ? 'read-only' : ''}`}
aria-label="Code Editor"
font={this.props.font}
fontSize={this.props.fontSize}

View File

@@ -1,19 +1,20 @@
import React from 'react';
import { IconCertificate, IconTrash, IconWorld } from '@tabler/icons';
import { useFormik } from 'formik';
import { uuid } from 'utils/common';
import * as Yup from 'yup';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { useState } from 'react';
import StyledWrapper from './StyledWrapper';
import { useRef } from 'react';
import path from 'utils/common/path';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField/index';
import { useTheme } from 'styled-components';
const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
const ClientCertSettings = ({ collection, clientCertConfig, onUpdate, onRemove }) => {
const certFilePathInputRef = useRef();
const keyFilePathInputRef = useRef();
const pfxFilePathInputRef = useRef();
const { storedTheme } = useTheme();
const formik = useFormik({
initialValues: {
@@ -68,10 +69,13 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
}
});
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning, warningMessage } = isSensitive(formik.values.passphrase);
const getFile = (e) => {
const filePath = window?.ipcRenderer?.getFilePath(e?.files?.[0]);
if (filePath) {
let relativePath = path.relative(root, filePath);
let relativePath = path.relative(collection.pathname, filePath);
formik.setFieldValue(e.name, relativePath);
}
};
@@ -82,8 +86,6 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
pfxFilePathInputRef.current.value = '';
};
const [passwordVisible, setPasswordVisible] = useState(false);
const handleTypeChange = (e) => {
formik.setFieldValue('type', e.target.value);
if (e.target.value === 'cert') {
@@ -314,21 +316,14 @@ const ClientCertSettings = ({ root, clientCertConfig, onUpdate, onRemove }) => {
Passphrase
</label>
<div className="textbox flex flex-row items-center w-[300px] h-[1.70rem] relative">
<input
id="passphrase"
type={passwordVisible ? 'text' : 'password'}
name="passphrase"
className="outline-none w-64 bg-transparent"
onChange={formik.handleChange}
<SingleLineEditor
value={formik.values.passphrase || ''}
theme={storedTheme}
onChange={(val) => formik.setFieldValue('passphrase', val)}
collection={collection}
isSecret={true}
/>
<button
type="button"
className="btn btn-sm absolute right-0 l"
onClick={() => setPasswordVisible(!passwordVisible)}
>
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
</button>
{showWarning && <SensitiveFieldWarning fieldName="basic-password" warningMessage={warningMessage} />}
</div>
{formik.touched.passphrase && formik.errors.passphrase ? (
<div className="ml-1 text-red-500">{formik.errors.passphrase}</div>

View File

@@ -45,7 +45,7 @@ const CollectionSettings = ({ collection }) => {
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const presets = get(collection, 'brunoConfig.presets', []);
const hasPresets = presets && presets.requestUrl !== "";
const hasPresets = presets && presets.requestUrl !== '';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const proxyEnabled = proxyConfig.hostname ? true : false;
@@ -120,7 +120,7 @@ const CollectionSettings = ({ collection }) => {
case 'clientCert': {
return (
<ClientCertSettings
root={collection.pathname}
collection={collection}
clientCertConfig={clientCertConfig}
onUpdate={onClientCertSettingsUpdate}
onRemove={onClientCertSettingsRemove}
@@ -167,7 +167,7 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy

View File

@@ -13,7 +13,7 @@ import {
IconChevronDown,
IconTerminal2,
IconNetwork,
IconDashboard,
IconDashboard
} from '@tabler/icons';
import {
closeConsole,

View File

@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
background: ${props => props.theme.console.bg};
background: ${(props) => props.theme.console.bg};
}
.tab-content-area {
@@ -30,19 +30,19 @@ const StyledWrapper = styled.div`
.section-header {
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid ${props => props.theme.console.border};
border-bottom: 1px solid ${(props) => props.theme.console.border};
h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
color: ${(props) => props.theme.console.titleColor};
}
p {
margin: 0;
font-size: 13px;
color: ${props => props.theme.console.textMuted};
color: ${(props) => props.theme.console.textMuted};
}
}
@@ -53,7 +53,7 @@ const StyledWrapper = styled.div`
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
color: ${(props) => props.theme.console.titleColor};
}
}
@@ -65,8 +65,8 @@ const StyledWrapper = styled.div`
}
.resource-card {
background: ${props => props.theme.console.headerBg};
border: 1px solid ${props => props.theme.console.border};
background: ${(props) => props.theme.console.headerBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
padding: 8px;
}
@@ -76,7 +76,7 @@ const StyledWrapper = styled.div`
align-items: center;
gap: 6px;
margin-bottom: 6px;
color: ${props => props.theme.console.titleColor};
color: ${(props) => props.theme.console.titleColor};
}
.resource-title {
@@ -87,13 +87,13 @@ const StyledWrapper = styled.div`
.resource-value {
font-size: 18px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
color: ${(props) => props.theme.console.titleColor};
margin-bottom: 2px;
}
.resource-subtitle {
font-size: 11px;
color: ${props => props.theme.console.buttonColor};
color: ${(props) => props.theme.console.buttonColor};
}
.resource-trend {
@@ -112,7 +112,7 @@ const StyledWrapper = styled.div`
}
&.stable {
color: ${props => props.theme.console.buttonColor};
color: ${(props) => props.theme.console.buttonColor};
}
}
`;

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import {
@@ -6,13 +6,44 @@ import {
IconDatabase,
IconClock,
IconServer,
IconChartLine,
IconChartLine
} from '@tabler/icons';
const Performance = () => {
const { systemResources } = useSelector(state => state.performance);
const { systemResources } = useSelector((state) => state.performance);
const formatBytes = bytes => {
useEffect(() => {
const { ipcRenderer } = window;
if (!ipcRenderer) {
console.warn('IPC Renderer not available');
return;
}
const startMonitoring = async () => {
try {
await ipcRenderer.invoke('renderer:start-system-monitoring', 2000);
} catch (error) {
console.error('Failed to start system monitoring:', error);
}
};
const stopMonitoring = async () => {
try {
await ipcRenderer.invoke('renderer:stop-system-monitoring');
} catch (error) {
console.error('Failed to stop system monitoring:', error);
}
};
startMonitoring();
return () => {
stopMonitoring();
};
}, []);
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
@@ -20,7 +51,7 @@ const Performance = () => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatUptime = seconds => {
const formatUptime = (seconds) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);

View File

@@ -0,0 +1,24 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
/* Environment item styling */
.environment-item {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
transition: background-color 0.15s ease;
.environment-name {
color: ${(props) => props.theme.text};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,269 @@
import React, { useState, useEffect, useMemo } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal';
import { exportBrunoEnvironment } from 'utils/exporters/bruno-environment';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
const ExportEnvironmentModal = ({ onClose, environments = [], environmentType }) => {
const dispatch = useDispatch();
// Helper function to truncate environment names
const truncateEnvName = (name) => {
if (name.length > 40) {
return name.substring(0, 40) + '...';
}
return name;
};
const [isExporting, setIsExporting] = useState(false);
const [filePath, setFilePath] = useState('');
const [selectedEnvironments, setSelectedEnvironments] = useState({});
const [exportFormat, setExportFormat] = useState(environments.length > 1 ? 'single-file' : 'single-object');
// Initialize selected environments
useEffect(() => {
const initialSelection = {};
// Add all environments and select them by default
environments.forEach((env) => {
initialSelection[env.uid] = true;
});
setSelectedEnvironments(initialSelection);
}, [environments]);
useEffect(() => {
const selectedCount = Object.values(selectedEnvironments).filter(Boolean).length;
if (selectedCount <= 1) {
setExportFormat('single-object');
}
if (exportFormat === 'single-object' && selectedCount > 1) {
setExportFormat('single-file');
}
}, [selectedEnvironments]);
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
setFilePath(dirPath);
}
})
.catch((error) => {
setFilePath('');
console.error(error);
});
};
const handleEnvironmentToggle = (envUid) => {
setSelectedEnvironments((prev) => {
const newSelection = {
...prev,
[envUid]: !prev[envUid]
};
return newSelection;
});
};
const handleSelectAll = () => {
const allSelected = environments.every((env) => selectedEnvironments[env.uid]) || false;
const newSelection = environments.reduce((acc, env) => ({
...acc,
[env.uid]: !allSelected
}), {}) || {};
setSelectedEnvironments(newSelection);
};
// Memoized selected environments and count
const selectedEnvs = useMemo(() => {
return environments.filter((env) => selectedEnvironments[env.uid]) || [];
}, [environments, selectedEnvironments]);
const selectedCount = selectedEnvs.length;
const exportFormatOptions = useMemo(() => {
const isMultiple = selectedCount > 1;
if (isMultiple) {
return [
{ value: 'single-file', label: 'Single JSON file', description: 'All environments in one JSON array' },
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: false }
];
}
return [
{ value: 'single-object', label: 'Single JSON file', description: 'Export as a single environment JSON object' },
{ value: 'folder', label: 'Separate files in folder', description: 'Each environment as a separate JSON file', disabled: true }
];
}, [selectedCount, exportFormat]);
const handleExport = async () => {
try {
setIsExporting(true);
if (!filePath) {
toast.error('Please select a location to save the files');
return;
}
if (selectedCount === 0) {
toast.error('Please select at least one environment to export');
return;
}
await exportBrunoEnvironment({ environments: selectedEnvs, environmentType, filePath, exportFormat });
const successMessage = exportFormat === 'folder'
? `Environments exported successfully to bruno-${environmentType}-environments folder`
: 'Environment(s) exported successfully';
toast.success(successMessage);
onClose();
} catch (error) {
console.error('Export error:', error);
toast.error(error.message || 'Failed to export environments');
} finally {
setIsExporting(false);
}
};
return (
<Portal>
<StyledWrapper>
<Modal
size="md"
title="Export Environments"
hideFooter={true}
handleCancel={onClose}
>
<div className="py-2">
{/* Environments Section */}
<div className="mb-4">
{environments && environments.length > 0 ? (
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-2 pb-1">
<h3 className="font-semibold text-sm text-theme">
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
</h3>
<button
type="button"
onClick={handleSelectAll}
className="text-xs text-link px-1 py-0.5 rounded transition-colors"
>
{environments.every((env) => selectedEnvironments[env.uid]) ? 'Deselect All' : 'Select All'}
</button>
</div>
<div className="flex flex-col gap-1 flex-1 overflow-y-auto">
{environments.map((env) => (
<label key={env.uid} className="environment-item">
<input
type="checkbox"
checked={selectedEnvironments[env.uid] || false}
onChange={() => handleEnvironmentToggle(env.uid)}
disabled={isExporting}
className="w-3.5 h-3.5 flex-shrink-0"
/>
<span className="environment-name">{truncateEnvName(env.name)}</span>
</label>
))}
</div>
</div>
) : (
<div className="flex flex-col h-full">
<div className="flex justify-between items-center mb-2 pb-1">
<h3 className="font-semibold text-sm text-theme">
{environmentType === 'global' ? 'Global Environments' : 'Collection Environments'}
</h3>
</div>
<div className="flex items-center justify-center flex-1 p-4 text-center">
<span className="text-xs text-muted">
No {environmentType === 'global' ? 'global' : 'collection'} environments
</span>
</div>
</div>
)}
</div>
{/* Export Format Section */}
{selectedCount > 0 && (
<div className="mb-4">
<label className="block text-sm font-medium mb-2 text-theme">
Export Format
</label>
<div className="space-y-2">
{exportFormatOptions.map((option) => (
<label key={option.value} className={`flex items-start p-2 rounded transition-colors ${option.disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}>
<input
type="radio"
name="exportFormat"
value={option.value}
checked={exportFormat === option.value}
onChange={(e) => setExportFormat(e.target.value)}
disabled={isExporting || option.disabled}
className={`mt-0.5 mr-3 w-4 h-4 ${option.disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
/>
<div>
<div className={`text-sm font-medium ${option.disabled ? 'text-muted' : 'text-theme'}`}>{option.label}</div>
<div className="text-xs text-muted">{option.description}</div>
</div>
</label>
))}
</div>
</div>
)}
{/* Location Input Section */}
<div className="mb-4">
<label htmlFor="export-location" className="block text-sm font-medium mb-2 text-theme">
Location
</label>
<div className="flex flex-col relative items-center">
<input
id="export-location"
type="text"
className={`flex-1 textbox w-full ${isExporting || selectedCount <= 0 ? '' : 'cursor-pointer'}`}
title={filePath}
value={filePath}
onClick={browse}
onChange={(e) => setFilePath(e.target.value)}
disabled={isExporting || selectedCount <= 0}
placeholder="Select a target location"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
</div>
</div>
{/* Export Actions */}
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
className="btn btn-sm btn-cancel mt-2 flex items-center"
onClick={onClose}
disabled={isExporting}
>
Cancel
</button>
<button
type="button"
className="btn btn-sm btn-secondary mt-2 flex items-center"
onClick={handleExport}
disabled={isExporting || selectedCount === 0}
>
{isExporting ? 'Exporting...' : `Export ${selectedCount || ''} Environment${selectedCount !== 1 ? 's' : ''}`}
</button>
</div>
</div>
</Modal>
</StyledWrapper>
</Portal>
);
};
export default ExportEnvironmentModal;

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import importBrunoEnvironment from 'utils/importers/bruno-environment';
import { readMultipleFiles } from 'utils/importers/file-reader';
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { toastError } from 'utils/common/error';
import { IconFileImport } from '@tabler/icons';
const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const [isDragOver, setIsDragOver] = useState(false);
const isGlobal = type === 'global';
// Validate required props
if (!isGlobal && !collection) {
console.error('ImportEnvironmentModal: collection prop is required when type is "collection"');
return null;
}
const modalTitle = isGlobal ? 'Import Global Environment' : 'Import Environment';
const modalTestId = isGlobal ? 'import-global-environment-modal' : 'import-environment-modal';
const importTestId = isGlobal ? 'import-global-environment' : 'import-environment';
const processEnvironments = async (environments, successMessage) => {
const validEnvironments = environments.filter((env) => {
if (env.name && env.name !== 'undefined') {
return true;
} else {
toast.error('Failed to import environment: env has no name');
return false;
}
});
if (validEnvironments.length === 0) {
toast.error('No valid environments found to import');
return;
}
try {
// Process environments sequentially to ensure unique name checking considers previously imported environments
let importedCount = 0;
for (const environment of validEnvironments) {
const action = isGlobal
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
await dispatch(action);
importedCount++;
}
toast.success(`${importedCount > 1 ? `${importedCount} environments` : 'Environment'} imported successfully`);
} catch (error) {
toast.error('An error occurred while importing the environment(s)');
console.error(error);
throw error;
}
};
const detectEnvironmentFormat = (data) => {
// bruno environment `single-object` export type
if (data.info && data.info.type === 'bruno-environment') {
return 'bruno';
} else if (Array.isArray(data)) {
// bruno environment`single-file` export type
return data.some((env) => env.info && env.info.type === 'bruno-environment') ? 'bruno' : 'postman';
} else if (data.id && data.values) {
// postman environment
return 'postman';
}
return 'bruno';
};
const handleImportEnvironment = async (files) => {
try {
// Read and parse all files
const parsedFiles = await readMultipleFiles(Array.from(files));
// Detect format from first file's content
const format = detectEnvironmentFormat(parsedFiles[0].content);
let environments;
if (format === 'postman') {
environments = await importPostmanEnvironment(parsedFiles);
} else {
environments = await importBrunoEnvironment(parsedFiles);
}
await processEnvironments(environments);
onClose();
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
} catch (err) {
toastError(err, 'Import environment failed');
}
};
const handleFileSelect = () => {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.accept = '.json';
input.onchange = (e) => {
if (e.target.files && e.target.files.length > 0) {
handleImportEnvironment(e.target.files);
}
};
input.click();
};
const handleDragOver = (e) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
setIsDragOver(false);
};
const handleDrop = (e) => {
e.preventDefault();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
handleImportEnvironment(files);
}
};
return (
<Portal>
<Modal size="md" title={modalTitle} hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId={modalTestId}>
<div className="py-2">
<div
className={`flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed p-12 text-center cursor-pointer transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
isDragOver
? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20'
: 'border-zinc-300 dark:border-zinc-400 hover:border-zinc-400'
}`}
onClick={handleFileSelect}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
data-testid={importTestId}
>
<IconFileImport size={64} />
<span className="mt-2 block text-sm font-semibold">
{isDragOver ? 'Drop your environment files here' : 'Import your environments'}
</span>
<span className="mt-1 block text-xs text-muted">
Drag & drop JSON files/folders or click to browse. Supports both Bruno and Postman formats.
</span>
</div>
</div>
</Modal>
</Portal>
);
};
export default ImportEnvironmentModal;

View File

@@ -11,9 +11,8 @@ import EnvironmentListContent from './EnvironmentListContent/index';
import EnvironmentSettings from '../EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
import ImportEnvironment from '../EnvironmentSettings/ImportEnvironment';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
import ImportGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -242,7 +241,8 @@ const EnvironmentSelector = ({ collection }) => {
)}
{showImportGlobalModal && (
<ImportGlobalEnvironment
<ImportEnvironmentModal
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
@@ -261,7 +261,8 @@ const EnvironmentSelector = ({ collection }) => {
)}
{showImportCollectionModal && (
<ImportEnvironment
<ImportEnvironmentModal
type="collection"
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {

View File

@@ -185,7 +185,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</thead>
<tbody>
{formik.values.map((variable, index) => (
<tr key={variable.uid}>
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
<input
type="checkbox"

View File

@@ -4,6 +4,7 @@ import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ToolHint from 'components/ToolHint/index';
const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => {
const [openEditModal, setOpenEditModal] = useState(false);
@@ -30,10 +31,22 @@ const EnvironmentDetails = ({ environment, collection, setIsModified, onClose })
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-semibold break-all">{environment.name}</span>
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash
className="cursor-pointer"
size={20}
strokeWidth={1.5}
onClick={() => setOpenDeleteModal(true)}
data-testid="delete-environment-button"
/>
</ToolHint>
</div>
</div>

View File

@@ -3,15 +3,15 @@ import { findEnvironmentInCollection } from 'utils/collections';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock } from '@tabler/icons';
import ImportEnvironment from '../ImportEnvironment';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ManageSecrets from '../ManageSecrets';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ToolHint from 'components/ToolHint';
import { isEqual } from 'lodash';
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose }) => {
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose, setShowExportModal }) => {
const { environments } = collection;
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
@@ -96,7 +96,7 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment collection={collection} onClose={() => setOpenImportModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
@@ -129,6 +129,10 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => setShowExportModal(true)}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>

View File

@@ -1,64 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { importEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
.then((environments) => {
environments
.filter((env) =>
env.name && env.name !== 'undefined'
? true
: () => {
toast.error('Failed to import environment: env has no name');
return false;
}
)
.map((environment) => {
dispatch(importEnvironment(environment.name, environment.variables, collection.uid))
.then(() => {
toast.success('Environment imported successfully');
})
.catch((error) => {
toast.error('An error occurred while importing the environment');
console.error(error);
});
});
})
.then(() => {
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
data-testid="import-postman-environment"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
</button>
</Modal>
</Portal>
);
};
export default ImportEnvironment;

View File

@@ -3,8 +3,9 @@ import React, { useState } from 'react';
import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import ImportEnvironment from './ImportEnvironment';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { IconFileAlert } from '@tabler/icons';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
@@ -47,6 +48,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
const { environments } = collection;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
@@ -54,7 +56,7 @@ const EnvironmentSettings = ({ collection, onClose }) => {
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
@@ -64,16 +66,26 @@ const EnvironmentSettings = ({ collection, onClose }) => {
}
return (
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
onClose={onClose}
/>
</Modal>
<StyledWrapper>
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
onClose={onClose}
setShowExportModal={setShowExportModal}
/>
</Modal>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={collection.environments}
environmentType="collection"
/>
)}
</StyledWrapper>
);
};

View File

@@ -5,7 +5,7 @@ import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { IconX } from '@tabler/icons';
import { isWindowsOS } from 'utils/common/platform';
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false, readOnly = false }) => {
const dispatch = useDispatch();
const filenames = (isSingleFilePicker ? [value] : value || [])
.filter((v) => v != null && v != '')
@@ -50,20 +50,24 @@ const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = fa
return filenames.length + ' file(s) selected';
};
const buttonClass = `btn btn-secondary px-1 ${readOnly ? 'view-mode' : 'edit-mode'}`;
return filenames.length > 0 ? (
<div
className="btn btn-secondary px-1"
className={buttonClass}
style={{ fontWeight: 400, width: '100%', textOverflow: 'ellipsis', overflowX: 'hidden' }}
title={title}
>
<button className="align-middle" onClick={clear}>
<IconX size={18} />
</button>
&nbsp;
{!readOnly && (
<button className="align-middle" onClick={clear}>
<IconX size={18} />
</button>
)}
{!readOnly && <>&nbsp;</>}
{renderButtonText(filenames)}
</div>
) : (
<button className="btn btn-secondary px-1" style={{ width: '100%' }} onClick={browse}>
<button className={buttonClass} style={{ width: '100%' }} onClick={!readOnly ? browse : undefined} disabled={readOnly}>
{isSingleFilePicker ? 'Select File' : 'Select Files'}
</button>
);

View File

@@ -17,7 +17,7 @@ import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -48,8 +48,6 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;

View File

@@ -18,7 +18,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector(state => state.globalEnvironments);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
@@ -125,7 +125,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</thead>
<tbody>
{formik.values.map((variable, index) => (
<tr key={variable.uid}>
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
<input
type="checkbox"

View File

@@ -4,8 +4,9 @@ import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ToolHint from 'components/ToolHint/index';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection, allEnvironments }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
@@ -29,15 +30,26 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-semibold break-all">{environment.name}</span>
</div>
<div className="flex gap-x-4 pl-4">
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} />
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} data-testid="delete-environment-button" />
</ToolHint>
</div>
</div>
<div>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
allEnvironments={allEnvironments}
/>
</div>
</div>
);

View File

@@ -2,15 +2,15 @@ import React, { useEffect, useState } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock } from '@tabler/icons';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
import ImportEnvironment from '../ImportEnvironment';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { isEqual } from 'lodash';
import ToolHint from 'components/ToolHint/index';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
@@ -38,7 +38,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
return;
}
const environment = environments?.find(env => env.uid === activeEnvironmentUid) || environments?.[0];
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0] || null;
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
@@ -90,6 +90,12 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
setOpenManageSecretsModal(true);
};
const handleExportClick = () => {
if (setShowExportModal) {
setShowExportModal(true);
}
};
const handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
@@ -99,7 +105,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
@@ -132,6 +138,10 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleExportClick()}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
@@ -144,6 +154,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
allEnvironments={environments}
/>
</div>
</StyledWrapper>

View File

@@ -1,65 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import importPostmanEnvironment from 'utils/importers/postman-environment';
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { uuid } from 'utils/common/index';
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
importPostmanEnvironment()
.then((environments) => {
environments
.filter((env) =>
env.name && env.name !== 'undefined'
? true
: () => {
toast.error('Failed to import environment: env has no name');
return false;
}
)
.map((environment) => {
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
.then(() => {
toast.success('Global Environment imported successfully');
})
.catch((error) => {
toast.error('An error occurred while importing the environment');
console.error(error);
});
});
})
.then(() => {
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-global-environment-modal">
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
data-testid="import-postman-global-environment"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
</button>
</Modal>
</Portal>
);
};
export default ImportEnvironment;

View File

@@ -4,7 +4,8 @@ import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironment from './ImportEnvironment/index';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
@@ -44,6 +45,7 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
@@ -51,7 +53,7 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
{tab === 'create' ? (
<CreateEnvironment onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironment onClose={() => setTab('default')} />
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
@@ -61,17 +63,27 @@ const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvir
}
return (
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
/>
</Modal>
<StyledWrapper>
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
setShowExportModal={setShowExportModal}
/>
</Modal>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={globalEnvironments}
environmentType="global"
/>
)}
</StyledWrapper>
);
};

View File

@@ -74,7 +74,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
if (isItemARequest(item)) {
// add an optional check for the item name to prevent a crash if it doesnt exist.
const nameMatch = searchTerms.every(term => (item.name || '').toLowerCase().includes(term));
const nameMatch = searchTerms.every((term) => (item.name || '').toLowerCase().includes(term));
const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term));
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));

View File

@@ -0,0 +1,21 @@
import React from 'react';
const ExampleIcon = ({ color = 'white', size = 16, ...props }) => {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_486_1191)">
<path d="M2.66699 3.33329C2.66699 3.15648 2.73723 2.98691 2.86225 2.86189C2.98728 2.73686 3.15685 2.66663 3.33366 2.66663H12.667C12.8438 2.66663 13.0134 2.73686 13.1384 2.86189C13.2634 2.98691 13.3337 3.15648 13.3337 3.33329V12.6666C13.3337 12.8434 13.2634 13.013 13.1384 13.138C13.0134 13.2631 12.8438 13.3333 12.667 13.3333H3.33366C3.15685 13.3333 2.98728 13.2631 2.86225 13.138C2.73723 13.013 2.66699 12.8434 2.66699 12.6666V3.33329Z" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.33366 5.33337H6.66699V10.6667H9.33366" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9.33366 8H6.66699" stroke={color} stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</g>
<defs>
<clipPath id="clip0_486_1191">
<rect width={size} height={size} fill="white" />
</clipPath>
</defs>
</svg>
);
};
export default ExampleIcon;

View File

@@ -0,0 +1,18 @@
import React from 'react';
const IconCaretDown = ({ color = '#8C8C8C', ...props }) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<g clipPath="url(#clip0_464_9256)">
<path d="M10.5444 5.75H4.46004C4.26888 5.7509 4.08142 5.78521 3.91637 5.84952C3.75132 5.91383 3.61447 6.00587 3.51947 6.11647C3.42448 6.22706 3.37466 6.35234 3.375 6.47978C3.37534 6.60723 3.42583 6.73238 3.52142 6.84275L6.56492 10.23C6.66228 10.3372 6.79942 10.4258 6.96311 10.4874C7.1268 10.5489 7.31151 10.5813 7.49945 10.5814C7.68739 10.5816 7.8722 10.5494 8.03608 10.4881C8.19995 10.4267 8.33735 10.3383 8.43504 10.2312L11.4763 6.8465C11.573 6.73635 11.6246 6.61118 11.626 6.48355C11.6273 6.35591 11.5783 6.23028 11.4839 6.11924C11.3895 6.0082 11.253 5.91564 11.088 5.85084C10.9231 5.78603 10.7359 5.75126 10.5444 5.75Z" fill="#8C8C8C" />
</g>
<defs>
<clipPath id="clip0_464_9256">
<rect width="9" height="6" fill="white" transform="translate(3 5)" />
</clipPath>
</defs>
</svg>
);
};
export default IconCaretDown;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const IconCheckMark = ({ color = '#cccccc', size = 16, ...props }) => {
return (
<svg width={size} height={size} viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M3.3335 8.49996L6.66683 11.8333L13.3335 5.16663" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
};
export default IconCheckMark;

View File

@@ -0,0 +1,19 @@
import React from 'react';
const IconEdit = ({ color = '#F39D0E', size = 16, ...props }) => {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_464_9527)">
<path d="M12.6665 13.3332H5.66654L2.85988 10.4665C2.73571 10.3416 2.66602 10.1727 2.66602 9.99654C2.66602 9.82042 2.73571 9.65145 2.85988 9.52654L9.52654 2.85988C9.65145 2.73571 9.82042 2.66602 9.99654 2.66602C10.1727 2.66602 10.3416 2.73571 10.4665 2.85988L13.7999 6.19321C13.924 6.31812 13.9937 6.48709 13.9937 6.66321C13.9937 6.83933 13.924 7.0083 13.7999 7.13321L7.66654 13.3332" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.9998 8.86663L7.7998 4.66663" stroke={color} strokeWidth="1.33333" strokeLinecap="round" strokeLinejoin="round" />
</g>
<defs>
<clipPath id="clip0_464_9527">
<rect width={size} height={size} fill="white" />
</clipPath>
</defs>
</svg>
);
};
export default IconEdit;

View File

@@ -26,7 +26,8 @@ const ModalFooter = ({
handleCancel,
confirmDisabled,
hideCancel,
hideFooter
hideFooter,
confirmButtonClass = 'btn-secondary'
}) => {
confirmText = confirmText || 'Save';
cancelText = cancelText || 'Cancel';
@@ -45,7 +46,7 @@ const ModalFooter = ({
<span>
<button
type="submit"
className="submit btn btn-md btn-secondary"
className={`submit btn btn-md ${confirmButtonClass}`}
disabled={confirmDisabled}
onClick={handleSubmit}
>
@@ -73,7 +74,8 @@ const Modal = ({
disableEscapeKey,
onClick,
closeModalFadeTimeout = 500,
dataTestId
dataTestId,
confirmButtonClass
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
@@ -139,6 +141,7 @@ const Modal = ({
confirmDisabled={confirmDisabled}
hideCancel={hideCancel}
hideFooter={hideFooter}
confirmButtonClass={confirmButtonClass}
/>
</div>

View File

@@ -9,14 +9,15 @@ const StyledWrapper = styled.div`
&.read-only {
.CodeMirror .CodeMirror-lines {
cursor: not-allowed !important;
user-select: none !important;
-webkit-user-select: none !important;
-ms-user-select: none !important;
}
.CodeMirror-line {
color: ${(props) => props.theme.colors.text.muted} !important;
}
.CodeMirror-cursor {
display: none !important;
}
}
.CodeMirror {

View File

@@ -18,6 +18,7 @@ class MultiLineEditor extends Component {
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
this.readOnly = props.readOnly || false;
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
@@ -35,7 +36,7 @@ class MultiLineEditor extends Component {
brunoVarInfo: {
variables
},
readOnly: this.props.readOnly ? 'nocursor' : false,
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
'Ctrl-Enter': () => {
@@ -108,8 +109,11 @@ class MultiLineEditor extends Component {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
} else {
this.maskedEditor?.disable();
this.maskedEditor = null;
if (this.maskedEditor) {
this.maskedEditor.disable();
this.maskedEditor.destroy();
this.maskedEditor = null;
}
}
};
@@ -128,7 +132,7 @@ class MultiLineEditor extends Component {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly ? 'nocursor' : false);
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
@@ -140,6 +144,9 @@ class MultiLineEditor extends Component {
// also set the maskInput flag to the new value
this.setState({ maskInput: this.props.isSecret });
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly || false);
}
this.ignoreChangeEvent = false;
}

View File

@@ -0,0 +1,74 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.radio-container {
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
.radio-input {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 16px;
height: 16px;
border: 2px solid ${(props) => props.theme.colors.text.muted};
border-radius: 50%;
background-color: transparent;
cursor: pointer;
position: relative;
outline: none;
box-shadow: none;
margin: 0;
&:checked {
border-color: ${(props) => props.theme.colors.text.yellow};
background-color: transparent;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: ${(props) => props.theme.colors.text.yellow};
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
border-color: ${(props) => props.theme.colors.text.muted};
background-color: transparent;
&:checked {
border-color: ${(props) => props.theme.colors.text.muted};
&::after {
background-color: ${(props) => props.theme.colors.text.muted};
}
}
}
&:hover:not(:disabled) {
opacity: 0.8;
}
}
.radio-label {
position: absolute;
top: 0;
left: 0;
width: 16px;
height: 16px;
cursor: pointer;
pointer-events: none;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,40 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const RadioButton = ({
checked,
disabled = false,
onChange,
name,
value,
id,
className = '',
dataTestId = 'radio-button'
}) => {
const handleChange = (e) => {
if (!disabled && onChange) {
onChange(e);
}
};
return (
<StyledWrapper>
<div className={`radio-container ${className}`}>
<input
type="radio"
id={id}
name={name}
value={value}
checked={checked}
disabled={disabled}
onChange={handleChange}
className="radio-input"
data-testid={dataTestId}
/>
<label htmlFor={id} className="radio-label" />
</div>
</StyledWrapper>
);
};
export default RadioButton;

View File

@@ -169,7 +169,6 @@ const AssertionRow = ({
<SingleLineEditor
value={value}
theme={storedTheme}
readOnly={true}
onSave={onSave}
onChange={(newValue) => {
handleAssertionChange(

View File

@@ -1,12 +1,14 @@
import React, { forwardRef } from 'react';
import { IconChevronDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown/index';
import {
IconGrpcUnary,
IconGrpcBidiStreaming,
IconGrpcClientStreaming,
IconGrpcServerStreaming,
IconGrpcBidiStreaming
IconGrpcUnary
} from 'components/Icons/Grpc';
import SearchInput from 'components/SearchInput/index';
import { search } from 'fast-fuzzy';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
const MethodDropdown = ({
grpcMethods,
@@ -14,6 +16,19 @@ const MethodDropdown = ({
onMethodSelect,
onMethodDropdownCreate
}) => {
const [searchText, setSearchText] = useState('');
const [focusedIndex, setFocusedIndex] = useState(-1);
const searchInputRef = useRef();
const listRef = useRef();
useEffect(() => {
const activeItem = listRef.current?.querySelector(`[data-index="${focusedIndex}"]`);
if (activeItem) {
activeItem.scrollIntoView({ block: 'nearest' });
}
}, [focusedIndex]);
const groupMethodsByService = (methods) => {
if (!methods || !methods.length) return {};
@@ -62,7 +77,7 @@ const MethodDropdown = ({
{selectedGrpcMethod && <div className="mr-2">{getIconForMethodType(selectedGrpcMethod.type)}</div>}
<span className="text-xs">
{selectedGrpcMethod ? (
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap" data-testid="selected-grpc-method-name">
{selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path}
</span>
) : (
@@ -79,49 +94,125 @@ const MethodDropdown = ({
onMethodSelect({ path: method.path, type: methodType });
};
const filteredMethods = searchText ? search(String(searchText), grpcMethods, { keySelector: (obj) => obj.path }) : grpcMethods;
const groupedMethods = groupMethodsByService(filteredMethods);
// Flatten grouped methods for keyboard navigation
const flatMethodList = Object.values(groupedMethods).flat();
const handleKeyDown = (e) => {
if (!flatMethodList.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setFocusedIndex((prev) =>
prev < flatMethodList.length - 1 ? prev + 1 : flatMethodList.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setFocusedIndex((prev) =>
prev >= 0 ? prev - 1 : -1);
} else if (e.key === 'Enter' && focusedIndex >= 0) {
e.preventDefault();
handleGrpcMethodSelect(flatMethodList[focusedIndex]);
}
};
const focusSearchInput = () => {
setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, 0); // 0ms to ensure the dropdown is fully rendered and focused
};
const handleDropdownShow = () => {
focusSearchInput();
setSearchText('');
setFocusedIndex(-1);
};
const handleSearchChange = (e) => {
// auto focus the first method when the search input is not empty
if (e.target.value.trim().length > 0) {
setFocusedIndex(0);
} else {
setFocusedIndex(-1);
}
};
if (!grpcMethods || grpcMethods.length === 0) {
return null;
}
return (
<div className="flex items-center h-full mr-2" data-testid="grpc-methods-dropdown">
<Dropdown onCreate={onMethodDropdownCreate} icon={<MethodsDropdownIcon />} placement="bottom-end" style={{ maxWidth: 'unset' }}>
<div className="max-h-96 overflow-y-auto max-w-96 min-w-60" data-testid="grpc-methods-list">
{Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => (
<div key={serviceIndex} className="service-group mb-2">
<Dropdown onCreate={onMethodDropdownCreate} icon={<MethodsDropdownIcon />} placement="bottom-end" style={{ maxWidth: 'unset' }} onShow={handleDropdownShow}>
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search"
ref={searchInputRef}
onKeyDown={handleKeyDown}
onBlur={focusSearchInput}
onChange={handleSearchChange}
className="mt-2 mb-3 "
data-testid="grpc-methods-search-input"
/>
<div ref={listRef} className="max-h-96 overflow-y-auto w-96 min-w-60" data-testid="grpc-methods-list">
{Object.entries(groupedMethods).map(([serviceName, methods], serviceIndex) => (
<div key={serviceIndex} className="service-group mb-2" onKeyDown={handleKeyDown} tabIndex={0}>
<div className="service-header px-3 py-1 bg-neutral-100 dark:bg-neutral-800 text-sm font-medium truncate sticky top-0 z-10">
{serviceName || 'Default Service'}
</div>
<div className="service-methods">
{methods.map((method, methodIndex) => (
<div
key={`${serviceIndex}-${methodIndex}`}
className={`py-2 px-3 w-full border-l-2 transition-all duration-200 relative group ${
selectedGrpcMethod && selectedGrpcMethod.path === method.path
? 'border-yellow-500 bg-yellow-500/20 dark:bg-yellow-900/20'
: 'border-transparent hover:bg-black/5 dark:hover:bg-white/5'
}`}
onClick={() => handleGrpcMethodSelect(method)}
data-testid="grpc-method-item"
>
<div className="flex items-center">
<div className="text-xs mr-3 text-gray-500">
{getIconForMethodType(method.type)}
</div>
<div className="flex flex-col flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">
{method.methodName}
{methods.map((method, methodIndex) => {
const globalMethodIndex
= Object.values(groupedMethods)
.slice(0, serviceIndex)
.reduce((acc, group) => acc + group.length, 0) + methodIndex;
return (
<div
key={`${serviceIndex}-${methodIndex}`}
className={`py-2 px-3 w-full border-l-2 transition-all duration-200 relative group ${
selectedGrpcMethod && selectedGrpcMethod.path === method.path
? 'border-yellow-500 bg-yellow-500/20 dark:bg-yellow-900/20'
: 'border-transparent hover:bg-black/5 dark:hover:bg-white/5'
} ${focusedIndex === globalMethodIndex
? 'bg-black/5 dark:bg-white/5' : ''}`}
onClick={() => handleGrpcMethodSelect(method)}
data-index={globalMethodIndex}
data-testid="grpc-method-item"
>
<div className="flex items-center">
<div className="text-xs mr-3 text-gray-500">
{getIconForMethodType(method.type)}
</div>
<div className="text-xs text-gray-500">
{method.type}
<div className="flex flex-col flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">
{method.methodName}
</div>
<div className="text-xs text-gray-500">
{method.type}
</div>
</div>
</div>
</div>
</div>
))}
);
})}
</div>
</div>
))}
{filteredMethods.length === 0 && (
<div className="py-2 px-3 w-full transition-all duration-200 relative group">
<div className="flex items-center">
<div className="text-xs mr-3 text-gray-500">
No methods found for the search term
</div>
</div>
</div>
)}
</div>
</Dropdown>
</div>

View File

@@ -346,6 +346,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
strokeWidth={1.5}
size={22}
className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`}
data-testid="refresh-methods-icon"
/>
<span className="infotip-text text-xs">
{isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
@@ -407,6 +408,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
{(!isConnectionActive || !isStreamingMethod) && (
<div
className="cursor-pointer"
data-testid="grpc-send-request-button"
onClick={(e) => {
e.stopPropagation();
handleRun(e);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
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';
@@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme';
import { IconDeviceFloppy, IconArrowRight, IconCode } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import GenerateCodeItem from 'components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index';
import toast from 'react-hot-toast';
@@ -24,6 +25,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
useEffect(() => {
const el = document.querySelector('.method-selector-container');
@@ -133,15 +135,15 @@ const QueryUrl = ({ item, collection, handleRun }) => {
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>

View File

@@ -8,7 +8,7 @@ import { humanizeRequestBodyMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections/index';
import { toastError } from 'utils/common/error';
import { prettifyJSON } from 'utils/common';
import { prettifyJsonString } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
const RequestBodyMode = ({ item, collection }) => {
@@ -39,7 +39,7 @@ const RequestBodyMode = ({ item, collection }) => {
const onPrettify = () => {
if (body?.json && bodyMode === 'json') {
try {
const prettyBodyJson = prettifyJSON(body.json);
const prettyBodyJson = prettifyJsonString(body.json);
dispatch(
updateRequestBody({
content: prettyBodyJson,

View File

@@ -10,7 +10,7 @@ import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { autoDetectLang } from 'utils/codemirror/lang-detect';
import { toastError } from 'utils/common/error';
import { prettifyJSON } from 'utils/common/index';
import { prettifyJsonString } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
import WSRequestBodyMode from '../BodyMode/index';
@@ -105,7 +105,7 @@ export const SingleWSMessage = ({
const onPrettify = () => {
if (codeType === 'json') {
try {
const prettyBodyJson = prettifyJSON(content);
const prettyBodyJson = prettifyJsonString(content);
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],

View File

@@ -5,11 +5,12 @@ import SingleLineEditor from 'components/SingleLineEditor/index';
import { requestUrlChanged } from 'providers/ReduxStore/slices/collections';
import { wsConnectOnly, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { getPropertyFromDraftOrRequest } from 'utils/collections';
import { isMacOS } from 'utils/common/platform';
import { hasRequestChanges } from 'utils/collections';
import { closeWsConnection, isWsConnectionActive } from 'utils/network/index';
import StyledWrapper from './StyledWrapper';
import get from 'lodash/get';
@@ -23,6 +24,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
const url = getPropertyFromDraftOrRequest(item, 'request.url');
const response = item.draft ? get(item, 'draft.response', {}) : get(item, 'response', {});
const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S';
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
const showConnectingPulse = isConnecting && response.status !== 'CLOSED';
@@ -108,15 +110,15 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.colors.text.yellow : theme.requestTabs.icon.color}
color={hasChanges ? theme.colors.text.yellow : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>

View File

@@ -0,0 +1,40 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
const ExampleNotFound = ({ exampleUid }) => {
const dispatch = useDispatch();
const [showErrorMessage, setShowErrorMessage] = useState(false);
const closeTab = () => {
dispatch(closeTabs({
tabUids: [exampleUid]
}));
};
useEffect(() => {
setTimeout(() => {
setShowErrorMessage(true);
}, 300);
}, []);
if (!showErrorMessage) {
return null;
}
return (
<div className="mt-6 px-6">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
<div>Response example no longer exists.</div>
<div className="mt-2">
This can occur when the example definition in your local file has been deleted or updated.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
Close Tab
</button>
</div>
);
};
export default ExampleNotFound;

View File

@@ -29,9 +29,11 @@ import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import FolderNotFound from './FolderNotFound';
import ExampleNotFound from './ExampleNotFound';
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import ResponseExample from 'components/ResponseExample';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -186,6 +188,16 @@ const RequestTabPanel = () => {
return <div className="pb-4 px-4">Collection not found!</div>;
}
if (focusedTab.type === 'response-example') {
const item = findItemInCollection(collection, focusedTab.itemUid);
const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid);
if (!example) {
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
}
return <ResponseExample item={item} collection={collection} example={example} />;
}
const item = findItemInCollection(collection, activeTabUid);
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';

View File

@@ -0,0 +1,146 @@
import React, { useState, useRef, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
import StyledWrapper from '../RequestTab/StyledWrapper';
import CloseTabIcon from '../RequestTab/CloseTabIcon';
import DraftTabIcon from '../RequestTab/DraftTabIcon';
const ExampleTab = ({ tab, collection }) => {
const dispatch = useDispatch();
const [showConfirmClose, setShowConfirmClose] = useState(false);
const dropdownTippyRef = useRef();
// Get item and example data
const item = findItemInCollection(collection, tab.itemUid);
const example = useMemo(() => item?.examples?.find((ex) => ex.uid === tab.uid), [item?.examples, tab.uid]);
const hasChanges = useMemo(() => hasExampleChanges(item, tab.uid), [item, tab.uid]);
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
dispatch(closeTabs({
tabUids: [tab.uid]
}));
};
const handleRightClick = (_event) => {
const menuDropdown = dropdownTippyRef.current;
if (!menuDropdown) {
return;
}
if (menuDropdown.state.isShown) {
menuDropdown.hide();
} else {
menuDropdown.show();
}
};
const handleMouseUp = (e) => {
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
// Close the tab
dispatch(closeTabs({
tabUids: [tab.uid]
}));
}
};
if (!item || !example) {
return (
<StyledWrapper
className="flex items-center justify-between tab-container px-1"
onMouseUp={(e) => {
if (e.button === 1) {
e.preventDefault();
e.stopPropagation();
dispatch(closeTabs({ tabUids: [tab.uid] }));
}
}}
>
<RequestTabNotFound handleCloseClick={handleCloseClick} />
</StyledWrapper>
);
}
return (
<StyledWrapper className="flex items-center justify-between tab-container px-1">
{showConfirmClose && (
<ConfirmRequestClose
item={item}
example={example}
onCancel={() => setShowConfirmClose(false)}
onCloseWithoutSave={() => {
dispatch(deleteRequestDraft({
itemUid: item.uid,
collectionUid: collection.uid
}));
dispatch(closeTabs({
tabUids: [tab.uid]
}));
setShowConfirmClose(false);
}}
onSaveAndClose={() => {
// For examples, we don't have a separate save action
// The changes are saved automatically when the request is saved
dispatch(saveRequest(item.uid, collection.uid));
dispatch(closeTabs({
tabUids: [tab.uid]
}));
setShowConfirmClose(false);
}}
/>
)}
<div
className={`flex items-center tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
if (!hasChanges) return handleMouseUp(e);
if (e.button === 1) {
e.stopPropagation();
e.preventDefault();
setShowConfirmClose(true);
}
}}
>
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
<span className="tab-name" title={example.name}>
{example.name}
</span>
</div>
<div
className="flex px-2 close-icon-container"
onClick={(e) => {
if (!hasChanges) {
return handleCloseClick(e);
}
e.stopPropagation();
e.preventDefault();
setShowConfirmClose(true);
}}
>
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
)}
</div>
</StyledWrapper>
);
};
export default ExampleTab;

View File

@@ -2,7 +2,11 @@ import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
const ConfirmRequestClose = ({ item, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
const ConfirmRequestClose = ({ item, example, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
const isExample = !!example;
const itemName = isExample ? example.name : item.name;
const itemType = isExample ? 'example' : 'request';
return (
<Modal
size="md"
@@ -24,7 +28,7 @@ const ConfirmRequestClose = ({ item, onCancel, onCloseWithoutSave, onSaveAndClos
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
</div>
<div className="font-normal mt-4">
You have unsaved changes in request <span className="font-semibold">{item.name}</span>.
You have unsaved changes in {itemType} <span className="font-semibold">{itemName}</span>.
</div>
<div className="flex justify-between mt-6">

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useState, useRef, Fragment } from 'react';
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';
@@ -7,7 +7,7 @@ import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import darkTheme from 'themes/dark';
import lightTheme from 'themes/light';
import { findItemInCollection } from 'utils/collections';
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
import ConfirmRequestClose from './ConfirmRequestClose';
import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
@@ -19,6 +19,7 @@ import CloseTabIcon from './CloseTabIcon';
import DraftTabIcon from './DraftTabIcon';
import { flattenItems } from 'utils/collections/index';
import { closeWsConnection } from 'utils/network/index';
import ExampleTab from '../ExampleTab';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch();
@@ -29,6 +30,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const item = findItemInCollection(collection, tab.uid);
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
@@ -92,7 +95,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
}
const item = findItemInCollection(collection, tab.uid);
// Handle response-example tabs specially
if (tab.type === 'response-example') {
return (
<ExampleTab
tab={tab}
collection={collection}
tabIndex={tabIndex}
collectionRequestTabs={collectionRequestTabs}
folderUid={folderUid}
/>
);
}
const getMethodText = useCallback((item) => {
if (!item) return;
@@ -109,6 +124,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}, [item]);
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
if (!item) {
return (
<StyledWrapper
@@ -172,7 +189,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e);
if (!hasChanges) return handleMouseUp(e);
if (e.button === 1) {
e.stopPropagation();
@@ -200,7 +217,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<div
className="flex px-2 close-icon-container"
onClick={(e) => {
if (!item.draft) {
if (!hasChanges) {
isWS && closeWsConnection(item.uid);
return handleCloseClick(e);
};
@@ -210,7 +227,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmClose(true);
}}
>
{!item.draft ? (
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
@@ -227,6 +244,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
const totalTabs = collectionRequestTabs.length || 0;
const currentTabUid = collectionRequestTabs[tabIndex]?.uid;
const currentTabItem = findItemInCollection(collection, currentTabUid);
const currentTabHasChanges = useMemo(() => hasRequestChanges(currentTabItem), [currentTabItem]);
const hasLeftTabs = tabIndex !== 0;
const hasRightTabs = totalTabs > tabIndex + 1;
@@ -243,7 +261,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
try {
const item = findItemInCollection(collection, tabUid);
// silently save unsaved changes before closing the tab
if (item.draft) {
if (hasRequestChanges(item)) {
await dispatch(saveRequest(item.uid, collection.uid, true));
}
@@ -251,7 +269,6 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
} catch (err) {}
}
function handleRevertChanges(event) {
event.stopPropagation();
dropdownTippyRef.current.hide();
@@ -263,12 +280,10 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
try {
const item = findItemInCollection(collection, currentTabUid);
if (item.draft) {
dispatch(
deleteRequestDraft({
itemUid: item.uid,
collectionUid: collection.uid
})
);
dispatch(deleteRequestDraft({
itemUid: item.uid,
collectionUid: collection.uid
}));
}
} catch (err) {}
}
@@ -298,7 +313,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
event.stopPropagation();
const items = flattenItems(collection?.items);
const savedTabs = items?.filter?.((item) => !item.draft);
const savedTabs = items?.filter?.((item) => !hasRequestChanges(item));
const savedTabIds = savedTabs?.map((item) => item.uid) || [];
dispatch(closeTabs({ tabUids: savedTabIds }));
}
@@ -340,7 +355,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
>
Clone Request
</button>
<button
<button
className="dropdown-item w-full"
onClick={handleRevertChanges}
disabled={!currentTabItem?.draft}

View File

@@ -0,0 +1,102 @@
import { useState, useEffect } from 'react';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
const CreateExampleModal = ({ isOpen, onClose, onSave, title = 'Create Response Example', initialName = '' }) => {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [nameError, setNameError] = useState('');
const handleNameChange = (e) => {
setName(e.target.value);
// Clear error when user starts typing
if (nameError) {
setNameError('');
}
};
const handleConfirm = () => {
if (name.trim()) {
onSave(name.trim(), description.trim());
// Reset form
setName('');
setDescription('');
setNameError('');
} else {
setNameError('Example name is required');
}
};
const handleClose = () => {
// Reset form when closing
setName('');
setDescription('');
setNameError('');
onClose();
};
useEffect(() => {
if (isOpen) {
setName(initialName);
setDescription('');
setNameError('');
}
}, [isOpen, initialName]);
if (!isOpen) {
return null;
}
return (
<Portal>
<Modal
size="md"
title={title}
handleCancel={handleClose}
handleConfirm={handleConfirm}
confirmText="Create Example"
cancelText="Cancel"
isOpen={isOpen}
>
<div className="space-y-4">
<div>
<label htmlFor="exampleName" className="block font-semibold">
Example Name<span className="text-red-600">*</span>
</label>
<input
id="exampleName"
type="text"
className="textbox mt-2 w-full"
value={name}
onChange={handleNameChange}
autoFocus
required
data-testid="create-example-name-input"
/>
{nameError && (
<div className="text-red-500 text-sm mt-1" data-testid="name-error">
{nameError}
</div>
)}
</div>
<div>
<label htmlFor="exampleDescription" className="block font-semibold">
Description
</label>
<textarea
id="exampleDescription"
className="textbox mt-2 w-full"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
data-testid="create-example-description-input"
/>
</div>
</div>
</Modal>
</Portal>
);
};
export default CreateExampleModal;

View File

@@ -0,0 +1,80 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
.title {
font-weight: 700;
color: ${(props) => props.theme.text};
}
font-size: 0.8125rem;
.body-mode-selector {
background: transparent;
border-radius: 3px;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
padding-left: 1.5rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
&.cursor-default {
opacity: 0.6;
.selected-body-mode {
color: ${(props) => props.theme.colors.text.muted};
}
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
.btn-action {
border-radius: 3px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.no-body-text {
color: ${(props) => props.theme.colors.text.muted};
}
/* CodeEditor container */
.code-editor-container {
flex: 1;
min-height: 200px;
height: 200px;
border-top: none;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,80 @@
import React, { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { updateResponseExampleRequest } from 'providers/ReduxStore/slices/collections';
import ResponseExampleBodyMode from '../ResponseExampleBodyMode';
import ResponseExampleBodyRenderer from '../ResponseExampleBodyRenderer';
import StyledWrapper from './StyledWrapper';
const ResponseExampleBody = ({ editMode, item, collection, exampleUid, onSave }) => {
const dispatch = useDispatch();
const body = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body || { mode: 'none' }
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body || { mode: 'none' };
}, [item, exampleUid]);
const onBodyEdit = (value) => {
if (editMode && item && collection.uid && exampleUid) {
const updatedBody = { ...body };
switch (body.mode) {
case 'json':
updatedBody.json = value;
break;
case 'text':
updatedBody.text = value;
break;
case 'xml':
updatedBody.xml = value;
break;
case 'sparql':
updatedBody.sparql = value;
break;
default:
break;
}
dispatch(updateResponseExampleRequest({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
request: {
body: updatedBody
}
}));
}
};
return (
<StyledWrapper className="w-full mt-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="title text-xs mr-2">Body</div>
</div>
<ResponseExampleBodyMode
item={item}
collection={collection}
exampleUid={exampleUid}
body={body}
bodyMode={body.mode}
onBodyEdit={onBodyEdit}
editMode={editMode}
/>
</div>
<ResponseExampleBodyRenderer
bodyMode={body.mode}
body={body}
editMode={editMode}
item={item}
collection={collection}
exampleUid={exampleUid}
onBodyEdit={onBodyEdit}
onSave={onSave}
/>
</StyledWrapper>
);
};
export default ResponseExampleBody;

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { updateResponseExampleRequest } from 'providers/ReduxStore/slices/collections';
import BodyModeSelector from 'components/BodyModeSelector';
import { format, applyEdits } from 'jsonc-parser';
import xmlFormat from 'xml-formatter';
import { toastError } from 'utils/common/error';
const ResponseExampleBodyMode = ({ item, collection, exampleUid, body, bodyMode, onBodyEdit, editMode = false }) => {
const dispatch = useDispatch();
const onModeChange = (value) => {
if (item && collection && exampleUid) {
// Initialize the new body structure based on the selected mode
let newBody = { mode: value };
// Preserve existing data for the new mode if it exists
if (body) {
switch (value) {
case 'json':
newBody.json = body.json || '';
break;
case 'text':
newBody.text = body.text || '';
break;
case 'xml':
newBody.xml = body.xml || '';
break;
case 'sparql':
newBody.sparql = body.sparql || '';
break;
case 'formUrlEncoded':
newBody.formUrlEncoded = body.formUrlEncoded || [];
break;
case 'multipartForm':
newBody.multipartForm = body.multipartForm || [];
break;
case 'file':
newBody.file = body.file || { name: '', data: '' };
break;
case 'none':
// No additional data needed for 'none' mode
break;
default:
break;
}
}
dispatch(updateResponseExampleRequest({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
request: {
body: newBody
}
}));
}
};
const onPrettify = () => {
if (body?.json && bodyMode === 'json') {
try {
const edits = format(body.json, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(body.json, edits);
onBodyEdit(prettyBodyJson);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
} else if (body?.xml && bodyMode === 'xml') {
try {
const prettyBodyXML = xmlFormat(body.xml, { collapseContent: true });
onBodyEdit(prettyBodyXML);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid XML format.'));
}
}
};
return (
<div className="flex items-center">
{['json', 'xml'].includes(bodyMode) && (
<button
className="btn-action text-link mr-2 py-1 px-2 text-xs"
onClick={onPrettify}
>
Prettify
</button>
)}
<BodyModeSelector
currentMode={bodyMode}
onModeChange={onModeChange}
disabled={!editMode}
/>
</div>
);
};
export default ResponseExampleBodyMode;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import get from 'lodash/get';
import CodeEditor from 'components/CodeEditor';
import ResponseExampleFormUrlEncodedParams from '../ResponseExampleFormUrlEncodedParams';
import ResponseExampleMultipartFormParams from '../ResponseExampleMultipartFormParams';
import ResponseExampleFileBody from '../ResponseExampleFileBody';
const ResponseExampleBodyRenderer = ({
bodyMode,
body,
editMode,
item,
collection,
exampleUid,
onBodyEdit,
onSave
}) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const getBodyContent = () => {
if (!body) return '';
switch (bodyMode) {
case 'json':
return body.json || '';
case 'text':
return body.text || '';
case 'xml':
return body.xml || '';
case 'sparql':
return body.sparql || '';
default:
return '';
}
};
const getCodeMirrorMode = () => {
const modeMap = {
json: 'application/ld+json',
text: 'application/text',
xml: 'application/xml',
sparql: 'application/sparql-query'
};
return modeMap[bodyMode] || 'application/text';
};
const renderBodyContent = () => {
switch (bodyMode) {
case 'none':
return (
<div className="text-sm no-body-text">
No Body
</div>
);
case 'json':
case 'xml':
case 'text':
case 'sparql':
return (
<div className="min-h-96">
<CodeEditor
collection={collection}
item={item}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={getBodyContent()}
onEdit={onBodyEdit}
onRun={() => {}}
onSave={onSave}
mode={getCodeMirrorMode()}
enableVariableHighlighting={true}
showHintsFor={['variables']}
readOnly={!editMode}
/>
</div>
);
case 'formUrlEncoded':
return <ResponseExampleFormUrlEncodedParams item={item} collection={collection} exampleUid={exampleUid} editMode={editMode} />;
case 'multipartForm':
return <ResponseExampleMultipartFormParams item={item} collection={collection} exampleUid={exampleUid} editMode={editMode} />;
case 'file':
return <ResponseExampleFileBody item={item} collection={collection} exampleUid={exampleUid} editMode={editMode} />;
default:
return (
<div className="text-sm no-body-text">
No Body
</div>
);
}
};
return renderBodyContent();
};
export default ResponseExampleBodyRenderer;

View File

@@ -0,0 +1,38 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
textarea {
background-color: transparent;
color: ${(props) => props.theme.text};
font-family: inherit;
font-size: 14px;
line-height: 1.5;
border: 1px solid transparent;
padding: 0;
&:not([readonly]) {
border: 1px solid ${(props) => props.theme.input.border};
padding: 8px;
}
&:focus {
outline: none;
box-shadow: none;
border: 1px solid ${(props) => props.theme.examples.urlBar.border};
}
&:disabled {
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: not-allowed;
box-shadow: none;
}
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,48 @@
import React, { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import get from 'lodash/get';
import { updateResponseExampleDetails } from 'providers/ReduxStore/slices/collections';
import StyledWrapper from './StyledWrapper';
const ResponseExampleDescription = ({ editMode, item, collection, exampleUid }) => {
const dispatch = useDispatch();
const description = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.description || ''
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.description || '';
}, [item, exampleUid]);
const handleChange = (e) => {
const newValue = e.target.value;
if (editMode && item && collection && exampleUid) {
dispatch(updateResponseExampleDetails({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
details: {
description: newValue
}
}));
}
};
return (
<StyledWrapper className="w-full">
<div className="mb-2">
<textarea
data-testid="response-example-description-input"
value={description}
onChange={handleChange}
readOnly={!editMode}
placeholder="Enter example description..."
className="w-full p-3 border rounded-md"
rows={1}
/>
</div>
</StyledWrapper>
);
};
export default ResponseExampleDescription;

View File

@@ -0,0 +1,131 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
background: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
.btn-action {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-secondary {
&.edit-mode {
background-color: ${(props) => props.theme.colors.text.yellow}20;
border-color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.colors.text.yellow};
}
&.view-mode {
background-color: transparent;
border-color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.colors.text.muted};
cursor: default;
}
/* Fix alignment for file picker content */
display: flex;
align-items: center;
justify-content: center;
button {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
margin-right: 4px;
}
}
tr {
position: relative;
&:hover .delete-button.edit-mode {
opacity: 1;
visibility: visible;
}
}
.delete-button {
opacity: 0;
visibility: hidden;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
&:hover {
color: ${(props) => props.theme.colors.text.red};
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
svg {
width: 16px;
height: 16px;
color: ${(props) => props.theme.text};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,222 @@
import React, { useState, useMemo } from 'react';
import { get, cloneDeep } from 'lodash';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateResponseExampleFileBodyParams } from 'providers/ReduxStore/slices/collections';
import mime from 'mime-types';
import path from 'utils/common/path';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor/index';
import SingleLineEditor from 'components/SingleLineEditor/index';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import RadioButton from 'components/RadioButton';
const ResponseExampleFileBody = ({ item, collection, exampleUid, editMode = false }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
// Get file data from the specific example
const params = useMemo(() => {
const _params = item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body?.file || []
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.file || [];
return Array.isArray(_params) ? _params : [];
}, [item.draft, item.examples, item, exampleUid]);
const [enabledFileUid, setEnableFileUid] = useState(params.length > 0 ? params[0].uid : '');
const addFile = () => {
const newParam = {
filePath: '',
contentType: '',
selected: true
};
const updatedParams = [...params, newParam];
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamChange = (e, _param, type) => {
if (!editMode) return;
const param = cloneDeep(_param);
switch (type) {
case 'filePath': {
param.filePath = e.target.filePath;
// Auto-detect content type from file extension using mime library (same as updateFile)
const contentType = mime.contentType(path.extname(e.target.filePath));
param.contentType = contentType || '';
break;
}
case 'contentType': {
param.contentType = e.target.contentType;
break;
}
case 'selected': {
// When a file is selected, deselect all others and select this one
const updatedParams = params.map((p) => ({
...p,
selected: p.uid === param.uid ? e.target.checked : false
}));
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
// Update the enabled file UID state
if (e.target.checked) {
setEnableFileUid(param.uid);
}
return; // Early return since we already dispatched
}
}
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleRemoveParams = (param) => {
if (!editMode) return;
const updatedParams = params.filter((p) => p.uid !== param.uid);
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamDrag = ({ updateReorderedItem }) => {
if (!editMode) return;
const reorderedParams = updateReorderedItem.map((uid) => {
return params.find((p) => p.uid === uid);
});
dispatch(updateResponseExampleFileBodyParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: reorderedParams
}));
};
if (params.length === 0 && !editMode) {
return null;
}
return (
<StyledWrapper className="w-full mt-4">
<Table
headers={[
{ name: 'File', accessor: 'file', width: '50%' },
{ name: 'Content-Type', accessor: 'contentType', width: '30%' },
{ name: 'Selected', accessor: 'selected', width: '20%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<FilePickerEditor
isSingleFilePicker={true}
value={param.filePath}
onChange={editMode ? (path) =>
handleParamChange({
target: {
filePath: path
}
},
param,
'filePath') : () => {}}
collection={collection}
readOnly={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
className="flex items-center justify-center"
onSave={() => {}}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={editMode ? (newValue) =>
handleParamChange({
target: {
contentType: newValue
}
},
param,
'contentType') : () => {}}
onRun={() => {}}
collection={collection}
/>
</div>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<RadioButton
key={param.uid}
id={`file-${param.uid}`}
name="selectedFile"
value={param.uid}
checked={enabledFileUid === param.uid || param.selected}
onChange={editMode ? (e) => handleParamChange(e, param, 'selected') : () => {}}
disabled={!editMode}
className="mr-1 mousetrap"
dataTestId={`file-radio-button-${index}`}
/>
<button
tabIndex="-1"
onClick={() => handleRemoveParams(param)}
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
disabled={!editMode}
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action pr-2 py-3 select-none"
onClick={addFile}
>
+ Add File
</button>
</div>
)}
</StyledWrapper>
);
};
export default ResponseExampleFileBody;

View File

@@ -0,0 +1,80 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
background: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
tr {
position: relative;
&:hover .delete-button.edit-mode {
opacity: 1;
visibility: visible;
}
}
.delete-button {
opacity: 0;
visibility: hidden;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
&:hover {
color: ${(props) => props.theme.colors.text.red};
}
svg {
width: 16px;
height: 16px;
color: ${(props) => props.theme.text};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,180 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateResponseExampleFormUrlEncodedParams } from 'providers/ReduxStore/slices/collections';
import MultiLineEditor from 'components/MultiLineEditor';
import StyledWrapper from './StyledWrapper';
import ReorderTable from 'components/ReorderTable/index';
import Table from 'components/Table-v2';
import Checkbox from 'components/Checkbox';
const ResponseExampleFormUrlEncodedParams = ({ item, collection, exampleUid, editMode = false }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body?.formUrlEncoded || []
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.formUrlEncoded || [];
}, [item, exampleUid]);
const addParam = () => {
const newParam = {
name: '',
value: '',
enabled: true
};
const updatedParams = [...params, newParam];
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamChange = (e, _param, type) => {
if (!editMode) return;
const param = cloneDeep(_param);
switch (type) {
case 'name': {
param.name = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
break;
}
}
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleRemoveParams = (param) => {
const updatedParams = params.filter((p) => p.uid !== param.uid);
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamDrag = ({ updateReorderedItem }) => {
const updatedParams = updateReorderedItem(params);
dispatch(updateResponseExampleFormUrlEncodedParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
if (params.length === 0 && !editMode) {
return null;
}
return (
<StyledWrapper className="w-full mt-4">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={param.enabled === true}
disabled={!editMode}
onChange={(e) => handleParamChange(e, param, 'enabled')}
dataTestId={`urlencoded-param-checkbox-${index}`}
/>
</div>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={editMode ? (e) => handleParamChange(e, param, 'name') : () => {}}
disabled={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<MultiLineEditor
value={param.value}
theme={storedTheme}
onSave={() => {}}
onChange={editMode ? (newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'value') : () => {}}
allowNewlines={true}
onRun={() => {}}
collection={collection}
item={item}
/>
<button
tabIndex="-1"
onClick={() => handleRemoveParams(param)}
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
disabled={!editMode}
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={addParam}
>
+ Add Param
</button>
</div>
)}
</StyledWrapper>
);
};
export default ResponseExampleFormUrlEncodedParams;

View File

@@ -0,0 +1,60 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.title {
color: ${(props) => props.theme.text};
}
.btn-action {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: opacity 0.2s ease;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
tr {
position: relative;
&:hover .delete-button {
opacity: 1;
visibility: visible;
}
}
.delete-button {
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
&:hover {
color: ${(props) => props.theme.colors.text.red};
}
svg {
width: 16px;
height: 16px;
color: ${(props) => props.theme.text};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,213 @@
import React, { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconTrash } from '@tabler/icons';
import get from 'lodash/get';
import { addResponseExampleRequestHeader, updateResponseExampleRequestHeader, deleteResponseExampleRequestHeader, moveResponseExampleRequestHeader, setResponseExampleRequestHeaders } from 'providers/ReduxStore/slices/collections';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
import Checkbox from 'components/Checkbox';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import StyledWrapper from './StyledWrapper';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const ResponseExampleHeaders = ({ editMode, item, collection, exampleUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const headers = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.headers || []
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.headers || [];
}, [item, exampleUid]);
const handleAddHeader = () => {
if (editMode) {
dispatch(addResponseExampleRequestHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid
}));
}
};
const handleHeaderValueChange = (e, header, type) => {
if (editMode) {
const updatedHeader = { ...header };
switch (type) {
case 'name': {
updatedHeader.name = e.target.value;
break;
}
case 'value': {
updatedHeader.value = e.target.value;
break;
}
case 'enabled': {
updatedHeader.enabled = e.target.checked;
break;
}
}
dispatch(updateResponseExampleRequestHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
header: updatedHeader
}));
}
};
const handleRemoveHeader = (header) => {
if (editMode) {
dispatch(deleteResponseExampleRequestHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
headerUid: header.uid
}));
}
};
const handleHeaderDrag = ({ updateReorderedItem }) => {
if (editMode) {
dispatch(moveResponseExampleRequestHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
updateReorderedItem
}));
}
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
if (editMode) {
dispatch(setResponseExampleRequestHeaders({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
headers: newHeaders
}));
}
};
if (isBulkEditMode && editMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
/>
</StyledWrapper>
);
}
if (headers.length === 0 && !editMode) {
return null;
}
return (
<StyledWrapper className="w-full mt-4">
<div className="mb-1 title text-xs font-bold">Headers</div>
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleHeaderDrag}>
{headers && headers.length
? headers.map((header, index) => (
<tr key={header.uid} data-uid={header.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={header.enabled === true}
disabled={!editMode}
onChange={(e) => handleHeaderValueChange(e, header, 'enabled')}
dataTestId={`header-checkbox-${index}`}
/>
</div>
<SingleLineEditor
value={header.name || ''}
readOnly={!editMode}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'name')}
autocomplete={headerAutoCompleteList}
onRun={() => {}}
collection={collection}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
value={header.value || ''}
readOnly={!editMode}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'value')}
onRun={() => {}}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
/>
{editMode && (
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)} className="delete-button">
<IconTrash strokeWidth={1.5} size={16} />
</button>
)}
</div>
</td>
</tr>
))
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={handleAddHeader}
>
+ Add Header
</button>
<button
className="btn-action text-link select-none"
onClick={toggleBulkEditMode}
>
Bulk Edit
</button>
</div>
)}
</StyledWrapper>
);
};
export default ResponseExampleHeaders;

View File

@@ -0,0 +1,100 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 600;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: 0.8125rem;
user-select: none;
}
td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: 0.8125rem;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
color: ${(props) => props.theme.table.input.color};
background: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
position: relative;
top: 1px;
}
.btn-secondary {
&.edit-mode {
background-color: ${(props) => props.theme.colors.text.yellow}20;
border-color: ${(props) => props.theme.colors.text.yellow};
color: ${(props) => props.theme.colors.text.yellow};
}
&.view-mode {
background-color: transparent;
border-color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.colors.text.muted};
cursor: default;
}
}
tr {
position: relative;
&:hover .delete-button.edit-mode {
opacity: 1;
visibility: visible;
}
}
.delete-button {
opacity: 0;
visibility: hidden;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
&:hover {
color: ${(props) => props.theme.colors.text.red};
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
svg {
width: 16px;
height: 16px;
color: ${(props) => props.theme.text};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,263 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
import mime from 'mime-types';
import path from 'utils/common/path';
import MultiLineEditor from 'components/MultiLineEditor';
import StyledWrapper from './StyledWrapper';
import FilePickerEditor from 'components/FilePickerEditor';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable/index';
import Checkbox from 'components/Checkbox';
const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const params = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.body?.multipartForm || []
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.body?.multipartForm || [];
}, [item, exampleUid]);
const addParam = () => {
const newParam = {
name: '',
value: '',
contentType: '',
enabled: true,
type: 'text'
};
const updatedParams = [...params, newParam];
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const addFile = () => {
const newParam = {
name: '',
value: [],
contentType: '',
enabled: true,
type: 'file'
};
const updatedParams = [...params, newParam];
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamChange = (e, _param, type) => {
if (!editMode) return;
const param = cloneDeep(_param);
switch (type) {
case 'name': {
param.name = e.target.value;
break;
}
case 'value': {
param.value = e.target.value;
if (param.type === 'file' && e.target.value) {
const contentType = mime.contentType(path.extname(e.target.value));
param.contentType = contentType || '';
}
break;
}
case 'contentType': {
param.contentType = e.target.value;
break;
}
case 'enabled': {
param.enabled = e.target.checked;
break;
}
}
const updatedParams = params.map((p) => p.uid === param.uid ? param : p);
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleRemoveParams = (param) => {
if (!editMode) return;
const updatedParams = params.filter((p) => p.uid !== param.uid);
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: updatedParams
}));
};
const handleParamDrag = ({ updateReorderedItem }) => {
if (!editMode) return;
const reorderedParams = updateReorderedItem.map((uid) => {
return params.find((p) => p.uid === uid);
});
dispatch(updateResponseExampleMultipartFormParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: reorderedParams
}));
};
if (params.length === 0 && !editMode) {
return null;
}
return (
<StyledWrapper className="w-full mt-4">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '30%' },
{ name: 'Value', accessor: 'value', width: '40%' },
{ name: 'Content-Type', accessor: 'content-type', width: '30%' }
]}
>
<ReorderTable updateReorderedItem={handleParamDrag}>
{params && params.length
? params.map((param, index) => {
return (
<tr key={param.uid} className="w-full" data-uid={param.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={param.enabled === true}
disabled={!editMode}
onChange={(e) => handleParamChange(e, param, 'enabled')}
dataTestId={`multipart-form-param-checkbox-${index}`}
/>
</div>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={param.name}
className="mousetrap"
onChange={(e) => handleParamChange(e, param, 'name')}
disabled={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
{param.type === 'file' ? (
<FilePickerEditor
value={param.value}
onChange={(newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'value')}
collection={collection}
readOnly={!editMode}
/>
) : (
<MultiLineEditor
onSave={() => {}}
theme={storedTheme}
value={param.value}
onChange={(newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'value')}
onRun={() => {}}
allowNewlines={true}
collection={collection}
item={item}
readOnly={!editMode}
/>
)}
</div>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<MultiLineEditor
onSave={() => {}}
theme={storedTheme}
placeholder="Auto"
value={param.contentType}
onChange={(newValue) =>
handleParamChange({
target: {
value: newValue
}
},
param,
'contentType')}
onRun={() => {}}
collection={collection}
readOnly={!editMode}
/>
<button
tabIndex="-1"
onClick={() => handleRemoveParams(param)}
className={`delete-button ${editMode ? 'edit-mode' : ''}`}
disabled={!editMode}
>
<IconTrash strokeWidth={1.5} size={16} />
</button>
</div>
</td>
</tr>
);
})
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={addParam}
>
+ Add Param
</button>
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={addFile}
>
+ Add File
</button>
</div>
)}
</StyledWrapper>
);
};
export default ResponseExampleMultipartFormParams;

View File

@@ -0,0 +1,89 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.title {
font-weight: 700;
color: ${(props) => props.theme.text};
}
.btn-action {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: opacity 0.2s ease;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
table {
border-collapse: collapse;
width: 100%;
thead {
td {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 0;
border-bottom: 1px solid ${(props) => props.theme.table.border};
}
}
tbody {
tr {
border-bottom: 1px solid ${(props) => props.theme.table.border};
&:hover {
background: ${(props) => props.theme.plainGrid.hoverBg};
}
}
}
}
tr {
position: relative;
&:hover .delete-button {
opacity: 1;
visibility: visible;
}
}
.delete-button {
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
&:hover {
color: ${(props) => props.theme.colors.text.red};
}
svg {
width: 16px;
height: 16px;
color: ${(props) => props.theme.text};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,272 @@
import React, { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { IconTrash } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import get from 'lodash/get';
import { addResponseExampleParam, updateResponseExampleParam, deleteResponseExampleParam, moveResponseExampleParam, setResponseExampleParams } from 'providers/ReduxStore/slices/collections';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
import Checkbox from 'components/Checkbox';
import InfoTip from 'components/InfoTip';
import StyledWrapper from './StyledWrapper';
const ResponseExampleParams = ({ editMode, item, collection, exampleUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const params = useMemo(() => {
return item.draft
? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.request?.params || []
: get(item, 'examples', []).find((e) => e.uid === exampleUid)?.request?.params || [];
}, [item, exampleUid]);
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
const handleAddQueryParam = () => {
if (!editMode) {
return;
}
dispatch(addResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid
}));
};
const handleQueryParamChange = (e, data, key) => {
if (!editMode) {
return;
}
const updatedParam = { ...data };
switch (key) {
case 'name': {
updatedParam.name = e.target.value;
break;
}
case 'value': {
updatedParam.value = e.target.value;
break;
}
case 'enabled': {
updatedParam.enabled = e.target.checked;
break;
}
}
dispatch(updateResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
param: updatedParam
}));
};
const handleRemoveQueryParam = (param) => {
if (!editMode) {
return;
}
dispatch(deleteResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
paramUid: param.uid
}));
};
const handleQueryParamDrag = ({ updateReorderedItem }) => {
if (!editMode) {
return;
}
dispatch(moveResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
updateReorderedItem
}));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkParamsChange = (newParams) => {
if (!editMode) {
return;
}
dispatch(setResponseExampleParams({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
params: newParams
}));
};
const handlePathParamChange = (e, data) => {
if (!editMode) {
return;
}
const updatedParam = { ...data };
updatedParam.value = e.target.value;
dispatch(updateResponseExampleParam({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
param: updatedParam
}));
};
if (isBulkEditMode && editMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={queryParams}
onChange={handleBulkParamsChange}
onToggle={toggleBulkEditMode}
/>
</StyledWrapper>
);
}
if (queryParams.length === 0 && pathParams.length === 0 && !editMode) {
return null;
}
return (
<StyledWrapper className="w-full mt-4">
<div className="mb-1 title text-xs font-bold">Query parameters</div>
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleQueryParamDrag}>
{queryParams && queryParams.length
? queryParams.map((param, index) => (
<tr key={param.uid} data-uid={param.uid}>
<td className="flex relative">
<div className="flex items-center justify-center mr-3">
<Checkbox
checked={param.enabled !== false}
disabled={!editMode}
onChange={(e) => handleQueryParamChange(e, param, 'enabled')}
dataTestId={`query-param-checkbox-${index}`}
/>
</div>
<SingleLineEditor
value={param.name || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'name')}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
value={param.value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => handleQueryParamChange({ target: { value: newValue } }, param, 'value')}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
/>
{editMode && (
<button tabIndex="-1" onClick={() => handleRemoveQueryParam(param)} className="delete-button">
<IconTrash strokeWidth={1.5} size={16} />
</button>
)}
</div>
</td>
</tr>
))
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={handleAddQueryParam}
>
+ Add Param
</button>
<button
className="btn-action text-link select-none"
onClick={toggleBulkEditMode}
>
Bulk Edit
</button>
</div>
)}
{pathParams && pathParams.length > 0 && (
<>
<div className="mb-1 title text-xs font-bold flex items-stretch mt-4">
<span>Path parameters</span>
<InfoTip infotipId="path-param-InfoTip">
<div>
Path variables are automatically added whenever the
<code className="font-mono mx-2">:name</code>
template is used in the URL. <br /> For example:
<code className="font-mono mx-2">
https://example.com/v1/users/<span>:id</span>
</code>
</div>
</InfoTip>
</div>
<Table
headers={[
{ name: 'Name', accessor: 'name', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
{pathParams && pathParams.length
? pathParams.map((path, index) => {
return (
<tr key={index} data-uid={path.uid}>
<td>
{path.name}
</td>
<td>
<SingleLineEditor
value={path.value}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) => handlePathParamChange({ target: { value: newValue } }, path)}
onRun={() => {}}
collection={collection}
variablesAutocomplete={true}
readOnly={!editMode}
/>
</td>
</tr>
);
})
: null}
</Table>
{pathParams.length === 0 && <div className="title pr-2 py-3 mt-2 text-xs">No path parameters defined</div>}
</>
)}
</StyledWrapper>
);
};
export default ResponseExampleParams;

View File

@@ -0,0 +1,53 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.url-bar-container {
border: 1px solid ${(props) => props.theme.examples.urlBar.border};
}
.method {
color: #fff;
}
.method-get {
background-color: ${(props) => props.theme.request.methods.get};
}
.method-post {
background-color: ${(props) => props.theme.request.methods.post};
}
.method-put {
background-color: ${(props) => props.theme.request.methods.put};
}
.method-delete {
background-color: ${(props) => props.theme.request.methods.delete};
}
.method-patch {
background-color: ${(props) => props.theme.request.methods.patch};
}
.method-options {
background-color: ${(props) => props.theme.request.methods.options};
}
.method-head {
background-color: ${(props) => props.theme.request.methods.head};
}
.method-trace {
background-color: ${(props) => props.theme.request.methods.options};
}
.method-connect {
background-color: ${(props) => props.theme.request.methods.options};
}
.method-custom {
background-color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,81 @@
import React, { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { updateResponseExampleRequestUrl } from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import get from 'lodash/get';
const ResponseExampleUrlBar = ({ item, collection, editMode, onSave, exampleUid }) => {
const dispatch = useDispatch();
const exampleData = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) : get(item, 'examples', []).find((e) => e.uid === exampleUid);
}, [item, exampleUid]);
const method = get(exampleData, 'request.method');
const url = get(exampleData, 'request.url');
const onChange = (value) => {
if (!editMode) {
return;
}
dispatch(updateResponseExampleRequestUrl({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
request: { url: value }
}));
};
const getMethodClass = () => {
switch (method?.toUpperCase()) {
case 'GET':
return 'method-get';
case 'POST':
return 'method-post';
case 'PUT':
return 'method-put';
case 'DELETE':
return 'method-delete';
case 'PATCH':
return 'method-patch';
case 'OPTIONS':
return 'method-options';
case 'HEAD':
return 'method-head';
case 'OPTIONS':
return 'method-options';
case 'HEAD':
return 'method-head';
default:
return 'method-get';
};
};
return (
<StyledWrapper className="flex items-center">
<div className="url-bar-container w-full flex p-2 text-xs rounded-md items-center justify-between" data-testid="url-bar-container">
<div className={`method flex text-xs items-center justify-center px-2 rounded h-6 flex-shrink-0 mr-2 overflow-hidden whitespace-nowrap font-semibold uppercase ${getMethodClass()}`}>
{method || 'GET'}
</div>
<div
id="response-example-url"
className="response-example-url flex items-center flex-1 h-6"
>
<SingleLineEditor
value={url}
onSave={onSave}
onChange={onChange}
collection={collection}
highlightPathParams={true}
item={item}
readOnly={!editMode}
/>
</div>
</div>
</StyledWrapper>
);
};
export default ResponseExampleUrlBar;

View File

@@ -0,0 +1,32 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: 0.8125rem;
height: 300px;
.body-mode-selector {
background: transparent;
border-radius: 3px;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
padding-left: 1.5rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,47 @@
import React from 'react';
import ResponseExampleUrlBar from './ResponseExampleUrlBar';
import ResponseExampleParams from './ResponseExampleParams';
import ResponseExampleHeaders from './ResponseExampleHeaders';
import ResponseExampleBody from './ResponseExampleBody';
import StyledWrapper from './StyledWrapper';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const ResponseExampleRequestPane = ({ item, collection, editMode, exampleUid, onSave }) => {
return (
<HeightBoundContainer>
<StyledWrapper className="flex flex-col h-full w-full">
<ResponseExampleUrlBar
item={item}
collection={collection}
exampleUid={exampleUid}
editMode={editMode}
onSave={onSave}
/>
<ResponseExampleParams
editMode={editMode}
item={item}
collection={collection}
exampleUid={exampleUid}
/>
<ResponseExampleHeaders
editMode={editMode}
item={item}
collection={collection}
exampleUid={exampleUid}
/>
<ResponseExampleBody
editMode={editMode}
item={item}
collection={collection}
exampleUid={exampleUid}
onSave={onSave}
/>
</StyledWrapper>
</HeightBoundContainer>
);
};
export default ResponseExampleRequestPane;

View File

@@ -0,0 +1,16 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
/* CodeEditor container */
.code-editor-container {
flex: 1;
min-height: 300px;
height: 300px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,94 @@
import React, { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import get from 'lodash/get';
import { updateResponseExampleResponse } from 'providers/ReduxStore/slices/collections';
import CodeEditor from 'components/CodeEditor';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
const ResponseExampleResponseContent = ({ editMode, item, collection, exampleUid, onSave }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const response = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response || {};
}, [item, exampleUid]);
const getResponseContent = () => {
if (!response) {
return '';
}
if (!response.body) {
return '';
}
return response.body.content;
};
const getCodeMirrorMode = () => {
if (!response) {
return null;
}
if (response.body && response.body.type) {
const bodyType = response.body.type;
if (bodyType === 'json') {
return 'application/ld+json';
} else if (bodyType === 'xml') {
return 'application/xml';
} else if (bodyType === 'html') {
return 'application/html';
} else if (bodyType === 'text') {
return 'application/text';
}
}
const contentType = response.headers?.find((h) => h.name?.toLowerCase() === 'content-type')?.value?.toLowerCase() || '';
return getCodeMirrorModeBasedOnContentType(contentType);
};
const onResponseEdit = (value) => {
if (editMode && item && collection && exampleUid) {
const currentBody = response.body || {};
dispatch(updateResponseExampleResponse({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
response: {
body: {
type: currentBody.type || 'text',
content: value
}
}
}));
}
};
return (
<StyledWrapper className="w-full px-4">
<div className="code-editor-container">
<CodeEditor
collection={collection}
item={item}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={getResponseContent()}
onEdit={onResponseEdit}
onRun={() => {}}
onSave={onSave}
mode={getCodeMirrorMode()}
enableVariableHighlighting={false}
readOnly={!editMode}
/>
</div>
</StyledWrapper>
);
};
export default ResponseExampleResponseContent;

View File

@@ -0,0 +1,56 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.btn-action {
background: none;
color: ${(props) => props.theme.colors.text.muted};
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: opacity 0.2s ease;
&:hover {
opacity: 0.8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
tr {
position: relative;
&:hover .delete-button {
opacity: 1;
visibility: visible;
}
}
.delete-button {
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
&:hover {
color: ${(props) => props.theme.colors.text.red};
}
svg {
width: 16px;
height: 16px;
color: ${(props) => props.theme.text};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,240 @@
import React, { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconTrash } from '@tabler/icons';
import get from 'lodash/get';
import { addResponseExampleHeader, updateResponseExampleHeader, deleteResponseExampleHeader, moveResponseExampleHeader, setResponseExampleHeaders, updateResponseExampleResponse } from 'providers/ReduxStore/slices/collections';
import { getBodyType } from 'utils/responseBodyProcessor';
import Table from 'components/Table-v2';
import ReorderTable from 'components/ReorderTable';
import SingleLineEditor from 'components/SingleLineEditor';
import BulkEditor from 'components/BulkEditor';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import StyledWrapper from './StyledWrapper';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const ResponseExampleResponseHeaders = ({ editMode, item, collection, exampleUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const headers = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response?.headers || [] : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response?.headers || [];
}, [item, exampleUid]);
const response = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid)?.response || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid)?.response || {};
}, [item, exampleUid]);
const handleAddHeader = () => {
if (!editMode) {
return;
}
dispatch(addResponseExampleHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid
}));
};
const handleHeaderValueChange = (e, header, type) => {
if (!editMode) {
return;
}
const updatedHeader = { ...header };
switch (type) {
case 'name': {
updatedHeader.name = e.target.value;
break;
}
case 'value': {
updatedHeader.value = e.target.value;
break;
}
}
dispatch(updateResponseExampleHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
header: updatedHeader
}));
// If content-type header is being updated, automatically update the body type
if (header.name?.toLowerCase() === 'content-type' && type === 'value') {
const newContentType = updatedHeader.value?.toLowerCase() || '';
const newBodyType = getBodyType(newContentType);
const currentBodyType = response.body?.type || 'text';
// Only update if the body type has changed
if (newBodyType !== currentBodyType) {
dispatch(updateResponseExampleResponse({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
response: {
body: {
type: newBodyType,
content: response.body?.content || ''
}
}
}));
}
}
};
const handleRemoveHeader = (header) => {
if (!editMode) {
return;
}
dispatch(deleteResponseExampleHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
headerUid: header.uid
}));
};
const handleHeaderDrag = ({ updateReorderedItem }) => {
if (!editMode) {
return;
}
dispatch(moveResponseExampleHeader({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
updateReorderedItem
}));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
if (!editMode) {
return;
}
const cleanedHeaders = newHeaders.map((header) => ({
uid: header.uid,
name: header.name,
value: header.value
}));
dispatch(setResponseExampleHeaders({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
headers: cleanedHeaders
}));
};
if (isBulkEditMode && editMode) {
// Ensure all headers have enabled: true for bulk edit display
const headersForBulkEdit = headers.map((header) => ({
...header,
enabled: true
}));
return (
<StyledWrapper className="w-full overflow-auto">
<BulkEditor
params={headersForBulkEdit}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full px-4">
<Table
headers={[
{ name: 'Key', accessor: 'key', width: '40%' },
{ name: 'Value', accessor: 'value', width: '60%' }
]}
>
<ReorderTable updateReorderedItem={handleHeaderDrag}>
{headers && headers.length
? headers.map((header) => (
<tr key={header.uid} data-uid={header.uid}>
<td className="flex relative">
<SingleLineEditor
value={header.name || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'name')}
autocomplete={headerAutoCompleteList}
onRun={() => {}}
collection={collection}
readOnly={!editMode}
/>
</td>
<td>
<div className="flex items-center justify-center pl-4">
<SingleLineEditor
value={header.value || ''}
theme={storedTheme}
onSave={() => {}}
onChange={(newValue) =>
handleHeaderValueChange({
target: {
value: newValue
}
},
header,
'value')}
onRun={() => {}}
autocomplete={MimeTypes}
allowNewlines={true}
collection={collection}
item={item}
readOnly={!editMode}
/>
{editMode && (
<button tabIndex="-1" onClick={() => handleRemoveHeader(header)} className="delete-button">
<IconTrash strokeWidth={1.5} size={16} />
</button>
)}
</div>
</td>
</tr>
))
: null}
</ReorderTable>
</Table>
{editMode && (
<div className="flex justify-between mt-2 flex-shrink-0">
<button
className="btn-action text-link pr-2 py-3 select-none"
onClick={handleAddHeader}
>
+ Add Header
</button>
<button
className="btn-action text-link select-none"
onClick={toggleBulkEditMode}
>
Bulk Edit
</button>
</div>
)}
</StyledWrapper>
);
};
export default ResponseExampleResponseHeaders;

View File

@@ -0,0 +1,83 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
display: inline-block;
.response-status-input {
background: ${(props) => props.theme.requestTabPanel.url.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 3px;
padding: 0.35rem 0.6rem;
font-size: 0.8125rem;
font-weight: 500;
color: ${(props) => props.theme.text.primary};
min-width: 120px;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.primary};
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.primary}20;
}
&::placeholder {
color: ${(props) => props.theme.text.muted};
}
&.text-ok {
color: ${(props) => props.theme.colors.success};
}
&.text-warning {
color: ${(props) => props.theme.colors.warning};
}
&.text-error {
color: ${(props) => props.theme.colors.error};
}
}
.status-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: ${(props) => props.theme.dropdown.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
border-top: none;
border-radius: 0 0 3px 3px;
box-shadow: ${(props) => props.theme.dropdown.shadow};
z-index: 1000;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
.suggestion-item {
display: flex;
align-items: center;
padding: 0.35rem 0.6rem;
margin: 0;
cursor: pointer;
transition: background-color 0.15s ease;
font-size: 0.8125rem;
color: ${(props) => props.theme.dropdown.primaryText};
width: 100%;
box-sizing: border-box;
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
.status {
font-weight: 600;
color: inherit;
margin-right: 0.5rem;
min-width: 40px;
flex-shrink: 0;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,208 @@
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { updateResponseExampleStatusCode, updateResponseExampleStatusText } from 'providers/ReduxStore/slices/collections';
import statusCodePhraseMap from 'components/ResponsePane/StatusCode/get-status-code-phrase';
import StyledWrapper from './StyledWrapper';
const ResponseExampleStatusInput = ({ item, collection, exampleUid, status, statusText }) => {
const dispatch = useDispatch();
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredSuggestions, setFilteredSuggestions] = useState([]);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef(null);
const suggestionsRef = useRef(null);
// Initialize inputValue from Redux state on mount or when prop changes
useEffect(() => {
const displayValue = () => {
if (status && statusText) {
return `${status} ${statusText}`;
} else if (status) {
return status;
}
return '';
};
setInputValue(displayValue());
}, [status, statusText]);
// Create suggestions from status code map
const suggestions = Object.entries(statusCodePhraseMap).map(([code, phrase]) => ({
code: parseInt(code),
phrase,
display: `${code} ${phrase}`
}));
const handleInputChange = (e) => {
const value = e.target.value;
// Update local state to allow typing freely (including spaces)
setInputValue(value);
if (value.trim()) {
// Filter suggestions based on input
const filtered = suggestions.filter((suggestion) =>
suggestion.display.toLowerCase().includes(value.toLowerCase())
|| suggestion.code.toString().includes(value)
|| suggestion.phrase.toLowerCase().includes(value.toLowerCase()));
setFilteredSuggestions(filtered);
setShowSuggestions(true);
} else {
setShowSuggestions(false);
setFilteredSuggestions([]);
}
};
const handleKeyDown = (e) => {
// Handle Cmd+S to save status to Redux
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
parseAndSaveStatus(inputValue);
}
if (!showSuggestions) return;
switch (e.key) {
case 'Escape':
setShowSuggestions(false);
break;
}
};
const selectSuggestion = (suggestion) => {
setShowSuggestions(false);
// Update local input value
const newValue = `${suggestion.code} ${suggestion.phrase}`;
setInputValue(newValue);
// Save the status and statusText
dispatch(updateResponseExampleStatusCode({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
statusCode: String(suggestion.code)
}));
dispatch(updateResponseExampleStatusText({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
statusText: suggestion.phrase
}));
};
const parseAndSaveStatus = (value) => {
// Find the first space
const firstSpaceIndex = value.indexOf(' ');
let statusCode, statusText;
if (firstSpaceIndex === -1) {
// No space found, treat entire value as status code
statusCode = value;
statusText = '';
} else {
// Split on first space only, preserving all other spaces
statusCode = value.substring(0, firstSpaceIndex);
statusText = value.substring(firstSpaceIndex + 1);
}
// Save both as strings - no validation needed
dispatch(updateResponseExampleStatusCode({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
statusCode: statusCode
}));
dispatch(updateResponseExampleStatusText({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
statusText: statusText
}));
setShowSuggestions(false);
};
const handleBlur = (e) => {
// Check if the blur is caused by clicking on a suggestion
const relatedTarget = e.relatedTarget;
if (relatedTarget && relatedTarget.closest('.status-suggestions')) {
return; // Don't close suggestions if clicking on them
}
// Save the status to Redux
parseAndSaveStatus(inputValue);
// Small delay to allow click events on suggestions
setTimeout(() => {
setShowSuggestions(false);
}, 150);
};
const handleFocus = () => {
if (inputValue.trim()) {
const filtered = suggestions.filter((suggestion) =>
suggestion.display.toLowerCase().includes(inputValue.toLowerCase())
|| suggestion.code.toString().includes(inputValue)
|| suggestion.phrase.toLowerCase().includes(inputValue.toLowerCase()));
setFilteredSuggestions(filtered);
setShowSuggestions(true);
}
};
const getStatusClass = (status) => {
const numStatus = parseInt(status);
if (!isNaN(numStatus)) {
if (numStatus >= 200 && numStatus < 300) return 'text-ok';
if (numStatus >= 300 && numStatus < 400) return 'text-warning';
if (numStatus >= 400) return 'text-error';
}
return 'text-ok';
};
return (
<StyledWrapper className="relative">
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
placeholder="e.g., 200 OK, 404 Unknown, 999 Custom Error"
className={`response-status-input ${getStatusClass(status)}`}
data-testid="response-status-input"
/>
{showSuggestions && filteredSuggestions.length > 0 && (
<div
ref={suggestionsRef}
className="status-suggestions"
data-testid="status-suggestions"
onMouseDown={(e) => e.preventDefault()} // Prevent input blur when clicking on suggestions
>
{filteredSuggestions.map((suggestion) => (
<div
key={suggestion.code}
className="suggestion-item"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
selectSuggestion(suggestion);
}}
onMouseDown={(e) => e.preventDefault()}
data-testid={`suggestion-${suggestion.code}`}
>
<span className="status">{`${suggestion.code} ${suggestion.phrase}`}</span>
</div>
))}
</div>
)}
</StyledWrapper>
);
};
export default ResponseExampleStatusInput;

View File

@@ -0,0 +1,39 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
div.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
.some-tests-failed {
color: ${(props) => props.theme.colors.text.danger} !important;
}
.all-tests-passed {
color: ${(props) => props.theme.colors.text.green} !important;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,102 @@
import React, { useState, useMemo } from 'react';
import get from 'lodash/get';
import Tab from 'components/Tab';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import StatusCode from 'components/ResponsePane/StatusCode';
import ResponseExampleResponseContent from './ResponseExampleResponseContent';
import ResponseExampleResponseHeaders from './ResponseExampleResponseHeaders';
import ResponseExampleStatusInput from './ResponseExampleStatusInput';
import StyledWrapper from './StyledWrapper';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const ResponseExampleResponsePane = ({ item, collection, editMode, exampleUid, onSave }) => {
const [activeTab, setActiveTab] = useState('response');
const exampleData = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid) || {};
}, [item, exampleUid]);
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
return (
<ResponseExampleResponseContent
editMode={editMode}
item={item}
collection={collection}
exampleUid={exampleUid}
onSave={onSave}
/>
);
}
case 'headers': {
return (
<ResponseExampleResponseHeaders
editMode={editMode}
item={item}
collection={collection}
exampleUid={exampleUid}
onSave={onSave}
/>
);
}
default: {
return <div>404 | Not found</div>;
}
}
};
const tabConfig = [
{
name: 'response',
label: 'Response'
},
{
name: 'headers',
label: 'Headers',
count: (exampleData?.response?.headers || []).length
}
];
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs mb-4 px-4" role="tablist">
{tabConfig.map((tab) => (
<Tab
key={tab.name}
name={tab.name}
label={tab.label}
isActive={activeTab === tab.name}
onClick={setActiveTab}
count={tab.count}
/>
))}
<div className="flex flex-grow justify-end items-center">
<ResponseLayoutToggle />
{editMode ? (
<ResponseExampleStatusInput
item={item}
collection={collection}
exampleUid={exampleUid}
status={exampleData?.response?.status}
statusText={exampleData?.response?.statusText}
/>
) : (
exampleData?.response?.status && (
<StatusCode status={exampleData.response.status} statusText={exampleData.response.statusText} />
)
)}
</div>
</div>
<section className="flex w-full flex-1 relative">
<HeightBoundContainer>
{getTabPanel(activeTab)}
</HeightBoundContainer>
</section>
</StyledWrapper>
);
};
export default ResponseExampleResponsePane;

View File

@@ -0,0 +1,84 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.bg};
border-bottom: 1px solid ${(props) => props.theme.examples.border};
.response-example-title {
color: ${(props) => props.theme.text};
}
.response-example-description {
color: ${(props) => props.theme.colors.text.muted};
}
.primary-btn {
background-color: ${(props) => props.theme.examples.buttonColor};
border: 1px solid ${(props) => props.theme.examples.buttonColor};
color: white;
svg {
color: ${(props) => props.theme.text} !important;
}
}
.secondary-btn {
background-color: transparent;
color: ${(props) => props.theme.text};
border: 1px solid ${(props) => props.theme.examples.border};
svg {
color: ${(props) => props.theme.text};
}
}
.example-input-label {
display: block;
font-size: 14px;
font-weight: 500;
color: ${(props) => props.theme.text};
margin-bottom: 4px;
}
.example-input {
width: 100%;
padding: 8px 12px;
border: 1px solid ${(props) => props.theme.examples.border};
border-radius: 6px;
background-color: transparent;
color: ${(props) => props.theme.text};
font-family: inherit;
font-size: 14px;
line-height: 1.5;
transition: all 0.2s ease;
outline: none;
box-shadow: none;
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
outline: none;
box-shadow: none;
}
&:disabled {
background-color: transparent;
color: ${(props) => props.theme.text};
cursor: not-allowed;
opacity: 0.6;
}
}
.example-input-description {
font-size: 0.875rem;
line-height: 1.6;
resize: none;
min-height: 80px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,204 @@
import React, { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import IconEdit from 'components/Icons/IconEdit';
import { IconCode, IconDeviceFloppy } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
import TruncatedText from 'components/TruncatedText';
import { updateResponseExampleName, updateResponseExampleDescription } from 'providers/ReduxStore/slices/collections';
import get from 'lodash/get';
const ResponseExampleTopBar = ({
item,
collection,
exampleUid,
editMode,
onEditToggle,
onSave,
onCancel,
onGenerateCode
}) => {
const { theme } = useTheme();
const dispatch = useDispatch();
const example = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) : get(item, 'examples', []).find((e) => e.uid === exampleUid);
}, [item.draft, item.examples, item, exampleUid]);
const handleGenerateCode = () => {
if (onGenerateCode) {
onGenerateCode({
...example,
isExample: true,
exampleUid: exampleUid
});
}
};
const handleNameChange = (e) => {
// Validate required fields before dispatching
if (!item?.uid) {
console.error('item.uid is missing');
return;
}
if (!collection?.uid) {
console.error('collection.uid is missing');
return;
}
if (!exampleUid) {
console.error('exampleUid is missing');
return;
}
dispatch(updateResponseExampleName({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
name: e.target.value
}));
};
const handleDescriptionChange = (e) => {
// Validate required fields before dispatching
if (!item?.uid) {
console.error('item.uid is missing');
return;
}
if (!collection?.uid) {
console.error('collection.uid is missing');
return;
}
if (!exampleUid) {
console.error('exampleUid is missing');
return;
}
dispatch(updateResponseExampleDescription({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: exampleUid,
description: e.target.value
}));
};
const handleSave = () => {
// Call the parent save handler
if (onSave) {
onSave();
}
};
const handleCancel = () => {
if (onCancel) {
onCancel();
}
};
if (!example || !exampleUid) {
return null;
}
if (editMode) {
return (
<StyledWrapper className="p-4">
<div className="max-w-full">
<div className="flex items-start justify-between gap-6 md:flex-row flex-col">
<div className="flex-1 min-w-0">
<div className="space-y-3">
<div>
<input
type="text"
value={example?.name || ''}
onChange={handleNameChange}
className="example-input example-input-name"
placeholder="Enter example name"
autoFocus
data-testid="response-example-name-input"
/>
</div>
<div>
<textarea
value={example?.description || ''}
onChange={handleDescriptionChange}
className="example-input example-input-description"
placeholder="Enter example description"
rows={3}
data-testid="response-example-description-input"
/>
</div>
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0 md:w-auto w-full md:justify-end">
<button
className="secondary-btn flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
onClick={handleCancel}
data-testid="response-example-cancel-btn"
>
Cancel
</button>
<button
className="primary-btn flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
onClick={handleSave}
data-testid="response-example-save-btn"
>
<IconDeviceFloppy size={16} color={theme.examples.buttonText} />
Save
</button>
</div>
</div>
</div>
</StyledWrapper>
);
}
// Default view mode
return (
<StyledWrapper className="p-4">
<div className="max-w-full">
<div className="flex items-start justify-between gap-6 md:flex-row flex-col">
<div className="flex-1 min-w-0">
<h2 className="response-example-title font-semibold mb-2 leading-tight" data-testid="response-example-title">
<span className="opacity-60">{item.name}</span>
{' / '}
<span>{example.name}</span>
</h2>
{example.description && example.description.trim().length > 0 && (
<TruncatedText
text={example.description}
maxLines={2}
className="response-example-description-container"
textClassName="response-example-description text-sm leading-relaxed max-w-fit"
buttonClassName="text-blue-600 hover:text-blue-800 font-medium"
viewMoreText="View More"
viewLessText="View Less"
dataTestId="response-example-description"
/>
)}
</div>
<div className="flex items-center gap-3 flex-shrink-0 md:w-auto w-full md:justify-end">
<button
className="secondary-btn flex items-center gap-1.5 p-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
onClick={handleGenerateCode}
title="Generate Code"
data-testid="response-example-generate-code-btn"
>
<IconCode size={16} color={theme.examples.buttonIconColor} />
</button>
<button
className="secondary-btn flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium cursor-pointer border whitespace-nowrap"
onClick={onEditToggle}
data-testid="response-example-edit-btn"
>
<IconEdit size={16} color={theme.examples.buttonIconColor} />
Edit Example
</button>
</div>
</div>
</div>
</StyledWrapper>
);
};
export default ResponseExampleTopBar;

View File

@@ -0,0 +1,67 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
&.dragging {
cursor: col-resize;
&.vertical-layout {
cursor: row-resize;
}
}
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
&.vertical-layout {
.request-pane {
padding-bottom: 0.5rem;
}
.response-pane {
padding-top: 0.5rem;
}
div.dragbar-wrapper {
width: 100%;
height: 10px;
cursor: row-resize;
padding: 0 1rem;
position: relative;
div.dragbar-handle {
width: 100%;
height: 1px;
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,224 @@
import React, { useState, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { cancelResponseExampleEdit } from 'providers/ReduxStore/slices/collections';
import ResponseExampleTopBar from './ResponseExampleTopBar';
import ResponseExampleRequestPane from './ResponseExampleRequestPane';
import ResponseExampleResponsePane from './ResponseExampleResponsePane';
import GenerateCodeItem from 'components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem';
import StyledWrapper from './StyledWrapper';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const ResponseExample = ({ item, collection, example }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const screenWidth = useSelector((state) => state.app.screenWidth);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const [leftPaneWidth, setLeftPaneWidth] = useState((screenWidth - leftSidebarWidth) / 2.2);
const [topPaneHeight, setTopPaneHeight] = useState(MIN_TOP_PANE_HEIGHT);
const [dragging, setDragging] = useState(false);
const [editMode, setEditMode] = useState(false);
const [showGenerateCodeModal, setShowGenerateCodeModal] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const mainSectionRef = useRef(null);
const handleMouseMove = (e) => {
if (dragging && mainSectionRef.current) {
e.preventDefault();
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
return;
}
setTopPaneHeight(newHeight);
} else {
const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
return;
}
setLeftPaneWidth(newWidth);
}
}
};
const handleMouseUp = (e) => {
if (dragging && mainSectionRef.current) {
e.preventDefault();
setDragging(false);
if (!isVerticalLayout) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
dispatch(updateRequestPaneTabWidth({
uid: item.uid,
requestPaneWidth: e.clientX - mainRect.left
}));
}
}
};
const handleDragbarMouseDown = (e) => {
e.preventDefault();
setDragging(true);
if (isVerticalLayout) {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.y = e.clientY - dragBarRect.top;
} else {
const dragBar = e.currentTarget;
const dragBarRect = dragBar.getBoundingClientRect();
dragOffset.current.x = e.clientX - dragBarRect.left;
}
};
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [dragging]);
const handleEditToggle = () => {
setEditMode(!editMode);
};
const handleSave = () => {
if (item && collection) {
dispatch(saveRequest(item.uid, collection.uid));
setEditMode(false);
}
};
const handleCancel = () => {
if (item && collection && example?.uid) {
dispatch(cancelResponseExampleEdit({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: example.uid
}));
}
setEditMode(false);
};
const handleGenerateCode = (exampleData) => {
setShowGenerateCodeModal(true);
};
const handleCloseGenerateCodeModal = () => {
setShowGenerateCodeModal(false);
};
const handleTryExample = (example) => {
// TODO: Implement try example functionality
};
// Update width when screen width or sidebar width changes
useEffect(() => {
if (mainSectionRef.current) {
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayout) {
// In vertical mode, set leftPaneWidth to full container width
setLeftPaneWidth(mainRect.width);
} else {
// In horizontal mode, set to roughly half width
setLeftPaneWidth((screenWidth - leftSidebarWidth) / 2.2);
}
}
}, [isVerticalLayout, screenWidth, leftSidebarWidth]);
// Keyboard shortcut support for Ctrl/Cmd+S
useEffect(() => {
const handleKeyDown = (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
if (editMode && item && collection) {
handleSave();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [editMode, item, collection]);
return (
<>
<StyledWrapper className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''}`}>
<ResponseExampleTopBar
item={item}
collection={collection}
exampleUid={example.uid}
editMode={editMode}
onEditToggle={handleEditToggle}
onSave={handleSave}
onCancel={handleCancel}
onGenerateCode={handleGenerateCode}
onTryExample={handleTryExample}
/>
<section ref={mainSectionRef} className={`main wrapper flex mt-4 ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto scrollbar-hover`}>
<section className="request-pane">
<div
className="px-4 h-full"
style={isVerticalLayout ? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
} : {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
}}
>
<ResponseExampleRequestPane
item={item}
collection={collection}
example={example}
editMode={editMode}
exampleUid={example?.uid}
onSave={handleSave}
/>
</div>
</section>
<div className="dragbar-wrapper" onMouseDown={handleDragbarMouseDown}>
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto">
<ResponseExampleResponsePane
item={item}
collection={collection}
example={example}
editMode={editMode}
exampleUid={example?.uid}
onSave={handleSave}
/>
</section>
</section>
</StyledWrapper>
{showGenerateCodeModal && (
<GenerateCodeItem
collectionUid={collection.uid}
item={item}
onClose={handleCloseGenerateCodeModal}
isExample={true}
exampleUid={example.uid}
/>
)}
</>
);
};
export default ResponseExample;

View File

@@ -18,8 +18,8 @@ const GrpcStatusCode = ({ status, text }) => {
return (
<StyledWrapper className={getTabClassname(status)}>
{Number.isInteger(status) ? <div className="mr-1">{status}</div> : null}
{statusText && <div>{statusText}</div>}
{Number.isInteger(status) ? <div className="mr-1" data-testid="grpc-response-status-code">{status}</div> : null}
{statusText && <div data-testid="grpc-response-status-text">{statusText}</div>}
</StyledWrapper>
);
};

View File

@@ -1,10 +1,8 @@
import { debounce } from 'lodash';
import QueryResultFilter from './QueryResultFilter';
import { JSONPath } from 'jsonpath-plus';
import React from 'react';
import classnames from 'classnames';
import iconv from 'iconv-lite';
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
import { getContentType, formatResponse } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import QueryResultPreview from './QueryResultPreview';
import StyledWrapper from './StyledWrapper';
@@ -13,54 +11,6 @@ import { useTheme } from 'providers/Theme/index';
import { getEncoding, uuid } from 'utils/common/index';
import LargeResponseWarning from '../LargeResponseWarning';
const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
if (data === undefined || !dataBuffer || !mode) {
return '';
}
// TODO: We need a better way to get the raw response-data here instead
// of using this dataBuffer param.
// Also, we only need the raw response-data and content-type to show the preview.
const rawData = iconv.decode(
Buffer.from(dataBuffer, "base64"),
iconv.encodingExists(encoding) ? encoding : "utf-8"
);
if (mode.includes('json')) {
try {
JSON.parse(rawData);
} catch (error) {
// If the response content-type is JSON and it fails parsing, its an invalid JSON.
// In that case, just show the response as it is in the preview.
return rawData;
}
if (filter) {
try {
data = JSONPath({ path: filter, json: data });
} catch (e) {
console.warn('Could not apply JSONPath filter:', e.message);
}
}
return safeStringifyJSON(data, true);
}
if (mode.includes('xml')) {
let parsed = safeParseXML(data, { collapseContent: true });
if (typeof parsed === 'string') {
return parsed;
}
return safeStringifyJSON(parsed, true);
}
if (typeof data === 'string') {
return data;
}
return safeStringifyJSON(data, true);
};
const formatErrorMessage = (error) => {
if (!error) return 'Something went wrong';
@@ -106,7 +56,7 @@ const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListen
if (isLargeResponse && !showLargeResponse) {
return '';
}
return formatResponse(data, dataBuffer, responseEncoding, mode, filter);
return formatResponse(data, dataBuffer, mode, filter);
},
[data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
);

View File

@@ -0,0 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
`;
export default StyledWrapper;

View File

@@ -0,0 +1,130 @@
import React, { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { IconBookmark } from '@tabler/icons';
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
import { getBodyType } from 'utils/responseBodyProcessor';
import { getInitialExampleName } from 'utils/collections/index';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
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
// Only show for HTTP requests
if (item.type !== 'http-request') {
return null;
}
const handleSaveClick = () => {
if (!response || response.error) {
toast.error('No valid response to save as example');
return;
}
if (isResponseTooLarge) {
toast.error('Response size exceeds 5MB limit. Cannot save as example.');
return;
}
setShowSaveResponseExampleModal(true);
};
const saveAsExample = async (name, description = '') => {
// Convert headers object to array format expected by schema
const headersArray = response.headers && typeof response.headers === 'object'
? Object.entries(response.headers).map(([name, value]) => ({
name,
value,
enabled: true
}))
: [];
const contentTypeHeader = headersArray.find((h) => h.name?.toLowerCase() === 'content-type');
const contentType = contentTypeHeader?.value?.toLowerCase() || '';
const bodyType = getBodyType(contentType);
const content = response.data;
const exampleData = {
name: name,
status: response.status || 200,
headers: headersArray,
body: {
type: bodyType,
content: content
},
description: description
};
// Calculate the index where the example will be saved
// This will be the length of the examples array after adding the new one
const existingExamples = item.draft?.examples || item.examples || [];
const exampleIndex = existingExamples.length;
const exampleUid = uuid();
dispatch(addResponseExample({
itemUid: item.uid,
collectionUid: collection.uid,
example: {
...exampleData,
uid: exampleUid
}
}));
// Save the request
await dispatch(saveRequest(item.uid, collection.uid));
// Task middleware will track this and open the example in a new tab once the file is reloaded
dispatch(insertTaskIntoQueue({
uid: exampleUid,
type: 'OPEN_EXAMPLE',
collectionUid: collection.uid,
itemUid: item.uid,
exampleIndex: exampleIndex
}));
setShowSaveResponseExampleModal(false);
toast.success(`Example "${name}" created successfully`);
};
return (
<>
<StyledWrapper className="ml-2 flex items-center">
<button
onClick={handleSaveClick}
disabled={isResponseTooLarge}
title={
isResponseTooLarge
? 'Response size exceeds 5MB limit. Cannot save as example.'
: 'Save current response as example'
}
className={classnames('p-1', {
'opacity-50 cursor-not-allowed': isResponseTooLarge
})}
data-testid="response-bookmark-btn"
>
<IconBookmark size={16} strokeWidth={1.5} />
</button>
</StyledWrapper>
<CreateExampleModal
isOpen={showSaveResponseExampleModal}
onClose={() => setShowSaveResponseExampleModal(false)}
onSave={saveAsExample}
title="Save Response as Example"
initialName={getInitialExampleName(item)}
/>
</>
);
};
export default ResponseBookmark;

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 }) => {
const StatusCode = ({ status, statusText }) => {
const getTabClassname = (status) => {
return classnames('ml-2', {
'text-ok': status >= 100 && status < 200,
@@ -17,7 +17,7 @@ const StatusCode = ({ status }) => {
return (
<StyledWrapper className={`response-status-code ${getTabClassname(status)}`} data-testid="response-status-code">
{status} {statusCodePhraseMap[status]}
{status} {statusText || statusCodePhraseMap[status]}
</StyledWrapper>
);
};

View File

@@ -49,7 +49,7 @@ const EventTypeColors = {
cancel: "border-amber-500/20"
};
const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, item, collection, width }) => {
const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, item }) => {
const [isCollapsed, setIsCollapsed] = useState(true);
const toggleCollapse = () => setIsCollapsed(prev => !prev);
@@ -83,7 +83,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
{Object.entries(effectiveRequest.headers).map(([key, value], idx) => (
<div key={idx} className="contents">
<div className="text-xs font-medium overflow-hidden text-ellipsis">{key}:</div>
<div className="text-xs overflow-hidden text-ellipsis">{value}</div>
<div className="text-xs overflow-hidden text-ellipsis">{typeof value === 'string' ? value : '[Buffer Buffer]'}</div>
</div>
))}
</div>
@@ -148,9 +148,9 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
return (
<div className="mt-2 bg-green-50 dark:bg-green-900/10 rounded p-2">
<div className="font-semibold mb-1 text-green-700 dark:text-green-400">
Response Message #{(response.responses.length || 0)}
Response Message #{(response?.responses?.length) || 0}
</div>
{response.responses && response.responses.length > 0 ? (
{response?.responses && response.responses.length > 0 ? (
<pre className="text-xs bg-white dark:bg-gray-800 p-2 rounded overflow-auto max-h-[200px]">
{JSON.stringify(response.responses[response.responses.length - 1], null, 2)}
</pre>
@@ -221,7 +221,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
<div className="mt-2 bg-gray-50 dark:bg-gray-700/30 rounded p-2">
<div className="font-semibold mb-1">Stream Ended</div>
<div className="text-sm">
Total messages: {response.responses.length || 0}
Total messages: {(response?.responses?.length) || 0}
</div>
</div>
);

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -18,6 +18,7 @@ import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
import ResponseBookmark from 'src/components/ResponsePane/ResponseBookmark';
import SkippedRequest from './SkippedRequest';
import ClearTimeline from './ClearTimeline/index';
import ResponseLayoutToggle from './ResponseLayoutToggle';
@@ -50,7 +51,22 @@ const ResponsePane = ({ item, collection }) => {
};
const response = item.response || {};
const responseSize = response.size || 0;
const responseSize = useMemo(() => {
if (typeof response.size === 'number') {
return response.size;
}
if (!response.dataBuffer) return 0;
try {
// dataBuffer is base64 encoded, so we need to calculate the actual size
const buffer = Buffer.from(response.dataBuffer, 'base64');
return buffer.length;
} catch (error) {
return 0;
}
}, [response.size, response.dataBuffer]);
const getTabPanel = (tab) => {
switch (tab) {
@@ -167,6 +183,7 @@ 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} />
<ResponseSize size={responseSize} />

View File

@@ -0,0 +1,56 @@
import React from 'react';
import { IconSearch, IconX } from '@tabler/icons';
const SearchInput = ({
searchText,
setSearchText,
placeholder = 'Search',
className = '',
onChange,
...props
}) => {
const handleChange = (e) => {
setSearchText(e.target.value);
if (onChange) {
onChange(e);
}
};
return (
<div className={`relative px-2 ${className}`}>
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span className="text-gray-500 sm:text-sm">
<IconSearch size={16} strokeWidth={1.5} />
</span>
</div>
<input
type="text"
name="search"
placeholder={placeholder}
id="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="block w-full pl-7 py-2 sm:text-sm rounded-md"
value={searchText}
onChange={handleChange}
{...props}
/>
{searchText !== '' && (
<div className="absolute inset-y-0 right-0 pr-4 flex items-center">
<span
className="close-icon"
onClick={() => {
setSearchText('');
}}
>
<IconX size={16} strokeWidth={1.5} className="cursor-pointer" />
</span>
</div>
)}
</div>
);
};
export default SearchInput;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import { useDispatch } from 'react-redux';
import { deleteResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
const dispatch = useDispatch();
const onConfirm = () => {
dispatch(deleteResponseExample({
itemUid: item.uid,
collectionUid: collection.uid,
exampleUid: example.uid
}));
dispatch(saveRequest(item.uid, collection.uid));
onClose();
};
return (
<Portal>
<Modal
size="sm"
title="Delete Example"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
confirmButtonClass="btn-danger"
>
Are you sure you want to delete the example <span className="font-semibold">{example.name}</span>?
</Modal>
</Portal>
);
};
export default DeleteResponseExampleModal;

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