Compare commits

...

95 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
Chirag Chandrashekhar
fa1498e2a8 Bugfix/incorrect space encode (#5870)
* Fix the space encoding issue

* fix: incorrect space encoding

Fixed an issue in Code Generation for requests. The original fix was
raised in [PR](https://github.com/usebruno/bruno/pull/4478). The current
PR fixes some merge conflicts and resolves some unimported dependencies
error.

* test: add URL encoding tests for code generation feature

Add Playwright tests to verify proper URL encoding behavior in Bruno's
code generation dialog for both encoded and unencoded query parameters.

* moved the test script inside request

* updated the snippet generation code to reuse code and reduce redundancy

* removed redundant code and reverted autoformat

* reverted some auto formatted changes

* reverting format during commit hook

* chore: reset formatting

* chore: reformat

---------

Co-authored-by: Vipin Sundar <86339268+vipin-sundar@users.noreply.github.com>
Co-authored-by: Chirag Chandrashekhar <chiragchan@Chirags-MacBook-Air.local>
Co-authored-by: Sid <siddharth@usebruno.com>

chore: reformat
2025-10-24 16:29:22 +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
Vipin Sundar
045141efaf Fix the space encoding issue (#4478) 2025-10-23 15:27:19 +05:30
anusree-bruno
c997b91698 added jsonwebtoken as inbuilt library (#5535)
* added jsonwebtoken as inbuilt library

* removed bundling

* handle callback in quickjs

* chore: tests folder restructure

* chore: lint fix

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 14:57:19 +05:30
anusree-bruno
986d5b0b2a moved custom search to components folder (#5750)
* moved custom search to components folder

* renamed custom search

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 14:56:57 +05:30
ganesh
a2a521477a add fix for runtime var color (#4254)
* added new changes

* adds color to light and dark theme file

* import theme obj and use variable runtime color

* fix: operator linebreak style for eslint

* chore: remove un-needed changes

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 14:23:12 +05:30
Siddharth Gelera (reaper)
8e70adcbf9 fix: incomplete tests (#5824)
* fix: close support modal for other tests to reuse the window properly

* Update support-links.spec.js

* chore: reformat

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 13:39:36 +05:30
Prasanth Baskar
87296776fa add arch linux install to readme (#4569)
Signed-off-by: bupd <bupdprasanth@gmail.com>
2025-10-19 03:19:16 +05:30
Alex
9df70cd759 Merge pull request #5809 from 0x416c6578/feature/minify-json-xml
Add `bru.utils.minifyXml` and `bru.utils.minifyJson`
2025-10-19 01:29:32 +05:30
Anoop M D
8f9fb3b3c9 Merge pull request #5163 from josbiz/fix--dot-on-proxy-options-when-unused
fix: dot on unused proxy settings
2025-10-19 01:15:21 +05:30
Anoop M D
6d018f5648 Merge pull request #5164 from josbiz/fix--dot-now-showed-on-used-preset-setting
fix: show dot on used preset setting
2025-10-19 01:14:00 +05:30
Anoop M D
789d0b23c0 added option to revert changes (#4503) 2025-10-19 01:09:07 +05:30
anusree-bruno
81e1e403e4 handle options in getBody for QuickJS VM (#4614) 2025-10-19 01:02:30 +05:30
Sanjai Kumar
ad2add4026 Added tests for replacing invalid variable characters in Postman collection Env (#4634)
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
Co-authored-by: Anoop M D <anoop@usebruno.com>
2025-10-19 01:00:19 +05:30
naman-bruno
02554c3ad9 Merge pull request #4279 from naman-bruno/feat/apikey-codegen
Add: API Key auth in code generator
2025-10-19 00:53:58 +05:30
Anoop M D
62815e3429 Merge pull request #5008 from anusree-bruno/feat/add-process-env-vars-to-gql-introspection
add process.env variable support to GraphQL introspection
2025-10-18 23:15:51 +05:30
Anoop M D
9859b69559 Merge pull request #5113 from apealpha/bugfix/3019-prettify-json-with-variables
fix(request): prettify JSON with variables
2025-10-18 22:51:10 +05:30
Anoop M D
440c688bbb Merge pull request #4708 from pooja-bruno/improve/use-common-getTreePathFromCollectionToItem-function
improve: use common getTreePathFromCollectionToItem function
2025-10-18 22:44:08 +05:30
Anoop M D
416eb754b7 Merge pull request #4747 from ZieglerZhu/bugfix/update-readme-cn
docs(readme): update readme_cn.md
2025-10-18 18:00:11 +05:30
Anoop M D
b85d6efa60 Merge pull request #5303 from usebruno/dependabot/github_actions/actions/checkout-5
build(deps): bump actions/checkout from 4 to 5
2025-10-18 16:49:35 +05:30
Blake Guilloud
19dea18629 Merge pull request #5829 from BlakeGuilloud/bugfix/5823-saving-url-in-response-pane
Bugfix/5823 saving url in response pane
2025-10-18 16:39:21 +05:30
Abhishek S Lal
636901c23d fix: resolve global env variable becoming undefined on script execution (#5816)
* fix: resolve global env variable becoming undefined on script execution

Fixes an issue where global disabled environment variables were becoming undefined during request execution when the pre request script is non-empty.

The update ensures that global variables persist as expected and are correctly referenced throughout the request lifecycle.

Closes #5772.

* feat: added test for checking proper global env update through scripts

* refactor: updated comments for more readability and added a new data-testid in modal.
2025-10-17 21:50:50 +05:30
lohit
a4b1941817 fix(bru-2035): form-urlencoded logic updates (#5820) 2025-10-17 18:22:43 +05:30
Sid
7d8fde9180 fix: improve URL parsing in getParsedWsUrlObject (#5822) 2025-10-17 18:15:15 +05:30
Anoop M D
4197304bf9 Merge pull request #5679 from mheidinger/visual-gql-indicator
feat: add visual indicator for GQL requests
2025-10-17 15:00:37 +05:30
Max Heidinger
b75422a010 feat: add visual indicator for GQL requests 2025-10-17 10:25:54 +02:00
Pragadesh-45
e9f03c46c7 tests: add tests for URN parsing (#5819) 2025-10-17 10:58:26 +05:30
Pragadesh-45
73e828621f fix: enhance URL parameter parsing and interpolation logic (#5812)
* fix: enhance URL parameter parsing and interpolation logic
2025-10-16 17:58:53 +05:30
Siddharth Gelera (reaper)
2becf49542 fix: harden type checks for buildFormUrlEncodedPayload (#5811) 2025-10-16 13:31:13 +05:30
dependabot[bot]
e57162b79a build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 17:10:22 +00:00
Jose Bolivar Ibz
1de9203dd5 Merge branch 'main' into fix--dot-on-proxy-options-when-unused 2025-08-29 08:45:44 -07:00
Jose Bolivar Ibz
cffa37ed50 fix: show dot on used preset setting 2025-07-22 12:17:19 -07:00
Jose Bolivar Ibz
bcf61f507a Update index.js
Variable change from let to const
2025-07-22 12:10:14 -07:00
Jose Bolivar Ibz
325b573da9 fix: dot on unused proxy settings 2025-07-22 11:51:02 -07:00
apealpha
4b5c7dcca6 fix(request): prettify JSON with variables 2025-07-16 00:00:26 +09:00
anusree-bruno
d2888daa88 add process.env variable support to GraphQL introspection 2025-06-30 13:12:35 +05:30
ZieglerZhu
ec9d63219f docs(readme): update readme_cn.md
- Added introduction of commercial version
- Added Table of Contents
- Updated installation guide via package managers
- Updated important links
- Adjusted document structure, optimized title hierarchy
2025-05-22 23:38:33 +08:00
pooja-bruno
9173ffbdee improve: use common getTreePathFromCollectionToItem function 2025-05-19 12:42:10 +05:30
anusree-bruno
5f112a318d added option to revert changes 2025-04-15 00:11:41 +05:30
469 changed files with 26092 additions and 1679 deletions

View File

@@ -25,7 +25,7 @@ jobs:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'

View File

@@ -15,7 +15,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
@@ -44,7 +44,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
@@ -73,7 +73,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps

View File

@@ -13,7 +13,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
@@ -66,7 +66,7 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
@@ -108,7 +108,7 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: v22.11.x

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

@@ -37,13 +37,37 @@ Bruno 直接在您的电脑文件夹中存储您的 API 信息。我们使用纯
Bruno 仅限离线使用。我们计划永不向 Bruno 添加云同步功能。我们重视您的数据隐私,并认为它应该留在您的设备上。阅读我们的长期愿景 [点击查看](https://github.com/usebruno/bruno/discussions/269)
[下载 Bruno](https://www.usebruno.com/downloads)
📢 观看我们在印度 FOSS 3.0 会议上的最新演讲 [点击查看](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](../../assets/images/landing-2.png) <br /><br />
### 安装
## 商业版本 ✨
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) Mac、Windows 和 Linux 的可执行文件
我们的大多数功能都是免费且开源的
我们致力于在 [开源与可持续性发展](https://github.com/usebruno/bruno/discussions/269) 之间取得和谐的平衡
欢迎使用我们的 [付费版本](https://www.usebruno.com/pricing) ,看看附加的功能是否对您或团队有所帮助! <br/>
## 目录
- [安装](#安装)
- [特性](#特性)
- [跨平台使用 🖥️](#跨平台使用-)
- [通过Git协作 👩‍💻🧑‍💻](#通过git协作-)
- [重要链接 📌](#重要链接-)
- [展示 🎥](#展示-)
- [分享评价 📣](#分享评价-)
- [发布到新的包管理器](#发布到新的包管理器)
- [联系方式 🌐](#联系方式-)
- [商标](#商标)
- [贡献 👩‍💻🧑‍💻](#贡献-)
- [作者](#作者)
- [许可证 📄](#许可证-)
## 安装
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) 适用于Mac、Windows 和 Linux 的可执行文件。
您也可以通过包管理器如 Homebrew、Chocolatey、Scoop、Snap 和 Apt 安装 Bruno。
@@ -58,9 +82,15 @@ choco install bruno
scoop bucket add extras
scoop install bruno
# 在 Windows 上用 winget 安装
winget install Bruno.Bruno
# 在 Linux 上用 Snap 安装
snap install bruno
# 在 Linux 上用 Flatpak 安装
flatpak install com.usebruno.Bruno
# 在 Linux 上用 Apt 安装
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg curl
@@ -73,67 +103,50 @@ echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebr
sudo apt update && sudo apt install bruno
```
### 在 Mac 上通过 Homebrew 安装 🖥️
## 特性
### 跨平台使用 🖥️
![bruno](../../assets/images/run-anywhere.png) <br /><br />
### Collaborate 安装 👩‍💻🧑‍💻
### 通过Git协作 👩‍💻🧑‍💻
或者任何您选择的版本控制系统
![bruno](../../assets/images/version-control.png) <br /><br />
### 重要链接 📌
## 重要链接 📌
- [我们的愿景](https://github.com/usebruno/bruno/discussions/269)
- [路线图](https://github.com/usebruno/bruno/discussions/384)
- [路线图](https://www.usebruno.com/roadmap)
- [文档](https://docs.usebruno.com)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
- [网站](https://www.usebruno.com)
- [价格](https://www.usebruno.com/pricing)
- [下载](https://www.usebruno.com/downloads)
- [GitHub 赞助](https://github.com/sponsors/helloanoop).
### 展示 🎥
## 展示 🎥
- [Testimonials](https://github.com/usebruno/bruno/discussions/343)
- [Knowledge Hub](https://github.com/usebruno/bruno/discussions/386)
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
### 支持 ❤️
如果您喜欢 Bruno 并想支持我们的开源工作,请考虑通过 [GitHub Sponsors](https://github.com/sponsors/helloanoop) 来赞助我们。
### 分享评价 📣
## 分享评价 📣
如果 Bruno 在您的工作和团队中帮助了您,请不要忘记在我们的 GitHub 讨论上分享您的 [评价](https://github.com/usebruno/bruno/discussions/343)
### 发布到新的包管理器
## 发布到新的包管理器
有关更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
如需了解更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
### 贡献 👩‍💻🧑‍💻
我很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
### 作者
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### 联系方式 🌐
## 联系方式 🌐
[𝕏 (Twitter)](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq) <br />
[LinkedIn](https://www.linkedin.com/company/usebruno)
### 商标
## 商标
**名称**
@@ -143,6 +156,20 @@ sudo apt update && sudo apt install bruno
Logo 源自 [OpenMoji](https://openmoji.org/library/emoji-1F436/). License: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
### 许可证 📄
## 贡献 👩‍💻🧑‍💻
很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
## 作者
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
## 许可证 📄
[MIT](../../license.md)

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}"],

86
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",
@@ -31986,6 +32028,7 @@
"cheerio": "^1.0.0",
"crypto-js": "^4.2.0",
"json-query": "^2.2.2",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "3.3.8",
@@ -31995,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": {
@@ -32036,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

@@ -14,7 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import CustomSearch from './CustomSearch';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -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,12 +266,12 @@ 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}
>
<CustomSearch
<CodeMirrorSearch
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}

View File

@@ -1,5 +1,4 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -9,7 +8,7 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const CustomSearch = ({ visible, editor, onClose }) => {
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
@@ -199,4 +198,4 @@ const CustomSearch = ({ visible, editor, onClose }) => {
);
};
export default CustomSearch;
export default CodeMirrorSearch;

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

@@ -44,7 +44,11 @@ const CollectionSettings = ({ collection }) => {
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const presets = get(collection, 'brunoConfig.presets', []);
const hasPresets = presets && presets.requestUrl !== '';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const proxyEnabled = proxyConfig.hostname ? true : false;
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const protobufConfig = get(collection, 'brunoConfig.protobuf', {});
@@ -116,7 +120,7 @@ const CollectionSettings = ({ collection }) => {
case 'clientCert': {
return (
<ClientCertSettings
root={collection.pathname}
collection={collection}
clientCertConfig={clientCertConfig}
onUpdate={onClientCertSettingsUpdate}
onRemove={onClientCertSettingsRemove}
@@ -163,10 +167,11 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates

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 { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -48,16 +48,6 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getTreePathFromCollectionToFolder = (collection, _folder) => {
let path = [];
let item = findItemInCollection(collection, _folder?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@@ -69,7 +59,7 @@ const Auth = ({ collection, folder }) => {
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder

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"
@@ -136,7 +136,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
/>
</td>
<td>
<div className="flex items-center">
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
<input
type="text"
autoComplete="off"
@@ -153,7 +153,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
<MultiLineEditor
theme={storedTheme}
collection={_collection}

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

@@ -9,7 +9,8 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
<div className="bruno-modal-header">
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
{handleCancel && !hideClose ? (
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button">
// TODO: Remove data-test-id and use data-testid instead across the codebase.
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button" data-testid="modal-close-button">
×
</div>
) : null}
@@ -25,7 +26,8 @@ const ModalFooter = ({
handleCancel,
confirmDisabled,
hideCancel,
hideFooter
hideFooter,
confirmButtonClass = 'btn-secondary'
}) => {
confirmText = confirmText || 'Save';
cancelText = cancelText || 'Cancel';
@@ -44,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}
>
@@ -72,7 +74,8 @@ const Modal = ({
disableEscapeKey,
onClick,
closeModalFadeTimeout = 500,
dataTestId
dataTestId,
confirmButtonClass
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
@@ -138,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

@@ -9,6 +9,7 @@ import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@@ -27,6 +28,7 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
};
const Auth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
@@ -37,7 +39,7 @@ const Auth = ({ item, collection }) => {
// Save function for request level
const save = () => {
return saveRequest(item.uid, collection.uid);
return dispatch(saveRequest(item.uid, collection.uid));
};
const getEffectiveAuthSource = () => {

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 { format, applyEdits } from 'jsonc-parser';
import { prettifyJsonString } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
const RequestBodyMode = ({ item, collection }) => {
@@ -39,8 +39,7 @@ const RequestBodyMode = ({ item, collection }) => {
const onPrettify = () => {
if (body?.json && bodyMode === 'json') {
try {
const edits = format(body.json, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(body.json, edits);
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,21 +95,37 @@ 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;
const isGrpc = item.type === 'grpc-request';
const isWS = item.type === 'ws-request';
if (!isWS && !isGrpc) {
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
switch (item.type) {
case 'grpc-request':
return 'gRPC';
case 'ws-request':
return 'WS';
case 'graphql-request':
return 'GQL';
default:
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
}
if (isGrpc) {
return 'gRPC';
}
return 'WS';
}, [item]);
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
if (!item) {
return (
<StyledWrapper
@@ -170,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();
@@ -198,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);
};
@@ -208,7 +227,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmClose(true);
}}
>
{!item.draft ? (
{!hasChanges ? (
<CloseTabIcon />
) : (
<DraftTabIcon />
@@ -225,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;
@@ -241,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));
}
@@ -249,6 +269,25 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
} catch (err) {}
}
function handleRevertChanges(event) {
event.stopPropagation();
dropdownTippyRef.current.hide();
if (!currentTabUid) {
return;
}
try {
const item = findItemInCollection(collection, currentTabUid);
if (item.draft) {
dispatch(deleteRequestDraft({
itemUid: item.uid,
collectionUid: collection.uid
}));
}
} catch (err) {}
}
function handleCloseOtherTabs(event) {
dropdownTippyRef.current.hide();
@@ -274,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 }));
}
@@ -316,6 +355,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
>
Clone Request
</button>
<button
className="dropdown-item w-full"
onClick={handleRevertChanges}
disabled={!currentTabItem?.draft}
>
Revert Changes
</button>
<button className="dropdown-item w-full" onClick={(e) => handleCloseTab(e, currentTabUid)}>
Close
</button>

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

@@ -4,7 +4,7 @@ import { get } from 'lodash';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
import 'pdfjs-dist/build/pdf.worker';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
@@ -76,6 +76,8 @@ const QueryResultPreview = ({
dispatch(sendRequest(item, collection.uid));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onScroll = (event) => {
dispatch(
updateResponsePaneScrollPosition({
@@ -127,6 +129,7 @@ const QueryResultPreview = ({
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={mode}

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