Compare commits

...

61 Commits

Author SHA1 Message Date
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
320 changed files with 13620 additions and 5762 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'],

2554
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,9 +20,15 @@
],
"homepage": "https://usebruno.com",
"devDependencies": {
"@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",
"storybook": "^10.1.10",
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.3.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@stylistic/eslint-plugin": "^5.3.1",

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",
@@ -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",

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, 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,15 +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(() => {
@@ -43,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);
@@ -56,6 +110,7 @@ const AppTitleBar = () => {
}, [workspaces, preferences]);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [importWorkspaceModalOpen, setImportWorkspaceModalOpen] = useState(false);
const WorkspaceName = forwardRef((props, ref) => {
return (
@@ -88,6 +143,14 @@ const AppTitleBar = () => {
setCreateWorkspaceModalOpen(true);
};
const handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
};
const handleImportWorkspace = () => {
setImportWorkspaceModalOpen(true);
};
const handlePinWorkspace = useCallback((workspaceUid, e) => {
e.preventDefault();
e.stopPropagation();
@@ -154,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
}
);
@@ -161,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 */}
@@ -197,27 +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>
<ResponseLayoutToggle />
<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

@@ -1,27 +1,33 @@
import React, { useRef, forwardRef } from 'react';
import {
IconCaretDown,
IconForms,
IconBraces,
IconCode,
IconFileText,
IconDatabase,
IconFile,
IconX
} 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', icon: IconForms },
{ key: 'formUrlEncoded', label: 'Form URL Encoded', category: 'Form', icon: IconForms },
{ key: 'json', label: 'JSON', category: 'Raw', icon: IconBraces },
{ key: 'xml', label: 'XML', category: 'Raw', icon: IconCode },
{ key: 'text', label: 'TEXT', category: 'Raw', icon: IconFileText },
{ key: 'sparql', label: 'SPARQL', category: 'Raw', icon: IconDatabase },
{ key: 'file', label: 'File / Binary', category: 'Other', icon: IconFile },
{ key: 'none', label: 'No Body', category: 'Other', icon: IconX }
{
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 = ({
@@ -31,70 +37,37 @@ 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 (
<StyledWrapper className={wrapperClassName}>
<div className={`inline-flex items-center body-mode-selector ${disabled ? 'cursor-default' : 'cursor-pointer'}`}>
<Dropdown
onCreate={onDropdownCreate}
icon={<Icon />}
<MenuDropdown
items={menuItems}
placement={placement}
disabled={disabled}
className={className}
selectedItemId={currentMode}
showGroupDividers={false}
groupStyle="select"
>
{Object.entries(groupedModes).map(([category, categoryModes]) => (
<React.Fragment key={category}>
{showCategories && <div className="label-item">{category}</div>}
{categoryModes.map((mode) => {
const ModeIcon = mode.icon;
return (
<div
key={mode.key}
className="dropdown-item"
onClick={() => onModeSelect(mode.key)}
>
{ModeIcon && (
<span className="dropdown-icon">
<ModeIcon size={16} strokeWidth={2} />
</span>
)}
{mode.label}
</div>
);
})}
</React.Fragment>
))}
</Dropdown>
<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

@@ -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

@@ -180,6 +180,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

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,134 @@
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';
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" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
Save
</button>
</div>
</div>
</StyledWrapper>
);
};
export default PresetsSettings;

View File

@@ -14,11 +14,11 @@ 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">

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

@@ -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

@@ -41,7 +41,6 @@ const Wrapper = styled.div`
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;
@@ -137,6 +136,10 @@ const Wrapper = styled.div`
margin-top: 0.25rem;
padding-top: 0.375rem;
}
&.dropdown-item-select {
padding-left: 1.5rem;
}
}
.dropdown-separator {

View File

@@ -17,18 +17,18 @@ 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};
@@ -55,7 +55,7 @@ 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};
@@ -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);
@@ -224,7 +225,7 @@ const EditableTable = ({
return (
<StyledWrapper className={showCheckbox ? 'has-checkbox' : 'no-checkbox'}>
<div className="table-container" ref={tableRef}>
<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

@@ -0,0 +1,46 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
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 className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
Save
</button>
</div>
</div>
</Modal>
</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

@@ -206,7 +206,7 @@ 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()}

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

@@ -14,11 +14,11 @@ 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">

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

@@ -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}
confirmButtonClass="btn-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';
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 className="create-workspace-btn" onClick={() => setCreateWorkspaceModalOpen(true)}>
<IconPlus size={14} strokeWidth={2} />
<span>Create Workspace</span>
</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

@@ -10,7 +10,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}
@@ -82,6 +82,12 @@ const Modal = ({
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;

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';
@@ -74,7 +75,7 @@ const ProxySettings = ({ close }) => {
}
});
const onUpdate = (values) => {
const onUpdate = useCallback((values) => {
proxySchema
.validate(values, { abortEarly: true })
.then((validatedProxy) => {
@@ -83,18 +84,20 @@ 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);
@@ -113,6 +116,15 @@ const ProxySettings = ({ close }) => {
});
}, [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}>
@@ -365,11 +377,6 @@ const ProxySettings = ({ close }) => {
</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

@@ -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);

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

@@ -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>
);

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
import React, { useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import { useSelector, useDispatch } from 'react-redux';
import { find, get } from 'lodash';
@@ -17,6 +17,7 @@ import Documentation from 'components/Documentation/index';
import StatusDot from 'components/StatusDot';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import AuthMode from '../Auth/AuthMode/index';
const MULTIPLE_CONTENT_TABS = new Set(['params', 'script', 'vars', 'auth', 'docs']);
@@ -51,8 +52,7 @@ const HttpRequestPane = ({ item, collection }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const bodyModeRef = useRef(null);
const initialAutoSelectDone = useRef(false);
const rightContentRef = useRef(null);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
@@ -116,13 +116,6 @@ const HttpRequestPane = ({ item, collection }) => {
return Component ? <Component item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
}, [requestPaneTab, item, collection]);
useEffect(() => {
if (!initialAutoSelectDone.current && activeCounts.params === 0 && body.mode !== 'none') {
selectTab('body');
}
initialAutoSelectDone.current = true;
}, [activeCounts.params, body.mode, selectTab]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
@@ -130,9 +123,13 @@ const HttpRequestPane = ({ item, collection }) => {
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const rightContent = requestPaneTab === 'body' ? (
<div ref={bodyModeRef}>
<div ref={rightContentRef}>
<RequestBodyMode item={item} collection={collection} />
</div>
) : requestPaneTab === 'auth' ? (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>
) : null;
return (
@@ -142,7 +139,7 @@ const HttpRequestPane = ({ item, collection }) => {
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={bodyModeRef}
rightContentRef={rightContent ? rightContentRef : null}
delayedTabs={['body']}
/>

View File

@@ -136,7 +136,7 @@ const QueryParams = ({ item, collection }) => {
return (
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<div className="mb-3 title text-xs">Query</div>
<EditableTable
columns={queryColumns}
rows={queryParams || []}
@@ -151,7 +151,7 @@ const QueryParams = ({ item, collection }) => {
</button>
</div>
<div className="mb-2 title text-xs flex items-stretch">
<div className="mb-3 title text-xs flex items-stretch">
<span>Path</span>
<InfoTip infotipId="path-param-InfoTip">
<div>

View File

@@ -18,6 +18,10 @@ const Wrapper = styled.div`
.dropdown-item {
padding: 0.25rem 0.6rem !important;
}
.text-link {
color: ${(props) => props.theme.textLink};
}
}
input {
@@ -40,6 +44,9 @@ const Wrapper = styled.div`
overflow: hidden;
white-space: nowrap;
display: inline-block;
text-align: center;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
}
.caret {

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, forwardRef } from 'react';
import React, { useState, useRef, useMemo, useCallback } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import StyledWrapper from './StyledWrapper';
const STANDARD_METHODS = Object.freeze(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT']);
@@ -9,58 +9,27 @@ const KEY = Object.freeze({ ENTER: 'Enter', ESCAPE: 'Escape' });
const DEFAULT_METHOD = 'GET';
function Verb({ verb, onSelect }) {
const TriggerButton = ({ method, ...props }) => {
return (
<div className="dropdown-item" onClick={() => onSelect(verb)}>
{verb}
</div>
);
}
const Icon = forwardRef(function IconComponent(
{ isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef },
ref
) {
if (isCustomMode) {
return (
<div className="flex flex-col w-full">
<input
ref={inputRef}
type="text"
className="px-2 w-full focus:bg-transparent"
value={inputValue}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
title={inputValue}
autoFocus
/>
</div>
);
}
return (
<div ref={ref} className="flex pr-4 select-none">
<button
type="button"
className="cursor-pointer flex items-center text-left w-full"
<button
type="button"
className="cursor-pointer flex items-center text-left w-full pr-4 select-none"
{...props}
>
<span
className="px-3 truncate method-span"
id="create-new-request-method"
title={method}
>
<span
className="px-2 truncate method-span"
id="create-new-request-method"
title={inputValue}
>
{inputValue}
</span>
<IconCaretDown className="caret" size={16} strokeWidth={2} />
</button>
</div>
{method}
</span>
<IconCaretDown className="caret" size={16} strokeWidth={2} />
</button>
);
});
};
const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
const [isCustomMode, setIsCustomMode] = useState(false);
const dropdownTippyRef = useRef();
const inputRef = useRef();
const blurInput = () => inputRef.current?.blur();
@@ -70,74 +39,110 @@ const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect }) => {
onMethodSelect(val);
};
const handleDropdownSelect = (verb) => {
const handleMethodSelect = useCallback((verb) => {
onMethodSelect(verb);
setIsCustomMode(false);
dropdownTippyRef.current?.hide();
blurInput();
};
}, [onMethodSelect]);
const handleBlur = () => {
const handleBlur = (e) => {
// Keep the current value when blurring
const currentValue = e.target.value ? e.target.value.toUpperCase() : method;
onMethodSelect(currentValue);
setIsCustomMode(false);
};
const handleAddCustomMethod = () => {
const handleAddCustomMethod = useCallback(() => {
setIsCustomMode(true);
onMethodSelect('');
dropdownTippyRef.current?.hide();
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
};
}, [onMethodSelect]);
const handleKeyDown = (e) => {
switch (e.key) {
case KEY.ESCAPE:
case KEY.ESCAPE: {
setIsCustomMode(false);
blurInput();
e.preventDefault();
e.stopPropagation();
return;
case KEY.ENTER:
}
case KEY.ENTER: {
onMethodSelect(e.target.value ? e.target.value.toUpperCase() : DEFAULT_METHOD);
setIsCustomMode(false);
blurInput();
return;
default:
}
default: {
return;
}
}
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
// Convert STANDARD_METHODS to MenuDropdown items format
const menuItems = useMemo(() => {
const items = STANDARD_METHODS.map((verb) => ({
id: verb.toLowerCase(),
label: verb,
onClick: () => handleMethodSelect(verb)
}));
// Add "Add Custom" item
items.push({
id: 'add-custom',
label: '+ Add Custom',
onClick: handleAddCustomMethod,
className: 'font-normal mt-1 text-link'
});
return items;
}, [handleMethodSelect, handleAddCustomMethod]);
// Determine selected item ID (only if method is a standard method)
const selectedItemId = useMemo(() => {
if (isCustomMode || !STANDARD_METHODS.includes(method)) {
return null;
}
return method.toLowerCase();
}, [method, isCustomMode]);
// If in custom mode, render input field instead of dropdown
if (isCustomMode) {
return (
<StyledWrapper>
<div className="flex method-selector">
<div className="flex flex-col w-full">
<input
ref={inputRef}
type="text"
className="px-2 w-full focus:bg-transparent"
value={method}
onChange={handleInputChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
title={method}
autoFocus
/>
</div>
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<div className="flex method-selector">
<Dropdown
onCreate={onDropdownCreate}
icon={(
<Icon
isCustomMode={isCustomMode}
inputValue={method}
handleInputChange={handleInputChange}
handleBlur={handleBlur}
handleKeyDown={handleKeyDown}
inputRef={inputRef}
/>
)}
<MenuDropdown
items={menuItems}
placement="bottom-start"
selectedItemId={selectedItemId}
>
<div>
{STANDARD_METHODS.map((verb) => (
<Verb key={verb} verb={verb} onSelect={handleDropdownSelect} />
))}
<div className="dropdown-item font-normal mt-1" onClick={handleAddCustomMethod}>
<span className="text-link">+ Add Custom</span>
</div>
</div>
</Dropdown>
<TriggerButton method={method} />
</MenuDropdown>
</div>
</StyledWrapper>
);

View File

@@ -57,13 +57,13 @@ describe('HttpMethodSelector', () => {
await waitFor(() => {
const standardMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS', 'HEAD', 'TRACE', 'CONNECT'];
const dropdownItems = screen.getAllByText((content, element) => {
return element?.classList.contains('dropdown-item');
});
const renderedMethods = dropdownItems.map((item) => item.textContent);
const dropdownItems = screen.getAllByRole('menuitem');
const renderedMethods = dropdownItems.map((item) => item.textContent.trim());
standardMethods.forEach((method) => {
expect(renderedMethods).toContain(method);
standardMethods.forEach((method, index) => {
// GET should have a checkmark (✓) since it's the default selected method
const expectedText = index === 0 ? method + '✓' : method;
expect(renderedMethods).toContain(expectedText);
});
});
});
@@ -77,7 +77,8 @@ describe('HttpMethodSelector', () => {
await waitFor(() => {
const addCustomSpan = screen.getByText('+ Add Custom');
expect(addCustomSpan).toBeInTheDocument();
expect(addCustomSpan).toHaveClass('text-link');
// The className is applied to the parent dropdown-item div, not the label span
expect(addCustomSpan.closest('.dropdown-item')).toHaveClass('text-link');
});
});
@@ -88,10 +89,13 @@ describe('HttpMethodSelector', () => {
fireEvent.click(button);
await waitFor(() => {
const postMethod = screen.getByText('POST');
fireEvent.click(postMethod);
const postMethod = screen.getByRole('menuitem', { name: /^POST/ });
expect(postMethod).toBeInTheDocument();
});
const postMethod = screen.getByRole('menuitem', { name: /^POST/ });
fireEvent.click(postMethod);
expect(mockOnMethodSelect).toHaveBeenCalledWith('POST');
});
});

View File

@@ -8,15 +8,6 @@ const Wrapper = styled.div`
background: transparent;
border-radius: 3px;
.dropdown-item {
padding: 0.2rem 0.6rem !important;
padding-left: 1.5rem !important;
}
.label-item {
padding: 0.2rem 0.6rem !important;
}
.selected-body-mode {
color: ${(props) => props.theme.colors.text.yellow};
}
@@ -24,7 +15,7 @@ const Wrapper = styled.div`
.caret {
color: rgb(140, 140, 140);
fill: rgb(140 140 140);
fill: rgb(140, 140, 140);
}
`;

View File

@@ -1,4 +1,4 @@
import React, { useRef, forwardRef } from 'react';
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import {
IconCaretDown,
@@ -10,7 +10,7 @@ import {
IconFile,
IconX
} from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { useDispatch } from 'react-redux';
import { updateRequestBodyMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestBodyMode } from 'utils/collections';
@@ -20,22 +20,38 @@ import { toastError } from 'utils/common/error';
import { prettifyJsonString } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
const DEFAULT_MODES = [
{
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 RequestBodyMode = ({ item, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const bodyMode = body?.mode;
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(bodyMode)} <IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
const onModeChange = (value) => {
const onModeChange = useCallback((value) => {
dispatch(
updateRequestBodyMode({
itemUid: item.uid,
@@ -43,7 +59,7 @@ const RequestBodyMode = ({ item, collection }) => {
mode: value
})
);
};
}, [dispatch, item.uid, collection.uid]);
const onPrettify = () => {
if (body?.json && bodyMode === 'json') {
@@ -75,110 +91,30 @@ const RequestBodyMode = ({ item, collection }) => {
}
};
const menuItems = useMemo(() => {
return DEFAULT_MODES.map((group) => ({
...group,
options: group.options.map((option) => ({
...option,
onClick: () => onModeChange(option.id)
}))
}));
}, [onModeChange]);
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item">Form</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('multipartForm');
}}
>
<span className="dropdown-icon">
<IconForms size={16} strokeWidth={2} />
</span>
Multipart Form
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={bodyMode}
showGroupDividers={false}
groupStyle="select"
>
<div className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('formUrlEncoded');
}}
>
<span className="dropdown-icon">
<IconForms size={16} strokeWidth={2} />
</span>
Form URL Encoded
</div>
<div className="label-item">Raw</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('json');
}}
>
<span className="dropdown-icon">
<IconBraces size={16} strokeWidth={2} />
</span>
JSON
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('xml');
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
XML
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('text');
}}
>
<span className="dropdown-icon">
<IconFileText size={16} strokeWidth={2} />
</span>
TEXT
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('sparql');
}}
>
<span className="dropdown-icon">
<IconDatabase size={16} strokeWidth={2} />
</span>
SPARQL
</div>
<div className="label-item">Other</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('file');
}}
>
<span className="dropdown-icon">
<IconFile size={16} strokeWidth={2} />
</span>
File / Binary
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('none');
}}
>
<span className="dropdown-icon">
<IconX size={16} strokeWidth={2} />
</span>
No Body
</div>
</Dropdown>
</MenuDropdown>
</div>
{(bodyMode === 'json' || bodyMode === 'xml') && (
<button className="ml-2" onClick={onPrettify}>

View File

@@ -10,11 +10,11 @@ const Vars = ({ item, collection }) => {
return (
<StyledWrapper className="w-full flex flex-col">
<div className="mt-2">
<div className="mb-1 title text-xs">Pre Request</div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
</div>
<div>
<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 item={item} collection={collection} vars={responseVars} varType="response" />
</div>
</StyledWrapper>

View File

@@ -1,82 +1,70 @@
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';
import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper';
const AUTH_MODES = [
{
name: 'Basic Auth',
mode: 'basic'
},
{
name: 'Bearer Token',
mode: 'bearer'
},
{
name: 'API Key',
mode: 'apikey'
},
{
name: 'OAuth2',
mode: 'oauth2'
},
{
name: 'Inherit',
mode: 'inherit'
},
{
name: 'No Auth',
mode: 'none'
}
];
const WSAuthMode = ({ 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,
collectionUid: collection.uid,
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: '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">
{AUTH_MODES.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>
);

View File

@@ -34,9 +34,11 @@ import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import ResponseExample from 'components/ResponseExample';
import WorkspaceHome from 'components/WorkspaceHome';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 480;
const MIN_RIGHT_PANE_WIDTH = 490;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
@@ -52,6 +54,7 @@ const RequestTabPanel = () => {
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
// Use ref to avoid stale closure in event handlers
const isVerticalLayoutRef = useRef(isVerticalLayout);
@@ -80,6 +83,7 @@ const RequestTabPanel = () => {
const draggingRef = useRef(false);
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
const previousTopPaneHeight = useRef(null); // Store height before devtools opens
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
@@ -145,11 +149,37 @@ const RequestTabPanel = () => {
};
}, [handleMouseUp, handleMouseMove]);
if (!activeTabUid) {
useEffect(() => {
if (!isVerticalLayout) return;
if (isConsoleOpen) {
// Store current height before reducing
if (previousTopPaneHeight.current === null) {
previousTopPaneHeight.current = topPaneHeight;
}
// Reduce request pane height to make room for response pane when devtools is open
const maxHeight = 200;
if (topPaneHeight > maxHeight) {
setTopPaneHeight(maxHeight);
}
} else {
// Restore previous height when devtools closes
if (previousTopPaneHeight.current !== null) {
setTopPaneHeight(previousTopPaneHeight.current);
previousTopPaneHeight.current = null;
}
}
}, [isConsoleOpen, isVerticalLayout]);
if (!activeTabUid || !focusedTab) {
return <WorkspaceHome />;
}
if (!focusedTab || !focusedTab.uid || !focusedTab.collectionUid) {
if (focusedTab.type === 'global-environment-settings') {
return <GlobalEnvironmentSettings />;
}
if (!focusedTab.uid || !focusedTab.collectionUid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
@@ -200,6 +230,10 @@ const RequestTabPanel = () => {
return <SecuritySettings collection={collection} />;
}
if (focusedTab.type === 'environment-settings') {
return <EnvironmentSettings collection={collection} />;
}
if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />;
}

View File

@@ -7,6 +7,7 @@ import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
@@ -43,33 +44,31 @@ const CollectionToolBar = ({ collection }) => {
return (
<StyledWrapper>
<div className="flex items-center py-2 px-4">
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
<div className="flex items-center justify-between gap-2 py-2 px-4">
<button className="flex items-center cursor-pointer hover:underline bg-transparent border-none p-0 text-inherit" onClick={viewCollectionSettings}>
<IconBox size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</div>
<div className="flex flex-3 items-center justify-end">
<span className="mr-3">
<ToolHint text="Runner" toolhintId="RunnnerToolhintId" place="bottom">
<IconRun className="cursor-pointer" size={16} strokeWidth={1.5} onClick={handleRun} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<IconEye className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewVariables} />
</ToolHint>
</span>
<span className="mr-3">
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<IconSettings className="cursor-pointer" size={16} strokeWidth={1.5} onClick={viewCollectionSettings} />
</ToolHint>
</span>
<span className="mr-2">
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
<JsSandboxMode collection={collection} />
</ToolHint>
</span>
<span>
</button>
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Javascript Sandbox" toolhintId="JavascriptSandboxToolhintId" place="bottom">
<JsSandboxMode collection={collection} />
</ToolHint>
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>

View File

@@ -115,8 +115,8 @@ const ExampleTab = ({ tab, collection }) => {
}
}}
>
<ExampleIcon size={14} color="currentColor" className="mr-1.5 text-gray-500 flex-shrink-0" />
<span className="tab-name" title={example.name}>
<ExampleIcon size={14} color="currentColor" className="example-icon flex-shrink-0" />
<span className="tab-name ml-1" title={example.name}>
{example.name}
</span>
</div>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
const getTabInfo = (type, tabName) => {
@@ -53,6 +53,22 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
</>
);
}
case 'environment-settings': {
return (
<>
<IconDatabase size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Environments</span>
</>
);
}
case 'global-environment-settings': {
return (
<>
<IconWorld size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
<span className="ml-1 tab-name">Global Environments</span>
</>
);
}
}
};

View File

@@ -28,6 +28,10 @@ const StyledWrapper = styled.div`
// so that the name does not cutoff when italicized
padding-right: 2px;
}
.example-icon {
color: ${(props) => props.theme.requestTabs.example.iconColor};
}
`;
export default StyledWrapper;

View File

@@ -1,16 +1,19 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import darkTheme from 'themes/dark';
import lightTheme from 'themes/light';
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
import ConfirmRequestClose from './ConfirmRequestClose';
import ConfirmCollectionClose from './ConfirmCollectionClose';
import ConfirmFolderClose from './ConfirmFolderClose';
import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnvironment';
import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
@@ -21,6 +24,7 @@ import GradientCloseButton from './GradientCloseButton';
import { flattenItems } from 'utils/collections/index';
import { closeWsConnection } from 'utils/network/index';
import ExampleTab from '../ExampleTab';
import toast from 'react-hot-toast';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => {
const dispatch = useDispatch();
@@ -31,6 +35,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const [showConfirmClose, setShowConfirmClose] = useState(false);
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false);
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
@@ -152,8 +158,31 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
const hasEnvironmentDraft = tab.type === 'environment-settings' && collection?.environmentsDraft;
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft;
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
const handleCloseEnvironmentSettings = (event) => {
if (!collection?.environmentsDraft) {
return handleCloseClick(event);
}
event.stopPropagation();
event.preventDefault();
setShowConfirmEnvironmentClose(true);
};
const handleCloseGlobalEnvironmentSettings = (event) => {
if (!globalEnvironmentDraft) {
return handleCloseClick(event);
}
event.stopPropagation();
event.preventDefault();
setShowConfirmGlobalEnvironmentClose(true);
};
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings', 'environment-settings', 'global-environment-settings'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
@@ -214,12 +243,70 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
/>
)}
{showConfirmEnvironmentClose && tab.type === 'environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={false}
onCancel={() => setShowConfirmEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
}}
onSaveAndClose={() => {
const draft = collection.environmentsDraft;
if (draft?.environmentUid && draft?.variables) {
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
.then(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
toast.success('Environment saved');
})
.catch((err) => {
console.log('err', err);
toast.error('Failed to save environment');
});
}
}}
/>
)}
{showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={true}
onCancel={() => setShowConfirmGlobalEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
}}
onSaveAndClose={() => {
const draft = globalEnvironmentDraft;
if (draft?.environmentUid && draft?.variables) {
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
.then(() => {
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
toast.success('Global environment saved');
})
.catch((err) => {
console.log('err', err);
toast.error('Failed to save global environment');
});
}
}}
/>
)}
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseFolderSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} />
) : tab.type === 'collection-settings' ? (
<SpecialTab handleCloseClick={handleCloseCollectionSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={collection?.name} hasDraft={hasDraft} />
) : tab.type === 'environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} />
) : tab.type === 'global-environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseGlobalEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}

View File

@@ -76,7 +76,7 @@ const Wrapper = styled.div`
}
&:nth-last-child(1) {
margin-right: 10px;
margin-right: 4px;
}
&.has-overflow:not(:hover) .tab-name {

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