Compare commits

..

174 Commits

Author SHA1 Message Date
Pooja
364fb45e97 add: pre and post tests in runner (#4878) 2025-06-11 22:38:58 +05:30
Pooja
5c9981aca2 Fix: AWS v4 auth empty fields displaying "undefined" after save (#4814)
* Fix: AWS v4 auth empty fields displaying "undefined" after save
2025-06-11 14:27:45 +05:30
Pooja
fc697bf81b feat: support chai in scripts (#4552)
feat: support chai in scripts
2025-06-10 22:41:11 +05:30
lohit
9bc07afc77 initRunRequestEvent function for initializing request execution related details (#4863)
added a initRunRequestEvent function resetting and initializing request run event related details
2025-06-10 21:05:39 +05:30
Pooja
e4ae857df3 Merge pull request #4693 from pooja-bruno/mv/isValidValue-in-common-file
Fixed a bug causing secrets to appear as null instead of an empty value.

rm isValidValue and directly handle it in encryptString and `decryptString` function
2025-06-09 13:50:25 +05:30
Anoop M D
3d26833b8a Merge pull request #4837 from maintainer-bruno/feat/develop-hot-reload-js
feat(dev): enhance hot reload development setup
2025-06-07 13:21:13 +05:30
sreelakshmi-bruno
1089a52171 Tests for responseSize component (#4750)
---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-06-06 01:54:01 +05:30
lohit
9dde2df475 Merge pull request #4661 from devendra-bruno/fix/gql-introspection-variable-interpolation
Added combined Vars for prepareGqlIntrospectionRequest for all interp…
2025-06-05 18:05:45 +05:30
Maintainer Bruno
1cc94e8ffe feat(dev): enhance hot reload development setup 2025-06-04 16:56:22 +05:30
lohit
223f79a3e2 Merge pull request #4694 from usebruno/feature/playwright
Improvements in Playwright setup and added tests for running bruno-testbench
2025-06-04 15:18:34 +05:30
lohit
5dc6f6757d Merge pull request #4765 from lohxt1/single_line_editor_onedit
fix: single line editor component onChange validations update
2025-06-04 14:48:48 +05:30
lohit
e20fe790a6 Merge pull request #4782 from ramki-bruno/fix/proxy-pass-encoding
Fix: Special URI characters in proxy username/password is giving error
2025-06-04 14:48:26 +05:30
ramki-bruno
a006fe8230 Move Playwright tests to Tests Workflow itself
Currently the test-results and annotations form jobs are getting added
to random Workflow and there is no fix for it right now
Ref: https://github.com/EnricoMi/publish-unit-test-result-action/issues/12
Also Playwright tests have the same triggers as Tests, so no need to
keep it separate.
2025-05-30 13:57:44 +05:30
ramki-bruno
577d54b432 Added Playwright test for bruno-testbench, few sanity tests and improvements
- Trace will capture snapshots now
- Added ability to add init Electron user-data, preferences and other
  app settings.
- Improved test Fixtures
  - Use tempdir for Electron user-data
  - Ability to reuse app instance for a given init user-data by placing
    them in a folder(`pageWithUserData` Fixture)
  - Ability to create tests with fresh user-data(`newPage` Fixture)
- Improved logging
- Improved the env vars to customize the Electron user-data-path
2025-05-30 13:57:44 +05:30
maintainer-bruno
afaebf6b3d Merge pull request #4796 from lohxt1/collection_auth_default_values_issue
fix: collection auth default value access fix and validations
2025-05-29 18:28:57 +05:30
lohit
6e89001825 fix: collection auth default value access fix and validations 2025-05-29 17:45:42 +05:30
ramki-bruno
cb611c6510 Fix: Special URI characters in proxy username/password is giving error
URI-encoding the _username_ and _password_ before creating the proxy URI
which then gets passed to `HttpsProxyAgent` and `HttpProxyAgent`
respectively.
2025-05-28 14:45:21 +05:30
lohit
e7dd78ea53 Merge pull request #4775 from lohxt1/axios_instance_redirect_error_fix
fix: return the actual axios error in bruno-cli' axios-instance for url-redirect related errors
2025-05-27 20:27:48 +05:30
lohit
9ad0f2d169 revert custom error messages 2025-05-27 19:40:51 +05:30
lohit
bf19645282 revert test update 2025-05-27 19:40:22 +05:30
lohit
bb01199877 updates 2025-05-27 19:17:05 +05:30
lohit
5627c5624f updates 2025-05-27 19:16:29 +05:30
lohit
8e23a7054f Merge pull request #4770 from lohxt1/error_requests_cli_tests_issue
fix: consider request execution errors as `CLI Tests` workflow failure
2025-05-27 18:49:02 +05:30
lohit
d820069371 return the actual axios error with the custom error message in bruno-cli axios-instance 2025-05-27 18:41:47 +05:30
devendra-bruno
6f9daadcfb Update index.js Removed duplicate variable 2025-05-27 15:44:07 +05:30
lohit
2de9b87c6f consider errored request as a collection run fail 2025-05-27 15:30:54 +05:30
devendra-bruno
8d5d952026 Added runtimeVars in prepareGqlIntrospectionRequest 2025-05-27 14:38:48 +05:30
lohit
178773d63a Merge pull request #4173 from Pragadesh-45/feat/custom-installation-path
Feat/ Custom installation path for GUI installer on Windows
2025-05-27 11:49:29 +05:30
lohit
7994946c85 Merge pull request #4764 from lohxt1/shortcut_key_new_request_issue
fix: new request shortcut key
2025-05-27 11:49:15 +05:30
lohit
b020255269 Merge pull request #4662 from sanjaikumar-bruno/fix/cli-not-following-redirects
feat: enhance axios instance with redirect handling and cookie management in CLI
2025-05-27 11:48:43 +05:30
devendra-bruno
afb2d3dffd Updated resolved variable assignment and testcases 2025-05-26 22:52:37 +05:30
lohit
73b0f0919d Merge pull request #4679 from anusree-bruno/bugfix/timestamp-current-time
fix: ensure timestamp and isoTimestamp return current time instead of random values
2025-05-26 22:29:53 +05:30
Pragadesh-45
8975b9eef6 fix: update Windows build configuration for icon and publisher name 2025-05-26 21:06:01 +05:45
lohit
865e813b42 revert test bru file 2025-05-26 20:45:33 +05:30
lohit
51f36b1903 Merge pull request #4038 from Chriss4123/feature/localhost-secure-context
feat: Add RFC 6761–compliant localhost loopback checks so `secure` cookies work on localhost (fixes: #1676)
2025-05-26 17:16:33 +05:30
Clay Powers
6b122d7262 Switch GraphQL variables code editor to json linting (#4756) 2025-05-26 16:55:11 +05:30
devendra-bruno
9f1aed3209 Refactored fetch-gql-schema-handler.spec.js 2025-05-26 16:42:18 +05:30
devendra-bruno
ce1110bdd4 Added interpolate for header values 2025-05-26 16:39:40 +05:30
devendra-bruno
788569a5f4 Added testcases for prepare-gql-introspection-request.spec.js 2025-05-26 16:39:07 +05:30
devendra-bruno
91397eaf57 Renamed fetchGqlSchema to fetchGqlSchemaHandler 2025-05-26 16:38:09 +05:30
devendra-bruno
c293ceefcf Refactored fetch-gql-schema-handler.spec.js 2025-05-26 16:37:28 +05:30
lohit
a8e5ce9c13 fix: new request shortcut key 2025-05-26 14:58:25 +05:30
anusree-bruno
8ac916b0ff removed unwanted tests 2025-05-26 14:49:21 +05:30
anusree-bruno
8d860a051c replace real time with mocked time in faker tests 2025-05-26 14:43:23 +05:30
lohit
256f63dd38 single line editor comp onChange validations 2025-05-26 10:20:22 +05:30
devendra-bruno
0948964677 Revert changes to common.spec.js 2025-05-26 09:47:43 +05:30
Anoop M D
4ac2c4ac34 Merge pull request #4706 from ved-bruno/e2e_support
Playwright: Support Element Verification
2025-05-23 21:11:27 +05:30
maintainer-bruno
7c27193983 chore: add CODEOWNERS file for repository maintenance 2025-05-23 16:57:48 +05:30
ramki-bruno
2c3d2ff6a7 Make Secure-local-cookies work in CLI as well 2025-05-23 13:49:56 +05:30
Chriss4123
a4fff01647 Support Secure cookies for localhost and loopback addresses 2025-05-23 12:35:04 +05:30
sanjai0py
2cd985faf7 Remove test file for redirects with cookies 2025-05-23 08:58:28 +05:30
sanish chirayath
9a35302d4b Feature: implemented bru.interpolate (#4122)
* feat: enhance variable highlighting in CodeMirror and update interpolation method

* feat: add interpolate function to bru shim and corresponding tests

- Implemented the `interpolate` function in the bru shim to handle variable interpolation.
- Added a new test case for the `interpolate` function to verify its functionality with mock variables.

* feat: enhance interpolate function to support object interpolation

* feat: add translation support for pm.variables.replaceIn to bru.interpolate

* revert: eslint config changes

* revert: eslint config changes

* fix: update method call to use correct interpolation function in Bru class

* refactor: added jsdoc to codemirror highlighting code

* fix: higlighting for multiline editor
2025-05-22 15:37:15 +05:30
Pooja
553f7675f2 fix: request timer reset while switching tabs (#4165)
* fix" request timer reset while switching tabs

* fix

* rm: extraReducers

* improve: logic

* fix: pass startTime as prop

* fix

* fix: directly use collection in setRequestStartTime

* rm: reseting start time null
2025-05-22 15:36:26 +05:30
devendra-bruno
3e714ab9f8 Updated handler fetch-gql-schema 2025-05-21 17:54:53 +05:30
devendra-bruno
f2e9a6a502 Added folder level variable support 2025-05-21 17:39:10 +05:30
devendra-bruno
b924e15afa Added testcases for fetch-gql-schema-handler 2025-05-21 17:35:47 +05:30
devendra-bruno
b0c74909ba Updated argument request object for useGraphqlSchema hook 2025-05-21 17:35:17 +05:30
devendra-bruno
548a6b4319 Rename combinedVars to resolvedVars 2025-05-21 17:34:36 +05:30
sanjai0py
b299879b82 Refactor saveCookies function to remove disableCookies parameter and streamline cookie handling in response interceptors 2025-05-21 17:00:22 +05:30
Pooja
3696562414 fix: circular recursion for openapi import (#4729) 2025-05-21 15:10:35 +05:30
devendra-bruno
e02c6c274b Fix/svg render respone panel (#4655)
* Refactor getContentType in utils

* Add testcases for getContentType

* Refactor getContent

* Refactor getContent

* Refactor getContent

* Added testcase of case insensitivity

* Added content-type case in sensitive

* Refactor testcase spec

* Added testcases for empty content-type
2025-05-21 13:45:06 +05:30
devendra-bruno
9c9afaf78f Extracted fetchGqlSchema handler seperate from ipc handler 2025-05-21 06:42:19 +05:30
devendra-bruno
6cde453032 Added test for prepareGqlIntrospectionRequest 2025-05-21 06:41:18 +05:30
devendra-bruno
8f06889996 Remove mergeEnvironmnetVariable method from spec file 2025-05-21 06:40:21 +05:30
devendra-bruno
52662f0766 Updated testcases in prepare-gql-introspection spec 2025-05-19 17:39:39 +05:30
sanjai0py
ab0a4b8140 Add disableCookies option to axios instance and saveCookies function 2025-05-19 15:08:12 +05:30
sanjai0py
1b268ae9db Merge branch 'main' into fix/cli-not-following-redirects 2025-05-19 14:36:54 +05:30
ved-bruno
8debb9fd11 made suggested changes for support element verification 2025-05-19 14:02:34 +05:30
naman-bruno
7c07488e16 Merge pull request #4697 from lohxt1/req_get_name_test
fix: bruno converters test for reg.getName()
2025-05-16 21:52:30 +05:30
lohit
6073a9e2c3 fix bruno converters test for reg.getName() 2025-05-16 21:27:00 +05:30
lohit
9c652f86c9 Merge pull request #4696 from naman-bruno/bugfix/noproxy-option
add: noproxy flag in options
2025-05-16 21:06:29 +05:30
naman-bruno
3c0090d86f fix: runSingleRequest function 2025-05-16 21:02:24 +05:30
naman-bruno
9132755d49 add: noproxy in options 2025-05-16 20:52:15 +05:30
lohit
2a44691cb3 Merge pull request #4590 from poojabela/feat/add-getName-api-for-script
feat: add `req.getName` & `bru.getCollectionName` api
2025-05-16 20:21:35 +05:30
lohit
0d8a696498 Merge pull request #4609 from pooja-bruno/feat/extend-support-for-more-auth-for-folder-level
feat: extend support for more auth in folder level
2025-05-16 20:20:33 +05:30
lohit
bfa2706598 Merge pull request #4366 from Pragadesh-45/fix/import-curl
Feat: Enhance curl parsing for multipart form data
2025-05-16 20:20:18 +05:30
ved-bruno
5fdb52388a support element verification 2025-05-16 19:34:39 +05:30
Pooja
799dc9a1ca feat: add function in bruno converters package (#4669)
* feat: add  function in bruno converters package

* add: example for openapi yaml to bruno conversion

* add: converting json to yaml in converters

* fix
2025-05-16 17:26:40 +05:30
naman-bruno
2bb56e8a4b Fix: properly handling redirects with status code (#4561)
* Fix: properly handling redirects with status code

* fix: updated redirect logic for method change
2025-05-16 17:14:37 +05:30
Pragadesh-45
084d2bf692 test: add unit tests for basic functionality, headers, auth, and form data handling in parseCurlCommand 2025-05-16 14:32:30 +05:45
Pragadesh-45
10640c7561 feat: enhance curl parsing for multipart form data
- Updated `parseCurlCommand` to handle `-F` and `--form` flags, allowing for multiple form fields with file uploads.
- Adjusted `curlToJson` to set `Content-Type` for multipart data and handle binary data correctly.
2025-05-16 14:32:05 +05:45
naman-bruno
9f044c48fe Added proxy flag for cli (#3963)
* system level proxy depends on proxy flag

* added proxy flag

* fix: proxy flag behaviour

* fix: noproxy flag logic
2025-05-16 14:02:11 +05:30
devendra-bruno
5567e1b7f2 Fixed typo in prepareGqlIntrospectionRequest 2025-05-16 00:47:49 +05:30
devendra-bruno
3cd18d1e16 Added testcases for prepare-gql-introspection-request 2025-05-16 00:43:58 +05:30
devendra-bruno
9d3e42b5d4 Update prepareGqlIntrospectionRequest change assignment sequence 2025-05-16 00:43:27 +05:30
devendra-bruno
0f318c26c2 Updated precedence in combinedVars object 2025-05-16 00:42:27 +05:30
Anoop M D
79f4e69a05 Merge pull request #4160 from usebruno/feature/custom-user-data-path-for-dev 2025-05-15 16:18:28 +05:30
ramki-bruno
f13148af3d Added option to customize userData path on dev mode
If `ELECTRON_APP_NAME` env-variable is present and its development mode,
then the `appName` and `userData` path is modified accordingly.

e.g.
```sh
ELECTRON_APP_NAME=bruno-dev npm run dev:electron
```

Note: This doesn't change the name of the window or the names in lot of
other places, only the name used by Electron internally.
2025-05-15 16:12:51 +05:30
devendra-bruno
6598d23ff0 Removed mergeEnvrionmentVariables tests from common.spec.js 2025-05-15 15:57:43 +05:30
devendra-bruno
c83436655c Remove mergeEnvironmnetVariables from common utils 2025-05-15 15:57:00 +05:30
devendra-bruno
62595c519c Added lodash merge for combining vars before interpolateVars 2025-05-15 15:56:30 +05:30
anusree-bruno
d2eb2d2941 fix: ensure timestamp and isoTimestamp return current time instead of random values 2025-05-15 14:11:53 +05:30
Anoop M D
942c0ee113 Merge pull request #4671 from lohxt1/lint_gh_workflow_step
add lint step to the unit-tests gh workflow
2025-05-14 17:58:54 +05:30
pooja-bruno
fbd3a38587 fix 2025-05-14 17:55:50 +05:30
pooja-bruno
45b660985e fix: ui 2025-05-14 17:45:03 +05:30
pooja-bruno
0888125899 add: default auth mode inherit in folder 2025-05-14 16:12:48 +05:30
pooja-bruno
c85d9bcd84 fix: folder inherit auth 2025-05-14 16:01:42 +05:30
lohit
dbf8af1146 Merge branch 'main' into lint_gh_workflow_step 2025-05-14 15:41:35 +05:30
lohit
d7ccf1454e revert lint-staged step, make lint check as part of gh unit-tests workflow 2025-05-14 14:05:49 +05:30
Anoop M D
652d447f8b Merge pull request #4667 from usebruno/feature/playwright
Customize Playwright reporter and retries for dev and CI env
2025-05-14 13:28:03 +05:30
devendra-bruno
8e91640084 Added mergeEnvironmentVariables method for gql prep method 2025-05-14 12:25:41 +05:30
devendra-bruno
0ca2891166 Added mergeEnvironmentVariables method in electron common utils export 2025-05-14 12:24:09 +05:30
devendra-bruno
5000bb8db3 Added testcases for mergeEnvironmentVariables method 2025-05-14 12:23:32 +05:30
devendra-bruno
9927424826 Added mergeEnvironmentVariables method in electron common utils 2025-05-14 12:22:39 +05:30
ramki-bruno
2f58379feb Customize Playwright reporter and retries for dev and CI env 2025-05-14 11:54:56 +05:30
sanjai0py
c14d3f4274 feat: add test case for redirects with cookie authentication 2025-05-14 10:46:14 +05:30
Anoop M D
d4673a2f07 Merge pull request #4665 from lohxt1/eslint_node_space_issue
fix: eslint - javascript heap out of memory
2025-05-14 00:34:03 +05:30
Anoop M D
3a0c94577f Merge pull request #4666 from sreelakshmi-bruno/update_contributing_guidelines
Updating contributing.md
2025-05-13 23:59:59 +05:30
sanjai0py
5a4e33e503 Merge branch 'main' into fix/cli-not-following-redirects 2025-05-13 20:07:29 +05:30
sanjai0py
5649799167 feat: add maxRedirects configuration to runSingleRequest 2025-05-13 20:02:29 +05:30
sreelakshmi-bruno
c407b73c22 Updating contributing.md 2025-05-13 19:42:00 +05:30
lohit
361add3385 handle folder run action while a collection run is in progress (#4658)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-13 19:41:39 +05:30
lohit
9d6ab69d37 eslint - node out of memeory issue 2025-05-13 19:21:30 +05:30
sanjai0py
0f6da35c0b feat: enhance axios instance with redirect handling and cookie management 2025-05-13 17:27:55 +05:30
devendra-bruno
ad3f5de99a Added combined variable object for gqlIntrospectionRequest 2025-05-13 17:05:37 +05:30
devendra-bruno
2de7ba0d0c Added combined Vars for prepareGqlIntrospectionRequest for all interpolate 2025-05-13 16:06:20 +05:30
ramki-bruno
b699088dd6 Create/Import collection UX improvements (#4540)
* Fix: Improve UX for selecting location when create/import collection

Allow editing the input path where previously the `<input>` is marked
`readonly`.
Also this will allow automating test using Playwright.

* Fix: Import-collection select-location Modal closes on error

* Improved error-toast for creating and importing collections

- Added a util for formatting the error form IPC
- Updated Toast global styles to prevent text overflow.
  Whenever long file paths are shown, it overflows the Toast container.
2025-05-13 14:25:35 +05:30
ganesh
458c070004 Fix: Specify Node.js version in Contributing Guide (#4656)
* fix node version on contributing file

* updated to node 22 version
2025-05-13 14:10:10 +05:30
Anoop M D
babac6df3c Merge pull request #4651 from usebruno/feature/playwright
Playwright Codegen and CI setup
2025-05-12 22:08:31 +05:30
Pooja
f58477931f feat: add support for oauth2 in cli (#4578)
Co-authored-by: Pooja Belaramani <109731557+poojabela@users.noreply.github.com>
2025-05-12 21:37:42 +05:30
lohit
2171d491a6 Merge pull request #4641 from lohxt1/folder_sequencing_cli
refactor: `bruno-cli` - follow folder/request sequences during collection runs
2025-05-12 21:19:58 +05:30
ramki-bruno
aa911f88f2 Playwright Codegen and CI setup
- Improved the Codegen setup
  - Removed the app-launch related boilerplate from tests
  - Enable recording mode by default
  - Option to provide the test file name to save the recording
- Added GitHub workflow to run Playwright tests with Electron in
  Headless mode(mocking display using `xvfb`).
2025-05-12 20:35:05 +05:30
lohit
bdbcaeff67 updates 2025-05-12 20:06:26 +05:30
lohit
b2756b3c63 updates 2025-05-12 17:37:45 +05:30
lohit
27f11ab583 updates 2025-05-12 17:19:13 +05:30
lohit
2776970317 revert collection-pathname param 2025-05-12 16:57:27 +05:30
Anoop M D
9d28bf7e82 Merge pull request #4571 from pooja-bruno/feat/extend-cli-auth-for-Inherit-and-req
feat: extend cli auth for inherit and request
2025-05-12 16:15:17 +05:30
ramki-bruno
6455b00742 Removed old Playwright setup 2025-05-12 12:12:21 +05:30
lohit
16179a3b50 refactored getCollectionJsonFromPathname function and added tests 2025-05-11 23:08:44 +05:30
Anoop M D
6a37c9d076 Merge pull request #4636 from sreelakshmi-bruno/add-build-commands
Adding build instructions for new packages
2025-05-10 17:03:14 +05:30
Anoop M D
1915b1c00a Fix/add missing translations (#4637)
* fix: add missing deps
* feat: add missing translations
* fix: regex tranasaltion for to.have.headers
2025-05-10 16:59:21 +05:30
lohit
a9982d6e28 removed unused collectionPathnmae prop to components (#4640)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-10 15:11:26 +05:30
sanish-bruno
1daeb8fe93 fix: regex tranasaltion for to.have.headers 2025-05-09 19:12:52 +05:30
sanish-bruno
3dfb158382 feat: add missing translations 2025-05-09 17:56:32 +05:30
sanish-bruno
fb7d247fa7 fix: add missing deps 2025-05-09 17:37:16 +05:30
lohit
6bf2312a94 Merge branch 'main' into folder_sequencing_cli 2025-05-09 16:34:50 +05:30
sreelakshmi-bruno
0cdcb83a7a Adding build instructions for new packages 2025-05-09 15:48:49 +05:30
sanish chirayath
e4f48e81fc feat: add setBody test script to bruno-tests collection (#4415) 2025-05-09 14:16:29 +05:30
lohit
1d32a95a09 Merge pull request #4628 from lohxt1/fix_tests_converters_package
fix: tests for postmanToBrunoEnvironment function
2025-05-09 01:45:56 +05:30
lohit
4c934a78a6 fix tests for postmanToBrunoEnvironment function 2025-05-09 00:07:58 +05:30
Pooja
c47bc86d37 fix: Improve Variable Highlighting in Code Generation and Environment Views (#4593) 2025-05-08 21:53:14 +05:30
Sanjai Kumar
a125781312 feat/replace unsupported characters in env key during pm import (#4618)
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
2025-05-08 21:52:58 +05:30
sanish chirayath
dfa951e574 Feature: postman to bru translator (#4534) 2025-05-08 21:51:21 +05:30
naman-bruno
76779e6f95 Merge pull request #4615 from pooja-bruno/feat/add-openapi-to-bruno-import-in-cli
Feat: add openapi to bruno import in cli
2025-05-08 19:53:00 +05:30
lohit
e9a79a32da lint error fixes (#4623)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-08 18:12:16 +05:30
lohit
967170a7b2 eslint for bruno-app and bruno-electron packages (#4622)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-08 18:09:55 +05:30
Anoop M D
3326784315 Merge pull request #4620 from lohxt1/fix_regex_tests
fix: tests for sanitizeName function
2025-05-08 18:07:41 +05:30
lohit
fc553e1009 fix sanitize name function tests in bruno-electron 2025-05-07 22:24:52 +05:30
lohit
da172ff9b5 fix sanitize name function tests 2025-05-07 22:12:57 +05:30
lohit
fc422853ef update T_RunnerResults type to include summary prop (#4617)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-07 19:19:40 +05:30
Pooja Belaramani
2852c07ec7 feat: support tv4 as a inbuilt lib (#4589) 2025-05-07 17:44:29 +05:30
Anoop M D
ead1c9ecab Merge pull request #4542 from pooja-bruno/fix/app-crash-when-we-rename-folder-in-fs
fix: app crash when we rename folder in fs
2025-05-07 17:42:09 +05:30
Anoop M D
5b5066577f Merge pull request #4373 from vishnuprasanth-j/bugfix/regression-4350
Fix: Leading dot is not allowed for collections, folders and requests
2025-05-07 17:39:25 +05:30
sreelakshmi-bruno
4af0bb3943 Fix: ResponseSize component logic to handle size when it's undefined (#4613)
* Fix: ResponseSize component logic to handle size when it's undefined
* Improved check for valid response size

---------

Co-authored-by: Sreelakshmi Jayarajan <sreelakshmi@Sreelakshmis-MacBook-Air.local>
2025-05-07 16:59:35 +05:30
pooja-bruno
f2eaa79318 Feat: add openapi to bruno import in cli 2025-05-07 15:36:21 +05:30
lohit
2ee7ce5829 persist request/folder uids after request/folder resequencing and ui updates (#4611)
* move file/folder uids to new paths

* drag file/folder preview ui updates, can item be dropped ui hint check

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-05-06 22:20:59 +05:30
pooja-bruno
0d7c94e7e9 add: auth for other 2025-05-06 18:41:40 +05:30
pooja-bruno
9e29821012 feat: extend support for more auth in folder level 2025-05-06 17:56:34 +05:30
lohit
38c307d6f1 feat: folder sequencing (#4595)
Co-authored-by: Pooja Belaramani <pooja@usebruno.com>
Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-05 16:52:00 +05:30
lohit
520567793a feat(cli): Refactor request runner and improve folder sorting
~ remove duplicate code by consolidating request collection functionality
~ add proper sorting of folder and request items based on sequence numbers
~ add error request count to run summary output
~ update dev dependencies with proper markings in package-lock.json and removed previously added currently reverted entries
2025-05-03 23:50:12 +05:30
poojabela
e0fb379511 add: bru.collectionName api 2025-04-30 17:25:42 +05:30
poojabela
ba9362ccb2 add: getName in collection 2025-04-30 15:36:44 +05:30
poojabela
261a36c435 add: getName in hint 2025-04-30 15:29:10 +05:30
poojabela
cb92e46f8d feat: add req.getName api 2025-04-30 15:14:36 +05:30
Pooja Belaramani
126186041e add: tests for request level auth 2025-04-28 15:18:18 +05:30
Pooja Belaramani
6379e1703c feat: extend cli auth for inherit and request 2025-04-28 12:52:52 +05:30
Pooja Belaramani
2b246e431b change: no found folder tab 2025-04-25 15:58:53 +05:30
Pooja Belaramani
529803f791 fix: app crash when we rename folder in fs 2025-04-22 13:18:25 +05:30
ramki-bruno
b93d8e73a2 Allow leading dot for file and folder names
Co-authored-by: vishnuprasanth-j <jvpvis6@gmail.com>
2025-04-16 03:26:38 +05:30
ramki-bruno
17c9813c98 Regularize RegEx patterns for validating filenames
- Fixed inconsistency in validating last character between
  bruno-electron and bruno-app.
- Fixed inconsistency in sanitizing first character between
  bruno-electron and bruno-app.
- Updated the comments to accurately reflect the patterns
- Fixed inconsistencies in escaping certain characters in the patterns
  itself.
2025-04-16 03:20:58 +05:30
Pragadesh-45
f6ab59ceda feat: update Windows build configuration to support custom installation path from GUI installer 2025-03-06 17:40:15 +05:30
Pragadesh-45
f1004e2e36 Merge branch 'main' of https://github.com/Pragadesh-45/bruno 2025-03-06 00:27:18 +05:30
Pragadesh-45
26eaec4c72 Merge branch 'usebruno:main' into main 2025-02-07 10:01:15 +05:30
Pragadesh-45
d0419edb92 fix: correct variable used in collection name update 2025-02-04 17:49:40 +05:30
259 changed files with 16445 additions and 2229 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @helloanoop @maintainer-bruno @lohit-bruno @naman-bruno

View File

@@ -31,6 +31,9 @@ jobs:
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
- name: Lint Check
run: npm run lint
# tests
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js
@@ -88,5 +91,43 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

6
.gitignore vendored
View File

@@ -46,4 +46,8 @@ yarn-error.log*
#dev editor
bruno.iml
.idea
.idea
.vscode
# Playwright
/blob-report/

View File

@@ -15,15 +15,15 @@
| [正體中文](docs/contributing/contributing_zhtw.md)
| [日本語](docs/contributing/contributing_ja.md)
| [हिंदी](docs/contributing/contributing_hi.md)
| [Nederlands](docs/contributing/contributing_nl.md)
| [Dutch](docs/contributing/contributing_nl.md)
## Let's make Bruno better, together!!
We are happy that you are looking to improve Bruno. Below are the guidelines to get started bringing up Bruno on your computer.
We are happy that you are looking to improve Bruno. Below are the guidelines to run Bruno on your computer.
### Technology Stack
Bruno is built using Next.js and React. We also use electron to ship a desktop version (that supports local collections)
Bruno is built using React and Electron.
Libraries we use
@@ -42,30 +42,49 @@ Libraries we use
## Development
Bruno is being developed as a desktop app. You need to load the app by running the Next.js app in one terminal and then run the electron app in another terminal.
Bruno is a desktop app. Below are the instructions to run Bruno.
> Note: We use React for the frontend and rsbuild for build and dev server.
## Install Dependencies
```bash
# use nodejs 20 version
# use nodejs 22 version
nvm use
# install deps
npm i --legacy-peer-deps
```
### Local Development (Option 1)
### Local Development
```bash
#### Build packages
##### Option 1
```bash
# build packages
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
```
##### Option 2
```bash
# install dependencies and setup
npm run setup
```
#### Run the app
##### Option 1
```bash
# run react app (terminal 1)
npm run dev:web
@@ -73,16 +92,21 @@ npm run dev:web
npm run dev:electron
```
### Local Development (Option 2)
##### Option 2
```bash
# install dependencies and setup
npm run setup
# run electron and react app concurrently
npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
This will create a `bruno-test` folder on your Desktop and use it as the `userData` path.
### Troubleshooting
You might encounter a `Unsupported platform` error when you run `npm install`. To fix this, you will need to delete `node_modules` and `package-lock.json` and run `npm install`. This should install all the necessary packages needed to run the app.
@@ -101,7 +125,28 @@ find . -type f -name "package-lock.json" -delete
```bash
# run bruno-schema tests
npm test --workspace=packages/bruno-schema
npm run test --workspace=packages/bruno-schema
# run bruno-query tests
npm run test --workspace=packages/bruno-query
# run bruno-common tests
npm run test --workspace=packages/bruno-common
# run bruno-converters tests
npm run test --workspace=packages/bruno-converters
# run bruno-app tests
npm run test --workspace=packages/bruno-app
# run bruno-electron tests
npm run test --workspace=packages/bruno-electron
# run bruno-lang tests
npm run test --workspace=packages/bruno-lang
# run bruno-toml tests
npm run test --workspace=packages/bruno-toml
# run tests over all workspaces
npm test --workspaces --if-present

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# Next.js ऐप चलाएँ (टर्मिनल 1 पर)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# run next app (terminal 1)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# next 앱 실행 (1번 터미널)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# draai next app (terminal 1)
npm run dev:web

View File

@@ -42,6 +42,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# spustite ďalšiu aplikáciu (terminál 1)
npm run dev:web

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@@ -0,0 +1,31 @@
import { test, expect } from '../../playwright';
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.getByPlaceholder('Request URL').click();
await page.getByPlaceholder('Request URL').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('pre').filter({ hasText: 'http://localhost:' }).click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByRole('img').nth(2).click();
await expect(page.getByRole('main')).toContainText('200 OK');
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByText('GETr1').click();
await page.getByRole('button', { name: 'Clear response' }).click();
await page.locator('body').press('ControlOrMeta+Enter');
await expect(page.getByRole('main')).toContainText('200 OK');
});

View File

@@ -0,0 +1,4 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/packages/bruno-tests/collection"]
}

View File

@@ -0,0 +1,49 @@
import { test, expect } from '../../playwright';
test.describe.parallel('Run Testbench Requests', () => {
test('Run bruno-testbench in Developer Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
});
test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Safe ModeBETA').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - 1);
});
});

View File

@@ -0,0 +1,27 @@
import { test, expect } from '../../playwright';
test('Should verify all support links with correct URL in preference > Support tab', async ({ page }) => {
// Open Preferences
await page.getByLabel('Open Preferences').click();
// Verify Support tab
await page.getByRole('tab', { name: 'Support' }).click();
const locator_twitter = page.getByRole('link', { name: 'Twitter' });
expect(await locator_twitter.getAttribute('href')).toEqual('https://twitter.com/use_bruno');
const locator_github = page.getByRole('link', { name: 'GitHub', exact: true });
expect(await locator_github.getAttribute('href')).toEqual('https://github.com/usebruno/bruno');
const locator_discord = page.getByRole('link', { name: 'Discord', exact: true });
expect(await locator_discord.getAttribute('href')).toEqual('https://discord.com/invite/KgcZUncpjq');
const locator_reportissues = page.getByRole('link', { name: 'Report Issues', exact: true });
expect(await locator_reportissues.getAttribute('href')).toEqual('https://github.com/usebruno/bruno/issues');
const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true });
expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com');
});

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
// eslint.config.js
const { defineConfig } = require("eslint/config");
const globals = require("globals");
module.exports = defineConfig([
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.browser,
...globals.jest,
global: false,
require: false,
Buffer: false,
process: false
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
"no-undef": "error",
},
}
]);

4779
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,17 @@
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1",
"@playwright/test": "^1.51.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
"husky": "^8.0.3",
"globals": "^16.1.0",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
@@ -37,6 +40,7 @@
"setup": "node ./scripts/setup.js",
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",
@@ -55,11 +59,10 @@
"build:electron:rpm": "./scripts/build-electron.sh rpm",
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"test:codegen": "npm run dev:web & node ./scripts/playwright-codegen.js",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"prepare": "husky install"
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
},
"overrides": {
"rollup": "3.29.5",
@@ -69,4 +72,4 @@
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
module.exports = {
presets: [
'@babel/preset-env',
['@babel/preset-react', {
runtime: 'automatic'
}]
],
plugins: ['babel-plugin-styled-components']
};

View File

@@ -12,5 +12,14 @@ module.exports = {
},
clearMocks: true,
moduleDirectories: ['node_modules', 'src'],
testEnvironment: 'node'
testEnvironment: 'jsdom',
transform: {
'^.+\\.[jt]sx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!(nanoid|xml-formatter)/)'
],
testMatch: [
'<rootDir>/src/**/*.spec.[jt]s?(x)'
]
};

View File

@@ -1,6 +1,7 @@
{
"name": "@usebruno/app",
"version": "2.0.0",
"license": "MIT",
"private": true,
"scripts": {
"dev": "rsbuild dev",
@@ -11,7 +12,6 @@
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
},
"dependencies": {
"@babel/preset-env": "^7.26.0",
"@fontsource/inter": "^5.0.15",
"@prantlf/jsonlint": "^16.0.0",
"@reduxjs/toolkit": "^1.8.0",
@@ -82,19 +82,28 @@
"yup": "^0.32.11"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^9.3.3",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"autoprefixer": "10.4.20",
"babel-jest": "^29.7.0",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
"babel-plugin-styled-components": "^2.1.4",
"cross-env": "^7.0.3",
"css-loader": "7.1.2",
"file-loader": "^6.2.0",
"html-loader": "^3.0.1",
"html-webpack-plugin": "^5.5.0",
"jest-environment-jsdom": "^29.7.0",
"mini-css-extract-plugin": "^2.4.5",
"postcss": "8.4.47",
"style-loader": "^3.3.1",
@@ -102,4 +111,4 @@
"webpack": "^5.64.4",
"webpack-cli": "^4.9.1"
}
}
}

View File

@@ -19,7 +19,7 @@ export default defineConfig({
})
],
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
},
html: {
title: 'Bruno'
@@ -34,6 +34,16 @@ export default defineConfig({
},
},
},
ignoreWarnings: [
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
],
// Add externals configuration to exclude Node.js libraries
externals: {
// List specific Node.js modules you want to exclude
// Format: 'module-name': 'commonjs module-name'
'worker_threads': 'commonjs worker_threads',
// 'path': 'commonjs path'
}
},
}
});

View File

@@ -58,6 +58,7 @@ if (!SERVER_RENDERED) {
'req.getTimeout()',
'req.setTimeout(timeout)',
'req.getExecutionMode()',
'req.getName()',
'bru',
'bru.cwd()',
'bru.getEnvName()',
@@ -80,12 +81,14 @@ if (!SERVER_RENDERED) {
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getCollectionName()',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()'
'bru.runner.stopExecution()',
'bru.interpolate(str)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@@ -363,7 +366,7 @@ export default class CodeEditor extends React.Component {
let variables = getAllVariables(this.props.collection, this.props.item);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, mode);
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -21,12 +21,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -38,12 +38,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -55,12 +55,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -72,12 +72,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -89,12 +89,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@@ -106,12 +106,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: profileName || ''
}
})
);

View File

@@ -21,8 +21,8 @@ const BasicAuth = ({ collection }) => {
mode: 'basic',
collectionUid: collection.uid,
content: {
username: username,
password: basicAuth.password
username: username || '',
password: basicAuth.password || ''
}
})
);
@@ -34,8 +34,8 @@ const BasicAuth = ({ collection }) => {
mode: 'basic',
collectionUid: collection.uid,
content: {
username: basicAuth.username,
password: password
username: basicAuth.username || '',
password: password || ''
}
})
);

View File

@@ -21,8 +21,8 @@ const DigestAuth = ({ collection }) => {
mode: 'digest',
collectionUid: collection.uid,
content: {
username: username,
password: digestAuth.password
username: username || '',
password: digestAuth.password || ''
}
})
);
@@ -34,8 +34,8 @@ const DigestAuth = ({ collection }) => {
mode: 'digest',
collectionUid: collection.uid,
content: {
username: digestAuth.username,
password: password
username: digestAuth.username || '',
password: password || ''
}
})
);

View File

@@ -28,9 +28,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
username: username || '',
password: ntlmAuth.password || '',
domain: ntlmAuth.domain || ''
}
})
@@ -43,9 +43,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: ntlmAuth.username,
password: password,
domain: ntlmAuth.domain
username: ntlmAuth.username || '',
password: password || '',
domain: ntlmAuth.domain || ''
}
})
);
@@ -57,9 +57,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: ntlmAuth.username,
password: ntlmAuth.password,
domain: domain
username: ntlmAuth.username || '',
password: ntlmAuth.password || '',
domain: domain || ''
}
})
);

View File

@@ -21,8 +21,8 @@ const WsseAuth = ({ collection }) => {
mode: 'wsse',
collectionUid: collection.uid,
content: {
username,
password: wsseAuth.password
username: username || '',
password: wsseAuth.password || ''
}
})
);
@@ -34,8 +34,8 @@ const WsseAuth = ({ collection }) => {
mode: 'wsse',
collectionUid: collection.uid,
content: {
username: wsseAuth.username,
password
username: wsseAuth.username || '',
password: password || ''
}
})
);

View File

@@ -72,7 +72,7 @@ const Info = ({ collection }) => {
</div>
</div>
</div>
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} onClose={handleToggleShowShareCollectionModal(false)} />}
</div>
</div>
</div>

View File

@@ -49,7 +49,7 @@ const CollectionSettings = ({ collection }) => {
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(collection, 'root.request.auth', {}).mode;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
@@ -155,7 +155,7 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{auth !== 'none' && <ContentIndicator />}
{authMode !== 'none' && <ContentIndicator />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -13,11 +13,18 @@ import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
@@ -160,7 +167,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
collection={collection}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}

View File

@@ -11,6 +11,12 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -9,6 +9,14 @@ import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/Passwo
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import AuthMode from '../AuthMode';
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
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';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -37,12 +45,132 @@ 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;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder
for (let i = 0; i < folderTreePath.length - 1; i++) {
const parentFolder = folderTreePath[i];
if (parentFolder.type === 'folder') {
const folderAuth = get(parentFolder, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: parentFolder.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return (
<BasicAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'bearer': {
return (
<BearerAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'digest': {
return (
<DigestAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'ntlm': {
return (
<NTLMAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'apikey': {
return (
<ApiKeyAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'awsv4': {
return (
<AwsV4Auth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'oauth2': {
return (
<>
@@ -56,6 +184,17 @@ const Auth = ({ collection, folder }) => {
</>
);
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
}
case 'none': {
return null;
}
@@ -64,6 +203,7 @@ const Auth = ({ collection, folder }) => {
}
};
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">

View File

@@ -35,6 +35,51 @@ const AuthMode = ({ collection, folder }) => {
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
@@ -44,6 +89,33 @@ const AuthMode = ({ collection, folder }) => {
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('inherit');
}}
>
Inherit
</div>
<div
className="dropdown-item"
onClick={() => {

View File

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

View File

@@ -130,7 +130,7 @@ class MultiLineEditor extends Component {
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ item, collection }) => {
const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
const apikeyAuth = get(request, 'auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const Icon = forwardRef((props, ref) => {
return (
@@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'header');
}}
>
@@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
Query Param
</div>
</Dropdown>
</div>

View File

@@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
const AwsV4Auth = ({ onTokenChange, item, collection }) => {
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
const awsv4Auth = get(request, 'auth.awsv4', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ item, collection }) => {
const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
const basicAuth = get(request, 'auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ item, collection }) => {
const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = item.draft
? get(item, 'draft.request.auth.bearer.token', '')
: get(item, 'request.auth.bearer.token', '');
// Use the request prop directly like OAuth2ClientCredentials does
const bearerToken = get(request, 'auth.bearer.token', '');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleTokenChange = (token) => {
dispatch(

View File

@@ -3,18 +3,20 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ item, collection }) => {
const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
const digestAuth = get(request, 'auth.digest', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const NTLMAuth = ({ item, collection }) => {
const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {});
const ntlmAuth = get(request, 'auth.ntlm', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(
@@ -26,7 +29,6 @@ const NTLMAuth = ({ item, collection }) => {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
}
})
);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from "react";
import { find } from "lodash";
import StyledWrapper from "./StyledWrapper";
import { useState, useEffect } from "react";
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const wsseAuth = get(request, 'auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUserChange = (username) => {
dispatch(
@@ -55,6 +58,7 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
@@ -67,6 +71,8 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
/>
</div>
</StyledWrapper>

View File

@@ -7,6 +7,8 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@@ -27,6 +29,16 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Create a request object to pass to the auth components
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
// Save function for request level
const save = () => {
return saveRequest(item.uid, collection.uid);
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@@ -59,28 +71,28 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} />;
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'basic': {
return <BasicAuth collection={collection} item={item} />;
return <BasicAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
return <BearerAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'digest': {
return <DigestAuth collection={collection} item={item} />;
return <DigestAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'ntlm': {
return <NTLMAuth collection={collection} item={item} />;
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} />;
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
return <WsseAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();

View File

@@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown';
const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => {
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
const request = item.draft ? item.draft.request : item.request;
const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
let {
schema,

View File

@@ -64,9 +64,10 @@ const GraphQLVariables = ({ variables, item, collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
/>
</>
);

View File

@@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
</StyledWrapper>
);

View File

@@ -49,7 +49,7 @@ const RequestBody = ({ item, collection }) => {
<StyledWrapper className="w-full">
<CodeEditor
collection={collection}
item={item}
item={item}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
@@ -58,13 +58,14 @@ const RequestBody = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
/>
</StyledWrapper>
);
}
if (bodyMode === 'file') {
return <FileBody item={item} collection={collection}/>
return <FileBody item={item} collection={collection} />;
}
if (bodyMode === 'formUrlEncoded') {
@@ -77,4 +78,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
const FolderNotFound = ({ folderUid }) => {
const dispatch = useDispatch();
const [showErrorMessage, setShowErrorMessage] = useState(false);
const closeTab = useCallback(() => {
dispatch(
closeTabs({
tabUids: [folderUid]
})
);
}, [dispatch, folderUid]);
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>Folder no longer exists.</div>
<div className="mt-2">
This can happen when the folder was renamed or deleted on your filesystem.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
Close Tab
</button>
</div>
);
};
export default FolderNotFound;

View File

@@ -25,7 +25,7 @@ import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import FolderNotFound from './FolderNotFound';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -165,11 +165,7 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
if (!folder) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
})
);
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
return <FolderSettings collection={collection} folder={folder} />;

View File

@@ -76,7 +76,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{tab.type === 'folder-settings' ? (
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
@@ -261,13 +263,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
return (
<Fragment>
{showAddNewRequestModal && (
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
<NewRequest collectionUid={collection.uid} onClose={() => setShowAddNewRequestModal(false)} />
)}
{showCloneRequestModal && (
<CloneCollectionItem
item={currentTabItem}
collection={collection}
collectionUid={collection.uid}
onClose={() => setShowCloneRequestModal(false)}
/>
)}

View File

@@ -79,7 +79,7 @@ const RequestTabs = () => {
return (
<StyledWrapper className={getRootClassname()}>
{newRequestModalOpen && (
<NewRequest collection={activeCollection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>

View File

@@ -17,7 +17,7 @@ const ResponseLoadingOverlay = ({ item, collection }) => {
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>
<StopWatch requestTimestamp={item?.requestSent?.timestamp} />
<StopWatch startTime={item?.requestStartTime} />
</div>
</div>
<IconRefresh size={24} className="loading-icon" />

View File

@@ -0,0 +1,110 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import ResponseSize from './index';
// Create minimal theme with only the properties needed for the component
const theme = {
requestTabPanel: {
responseStatus: '#666'
}
};
// Wrap component with theme provider for styled-components
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
describe('ResponseSize', () => {
describe('Invalid or excluded size values', () => {
it('should not render when size is undefined', () => {
const { container } = renderWithTheme(<ResponseSize size={undefined} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is null', () => {
const { container } = renderWithTheme(<ResponseSize size={null} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is NaN', () => {
const { container } = renderWithTheme(<ResponseSize size={NaN} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is Infinity', () => {
const { container } = renderWithTheme(<ResponseSize size={Infinity} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is -Infinity', () => {
const { container } = renderWithTheme(<ResponseSize size={-Infinity} />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is a string', () => {
const { container } = renderWithTheme(<ResponseSize size="1024" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render when size is an object', () => {
const { container } = renderWithTheme(<ResponseSize size={{value: 1024}} />);
expect(container).toBeEmptyDOMElement();
});
});
describe('Valid size values', () => {
it('should handle zero bytes', () => {
renderWithTheme(<ResponseSize size={0} />);
const element = screen.getByText(/0B/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^0B$/);
expect(element).toHaveAttribute('title', '0B');
});
it('should render bytes when size is less than 1024', () => {
renderWithTheme(<ResponseSize size={500} />);
const element = screen.getByText(/500B/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^500B$/);
expect(element).toHaveAttribute('title', '500B');
});
it('should handle exactly 1024 bytes as size', () => {
renderWithTheme(<ResponseSize size={1024} />);
const element = screen.getByText(/1024B/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^1024B$/);
expect(element).toHaveAttribute('title', '1,024B');
});
it('should render kilobytes when size is greater than 1024', () => {
renderWithTheme(<ResponseSize size={1500} />);
const element = screen.getByText(/1\.46KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', '1,500B');
});
it('should handle large size numbers', () => {
renderWithTheme(<ResponseSize size={10240} />);
const element = screen.getByText(/10\.0KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', '10,240B');
});
it('should handle decimal size numbers', () => {
renderWithTheme(<ResponseSize size={1126.5} />);
const element = screen.getByText(/1\.10KB/);
expect(element).toBeInTheDocument();
expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
expect(element).toHaveAttribute('title', '1,126.5B');
});
});
});

View File

@@ -2,14 +2,20 @@ import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseSize = ({ size }) => {
if (!Number.isFinite(size)) {
return null;
}
let sizeToDisplay = '';
// If size is greater than 1024 bytes, format as KB
if (size > 1024) {
// size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
// If size is less than or equal to 1024 bytes, display as bytes (B)
sizeToDisplay = size + 'B';
}

View File

@@ -1,6 +1,18 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.test-summary {
transition: background-color 0.2s;
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
color: ${(props) => props.theme.text};
&:hover {
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
.test-success {
color: ${(props) => props.theme.colors.text.green};
}
@@ -9,12 +21,24 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
.test-success-count {
color: ${(props) => props.theme.colors.text.green};
}
.test-failure-count {
color: ${(props) => props.theme.colors.text.danger};
}
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
.skipped-request {
color: ${(props) => props.theme.colors.text.muted};
.test-results-list {
transition: all 0.3s ease;
}
.dropdown-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
}
`;

View File

@@ -1,63 +1,151 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
import {
IconChevronDown,
IconChevronRight,
IconCircleCheck,
IconCircleX
} from '@tabler/icons';
const TestResults = ({ results, assertionResults }) => {
const ResultIcon = ({ status }) => (
<span className={`inline-flex items-center ${status === 'pass' ? 'test-success' : 'test-failure'}`}>
{status === 'pass' ? (
<IconCircleCheck size={14} className="mr-1" aria-label="Test passed" />
) : (
<IconCircleX size={14} className="mr-1" aria-label="Test failed" />
)}
</span>
);
const ErrorMessage = ({ error }) => error && (
<>
<br />
<span className="error-message pl-8" role="alert">
{error}
</span>
</>
);
const ResultItem = ({ result, type }) => (
<div className="test-result-item">
<ResultIcon status={result.status} />
<span className={result.status === 'pass' ? 'test-success' : 'test-failure'}>
{type === 'assertion'
? `${result.lhsExpr}: ${result.rhsExpr}`
: result.description
}
</span>
<ErrorMessage error={result.error} />
</div>
);
const TestSection = ({
title,
results,
isExpanded,
onToggle,
type = 'test'
}) => {
const passedResults = results.filter((result) => result.status === 'pass');
const failedResults = results.filter((result) => result.status === 'fail');
if (results.length === 0) return null;
return (
<div className='mb-4'>
<div
className="font-medium test-summary flex items-center cursor-pointer hover:bg-opacity-10 hover:bg-gray-500 rounded py-2"
onClick={onToggle}
>
<span className="dropdown-icon mr-2 flex items-center">
{isExpanded ?
<IconChevronDown size={18} stroke={1.5} /> :
<IconChevronRight size={18} stroke={1.5} />
}
</span>
<span className="flex-grow">
{title} ({results.length}), Passed: {passedResults.length}, Failed: {failedResults.length}
</span>
</div>
{isExpanded && (
<ul className="ml-5">
{results.map((result) => (
<li key={result.uid} className="py-1">
<ResultItem result={result} type={type} />
</li>
))}
</ul>
)}
</div>
);
};
const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
if (!results.length && !assertionResults.length) {
preRequestTestResults = preRequestTestResults || [];
postResponseTestResults = postResponseTestResults || [];
const [expandedSections, setExpandedSections] = useState({
preRequest: true,
tests: true,
postResponse: true,
assertions: true
});
useEffect(() => {
setExpandedSections({
preRequest: preRequestTestResults.length > 0,
tests: results.length > 0,
postResponse: postResponseTestResults.length > 0,
assertions: assertionResults.length > 0
});
}, [results.length, assertionResults.length, preRequestTestResults.length, postResponseTestResults.length]);
const toggleSection = (section) => {
setExpandedSections({
...expandedSections,
[section]: !expandedSections[section]
});
};
if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
return <div className="px-3">No tests found</div>;
}
const passedTests = results.filter((result) => result.status === 'pass');
const failedTests = results.filter((result) => result.status === 'fail');
const passedAssertions = assertionResults.filter((result) => result.status === 'pass');
const failedAssertions = assertionResults.filter((result) => result.status === 'fail');
return (
<StyledWrapper className="flex flex-col">
<div className="pb-2 font-medium test-summary">
Tests ({results.length}/{results.length}), Passed: {passedTests.length}, Failed: {failedTests.length}
</div>
<ul className="">
{results.map((result) => (
<li key={result.uid} className="py-1">
{result.status === 'pass' ? (
<span className="test-success">&#x2714;&nbsp; {result.description}</span>
) : (
<>
<span className="test-failure">&#x2718;&nbsp; {result.description}</span>
<br />
<span className="error-message pl-8">{result.error}</span>
</>
)}
</li>
))}
</ul>
<StyledWrapper className="flex flex-col px-3">
<TestSection
title="Pre-Request Tests"
results={preRequestTestResults}
isExpanded={expandedSections.preRequest}
onToggle={() => toggleSection('preRequest')}
type="test"
/>
<div className="py-2 font-medium test-summary">
Assertions ({assertionResults.length}/{assertionResults.length}), Passed: {passedAssertions.length}, Failed:{' '}
{failedAssertions.length}
</div>
<ul className="">
{assertionResults.map((result) => (
<li key={result.uid} className="py-1">
{result.status === 'pass' ? (
<span className="test-success">
&#x2714;&nbsp; {result.lhsExpr}: {result.rhsExpr}
</span>
) : (
<>
<span className="test-failure">
&#x2718;&nbsp; {result.lhsExpr}: {result.rhsExpr}
</span>
<br />
<span className="error-message pl-8">{result.error}</span>
</>
)}
</li>
))}
</ul>
<TestSection
title="Post-Response Tests"
results={postResponseTestResults}
isExpanded={expandedSections.postResponse}
onToggle={() => toggleSection('postResponse')}
type="test"
/>
<TestSection
title="Tests"
results={results}
isExpanded={expandedSections.tests}
onToggle={() => toggleSection('tests')}
type="test"
/>
<TestSection
title="Assertions"
results={assertionResults}
isExpanded={expandedSections.assertions}
onToggle={() => toggleSection('assertions')}
type="assertion"
/>
</StyledWrapper>
);
};

View File

@@ -1,9 +1,13 @@
import React from 'react';
import { IconCircleCheck, IconCircleX } from '@tabler/icons';
const TestResultsLabel = ({ results, assertionResults }) => {
const TestResultsLabel = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
if (!results.length && !assertionResults.length) {
preRequestTestResults = preRequestTestResults || [];
postResponseTestResults = postResponseTestResults || [];
if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
return 'Tests';
}
@@ -13,8 +17,14 @@ const TestResultsLabel = ({ results, assertionResults }) => {
const numberOfAssertions = assertionResults.length;
const numberOfFailedAssertions = assertionResults.filter((result) => result.status === 'fail').length;
const totalNumberOfTests = numberOfTests + numberOfAssertions;
const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions;
const numberOfPreRequestTests = preRequestTestResults.length;
const numberOfFailedPreRequestTests = preRequestTestResults.filter((result) => result.status === 'fail').length;
const numberOfPostResponseTests = postResponseTestResults.length;
const numberOfFailedPostResponseTests = postResponseTestResults.filter((result) => result.status === 'fail').length;
const totalNumberOfTests = numberOfTests + numberOfAssertions + numberOfPreRequestTests + numberOfPostResponseTests;
const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions + numberOfFailedPreRequestTests + numberOfFailedPostResponseTests;
return (
<div className="flex items-center">

View File

@@ -48,6 +48,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const response = item.response || {};
const responseSize = response.size || 0;
const getTabPanel = (tab) => {
switch (tab) {
@@ -72,7 +73,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <Timeline collection={collection} item={item} width={rightPaneWidth} />;
}
case 'tests': {
return <TestResults results={item.testResults} assertionResults={item.assertionResults} />;
return <TestResults
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>;
}
default: {
@@ -121,7 +127,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage;
return (
@@ -138,14 +144,19 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
<TestResultsLabel results={item.testResults} assertionResults={item.assertionResults} />
<TestResultsLabel
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
postResponseTestResults={item.postResponseTestResults}
/>
</div>
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{hasScriptError && !showScriptErrorCard && (
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
<ScriptErrorIcon
itemUid={item.uid}
onClick={() => setShowScriptErrorCard(true)}
/>
)}
{focusedTab?.responsePaneTab === "timeline" ? (
@@ -156,7 +167,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<ResponseSave item={item} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<ResponseSize size={response.size} />
<ResponseSize size={responseSize} />
</>
) : null}
</div>
@@ -167,9 +178,9 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{hasScriptError && showScriptErrorCard && (
<ScriptError
item={item}
onClose={() => setShowScriptErrorCard(false)}
<ScriptError
item={item}
onClose={() => setShowScriptErrorCard(false)}
/>
)}
{!item?.response ? (

View File

@@ -16,7 +16,7 @@ import RunnerTimeline from 'components/ResponsePane/RunnerTimeline';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response');
const { requestSent, responseReceived, testResults, assertionResults, error } = item;
const { requestSent, responseReceived, testResults, assertionResults, preRequestTestResults, postResponseTestResults, error } = item;
const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0);
@@ -49,7 +49,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return <RunnerTimeline request={requestSent} response={responseReceived} />;
}
case 'tests': {
return <TestResults results={testResults} assertionResults={assertionResults} />;
return <TestResults
results={testResults}
assertionResults={assertionResults}
preRequestTestResults={preRequestTestResults}
postResponseTestResults={postResponseTestResults}
/>;
}
default: {
@@ -86,7 +91,12 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
<TestResultsLabel results={testResults} assertionResults={assertionResults} />
<TestResultsLabel
results={testResults}
assertionResults={assertionResults}
preRequestTestResults={preRequestTestResults}
postResponseTestResults={postResponseTestResults}
/>
</div>
<div className="flex flex-grow justify-end items-center">
<StatusCode status={status} />

View File

@@ -16,6 +16,28 @@ const getDisplayName = (fullPath, pathname, name = '') => {
return path.join(dir, name);
};
const getTestStatus = (results) => {
if (!results || !results.length) return 'pass';
const failed = results.filter((result) => result.status === 'fail');
return failed.length ? 'fail' : 'pass';
};
const allTestsPassed = (item) => {
return item.status !== 'error' &&
item.testStatus === 'pass' &&
item.assertionStatus === 'pass' &&
item.preRequestTestStatus === 'pass' &&
item.postResponseTestStatus === 'pass';
};
const anyTestFailed = (item) => {
return item.status === 'error' ||
item.testStatus === 'fail' ||
item.assertionStatus === 'fail' ||
item.preRequestTestStatus === 'fail' ||
item.postResponseTestStatus === 'fail';
};
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
@@ -56,19 +78,10 @@ export default function RunnerResults({ collection }) {
displayName: getDisplayName(collection.pathname, info.pathname, info.name)
};
if (newItem.status !== 'error' && newItem.status !== 'skipped') {
if (newItem.testResults) {
const failed = newItem.testResults.filter((result) => result.status === 'fail');
newItem.testStatus = failed.length ? 'fail' : 'pass';
} else {
newItem.testStatus = 'pass';
}
if (newItem.assertionResults) {
const failed = newItem.assertionResults.filter((result) => result.status === 'fail');
newItem.assertionStatus = failed.length ? 'fail' : 'pass';
} else {
newItem.assertionStatus = 'pass';
}
newItem.testStatus = getTestStatus(newItem.testResults);
newItem.assertionStatus = getTestStatus(newItem.assertionResults);
newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults);
newItem.postResponseTestStatus = getTestStatus(newItem.postResponseTestResults);
}
return newItem;
})
@@ -95,12 +108,8 @@ export default function RunnerResults({ collection }) {
};
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
const passedRequests = items.filter((item) => {
return item.status !== 'error' && item.testStatus === 'pass' && item.assertionStatus === 'pass';
});
const failedRequests = items.filter((item) => {
return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
});
const passedRequests = items.filter(allTestsPassed);
const failedRequests = items.filter(anyTestFailed);
const skippedRequests = items.filter((item) => {
return item.status === 'skipped';
@@ -176,18 +185,18 @@ export default function RunnerResults({ collection }) {
<div className="item-path mt-2">
<div className="flex items-center">
<span>
{item.testStatus === 'pass' && item.assertionStatus === 'pass' ?
{allTestsPassed(item) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{item.status === 'skipped' ?
<IconCircleOff className="skipped-request" size={20} strokeWidth={1.5} />
:null}
{item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ?
{anyTestFailed(item) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
:null}
</span>
<span
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : item.status === 'error' || item.testStatus === 'fail' || item.assertionStatus === 'fail' ? 'danger' : ''}`}
className={`mr-1 ml-2 ${item.status == 'skipped' ? 'skipped-request' : anyTestFailed(item) ? 'danger' : ''}`}
>
{item.displayName}
</span>
@@ -208,6 +217,46 @@ export default function RunnerResults({ collection }) {
{item.status == 'error' ? <div className="error-message pl-8 pt-2 text-xs">{item.error}</div> : null}
<ul className="pl-8">
{item.preRequestTestResults
? item.preRequestTestResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2" />
{result.description}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
{result.description}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))
: null}
{item.postResponseTestResults
? item.postResponseTestResults.map((result) => (
<li key={result.uid}>
{result.status === 'pass' ? (
<span className="test-success flex items-center">
<IconCheck size={18} strokeWidth={2} className="mr-2" />
{result.description}
</span>
) : (
<>
<span className="test-failure flex items-center">
<IconX size={18} strokeWidth={2} className="mr-2" />
{result.description}
</span>
<span className="error-message pl-8 text-xs">{result.error}</span>
</>
)}
</li>
))
: null}
{item.testResults
? item.testResults.map((result) => (
<li key={result.uid}>
@@ -271,10 +320,10 @@ export default function RunnerResults({ collection }) {
<div className="flex items-center px-3 mb-4 font-medium">
<span className="mr-2">{selectedItem.displayName}</span>
<span>
{selectedItem.testStatus === 'pass' && selectedItem.assertionStatus === 'pass' ?
{allTestsPassed(selectedItem) ?
<IconCircleCheck className="test-success" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'error' || selectedItem.testStatus === 'fail' || selectedItem.assertionStatus === 'fail' ?
{anyTestFailed(selectedItem) ?
<IconCircleX className="test-failure" size={20} strokeWidth={1.5} />
: null}
{selectedItem.status === 'skipped' ?

View File

@@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid } from 'utils/collections/index';
const ShareCollection = ({ onClose, collection }) => {
const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
@@ -11,11 +11,13 @@ import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
import { findCollectionByUid } from 'utils/collections/index';
const CloneCollection = ({ onClose, collection }) => {
const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false);
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const { name } = collection;
const formik = useFormik({
@@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
values.collectionName,
values.collectionFolderName,
values.collectionLocation,
collection.pathname
collection?.pathname
)
)
.then(() => {

View File

@@ -15,7 +15,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const CloneCollectionItem = ({ collection, item, onClose }) => {
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: (values) => {
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
.then(() => {
toast.success('Request cloned!');
onClose();
@@ -172,8 +172,6 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.drag-preview {
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,49 @@
import { useDragLayer } from 'react-dnd';
import {
IconFile,
IconFolder,
} from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
function getItemStyles({ x, y }) {
if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
const transform = `translate(${x}px, ${y}px)`;
return {
position: 'fixed',
pointerEvents: 'none',
top: 0,
transform,
WebkitTransform: transform,
zIndex: 100,
};
}
export const CollectionItemDragPreview = () => {
const {
item,
isDragging,
clientOffset
} = useDragLayer((monitor) => ({
item: monitor.getItem(),
isDragging: monitor.isDragging(),
clientOffset: monitor.getClientOffset(),
}));
if (!isDragging) return null;
const { x, y } = clientOffset || {};
const shouldShowFolderIcon = !item.type || item.type === 'folder';
return (
<StyledWrapper>
<div style={getItemStyles({ x, y })} className='p-2'>
<div className='flex items-center gap-2 border border-gray-500/10 rounded-md px-2 py-1 drag-preview'>
{shouldShowFolderIcon ? (
<IconFolder size={16} />
) : (
<IconFile size={16} />
)}
{item.name}
</div>
</div>
</StyledWrapper>
);
};

View File

@@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const DeleteCollectionItem = ({ onClose, item, collection }) => {
const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const onConfirm = () => {
dispatch(deleteItem(item.uid, collection.uid)).then(() => {
dispatch(deleteItem(item.uid, collectionUid)).then(() => {
if (isFolder) {
// close all tabs that belong to the folder

View File

@@ -62,6 +62,7 @@ const CodeView = ({ language, item }) => {
<CodeEditor
readOnly
collection={collection}
item={item}
value={snippet}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}

View File

@@ -4,15 +4,65 @@ import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections';
import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const GenerateCodeItem = ({ collection, item, onClose }) => {
const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Function to resolve inherited auth
const resolveInheritedAuth = (item, collection) => {
const request = item.draft?.request || item.request;
const authMode = request?.auth?.mode;
// If auth is not inherit or no auth defined, return the request as is
if (!authMode || authMode !== 'inherit') {
return {
...request
};
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
let source = 'collection';
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
source = 'folder';
break;
}
}
}
return {
...request,
auth: effectiveAuth
};
};
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
@@ -44,6 +94,9 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
// Resolve auth inheritance
const resolvedRequest = resolveInheritedAuth(item, collection);
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
@@ -92,16 +145,10 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
language={selectedLanguage}
item={{
...item,
request:
item.request.url !== ''
? {
...item.request,
url: finalUrl
}
: {
...item.draft.request,
url: finalUrl
}
request: {
...resolvedRequest,
url: finalUrl
}
}}
/>
) : (

View File

@@ -16,7 +16,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const RenameCollectionItem = ({ collection, item, onClose }) => {
const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
return;
}
if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true));
await dispatch(saveRequest(item.uid, collectionUid, true));
}
const { name: newName, filename: newFilename } = values;
try {
let renameConfig = {
itemUid: item.uid,
collectionUid: collection.uid,
collectionUid,
};
renameConfig['newName'] = newName;
if (itemFilename !== newFilename) {
@@ -191,8 +191,6 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -2,16 +2,19 @@ import React from 'react';
import get from 'lodash/get';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const RunCollectionItem = ({ collection, item, onClose }) => {
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
const onSubmit = (recursive) => {
dispatch(
addTab({
@@ -20,10 +23,24 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
type: 'collection-runner'
})
);
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
if (!isCollectionRunInProgress) {
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
}
onClose();
};
const handleViewRunner = (e) => {
e.preventDefault();
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
onClose();
}
const getRequestsCount = (items) => {
const requestTypes = ['http-request', 'graphql-request']
return items.filter(req => requestTypes.includes(req.type)).length;
@@ -34,8 +51,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
const recursiveRunLength = getRequestsCount(flattenedItems);
const isFolderLoading = areItemsLoading(item);
console.log(item);
console.log(isFolderLoading);
return (
<StyledWrapper>
@@ -55,22 +70,34 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
</div>
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</div> : null}
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
{
isCollectionRunInProgress ?
<span>
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={handleViewRunner}>
View Run
</button>
</span>
:
<>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
</>
}
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
height: 1.875rem;
cursor: pointer;
user-select: none;
position: relative;
/* Common styles for drop indicators */
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.dragAndDrop.border};
opacity: 0;
pointer-events: none;
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
/* Drop target styles */
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
&::before,
&::after {
opacity: 0;
}
}
&.drop-target-above {
&::before {
opacity: 1;
height: 2px;
}
}
&.drop-target-below {
&::after {
opacity: 1;
height: 2px;
}
}
/* Inside drop target style */
&.drop-target {
&::before {
top: 0;
bottom: 0;
height: 100%;
opacity: 1;
background: ${(props) => props.theme.dragAndDrop.hoverBg};
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
// border-radius: 4px;
}
}
.rotate-90 {
transform: rotateZ(90deg);
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
}
}
&.item-target {
background: #ccc3;
}
&.item-seperator {
.seperator {
bottom: 0px;
position: absolute;
height: 3px;
width: 100%;
background: #ccc3;
}
}
&.item-focused-in-tab {
background: ${(props) => props.theme.sidebar.collection.item.bg};

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -6,7 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -16,7 +17,7 @@ import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { isItemARequest, isItemAFolder } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
@@ -26,13 +27,22 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
import { calculateDraggedItemNewPathname } from 'utils/collections/index';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
const collectionItemRef = useRef(null);
// We use a single ref for drag and drop.
const ref = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -44,10 +54,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const isFolder = isItemAFolder(item);
const [{ isDragging }, drag] = useDrag({
type: `collection-item-${collection.uid}`,
item: item,
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [{ isDragging }, drag, dragPreview] = useDrag({
type: `collection-item-${collectionUid}`,
item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
}),
@@ -56,21 +69,72 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
});
const [{ isOver }, drop] = useDrop({
accept: `collection-item-${collection.uid}`,
drop: (draggedItem) => {
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!hoverBoundingRect || !clientOffset) return null;
const clientY = clientOffset.y - hoverBoundingRect.top;
const folderUpperThreshold = hoverBoundingRect.height * 0.35;
const fileUpperThreshold = hoverBoundingRect.height * 0.5;
if (isItemAFolder(item)) {
return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
} else {
return clientY < fileUpperThreshold ? 'adjacent' : null;
}
};
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
if (draggedItemUid === targetItemUid) return false;
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
if (!newPathname) return false;
if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
return true;
};
const [{ isOver, canDrop }, drop] = useDrop({
accept: `collection-item-${collectionUid}`,
hover: (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
setDropType(_canItemBeDropped ? dropType : null);
},
canDrop: (draggedItem) => {
return draggedItem.uid !== item.uid;
drop: async (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
if (!dropType) return;
await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
setDropType(null);
},
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
collect: (monitor) => ({
isOver: monitor.isOver(),
isOver: monitor.isOver()
}),
});
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
@@ -84,13 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
'rotate-90': !itemIsCollapsed
});
const itemRowClassName = classnames('flex collection-item-name items-center', {
'item-focused-in-tab': item.uid == activeTabUid,
'item-hovered': isOver
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
'item-focused-in-tab': isTabForItemActive,
'item-hovered': isOver && canDrop,
'drop-target': isOver && dropType === 'inside',
'drop-target-above': isOver && dropType === 'adjacent'
});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
dispatch(sendRequest(item, collectionUid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
@@ -101,12 +167,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
if (event && event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
if (isTabForItemPresent) {
dispatch(
focusTab({
uid: item.uid
@@ -114,11 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
})
@@ -127,14 +190,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
type: 'folder-settings',
})
);
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
@@ -146,10 +209,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
};
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
@@ -164,7 +227,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -183,49 +245,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid }))
dispatch(makeTabPermanent({ uid: item.uid }));
};
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
// Sort items by their "seq" property.
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
focusTab({
uid: item.uid
})
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
})
);
return;
}
};
const handleShowInFolder = () => {
dispatch(showInFolder(item.pathname)).catch((error) => {
console.error('Error opening the folder', error);
@@ -233,62 +260,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
});
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (
(item?.request?.url !== '') ||
(item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (isTabForItemPresent) {
dispatch(focusTab({ uid: item.uid }));
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid,
type: 'folder-settings'
})
);
}
};
return (
<StyledWrapper className={className}>
{renameItemModalOpen && (
<RenameCollectionItem item={item} collection={collection} onClose={() => setRenameItemModalOpen(false)} />
<RenameCollectionItem item={item} collectionUid={collectionUid} onClose={() => setRenameItemModalOpen(false)} />
)}
{cloneItemModalOpen && (
<CloneCollectionItem item={item} collection={collection} onClose={() => setCloneItemModalOpen(false)} />
<CloneCollectionItem item={item} collectionUid={collectionUid} onClose={() => setCloneItemModalOpen(false)} />
)}
{deleteItemModalOpen && (
<DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />
<DeleteCollectionItem item={item} collectionUid={collectionUid} onClose={() => setDeleteItemModalOpen(false)} />
)}
{newRequestModalOpen && (
<NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest item={item} collectionUid={collectionUid} onClose={() => setNewRequestModalOpen(false)} />
)}
{newFolderModalOpen && (
<NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />
<NewFolder item={item} collectionUid={collectionUid} onClose={() => setNewFolderModalOpen(false)} />
)}
{runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collectionUid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
{itemInfoModalOpen && (
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} />
<CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
)}
<div className={itemRowClassName} ref={collectionItemRef}>
<div
className={itemRowClassName}
ref={(node) => {
ref.current = node;
drag(drop(node));
}}
>
<div className="flex items-center h-full w-full">
{indents && indents.length
? indents.map((i) => {
return (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{
width: 16,
minWidth: 16,
height: '100%'
}}
>
&nbsp;{/* Indent */}
</div>
);
})
? indents.map((i) => (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{ width: 16, minWidth: 16, height: '100%' }}
>
&nbsp;{/* Indent */}
</div>
))
: null}
<div
className="flex flex-grow items-center h-full overflow-hidden"
style={{
paddingLeft: 8
}}
style={{ paddingLeft: 8 }}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
@@ -304,10 +358,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
/>
) : null}
</div>
<div
className="ml-1 flex w-full h-full items-center overflow-hidden"
>
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
{item.name}
@@ -429,17 +480,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div>
</div>
</div>
{!itemIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
</div>
@@ -448,4 +498,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
export default CollectionItem;
export default React.memo(CollectionItem);

View File

@@ -1,12 +1,14 @@
import React from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RemoveCollection = ({ onClose, collection }) => {
const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const onConfirm = () => {
dispatch(removeCollection(collection.uid))

View File

@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RenameCollection = ({ collection, onClose }) => {
const RenameCollection = ({ collectionUid, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const formik = useFormik({
enableReinitialize: true,
initialValues: {

View File

@@ -13,7 +13,8 @@ const Wrapper = styled.div`
}
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-bottom: 2px solid transparent;
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
@@ -62,6 +63,36 @@ const Wrapper = styled.div`
color: white;
}
}
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-above {
border: none;
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-top: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-below {
border: none;
border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-bottom: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
}
.collection-name.drop-target {
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-radius: 4px;
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
margin: -2px;
transition: ${(props) => props.theme.dragAndDrop.transition};
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
}
#sidebar-collection-name {

View File

@@ -1,4 +1,5 @@
import React, { useState, forwardRef, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
@@ -6,8 +7,8 @@ import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
@@ -19,9 +20,10 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading, findItemInCollection } from 'utils/collections';
import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -33,7 +35,7 @@ const Collection = ({ collection, searchText }) => {
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
@@ -127,8 +129,8 @@ const Collection = ({ collection, searchText }) => {
const isCollectionItem = (itemType) => {
return itemType.startsWith('collection-item');
};
const [{ isDragging }, drag] = useDrag({
const [{ isDragging }, drag, dragPreview] = useDrag({
type: "collection",
item: collection,
collect: (monitor) => ({
@@ -144,7 +146,7 @@ const Collection = ({ collection, searchText }) => {
drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
} else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
}
@@ -157,7 +159,9 @@ const Collection = ({ collection, searchText }) => {
}),
});
drag(drop(collectionRef));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
@@ -170,36 +174,35 @@ const Collection = ({ collection, searchText }) => {
});
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
return (
<StyledWrapper className="flex flex-col">
{showNewRequestModal && <NewRequest collection={collection} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />}
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
{showRenameCollectionModal && (
<RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} />
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
)}
{showRemoveCollectionModal && (
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
<RemoveCollection collectionUid={collection.uid} onClose={() => setShowRemoveCollectionModal(false)} />
)}
{showShareCollectionModal && (
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
)}
{showCloneCollectionModalOpen && (
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
)}
<CollectionItemDragPreview />
<div className={collectionRowClassName}
ref={collectionRef}
ref={(node) => {
collectionRef.current = node;
drag(drop(node));
}}
>
<div
className="flex flex-grow items-center overflow-hidden"
@@ -296,20 +299,15 @@ const Collection = ({ collection, searchText }) => {
</Dropdown>
</div>
</div>
<div>
{!collectionIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{folderItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
{requestItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
</div>
) : null}
</div>

View File

@@ -11,6 +11,8 @@ import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from '@tabler/icons';
import Help from 'components/Help';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
@@ -45,7 +47,7 @@ const CreateCollection = ({ onClose }) => {
toast.success('Collection created!');
onClose();
})
.catch((e) => toast.error('An error occurred while creating the collection - ' + e));
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
}
});
@@ -113,7 +115,6 @@ const CreateCollection = ({ onClose }) => {
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
@@ -121,6 +122,9 @@ const CreateCollection = ({ onClose }) => {
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>

View File

@@ -1,34 +1,43 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import fileDialog from 'file-dialog';
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false)
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Import collection failed'));
.catch((err) => toastError(err, 'Import collection failed'))
};
const handleImportPostmanCollection = () => {
importPostmanCollection()
.then(({ collection }) => {
handleSubmit({ collection });
fileDialog({ accept: 'application/json' })
.then((...args) => {
setIsLoading(true);
return readFile(...args);
})
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
.then((collection) => postmanToBruno(collection))
.then((collection) => handleSubmit({ collection }))
.catch((err) => toastError(err, 'Postman Import collection failed'))
.finally(() => setIsLoading(false));
}
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
};
const handleImportOpenapiCollection = () => {
@@ -36,8 +45,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
};
const CollectionButton = ({ children, className, onClick }) => {
return (
<button
@@ -50,18 +60,67 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
</button>
);
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
const FullscreenLoader = () => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
// Cycle through loading messages for better UX
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
</Modal>
);
};
return (
<>
{isLoading && <FullscreenLoader />}
{!isLoading && (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
</div>
</div>
</Modal>
)}
</>
);
};

View File

@@ -61,7 +61,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
@@ -69,6 +68,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
</>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (

View File

@@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown";
import { IconCaretDown } from "@tabler/icons";
import StyledWrapper from './StyledWrapper';
const NewFolder = ({ collection, item, onClose }) => {
const NewFolder = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const [isEditing, toggleEditing] = useState(false);
@@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => {
})
}),
onSubmit: (values) => {
dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null))
dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
.then(() => {
toast.success('New folder created!');
onClose();

View File

@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
import path from 'utils/common/path';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
@@ -20,9 +20,11 @@ import Portal from 'components/Portal';
import Help from 'components/Help';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
@@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid
collectionUid: collectionUid
})
)
.then(() => {
dispatch(
addTab({
uid: uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
})
);
@@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null,
headers: request.headers,
body: request.body,
@@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null
})
)
@@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
/>
</div>

View File

@@ -11,6 +11,8 @@ import { useDispatch } from 'react-redux';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const TitleBar = () => {
const [importedCollection, setImportedCollection] = useState(null);
@@ -34,9 +36,8 @@ const TitleBar = () => {
toast.success('Collection imported successfully');
})
.catch((err) => {
setImportCollectionLocationModalOpen(false);
console.error(err);
toast.error('An error occurred while importing the collection. Check the logs for more information.');
toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err)));
});
};

View File

@@ -83,7 +83,7 @@ class SingleLineEditor extends Component {
}
});
}
this.editor.setValue(String(this.props.value) || '');
this.editor.setValue(String(this.props.value ?? ''));
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
this._enableMaskedEditor(this.props.isSecret);
@@ -107,7 +107,7 @@ class SingleLineEditor extends Component {
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
if (this.props.onChange) {
if (this.props.onChange && (this.props.value !== this.cachedValue)) {
this.props.onChange(this.cachedValue);
}
}
@@ -129,7 +129,7 @@ class SingleLineEditor extends Component {
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setValue(String(this.props.value ?? ''));
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
@@ -146,7 +146,7 @@ class SingleLineEditor extends Component {
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams);
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -1,27 +1,24 @@
import React, { useState, useEffect } from 'react';
const StopWatch = () => {
const [milliseconds, setMilliseconds] = useState(0);
const tickInterval = 100;
const tick = () => {
setMilliseconds(_milliseconds => _milliseconds + tickInterval);
};
const StopWatch = ({ startTime }) => {
const [currentTime, setCurrentTime] = useState(Date.now());
useEffect(() => {
let timerID = setInterval(() => {
tick()
}, tickInterval);
return () => {
clearTimeout(timerID);
};
}, []);
if (milliseconds < 250) {
return 'Loading...';
}
let seconds = milliseconds / 1000;
if (!startTime) return;
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 100);
return () => clearInterval(intervalId);
}, [startTime]);
if (!startTime) return <span>Loading...</span>;
const elapsedTime = currentTime - startTime;
if (elapsedTime < 250) return <span>Loading...</span>;
const seconds = elapsedTime / 1000;
return <span>{seconds.toFixed(1)}s</span>;
};

View File

@@ -211,13 +211,15 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, dispatch]);
const currentCollection = getCurrentCollection();
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showEnvSettingsModal && (
<EnvironmentSettings collection={getCurrentCollection()} onClose={() => setShowEnvSettingsModal(false)} />
<EnvironmentSettings collection={currentCollection} onClose={() => setShowEnvSettingsModal(false)} />
)}
{showNewRequestModal && (
<NewRequest collection={getCurrentCollection()} onClose={() => setShowNewRequestModal(false)} />
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
)}
<div>{props.children}</div>
</HotkeysContext.Provider>

View File

@@ -13,12 +13,9 @@ import {
findEnvironmentInCollection,
findItemInCollection,
findParentItemInCollection,
getItemsToResequence,
isItemAFolder,
refreshUidsInItem,
isItemARequest,
moveCollectionItem,
moveCollectionItemToRootOfCollection,
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
@@ -39,7 +36,8 @@ import {
updateLastAction,
setCollectionSecurityConfig,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl
collectionClearOauth2CredentialsByUrl,
initRunRequestEvent
} from './index';
import { each } from 'lodash';
@@ -47,8 +45,7 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
@@ -60,7 +57,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-collection', newName, collection.pathname).then(resolve).catch(reject);
});
};
@@ -224,15 +221,26 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const state = getState();
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const itemUid = item?.uid;
return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const itemCopy = cloneDeep(item || {});
let collectionCopy = cloneDeep(collection);
const itemCopy = cloneDeep(item);
const requestUid = uuid();
itemCopy.requestUid = requestUid;
await dispatch(initRunRequestEvent({
requestUid,
itemUid,
collectionUid
}));
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
@@ -251,8 +259,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
return dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: serializedResponse
})
);
@@ -263,8 +271,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
console.log('>> request cancelled');
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: null
})
);
@@ -281,8 +289,8 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collectionUid,
itemUid,
collectionUid,
response: errorResponse
})
);
@@ -337,6 +345,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
})
);
const { ipcRenderer } = window;
ipcRenderer
.invoke(
'renderer:run-collection-folder',
@@ -358,6 +367,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
return new Promise((resolve, reject) => {
if (!collection) {
@@ -372,10 +383,32 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
if (!folderWithSameNameExists) {
const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -392,8 +425,31 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -495,8 +551,11 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
set(item, 'name', newName);
set(item, 'filename', newFilename);
set(item, 'root.meta.name', newName);
set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
const collectionPath = path.join(parentFolder.pathname, newFilename);
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return;
}
@@ -594,176 +653,114 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
export const sortCollections = (payload) => (dispatch) => {
dispatch(_sortCollections(payload));
};
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
.then(resolve)
.catch(reject);
});
}
export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
const { uid: targetItemUid } = targetItem;
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
const newDirname = path.dirname(newPathname);
await dispatch(moveItem({
targetDirname: newDirname,
sourcePathname: draggedItemPathname
}));
// Update sequences in the source directory
if (draggedItemDirectoryItems?.length) {
// reorder items in the source directory
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
if (reorderedSourceItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
}
}
// Update sequences in the target directory (if dropping adjacent)
if (dropType === 'adjacent') {
const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
const draggedItemWithNewPathAndSequence = {
...draggedItem,
pathname: newPathname,
seq: targetItemSequence
};
// draggedItem is added to the targetItem's directory
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
targetItemUid,
draggedItemUid
});
if (reorderedTargetItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
}
}
};
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
const { uid: targetItemUid } = targetItem;
const { uid: draggedItemUid } = draggedItem;
// reorder items in the targetItem's directory
const reorderedItems = getReorderedItemsInTargetDirectory({
items: targetItemDirectoryItems,
targetItemUid,
draggedItemUid
});
if (reorderedItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
}
};
return new Promise(async (resolve, reject) => {
try {
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname: collection.pathname });
if (!newPathname) return;
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
if (newPathname !== draggedItemPathname) {
await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
} else {
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
}
resolve();
} catch (error) {
console.error(error);
toast.error(error?.message);
reject(error);
}
})
}
export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
const targetItem = findItemInCollection(collectionCopy, targetItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
if (!targetItem) {
return reject(new Error('Target item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
const sameParent = draggedItemParent === targetItemParent;
// file item dragged onto another file item and both are in the same folder
// this is also true when both items are at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item which is at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item and both are in different folders
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged into its own folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// file item dragged into another folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// end of the file drags, now let's handle folder drags
// folder drags are simpler since we don't allow ordering of folders
// folder dragged into its own folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// folder dragged into a file which is at the same level
// this is also true when both items are at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
return resolve();
}
// folder dragged into a file which is a child of the folder
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
return resolve();
}
// folder dragged into a file which is at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
}
// folder dragged into another folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
.then(resolve)
.catch((error) => reject(error));
}
ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch(reject);
});
};
export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
// file item is already at the root level
if (!draggedItemParent) {
return resolve();
}
const draggedItemPathname = draggedItem.pathname;
moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
if (isItemAFolder(draggedItem)) {
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
} else {
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
});
};
}
export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
@@ -823,8 +820,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(collection.pathname, resolvedFilename);
@@ -852,8 +849,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
currentItem.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(currentItem.pathname, resolvedFilename);
const { ipcRenderer } = window;
@@ -885,6 +882,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name)
.then(
@@ -913,6 +911,7 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then(
@@ -946,6 +945,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then(
@@ -982,6 +982,7 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
const oldName = environment.name;
environment.name = sanitizedName;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
@@ -1005,6 +1006,7 @@ export const deleteEnvironment = (environmentUid, collectionUid) => (dispatch, g
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-environment', collection.pathname, environment.name)
.then(resolve)
@@ -1028,6 +1030,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
environment.variables = variables;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
@@ -1053,7 +1056,8 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
if (environmentUid && !environmentName) {
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
@@ -1112,11 +1116,13 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:update-bruno-config', brunoConfig, collection.pathname, collectionUid)
.then(resolve)
@@ -1135,6 +1141,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
brunoConfig: brunoConfig
};
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
collectionSchema

View File

@@ -276,6 +276,8 @@ export const collectionsSlice = createSlice({
if (item) {
item.response = null;
item.cancelTokenUid = null;
item.requestUid = null;
item.requestStartTime = null;
}
}
},
@@ -288,6 +290,7 @@ export const collectionsSlice = createSlice({
item.requestState = 'received';
item.response = action.payload.response;
item.cancelTokenUid = null;
item.requestStartTime = null;
if (!collection.timeline) {
collection.timeline = [];
@@ -1593,6 +1596,27 @@ export const collectionsSlice = createSlice({
case 'oauth2':
set(folder, 'root.request.auth.oauth2', action.payload.content);
break;
case 'basic':
set(folder, 'root.request.auth.basic', action.payload.content);
break;
case 'bearer':
set(folder, 'root.request.auth.bearer', action.payload.content);
break;
case 'digest':
set(folder, 'root.request.auth.digest', action.payload.content);
break;
case 'ntlm':
set(folder, 'root.request.auth.ntlm', action.payload.content);
break;
case 'apikey':
set(folder, 'root.request.auth.apikey', action.payload.content);
break;
case 'awsv4':
set(folder, 'root.request.auth.awsv4', action.payload.content);
break;
case 'wsse':
set(folder, 'root.request.auth.wsse', action.payload.content);
break;
}
}
},
@@ -1719,6 +1743,9 @@ export const collectionsSlice = createSlice({
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
if (file?.data?.meta?.seq) {
folderItem.seq = file.data?.meta?.seq;
}
}
return;
}
@@ -1795,9 +1822,10 @@ export const collectionsSlice = createSlice({
currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
uid: dir?.meta?.uid || uuid(),
pathname: currentPath,
name: dir?.meta?.name || directoryName,
seq: dir?.meta?.seq || 1,
filename: directoryName,
collapsed: true,
type: 'folder',
@@ -1829,6 +1857,9 @@ export const collectionsSlice = createSlice({
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
if (file?.data?.meta?.seq) {
folderItem.seq = file?.data?.meta?.seq;
}
folderItem.root = file.data;
}
return;
@@ -1926,26 +1957,40 @@ export const collectionsSlice = createSlice({
collection.runnerResult = null;
}
},
initRunRequestEvent: (state, action) => {
const { requestUid, itemUid, collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, itemUid);
if (!item) return;
item.requestState = null;
item.requestUid = requestUid;
item.requestStartTime = Date.now();
},
runRequestEvent: (state, action) => {
const { itemUid, collectionUid, type, requestUid, hasError } = action.payload;
const { itemUid, collectionUid, type, requestUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
// ignore outdated updates in case multiple requests are fired rapidly to avoid state inconsistency
if (item.requestUid !== requestUid) return;
if (type === 'pre-request-script-execution') {
item.requestUid = requestUid;
item.preRequestScriptErrorMessage = action.payload.errorMessage;
}
if(type === 'post-response-script-execution') {
item.requestUid = requestUid;
item.postResponseScriptErrorMessage = action.payload.errorMessage;
}
if (type === 'request-queued') {
const { cancelTokenUid } = action.payload;
item.requestUid = requestUid;
// ignore if request is already in progress or completed
if (['sending', 'received'].includes(item.requestState)) return;
item.requestState = 'queued';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1953,10 +1998,9 @@ export const collectionsSlice = createSlice({
if (type === 'request-sent') {
const { cancelTokenUid, requestSent } = action.payload;
item.requestSent = requestSent;
// sometimes the response is received before the request-sent event arrives
if (item.requestUid === requestUid && item.requestState === 'queued') {
item.requestUid = requestUid;
if (item.requestState === 'queued') {
item.requestState = 'sending';
item.cancelTokenUid = cancelTokenUid;
}
@@ -1971,6 +2015,16 @@ export const collectionsSlice = createSlice({
const { results } = action.payload;
item.testResults = results;
}
if (type === 'test-results-pre-request') {
const { results } = action.payload;
item.preRequestTestResults = results;
}
if (type === 'test-results-post-response') {
const { results } = action.payload;
item.postResponseTestResults = results;
}
}
}
},
@@ -2027,6 +2081,16 @@ export const collectionsSlice = createSlice({
item.testResults = action.payload.testResults;
}
if (type === 'test-results-pre-request') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.preRequestTestResults = action.payload.preRequestTestResults;
}
if (type === 'test-results-post-response') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.postResponseTestResults = action.payload.postResponseTestResults;
}
if (type === 'assertion-results') {
const item = collection.runnerResult.items.findLast((i) => i.uid === request.uid);
item.assertionResults = action.payload.assertionResults;
@@ -2158,6 +2222,7 @@ export const collectionsSlice = createSlice({
);
return oauth2Credential;
},
updateFolderAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
@@ -2166,8 +2231,9 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
}
},
}
}
},
});
export const {
@@ -2268,17 +2334,18 @@ export const {
collectionAddEnvFileEvent,
collectionRenamedEvent,
resetRunResults,
initRunRequestEvent,
runRequestEvent,
runFolderEvent,
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs,
moveCollection,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
moveCollection
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -1,5 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { stringifyIfNot, uuid } from 'utils/common/index';
import { uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
import { cloneDeep } from 'lodash';
@@ -90,6 +90,7 @@ export const {
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
@@ -104,6 +105,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
@@ -114,6 +116,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
@@ -139,6 +142,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
@@ -155,6 +159,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:select-global-environment', { environmentUid })
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
@@ -165,6 +170,7 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-global-environment', { environmentUid })
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
@@ -175,6 +181,7 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
if (!globalEnvironmentVariables) resolve();
const state = getState();

View File

@@ -7,9 +7,17 @@ export const ToastContext = React.createContext();
export const ToastProvider = (props) => {
const { storedTheme } = useTheme();
const toastOptions = { duration: 2000 };
const toastOptions = {
duration: 2000,
style: {
// Break long word like file-path, URL etc. to prevent overflow
overflowWrap: 'anywhere'
}
};
if (storedTheme === 'dark') {
toastOptions.style = {
...toastOptions.style,
borderRadius: '10px',
background: '#3d3d3d',
color: '#fff'

View File

@@ -0,0 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
export const isTabForItemActive = ({ itemUid }) => createSelector([
(state) => state.tabs?.activeTabUid
], (activeTabUid) => activeTabUid === itemUid);
export const isTabForItemPresent = ({ itemUid }) => createSelector([
(state) => state.tabs.tabs,
], (tabs) => tabs.some((tab) => tab.uid === itemUid));

View File

@@ -281,6 +281,12 @@ const darkTheme = {
color: 'rgb(52 51 49)'
},
dragAndDrop: {
border: '#666666',
borderStyle: '2px solid',
hoverBg: 'rgba(102, 102, 102, 0.08)',
transition: 'all 0.1s ease'
},
infoTip: {
bg: '#1f1f1f',
border: '#333333',

View File

@@ -282,6 +282,12 @@ const lightTheme = {
color: 'rgb(152 151 149)'
},
dragAndDrop: {
border: '#8b8b8b', // Using the same gray as focusBorder from input
borderStyle: '2px solid',
hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
transition: 'all 0.1s ease'
},
infoTip: {
bg: 'white',
border: '#e0e0e0',

View File

@@ -5,6 +5,8 @@
* Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
*/
import { JSHINT } from 'jshint';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;

View File

@@ -98,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
export const findParentItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
export const findItemInCollection = (collection, itemUid) => {
let flattenedItems = flattenItems(collection.items);
@@ -150,90 +158,6 @@ export const getItemsLoadStats = (folder) => {
};
}
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
if (targetItem.type === 'folder') {
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
targetItem.items.push(draggedItem);
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
}
};
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
// If the dragged item is already at the root of the collection, do nothing
if (!draggedItemParent) {
return;
}
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items.push(draggedItem);
if (draggedItem.type == 'folder') {
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
} else {
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
};
export const getItemsToResequence = (parent, collection) => {
let itemsToResequence = [];
if (!parent) {
let index = 1;
each(collection.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
if (parent.items && parent.items.length) {
let index = 1;
each(parent.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
return itemsToResequence;
};
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => {
return map(headers, (header) => {
@@ -502,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (meta?.name) {
di.root.meta = {};
di.root.meta.name = meta?.name;
di.root.meta.seq = meta?.seq;
}
if (!Object.keys(di.root.request)?.length) {
delete di.root.request;
@@ -1086,3 +1011,77 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = []
});
return credentialsVariables;
};
// item sequence utils - START
export const resetSequencesInFolder = (folderItems) => {
const items = folderItems;
const sortedItems = items.sort((a, b) => a.seq - b.seq);
return sortedItems.map((item, index) => {
item.seq = index + 1;
return item;
});
};
export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
if (targetItemSequence > sourceItemSequence) {
return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
}
return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
};
export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
if (!isDraggedItem) {
return null;
}
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
};
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
const targetSequence = targetItem?.seq;
const draggedSequence = draggedItem?.seq;
itemsWithFixedSequences?.forEach(item => {
const isDraggedItem = item?.uid === draggedItemUid;
const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
if (isBetween) {
item.seq += targetSequence > draggedSequence ? -1 : 1;
}
const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
if (newSequence !== null) {
item.seq = newSequence;
}
});
// only return items that have been reordered
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const getReorderedItemsInSourceDirectory = ({ items }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType, collectionPathname }) => {
const { pathname: targetItemPathname } = targetItem;
const { filename: draggedItemFilename } = draggedItem;
const targetItemDirname = path.dirname(targetItemPathname);
const isTargetTheCollection = targetItemPathname === collectionPathname;
const isTargetItemAFolder = isItemAFolder(targetItem);
if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
return path.join(targetItemPathname, draggedItemFilename)
} else if (dropType === 'adjacent') {
return path.join(targetItemDirname, draggedItemFilename)
}
return null;
};
// item sequence utils - END

View File

@@ -1,10 +0,0 @@
class Cache {
get(key) {
return window.localStorage.getItem(key);
}
set(key, val) {
window.localStorage.setItem(key, val);
}
}
module.exports = new Cache();

View File

@@ -74,11 +74,11 @@ export class MaskedEditor {
} else {
for (let line = 0; line < lineCount; line++) {
const lineLength = this.editor.getLine(line).length;
const maskedNode = document.createTextNode('*'.repeat(lineLength));
const maskedNode = document.createTextNode('*'.repeat(lineLength));
this.editor.markText(
{ line, ch: 0 },
{ line, ch: lineLength },
{ replacedWith: maskedNode, handleMouseEvents: false }
{ replacedWith: maskedNode, handleMouseEvents: false }
);
}
}
@@ -86,7 +86,18 @@ export class MaskedEditor {
};
}
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => {
/**
* Defines a custom CodeMirror mode for Bruno variables highlighting.
* This function creates a specialized mode that can highlight both Bruno template
* variables (in the format {{variable}}) and URL path parameters (in the format /:param).
*
* @param {Object} _variables - The variables object containing data to validate against
* @param {string} mode - The base CodeMirror mode to extend (e.g., 'javascript', 'application/json')
* @param {boolean} highlightPathParams - Whether to highlight URL path parameters
* @param {boolean} highlightVariables - Whether to highlight template variables
* @returns {void} - Registers the mode with CodeMirror for later use
*/
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams, highlightVariables) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
const { pathParams = {}, ...variables } = _variables || {};
const variablesOverlay = {
@@ -139,13 +150,15 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa
}
};
let baseMode = CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
let baseMode = CodeMirror.getMode(config, parserConfig.backdrop || mode);
if (highlightPathParams) {
return CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
} else {
return baseMode;
if (highlightVariables) {
baseMode = CodeMirror.overlayMode(baseMode, variablesOverlay);
}
if (highlightPathParams) {
baseMode = CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
}
return baseMode;
});
};

View File

@@ -34,3 +34,11 @@ export const toastError = (error, defaultErrorMsg = 'An error occurred') => {
return toast.error(errorMsg);
};
export function formatIpcError(error) {
if (!(error instanceof Error)) return error;
if (!error?.message) return ''; // Avoid returning `null` or `undefined`
// https://github.com/electron/electron/blob/659e79fc08c6ffc2f7506dd1358918d97d240147/lib/renderer/api/ipc-renderer.ts#L24-L30
// There is no other way to get rid of this error prefix as of now.
return error.message.replace(/^Error invoking remote method '.+?': (Error: )?/, '');
}

View File

@@ -53,7 +53,7 @@ export const safeStringifyJSON = (obj, indent = false) => {
export const convertToCodeMirrorJson = (obj) => {
try {
return JSON5.stringify(obj).slice(1, -1);
return JSON.stringify(obj, null, 2).slice(1, -1);
} catch (e) {
return obj;
}
@@ -83,29 +83,40 @@ export const normalizeFileName = (name) => {
};
export const getContentType = (headers) => {
const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
if (headersArray.length > 0) {
let contentType = headersArray
.filter((header) => header[0].toLowerCase() === 'content-type')
.map((header) => {
return header[1];
});
if (contentType && contentType.length) {
if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
return 'application/ld+json';
} else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) {
return 'image/svg+xml';
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml';
}
return contentType[0];
}
// Return empty string for invalid headers
if (!headers || typeof headers !== 'object' || Object.keys(headers).length === 0) {
return '';
}
return '';
};
// Get content-type header value
const contentTypeHeader = Object.entries(headers)
.find(([key]) => key.toLowerCase() === 'content-type');
const contentType = contentTypeHeader && contentTypeHeader[1];
// Return empty string if no content-type or not a string
if (!contentType || typeof contentType !== 'string') {
return '';
}
// This pattern matches content types like application/json, application/ld+json, text/json, etc.
const JSON_PATTERN = /^[\w\-]+\/([\w\-]+\+)?json/;
// This pattern matches content types like image/svg.
const SVG_PATTERN = /^image\/svg/i;
// This pattern matches content types like application/xml, text/xml, application/atom+xml, etc.
const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/;
if (JSON_PATTERN.test(contentType)) {
return 'application/ld+json';
} else if (SVG_PATTERN.test(contentType)) {
return 'image/svg+xml';
} else if (XML_PATTERN.test(contentType)) {
return 'application/xml';
}
return contentType;
}
export const startsWith = (str, search) => {
if (!str || !str.length || typeof str !== 'string') {
@@ -181,4 +192,8 @@ export const getEncoding = (headers) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers?.['content-type'] || '');
return charsetMatch?.[1];
}
export const multiLineMsg = (...messages) => {
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
}

View File

@@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
import { normalizeFileName, startsWith, humanizeDate, relativeDate } from './index';
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType } from './index';
describe('common utils', () => {
describe('normalizeFileName', () => {
@@ -107,4 +107,45 @@ describe('common utils', () => {
expect(relativeDate(date)).toBe('2 months ago');
});
});
describe('getContentType', () => {
it('should handle JSON content types correctly', () => {
expect(getContentType({ 'content-type': 'application/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'application/ld+json' })).toBe('application/ld+json');
});
it('should handle XML content types correctly', () => {
expect(getContentType({ 'content-type': 'text/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/atom+xml' })).toBe('application/xml');
});
it('should handle image content types correctly', () => {
expect(getContentType({ 'content-type': 'image/svg+xml;charset=utf-8' })).toBe('image/svg+xml');
expect(getContentType({ 'content-type': 'IMAGE/SVG+xml' })).toBe('image/svg+xml');
});
it('should return original content type when no pattern matches', () => {
expect(getContentType({ 'content-type': 'image/jpeg' })).toBe('image/jpeg');
expect(getContentType({ 'content-type': 'application/pdf' })).toBe('application/pdf');
});
it('should not be case sensitive', () => {
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'Content-Type': 'text/json' })).toBe('application/ld+json');
});
it('should handle empty content type', () => {
expect(getContentType({ 'content-type': '' })).toBe('');
expect(getContentType({ 'content-type': null })).toBe('');
expect(getContentType({ 'content-type': undefined })).toBe('');
});
it('should handle empty or invalid inputs', () => {
expect(getContentType({})).toBe('');
expect(getContentType(null)).toBe('');
expect(getContentType(undefined)).toBe('');
});
});
});

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