Compare commits

..

83 Commits

Author SHA1 Message Date
Bijin A B
67ab98f7ef chore: fix minor runtime warnings 2025-12-27 00:02:22 +05:30
lohit
faec95f623 feat: collection-level and app-level proxy settings updates (#6514)
* feat: collection and app proxy settings updates

* fix: opencollection proxy config export and import

* fix: coderabbit review fixes
2025-12-26 22:17:41 +05:30
Sanjai Kumar
cd6ffc2447 refactor: replace button elements with new Button component (#6512)
* refactor: replace button elements with new Button component

* chore: fix button size for consistency

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-26 19:25:45 +05:30
Bijin A B
d37bf7a5ad chore: update responsive tab's more icon (#6509) 2025-12-26 17:11:56 +05:30
Bijin A B
b41f4974f9 chore: playwright fix (#6507) 2025-12-25 15:16:34 +05:30
naman-bruno
2446301e41 use: button component (#6504)
* use: button component

* fixes
2025-12-25 12:33:49 +05:30
naman-bruno
67903f26bc export & import in opencollection format (#6329) 2025-12-24 22:28:38 +05:30
Abhishek S Lal
1b8eece173 Add right-click context menu to request tabs with MenuDropdown # (#6502)
* refactor: replace Dropdown with MenuDropdown in RequestTab component; update Dropdown props handling in Dropdown component

* refactor: remove Portal import and simplify menuDropdown rendering in RequestTab component

* refactor: streamline RequestTabMenu functionality and improve tab closing methods with async handling

* refactor: enhance Dropdown and MenuDropdown components with improved props handling and styling adjustments

* refactor: enhance Dropdown and MenuDropdown components by improving structure and removing unused styles

* refactor: update Dropdown and MenuDropdown components to append to sidebar sections container for improved layout

* refactor: integrate dropdownContainerRef for improved MenuDropdown positioning in RequestTabs and Sidebar components

* refactor: update Dropdown component to include 'tippy-box' class for e2e test selections

* refactor: update dropdown item selection logic in selectRequestPaneTab function for improved accuracy

* refactor: add fixed positioning to popperOptions in Collection and CollectionItem components for improved dropdown behavior

---------

Co-authored-by: sanjai <sanjai@usebruno.com>
2025-12-24 21:08:53 +05:30
Pooja
1f05ffd469 fix: pasting request ito parent folder even if request is selected (#6446) 2025-12-24 12:14:37 +05:30
Anoop M D
c2acc25461 Merge pull request #6498 from usebruno/feat/button-storybook
feat: button storybook
2025-12-24 05:52:09 +05:30
Anoop M D
dc9df80638 feat: update button component with new rounded options and story 2025-12-24 05:51:32 +05:30
Anoop M D
c5abe4122b feat: button storybook 2025-12-24 05:30:04 +05:30
naman-bruno
3081c06964 update: modal styles (#6487)
* update modals styles

* chore: color and style improvements

* fix: tests

* fixes: tests

---------

Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-12-23 23:29:03 +05:30
naman-bruno
8c7ed3fe51 improve: workspace handling (#6495)
* improve: workspace

* fixes
2025-12-23 20:22:51 +05:30
Pooja
ce33cee03d fix: autosave (#6392)
* fix: autosave

* rm: console
2025-12-23 19:21:56 +05:30
Abhishek S Lal
d93d1eacdb refactor: centralize tab management (#6494)
* refactor: centralize tab management by removing redundant hide calls in Collection components

- Removed dispatch calls for hiding home and API spec pages from Collection and CollectionItem components.
- Added logic in app slice to automatically hide these pages when a tab is added or focused, improving code maintainability.

* refactor: remove redundant hideHomePage dispatches from components
2025-12-23 19:19:25 +05:30
Abhishek S Lal
aeb6b12b06 fix: update SensitiveFieldWarning prop name for consistency in WsseAuth component (#6492) 2025-12-23 17:56:46 +05:30
lohit
41ed51b4e3 fix: handle additional context root paths for node-vm (#6491)
* fix: handle additional context root paths for node vm

* fix: handle additional context root paths for node vm

* fix: coderabbit review fixes
2025-12-23 17:31:51 +05:30
Sid
b85f60e1d6 fix: prevent double serialization of websocket text messages. (#6182) (#6479)
* fix: prevent double serialization of websocket text messages. (#6182)

* fix: improve websocket message handling and serialization

- Added normalization for message format to prevent double encoding.
- Updated queueMessage and sendMessage methods to handle message format.
- Refactored code for better readability and maintainability.

fix: enhance message normalization in WebSocket client

---------

Co-authored-by: Praveen kumar <praveenkumar042023@gmail.com>
2025-12-23 17:08:30 +05:30
naman-bruno
49ffdd1b8f fix: linux titlebar (#6483) 2025-12-23 16:54:30 +05:30
Abhishek S Lal
f1961a8988 refactor: update ResponsePane and QueryResultTypeSelector (#6490)
* refactor: update ResponsePane and QueryResultTypeSelector for improved tab handling and styling

- Adjusted the expanded width for right-side action buttons in ResponsePane.
- Refactored view tab toggle logic to enhance clarity and functionality.
- Introduced new styling for result view tabs and dropdown buttons.
- Added icon support for format options in QueryResultTypeSelector, improving visual feedback.
- Implemented dropdown state management to ensure proper interaction with active tabs.

* refactor: remove console log from ResponsePane for cleaner code
2025-12-23 16:38:24 +05:30
lohit
4831434e37 fix: oauth2 url update (#6489) 2025-12-23 16:00:17 +05:30
Sanjai Kumar
87c8934c45 fix: update stringifyHttpRequest to handle response body content correctly (#6488) 2025-12-23 15:48:52 +05:30
Pooja
01d4d3dc2a fix: run formatResponse execution on copy button click (#6485) 2025-12-23 14:25:18 +05:30
naman-bruno
70178f60b3 add: rename folder option while creating workspace (#6481) 2025-12-22 18:29:33 +05:30
Abhishek S Lal
cba164bc9b fix: update selectedTab prop to use selectedViewTab in ResponsePane component (#6478) 2025-12-22 14:58:54 +05:30
Pooja
669c99f40a fix: copy response based on preview toggle and selected format (#6436) 2025-12-22 13:53:08 +05:30
Abhishek S Lal
9967d863f5 feat: enhance ResponsePane with persisted response format and view tab state management (#6475)
- Added Redux state management for response format and view tab in ResponsePane.
- Implemented useCallback hooks for handling format changes and view tab toggling.
- Updated component to utilize persisted values from Redux, improving user experience by maintaining state across sessions.
2025-12-22 13:51:19 +05:30
Abhishek S Lal
3552801ca5 fix: refactor default tab selection logic for correct tab persistance (#6473) 2025-12-22 13:40:51 +05:30
Pooja
6f2804ea0f fix: var into tooltip for faker vars (#6312)
* fix: var into tooltip for faker vars

* fix: oauth variable

* rm: test

* rm: comment
2025-12-22 13:02:51 +05:30
Pragadesh-45
41efa8505b fix: restrict keyboard event handling to modal elements only (#6408)
* fix: restrict keyboard event handling to modal elements only

* chore: minor refactor

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-19 19:01:08 +05:30
Thomas
f47e9e9304 Add file attribute to Junit testsuite report (#6425)
* feat: add file attribute to Junit testsuite report

* test: update tests to include file attribute

* fix: update playwright tests to support the new file attribute

---------

Co-authored-by: Thomas Vackier <thomas.vackier@inthepocket.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-19 19:00:15 +05:30
Pooja
5f88e7d201 fix: auto-expand collapsed sidebar section when clicking action buttons (#6468) 2025-12-19 16:15:52 +05:30
Chirag Chandrashekhar
e4e17b0c74 fix: reverted the easy creation flow to the old, modal based approach (#6449)
* fix: reverted the easy creation flow to the old, modal based approach

* fix: updated the tests to use the old createRequest action and removed the usage of createUntitledRequest

* removed safe mode selection after collection open
2025-12-19 16:13:47 +05:30
Sid
83feffd41d chore: have setup install all deps (#6421)
* chore: have setup install all deps

* Update scripts/setup.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-12-19 16:05:28 +05:30
Chirag Chandrashekhar
7d783d473f feat: ability to open terminal at the root of a workspace (#6467)
* enhancement: ability to open terminal at the root of a workspace

* fix: imports

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-12-19 15:21:28 +05:30
dependabot[bot]
6a177e17d3 chore(deps): bump actions/upload-artifact from 5 to 6 (#6416)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-19 13:59:12 +05:30
Chirag Chandrashekhar
3552e7e609 Fix: Terminal Resize (#6448)
* fix: added watcher on parent div resize and window resize and calls fit() on both events

* removed effects and moved the resize handling to ref callback

* fix: added ResizeObserver cleanup

* fix: improve terminal resizing logic

- Refactored fit logic to avoid unnecessary calls during hidden states.
- Enhanced error handling when creating new terminal sessions.
- Updated ResizeObserver to ensure proper fitting on container resize.

* fix: remove unused fitRafRef in TerminalTab component

* fix: refactor terminal mount logic to use a dedicated callback

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-12-19 12:34:11 +05:30
Chirag Chandrashekhar
7164119695 fix: updated the selected state colors of tabs in dev tools (#6465) 2025-12-19 02:36:30 +05:30
Anoop M D
6a05b04676 Merge pull request #6463 from usebruno/feat/design-tweaks
feat: design tweaks
2025-12-19 00:32:49 +05:30
Anoop M D
8c1975ba7b feat: design tweaks 2025-12-19 00:32:02 +05:30
naman-bruno
397ccbb425 fix: export/import icons and styles (#6462) 2025-12-19 00:00:12 +05:30
Anoop M D
336496a1d7 Merge pull request #6461 from usebruno/feat/opencollection-presets
feat: opencollection presets
2025-12-18 22:43:38 +05:30
Anoop M D
aadbf8c33f feat: opencollection presets 2025-12-18 22:33:35 +05:30
Anoop M D
c5827dfa72 feat: opencollection actions (#6460) 2025-12-18 21:21:54 +05:30
Anoop M D
9738a2afb7 feat: opencollection actions 2025-12-18 21:19:29 +05:30
naman-bruno
a1c4113897 add: workspace tabs (#6456)
* add: workspace tabs

* fixes

* fixes
2025-12-18 21:01:58 +05:30
sanish chirayath
052d143d6e fix: example icon color (#6447)
* fix: example icon color

fix: example color

* fix: indentation

* fix: use gray color from colors for example

* fix:  margin  issues
2025-12-18 20:37:59 +05:30
Siddharth Gelera (reaper)
aac219d4cd fix: move gql variables prettify icon to a better position (#6455)
* move gql variable prettier icon down

* fix: doesn't need the class
2025-12-18 19:47:16 +05:30
sanish chirayath
b188a9e9a9 fix: unable to add assertions to a request (#6435)
* fix: add assertion

* rm: unnecessary wait fn

* fix: test

* fix: tests

* fix: review comments

* fix: review

* fix: review comments

* fix: review comments

* fix: test failure

* review fixes

* fix: rm sandbox accept

* fix: indentation
2025-12-18 19:37:33 +05:30
naman-bruno
6ab8fcb710 fix: duplicate message on workspace rename and close (#6457) 2025-12-18 18:48:57 +05:30
Sanjai Kumar
1cc117ceb9 fix: crash when saving empty GraphQL query in YAML collections (#6453)
* fix: ensure GraphQL variables are handled correctly in multiple components

* fix: reverted some changes

* chore: temp fix for tests

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-18 18:37:48 +05:30
Abhishek S Lal
62b8784972 feat: add hideResultTypeSelector prop to QueryResponse component (#6452)
* Introduced hideResultTypeSelector prop to conditionally render the QueryResultTypeSelector in the QueryResponse component.
* Updated BodyBlock to pass the type prop to control the visibility of the result type selector based on the request or response context.
* Adjusted styling in StyledWrapper for improved layout consistency.
2025-12-18 17:48:53 +05:30
Pooja
5e6444b8b5 feat: Set JavaScript sandbox to safe mode by default for new collections (#4824)
* feat: Set JavaScript sandbox to safe mode by default for new collections

* rm: sandbox code in playwright test

* rm: safe mode code in var interpolation test

* rm: sandbox modal code

* fix

* fix

* fix

* fix

* improve

* improvement

* fix

* fix
2025-12-18 17:27:38 +05:30
naman-bruno
bc2efb9686 Environment's as tabs (#6407)
* add: env's as tabs

* fix: test

* fix: tests

* fixes

* fix: test

* fixes

* fixes

* fix

* fix: styling

* fixes
2025-12-18 16:10:00 +05:30
Chirag Chandrashekhar
678fa88a7c perf: linkAware slow in large files (#6422)
* bugfix: linkAware slow in large files
- Added link detection and class addition operations in an editor.operation block for atomic operations and prevent multiple small rerenders.
- linkAware works on currently visible lines in the viewport. This is to speedup linkAware and defer detection for lines not in viewport.
- linkAware now runs after initial render and not before it. This ensures that we calculate to the lines on viewport and does not pause render.

* test(bruno-app): fix linkAware spec for debounced viewport marking
2025-12-18 15:52:04 +05:30
naman-bruno
80e09d1a26 fix: opencollection export as bruno json (#6444) 2025-12-18 14:24:45 +05:30
Abhishek S Lal
78ee99eab9 Fix/app titlebar windows (#6437)
* style: Update padding and margin in StyledWrapper for improved layout; adjust ActionIcon size in ResponseLayoutToggle for better UI consistency; enhance title bar color handling in Electron app

* feat: Enhance AppTitleBar with Windows-specific controls and OS detection

* refactor: Improve OS detection and error handling in AppTitleBar; streamline maximize state management

* feat: Implement IPC communication for maximize/unmaximize events in AppTitleBar; enhance state management in Electron main process
2025-12-17 21:40:24 +05:30
naman-bruno
73124fd715 add: manage workspace (#6424)
* add: manage workspace

* fixes

* replace dropdown to MenuDropdown

* rm: refs
2025-12-17 20:35:18 +05:30
naman-bruno
4c1fba611a fix: close all collection in workspace (#6434)
* fix: close all collection in workspace

* move: function
2025-12-17 18:52:17 +05:30
naman-bruno
3cfbf890ac add: export & import of workspace as zip (#6432)
* init

* fix

* update: package lock
2025-12-17 18:49:02 +05:30
Sanjai Kumar
395aa4246e fix: OpenAPI import fails when securitySchemes are not defined (#6429)
* feat: enhance OpenAPI security scheme handling

* refactor: revert test changes and update openapi-to-bruno
2025-12-17 16:52:54 +05:30
Pooja
639c8e573f fix: response pane size when devtool open (#6380) 2025-12-17 12:15:11 +05:30
Bijin A B
7d317a775b fix(playwright): interpolate request url with odata param (#6428) 2025-12-17 12:14:42 +05:30
Timon
2eb8db9b45 fix: Only update scroll position when unmounting the editor (#6420)
before the scroll position was updatet on every scroll, causing
everything related to the tab to rerender.
2025-12-16 18:34:29 +05:30
Abhishek S Lal
30d2a6d141 Refactor dropdown components to use MenuDropdown for improved functionality and keyboard accessibility (#6404)
* Refactor dropdown components to use MenuDropdown for improved functionality and keyboard accessibility

- Replaced Dropdown with MenuDropdown in various components including BodyModeSelector, AuthMode, and RequestBodyMode.
- Updated styles and structure for better usability and accessibility.
- Removed unused Dropdown component and its associated styles.
- Enhanced action buttons in ResponsePane and Collection components with ActionIcon for better UI consistency.

* fix: Update HttpMethodSelector styles and tests for improved accessibility

- Changed the class name for the "Add Custom" button to include 'text-link' for better styling.
- Updated tests to use role-based queries for dropdown items, enhancing accessibility checks.
- Ensured the correct application of classes in tests to reflect the updated structure.

* refactor: Improve component accessibility and consistency

* fix: update hover behavior for collection actions menu in runner.ts

* refactor: streamline hover interactions for collection actions across tests

* refactor: enhance component structure and accessibility across response actions

* fix: correct fill property syntax in StyledWrapper for consistent styling

* refactor: simplify isDisabled logic in response components for clarity

* fix: correct tabIndex logic in ResponseCopy component for improved accessibility

* fix: update tabIndex logic in ResponseBookmark component for improved accessibility

* fix: enable action buttons in ResponsePaneActions for improved usability

* refactor: remove unnecessary tabIndex attributes in response components for improved accessibility

* refactor: remove keyDown event handlers from response components for cleaner interaction

* refactor: remove SidebarHeader component and related styles for improved structure
2025-12-16 18:26:38 +05:30
lohit
231776ca4b feat: use default browser for oauth2 authorization bru-2167 (#6101)
* feat: use default browser for oauth2 authorization bru-2167

* fix: coderabbit review comment fixes

* fix: coderabbit review comment fixes

* fix: protocol registration updates

* fix: coderabbit review comment suggestions

* fix: oauth2 auth form use system browser option
2025-12-16 17:23:49 +05:30
Pooja
dbd966850c fix: openapi body import (#6288)
* fix: openapi body import

* add: unit test

* fix

* fix

* Revert "fix"

This reverts commit 3219e8af8e.

* fix: we need the same check here too!

* fix: handle number type

* fix: correct empty securitySchemes check

---------

Co-authored-by: Taylore Thornton <tthornton3@chewy.com>
2025-12-16 17:23:22 +05:30
Pooja
dc111ecce2 add: presets in collection setting (#6389)
* add: presets in collection setting

* fix

* add: websocket in preset

* fix: htmlFor
2025-12-16 17:16:41 +05:30
Pooja
fdff792476 feat: add support for ssl cert in websockt (#6286)
* feat: add support for ssl cert in websockt

* improvements

* add: wss in animation

* fix: avoid a race condition between the locator's promise and the expect call

JS starts resolving promises even without the await unless it's a function, this can cause a race in this case

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-12-16 17:12:47 +05:30
Bijin A B
a9c63e6f2a Revert "Save cookies on redirect response (#6094)" (#6413)
This reverts commit 1b9ea478da.
2025-12-15 20:10:06 +05:30
Abhishek S Lal
014817810d Fix/response pane optimizations (#6395)
* refactor: update content type detection to use base64 decoding

* fix: some styling issues and autofocus issues in input resolved

* refactor: enhance ResponsePane and QueryResult components for improved response handling and size display

* refactor: simplify size display logic in ResponseSize component

* refactor: improve size formatting logic in ResponseSize component for better readability

* refactor: enhance base64 decoding function to handle invalid input and improve error handling
2025-12-15 19:32:57 +05:30
Pragadesh-45
71cf1a8f26 fix: include request URL in prompt variable extraction and add tests (#6412) 2025-12-15 19:13:33 +05:30
naman-bruno
a769ca3ae4 fix: tabs z-index issue (#6411) 2025-12-15 18:22:08 +05:30
naman-bruno
3d61106cc1 fix: rename crash (#6410) 2025-12-15 18:20:36 +05:30
naman-bruno
6cc114100f fix (#6409) 2025-12-15 18:17:11 +05:30
Abhishek S Lal
c11266a96f fix: Improved logic for determining right side expandability of Response Actions (#6398)
* fix: Improved logic for determining right side expandability based on container width and provided width.

* refactor: Standardize naming for right-side expandability a

* refactor: Simplify collection interaction in keyboard shortcuts tests

* refactor: Update right-side expandability logic in ResponsiveTabs and StyledWrapper components
2025-12-15 16:22:32 +05:30
naman-bruno
8b0f41e3cb fix: default workspace error checking (#6379)
* fix: default workspace error checking

* add: tests

* fixes

* fix

* fixes

* fixes

* fix

* fixes

* fix

* chore: close app context in tests

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-15 15:10:53 +05:30
Jeroen Vinke
1b9ea478da Save cookies on redirect response (#6094)
Co-authored-by: Jeroen Vinke <jeroen.vinke@iddinkgroup.com>
2025-12-15 14:08:20 +05:30
sanish chirayath
8cbda5f5cc fix: refactor response examples to use MenuDropdown and Editable components (#6382)
* feat: use common dropdown component

* fix: update example ui to match v3

* fix: test cases, bugs

* fix: review comments

* fix: review comments

* fix: review

* fix: file body/binary table within response examples

* fix: file name, close btn not visible issue

* fix: unnessary transition for three  dots

* fix: install missing deps in bruno-app

* update example url when param is updated

* empty commit

* chore: update package-lock.json

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-14 16:21:06 +05:30
sanish chirayath
2f5537c8db Enhance file watching by ensuring 'node_modules' and '.git' are always ignored (#6391)
* Enhance file watching by ensuring 'node_modules' and '.git' are always ignored

* fix: tests

* rm: dialog assignments

* fix:  duplication
2025-12-12 19:04:52 +05:30
Abhishek S Lal
2327b21c85 Feat/response tabs rewamp (#6388)
* refactor: used common component for layout switching button

* refactor: replace RequestPaneTabs with ResponsiveTabs component across RequestPane and HttpRequestPane

* refactor: simplify ResponsePaneActions component and improve layout handling

* refactor: enhance ResponsePane component with improved tab handling and layout adjustments

* refactor: update layout toggle functionality and button labels in ResponsePane components

* refactor: ensure consistent action button selection in response actions
2025-12-12 17:33:07 +05:30
max-melhuish-depop
6652cca642 Removed filtering of empty strings from url paths when importing from postman collection (#5868)
* removed filtering of empty strings from url paths when importing from postman collection

* revert accidental non-pr changes

* chore: remove console logs

---------

Co-authored-by: Max Melhuish <238188923+max-melhuish-depop@users.noreply.github.com>
Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-12-12 11:52:29 +05:30
415 changed files with 20694 additions and 8536 deletions

View File

@@ -142,7 +142,7 @@ jobs:
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report

1
.gitignore vendored
View File

@@ -53,6 +53,7 @@ bruno.iml
/blob-report/
# Development plan files
CLAUDE.md
*.plan.md
# packages dist

2
.nvmrc
View File

@@ -1 +1 @@
v22.11.0
v22.12.0

View File

@@ -117,6 +117,18 @@ module.exports = runESMImports().then(() => defineConfig([
'no-undef': 'error'
}
},
{
// Storybook config files use CommonJS with __dirname and module.exports
files: ['packages/bruno-app/storybook/**/*.js'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'no-undef': 'error'
}
},
{
files: ['packages/bruno-cli/**/*.js'],
ignores: ['**/*.config.js'],

2666
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,10 @@
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
"@storybook/builder-webpack5": "^10.1.10",
"@storybook/react": "^10.1.10",
"@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
@@ -43,6 +47,7 @@
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
"storybook": "^10.1.10",
"ts-jest": "^29.2.6"
},
"scripts": {

View File

@@ -22,6 +22,7 @@ build
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# local env files
.env.local

View File

@@ -9,7 +9,9 @@
"preview": "rsbuild preview",
"test": "jest",
"test:prettier": "prettier --check \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\"",
"storybook": "storybook dev -p 6006 --config-dir storybook",
"build-storybook": "storybook build --config-dir storybook"
},
"dependencies": {
"@fontsource/inter": "^5.0.15",
@@ -44,9 +46,8 @@
"i18next": "24.1.2",
"idb": "^7.0.0",
"immer": "^9.0.15",
"jsesc": "^3.0.2",
"js-yaml": "^4.1.0",
"xml2js": "^0.6.2",
"jsesc": "^3.0.2",
"jshint": "^2.13.6",
"json5": "^2.2.3",
"jsonc-parser": "^3.2.1",
@@ -56,6 +57,7 @@
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",
"moment": "^2.30.1",
"moment-timezone": "^0.5.47",
"mousetrap": "^1.6.5",
@@ -63,6 +65,7 @@
"path": "^0.12.7",
"pdfjs-dist": "4.4.168",
"platform": "^1.3.6",
"polished": "^4.3.1",
"posthog-node": "4.2.1",
"prettier": "^2.7.1",
"qs": "^6.11.0",
@@ -89,6 +92,7 @@
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"xml2js": "^0.6.2",
"yup": "^0.32.11"
},
"devDependencies": {

View File

@@ -5,7 +5,6 @@ const Wrapper = styled.div`
display: flex;
align-items: center;
background: ${(props) => props.theme.sidebar.bg};
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
-webkit-app-region: drag;
user-select: none;
@@ -22,7 +21,7 @@ const Wrapper = styled.div`
/* When in full screen, no traffic lights so reduce padding */
&.fullscreen .titlebar-content {
padding-left: 4px;
padding-left: 6px;
}
/* Remove drag region from interactive elements */
@@ -103,6 +102,13 @@ const Wrapper = styled.div`
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
-webkit-app-region: no-drag;
}
/* App action buttons container */
.titlebar-actions {
display: flex;
align-items: center;
}
/* Workspace Dropdown Styles */
@@ -181,17 +187,70 @@ const Wrapper = styled.div`
}
/* Adjust for non-macOS platforms */
body:not(.os-mac) & {
.titlebar-content {
padding-left: 12px;
&:not(.os-mac) .titlebar-content {
padding-left: 12px;
}
/* Windows-specific styles */
&.os-windows .titlebar-content {
padding-right: 0px;
padding-left: 0px;
}
&.os-windows .titlebar-left {
margin-left: 6px;
}
&.os-linux .titlebar-content {
padding-right: 0px;
padding-left: 0px;
}
&.os-linux .titlebar-left {
margin-left: 6px;
}
/* Custom window control buttons for Windows - always interactive, above modal overlay */
.window-controls {
display: flex;
align-items: stretch;
height: 36px;
margin-left: 8px;
position: relative;
z-index: 1000;
}
.window-control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 46px;
height: 100%;
border: none;
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
transition: background-color 0.1s ease;
-webkit-app-region: no-drag;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&:active {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&.close:hover {
background: #e81123;
color: white;
}
}
/* Leave room for Windows caption buttons when the overlay is enabled */
body.os-windows & {
.titlebar-content {
padding-right: 120px;
}
.dropdown-item-active {
font-weight: 400 !important;
background-color: ${(props) => props.theme.dropdown.selectedBg} !important;
color: ${(props) => props.theme.dropdown.selectedColor} !important;
}
`;

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconPin, IconPinned, IconPlus, IconDownload, IconSettings, IconMinus, IconSquare, IconX, IconCopy } from '@tabler/icons';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showHomePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
@@ -14,14 +14,29 @@ import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import StyledWrapper from './StyledWrapper';
import { toTitleCase } from 'utils/common/index';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
const getOsClass = () => {
if (isMacOS()) return 'os-mac';
if (isWindowsOS()) return 'os-windows';
if (isLinuxOS()) return 'os-linux';
return 'os-other';
};
const AppTitleBar = () => {
const dispatch = useDispatch();
const [isFullScreen, setIsFullScreen] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const osClass = getOsClass();
const isWindows = osClass === 'os-windows';
const isLinux = osClass === 'os-linux';
const showWindowControls = isWindows || isLinux;
// Listen for fullscreen changes
useEffect(() => {
@@ -42,6 +57,46 @@ const AppTitleBar = () => {
};
}, []);
useEffect(() => {
if (!showWindowControls) return;
const { ipcRenderer } = window;
if (!ipcRenderer) return;
ipcRenderer.invoke('renderer:window-is-maximized')
.then((maximized) => {
setIsMaximized(maximized);
})
.catch((error) => {
console.error('Error getting initial maximized state:', error);
});
const removeMaximizedListener = ipcRenderer.on('main:window-maximized', () => {
setIsMaximized(true);
});
const removeUnmaximizedListener = ipcRenderer.on('main:window-unmaximized', () => {
setIsMaximized(false);
});
return () => {
removeMaximizedListener();
removeUnmaximizedListener();
};
}, [showWindowControls]);
const handleMinimize = useCallback(() => {
window.ipcRenderer?.send('renderer:window-minimize');
}, []);
const handleMaximize = useCallback(() => {
window.ipcRenderer?.send('renderer:window-maximize');
// State will be updated via IPC events from main process (main:window-maximized/main:window-unmaximized)
}, []);
const handleClose = useCallback(() => {
window.ipcRenderer?.send('renderer:window-close');
}, []);
// Get workspace info
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
@@ -55,6 +110,7 @@ const AppTitleBar = () => {
}, [workspaces, preferences]);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [importWorkspaceModalOpen, setImportWorkspaceModalOpen] = useState(false);
const WorkspaceName = forwardRef((props, ref) => {
return (
@@ -87,6 +143,14 @@ const AppTitleBar = () => {
setCreateWorkspaceModalOpen(true);
};
const handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
};
const handleImportWorkspace = () => {
setImportWorkspaceModalOpen(true);
};
const handlePinWorkspace = useCallback((workspaceUid, e) => {
e.preventDefault();
e.stopPropagation();
@@ -94,8 +158,6 @@ const AppTitleBar = () => {
dispatch(savePreferences(newPreferences));
}, [dispatch, preferences]);
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const handleToggleSidebar = () => {
dispatch(toggleSidebarCollapse());
};
@@ -108,18 +170,6 @@ const AppTitleBar = () => {
}
};
const handleToggleVerticalLayout = () => {
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences?.layout || {},
responsePaneOrientation: newOrientation
}
};
dispatch(savePreferences(updatedPreferences));
};
// Build workspace menu items
const workspaceMenuItems = useMemo(() => {
const items = sortedWorkspaces.map((workspace) => {
@@ -167,6 +217,18 @@ const AppTitleBar = () => {
leftSection: IconFolder,
label: 'Open workspace',
onClick: handleOpenWorkspace
},
{
id: 'import-workspace',
leftSection: IconDownload,
label: 'Import workspace',
onClick: handleImportWorkspace
},
{
id: 'manage-workspaces',
leftSection: IconSettings,
label: 'Manage workspaces',
onClick: handleManageWorkspaces
}
);
@@ -174,10 +236,13 @@ const AppTitleBar = () => {
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
)}
{importWorkspaceModalOpen && (
<ImportWorkspace onClose={() => setImportWorkspaceModalOpen(false)} />
)}
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
@@ -210,39 +275,55 @@ const AppTitleBar = () => {
{/* Right section: Action buttons */}
<div className="titlebar-right">
{/* Toggle sidebar */}
<ActionIcon
onClick={handleToggleSidebar}
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
size="lg"
data-testid="toggle-sidebar-button"
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
</ActionIcon>
<div className="titlebar-actions">
{/* Toggle sidebar */}
<ActionIcon
onClick={handleToggleSidebar}
label={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
size="lg"
data-testid="toggle-sidebar-button"
>
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
</ActionIcon>
{/* Toggle devtools */}
<ActionIcon
onClick={handleToggleDevtools}
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
size="lg"
data-testid="toggle-devtools-button"
>
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
</ActionIcon>
{/* Toggle devtools */}
<ActionIcon
onClick={handleToggleDevtools}
label={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
size="lg"
data-testid="toggle-devtools-button"
>
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
</ActionIcon>
{/* Toggle vertical layout */}
<ActionIcon
onClick={handleToggleVerticalLayout}
label={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
size="lg"
data-testid="toggle-vertical-layout-button"
>
{orientation === 'horizontal' ? (
<IconLayoutColumns size={16} stroke={1.5} />
) : (
<IconLayoutRows size={16} stroke={1.5} />
)}
</ActionIcon>
<ResponseLayoutToggle />
</div>
{showWindowControls && (
<div className="window-controls">
<button
className="window-control-btn minimize"
onClick={handleMinimize}
aria-label="Minimize"
>
<IconMinus size={16} stroke={1} />
</button>
<button
className="window-control-btn maximize"
onClick={handleMaximize}
aria-label={isMaximized ? 'Restore' : 'Maximize'}
>
{isMaximized ? <IconCopy size={14} stroke={1} /> : <IconSquare size={14} stroke={1} />}
</button>
<button
className="window-control-btn close"
onClick={handleClose}
aria-label="Close"
>
<IconX size={16} stroke={1} />
</button>
</div>
)}
</div>
</div>
</StyledWrapper>

View File

@@ -0,0 +1,38 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
font-size: ${(props) => props.theme.font.size.base};
.body-mode-selector {
background: transparent;
border-radius: 3px;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
padding-left: 1.5rem !important;
display: flex;
align-items: center;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
.dropdown-icon {
display: flex;
align-items: center;
margin-right: 0.5rem;
}
}
.caret {
color: ${(props) => props.theme.colors.text.muted};
fill: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -1,17 +1,33 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import React, { useMemo } from 'react';
import { IconCaretDown, IconForms, IconBraces, IconCode, IconFileText, IconDatabase, IconFile, IconX } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import { humanizeRequestBodyMode } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const DEFAULT_MODES = [
{ key: 'multipartForm', label: 'Multipart Form', category: 'Form' },
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form' },
{ key: 'json', label: 'JSON', category: 'Raw' },
{ key: 'xml', label: 'XML', category: 'Raw' },
{ key: 'text', label: 'TEXT', category: 'Raw' },
{ key: 'sparql', label: 'SPARQL', category: 'Raw' },
{ key: 'file', label: 'File / Binary', category: 'Other' },
{ key: 'none', label: 'None', category: 'Other' }
{
name: 'Form',
options: [
{ id: 'multipartForm', label: 'Multipart Form', leftSection: IconForms },
{ id: 'formUrlEncoded', label: 'Form URL Encoded', leftSection: IconForms }
]
},
{
name: 'Raw',
options: [
{ id: 'json', label: 'JSON', leftSection: IconBraces },
{ id: 'xml', label: 'XML', leftSection: IconCode },
{ id: 'text', label: 'TEXT', leftSection: IconFileText },
{ id: 'sparql', label: 'SPARQL', leftSection: IconDatabase }
]
},
{
name: 'Other',
options: [
{ id: 'file', label: 'File / Binary', leftSection: IconFile },
{ id: 'none', label: 'No Body', leftSection: IconX }
]
}
];
const BodyModeSelector = ({
@@ -21,62 +37,39 @@ const BodyModeSelector = ({
disabled = false,
className = '',
wrapperClassName = '',
showCategories = true,
placement = 'bottom-end'
}) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(currentMode)}
{' '}
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
const onModeSelect = (mode) => {
dropdownTippyRef.current.hide();
onModeChange(mode);
};
// Group modes by category for rendering
const groupedModes = modes.reduce((acc, mode) => {
const category = mode.category || 'Other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(mode);
return acc;
}, {});
// Add onClick handlers to mode options
const menuItems = useMemo(() => {
return modes.map((group) => ({
...group,
options: group.options.map((option) => ({
...option,
onClick: () => onModeChange(option.id)
}))
}));
}, [modes, onModeChange]);
return (
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'} ${wrapperClassName}`}>
<Dropdown
onCreate={onDropdownCreate}
icon={<Icon />}
placement={placement}
disabled={disabled}
className={className}
>
{Object.entries(groupedModes).map(([category, categoryModes]) => (
<React.Fragment key={category}>
{showCategories && <div className="label-item font-medium">{category}</div>}
{categoryModes.map((mode) => (
<div
key={mode.key}
className="dropdown-item"
onClick={() => onModeSelect(mode.key)}
>
{mode.label}
</div>
))}
</React.Fragment>
))}
</Dropdown>
</div>
<StyledWrapper className={wrapperClassName}>
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'}`}>
<MenuDropdown
items={menuItems}
placement={placement}
disabled={disabled}
className={className}
selectedItemId={currentMode}
showGroupDividers={false}
groupStyle="select"
>
<div className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(currentMode)}
{' '}
<IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</StyledWrapper>
);
};

View File

@@ -192,7 +192,6 @@ export default class CodeEditor extends React.Component {
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
editor.on('scroll', this.onScroll);
editor.scrollTo(null, this.props.initialScroll);
this.addOverlay();
@@ -229,8 +228,10 @@ export default class CodeEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setCursor(cursor);
}
if (this.editor) {
@@ -275,13 +276,19 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);
}
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this.onScroll);
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
const wrapper = this.editor.getWrapperElement();
wrapper?.parentNode?.removeChild(wrapper);
this.editor = null;
}
}
@@ -325,8 +332,6 @@ export default class CodeEditor extends React.Component {
this.editor.setOption('mode', 'brunovariables');
};
onScroll = (event) => this.props.onScroll?.(event);
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);

View File

@@ -8,20 +8,12 @@ const Wrapper = styled.div`
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
.caret {
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef } from 'react';
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { useDispatch } from 'react-redux';
import { updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,113 +9,77 @@ import StyledWrapper from './StyledWrapper';
const AuthMode = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = collection.draft?.root ? get(collection, 'draft.root.request.auth.mode') : get(collection, 'root.request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
const onModeChange = useCallback((value) => {
dispatch(
updateCollectionAuthMode({
collectionUid: collection.uid,
mode: value
})
);
};
}, [dispatch, collection.uid]);
const menuItems = useMemo(() => [
{
id: 'awsv4',
label: 'AWS Sig v4',
onClick: () => onModeChange('awsv4')
},
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'digest',
label: 'Digest Auth',
onClick: () => onModeChange('digest')
},
{
id: 'ntlm',
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE 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={() => {
dropdownTippyRef.current.hide();
onModeChange('oauth2');
}}
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</MenuDropdown>
</div>
</StyledWrapper>
);

View File

@@ -12,6 +12,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
import NTLMAuth from './NTLMAuth';
import Button from 'ui/Button';
const Auth = ({ collection }) => {
const authMode = collection.draft?.root ? get(collection, 'draft.root.request.auth.mode') : get(collection, 'root.request.auth.mode');
@@ -59,9 +60,9 @@ const Auth = ({ collection }) => {
</div>
{getAuthView()}
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -48,32 +48,37 @@ const StyledWrapper = styled.div`
}
.protocol-https,
.protocol-grpcs {
.protocol-grpcs,
.protocol-wss {
position: absolute;
right: 8px;
top: 0;
bottom: 0;
transition: transform 0.3s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
}
.protocol-https {
animation: slideUpDown 6s infinite;
animation: slideUpDown 9s infinite;
transform: translateY(0);
}
.protocol-grpcs {
animation: slideUpDown 6s infinite 3s;
animation: slideUpDown 9s infinite 3s;
transform: translateY(100%);
}
.protocol-wss {
animation: slideUpDown 9s infinite 6s;
transform: translateY(100%);
}
@keyframes slideUpDown {
0%, 45% {
0%, 30% {
transform: translateY(0);
}
50%, 95% {
33.33%, 97% {
transform: translateY(100%);
}
100% {

View File

@@ -13,6 +13,7 @@ import { useDispatch } from 'react-redux';
import { updateCollectionClientCertificates } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import get from 'lodash/get';
import Button from 'ui/Button';
const ClientCertSettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -180,6 +181,7 @@ const ClientCertSettings = ({ collection }) => {
<span className="protocol-placeholder">
<span className="protocol-https">https://</span>
<span className="protocol-grpcs">grpcs://</span>
<span className="protocol-wss">wss://</span>
</span>
</div>
<input
@@ -373,13 +375,13 @@ const ClientCertSettings = ({ collection }) => {
) : null}
</div>
<div className="mt-6 flex flex-row gap-2 items-center">
<button type="submit" className="submit btn btn-sm btn-secondary" data-testid="add-client-cert">
<Button type="submit" size="sm" data-testid="add-client-cert">
Add
</button>
</Button>
<div className="h-4 border-l border-gray-600"></div>
<button type="button" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="button" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</form>
</StyledWrapper>

View File

@@ -10,6 +10,7 @@ import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -107,9 +108,9 @@ const Headers = ({ collection }) => {
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -1,11 +1,11 @@
import React from 'react';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconBox, IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
import { IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ShareCollection from 'components/ShareCollection/index';
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
const Info = ({ collection }) => {
const dispatch = useDispatch();
@@ -53,7 +53,13 @@ const Info = ({ collection }) => {
type="button"
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
onClick={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
>
{collectionEnvironmentCount} collection environment{collectionEnvironmentCount !== 1 ? 's' : ''}
@@ -61,7 +67,15 @@ const Info = ({ collection }) => {
<button
type="button"
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
onClick={() => dispatch(updateGlobalEnvironmentSettingsModalVisibility(true))}
onClick={() => {
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
>
{globalEnvironmentCount} global environment{globalEnvironmentCount !== 1 ? 's' : ''}
</button>

View File

@@ -6,7 +6,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { isItemARequest, itemIsOpenedInTabs } from 'utils/tabs/index';
import { getDefaultRequestPaneTab } from 'utils/collections/index';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
const RequestsNotLoaded = ({ collection }) => {
const dispatch = useDispatch();
@@ -21,7 +20,6 @@ const RequestsNotLoaded = ({ collection }) => {
const handleRequestClick = (item) => (e) => {
e.preventDefault();
if (isItemARequest(item)) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
focusTab({

View File

@@ -0,0 +1,29 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
.settings-label {
width: 110px;
}
.textbox {
border: 1px solid #ccc;
padding: 0.15rem 0.45rem;
box-shadow: none;
border-radius: 0px;
outline: none;
box-shadow: none;
transition: border-color ease-in-out 0.1s;
border-radius: 3px;
background-color: ${(props) => props.theme.modal.input.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
&:focus {
border: solid 1px ${(props) => props.theme.modal.input.focusBorder} !important;
outline: none !important;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,135 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
import Button from 'ui/Button';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const initialPresets = { requestType: 'http', requestUrl: '' };
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
const currentPresets = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.presets', initialPresets)
: get(collection, 'brunoConfig.presets', initialPresets);
// Helper to update presets config
const updatePresets = (updates) => {
const updatedPresets = { ...currentPresets, ...updates };
dispatch(updateCollectionPresets({
collectionUid: collection.uid,
presets: updatedPresets
}));
};
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const handleRequestTypeChange = (e) => {
updatePresets({ requestType: e.target.value });
};
const handleRequestUrlChange = (e) => {
updatePresets({ requestUrl: e.target.value });
};
return (
<StyledWrapper className="h-full w-full">
<div className="text-xs mb-4 mt-4 text-muted">
These presets will be used as the default values for new requests in this collection.
</div>
<div className="bruno-form">
<div className="mb-3 flex items-center">
<label className="settings-label flex items-center" htmlFor="http">
Request Type
</label>
<div className="flex items-center">
<input
id="http"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="http"
checked={(currentPresets.requestType || 'http') === 'http'}
/>
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
HTTP
</label>
<input
id="graphql"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="graphql"
checked={(currentPresets.requestType || 'http') === 'graphql'}
/>
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
GraphQL
</label>
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="grpc"
checked={(currentPresets.requestType || 'http') === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
<input
id="ws"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="ws"
checked={(currentPresets.requestType || 'http') === 'ws'}
/>
<label htmlFor="ws" className="ml-1 cursor-pointer select-none">
WebSocket
</label>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="request-url">
Base URL
</label>
<div className="flex items-center w-full">
<div className="flex items-center flex-grow input-container h-full">
<input
id="request-url"
type="text"
name="requestUrl"
placeholder="Request URL"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handleRequestUrlChange}
value={currentPresets.requestUrl || ''}
style={{ width: '100%' }}
/>
</div>
</div>
</div>
<div className="mt-6">
<Button type="button" size="sm" onClick={handleSave}>
Save
</Button>
</div>
</div>
</StyledWrapper>
);
};
export default PresetsSettings;

View File

@@ -12,6 +12,7 @@ import { getBasename } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import useProtoFileManagement from '../../../hooks/useProtoFileManagement';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Button from 'ui/Button';
const ProtobufSettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -335,9 +336,9 @@ const ProtobufSettings = ({ collection }) => {
</div>
<div className="mt-6">
<button type="button" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="button" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>

View File

@@ -8,10 +8,23 @@ import { updateCollectionProxy } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
const ProxySettings = ({ collection }) => {
const dispatch = useDispatch();
const initialProxyConfig = { enabled: 'global', protocol: 'http', hostname: '', port: '', auth: { enabled: false, username: '', password: '' }, bypassProxy: '' };
const initialProxyConfig = {
inherit: true,
config: {
protocol: 'http',
hostname: '',
port: '',
auth: {
username: '',
password: ''
},
bypassProxy: ''
}
};
// Get proxy from draft.brunoConfig if it exists, otherwise from brunoConfig
const currentProxyConfig = collection.draft?.brunoConfig
@@ -81,34 +94,57 @@ const ProxySettings = ({ collection }) => {
const handleEnabledChange = (e) => {
const value = e.target.value;
// Convert string to boolean or keep as 'global'
const enabled = value === 'true' ? true : value === 'false' ? false : 'global';
updateProxy({ enabled });
// Map UI values to new format
if (value === 'inherit') {
updateProxy({ disabled: false, inherit: true });
} else if (value === 'true') {
updateProxy({ disabled: false, inherit: false });
} else {
updateProxy({ disabled: true, inherit: false });
}
};
const handleProtocolChange = (e) => {
updateProxy({ protocol: e.target.value });
updateProxy({
config: {
...currentProxyConfig.config,
protocol: e.target.value
}
});
};
const handleHostnameChange = (e) => {
const hostname = e.target.value;
if (validateHostnameOnChange(hostname)) {
updateProxy({ hostname });
updateProxy({
config: {
...currentProxyConfig.config,
hostname
}
});
}
};
const handlePortChange = (e) => {
const port = e.target.value ? Number(e.target.value) : '';
if (validatePortOnChange(port)) {
updateProxy({ port });
updateProxy({
config: {
...currentProxyConfig.config,
port
}
});
}
};
const handleAuthEnabledChange = (e) => {
updateProxy({
auth: {
...currentProxyConfig.auth,
enabled: e.target.checked
config: {
...currentProxyConfig.config,
auth: {
...currentProxyConfig.config.auth,
disabled: !e.target.checked
}
}
});
};
@@ -117,9 +153,12 @@ const ProxySettings = ({ collection }) => {
const username = e.target.value;
if (validateAuthUsernameOnChange(username)) {
updateProxy({
auth: {
...currentProxyConfig.auth,
username
config: {
...currentProxyConfig.config,
auth: {
...currentProxyConfig.config.auth,
username
}
}
});
}
@@ -129,9 +168,12 @@ const ProxySettings = ({ collection }) => {
const password = e.target.value;
if (validateAuthPasswordOnChange(password)) {
updateProxy({
auth: {
...currentProxyConfig.auth,
password
config: {
...currentProxyConfig.config,
auth: {
...currentProxyConfig.config.auth,
password
}
}
});
}
@@ -140,11 +182,19 @@ const ProxySettings = ({ collection }) => {
const handleBypassProxyChange = (e) => {
const bypassProxy = e.target.value;
if (validateBypassProxyOnChange(bypassProxy)) {
updateProxy({ bypassProxy });
updateProxy({
config: {
...currentProxyConfig.config,
bypassProxy
}
});
}
};
const enabledValue = currentProxyConfig.enabled === true ? 'true' : currentProxyConfig.enabled === false ? 'false' : 'global';
// Map new format to UI values
const disabled = currentProxyConfig.disabled || false;
const inherit = currentProxyConfig.inherit !== undefined ? currentProxyConfig.inherit : true;
const enabledValue = disabled ? 'false' : (inherit ? 'inherit' : 'true');
return (
<StyledWrapper className="h-full w-full">
@@ -156,9 +206,9 @@ const ProxySettings = ({ collection }) => {
<InfoTip infotipId="request-var">
<div>
<ul>
<li><span style={{ width: '50px', display: 'inline-block' }}>global</span> - use global proxy config</li>
<li><span style={{ width: '50px', display: 'inline-block' }}>enabled</span> - use collection proxy config</li>
<li><span style={{ width: '50px', display: 'inline-block' }}>disable</span> - disable proxy</li>
<li><span style={{ width: '50px', display: 'inline-block' }}>inherit</span> - inherit from global preferences</li>
<li><span style={{ width: '50px', display: 'inline-block' }}>enabled</span> - use collection-specific proxy config</li>
<li><span style={{ width: '50px', display: 'inline-block' }}>disabled</span> - disable proxy for this collection</li>
</ul>
</div>
</InfoTip>
@@ -168,12 +218,12 @@ const ProxySettings = ({ collection }) => {
<input
type="radio"
name="enabled"
value="global"
checked={enabledValue === 'global'}
value="inherit"
checked={enabledValue === 'inherit'}
onChange={handleEnabledChange}
className="mr-1"
/>
global
inherit
</label>
<label className="flex items-center ml-4">
<input
@@ -199,168 +249,172 @@ const ProxySettings = ({ collection }) => {
</label>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol">
Protocol
</label>
<div className="flex items-center">
<label className="flex items-center">
{enabledValue === 'true' && (
<>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol">
Protocol
</label>
<div className="flex items-center">
<label className="flex items-center">
<input
type="radio"
name="protocol"
value="http"
checked={(currentProxyConfig.config?.protocol || 'http') === 'http'}
onChange={handleProtocolChange}
className="mr-1"
/>
HTTP
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="https"
checked={(currentProxyConfig.config?.protocol || 'http') === 'https'}
onChange={handleProtocolChange}
className="mr-1"
/>
HTTPS
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="socks4"
checked={(currentProxyConfig.config?.protocol || 'http') === 'socks4'}
onChange={handleProtocolChange}
className="mr-1"
/>
SOCKS4
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="socks5"
checked={(currentProxyConfig.config?.protocol || 'http') === 'socks5'}
onChange={handleProtocolChange}
className="mr-1"
/>
SOCKS5
</label>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="hostname">
Hostname
</label>
<input
type="radio"
name="protocol"
value="http"
checked={(currentProxyConfig.protocol || 'http') === 'http'}
onChange={handleProtocolChange}
className="mr-1"
/>
HTTP
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="https"
checked={(currentProxyConfig.protocol || 'http') === 'https'}
onChange={handleProtocolChange}
className="mr-1"
/>
HTTPS
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="socks4"
checked={(currentProxyConfig.protocol || 'http') === 'socks4'}
onChange={handleProtocolChange}
className="mr-1"
/>
SOCKS4
</label>
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
value="socks5"
checked={(currentProxyConfig.protocol || 'http') === 'socks5'}
onChange={handleProtocolChange}
className="mr-1"
/>
SOCKS5
</label>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="hostname">
Hostname
</label>
<input
id="hostname"
type="text"
name="hostname"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handleHostnameChange}
value={currentProxyConfig.hostname || ''}
/>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="port">
Port
</label>
<input
id="port"
type="number"
name="port"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handlePortChange}
value={currentProxyConfig.port || ''}
/>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.enabled">
Auth
</label>
<input
type="checkbox"
name="auth.enabled"
checked={currentProxyConfig.auth?.enabled || false}
onChange={handleAuthEnabledChange}
/>
</div>
<div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.username">
Username
</label>
<input
id="auth.username"
type="text"
name="auth.username"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={currentProxyConfig.auth?.username || ''}
onChange={handleAuthUsernameChange}
/>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.password">
Password
</label>
<div className="textbox flex flex-row items-center w-[13.2rem] h-[1.70rem] relative">
<input
id="auth.password"
type={passwordVisible ? 'text' : 'password'}
name="auth.password"
className="outline-none bg-transparent w-[10.5rem]"
id="hostname"
type="text"
name="hostname"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={currentProxyConfig.auth?.password || ''}
onChange={handleAuthPasswordChange}
onChange={handleHostnameChange}
value={currentProxyConfig.config?.hostname || ''}
/>
<button
type="button"
className="btn btn-sm absolute right-0"
onClick={() => setPasswordVisible(!passwordVisible)}
>
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
</button>
</div>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="bypassProxy">
Proxy Bypass
</label>
<input
id="bypassProxy"
type="text"
name="bypassProxy"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handleBypassProxyChange}
value={currentProxyConfig.bypassProxy || ''}
/>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="port">
Port
</label>
<input
id="port"
type="number"
name="port"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handlePortChange}
value={currentProxyConfig.config?.port || ''}
/>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.disabled">
Auth
</label>
<input
type="checkbox"
name="auth.disabled"
checked={!currentProxyConfig.config?.auth?.disabled}
onChange={handleAuthEnabledChange}
/>
</div>
<div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.username">
Username
</label>
<input
id="auth.username"
type="text"
name="auth.username"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={currentProxyConfig.config?.auth?.username || ''}
onChange={handleAuthUsernameChange}
/>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.password">
Password
</label>
<div className="textbox flex flex-row items-center w-[13.2rem] h-[1.70rem] relative">
<input
id="auth.password"
type={passwordVisible ? 'text' : 'password'}
name="auth.password"
className="outline-none bg-transparent w-[10.5rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={currentProxyConfig.config?.auth?.password || ''}
onChange={handleAuthPasswordChange}
/>
<button
type="button"
className="btn btn-sm absolute right-0"
onClick={() => setPasswordVisible(!passwordVisible)}
>
{passwordVisible ? <IconEyeOff size={18} strokeWidth={1.5} /> : <IconEye size={18} strokeWidth={1.5} />}
</button>
</div>
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="bypassProxy">
Proxy Bypass
</label>
<input
id="bypassProxy"
type="text"
name="bypassProxy"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={handleBypassProxyChange}
value={currentProxyConfig.config?.bypassProxy || ''}
/>
</div>
</>
)}
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</div>
</StyledWrapper>

View File

@@ -7,6 +7,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -98,9 +99,9 @@ const Script = ({ collection }) => {
</Tabs>
<div className="mt-12">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -6,6 +6,7 @@ import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
@@ -41,9 +42,9 @@ const Tests = ({ collection }) => {
/>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -4,6 +4,7 @@ import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
@@ -14,17 +15,17 @@ const Vars = ({ collection }) => {
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Pre Request</div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable collection={collection} vars={requestVars} varType="request" />
</div>
<div className="flex-1">
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -9,6 +9,7 @@ import Headers from './Headers';
import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Presets from './Presets';
import Protobuf from './Protobuf';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
@@ -58,6 +59,8 @@ const CollectionSettings = ({ collection }) => {
const protobufConfig = collection.draft?.brunoConfig
? get(collection, 'draft.brunoConfig.protobuf', {})
: get(collection, 'brunoConfig.protobuf', {});
const presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', {}) : get(collection, 'brunoConfig.presets', {});
const hasPresets = presets && presets.requestUrl !== '';
const getTabPanel = (tab) => {
switch (tab) {
@@ -79,6 +82,9 @@ const CollectionSettings = ({ collection }) => {
case 'tests': {
return <Test collection={collection} />;
}
case 'presets': {
return <Presets collection={collection} />;
}
case 'proxy': {
return <ProxySettings collection={collection} />;
}
@@ -123,6 +129,10 @@ const CollectionSettings = ({ collection }) => {
Tests
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}

View File

@@ -9,6 +9,7 @@ import ModifyCookieModal from 'components/Cookies/ModifyCookieModal/index';
import StyledWrapper from './StyledWrapper';
import moment from 'moment';
import { Tooltip } from 'react-tooltip';
import Button from 'ui/Button';
const ClearDomainCookiesModal = ({ onClose, domain, onClear }) => (
<Modal onClose={onClose} handleCancel={onClose} title="Clear Domain Cookies" hideFooter={true}>
@@ -22,14 +23,14 @@ const ClearDomainCookiesModal = ({ onClose, domain, onClear }) => (
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-close" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Close
</button>
</Button>
</div>
<div>
<button className="btn btn-sm btn-danger" onClick={onClear}>
<Button size="sm" color="danger" onClick={onClear}>
Clear All
</button>
</Button>
</div>
</div>
</Modal>
@@ -47,14 +48,14 @@ const DeleteCookieModal = ({ onClose, cookieName, onDelete }) => (
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-close" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Close
</button>
</Button>
</div>
<div>
<button className="btn btn-sm btn-danger" onClick={onDelete}>
<Button size="sm" color="danger" onClick={onDelete}>
Delete
</button>
</Button>
</div>
</div>
</Modal>
@@ -137,18 +138,20 @@ const CollectionProperties = ({ onClose }) => {
value={searchText || ''}
onChange={(e) => setSearchText(e.target.value)}
className="block textbox non-passphrase-input ml-auto font-normal"
autoFocus
/>
<button
<Button
type="submit"
className="submit btn btn-sm btn-secondary flex items-center gap-1 mx-4 font-medium"
size="sm"
className="mx-4"
icon={<IconCirclePlus strokeWidth={1.5} size={16} />}
onClick={(e) => {
e.stopPropagation();
handleAddCookie();
}}
>
<IconCirclePlus strokeWidth={1.5} size={16} />
<span>Add Cookie</span>
</button>
</Button>
</StyledWrapper>
) : null}
>
@@ -159,17 +162,18 @@ const CollectionProperties = ({ onClose }) => {
<IconCookieOff size={48} strokeWidth={1.5} className="text-gray-500" />
<h2 className="text-lg font-medium mt-4">No cookies found</h2>
<p className="text-gray-500 mt-2">Add cookies to get started</p>
<button
<Button
type="submit"
className="submit btn btn-sm btn-secondary flex items-center gap-1 mt-8"
size="sm"
className="mt-8"
icon={<IconCirclePlus strokeWidth={1.5} size={16} />}
onClick={(e) => {
e.stopPropagation();
handleAddCookie();
}}
>
<IconCirclePlus strokeWidth={1.5} size={16} />
<span>Add Cookie</span>
</button>
Add Cookie
</Button>
</div>
) : cookies.length && !filteredCookies.length ? (
// No search results

View File

@@ -1,26 +1,19 @@
import React, { useRef, forwardRef } from 'react';
import React, { useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { newHttpRequest, newGrpcRequest, newWsRequest } from 'providers/ReduxStore/slices/collections/actions';
import { generateUniqueRequestName } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { IconApi, IconBrandGraphql, IconPlugConnected, IconCode, IconPlus } from '@tabler/icons';
import ActionIcon from 'ui/ActionIcon';
const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated, placement = 'bottom' }) => {
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const collection = collections?.find((c) => c.uid === collectionUid);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
if (!collection) {
return null;
}
const handleCreateHttpRequest = async () => {
dropdownTippyRef.current?.hide();
const handleCreateHttpRequest = useCallback(async () => {
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
@@ -40,10 +33,9 @@ const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
}, [dispatch, collection, itemUid, onRequestCreated]);
const handleCreateGraphQLRequest = async () => {
dropdownTippyRef.current?.hide();
const handleCreateGraphQLRequest = useCallback(async () => {
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
@@ -70,10 +62,9 @@ const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
}, [dispatch, collection, itemUid, onRequestCreated]);
const handleCreateWebSocketRequest = async () => {
dropdownTippyRef.current?.hide();
const handleCreateWebSocketRequest = useCallback(async () => {
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
@@ -92,10 +83,9 @@ const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
}, [dispatch, collection, itemUid, onRequestCreated]);
const handleCreateGrpcRequest = async () => {
dropdownTippyRef.current?.hide();
const handleCreateGrpcRequest = useCallback(async () => {
const uniqueName = await generateUniqueRequestName(collection, 'Untitled', itemUid);
const filename = sanitizeName(uniqueName);
@@ -113,59 +103,49 @@ const CreateUntitledRequest = ({ collectionUid, itemUid = null, onRequestCreated
onRequestCreated?.();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
};
}, [dispatch, collection, itemUid, onRequestCreated]);
const menuItems = useMemo(() => [
{
id: 'http',
label: 'HTTP',
leftSection: <IconApi size={16} strokeWidth={2} />,
onClick: handleCreateHttpRequest
},
{
id: 'graphql',
label: 'GraphQL',
leftSection: <IconBrandGraphql size={16} strokeWidth={2} />,
onClick: handleCreateGraphQLRequest
},
{
id: 'websocket',
label: 'WebSocket',
leftSection: <IconPlugConnected size={16} strokeWidth={2} />,
onClick: handleCreateWebSocketRequest
},
{
id: 'grpc',
label: 'gRPC',
leftSection: <IconCode size={16} strokeWidth={2} />,
onClick: handleCreateGrpcRequest
}
], [handleCreateHttpRequest, handleCreateGraphQLRequest, handleCreateWebSocketRequest, handleCreateGrpcRequest]);
if (!collection) {
return null;
}
return (
<Dropdown onCreate={onDropdownCreate} icon={<IconPlus size={16} strokeWidth={2} />} placement={placement}>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateHttpRequest();
}}
>
<span className="dropdown-icon">
<IconApi size={16} strokeWidth={2} />
</span>
HTTP
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGraphQLRequest();
}}
>
<span className="dropdown-icon">
<IconBrandGraphql size={16} strokeWidth={2} />
</span>
GraphQL
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateWebSocketRequest();
}}
>
<span className="dropdown-icon">
<IconPlugConnected size={16} strokeWidth={2} />
</span>
WebSocket
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleCreateGrpcRequest();
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
gRPC
</div>
</Dropdown>
<MenuDropdown
items={menuItems}
placement={placement}
autoFocusFirstOption={true}
>
<ActionIcon size="sm">
<IconPlus size={16} strokeWidth={2} />
</ActionIcon>
</MenuDropdown>
);
};

View File

@@ -183,6 +183,29 @@ const openTerminalIntoContainer = async (container, sessionId) => {
}
};
let fitFrameRef;
const fitTerminal = (activeSessionId, container) => {
if (!container) return;
const instance = terminalInstances.get(activeSessionId);
if (!instance?.fitAddon) return;
if (fitFrameRef) {
cancelAnimationFrame(fitFrameRef);
}
fitFrameRef = requestAnimationFrame(() => {
fitFrameRef = null;
// Avoid fitting when hidden/0-sized (common during tab switches/layout transitions)
if (container.offsetWidth === 0 || container.offsetHeight === 0) return;
try {
instance.fitAddon.fit();
} catch (e) {}
});
};
const TerminalTab = () => {
const terminalRef = useRef(null);
const [sessions, setSessions] = useState([]);
@@ -223,22 +246,25 @@ const TerminalTab = () => {
}, []);
// Create new terminal session
const createNewSession = useCallback(async (cwd = null) => {
if (!window.ipcRenderer) return null;
const createNewSession = useCallback(
async (cwd = null) => {
if (!window.ipcRenderer) return null;
try {
const options = cwd ? { cwd } : {};
const newSessionId = await window.ipcRenderer.invoke('terminal:create', options);
if (newSessionId) {
await loadSessions(newSessionId);
setActiveSessionId(newSessionId);
return newSessionId;
try {
const options = cwd ? { cwd } : {};
const newSessionId = await window.ipcRenderer.invoke('terminal:create', options);
if (newSessionId) {
await loadSessions(newSessionId);
setActiveSessionId(newSessionId);
return newSessionId;
}
} catch (err) {
console.error('Failed to create terminal session:', err);
}
} catch (err) {
console.error('Failed to create terminal session:', err);
}
return null;
}, [loadSessions]);
return null;
},
[loadSessions]
);
// Listen for requests to open terminal at specific CWD
useEffect(() => {
@@ -339,31 +365,17 @@ const TerminalTab = () => {
if (mounted) {
const instance = terminalInstances.get(activeSessionId);
if (instance && instance.fitAddon) {
const onResize = () => {
try {
instance.fitAddon.fit();
} catch (e) {}
};
window.addEventListener('resize', onResize);
// Initial resize
setTimeout(() => {
try {
instance.fitAddon.fit();
const { cols, rows } = instance.terminal;
if (cols && rows && window.ipcRenderer) {
window.ipcRenderer.send('terminal:resize', activeSessionId, { cols, rows });
}
} catch (err) {
console.warn('Failed to perform initial resize:', err);
if (instance) {
try {
const { cols, rows } = instance.terminal;
if (cols && rows && window.ipcRenderer) {
window.ipcRenderer.send('terminal:resize', activeSessionId, { cols, rows });
}
}, 100);
} catch (err) {
console.warn('Failed to perform initial resize:', err);
}
return () => {
window.removeEventListener('resize', onResize);
// Park terminal element when switching sessions
if (instance.terminal && instance.terminal.element) {
const host = ensureParkingHost();
@@ -386,6 +398,18 @@ const TerminalTab = () => {
};
}, [activeSessionId]);
const onSessionMount = useCallback(
(node) => {
if (!node) return;
terminalRef.current = node;
fitTerminal(activeSessionId, node);
const ro = new ResizeObserver(() => fitTerminal(activeSessionId, node));
ro.observe(node.parentNode);
return () => ro.disconnect();
},
[activeSessionId]
);
return (
<StyledWrapper>
<div className="terminal-content">
@@ -405,13 +429,9 @@ const TerminalTab = () => {
</div>
<div className="terminal-sessions-list">
{isLoading ? (
<div style={{ padding: '12px', color: '#888', fontSize: '13px' }}>
Loading sessions...
</div>
<div style={{ padding: '12px', color: '#888', fontSize: '13px' }}>Loading sessions...</div>
) : sessions.length === 0 ? (
<div style={{ padding: '12px', color: '#888', fontSize: '13px' }}>
No active sessions
</div>
<div style={{ padding: '12px', color: '#888', fontSize: '13px' }}>No active sessions</div>
) : (
<SessionList
sessions={sessions}
@@ -432,7 +452,7 @@ const TerminalTab = () => {
</div>
)}
<div
ref={terminalRef}
ref={onSessionMount}
className="terminal-container"
style={{
height: '100%',

View File

@@ -1,150 +1,165 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.dropdown-toggle {
&:hover {
color: black;
min-width: 160px;
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.dropdown.color};
background-color: ${(props) => props.theme.dropdown.bg};
box-shadow: ${(props) => props.theme.shadow.sm};
border-radius: ${(props) => props.theme.border.radius.base};
max-height: 90vh;
overflow-y: auto;
max-width: unset !important;
padding: 0.25rem;
[role="menu"] {
outline: none;
&:focus {
outline: none;
}
&:focus-visible {
outline: none;
}
}
.tippy-box {
min-width: 160px;
font-size: ${(props) => props.theme.font.size.base};
.label-item {
display: flex;
align-items: center;
padding: 0.375rem 0.625rem 0.25rem 0.625rem;
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.025em;
color: ${(props) => props.theme.dropdown.color};
background-color: ${(props) => props.theme.dropdown.bg};
box-shadow: ${(props) => props.theme.shadow.sm};
border-radius: ${(props) => props.theme.border.radius.base};
max-height: 90vh;
overflow-y: auto;
max-width: unset !important;
padding: 0.25rem;
opacity: 0.6;
margin-top: 0.25rem;
&:first-child {
margin-top: 0;
}
}
.tippy-content {
padding-left: 0;
padding-right: 0;
padding-top: 0;
padding-bottom: 0;
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.275rem 0.625rem;
cursor: pointer;
border-radius: 6px;
margin: 0.0625rem 0;
font-size: 0.8125rem;
[role="menu"] {
outline: none;
&:focus {
outline: none;
}
&:focus-visible {
outline: none;
}
}
.label-item {
display: flex;
align-items: center;
padding: 0.375rem 0.625rem 0.25rem 0.625rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: ${(props) => props.theme.dropdown.color};
opacity: 0.6;
margin-top: 0.25rem;
&:first-child {
margin-top: 0;
}
}
.dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.275rem 0.625rem;
cursor: pointer;
border-radius: 6px;
margin: 0.0625rem 0;
font-size: 0.8125rem;
&.active {
color: ${(props) => props.theme.colors.text.yellow} !important;
.dropdown-icon {
color: ${(props) => props.theme.colors.text.yellow} !important;
}
}
.dropdown-label {
flex: 1;
}
.dropdown-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.dropdown.iconColor};
opacity: 0.8;
}
.dropdown-right-section {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.selected-focused:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:focus-visible:not(:disabled) {
outline: none;
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:focus:not(:focus-visible) {
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.delete-item {
color: ${(props) => props.theme.colors.text.danger};
.dropdown-icon {
color: ${(props) => props.theme.colors.text.danger};
}
&:hover {
background-color: ${({ theme }) => {
const hex = theme.colors.text.danger.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.04)`; // 4% opacity
}} !important;
color: ${(props) => props.theme.colors.text.danger} !important;
}
}
&.border-top {
border-top: solid 1px ${(props) => props.theme.dropdown.separator};
margin-top: 0.25rem;
padding-top: 0.375rem;
}
}
.dropdown-separator {
height: 1px;
background-color: ${(props) => props.theme.dropdown.separator};
margin: 0.25rem 0;
&.active {
color: ${(props) => props.theme.colors.text.yellow} !important;
.dropdown-icon {
color: ${(props) => props.theme.colors.text.yellow} !important;
}
}
.dropdown-label {
flex: 1;
}
.dropdown-icon {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.dropdown.iconColor};
opacity: 0.8;
}
.dropdown-right-section {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
&:hover:not(:disabled):not(.disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.selected-focused:not(:disabled):not(.disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:focus-visible:not(:disabled):not(.disabled) {
outline: none;
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:focus:not(:focus-visible) {
outline: none;
}
&:disabled,
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.delete-item {
color: ${(props) => props.theme.colors.text.danger};
.dropdown-icon {
color: ${(props) => props.theme.colors.text.danger};
}
&:hover {
background-color: ${({ theme }) => {
const hex = theme.colors.text.danger.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, 0.04)`; // 4% opacity
}} !important;
color: ${(props) => props.theme.colors.text.danger} !important;
}
}
&.border-top {
border-top: solid 1px ${(props) => props.theme.dropdown.separator};
margin-top: 0.25rem;
padding-top: 0.375rem;
}
&.dropdown-item-select {
padding-left: 1.5rem;
}
/* Focused state - applied during keyboard navigation */
&.dropdown-item-focused {
background-color: ${({ theme }) => theme.dropdown.hoverBg};
outline: none;
}
/* Active/selected state - applied to the currently selected item */
&.dropdown-item-active {
color: ${({ theme }) => theme.colors.text.yellow};
background-color: ${({ theme }) => theme.dropdown.activeBg};
font-weight: 500;
.dropdown-icon {
color: ${({ theme }) => theme.colors.text.yellow};
}
}
/* Combined state - when active item is also focused */
&.dropdown-item-active.dropdown-item-focused {
background-color: ${({ theme }) => theme.dropdown.activeHoverBg};
}
/* Focus visible for accessibility */
&:focus-visible {
outline: 2px solid ${({ theme }) => theme.dropdown.focusRing};
outline-offset: -2px;
}
}
.dropdown-separator {
height: 1px;
background-color: ${(props) => props.theme.dropdown.separator};
margin: 0.25rem 0;
}
`;

View File

@@ -2,25 +2,27 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, ...props }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, appendTo, ...props }) => {
// When in controlled mode (visible prop is provided), don't use trigger prop
const tippyProps = visible !== undefined
? { ...props, visible, interactive: true, appendTo: 'parent' }
: { ...props, trigger: 'click', interactive: true, appendTo: 'parent' };
? { ...props, visible, interactive: true, appendTo: appendTo || 'parent' }
: { ...props, trigger: 'click', interactive: true, appendTo: appendTo || 'parent' };
return (
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
content={children}
placement={placement || 'bottom-end'}
animation={false}
arrow={false}
onCreate={onCreate}
{...tippyProps}
>
{icon}
</Tippy>
</StyledWrapper>
<Tippy
render={(attrs) => (
<StyledWrapper className="tippy-box dropdown" transparent={transparent} tabIndex={-1} {...attrs}>
{children}
</StyledWrapper>
)}
placement={placement || 'bottom-end'}
animation={false}
arrow={false}
onCreate={onCreate}
{...tippyProps}
>
{icon}
</Tippy>
);
};

View File

@@ -17,35 +17,35 @@ const StyledWrapper = styled.div`
border-collapse: collapse;
table-layout: fixed;
font-size: ${(props) => props.theme.font.size.base};
font-weight: normal !important;
}
thead {
color: ${(props) => props.theme.colors.text} !important;
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
border: none !important;
td {
padding: 8px 10px;
padding: 5px 10px !important;
border-top: none !important;
border-left: none !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
vertical-align: middle;
&:nth-child(1) {
width: 25px !important;
border-right: none;
}
&:last-child {
border-right: none;
}
}
}
&.has-checkbox thead td:nth-child(1) {
width: 25px !important;
border-right: none;
}
tbody {
tr {
transition: background 0.1s ease;
@@ -55,26 +55,13 @@ const StyledWrapper = styled.div`
}
td {
padding: 2px 10px;
padding: 1px 10px !important;
border-top: none !important;
border-left: none !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
vertical-align: middle;
&:nth-child(1) {
width: 25px;
border-right: none;
text-align: center;
vertical-align: middle;
line-height: 1;
input[type='checkbox'] {
vertical-align: baseline;
display: inline-block;
}
}
&:last-child {
border-right: none;
}
@@ -82,6 +69,19 @@ const StyledWrapper = styled.div`
}
}
&.has-checkbox tbody td:nth-child(1) {
width: 25px;
border-right: none;
text-align: center;
vertical-align: middle;
line-height: 1;
input[type='checkbox'] {
vertical-align: baseline;
display: inline-block;
}
}
.tooltip-mod {
font-size: 11px !important;
max-width: 200px !important;
@@ -93,7 +93,6 @@ const StyledWrapper = styled.div`
background-color: transparent;
color: ${(props) => props.theme.text};
padding: 0;
font-size: 12px;
border-radius: 4px;
transition: all 0.15s ease;
@@ -106,7 +105,7 @@ const StyledWrapper = styled.div`
cursor: pointer;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.workspace.accent};
accent-color: ${(props) => props.theme.colors.accent};
vertical-align: middle;
margin: 0;
}

View File

@@ -16,7 +16,8 @@ const EditableTable = ({
checkboxKey = 'enabled',
reorderable = false,
onReorder,
showAddRow = true
showAddRow = true,
testId = 'editable-table'
}) => {
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
@@ -223,8 +224,8 @@ const EditableTable = ({
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
return (
<StyledWrapper>
<div className="table-container" ref={tableRef}>
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
<tr>
@@ -285,6 +286,7 @@ const EditableTable = ({
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
@@ -292,14 +294,17 @@ const EditableTable = ({
</td>
)}
{columns.map((column) => (
<td key={column.key}>
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button onClick={() => handleRemoveRow(row.uid)}>
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}

View File

@@ -6,6 +6,7 @@ import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
const ExportEnvironmentModal = ({ onClose, environments = [], environmentType }) => {
const dispatch = useDispatch();
@@ -242,22 +243,26 @@ const ExportEnvironmentModal = ({ onClose, environments = [], environmentType })
{/* Export Actions */}
<div className="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700">
<button
<Button
type="button"
className="btn btn-sm btn-cancel mt-2 flex items-center"
size="sm"
color="secondary"
variant="ghost"
onClick={onClose}
disabled={isExporting}
className="mt-2 mr-2"
>
Cancel
</button>
<button
</Button>
<Button
type="button"
className="btn btn-sm btn-secondary mt-2 flex items-center"
size="sm"
onClick={handleExport}
disabled={isExporting || selectedCount === 0}
className="mt-2"
>
{isExporting ? 'Exporting...' : `Export ${selectedCount || ''} Environment${selectedCount !== 1 ? 's' : ''}`}
</button>
</Button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import Button from 'ui/Button';
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
return (
<Portal>
<Modal
size="md"
title="Unsaved changes"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-medium">Hold on...</h1>
</div>
<div className="font-normal mt-4">
You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings.
</div>
<div className="flex justify-between mt-6">
<div>
<Button size="sm" color="danger" onClick={onCloseWithoutSave}>
Don't Save
</Button>
</div>
<div>
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" onClick={onSaveAndClose}>
Save
</Button>
</div>
</div>
</Modal>
</Portal>
);
};
export default ConfirmCloseEnvironment;

View File

@@ -1,18 +1,16 @@
import React, { useMemo, useState, useRef, forwardRef } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
import { IconWorld, IconDatabase, IconCaretDown } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import toast from 'react-hot-toast';
import EnvironmentListContent from './EnvironmentListContent/index';
import EnvironmentSettings from '../EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
import CreateGlobalEnvironment from 'components/WorkspaceHome/WorkspaceEnvironments/CreateEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
@@ -27,8 +25,6 @@ const EnvironmentSelector = ({ collection }) => {
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
const activeGlobalEnvironment = activeGlobalEnvironmentUid
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
: null;
@@ -75,12 +71,24 @@ const EnvironmentSelector = ({ collection }) => {
});
};
// Settings handler
// Settings handler - opens environment settings tab
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
} else {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}
dropdownTippyRef.current.hide();
};
@@ -105,12 +113,6 @@ const EnvironmentSelector = ({ collection }) => {
dropdownTippyRef.current.hide();
};
// Modal handlers
const handleCloseSettings = () => {
dispatch(updateEnvironmentSettingsModalVisibility(false));
dispatch(updateGlobalEnvironmentSettingsModalVisibility(false));
};
// Calculate dropdown width based on the longest environment name.
// To prevent resizing while switching between collection and global environments.
const dropdownWidth = useMemo(() => {
@@ -217,25 +219,17 @@ const EnvironmentSelector = ({ collection }) => {
</Dropdown>
</div>
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{isGlobalEnvironmentSettingsModalOpen && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
activeGlobalEnvironmentUid={activeGlobalEnvironmentUid}
onClose={handleCloseSettings}
/>
)}
{isEnvironmentSettingsModalOpen && (
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
)}
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
/>
)}
@@ -245,7 +239,13 @@ const EnvironmentSelector = ({ collection }) => {
type="global"
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-global-environment-settings`,
collectionUid: collection.uid,
type: 'global-environment-settings'
})
);
}}
/>
)}
@@ -255,7 +255,13 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
/>
)}
@@ -266,7 +272,13 @@ const EnvironmentSelector = ({ collection }) => {
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
dispatch(updateEnvironmentSettingsModalVisibility(true));
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}}
/>
)}

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
const ConfirmSwitchEnv = ({ onCancel }) => {
return createPortal(
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
</div>
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
Close
</button>
</div>
<div></div>
</div>
</Modal>,
document.body
);
};
export default ConfirmSwitchEnv;

View File

@@ -1,67 +1,188 @@
import styled from 'styled-components';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
.table-container {
overflow-y: auto;
border-radius: 8px;
border: ${(props) => props.theme.workspace.environments.indentBorder};
}
table {
width: 100%;
border-collapse: collapse;
font-weight: 500;
table-layout: fixed;
font-size: 12px;
thead,
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
vertical-align: middle;
padding: 2px 10px;
&:nth-child(1),
&:nth-child(1) {
width: 25px;
border-right: none;
}
&:nth-child(4) {
width: 70px;
width: 80px;
}
&:nth-child(5) {
width: 40px;
width: 60px;
}
&:nth-child(2) {
width: 25%;
width: 30%;
}
}
thead {
color: ${(props) => props.theme.table.thead.color};
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
td {
padding: 5px 10px !important;
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
&:last-child {
border-right: none;
}
}
}
thead td {
padding: 6px 10px;
tbody {
tr {
transition: background 0.1s ease;
&:last-child td {
border-bottom: none;
}
td {
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
&:last-child {
border-right: none;
}
}
}
}
}
.btn-add-param {
font-size: ${(props) => props.theme.font.size.base};
font-size: 12px;
color: ${(props) => props.theme.textLink};
font-weight: 500;
padding: 7px 14px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 6px;
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: transparent;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
.tooltip-mod {
font-size: ${(props) => props.theme.font.size.xs} !important;
width: 150px !important;
font-size: 11px !important;
max-width: 200px !important;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
border: 1px solid transparent;
outline: none !important;
background-color: transparent;
color: ${(props) => props.theme.text};
padding: 0;
border-radius: 4px;
transition: all 0.15s ease;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.colors.accent};
vertical-align: middle;
margin: 0;
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease, background 0.15s ease;
}
.button-container {
padding: 12px 2px;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
display: flex;
gap: 8px;
}
.submit {
padding: 6px 16px;
font-size: ${(props) => props.theme.font.size.sm};
border-radius: ${(props) => props.theme.border.radius.base};
border: none;
background: ${(props) => props.theme.brand};
color: ${(props) => props.theme.bg};
cursor: pointer;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.9;
}
}
.reset {
background: transparent;
padding: 6px 16px;
color: ${(props) => props.theme.brand};
&:hover {
opacity: 0.9;
}
}
.discard {
padding: 6px 16px;
font-size: ${(props) => props.theme.font.size.sm};
border-radius: ${(props) => props.theme.border.radius.base};
background: transparent;
color: ${(props) => props.theme.text};
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
`;
export default Wrapper;

View File

@@ -1,34 +1,43 @@
import React, { useRef, useEffect, useMemo } from 'react';
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { Tooltip } from 'react-tooltip';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
const environmentsDraft = collection?.environmentsDraft;
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
// Track environment changes for draft restoration
const prevEnvUidRef = React.useRef(null);
const mountedRef = React.useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// Check for non-secret variables used in sensitive fields
const nonSecretSensitiveVarUsageMap = useMemo(() => {
const result = {};
if (!collection || !environment?.variables) {
@@ -44,7 +53,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
const value = get(obj, fieldPath);
if (typeof value === 'string') {
varNames.forEach((varName) => {
if (new RegExp(`\{\{\s*${varName}\s*\}\}`).test(value)) {
if (new RegExp(`\\{\\{\\s*${varName}\\s*\\}\\}`).test(value)) {
result[varName] = true;
}
});
@@ -73,51 +82,147 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
return result;
}, [collection, environment]);
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
// Initial values based only on saved environment variables (not draft)
// Draft restoration happens in a separate effect to avoid infinite loops
const initialValues = React.useMemo(() => {
const vars = environment.variables || [];
return [
...vars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
}, [environment.uid, environment.variables]);
const formik = useFormik({
enableReinitialize: true,
initialValues: environment.variables || [],
initialValues: initialValues,
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim(),
.when('$isLastRow', {
is: true,
then: (schema) => schema.optional(),
otherwise: (schema) =>
schema
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim()
}),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.string().trim().nullable()
value: Yup.mixed().nullable()
})
),
onSubmit: (values) => {
if (!formik.dirty) {
toast.error('Nothing to save');
return;
}
validate: (values) => {
const errors = {};
values.forEach((variable, index) => {
const isLastRow = index === values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
dispatch(saveEnvironment(cloneDeep(values), environment.uid, collection.uid))
.then(() => {
toast.success('Changes saved successfully');
formik.resetForm({ values });
setIsModified(false);
})
.catch(() => toast.error('An error occurred while saving the changes'));
}
if (isLastRow && isEmptyRow) {
return;
}
if (!variable.name || variable.name.trim() === '') {
if (!errors[index]) errors[index] = {};
errors[index].name = 'Name cannot be empty';
} else if (!variableNameRegex.test(variable.name)) {
if (!errors[index]) errors[index] = {};
errors[index].name
= 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
}
});
return Object.keys(errors).length > 0 ? errors : {};
},
onSubmit: () => {}
});
const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
// Restore draft values on mount or environment switch
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
}, [formik.dirty]);
prevEnvUidRef.current = environment.uid;
mountedRef.current = true;
const ErrorMessage = ({ name }) => {
if ((isMount || envChanged) && hasDraftForThisEnv && environmentsDraft?.variables) {
formik.setValues([
...environmentsDraft.variables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
]);
}
}, [environment.uid, hasDraftForThisEnv, environmentsDraft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify(environment.variables || []);
}, [environment.variables]);
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
// Get existing draft for comparison
const existingDraftVariables = hasDraftForThisEnv ? environmentsDraft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
if (hasActualChanges) {
// Only dispatch if draft values are actually different
if (currentValuesJson !== existingDraftJson) {
dispatch(setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: environment.uid,
variables: currentValues
}));
}
} else if (hasDraftForThisEnv) {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, 300);
return () => clearTimeout(timeoutId);
}, [formik.values, savedValuesJson, environment.uid, collection.uid, dispatch, hasDraftForThisEnv, environmentsDraft?.variables]);
const ErrorMessage = ({ name, index }) => {
const meta = formik.getFieldMeta(name);
const id = uuid();
const id = `error-${name}-${index}`;
const isLastRow = index === formik.values.length - 1;
const variable = formik.values[index];
const isEmptyRow = !variable?.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return null;
}
if (!meta.error || !meta.touched) {
return null;
}
@@ -129,54 +234,161 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
);
};
const addVariable = () => {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
formik.setFieldValue(formik.values.length, newVariable, false);
};
const handleRemoveVar = useCallback((id) => {
const currentValues = formik.values;
const onActivate = () => {
dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
onClose();
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
const handleRemoveVar = (id) => {
formik.setValues(formik.values.filter((variable) => variable.uid !== id));
};
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
if (!currentValues || currentValues.length === 0) {
return;
}
}, [formik.values, formik.dirty]);
const lastRow = currentValues[currentValues.length - 1];
const isLastEmptyRow = lastRow?.uid === id && (!lastRow.name || lastRow.name.trim() === '');
if (isLastEmptyRow) {
return;
}
const filteredValues = currentValues.filter((variable) => variable.uid !== id);
const hasEmptyLastRow
= filteredValues.length > 0
&& (!filteredValues[filteredValues.length - 1].name
|| filteredValues[filteredValues.length - 1].name.trim() === '');
const newValues = hasEmptyLastRow
? filteredValues
: [
...filteredValues,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.setValues(newValues);
}, [formik.values]);
const handleNameChange = (index, e) => {
formik.handleChange(e);
const isLastRow = index === formik.values.length - 1;
if (isLastRow) {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
setTimeout(() => {
formik.setFieldValue(formik.values.length, newVariable, false);
}, 0);
}
};
const handleNameBlur = (index) => {
formik.setFieldTouched(`${index}.name`, true, true);
};
const handleNameKeyDown = (index, e) => {
if (e.key === 'Enter') {
e.preventDefault();
formik.setFieldTouched(`${index}.name`, true, true);
}
};
const handleSave = () => {
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
dispatch(saveEnvironment(cloneDeep(variablesToSave), environment.uid, collection.uid))
.then(() => {
toast.success('Changes saved successfully');
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
};
const handleReset = () => {
formik.resetForm({ originalEnvironmentVariables });
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
};
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
};
}, []);
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
<table className="environment-variables">
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center">Enabled</td>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
@@ -184,99 +396,112 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
onChange={formik.handleChange}
/>
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</div>
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
</td>
<td>
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</td>
</tr>
))}
{formik.values.map((variable, index) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div>
<button
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
id="add-variable"
data-testid="add-variable"
>
+ Add Variable
</button>
</div>
</div>
<div className="flex items-center">
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit} data-testid="save-env">
<IconDeviceFloppy size={16} strokeWidth={1.5} className="mr-1" />
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset} data-testid="reset-env">
<IconRefresh size={16} strokeWidth={1.5} className="mr-1" />
Reset
</button>
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate} data-testid="activate-env">
<IconCircleCheck size={16} strokeWidth={1.5} className="mr-1" />
Activate
</button>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
Save
</button>
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariables;

View File

@@ -0,0 +1,134 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: ${(props) => props.theme.bg};
.header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px 20px;
flex-shrink: 0;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.title-container {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
&.renaming {
.title-input {
flex: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
outline: none;
color: ${(props) => props.theme.text};
font-size: 15px;
font-weight: 500;
padding: 4px 8px;
border-radius: 5px;
}
.inline-actions {
display: flex;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.textLink};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
}
.title-error {
position: absolute;
top: 100%;
left: 20px;
margin-top: 4px;
padding: 4px 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.colors.text.danger};
border-radius: 4px;
white-space: nowrap;
}
.actions {
display: flex;
gap: 2px;
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&:last-child:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
}
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
}
`;
export default StyledWrapper;

View File

@@ -1,59 +1,183 @@
import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { useState } from 'react';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnvironment';
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ToolHint from 'components/ToolHint/index';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
const EnvironmentDetails = ({ environment, collection, setIsModified, onClose }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const inputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (name.length < 1) {
return 'Must be at least 1 character';
}
if (name.length > 255) {
return 'Must be 255 characters or less';
}
if (!validateName(name)) {
return validateNameError(name);
}
const trimmedName = name.toLowerCase().trim();
const isDuplicate = (environments || []).some(
(env) => env?.uid !== environment.uid && env?.name?.toLowerCase().trim() === trimmedName
);
if (isDuplicate) {
return 'Environment already exists';
}
return null;
};
const handleRenameClick = () => {
setIsRenaming(true);
setNewName(environment.name);
setNameError('');
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
};
const handleSaveRename = () => {
const error = validateEnvironmentName(newName);
if (error) {
setNameError(error);
return;
}
dispatch(renameEnvironment(newName, environment.uid, collection.uid))
.then(() => {
toast.success('Environment renamed!');
setIsRenaming(false);
setNewName('');
setNameError('');
})
.catch(() => {
toast.error('An error occurred while renaming the environment');
});
};
const handleCancelRename = () => {
setIsRenaming(false);
setNewName('');
setNameError('');
};
const handleNameChange = (e) => {
setNewName(e.target.value);
if (nameError) {
setNameError('');
}
};
const handleNameBlur = () => {
if (newName.trim() === '') {
handleCancelRename();
} else {
const error = validateEnvironmentName(newName);
if (error) {
setNameError(error);
}
}
};
const handleNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelRename();
}
};
return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
{openEditModal && (
<RenameEnvironment onClose={() => setOpenEditModal(false)} environment={environment} collection={collection} />
)}
<StyledWrapper>
{openDeleteModal && (
<DeleteEnvironment
onClose={() => setOpenDeleteModal(false)}
environment={environment}
collection={collection}
/>
<DeleteEnvironment onClose={() => setOpenDeleteModal(false)} environment={environment} collection={collection} />
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} collection={collection} />
)}
<div className="flex">
<div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-medium break-all">{environment.name}</span>
<div className="header">
<div className={`title-container ${isRenaming ? 'renaming' : ''}`}>
{isRenaming ? (
<>
<input
ref={inputRef}
type="text"
className="title-input"
value={newName}
onChange={handleNameChange}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</>
) : (
<h2 className="title">{environment.name}</h2>
)}
</div>
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash
className="cursor-pointer"
size={20}
strokeWidth={1.5}
onClick={() => setOpenDeleteModal(true)}
data-testid="delete-environment-button"
/>
</ToolHint>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenCopyModal(true)} title="Copy">
<IconCopy size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</div>
</div>
<div>
<EnvironmentVariables environment={environment} collection={collection} setIsModified={setIsModified} onClose={onClose} />
<div className="content">
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
</div>
</StyledWrapper>
);
};

View File

@@ -1,61 +1,281 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
display: flex;
height: 100%;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
position: relative;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.environments-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
min-height: 400px;
.environments-container {
display: flex;
height: 100%;
max-height: 85vh;
width: 100%;
overflow: hidden;
}
.confirm-switch-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
}
/* Left Sidebar */
.sidebar {
width: 240px;
min-width: 240px;
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
display: flex;
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
.search-container {
position: relative;
padding: 0 12px 12px 12px;
.search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-100%);
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
width: 100%;
padding: 6px 8px 6px 28px;
font-size: 12px;
background: transparent;
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
}
}
}
.environments-list {
flex: 1;
overflow-y: auto;
padding: 0 8px;
}
.environment-item {
min-width: 150px;
display: block;
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
margin-bottom: 1px;
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 5px;
transition: background 0.15s ease;
.environment-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.environment-actions {
display: flex;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
.activate-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: transparent;
cursor: pointer;
color: ${(props) => props.theme.text.muted};
border-radius: 3px;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
color: ${(props) => props.theme.colors.text.green};
}
}
.activated-checkmark {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.green};
opacity: 1;
}
}
&:hover .environment-actions {
opacity: 1;
}
&.activated .environment-actions {
opacity: 1;
}
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
background: ${(props) => props.theme.workspace.button.bg};
}
&.active {
background: ${(props) => props.theme.workspace.environments.activeBg};
color: ${(props) => props.theme.text};
}
&.renaming,
&.creating {
cursor: default;
padding: 4px 4px 4px 8px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
}
.rename-container {
display: flex;
align-items: center;
flex: 1;
.environment-name-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
margin-left: 4px;
}
}
&.creating {
.environment-name-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
margin-left: 4px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.textLink};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.listItem.hoverBg};
color: ${(props) => props.theme.text};
}
}
}
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.btn-create-environment,
.btn-import-environment {
padding: 8px 10px;
cursor: pointer;
border-bottom: none;
color: ${(props) => props.theme.textLink};
span:hover {
text-decoration: underline;
}
}
.btn-import-environment {
color: ${(props) => props.theme.colors.text.muted};
.env-error {
padding: 4px 12px;
margin-top: 4px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => `${props.theme.colors.text.danger}15`};
border-radius: 4px;
}
`;

View File

@@ -1,21 +1,39 @@
import React, { useEffect, useState } from 'react';
import { findEnvironmentInCollection } from 'utils/collections';
import React, { useEffect, useState, useRef } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ManageSecrets from '../ManageSecrets';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ToolHint from 'components/ToolHint';
import ConfirmSwitchEnv from 'components/WorkspaceHome/WorkspaceEnvironments/EnvironmentList/ConfirmSwitchEnv';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { isEqual } from 'lodash';
import { useDispatch } from 'react-redux';
import { addEnvironment, renameEnvironment, selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
const EnvironmentList = ({
environments,
activeEnvironmentUid,
selectedEnvironment,
setSelectedEnvironment,
isModified,
setIsModified,
collection,
setShowExportModal
}) => {
const dispatch = useDispatch();
const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collection, isModified, setIsModified, onClose, setShowExportModal }) => {
const { environments } = collection;
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
const [envNameError, setEnvNameError] = useState('');
const inputRef = useRef(null);
const renameContainerRef = useRef(null);
const createContainerRef = useRef(null);
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
@@ -24,23 +42,36 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (selectedEnvironment) {
const _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(selectedEnvironment.variables);
if (!environments?.length) {
setSelectedEnvironment(null);
setOriginalEnvironmentVariables([]);
return;
}
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
if (environment) {
setSelectedEnvironment(environment);
} else {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
if (selectedEnvironment) {
let _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
if (!_selectedEnvironment) {
_selectedEnvironment = environments?.find((env) => env?.name === selectedEnvironment?.name);
}
if (!_selectedEnvironment) {
_selectedEnvironment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
}
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged || selectedEnvironment.uid !== _selectedEnvironment?.uid) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(_selectedEnvironment?.variables || []);
return;
}
}, [collection, environments, selectedEnvironment]);
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
}, [environments, activeEnvironmentUid, selectedEnvironment]);
useEffect(() => {
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
@@ -55,6 +86,36 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
}, [envUids, environments, prevEnvUids]);
useEffect(() => {
if (!renamingEnvUid) return;
const handleClickOutside = (event) => {
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
handleCancelRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [renamingEnvUid]);
useEffect(() => {
if (!isCreatingInline) return;
const handleClickOutside = (event) => {
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
handleCancelCreate();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isCreatingInline]);
const handleEnvironmentClick = (env) => {
if (!isModified) {
setSelectedEnvironment(env);
@@ -63,18 +124,141 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
};
const handleEnvironmentDoubleClick = (env) => {
setRenamingEnvUid(env.uid);
setNewEnvName(env.name);
setEnvNameError('');
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
};
const handleActivateEnvironment = (e, env) => {
e.stopPropagation();
dispatch(selectEnvironment(env.uid, collection.uid))
.then(() => {
toast.success(`Environment "${env.name}" activated`);
})
.catch(() => {
toast.error('Failed to activate environment');
});
};
if (!selectedEnvironment) {
return null;
}
const validateEnvironmentName = (name, excludeUid = null) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (!validateName(name)) {
return validateNameError(name);
}
const trimmedName = name.toLowerCase().trim();
const isDuplicate = environments.some(
(env) => env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName
);
if (isDuplicate) {
return 'Environment already exists';
}
return null;
};
const handleCreateEnvClick = () => {
if (!isModified) {
setOpenCreateModal(true);
setIsCreatingInline(true);
setNewEnvName('');
setEnvNameError('');
setTimeout(() => {
inputRef.current?.focus();
}, 50);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleCancelCreate = () => {
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
};
const handleSaveNewEnv = () => {
const error = validateEnvironmentName(newEnvName);
if (error) {
setEnvNameError(error);
return;
}
dispatch(addEnvironment(newEnvName, collection.uid))
.then(() => {
toast.success('Environment created!');
setIsCreatingInline(false);
setNewEnvName('');
setEnvNameError('');
})
.catch(() => {
toast.error('An error occurred while creating the environment');
});
};
const handleEnvNameChange = (e) => {
const value = e.target.value;
setNewEnvName(value);
if (envNameError) {
setEnvNameError('');
}
};
const handleEnvNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (renamingEnvUid) {
handleSaveRename();
} else {
handleSaveNewEnv();
}
} else if (e.key === 'Escape') {
e.preventDefault();
if (renamingEnvUid) {
handleCancelRename();
} else {
handleCancelCreate();
}
}
};
const handleSaveRename = () => {
const error = validateEnvironmentName(newEnvName, renamingEnvUid);
if (error) {
setEnvNameError(error);
return;
}
dispatch(renameEnvironment(newEnvName, renamingEnvUid, collection.uid))
.then(() => {
toast.success('Environment renamed!');
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
})
.catch(() => {
toast.error('An error occurred while renaming the environment');
});
};
const handleCancelRename = () => {
setRenamingEnvUid(null);
setNewEnvName('');
setEnvNameError('');
};
const handleImportClick = () => {
if (!isModified) {
setOpenImportModal(true);
@@ -83,8 +267,10 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
};
const handleSecretsClick = () => {
setOpenManageSecretsModal(true);
const handleExportClick = () => {
if (setShowExportModal) {
setShowExportModal(true);
}
};
const handleConfirmSwitch = (saveChanges) => {
@@ -93,59 +279,160 @@ const EnvironmentList = ({ selectedEnvironment, setSelectedEnvironment, collecti
}
};
const filteredEnvironments
= environments?.filter((env) => env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment collection={collection} onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
{openImportModal && (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setOpenImportModal(false)} />
)}
<div className="flex">
<div>
{switchEnvConfirmClose && (
<div className="flex items-center justify-between tab-container px-1">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="environments-sidebar flex flex-col">
{environments
&& environments.length
&& environments.map((env) => (
<ToolHint key={env.uid} text={env.name} toolhintId={env.uid} place="right">
<div
id={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle clicks
>
<span className="break-all">{env.name}</span>
</div>
</ToolHint>
))}
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>
</div>
<div className="environments-container">
{switchEnvConfirmClose && (
<div className="confirm-switch-overlay">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="mt-auto btn-import-environment">
<div className="flex items-center" onClick={() => handleImportClick()}>
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => setShowExportModal(true)}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
</div>
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Environments</h2>
<div className="flex items-center gap-2">
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<IconDownload size={16} strokeWidth={1.5} />
</button>
<button className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<IconUpload size={16} strokeWidth={1.5} />
</button>
</div>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
key={env.uid}
id={env.uid}
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
>
{renamingEnvUid === env.uid ? (
<div className="rename-container" ref={renameContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<>
<span className="environment-name">{env.name}</span>
<div className="environment-actions">
{activeEnvironmentUid === env.uid ? (
<div className="activated-checkmark" title="Active environment">
<IconCheck size={16} strokeWidth={2} />
</div>
) : (
<button
className="activate-btn"
onClick={(e) => handleActivateEnvironment(e, env)}
title="Activate environment"
>
<IconCheck size={16} strokeWidth={2} />
</button>
)}
</div>
</>
)}
</div>
))}
{isCreatingInline && (
<div className="environment-item creating" ref={createContainerRef}>
<input
ref={inputRef}
type="text"
className="environment-name-input"
value={newEnvName}
onChange={handleEnvNameChange}
onKeyDown={handleEnvNameKeyDown}
placeholder="Environment name..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveNewEnv}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelCreate}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
)}
{envNameError && (isCreatingInline || renamingEnvUid) && <div className="env-error">{envNameError}</div>}
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
collection={collection}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
onClose={onClose}
collection={collection}
/>
</div>
</StyledWrapper>

View File

@@ -1,31 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
const ManageSecrets = ({ onClose }) => {
return (
<Portal>
<Modal size="sm" title="Manage Secrets" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<div>
<p>In any collection, there are secrets that need to be managed.</p>
<p className="mt-2">These secrets can be anything such as API keys, passwords, or tokens.</p>
<p className="mt-4">Bruno offers three approaches to manage secrets in collections.</p>
<p className="mt-2">
Read more about it in our{' '}
<a
href="https://docs.usebruno.com/secrets-management/overview"
target="_blank"
rel="noreferrer"
className="text-link hover:underline"
>
docs
</a>
.
</p>
</div>
</Modal>
</Portal>
);
};
export default ManageSecrets;

View File

@@ -1,89 +0,0 @@
import React, { useEffect, useRef } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment, collection }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('name is required')
}),
onSubmit: (values) => {
if (values.name === environment.name) {
return;
}
dispatch(renameEnvironment(values.name, environment.uid, collection.uid))
.then(() => {
toast.success('Environment renamed successfully');
onClose();
})
.catch(() => toast.error('An error occurred while renaming the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Rename Environment"
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default RenameEnvironment;

View File

@@ -1,11 +1,51 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
button.btn-create-environment {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: ${(props) => props.theme.bg};
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
color: ${(props) => props.theme.colors.text.muted};
svg {
opacity: 0.3;
margin-bottom: 8px;
}
.title {
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.actions {
display: flex;
gap: 8px;
}
}
.shared-button {
padding: 5px 10px;
font-size: 12px;
border-radius: 5px;
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&:hover {
span {
text-decoration: underline;
}
background: ${(props) => props.theme.listItem.hoverBg};
border-color: ${(props) => props.theme.textLink};
}
}
`;

View File

@@ -1,87 +1,64 @@
import Modal from 'components/Modal/index';
import React, { useState } from 'react';
import CreateEnvironment from './CreateEnvironment';
import CreateEnvironment from 'components/Environments/EnvironmentSettings/CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-medium text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const DefaultTab = ({ setTab }) => {
return (
<div className="text-center items-center flex flex-col">
<IconFileAlert size={64} strokeWidth={1} />
<span className="font-medium mt-2">No environments found</span>
<span className="font-extralight mt-2 text-zinc-500 dark:text-zinc-400">
Get started by using the following buttons :
</span>
<div className="flex items-center justify-center mt-6">
<SharedButton onClick={() => setTab('create')}>
<span>Create Environment</span>
</SharedButton>
<span className="mx-4">Or</span>
<SharedButton onClick={() => setTab('import')}>
<span>Import Environment</span>
</SharedButton>
</div>
const DefaultTab = ({ setTab }) => (
<div className="empty-state">
<IconFileAlert size={48} strokeWidth={1.5} />
<div className="title">No Environments</div>
<div className="actions">
<button className="shared-button" onClick={() => setTab('create')}>
Create Environment
</button>
<button className="shared-button" onClick={() => setTab('import')}>
Import Environment
</button>
</div>
);
};
</div>
);
const EnvironmentSettings = ({ collection, onClose }) => {
const EnvironmentSettings = ({ collection }) => {
const [isModified, setIsModified] = useState(false);
const { environments } = collection;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
const environments = collection?.environments || [];
if (!environments || !environments.length) {
return (
<StyledWrapper>
<Modal size="md" title="Environments" handleCancel={onClose} hideCancel={true} hideFooter={true}>
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</Modal>
{tab === 'create' ? (
<CreateEnvironment collection={collection} onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="collection" collection={collection} onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</StyledWrapper>
);
}
return (
<StyledWrapper>
<Modal size="lg" title="Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
collection={collection}
isModified={isModified}
setIsModified={setIsModified}
onClose={onClose}
setShowExportModal={setShowExportModal}
/>
</Modal>
<EnvironmentList
environments={environments}
activeEnvironmentUid={collection?.activeEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
setShowExportModal={setShowExportModal}
/>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={collection.environments}
environments={environments}
environmentType="collection"
/>
)}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import WorkspaceEnvironments from 'components/WorkspaceHome/WorkspaceEnvironments';
const GlobalEnvironmentSettings = () => {
return <WorkspaceEnvironments />;
};
export default GlobalEnvironmentSettings;

View File

@@ -55,16 +55,30 @@ const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = fa
return filenames.length > 0 ? (
<div
className={buttonClass}
style={{ fontWeight: 400, width: '100%', textOverflow: 'ellipsis', overflowX: 'hidden' }}
style={{
fontWeight: 400,
width: '100%',
display: 'flex',
alignItems: 'center',
overflow: 'hidden'
}}
title={title}
>
{!readOnly && (
<button className="align-middle" onClick={clear}>
<button className="align-middle" onClick={clear} style={{ flexShrink: 0 }}>
<IconX size={18} />
</button>
)}
{!readOnly && <>&nbsp;</>}
{renderButtonText(filenames)}
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1
}}
>
{renderButtonText(filenames)}
</span>
</div>
) : (
<button className={buttonClass} style={{ width: '100%' }} onClick={!readOnly ? browse : undefined} disabled={readOnly}>

View File

@@ -18,6 +18,7 @@ import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
import Button from 'ui/Button';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -206,14 +207,14 @@ const Auth = ({ collection, folder }) => {
Configures authentication for the entire folder. This applies to all requests using the{' '}
<span className="font-medium">Inherit</span> option in the <span className="font-medium">Auth</span> tab.
</div>
<div className="flex flex-grow justify-start items-center mb-4">
<div className="flex flex-grow justify-start items-center">
<AuthMode collection={collection} folder={folder} />
</div>
{getAuthView()}
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -1,16 +1,20 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.auth-mode-selector {
border: 1px solid ${({ theme }) => theme.colors.border};
padding: 4px 8px;
border-radius: 4px;
font-size: ${(props) => props.theme.font.size.base};
}
font-size: ${(props) => props.theme.font.size.base};
.auth-mode-label {
color: ${({ theme }) => theme.colors.text};
.auth-mode-selector {
background: transparent;
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
.caret {
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef } from 'react';
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { useDispatch } from 'react-redux';
import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,19 +9,9 @@ import StyledWrapper from './StyledWrapper';
const AuthMode = ({ collection, folder }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = folder.draft ? get(folder, 'draft.request.auth.mode') : get(folder, 'root.request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
const onModeChange = useCallback((value) => {
dispatch(
updateFolderAuthMode({
mode: value,
@@ -29,103 +19,74 @@ const AuthMode = ({ collection, folder }) => {
folderUid: folder.uid
})
);
};
}, [dispatch, collection.uid, folder.uid]);
const menuItems = useMemo(() => [
{
id: 'awsv4',
label: 'AWS Sig v4',
onClick: () => onModeChange('awsv4')
},
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'digest',
label: 'Digest Auth',
onClick: () => onModeChange('digest')
},
{
id: 'ntlm',
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'inherit',
label: 'Inherit',
onClick: () => onModeChange('inherit')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
return (
<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 className="inline-flex items-center cursor-pointer auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</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={() => {
dropdownTippyRef.current.hide();
onModeChange('oauth2');
}}
>
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={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</MenuDropdown>
</div>
</StyledWrapper>
);

View File

@@ -10,6 +10,7 @@ import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -112,9 +113,9 @@ const Headers = ({ collection, folder }) => {
</button>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -7,6 +7,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -100,9 +101,9 @@ const Script = ({ collection, folder }) => {
</Tabs>
<div className="mt-12">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -6,6 +6,7 @@ import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -42,9 +43,9 @@ const Tests = ({ collection, folder }) => {
/>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -4,6 +4,7 @@ import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -14,17 +15,17 @@ const Vars = ({ collection, folder }) => {
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Pre Request</div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
</div>
<div className="flex-1">
<div className="mt-1 mb-1 title text-xs">Post Response</div>
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -1,78 +0,0 @@
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
import { useFormik } from 'formik';
import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import * as Yup from 'yup';
const CopyEnvironment = ({ environment, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name + ' - Copy'
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(50, 'must be 50 characters or less')
.required('name is required')
}),
onSubmit: (values) => {
dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
.then(() => {
toast.success('Global environment created!');
onClose();
})
.catch((error) => {
toast.error('An error occurred while created the environment');
console.error(error);
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal size="sm" title="Copy Global Environment" confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
New Environment Name
</label>
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CopyEnvironment;

View File

@@ -1,100 +0,0 @@
import React, { useEffect, useRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch, useSelector } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const validateEnvironmentName = (name) => {
const trimmedName = name?.toLowerCase().trim();
return globalEnvs.every((env) => env?.name?.toLowerCase().trim() !== trimmedName);
};
const dispatch = useDispatch();
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: ''
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'Must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.required('Name is required')
.test('duplicate-name', 'Global Environment already exists', validateEnvironmentName)
}),
onSubmit: (values) => {
dispatch(addGlobalEnvironment({ name: values.name }))
.then(() => {
toast.success('Global environment created!');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch(() => toast.error('An error occurred while creating the environment'));
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Create Global Environment"
confirmText="Create"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
Environment Name
</label>
<div className="flex items-center mt-2">
<input
id="environment-name"
type="text"
name="name"
ref={inputRef}
className="block textbox w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.name || ''}
/>
</div>
{formik.touched.name && formik.errors.name ? (
<div className="text-red-500">{formik.errors.name}</div>
) : null}
</div>
</form>
</Modal>
</Portal>
);
};
export default CreateEnvironment;

View File

@@ -1,15 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
button.submit {
color: white;
background-color: var(--color-background-danger) !important;
border: inherit !important;
&:hover {
border: inherit !important;
}
}
`;
export default Wrapper;

View File

@@ -1,37 +0,0 @@
import React from 'react';
import Portal from 'components/Portal/index';
import toast from 'react-hot-toast';
import Modal from 'components/Modal/index';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
const DeleteEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
const onConfirm = () => {
dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid }))
.then(() => {
toast.success('Global Environment deleted successfully');
onClose();
})
.catch(() => toast.error('An error occurred while deleting the environment'));
};
return (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title="Delete Global Environment"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
>
Are you sure you want to delete <span className="font-medium">{environment.name}</span> ?
</Modal>
</StyledWrapper>
</Portal>
);
};
export default DeleteEnvironment;

View File

@@ -1,42 +0,0 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
const ConfirmSwitchEnv = ({ onCancel }) => {
return createPortal(
<Modal
size="md"
title="Unsaved changes"
confirmText="Save and Close"
cancelText="Close without saving"
disableEscapeKey={true}
disableCloseOnOutsideClick={true}
closeModalFadeTimeout={150}
handleCancel={onCancel}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
hideFooter={true}
>
<div className="flex items-center font-normal">
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
<h1 className="ml-2 text-lg font-medium">Hold on..</h1>
</div>
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
Close
</button>
</div>
<div></div>
</div>
</Modal>,
document.body
);
};
export default ConfirmSwitchEnv;

View File

@@ -1,67 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 500;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.collection.environment.settings.gridBorder};
padding: 4px 10px;
vertical-align: middle;
&:nth-child(1),
&:nth-child(4) {
width: 70px;
}
&:nth-child(5) {
width: 40px;
}
&:nth-child(2) {
width: 25%;
}
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
thead td {
padding: 6px 10px;
}
}
.btn-add-param {
font-size: ${(props) => props.theme.font.size.base};
}
.tooltip-mod {
font-size: ${(props) => props.theme.font.size.xs} !important;
width: 150px !important;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;
outline: none !important;
background-color: transparent;
&:focus {
outline: none !important;
border: solid 1px transparent;
}
}
input[type='checkbox'] {
cursor: pointer;
vertical-align: middle;
margin: 0;
}
`;
export default Wrapper;

View File

@@ -1,224 +0,0 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
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,
initialValues: environment.variables || [],
validationSchema: Yup.array().of(
Yup.object({
enabled: Yup.boolean(),
name: Yup.string()
.required('Name cannot be empty')
.matches(
variableNameRegex,
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.'
)
.trim(),
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
})
),
onSubmit: (values) => {
if (!formik.dirty) {
toast.error('Nothing to save');
return;
}
dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(values) }))
.then(() => {
toast.success('Changes saved successfully');
formik.resetForm({ values });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}
});
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
}, [formik.dirty]);
const ErrorMessage = ({ name }) => {
const meta = formik.getFieldMeta(name);
const id = uuid();
if (!meta.error || !meta.touched) {
return null;
}
return (
<span>
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
</span>
);
};
const addVariable = () => {
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
};
formik.setFieldValue(formik.values.length, newVariable, false);
};
const handleRemoveVar = (id) => {
formik.setValues(formik.values.filter((variable) => variable.uid !== id));
};
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [formik.values, formik.dirty]);
const handleReset = () => {
formik.resetForm({ originalEnvironmentVariables });
};
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
<table>
<thead>
<tr>
<td className="text-center">Enabled</td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
</td>
<td>
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
onChange={formik.handleChange}
/>
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
enableBrunoVarInfo={false}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.name}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.name}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
</td>
<td>
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={20} />
</button>
</td>
</tr>
))}
</tbody>
</table>
<div>
<button
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
data-testid="add-variable"
>
+ Add Variable
</button>
</div>
</div>
<div>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit} data-testid="save-env">
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>
</StyledWrapper>
);
};
export default EnvironmentVariables;

View File

@@ -1,58 +0,0 @@
import { IconCopy, IconDatabase, IconEdit, IconTrash } from '@tabler/icons';
import { useState } from 'react';
import CopyEnvironment from '../../CopyEnvironment';
import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ToolHint from 'components/ToolHint/index';
const EnvironmentDetails = ({ environment, setIsModified, collection, allEnvironments }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
return (
<div className="px-6 flex-grow flex flex-col pt-6" style={{ maxWidth: '700px' }}>
{openEditModal && (
<RenameEnvironment onClose={() => setOpenEditModal(false)} environment={environment} />
)}
{openDeleteModal && (
<DeleteEnvironment
onClose={() => setOpenDeleteModal(false)}
environment={environment}
/>
)}
{openCopyModal && (
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} />
)}
<div className="flex">
<div className="flex flex-grow items-center">
<IconDatabase className="cursor-pointer" size={20} strokeWidth={1.5} />
<span className="ml-1 font-medium break-all">{environment.name}</span>
</div>
<div className="flex gap-x-2 pl-2">
<ToolHint text="Edit Environment" toolhintId={`edit-${environment.uid}`}>
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenEditModal(true)} />
</ToolHint>
<ToolHint text="Copy Environment" toolhintId={`copy-${environment.uid}`}>
<IconCopy className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenCopyModal(true)} />
</ToolHint>
<ToolHint text="Delete Environment" toolhintId={`delete-${environment.uid}`}>
<IconTrash className="cursor-pointer" size={20} strokeWidth={1.5} onClick={() => setOpenDeleteModal(true)} data-testid="delete-environment-button" />
</ToolHint>
</div>
</div>
<div>
<EnvironmentVariables
environment={environment}
setIsModified={setIsModified}
collection={collection}
allEnvironments={allEnvironments}
/>
</div>
</div>
);
};
export default EnvironmentDetails;

View File

@@ -1,62 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-inline: -1rem;
margin-block: -1.5rem;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
.environments-sidebar {
background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
min-height: 400px;
height: 100%;
max-height: 85vh;
overflow-y: auto;
}
.environment-item {
min-width: 150px;
display: block;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
text-decoration: none;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
text-decoration: none;
background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
}
}
.active {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
}
}
.btn-create-environment,
.btn-import-environment {
padding: 8px 10px;
cursor: pointer;
border-bottom: none;
color: ${(props) => props.theme.textLink};
span:hover {
text-decoration: underline;
}
}
.btn-import-environment {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -1,163 +0,0 @@
import React, { useEffect, useState } from 'react';
import usePrevious from 'hooks/usePrevious';
import EnvironmentDetails from './EnvironmentDetails';
import CreateEnvironment from '../CreateEnvironment';
import { IconDownload, IconShieldLock, IconUpload } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
import ManageSecrets from 'components/Environments/EnvironmentSettings/ManageSecrets/index';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import { isEqual } from 'lodash';
import ToolHint from 'components/ToolHint/index';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection, setShowExportModal }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
useEffect(() => {
if (!environments?.length) {
setSelectedEnvironment(null);
setOriginalEnvironmentVariables([]);
return;
}
if (selectedEnvironment) {
const _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
if (hasSelectedEnvironmentChanged) {
setSelectedEnvironment(_selectedEnvironment);
}
setOriginalEnvironmentVariables(selectedEnvironment.variables);
return;
}
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0] || null;
setSelectedEnvironment(environment);
setOriginalEnvironmentVariables(environment?.variables || []);
}, [environments, activeEnvironmentUid]);
useEffect(() => {
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
if (newEnv) {
setSelectedEnvironment(newEnv);
}
}
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
}
}, [envUids, environments, prevEnvUids]);
const handleEnvironmentClick = (env) => {
if (!isModified) {
setSelectedEnvironment(env);
} else {
setSwitchEnvConfirmClose(true);
}
};
if (!selectedEnvironment) {
return null;
}
const handleCreateEnvClick = () => {
if (!isModified) {
setOpenCreateModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleImportClick = () => {
if (!isModified) {
setOpenImportModal(true);
} else {
setSwitchEnvConfirmClose(true);
}
};
const handleSecretsClick = () => {
setOpenManageSecretsModal(true);
};
const handleExportClick = () => {
if (setShowExportModal) {
setShowExportModal(true);
}
};
const handleConfirmSwitch = (saveChanges) => {
if (!saveChanges) {
setSwitchEnvConfirmClose(false);
}
};
return (
<StyledWrapper>
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
{openImportModal && <ImportEnvironmentModal type="global" onClose={() => setOpenImportModal(false)} />}
{openManageSecretsModal && <ManageSecrets onClose={() => setOpenManageSecretsModal(false)} />}
<div className="flex">
<div>
{switchEnvConfirmClose && (
<div className="flex items-center justify-between tab-container px-1">
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
</div>
)}
<div className="environments-sidebar flex flex-col">
{environments
&& environments.length
&& environments.map((env) => (
<ToolHint key={env.uid} text={env.name} toolhintId={env.uid} place="right">
<div
id={env.uid}
className={selectedEnvironment.uid === env.uid ? 'environment-item active' : 'environment-item'}
onClick={() => handleEnvironmentClick(env)} // Use handleEnvironmentClick to handle click
>
<span className="break-all">{env.name}</span>
</div>
</ToolHint>
))}
<div className="btn-create-environment" onClick={() => handleCreateEnvClick()}>
+ <span>Create</span>
</div>
<div className="mt-auto btn-import-environment">
<div className="flex items-center" onClick={() => handleImportClick()}>
<IconDownload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Import</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleExportClick()}>
<IconUpload size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Export</span>
</div>
<div className="flex items-center mt-2" onClick={() => handleSecretsClick()}>
<IconShieldLock size={12} strokeWidth={2} />
<span className="label ml-1 text-xs">Managing Secrets</span>
</div>
</div>
</div>
</div>
<EnvironmentDetails
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
allEnvironments={environments}
/>
</div>
</StyledWrapper>
);
};
export default EnvironmentList;

View File

@@ -1,13 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
button.btn-create-environment {
&:hover {
span {
text-decoration: underline;
}
}
}
`;
export default StyledWrapper;

View File

@@ -1,90 +0,0 @@
import Modal from 'components/Modal/index';
import React, { useState } from 'react';
import CreateEnvironment from './CreateEnvironment';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import { IconFileAlert } from '@tabler/icons';
import ImportEnvironmentModal from 'components/Environments/Common/ImportEnvironmentModal';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
export const SharedButton = ({ children, className, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-medium text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
${className}`}
>
{children}
</button>
);
};
const DefaultTab = ({ setTab }) => {
return (
<div className="text-center items-center flex flex-col">
<IconFileAlert size={64} strokeWidth={1} />
<span className="font-medium mt-2">No Global Environments found</span>
<div className="flex items-center justify-center mt-6">
<SharedButton onClick={() => setTab('create')}>
<span>Create Global Environment</span>
</SharedButton>
<span className="mx-4">Or</span>
<SharedButton onClick={() => setTab('import')}>
<span>Import Environment</span>
</SharedButton>
</div>
</div>
);
};
const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvironmentUid, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
const [tab, setTab] = useState('default');
const [showExportModal, setShowExportModal] = useState(false);
if (!environments || !environments.length) {
return (
<StyledWrapper>
<Modal size="md" title="Global Environments" handleCancel={onClose} hideCancel={true} hideFooter={true}>
{tab === 'create' ? (
<CreateEnvironment onClose={() => setTab('default')} />
) : tab === 'import' ? (
<ImportEnvironmentModal type="global" onClose={() => setTab('default')} />
) : (
<DefaultTab setTab={setTab} />
)}
</Modal>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<Modal size="lg" title="Global Environments" handleCancel={onClose} hideFooter={true}>
<EnvironmentList
environments={globalEnvironments}
activeEnvironmentUid={activeGlobalEnvironmentUid}
selectedEnvironment={selectedEnvironment}
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
setShowExportModal={setShowExportModal}
/>
</Modal>
{showExportModal && (
<ExportEnvironmentModal
onClose={() => setShowExportModal(false)}
environments={globalEnvironments}
environmentType="global"
/>
)}
</StyledWrapper>
);
};
export default EnvironmentSettings;

View File

@@ -10,7 +10,6 @@ import {
} from '@tabler/icons';
import { flattenItems, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { getDefaultRequestPaneTab } from 'utils/collections';
@@ -246,8 +245,6 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
expandItemPath(result);
if (result.type === SEARCH_TYPES.REQUEST) {
dispatch(hideHomePage());
const existingTab = tabs.find((tab) => tab.uid === result.item.uid);
if (existingTab) {

View File

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

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { IconFolder } from '@tabler/icons';
import { closeWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
const DeleteWorkspace = ({ onClose, workspace }) => {
const dispatch = useDispatch();
const [isDeleting, setIsDeleting] = useState(false);
const onConfirm = async () => {
if (isDeleting) return;
try {
setIsDeleting(true);
await dispatch(closeWorkspaceAction(workspace.uid));
onClose();
} catch (error) {
toast.error(error?.message || 'An error occurred while removing the workspace');
setIsDeleting(false);
}
};
return (
<Portal>
<Modal
size="sm"
title="Remove Workspace"
confirmText={isDeleting ? 'Removing...' : 'Remove'}
handleConfirm={onConfirm}
handleCancel={onClose}
confirmDisabled={isDeleting}
confirmButtonColor="danger"
>
<div className="flex items-center">
<IconFolder size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-semibold">{workspace?.name}</span>
</div>
{workspace?.pathname && (
<div className="break-words text-xs mt-1">{workspace.pathname}</div>
)}
<div className="mt-4">
Are you sure you want to remove workspace <span className="font-semibold">{workspace?.name}</span>?
</div>
<div className="mt-4">
The workspace will still be available in the file system and can be re-opened later.
</div>
</Modal>
</Portal>
);
};
export default DeleteWorkspace;

View File

@@ -4,40 +4,42 @@ import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
import { useDispatch, useSelector } from 'react-redux';
import { renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
const RenameEnvironment = ({ onClose, environment }) => {
const RenameWorkspace = ({ onClose, workspace }) => {
const dispatch = useDispatch();
const { workspaces } = useSelector((state) => state.workspaces);
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: environment.name
name: workspace.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'Must be 255 characters or less')
.test('is-valid-filename', function (value) {
const isValid = validateName(value);
return isValid ? true : this.createError({ message: validateNameError(value) });
})
.max(255, 'must be 255 characters or less')
.required('name is required')
.test('unique-name', 'A workspace with this name already exists', function (value) {
if (!value) return true;
return !workspaces.some((w) =>
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
);
})
}),
onSubmit: (values) => {
if (values.name === environment.name) {
if (values.name === workspace.name) {
onClose();
return;
}
dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
dispatch(renameWorkspaceAction(workspace.uid, values.name))
.then(() => {
toast.success('Environment renamed successfully');
onClose();
})
.catch((error) => {
toast.error('An error occurred while renaming the environment');
console.error(error);
toast.error(error?.message || 'An error occurred while renaming the workspace');
});
}
});
@@ -45,6 +47,7 @@ const RenameEnvironment = ({ onClose, environment }) => {
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [inputRef]);
@@ -56,18 +59,18 @@ const RenameEnvironment = ({ onClose, environment }) => {
<Portal>
<Modal
size="sm"
title="Rename Environment"
title="Rename Workspace"
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-medium">
Environment Name
<label htmlFor="workspace-name" className="block font-semibold">
Workspace Name
</label>
<input
id="environment-name"
id="workspace-name"
type="text"
name="name"
ref={inputRef}
@@ -89,4 +92,4 @@ const RenameEnvironment = ({ onClose, environment }) => {
);
};
export default RenameEnvironment;
export default RenameWorkspace;

View File

@@ -0,0 +1,175 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
.manage-workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
color: ${(props) => props.theme.text};
}
.header-title {
font-size: 15px;
font-weight: 600;
color: ${(props) => props.theme.text};
}
.create-workspace-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: ${(props) => props.theme.border.radius.base};
background: ${(props) => props.theme.brand};
color: white;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
cursor: pointer;
border: none;
}
.workspace-list {
flex: 1;
overflow-y: auto;
padding: 0 16px;
}
.workspace-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
}
.workspace-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.workspace-name-row {
display: flex;
align-items: center;
gap: 6px;
}
.workspace-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.default {
color: ${(props) => props.theme.colors.text.muted};
}
&.regular {
color: ${(props) => props.theme.brand};
}
}
.workspace-name {
font-size: ${(props) => props.theme.font.size.md};
font-weight: 500;
color: ${(props) => props.theme.text};
}
.default-badge {
padding: 1px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.sidebar.badge.bg};
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
}
.workspace-path {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: none;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.xs};
cursor: pointer;
}
.more-actions-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: ${(props) => props.theme.text};
cursor: pointer;
}
.dropdown-menu {
min-width: 120px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.sm};
&.danger {
color: ${(props) => props.theme.colors.text.danger};
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,162 @@
import React, { useState, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
import toast from 'react-hot-toast';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { sortWorkspaces } from 'utils/workspaces';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import RenameWorkspace from './RenameWorkspace';
import DeleteWorkspace from './DeleteWorkspace';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown/index';
import Button from 'ui/Button';
const ManageWorkspace = () => {
const dispatch = useDispatch();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [renameWorkspaceModal, setRenameWorkspaceModal] = useState({ open: false, workspace: null });
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
const sortedWorkspaces = useMemo(() => {
return sortWorkspaces(workspaces, preferences);
}, [workspaces, preferences]);
const handleBack = () => {
dispatch(showHomePage());
};
const handleOpenWorkspace = (workspace) => {
dispatch(switchWorkspace(workspace.uid));
dispatch(showHomePage());
toast.success(`Switched to ${workspace.name}`);
};
const handleShowInFolder = (workspace) => {
if (workspace.pathname) {
dispatch(showInFolder(workspace.pathname)).catch(() => {
toast.error('Error opening the folder');
});
}
};
const handleRenameClick = (workspace) => {
setRenameWorkspaceModal({ open: true, workspace });
};
const handleCloseClick = (workspace) => {
if (workspace.type === 'default') {
toast.error('Cannot remove the default workspace');
return;
}
setDeleteWorkspaceModal({ open: true, workspace });
};
return (
<StyledWrapper>
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
)}
{renameWorkspaceModal.open && renameWorkspaceModal.workspace && (
<RenameWorkspace
workspace={renameWorkspaceModal.workspace}
onClose={() => setRenameWorkspaceModal({ open: false, workspace: null })}
/>
)}
{deleteWorkspaceModal.open && deleteWorkspaceModal.workspace && (
<DeleteWorkspace
workspace={deleteWorkspaceModal.workspace}
onClose={() => setDeleteWorkspaceModal({ open: false, workspace: null })}
/>
)}
<div className="manage-workspace-header">
<div className="header-left">
<div className="back-button" onClick={handleBack}>
<IconArrowLeft size={18} strokeWidth={1.5} />
</div>
<span className="header-title">Manage Workspace</span>
</div>
<Button size="sm" onClick={() => setCreateWorkspaceModalOpen(true)} icon={<IconPlus size={14} strokeWidth={2} />}>
Create Workspace
</Button>
</div>
<div className="workspace-list">
{sortedWorkspaces.length === 0 ? (
<div className="empty-state">
<span>No workspaces found</span>
</div>
) : (
sortedWorkspaces.map((workspace) => {
const isDefault = workspace.type === 'default';
const isActive = workspace.uid === activeWorkspaceUid;
return (
<div key={workspace.uid} className="workspace-item">
<div className="workspace-info">
<div className="workspace-name-row">
<span className={`workspace-icon ${isDefault ? 'default' : 'regular'}`}>
{isDefault ? (
<IconLock size={14} strokeWidth={1.5} />
) : (
<IconCategory size={14} strokeWidth={1.5} />
)}
</span>
<span className="workspace-name">{workspace.name}</span>
{isDefault && <span className="default-badge">Default</span>}
</div>
{workspace.pathname && (
<div className="workspace-path">{workspace.pathname}</div>
)}
</div>
<div className="workspace-actions">
<button
className="action-btn"
onClick={() => handleOpenWorkspace(workspace)}
>
<IconLogin size={14} strokeWidth={1.5} />
<span>Open</span>
</button>
{workspace.pathname && workspace.type !== 'default' && (
<button
className="action-btn"
onClick={() => handleShowInFolder(workspace)}
>
<IconFolder size={14} strokeWidth={1.5} />
<span>Show in folder</span>
</button>
)}
{!isDefault && (
<MenuDropdown
placement="bottom-end"
items={[
{ id: 'rename', label: 'Rename', onClick: () => handleRenameClick(workspace) },
{ id: 'remove', label: 'Remove', onClick: () => handleCloseClick(workspace) }
]}
>
<button className="more-actions-btn">
<IconDots size={14} strokeWidth={1.5} />
</button>
</MenuDropdown>
)}
</div>
</div>
);
})
)}
</div>
</StyledWrapper>
);
};
export default ManageWorkspace;

View File

@@ -28,8 +28,8 @@ const Wrapper = styled.div`
.bruno-modal-card {
animation-duration: 0.85s;
animation-delay: 0.1s;
background: var(--color-background-top);
border-radius: var(--border-radius);
background: ${(props) => props.theme.modal.body.bg};
border-radius: ${(props) => props.theme.border.radius.base};
position: relative;
z-index: 11;
max-width: calc(100% - var(--spacing-base-unit));
@@ -68,25 +68,37 @@ const Wrapper = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
text-transform: uppercase;
color: ${(props) => props.theme.modal.title.color};
background-color: ${(props) => props.theme.modal.title.bg};
font-size: ${(props) => props.theme.font.size.sm};
padding: 12px;
font-size: ${(props) => props.theme.font.size.md};
padding: 0.5rem 1rem;
font-weight: 500;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-top-left-radius: ${(props) => props.theme.border.radius.base};
border-top-right-radius: ${(props) => props.theme.border.radius.base};
.bruno-modal-header-title {
display: flex;
align-items: center;
gap: 8px;
}
.close {
font-size: 1.3rem;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin-right: -0.5rem;
font-size: 1.125rem;
line-height: 1;
color: ${(props) => props.theme.modal.iconColor};
text-shadow: 0 1px 0 #fff;
opacity: 0.5;
margin-top: -2px;
color: ${(props) => props.theme.modal.title.color};
border-radius: ${(props) => props.theme.border.radius.sm};
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease;
&:hover {
opacity: 0.8;
opacity: 1;
background-color: ${(props) => props.theme.modal.closeButton.hoverBg};
}
}
}
@@ -104,7 +116,7 @@ const Wrapper = styled.div`
outline: none;
box-shadow: none;
transition: border-color ease-in-out 0.1s;
border-radius: 3px;
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.modal.input.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
@@ -144,14 +156,14 @@ const Wrapper = styled.div`
.bruno-modal-footer {
background-color: ${(props) => props.theme.modal.body.bg};
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
border-bottom-right-radius: ${(props) => props.theme.border.radius.base};
}
&.modal-footer-none {
.bruno-modal-content {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
border-bottom-right-radius: ${(props) => props.theme.border.radius.base};
}
}
`;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import useFocusTrap from 'hooks/useFocusTrap';
import Button from 'ui/Button';
const ESC_KEY_CODE = 27;
const ENTER_KEY_CODE = 13;
@@ -10,7 +11,7 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
{handleCancel && !hideClose ? (
// TODO: Remove data-test-id and use data-testid instead across the codebase.
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button" data-testid="modal-close-button">
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-testid="modal-close-button">
×
</div>
) : null}
@@ -27,7 +28,7 @@ const ModalFooter = ({
confirmDisabled,
hideCancel,
hideFooter,
confirmButtonClass = 'btn-secondary'
confirmButtonColor = 'primary'
}) => {
confirmText = confirmText || 'Save';
cancelText = cancelText || 'Cancel';
@@ -39,19 +40,20 @@ const ModalFooter = ({
return (
<div className="flex justify-end p-4 bruno-modal-footer">
<span className={hideCancel ? 'hidden' : 'mr-2'}>
<button type="button" onClick={handleCancel} className="btn btn-md btn-close">
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
{cancelText}
</button>
</Button>
</span>
<span>
<button
<Button
type="submit"
className={`submit btn btn-md ${confirmButtonClass}`}
color={confirmButtonColor}
disabled={confirmDisabled}
onClick={handleSubmit}
className="submit"
>
{confirmText}
</button>
</Button>
</span>
</div>
);
@@ -75,13 +77,19 @@ const Modal = ({
onClick,
closeModalFadeTimeout = 500,
dataTestId,
confirmButtonClass
confirmButtonColor = 'primary'
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
const handleKeydown = (event) => {
const { keyCode, shiftKey, ctrlKey, altKey, metaKey } = event;
// Only handle events from elements inside this modal
if (keyCode !== ESC_KEY_CODE && (!modalRef.current || !modalRef.current.contains(event.target))) {
return;
}
switch (keyCode) {
case ESC_KEY_CODE: {
if (disableEscapeKey) return;
@@ -143,7 +151,7 @@ const Modal = ({
confirmDisabled={confirmDisabled}
hideCancel={hideCancel}
hideFooter={hideFooter}
confirmButtonClass={confirmButtonClass}
confirmButtonColor={confirmButtonColor}
/>
</div>

View File

@@ -154,8 +154,10 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change

View File

@@ -1,9 +1,10 @@
import React from 'react';
import React, { useEffect, useCallback } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import toast from 'react-hot-toast';
import { IconFlask } from '@tabler/icons';
import get from 'lodash/get';
@@ -56,19 +57,37 @@ const Beta = ({ close }) => {
}
});
const handleSave = (newBetaPreferences) => {
const handleSave = useCallback((newBetaPreferences) => {
dispatch(
savePreferences({
...preferences,
beta: newBetaPreferences
})
)
.then(() => {
toast.success('Beta preferences saved successfully');
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
};
}, [dispatch, preferences]);
const debouncedSave = useCallback(
debounce((values) => {
betaSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave, betaSchema]
);
// Auto-save when form values change
useEffect(() => {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
const hasAnyBetaFeatures = BETA_FEATURES.length > 0;
@@ -113,12 +132,6 @@ const Beta = ({ close }) => {
<p>No beta features are currently available</p>
</div>
)}
<div className="mt-10">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import get from 'lodash/get';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
@@ -8,6 +9,7 @@ import toast from 'react-hot-toast';
const Font = ({ close }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const isInitialMount = useRef(true);
const [codeFont, setCodeFont] = useState(get(preferences, 'font.codeFont', 'default'));
const [codeFontSize, setCodeFontSize] = useState(get(preferences, 'font.codeFontSize', '13'));
@@ -22,22 +24,37 @@ const Font = ({ close }) => {
setCodeFontSize(clampedSize);
};
const handleSave = () => {
const handleSave = useCallback((font, fontSize) => {
dispatch(
savePreferences({
...preferences,
font: {
codeFont,
codeFontSize
codeFont: font,
codeFontSize: fontSize
}
})
).then(() => {
toast.success('Preferences saved successfully');
close();
}).catch(() => {
).catch(() => {
toast.error('Failed to save preferences');
});
};
}, [dispatch, preferences]);
const debouncedSave = useCallback(
debounce((font, fontSize) => {
handleSave(font, fontSize);
}, 500),
[handleSave]
);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
debouncedSave(codeFont, codeFontSize);
return () => {
debouncedSave.cancel();
};
}, [codeFont, codeFontSize, debouncedSave]);
return (
<StyledWrapper>
@@ -68,12 +85,6 @@ const Font = ({ close }) => {
/>
</div>
</div>
<div className="mt-10">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</StyledWrapper>
);
};

View File

@@ -1,5 +1,6 @@
import React, { useRef } from 'react';
import React, { useRef, useEffect, useCallback } from 'react';
import get from 'lodash/get';
import debounce from 'lodash/debounce';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
@@ -56,6 +57,9 @@ const General = ({ close }) => {
}
return true;
}),
oauth2: Yup.object({
useSystemBrowser: Yup.boolean()
}),
defaultCollectionLocation: Yup.string().max(1024)
});
@@ -76,6 +80,9 @@ const General = ({ close }) => {
enabled: get(preferences, 'autoSave.enabled', false),
interval: get(preferences, 'autoSave.interval', 1000)
},
oauth2: {
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
},
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
},
validationSchema: preferencesSchema,
@@ -89,7 +96,7 @@ const General = ({ close }) => {
}
});
const handleSave = (newPreferences) => {
const handleSave = useCallback((newPreferences) => {
dispatch(
savePreferences({
...preferences,
@@ -104,7 +111,10 @@ const General = ({ close }) => {
},
timeout: newPreferences.timeout,
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
sendCookies: newPreferences.sendCookies,
oauth2: {
useSystemBrowser: newPreferences.oauth2.useSystemBrowser
}
},
autoSave: {
enabled: newPreferences.autoSave.enabled,
@@ -114,12 +124,29 @@ const General = ({ close }) => {
defaultCollectionLocation: newPreferences.defaultCollectionLocation
}
}))
.then(() => {
toast.success('Preferences saved successfully');
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
};
}, [dispatch, preferences]);
const debouncedSave = useCallback(
debounce((values) => {
preferencesSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave]
);
useEffect(() => {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
const addCaCertificate = (e) => {
const filePath = window?.ipcRenderer?.getFilePath(e?.target?.files?.[0]);
@@ -258,6 +285,19 @@ const General = ({ close }) => {
Send Cookies automatically
</label>
</div>
<div className="flex items-center mt-2">
<input
id="oauth2.useSystemBrowser"
type="checkbox"
name="oauth2.useSystemBrowser"
checked={formik.values.oauth2.useSystemBrowser}
onChange={formik.handleChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="oauth2.useSystemBrowser">
Use System Browser for OAuth2 Authorization
</label>
</div>
<div className="flex flex-col mt-6">
<label className="block select-none" htmlFor="timeout">
Request Timeout (in ms)
@@ -344,11 +384,6 @@ const General = ({ close }) => {
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
) : null}
<div className="mt-10">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);

View File

@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import React, { useEffect, useCallback } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import toast from 'react-hot-toast';
import { savePreferences } from 'providers/ReduxStore/slices/app';
@@ -17,56 +18,41 @@ const ProxySettings = ({ close }) => {
console.log(preferences);
const proxySchema = Yup.object({
mode: Yup.string().oneOf(['off', 'on', 'system']),
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string()
.when('enabled', {
is: 'on',
then: (hostname) => hostname.required('Specify the hostname for your proxy.'),
otherwise: (hostname) => hostname.nullable()
})
.max(1024),
port: Yup.number()
.min(1)
.max(65535)
.typeError('Specify port between 1 and 65535')
.nullable()
.transform((_, val) => (val ? Number(val) : null)),
auth: Yup.object()
.when('enabled', {
is: 'on',
then: Yup.object({
enabled: Yup.boolean(),
username: Yup.string()
.when(['enabled'], {
is: true,
then: (username) => username.required('Specify username for proxy authentication.')
})
.max(1024),
password: Yup.string()
.when('enabled', {
is: true,
then: (password) => password.required('Specify password for proxy authentication.')
})
.max(1024)
})
})
.optional(),
bypassProxy: Yup.string().optional().max(1024)
disabled: Yup.boolean().optional(),
inherit: Yup.boolean().required(),
config: Yup.object({
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string().max(1024),
port: Yup.number()
.min(1)
.max(65535)
.typeError('Specify port between 1 and 65535')
.nullable()
.transform((_, val) => (val ? Number(val) : null)),
auth: Yup.object({
disabled: Yup.boolean().optional(),
username: Yup.string().max(1024),
password: Yup.string().max(1024)
}).optional(),
bypassProxy: Yup.string().optional().max(1024)
}).required()
});
const formik = useFormik({
initialValues: {
mode: preferences.proxy.mode,
protocol: preferences.proxy.protocol || 'http',
hostname: preferences.proxy.hostname || '',
port: preferences.proxy.port || 0,
auth: {
enabled: preferences.proxy.auth ? preferences.proxy.auth.enabled || false : false,
username: preferences.proxy.auth ? preferences.proxy.auth.username || '' : '',
password: preferences.proxy.auth ? preferences.proxy.auth.password || '' : ''
},
bypassProxy: preferences.proxy.bypassProxy || ''
disabled: preferences.proxy.disabled || false,
inherit: preferences.proxy.inherit || false,
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
port: preferences.proxy.config?.port || 0,
auth: {
disabled: preferences.proxy.config?.auth?.disabled || false,
username: preferences.proxy.config?.auth?.username || '',
password: preferences.proxy.config?.auth?.password || ''
},
bypassProxy: preferences.proxy.config?.bypassProxy || ''
}
},
validationSchema: proxySchema,
onSubmit: (values) => {
@@ -74,7 +60,7 @@ const ProxySettings = ({ close }) => {
}
});
const onUpdate = (values) => {
const onUpdate = useCallback((values) => {
proxySchema
.validate(values, { abortEarly: true })
.then((validatedProxy) => {
@@ -83,36 +69,50 @@ const ProxySettings = ({ close }) => {
...preferences,
proxy: validatedProxy
})
).then(() => {
toast.success('Preferences saved successfully');
close();
}).catch(() => {
).catch(() => {
toast.error('Failed to save preferences');
});
})
.catch((error) => {
let errMsg = error.message || 'Preferences validation error';
toast.error(errMsg);
});
};
}, [dispatch, preferences, proxySchema]);
const debouncedSave = useCallback(
debounce((values) => {
onUpdate(values);
}, 500),
[onUpdate]
);
const [passwordVisible, setPasswordVisible] = useState(false);
useEffect(() => {
formik.setValues({
mode: preferences.proxy.mode,
protocol: preferences.proxy.protocol || 'http',
hostname: preferences.proxy.hostname || '',
port: preferences.proxy.port || '',
auth: {
enabled: preferences.proxy.auth ? preferences.proxy.auth.enabled || false : false,
username: preferences.proxy.auth ? preferences.proxy.auth.username || '' : '',
password: preferences.proxy.auth ? preferences.proxy.auth.password || '' : ''
},
bypassProxy: preferences.proxy.bypassProxy || ''
disabled: preferences.proxy.disabled || false,
inherit: preferences.proxy.inherit || false,
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
port: preferences.proxy.config?.port || '',
auth: {
disabled: preferences.proxy.config?.auth?.disabled || false,
username: preferences.proxy.config?.auth?.username || '',
password: preferences.proxy.config?.auth?.password || ''
},
bypassProxy: preferences.proxy.config?.bypassProxy || ''
}
});
}, [preferences]);
useEffect(() => {
if (formik.dirty) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
};
}, [formik.values, formik.dirty, debouncedSave]);
return (
<StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
@@ -125,10 +125,11 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="false"
checked={formik.values.mode === 'off'}
value="off"
checked={formik.values.disabled === true}
onChange={(e) => {
formik.setFieldValue('mode', 'off');
formik.setFieldValue('disabled', true);
formik.setFieldValue('inherit', false);
}}
className="mr-1 cursor-pointer"
/>
@@ -138,10 +139,11 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="true"
checked={formik.values.mode === 'on'}
value="on"
checked={formik.values.disabled === false && formik.values.inherit === false}
onChange={(e) => {
formik.setFieldValue('mode', 'on');
formik.setFieldValue('disabled', false);
formik.setFieldValue('inherit', false);
}}
className="mr-1 cursor-pointer"
/>
@@ -152,15 +154,18 @@ const ProxySettings = ({ close }) => {
type="radio"
name="mode"
value="system"
checked={formik.values.mode === 'system'}
onChange={formik.handleChange}
checked={formik.values.disabled === false && formik.values.inherit === true}
onChange={(e) => {
formik.setFieldValue('disabled', false);
formik.setFieldValue('inherit', true);
}}
className="mr-1 cursor-pointer"
/>
System Proxy
</label>
</div>
</div>
{formik?.values?.mode === 'system' ? (
{formik.values.disabled === false && formik.values.inherit === true ? (
<div className="mb-3 pt-1 text-muted system-proxy-settings">
<small>
Below values are sourced from your system environment variables and cannot be directly updated in Bruno.<br />
@@ -188,7 +193,7 @@ const ProxySettings = ({ close }) => {
</div>
</div>
) : null}
{formik?.values?.mode === 'on' ? (
{formik.values.disabled === false && formik.values.inherit === false ? (
<>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol">
@@ -198,9 +203,9 @@ const ProxySettings = ({ close }) => {
<label className="flex items-center">
<input
type="radio"
name="protocol"
name="config.protocol"
value="http"
checked={formik.values.protocol === 'http'}
checked={formik.values.config.protocol === 'http'}
onChange={formik.handleChange}
className="mr-1"
/>
@@ -209,9 +214,9 @@ const ProxySettings = ({ close }) => {
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
name="config.protocol"
value="https"
checked={formik.values.protocol === 'https'}
checked={formik.values.config.protocol === 'https'}
onChange={formik.handleChange}
className="mr-1"
/>
@@ -220,9 +225,9 @@ const ProxySettings = ({ close }) => {
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
name="config.protocol"
value="socks4"
checked={formik.values.protocol === 'socks4'}
checked={formik.values.config.protocol === 'socks4'}
onChange={formik.handleChange}
className="mr-1"
/>
@@ -231,9 +236,9 @@ const ProxySettings = ({ close }) => {
<label className="flex items-center ml-4">
<input
type="radio"
name="protocol"
name="config.protocol"
value="socks5"
checked={formik.values.protocol === 'socks5'}
checked={formik.values.config.protocol === 'socks5'}
onChange={formik.handleChange}
className="mr-1"
/>
@@ -242,92 +247,94 @@ const ProxySettings = ({ close }) => {
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="hostname">
<label className="settings-label" htmlFor="config.hostname">
Hostname
</label>
<input
id="hostname"
id="config.hostname"
type="text"
name="hostname"
name="config.hostname"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.hostname || ''}
value={formik.values.config.hostname || ''}
/>
{formik.touched.hostname && formik.errors.hostname ? (
<div className="ml-3 text-red-500">{formik.errors.hostname}</div>
{formik.touched.config?.hostname && formik.errors.config?.hostname ? (
<div className="ml-3 text-red-500">{formik.errors.config.hostname}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="port">
<label className="settings-label" htmlFor="config.port">
Port
</label>
<input
id="port"
id="config.port"
type="number"
name="port"
name="config.port"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.port}
value={formik.values.config.port}
/>
{formik.touched.port && formik.errors.port ? (
<div className="ml-3 text-red-500">{formik.errors.port}</div>
{formik.touched.config?.port && formik.errors.config?.port ? (
<div className="ml-3 text-red-500">{formik.errors.config.port}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.enabled">
<label className="settings-label" htmlFor="config.auth.disabled">
Auth
</label>
<input
type="checkbox"
name="auth.enabled"
checked={formik.values.auth.enabled}
onChange={formik.handleChange}
name="config.auth.disabled"
checked={!formik.values.config.auth.disabled}
onChange={(e) => {
formik.setFieldValue('config.auth.disabled', !e.target.checked);
}}
/>
</div>
<div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.username">
<label className="settings-label" htmlFor="config.auth.username">
Username
</label>
<input
id="auth.username"
id="config.auth.username"
type="text"
name="auth.username"
name="config.auth.username"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.username}
value={formik.values.config.auth.username}
onChange={formik.handleChange}
/>
{formik.touched.auth?.username && formik.errors.auth?.username ? (
<div className="ml-3 text-red-500">{formik.errors.auth.username}</div>
{formik.touched.config?.auth?.username && formik.errors.config?.auth?.username ? (
<div className="ml-3 text-red-500">{formik.errors.config.auth.username}</div>
) : null}
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="auth.password">
<label className="settings-label" htmlFor="config.auth.password">
Password
</label>
<div className="textbox flex flex-row items-center w-[13.2rem] h-[2.25rem] relative">
<input
id="auth.password"
id="config.auth.password"
type={passwordVisible ? `text` : 'password'}
name="auth.password"
name="config.auth.password"
className="outline-none w-[10.5rem] bg-transparent"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.auth.password}
value={formik.values.config.auth.password}
onChange={formik.handleChange}
/>
<button
@@ -338,38 +345,33 @@ const ProxySettings = ({ close }) => {
{passwordVisible ? <IconEyeOff size={18} strokeWidth={2} /> : <IconEye size={18} strokeWidth={2} />}
</button>
</div>
{formik.touched.auth?.password && formik.errors.auth?.password ? (
<div className="ml-3 text-red-500">{formik.errors.auth.password}</div>
{formik.touched.config?.auth?.password && formik.errors.config?.auth?.password ? (
<div className="ml-3 text-red-500">{formik.errors.config.auth.password}</div>
) : null}
</div>
</div>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="bypassProxy">
<label className="settings-label" htmlFor="config.bypassProxy">
Proxy Bypass
</label>
<input
id="bypassProxy"
id="config.bypassProxy"
type="text"
name="bypassProxy"
name="config.bypassProxy"
className="block textbox"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.bypassProxy || ''}
value={formik.values.config.bypassProxy || ''}
/>
{formik.touched.bypassProxy && formik.errors.bypassProxy ? (
<div className="ml-3 text-red-500">{formik.errors.bypassProxy}</div>
{formik.touched.config?.bypassProxy && formik.errors.config?.bypassProxy ? (
<div className="ml-3 text-red-500">{formik.errors.config.bypassProxy}</div>
) : null}
</div>
</>
) : null}
<div className="mt-6">
<button type="submit" className="submit btn btn-md btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);

View File

@@ -2,14 +2,20 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.tabs {
padding: 8px;
min-width: 160px;
div.tab {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
min-width: 120px;
padding: 7px 10px;
padding: 6px 10px;
border: none;
border-bottom: solid 2px transparent;
color: var(--color-tab-inactive);
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: background-color 0.15s ease;
&:focus,
&:active,
@@ -21,18 +27,36 @@ const StyledWrapper = styled.div`
}
&.active {
color: ${(props) => props.theme.sidebar.color} !important;
background: ${(props) => props.theme.sidebar.collection.item.bg};
color: ${(props) => props.theme.text} !important;
background: ${(props) => props.theme.modal.title.bg};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.bg} !important;
background: ${(props) => props.theme.modal.title.bg} !important;
}
}
}
}
section.tab-panel {
min-height: 300px;
min-height: 70vh;
max-height: 70vh;
overflow-y: auto;
max-width: 50vw;
}
input[type="checkbox"],
input[type="radio"] {
accent-color: ${(props) => props.theme.workspace.accent};
cursor: pointer;
}
.section-header {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
font-weight: 500;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
`;

View File

@@ -81,7 +81,7 @@ const AssertionOperator = ({ operator, onChange }) => {
};
return (
<select value={operator} onChange={handleChange} className="mousetrap">
<select value={operator} onChange={handleChange} className="mousetrap" data-testid="assertion-operator-select">
{operators.map((operator) => (
<option key={operator} value={operator}>
{getLabel(operator)}

View File

@@ -163,6 +163,7 @@ const Assertions = ({ item, collection }) => {
defaultRow={defaultRow}
reorderable={true}
onReorder={handleAssertionDrag}
testId="assertions-table"
/>
</StyledWrapper>
);

View File

@@ -8,20 +8,12 @@ const Wrapper = styled.div`
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
.caret {
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
}
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
}
`;

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef } from 'react';
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,19 +9,9 @@ import StyledWrapper from './StyledWrapper';
const AuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
const onModeChange = useCallback((value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
@@ -29,102 +19,74 @@ const AuthMode = ({ item, collection }) => {
mode: value
})
);
};
}, [dispatch, item.uid, collection.uid]);
const menuItems = useMemo(() => [
{
id: 'awsv4',
label: 'AWS Sig v4',
onClick: () => onModeChange('awsv4')
},
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'digest',
label: 'Digest Auth',
onClick: () => onModeChange('digest')
},
{
id: 'ntlm',
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'inherit',
label: 'Inherit',
onClick: () => onModeChange('inherit')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef?.current?.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</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={() => {
dropdownTippyRef?.current?.hide();
onModeChange('oauth2');
}}
>
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={() => {
dropdownTippyRef?.current?.hide();
onModeChange('none');
}}
>
No Auth
</div>
</Dropdown>
</MenuDropdown>
</div>
</StyledWrapper>
);

View File

@@ -2,7 +2,7 @@ import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
@@ -12,10 +12,14 @@ import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const { storedTheme } = useTheme();
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
@@ -122,6 +126,29 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
);
};
const handleUseSystemBrowserToggle = (e) => {
const newValue = e.target.checked;
dispatch(
savePreferences({
...preferences,
request: {
...preferences.request,
oauth2: {
...preferences.request.oauth2,
useSystemBrowser: newValue
}
}
})
)
.then(() => {
toast.success('Preference updated successfully');
})
.catch((err) => {
console.error(err);
toast.error('Failed to update preference');
});
};
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={accessTokenUrl} credentialsId={credentialsId} />
@@ -133,6 +160,43 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-callbackUrl">
<label className="block min-w-[140px]">Callback URL</label>
<div className="flex flex-col gap-1 w-full">
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={callbackUrl}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
placeholder={useSystemBrowser ? 'https://oauth2.usebruno.com/callback' : undefined}
/>
</div>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-use-system-browser">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(useSystemBrowser)}
onChange={handleUseSystemBrowserToggle}
className="cursor-pointer"
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleUseSystemBrowserToggle({ target: { checked: !useSystemBrowser } });
}}
>
Use system browser for OAuth
</label>
</div>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
const value = oAuth[key] || '';

View File

@@ -1,8 +1,4 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef, useMemo } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
@@ -12,9 +12,13 @@ import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
@@ -77,6 +81,29 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
handleChange('autoFetchToken', e.target.checked);
};
const handleUseSystemBrowserToggle = (e) => {
const newValue = e.target.checked;
dispatch(
savePreferences({
...preferences,
request: {
...preferences.request,
oauth2: {
...preferences.request.oauth2,
useSystemBrowser: newValue
}
}
})
)
.then(() => {
toast.success('Preference updated successfully');
})
.catch((err) => {
console.error(err);
toast.error('Failed to update preference');
});
};
return (
<Wrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={authorizationUrl} credentialsId={credentialsId} />
@@ -88,6 +115,43 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-callbackUrl">
<label className="block min-w-[140px]">Callback URL</label>
<div className="flex flex-col gap-1 w-full">
<div className="oauth2-input-wrapper flex-1 flex items-center">
<SingleLineEditor
value={callbackUrl}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
placeholder={useSystemBrowser ? 'https://oauth2.usebruno.com/callback' : undefined}
/>
</div>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-use-system-browser">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(useSystemBrowser)}
onChange={handleUseSystemBrowserToggle}
className="cursor-pointer"
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleUseSystemBrowserToggle({ target: { checked: !useSystemBrowser } });
}}
>
Use system browser for OAuth
</label>
</div>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (

View File

@@ -1,8 +1,4 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'

View File

@@ -1,18 +1,36 @@
import { useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useMemo, useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { cloneDeep, find } from 'lodash';
import { IconLoader2 } from '@tabler/icons';
import { cloneDeep, find, get } from 'lodash';
import { IconLoader2, IconX } from '@tabler/icons';
import { interpolate } from '@usebruno/common';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials, cancelOauth2AuthorizationRequest, isOauth2AuthorizationRequestInProgress } from 'providers/ReduxStore/slices/collections/actions';
import { getAllVariables } from 'utils/collections/index';
const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => {
const { uid: collectionUid } = collection;
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const [fetchingToken, toggleFetchingToken] = useState(false);
const [refreshingToken, toggleRefreshingToken] = useState(false);
const [fetchingAuthorizationCode, toggleFetchingAuthorizationCode] = useState(false);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
// Check for pending authorization when component mounts or when fetching starts
useEffect(() => {
if (useSystemBrowser && fetchingToken) {
const getRequestStatus = async () => {
try {
toggleFetchingAuthorizationCode(await dispatch(isOauth2AuthorizationRequestInProgress()));
} catch (err) {
console.error('Error checking pending authorization:', err);
}
};
getRequestStatus();
}
}, [useSystemBrowser, fetchingToken, dispatch]);
const interpolatedAccessTokenUrl = useMemo(() => {
const variables = getAllVariables(collection, item);
@@ -35,8 +53,6 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
forceGetToken: true
}));
toggleFetchingToken(false);
// Check if the result contains error or if access_token is missing
if (!result || !result.access_token) {
const errorMessage = result?.error || 'No access token received from authorization server';
@@ -49,8 +65,14 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
} catch (error) {
console.error('could not fetch the token!');
console.error(error);
toggleFetchingToken(false);
// Don't show error toast for user cancellation
if (error?.message && error.message.includes('cancelled by user')) {
return;
}
toast.error(error?.message || 'An error occurred while fetching token!');
} finally {
toggleFetchingToken(false);
toggleFetchingAuthorizationCode(false);
}
};
@@ -95,6 +117,20 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
});
};
const handleCancelAuthorization = async () => {
try {
const result = await dispatch(cancelOauth2AuthorizationRequest());
if (result.success && result.cancelled) {
toast.error('Authorization cancelled');
toggleFetchingToken(false);
toggleFetchingAuthorizationCode(false);
}
} catch (err) {
console.error('Error cancelling authorization:', err);
toast.error('Failed to cancel authorization');
}
};
return (
<div className="flex flex-row gap-4 mt-4">
<button
@@ -115,6 +151,16 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
</button>
)
: null}
{useSystemBrowser && fetchingAuthorizationCode
? (
<button
onClick={handleCancelAuthorization}
className="submit btn btn-sm btn-secondary w-fit flex flex-row items-center"
>
<IconX size={16} className="mr-1" />
Cancel Authorization
</button>
) : null}
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>

View File

@@ -78,7 +78,7 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
item={item}
isSecret={true}
/>
{showWarning && <SensitiveFieldWarning fieldName="wsse-password" message={warningMessage} />}
{showWarning && <SensitiveFieldWarning fieldName="wsse-password" warningMessage={warningMessage} />}
</div>
</StyledWrapper>
);

View File

@@ -1,6 +1,5 @@
import React from 'react';
import get from 'lodash/get';
import AuthMode from './AuthMode';
import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
import BasicAuth from './BasicAuth';
@@ -73,6 +72,9 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'none': {
return <div className="mt-2">No Auth</div>;
}
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
@@ -113,9 +115,6 @@ const Auth = ({ item, collection }) => {
return (
<StyledWrapper className="w-full mt-1 overflow-auto">
<div className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);

View File

@@ -19,7 +19,7 @@ import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Settings from 'components/RequestPane/Settings';
import RequestPaneTabs from 'components/RequestPane/RequestPaneTabs';
import ResponsiveTabs from 'ui/ResponsiveTabs';
const MULTIPLE_CONTENT_TABS = new Set(['script', 'vars', 'auth', 'docs']);
@@ -46,8 +46,8 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
? get(item, 'draft.request.body.graphql.query', '')
: get(item, 'request.body.graphql.query', '');
const variables = item.draft
? get(item, 'draft.request.body.graphql.variables')
: get(item, 'request.body.graphql.variables');
? get(item, 'draft.request.body.graphql.variables', '')
: get(item, 'request.body.graphql.variables', '');
const { displayedTheme } = useTheme();
const [schema, setSchema] = useState(null);
@@ -146,7 +146,7 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
return (
<div className="flex flex-col h-full relative">
<RequestPaneTabs
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}

View File

@@ -50,7 +50,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
return (
<>
<button
className="btn-add-param text-link px-4 py-4 select-none absolute top-0 right-0 z-10"
className="btn-add-param text-link px-4 py-4 select-none absolute right-0 z-10"
onClick={onPrettify}
title="Prettify"
>

View File

@@ -6,6 +6,7 @@ import toast from 'react-hot-toast';
import get from 'lodash/get';
import Modal from 'components/Modal/index';
import CodeEditor from 'components/CodeEditor';
import Button from 'ui/Button';
const GrpcurlModal = ({ isOpen, onClose, command }) => {
const { displayedTheme } = useTheme();
@@ -39,12 +40,12 @@ const GrpcurlModal = ({ isOpen, onClose, command }) => {
<div className="flex w-full min-h-[400px]">
<div className="flex-grow relative">
<div className="absolute top-2 right-2 z-10">
<button
<Button
size="sm"
variant="ghost"
onClick={handleCopy}
className="btn btn-sm btn-secondary flex items-center gap-2"
>
{copied ? <IconCheck size={20} /> : <IconCopy size={20} />}
</button>
icon={copied ? <IconCheck size={20} /> : <IconCopy size={20} />}
/>
</div>
<CodeEditor
value={command}

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef } from 'react';
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -9,50 +9,9 @@ import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper';
const GrpcAuthMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const authModes = [
{
name: 'Basic Auth',
mode: 'basic'
},
{
name: 'Bearer Token',
mode: 'bearer'
},
{
name: 'API Key',
mode: 'apikey'
},
{
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'WSSE Auth',
mode: 'wsse'
},
{
name: 'Inherit',
mode: 'inherit'
},
{
name: 'No Auth',
mode: 'none'
}
];
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
const onModeChange = useCallback((value) => {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
@@ -60,27 +19,59 @@ const GrpcAuthMode = ({ item, collection }) => {
mode: value
})
);
};
}, [dispatch, item.uid, collection.uid]);
const onClickHandler = (mode) => {
dropdownTippyRef?.current?.hide();
onModeChange(mode);
};
const menuItems = useMemo(() => [
{
id: 'basic',
label: 'Basic Auth',
onClick: () => onModeChange('basic')
},
{
id: 'bearer',
label: 'Bearer Token',
onClick: () => onModeChange('bearer')
},
{
id: 'apikey',
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'oauth2',
label: 'OAuth 2.0',
onClick: () => onModeChange('oauth2')
},
{
id: 'wsse',
label: 'WSSE Auth',
onClick: () => onModeChange('wsse')
},
{
id: 'inherit',
label: 'Inherit',
onClick: () => onModeChange('inherit')
},
{
id: 'none',
label: 'No Auth',
onClick: () => onModeChange('none')
}
], [onModeChange]);
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
{authModes.map((authMode) => (
<div
key={authMode.mode}
className="dropdown-item"
onClick={() => onClickHandler(authMode.mode)}
>
{authMode.name}
</div>
))}
</Dropdown>
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
>
<div className="flex items-center justify-center auth-mode-label select-none">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</StyledWrapper>
);

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