Compare commits

...

20 Commits

Author SHA1 Message Date
Bijin A B
33439b3840 chore: playwright fix 2025-12-25 14:47:56 +05:30
naman-bruno
2446301e41 use: button component (#6504)
* use: button component

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

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

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

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

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

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

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

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

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

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

---------

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

* chore: color and style improvements

* fix: tests

* fixes: tests

---------

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

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

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

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

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

* fix: handle additional context root paths for node vm

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

* fix: improve websocket message handling and serialization

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

fix: enhance message normalization in WebSocket client

---------

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

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

* refactor: remove console log from ResponsePane for cleaner code
2025-12-23 16:38:24 +05:30
lohit
4831434e37 fix: oauth2 url update (#6489) 2025-12-23 16:00:17 +05:30
Sanjai Kumar
87c8934c45 fix: update stringifyHttpRequest to handle response body content correctly (#6488) 2025-12-23 15:48:52 +05:30
Pooja
01d4d3dc2a fix: run formatResponse execution on copy button click (#6485) 2025-12-23 14:25:18 +05:30
151 changed files with 7925 additions and 1665 deletions

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'],

2478
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,11 @@
],
"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",

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

@@ -201,6 +201,15 @@ const Wrapper = styled.div`
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;

View File

@@ -20,11 +20,12 @@ 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 } from 'utils/common/platform';
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';
};
@@ -34,6 +35,8 @@ const AppTitleBar = () => {
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(() => {
@@ -54,13 +57,11 @@ const AppTitleBar = () => {
};
}, []);
// Check initial maximized state and listen for changes (Windows only)
useEffect(() => {
if (!isWindows) return;
if (!showWindowControls) return;
const { ipcRenderer } = window;
if (!ipcRenderer) return;
// Get initial state
ipcRenderer.invoke('renderer:window-is-maximized')
.then((maximized) => {
setIsMaximized(maximized);
@@ -69,7 +70,6 @@ const AppTitleBar = () => {
console.error('Error getting initial maximized state:', error);
});
// Listen for maximize/unmaximize events from main process
const removeMaximizedListener = ipcRenderer.on('main:window-maximized', () => {
setIsMaximized(true);
});
@@ -82,9 +82,8 @@ const AppTitleBar = () => {
removeMaximizedListener();
removeUnmaximizedListener();
};
}, [isWindows]);
}, [showWindowControls]);
// Window control handlers (Windows only) - these always work, even with modals open
const handleMinimize = useCallback(() => {
window.ipcRenderer?.send('renderer:window-minimize');
}, []);
@@ -300,7 +299,7 @@ const AppTitleBar = () => {
<ResponseLayoutToggle />
</div>
{isWindows && (
{showWindowControls && (
<div className="window-controls">
<button
className="window-control-btn minimize"

View File

@@ -228,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) {

View File

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

View File

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

View File

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

View File

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

@@ -4,6 +4,7 @@ import StyledWrapper from './StyledWrapper';
import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
import Button from 'ui/Button';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -122,9 +123,9 @@ const PresetsSettings = ({ collection }) => {
</div>
<div className="mt-6">
<button type="button" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="button" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</div>
</StyledWrapper>

View File

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

View File

@@ -8,6 +8,7 @@ import { updateCollectionProxy } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
const ProxySettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -358,9 +359,9 @@ const ProxySettings = ({ collection }) => {
/>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</div>
</StyledWrapper>

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
@@ -22,9 +23,9 @@ const Vars = ({ collection }) => {
<VarsTable collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -9,6 +9,7 @@ import ModifyCookieModal from 'components/Cookies/ModifyCookieModal/index';
import StyledWrapper from './StyledWrapper';
import moment from 'moment';
import { Tooltip } from 'react-tooltip';
import Button from 'ui/Button';
const ClearDomainCookiesModal = ({ onClose, domain, onClear }) => (
<Modal onClose={onClose} handleCancel={onClose} title="Clear Domain Cookies" hideFooter={true}>
@@ -22,14 +23,14 @@ const ClearDomainCookiesModal = ({ onClose, domain, onClear }) => (
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-close" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Close
</button>
</Button>
</div>
<div>
<button className="btn btn-sm btn-danger" onClick={onClear}>
<Button size="sm" color="danger" onClick={onClear}>
Clear All
</button>
</Button>
</div>
</div>
</Modal>
@@ -47,14 +48,14 @@ const DeleteCookieModal = ({ onClose, cookieName, onDelete }) => (
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-close" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Close
</button>
</Button>
</div>
<div>
<button className="btn btn-sm btn-danger" onClick={onDelete}>
<Button size="sm" color="danger" onClick={onDelete}>
Delete
</button>
</Button>
</div>
</div>
</Modal>
@@ -139,17 +140,18 @@ const CollectionProperties = ({ onClose }) => {
className="block textbox non-passphrase-input ml-auto font-normal"
autoFocus
/>
<button
<Button
type="submit"
className="submit btn btn-sm btn-secondary flex items-center gap-1 mx-4 font-medium"
size="sm"
className="mx-4"
icon={<IconCirclePlus strokeWidth={1.5} size={16} />}
onClick={(e) => {
e.stopPropagation();
handleAddCookie();
}}
>
<IconCirclePlus strokeWidth={1.5} size={16} />
<span>Add Cookie</span>
</button>
</Button>
</StyledWrapper>
) : null}
>

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import Button from 'ui/Button';
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
return (
@@ -25,17 +26,17 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
<Button size="sm" color="danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</Button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
</Button>
<Button size="sm" onClick={onSaveAndClose}>
Save
</button>
</Button>
</div>
</div>
</Modal>

View File

@@ -18,6 +18,7 @@ import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
import Button from 'ui/Button';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -211,9 +212,9 @@ const Auth = ({ collection, folder }) => {
</div>
{getAuthView()}
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -22,9 +23,9 @@ const Vars = ({ collection, folder }) => {
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
<Button type="submit" size="sm" onClick={handleSave}>
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

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

@@ -32,7 +32,7 @@ const DeleteWorkspace = ({ onClose, workspace }) => {
handleConfirm={onConfirm}
handleCancel={onClose}
confirmDisabled={isDeleting}
confirmButtonClass="btn-danger"
confirmButtonColor="danger"
>
<div className="flex items-center">
<IconFolder size={18} strokeWidth={1.5} />

View File

@@ -13,6 +13,7 @@ import RenameWorkspace from './RenameWorkspace';
import DeleteWorkspace from './DeleteWorkspace';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown/index';
import Button from 'ui/Button';
const ManageWorkspace = () => {
const dispatch = useDispatch();
@@ -84,10 +85,9 @@ const ManageWorkspace = () => {
</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>
<Button size="sm" onClick={() => setCreateWorkspaceModalOpen(true)} icon={<IconPlus size={14} strokeWidth={2} />}>
Create Workspace
</Button>
</div>
<div className="workspace-list">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import React, { useRef } from 'react';
import React, { useRef, useEffect, useCallback } from 'react';
import get from 'lodash/get';
import debounce from 'lodash/debounce';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
@@ -95,7 +96,7 @@ const General = ({ close }) => {
}
});
const handleSave = (newPreferences) => {
const handleSave = useCallback((newPreferences) => {
dispatch(
savePreferences({
...preferences,
@@ -123,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]);
@@ -366,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

@@ -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,7 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const ExampleNotFound = ({ exampleUid }) => {
const dispatch = useDispatch();
@@ -30,9 +31,9 @@ const ExampleNotFound = ({ exampleUid }) => {
This can occur when the example definition in your local file has been deleted or updated.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
<Button size="md" color="secondary" variant="ghost" onClick={closeTab}>
Close Tab
</button>
</Button>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const FolderNotFound = ({ folderUid }) => {
const dispatch = useDispatch();
@@ -32,9 +33,9 @@ const FolderNotFound = ({ folderUid }) => {
This can happen when the folder was renamed or deleted on your filesystem.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
<Button size="md" color="secondary" variant="ghost" onClick={closeTab}>
Close Tab
</button>
</Button>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const RequestNotFound = ({ itemUid }) => {
const dispatch = useDispatch();
@@ -36,9 +37,9 @@ const RequestNotFound = ({ itemUid }) => {
This can happen when the .bru file associated with this request was deleted on your filesystem.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
<Button size="md" color="secondary" variant="ghost" onClick={closeTab}>
Close Tab
</button>
</Button>
</div>
);
};

View File

@@ -38,7 +38,7 @@ 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;

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Button from 'ui/Button';
const ConfirmCollectionClose = ({ collection, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
return (
@@ -29,17 +30,17 @@ const ConfirmCollectionClose = ({ collection, onCancel, onCloseWithoutSave, onSa
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
<Button size="sm" color="danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</Button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
</Button>
<Button size="sm" onClick={onSaveAndClose}>
Save
</button>
</Button>
</div>
</div>
</Modal>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Button from 'ui/Button';
const ConfirmFolderClose = ({ folder, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
return (
@@ -29,17 +30,17 @@ const ConfirmFolderClose = ({ folder, onCancel, onCloseWithoutSave, onSaveAndClo
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
<Button size="sm" color="danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</Button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
</Button>
<Button size="sm" onClick={onSaveAndClose}>
Save
</button>
</Button>
</div>
</div>
</Modal>

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Button from 'ui/Button';
const ConfirmRequestClose = ({ item, example, onCancel, onCloseWithoutSave, onSaveAndClose }) => {
const isExample = !!example;
@@ -33,17 +34,17 @@ const ConfirmRequestClose = ({ item, example, onCancel, onCloseWithoutSave, onSa
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCloseWithoutSave}>
<Button size="sm" color="danger" onClick={onCloseWithoutSave}>
Don't Save
</button>
</Button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onCancel}>
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={onSaveAndClose}>
</Button>
<Button size="sm" onClick={onSaveAndClose}>
Save
</button>
</Button>
</div>
</div>
</Modal>

View File

@@ -17,7 +17,7 @@ import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnviron
import RequestTabNotFound from './RequestTabNotFound';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
import NewRequest from 'components/Sidebar/NewRequest/index';
import GradientCloseButton from './GradientCloseButton';
@@ -26,11 +26,12 @@ 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 RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow, dropdownContainerRef }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
const tabNameRef = useRef(null);
const tabLabelRef = useRef(null);
const lastOverflowStateRef = useRef(null);
const [showConfirmClose, setShowConfirmClose] = useState(false);
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
@@ -38,8 +39,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const menuDropdownRef = useRef();
const item = findItemInCollection(collection, tab.uid);
@@ -99,17 +99,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
};
const handleRightClick = (_event) => {
const menuDropdown = dropdownTippyRef.current;
if (!menuDropdown) {
return;
}
if (menuDropdown.state.isShown) {
menuDropdown.hide();
} else {
menuDropdown.show();
}
const handleRightClick = (event) => {
event.preventDefault();
event.stopPropagation();
menuDropdownRef.current?.show();
};
const handleMouseUp = (e) => {
@@ -383,6 +376,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
<div
ref={tabLabelRef}
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
onContextMenu={handleRightClick}
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
@@ -403,13 +397,13 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{item.name}
</span>
<RequestTabMenu
onDropdownCreate={onDropdownCreate}
menuDropdownRef={menuDropdownRef}
tabLabelRef={tabLabelRef}
tabIndex={tabIndex}
collectionRequestTabs={collectionRequestTabs}
tabItem={item}
collection={collection}
dropdownTippyRef={dropdownTippyRef}
dispatch={dispatch}
dropdownContainerRef={dropdownContainerRef}
/>
</div>
<GradientCloseButton
@@ -429,10 +423,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
};
function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, collection, dropdownTippyRef, dispatch }) {
function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, tabIndex, collection, dispatch, dropdownContainerRef }) {
const [showCloneRequestModal, setShowCloneRequestModal] = useState(false);
const [showAddNewRequestModal, setShowAddNewRequestModal] = useState(false);
// Returns the tab-label's position for dropdown positioning.
// Returns zero-sized rect if element isn't mounted yet (prevents Tippy errors).
const getTabLabelRect = () => {
if (!tabLabelRef.current) {
return { width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 };
}
return tabLabelRef.current.getBoundingClientRect();
};
const totalTabs = collectionRequestTabs.length || 0;
const currentTabUid = collectionRequestTabs[tabIndex]?.uid;
const currentTabItem = findItemInCollection(collection, currentTabUid);
@@ -442,10 +445,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
const hasRightTabs = totalTabs > tabIndex + 1;
const hasOtherTabs = totalTabs > 1;
async function handleCloseTab(event, tabUid) {
event.stopPropagation();
dropdownTippyRef.current.hide();
async function handleCloseTab(tabUid) {
if (!tabUid) {
return;
}
@@ -461,10 +461,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
} catch (err) { }
}
function handleRevertChanges(event) {
event.stopPropagation();
dropdownTippyRef.current.hide();
function handleRevertChanges() {
if (!currentTabUid) {
return;
}
@@ -480,40 +477,96 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
} catch (err) { }
}
function handleCloseOtherTabs(event) {
dropdownTippyRef.current.hide();
async function handleCloseOtherTabs() {
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
otherTabs.forEach((tab) => handleCloseTab(event, tab.uid));
await Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid)));
}
function handleCloseTabsToTheLeft(event) {
dropdownTippyRef.current.hide();
async function handleCloseTabsToTheLeft() {
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
leftTabs.forEach((tab) => handleCloseTab(event, tab.uid));
await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid)));
}
function handleCloseTabsToTheRight(event) {
dropdownTippyRef.current.hide();
async function handleCloseTabsToTheRight() {
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
rightTabs.forEach((tab) => handleCloseTab(event, tab.uid));
await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid)));
}
function handleCloseSavedTabs(event) {
event.stopPropagation();
function handleCloseSavedTabs() {
const items = flattenItems(collection?.items);
const savedTabs = items?.filter?.((item) => !hasRequestChanges(item));
const savedTabIds = savedTabs?.map((item) => item.uid) || [];
dispatch(closeTabs({ tabUids: savedTabIds }));
}
function handleCloseAllTabs(event) {
collectionRequestTabs.forEach((tab) => handleCloseTab(event, tab.uid));
async function handleCloseAllTabs() {
await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid)));
}
const menuItems = useMemo(() => [
{
id: 'new-request',
label: 'New Request',
onClick: () => setShowAddNewRequestModal(true)
},
{
id: 'clone-request',
label: 'Clone Request',
onClick: () => setShowCloneRequestModal(true)
},
{
id: 'revert-changes',
label: 'Revert Changes',
onClick: handleRevertChanges,
disabled: !currentTabItem?.draft
},
{
id: 'close',
label: 'Close',
onClick: () => handleCloseTab(currentTabUid)
},
{
id: 'close-others',
label: 'Close Others',
onClick: handleCloseOtherTabs,
disabled: !hasOtherTabs
},
{
id: 'close-left',
label: 'Close to the Left',
onClick: handleCloseTabsToTheLeft,
disabled: !hasLeftTabs
},
{
id: 'close-right',
label: 'Close to the Right',
onClick: handleCloseTabsToTheRight,
disabled: !hasRightTabs
},
{
id: 'close-saved',
label: 'Close Saved',
onClick: handleCloseSavedTabs
},
{
id: 'close-all',
label: 'Close All',
onClick: handleCloseAllTabs
}
], [currentTabUid, currentTabItem, hasOtherTabs, hasLeftTabs, hasRightTabs, collection, collectionRequestTabs, tabIndex, dispatch]);
const menuDropdown = (
<MenuDropdown
ref={menuDropdownRef}
items={menuItems}
placement="bottom-start"
appendTo={dropdownContainerRef?.current || document.body}
getReferenceClientRect={getTabLabelRect}
>
<span></span>
</MenuDropdown>
);
return (
<Fragment>
{showAddNewRequestModal && (
@@ -528,51 +581,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
/>
)}
<Dropdown onCreate={onDropdownCreate} icon={<span></span>} placement="bottom-start">
<button
className="dropdown-item w-full"
onClick={() => {
dropdownTippyRef.current.hide();
setShowAddNewRequestModal(true);
}}
>
New Request
</button>
<button
className="dropdown-item w-full"
onClick={() => {
dropdownTippyRef.current.hide();
setShowCloneRequestModal(true);
}}
>
Clone Request
</button>
<button
className="dropdown-item w-full"
onClick={handleRevertChanges}
disabled={!currentTabItem?.draft}
>
Revert Changes
</button>
<button className="dropdown-item w-full" onClick={(e) => handleCloseTab(e, currentTabUid)}>
Close
</button>
<button disabled={!hasOtherTabs} className="dropdown-item w-full" onClick={handleCloseOtherTabs}>
Close Others
</button>
<button disabled={!hasLeftTabs} className="dropdown-item w-full" onClick={handleCloseTabsToTheLeft}>
Close to the Left
</button>
<button disabled={!hasRightTabs} className="dropdown-item w-full" onClick={handleCloseTabsToTheRight}>
Close to the Right
</button>
<button className="dropdown-item w-full" onClick={handleCloseSavedTabs}>
Close Saved
</button>
<button className="dropdown-item w-full" onClick={handleCloseAllTabs}>
Close All
</button>
</Dropdown>
{menuDropdown}
</Fragment>
);
}

View File

@@ -17,6 +17,7 @@ const RequestTabs = () => {
const dispatch = useDispatch();
const tabsRef = useRef();
const scrollContainerRef = useRef();
const collectionTabsRef = useRef();
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [tabOverflowStates, setTabOverflowStates] = useState({});
const [showChevrons, setShowChevrons] = useState(false);
@@ -115,7 +116,7 @@ const RequestTabs = () => {
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
<CollectionToolBar collection={activeCollection} />
<div className="flex items-center pl-2">
<div className="flex items-center pl-2" ref={collectionTabsRef}>
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={leftSlide}>
@@ -158,6 +159,7 @@ const RequestTabs = () => {
folderUid={tab.folderUid}
hasOverflow={tabOverflowStates[tab.uid]}
setHasOverflow={createSetHasOverflow(tab.uid)}
dropdownContainerRef={collectionTabsRef}
/>
</DraggableTab>
);

View File

@@ -2,16 +2,14 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.caret {
fill: currentColor;
color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
fill: ${(props) => props.theme.app.collection.toolbar.environmentSelector.caret};
}
.button-dropdown-button {
color: ${(props) => props.theme.dropdown.primaryText};
border-color: ${(props) => props.theme.workspace.border};
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
}
.dropdown-divider {
@@ -24,6 +22,10 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.yellow};
}
.icon-muted {
color: ${(props) => props.theme.colors.text.muted};
}
.preview-response-tab-label {
color: ${(props) => props.theme.colors.text.muted};
}

View File

@@ -1,11 +1,22 @@
import React, { forwardRef } from 'react';
import { IconEye, IconCaretDown } from '@tabler/icons';
import React, { forwardRef, useState } from 'react';
import { IconEye, IconCaretDown, IconBraces, IconCode, IconFileCode, IconBrandJavascript, IconFileText, IconHexagons, IconBinaryTree } from '@tabler/icons';
import classnames from 'classnames';
import MenuDropdown from 'ui/MenuDropdown';
import ToggleSwitch from 'components/ToggleSwitch';
import StyledWrapper from './StyledWrapper';
const ButtonIcon = forwardRef(({ disabled, className, style, prefix, selectedLabel, suffix, ...props }, ref) => {
// Icon mapping for format options
const FORMAT_ICONS = {
json: IconBraces,
html: IconCode,
xml: IconFileCode,
javascript: IconBrandJavascript,
raw: IconFileText,
hex: IconHexagons,
base64: IconBinaryTree
};
const ButtonIcon = forwardRef(({ disabled, className, style, prefix, selectedLabel, suffix, isActive, ...props }, ref) => {
return (
<button
ref={ref}
@@ -20,10 +31,10 @@ const ButtonIcon = forwardRef(({ disabled, className, style, prefix, selectedLab
role="button"
{...props}
>
{prefix && <span>{prefix}</span>}
<span className="active">{selectedLabel}</span>
{prefix && <span className={isActive ? 'active' : 'icon-muted'}>{prefix}</span>}
<span>{selectedLabel}</span>
{suffix && <span>{suffix}</span>}
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
{isActive && <IconCaretDown className="caret ml-0.5" size={12} strokeWidth={2} />}
</button>
);
});
@@ -34,8 +45,21 @@ const QueryResultTypeSelector = ({
formatValue,
onFormatChange,
onPreviewTabSelect,
selectedTab
selectedTab,
isActiveTab,
onTabSelect
}) => {
const [dropdownOpen, setDropdownOpen] = useState(false);
// Handle dropdown state change - only allow opening when active tab
const handleDropdownChange = (open) => {
if (!isActiveTab && open) {
// First click when not active - select this tab, don't open dropdown
onTabSelect?.();
return;
}
setDropdownOpen(open);
};
// Find the selected item's label
const findSelectedLabel = () => {
if (formatValue != null) {
@@ -47,10 +71,26 @@ const QueryResultTypeSelector = ({
const selectedLabel = findSelectedLabel();
// Enhance items with onChange handler
// Get the icon for the currently selected format
const SelectedFormatIcon = FORMAT_ICONS[formatValue];
// Determine the prefix icon - eye icon when in preview mode, format icon otherwise
const getPrefixIcon = () => {
if (selectedTab === 'preview') {
return <IconEye size={14} strokeWidth={2} />;
}
if (SelectedFormatIcon) {
return <SelectedFormatIcon size={14} strokeWidth={1.5} />;
}
return null;
};
// Enhance items with onChange handler and icons
const enhancedItems = formatOptions.map((item) => {
const IconComponent = FORMAT_ICONS[item.id];
return {
...item,
leftSection: IconComponent ? <IconComponent size={14} strokeWidth={1.5} /> : null,
onClick: () => {
if (onFormatChange) {
onFormatChange(item.id);
@@ -67,7 +107,7 @@ const QueryResultTypeSelector = ({
handleToggle={(e) => {
e.preventDefault();
// e.stopPropagation();
onPreviewTabSelect();
onPreviewTabSelect(selectedTab === 'preview' ? 'editor' : 'preview');
}}
size="2xs"
data-testid="preview-response-tab"
@@ -77,7 +117,7 @@ const QueryResultTypeSelector = ({
);
return (
<StyledWrapper>
<StyledWrapper className={isActiveTab ? 'tab-active' : ''}>
<MenuDropdown
items={enhancedItems}
header={header}
@@ -85,12 +125,15 @@ const QueryResultTypeSelector = ({
showTickMark={true}
placement="bottom-end"
data-testid="format-response-tab"
opened={dropdownOpen}
onChange={handleDropdownChange}
>
<ButtonIcon
selectedLabel={selectedLabel}
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
prefix={getPrefixIcon()}
isActive={isActiveTab}
disabled={false}
className="h-[20px] text-[11px]"
className="h-[22px] text-[10px]"
data-testid="format-response-tab"
/>
</MenuDropdown>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef, useMemo } from 'react';
import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef, useCallback } from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { IconCopy, IconCheck } from '@tabler/icons';
@@ -6,22 +6,23 @@ import classnames from 'classnames';
import ActionIcon from 'ui/ActionIcon/index';
import { formatResponse } from 'utils/common';
// Helper function to get text to copy
const getTextToCopy = (selectedTab, selectedFormat, data, dataBuffer) => {
// If preview is on, copy raw data (what's shown in TextPreview)
if (selectedTab === 'preview') {
return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
}
// If editor is on, copy formatted data based on selected format
if (selectedFormat && data && dataBuffer) {
return formatResponse(data, dataBuffer, selectedFormat, null);
}
return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
};
// Hook to get copy response function
export const useResponseCopy = (item, selectedFormat, selectedTab, data, dataBuffer) => {
const [copied, setCopied] = useState(false);
const textToCopy = useMemo(() => {
// If preview is on, copy raw data (what's shown in TextPreview)
if (selectedTab === 'preview') {
return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
}
// If editor is on, copy formatted data based on selected format
if (selectedFormat && data && dataBuffer) {
return formatResponse(data, dataBuffer, selectedFormat, null);
}
return typeof data === 'string' ? data : JSON.stringify(data, null, 2);
}, [data, dataBuffer, selectedFormat, selectedTab]);
useEffect(() => {
if (copied) {
const timer = setTimeout(() => {
@@ -31,15 +32,16 @@ export const useResponseCopy = (item, selectedFormat, selectedTab, data, dataBuf
}
}, [copied]);
const copyResponse = async () => {
const copyResponse = useCallback(async () => {
try {
const textToCopy = getTextToCopy(selectedTab, selectedFormat, data, dataBuffer);
await navigator.clipboard.writeText(textToCopy);
toast.success('Response copied to clipboard');
setCopied(true);
} catch (error) {
toast.error('Failed to copy response');
}
};
}, [selectedTab, selectedFormat, data, dataBuffer]);
return { copyResponse, copied, hasData: !!data };
};

View File

@@ -80,6 +80,33 @@ const StyledWrapper = styled.div`
border-left: 1px solid ${(props) => props.theme.preferences.sidebar.border};
margin: 0 8px;
}
.result-view-tabs {
display: flex;
align-items: center;
gap: 2px;
padding: 3px;
border-radius: 8px;
.button-dropdown-button {
border: 1px solid transparent !important;
background-color: transparent;
border-radius: 5px;
font-size: ${(props) => props.theme.font.size.sm};
&:hover {
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.border} !important;
}
}
.tab-active .button-dropdown-button {
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.border} !important;
&:hover {
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder} !important;
}
}
}
`;
export default StyledWrapper;

View File

@@ -26,7 +26,7 @@ import WSMessagesList from './WsResponsePane/WSMessagesList';
import ResponsiveTabs from 'ui/ResponsiveTabs';
// Width threshold for expanded right-side action buttons
const RIGHT_CONTENT_EXPANDED_WIDTH = 375;
const RIGHT_CONTENT_EXPANDED_WIDTH = 135;
const ResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -68,10 +68,9 @@ const ResponsePane = ({ item, collection }) => {
dispatch(updateResponseFormat({ uid: item.uid, responseFormat: newFormat }));
}, [dispatch, item.uid]);
const handleViewTabToggle = useCallback(() => {
const newViewTab = selectedViewTab === 'editor' ? 'preview' : 'editor';
const handleViewTabChange = useCallback((newViewTab) => {
dispatch(updateResponseViewTab({ uid: item.uid, responseViewTab: newViewTab }));
}, [dispatch, item.uid, selectedViewTab]);
}, [dispatch, item.uid]);
const requestTimeline = ([...(collection.timeline || [])]).filter((obj) => {
if (obj.itemUid === item.uid) return true;
@@ -226,15 +225,24 @@ const ResponsePane = ({ item, collection }) => {
onClick={() => setShowScriptErrorCard(true)}
/>
)}
{focusedTab?.responsePaneTab === 'response' ? (
{focusedTab?.responsePaneTab === 'response' && item?.response ? (
<>
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={handleFormatChange}
onPreviewTabSelect={handleViewTabToggle}
selectedTab={selectedViewTab}
/>
{/* Result View Tabs (Visualizations + Response Format) */}
<div className="result-view-tabs">
{/* Response Format */}
<QueryResultTypeSelector
formatOptions={previewFormatOptions}
formatValue={selectedFormat}
onFormatChange={handleFormatChange}
onPreviewTabSelect={handleViewTabChange}
selectedTab={selectedViewTab}
isActiveTab={selectedViewTab === 'editor' || selectedViewTab === 'preview'}
onTabSelect={() => {
handleViewTabChange('editor');
}}
/>
</div>
</>
) : null}
<div className="flex items-center response-pane-status">

View File

@@ -3,6 +3,7 @@ import { saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collec
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
const SecuritySettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -71,9 +72,9 @@ const SecuritySettings = ({ collection }) => {
JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
</p>
</div>
<button onClick={handleSave} className="submit btn btn-sm btn-secondary w-fit mt-6">
<Button size="sm" onClick={handleSave} className="w-fit mt-6">
Save
</button>
</Button>
</div>
</StyledWrapper>
);

View File

@@ -26,6 +26,21 @@ const StyledWrapper = styled.div`
}
}
}
.share-button {
display: flex;
border-radius: ${(props) => props.theme.border.radius.base};
padding: 10px;
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
background-color: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: all 0.1s ease;
&:hover {
background-color: ${(props) => props.theme.listItem.hoverBg};
}
}
`;
export default StyledWrapper;

View File

@@ -1,10 +1,11 @@
import React, { useMemo } from 'react';
import Modal from 'components/Modal';
import { IconUpload, IconLoader2, IconAlertTriangle } from '@tabler/icons';
import { IconUpload, IconLoader2, IconAlertTriangle, IconFileExport } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Bruno from 'components/Bruno';
import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import exportOpenCollection from 'utils/exporters/opencollection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
@@ -48,6 +49,12 @@ const ShareCollection = ({ onClose, collectionUid }) => {
onClose();
};
const handleExportOpenCollection = () => {
const collectionCopy = cloneDeep(collection);
exportOpenCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
onClose();
};
return (
<Modal
size="md"
@@ -60,10 +67,10 @@ const ShareCollection = ({ onClose, collectionUid }) => {
<StyledWrapper className="flex flex-col h-full w-[500px]">
<div className="space-y-2">
<div
className={`flex border border-gray-200 dark:border-gray-600 items-center p-3 rounded-lg transition-colors ${
className={`share-button ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportBrunoCollection}
>
@@ -77,10 +84,31 @@ const ShareCollection = ({ onClose, collectionUid }) => {
</div>
<div
className={`flex flex-col border border-gray-200 dark:border-gray-600 items-center rounded-lg transition-colors ${
className={`share-button ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-100 dark:hover:bg-gray-500/10 cursor-pointer'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportOpenCollection}
>
<div className="mr-3 p-1 rounded-full">
{isCollectionLoading ? (
<IconLoader2 size={28} className="animate-spin" />
) : (
<IconFileExport size={28} strokeWidth={1} />
)}
</div>
<div className="flex-1">
<div className="font-medium">OpenCollection</div>
<div className="text-xs">{isCollectionLoading ? 'Loading collection...' : 'Export in OpenCollection format'}</div>
</div>
</div>
<div
className={`flex !flex-col share-button ${
isCollectionLoading
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer'
}`}
onClick={isCollectionLoading ? undefined : handleExportPostmanCollection}
>

View File

@@ -26,7 +26,7 @@ const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
confirmButtonClass="btn-danger"
confirmButtonColor="danger"
>
Are you sure you want to delete the example <span className="font-medium">{example.name}</span>?
</Modal>

View File

@@ -37,7 +37,6 @@ import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage, hideApiSpecPage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
@@ -48,14 +47,16 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
import { calculateDraggedItemNewPathname, getInitialExampleName } from 'utils/collections/index';
import { calculateDraggedItemNewPathname, getInitialExampleName, findParentItemInCollection } from 'utils/collections/index';
import { sortByNameThenSequence } from 'utils/common/index';
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
import ActionIcon from 'ui/ActionIcon';
import MenuDropdown from 'ui/MenuDropdown';
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
const { dropdownContainerRef } = useSidebarAccordion();
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
@@ -214,8 +215,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
dispatch(hideApiSpecPage());
if (isTabForItemPresent) {
dispatch(
focusTab({
@@ -233,8 +232,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
})
);
} else {
dispatch(hideHomePage());
dispatch(hideApiSpecPage());
dispatch(
addTab({
uid: item.uid,
@@ -538,13 +535,14 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
};
const handlePasteItem = () => {
// Only allow paste into folders
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
let targetFolderUid = item.uid;
if (!isFolder) {
toast.error('Paste is only available for folders');
return;
const parentFolder = findParentItemInCollection(collection, item.uid);
targetFolderUid = parentFolder ? parentFolder.uid : null;
}
dispatch(pasteItem(collectionUid, item.uid))
dispatch(pasteItem(collectionUid, targetFolderUid))
.then(() => {
toast.success('Item pasted successfully');
})
@@ -644,8 +642,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<ActionIcon style={{ width: 16, minWidth: 16 }}>
{isFolder ? (
{isFolder ? (
<ActionIcon style={{ width: 16, minWidth: 16 }}>
<IconChevronRight
size={16}
strokeWidth={2}
@@ -655,7 +654,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onDoubleClick={handleFolderDoubleClick}
data-testid="folder-chevron"
/>
) : hasExamples ? (
</ActionIcon>
) : hasExamples ? (
<ActionIcon style={{ width: 16, minWidth: 16 }}>
<IconChevronRight
size={16}
strokeWidth={2}
@@ -665,8 +666,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onDoubleClick={handleExamplesDoubleClick}
data-testid="request-item-chevron"
/>
) : null}
</ActionIcon>
</ActionIcon>
) : null}
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
@@ -680,6 +682,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
items={buildMenuItems()}
placement="bottom-start"
data-testid="collection-item-menu"
popperOptions={{ strategy: 'fixed' }}
appendTo={dropdownContainerRef?.current || document.body}
>
<ActionIcon className="menu-icon">
<IconDots size={18} className="collection-item-menu-icon" />

View File

@@ -9,6 +9,7 @@ import { removeCollection } from 'providers/ReduxStore/slices/collections/action
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) => {
const MAX_UNSAVED_REQUESTS_TO_SHOW = 5;
@@ -102,17 +103,17 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={handleDiscardAll}>
<Button size="sm" color="danger" onClick={handleDiscardAll}>
Discard and Remove
</button>
</Button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={handleSaveAll}>
</Button>
<Button size="sm" onClick={handleSaveAll}>
{currentDrafts.length > 1 ? 'Save All and Remove' : 'Save and Remove'}
</button>
</Button>
</div>
</div>
</Modal>

View File

@@ -0,0 +1,26 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.collection-info-card {
background-color: ${(props) => props.theme.modal.title.bg};
border-radius: 4px;
padding: 12px;
}
.collection-name {
font-weight: 500;
padding-left: 0 !important;
color: ${(props) => props.theme.text};
margin-bottom: 4px;
cursor: default !important;
&:hover {
background: none !important;
}
}
.collection-path {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
word-break: break-all;
}
`;
export default StyledWrapper;

View File

@@ -2,11 +2,12 @@ import React, { useMemo } from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { IconAlertCircle } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges } from 'utils/collections/index';
import filter from 'lodash/filter';
import ConfirmCollectionCloseDrafts from './ConfirmCollectionCloseDrafts';
import StyledWrapper from './StyledWrapper';
const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
@@ -42,21 +43,35 @@ const RemoveCollection = ({ onClose, collectionUid }) => {
return <ConfirmCollectionCloseDrafts onClose={onClose} collection={collection} collectionUid={collectionUid} />;
}
const customHeader = (
<div className="flex items-center gap-2" data-testid="close-collection-modal-title">
<IconAlertCircle size={18} strokeWidth={1.5} className="text-red-500" />
<span>Remove Collection</span>
</div>
);
// Otherwise, show the standard remove confirmation modal
return (
<Modal size="sm" title="Remove Collection" confirmText="Remove" handleConfirm={onConfirm} handleCancel={onClose}>
<div className="flex items-center">
<IconFiles size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection.name}</span>
</div>
<div className="break-words text-xs mt-1">{collection.pathname}</div>
<div className="mt-4">
Are you sure you want to remove collection <span className="font-medium">{collection.name}</span> from this workspace?
</div>
<div className="mt-4 text-muted">
The collection files will remain on disk and can be re-added to this or another workspace later.
</div>
</Modal>
<StyledWrapper>
<Modal
size="sm"
title="Remove Collection"
customHeader={customHeader}
confirmText="Remove"
confirmButtonColor="danger"
handleConfirm={onConfirm}
handleCancel={onClose}
>
<p className="mb-4">Are you sure you want to close following collection in Bruno?</p>
<div className="collection-info-card">
<div className="collection-name">{collection.name}</div>
<div className="collection-path">{collection.pathname}</div>
</div>
<p className="mt-4 text-muted text-sm">
It will still be available in the filesystem at the above location and can be re-opened later.
</p>
</Modal>
</StyledWrapper>
);
};

View File

@@ -24,7 +24,6 @@ import {
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder, saveCollectionSecurityConfig } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { hideApiSpecPage, hideHomePage } from 'providers/ReduxStore/slices/app';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import toast from 'react-hot-toast';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -46,8 +45,10 @@ import { sortByNameThenSequence } from 'utils/common/index';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
import ActionIcon from 'ui/ActionIcon';
import MenuDropdown from 'ui/MenuDropdown';
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
const Collection = ({ collection, searchText }) => {
const { dropdownContainerRef } = useSidebarAccordion();
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
@@ -111,8 +112,6 @@ const Collection = ({ collection, searchText }) => {
}
if (!isChevronClick) {
dispatch(hideHomePage()); // @TODO Playwright tests are often stuck on home page, rather than collection settings tab. Revisit for a proper fix.
dispatch(hideApiSpecPage());
dispatch(
addTab({
uid: collection.uid,
@@ -437,6 +436,8 @@ const Collection = ({ collection, searchText }) => {
ref={menuDropdownRef}
items={menuItems}
placement="bottom-start"
appendTo={dropdownContainerRef?.current || document.body}
popperOptions={{ strategy: 'fixed' }}
data-testid="collection-actions"
>
<ActionIcon className="collection-actions">

View File

@@ -19,6 +19,7 @@ import {
} from 'utils/collections/index';
import { IconAlertTriangle } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
const MAX_COLLECTIONS_WIDTH = 530;
const CHARACTER_WIDTH = 8;
@@ -226,17 +227,17 @@ const RemoveCollectionsModal = ({ collectionUids, onClose }) => {
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={handleDiscard}>
<Button size="sm" color="danger" onClick={handleDiscard}>
Discard and Close
</button>
</Button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={handleCancel}>
<Button size="sm" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={handleSave}>
</Button>
<Button size="sm" onClick={handleSave}>
Save and Close
</button>
</Button>
</div>
</div>
</>
@@ -255,7 +256,7 @@ const RemoveCollectionsModal = ({ collectionUids, onClose }) => {
Collections will be removed from the current workspace but will still be available in the file system and can be re-opened later.
</div>
<div className="flex justify-end mt-6">
<button className="btn btn-close btn-sm mr-2" onClick={handleCancel}>
<button className="btn btn-close btn-sm mr-2" data-testid="modal-close-button" onClick={handleCancel}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={handleCloseAllCollections}>

View File

@@ -18,6 +18,7 @@ import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
import get from 'lodash/get';
import Button from 'ui/Button';
const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) => {
const inputRef = useRef();
@@ -319,17 +320,14 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
</div>
<div className="flex justify-end">
<span className="mr-2">
<button type="button" onClick={onClose} className="btn btn-md btn-close">
<Button type="button" size="sm" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</button>
</Button>
</span>
<span>
<button
type="submit"
className="submit btn btn-md btn-secondary"
>
<Button type="submit" size="sm">
Create
</button>
</Button>
</span>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
import { isBrunoCollection } from 'utils/importers/bruno-collection';
import { isOpenCollection } from 'utils/importers/opencollection';
import FullscreenLoader from './FullscreenLoader/index';
const convertFileToObject = async (file) => {
@@ -72,6 +73,8 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
type = 'postman';
} else if (isInsomniaCollection(data)) {
type = 'insomnia';
} else if (isOpenCollection(data)) {
type = 'opencollection';
} else if (isBrunoCollection(data)) {
type = 'bruno';
} else {
@@ -159,7 +162,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Supports Bruno, Postman, Insomnia, OpenAPI v3, and WSDL formats
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats
</p>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import { postmanToBruno } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { processOpenCollection } from 'utils/importers/opencollection';
import { wsdlToBruno } from '@usebruno/converters';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
@@ -37,6 +38,8 @@ const getCollectionName = (format, rawData) => {
return rawData.name || 'Insomnia Collection';
case 'bruno':
return rawData.name || 'Bruno Collection';
case 'opencollection':
return rawData.info?.name || 'OpenCollection';
case 'wsdl':
return 'WSDL Collection';
default:
@@ -65,6 +68,9 @@ const convertCollection = async (format, rawData, groupingType) => {
case 'bruno':
collection = await processBrunoCollection(rawData);
break;
case 'opencollection':
collection = await processOpenCollection(rawData);
break;
default:
throw new Error('Unknown collection format');
}

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
const SidebarAccordionContext = createContext();
@@ -12,6 +12,7 @@ export const useSidebarAccordion = () => {
export const SidebarAccordionProvider = ({ children, defaultExpanded = ['collections'] }) => {
const [expandedSections, setExpandedSections] = useState(new Set(defaultExpanded));
const dropdownContainerRef = useRef(null);
const toggleSection = useCallback((sectionId) => {
setExpandedSections((prev) => {
@@ -52,10 +53,13 @@ export const SidebarAccordionProvider = ({ children, defaultExpanded = ['collect
toggleSection,
setSectionExpanded,
isExpanded,
getExpandedCount
getExpandedCount,
dropdownContainerRef
}}
>
{children}
<div ref={dropdownContainerRef}>
{children}
</div>
</SidebarAccordionContext.Provider>
);
};

View File

@@ -169,8 +169,10 @@ class SingleLineEditor extends Component {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
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);
// Update newline markers after value change
if (this.props.showNewlineArrow) {

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import { createPortal } from 'react-dom';
import Button from 'ui/Button';
const ConfirmSwitchEnv = ({ onCancel }) => {
const modalContent = (
@@ -26,9 +27,9 @@ const ConfirmSwitchEnv = ({ onCancel }) => {
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={onCancel}>
<Button size="sm" color="danger" onClick={onCancel}>
Close
</button>
</Button>
</div>
<div></div>
</div>

View File

@@ -74,26 +74,6 @@ const Wrapper = styled.div`
}
}
.btn-add-param {
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: 11px !important;
max-width: 200px !important;
@@ -123,65 +103,11 @@ const Wrapper = styled.div`
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

@@ -17,6 +17,7 @@ import {
} from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import Button from 'ui/Button';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const dispatch = useDispatch();
@@ -425,14 +426,14 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</table>
</div>
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
<div className="button-container mt-5">
<div className="flex items-center gap-2">
<Button type="submit" size="sm" onClick={handleSave} data-testid="save-env">
Save
</button>
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
</Button>
<Button type="reset" size="sm" color="secondary" variant="ghost" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</Button>
</div>
</div>
</StyledWrapper>

View File

@@ -3,7 +3,6 @@ import { useSelector, useDispatch } from 'react-redux';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots } from '@tabler/icons';
import { removeCollectionFromWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { normalizePath } from 'utils/common/path';
import toast from 'react-hot-toast';
@@ -117,8 +116,6 @@ const CollectionsList = ({ workspace }) => {
})
);
dispatch(hideHomePage());
dispatch(
addTab({
uid: collection.uid,

View File

@@ -12,6 +12,7 @@ import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions
import { multiLineMsg } from 'utils/common/index';
import { formatIpcError } from 'utils/common/error';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import Button from 'ui/Button';
const CreateWorkspace = ({ onClose }) => {
const inputRef = useRef();
@@ -188,7 +189,7 @@ const CreateWorkspace = ({ onClose }) => {
</p>
</Help>
</label>
<div className="flex gap-2">
<div className="flex gap-2 items-center">
<input
id="workspace-location"
type="text"
@@ -202,9 +203,9 @@ const CreateWorkspace = ({ onClose }) => {
value={formik.values.workspaceLocation || ''}
onClick={browse}
/>
<button type="button" className="btn btn-sm btn-secondary" onClick={browse}>
<Button type="button" onClick={browse}>
Browse
</button>
</Button>
</div>
{formik.touched.workspaceLocation && formik.errors.workspaceLocation ? (
<div className="text-red-500 text-sm mt-1">{formik.errors.workspaceLocation}</div>

View File

@@ -10,6 +10,7 @@ import { importWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/ac
import { formatIpcError } from 'utils/common/error';
import { multiLineMsg } from 'utils/common/index';
import Help from 'components/Help';
import Button from 'ui/Button';
const ImportWorkspace = ({ onClose }) => {
const dispatch = useDispatch();
@@ -209,7 +210,7 @@ const ImportWorkspace = ({ onClose }) => {
</p>
</Help>
</label>
<div className="flex gap-2 mt-2">
<div className="flex gap-2 mt-2 items-center">
<input
id="workspace-location"
type="text"
@@ -224,9 +225,9 @@ const ImportWorkspace = ({ onClose }) => {
value={formik.values.workspaceLocation || ''}
onClick={browse}
/>
<button type="button" className="btn btn-sm btn-secondary" onClick={browse}>
<Button type="button" onClick={browse}>
Browse
</button>
</Button>
</div>
{formik.touched.workspaceLocation && formik.errors.workspaceLocation ? (
<div className="text-red-500 text-sm mt-1">{formik.errors.workspaceLocation}</div>

View File

@@ -10,6 +10,7 @@ import { completeQuitFlow } from 'providers/ReduxStore/slices/app';
import { saveMultipleRequests, saveMultipleCollections, saveMultipleFolders } from 'providers/ReduxStore/slices/collections/actions';
import { IconAlertTriangle } from '@tabler/icons';
import Modal from 'components/Modal';
import Button from 'ui/Button';
const SaveRequestsModal = ({ onClose }) => {
const MAX_UNSAVED_ITEMS_TO_SHOW = 5;
@@ -157,17 +158,17 @@ const SaveRequestsModal = ({ onClose }) => {
<div className="flex justify-between mt-6">
<div>
<button className="btn btn-sm btn-danger" onClick={closeWithoutSave}>
<Button size="sm" color="danger" onClick={closeWithoutSave}>
Don't Save
</button>
</Button>
</div>
<div>
<button className="btn btn-close btn-sm mr-2" onClick={onClose}>
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
Cancel
</button>
<button className="btn btn-secondary btn-sm" onClick={closeWithSave}>
</Button>
<Button size="sm" onClick={closeWithSave}>
{totalDraftsCount > 1 ? 'Save All' : 'Save'}
</button>
</Button>
</div>
</div>
</Modal>

View File

@@ -9,19 +9,23 @@ const actionsToIntercept = [
'collections/moveQueryParam',
'collections/updateQueryParam',
'collections/deleteQueryParam',
'collections/setQueryParams',
'collections/updatePathParam',
'collections/addRequestHeader',
'collections/updateRequestHeader',
'collections/deleteRequestHeader',
'collections/moveRequestHeader',
'collections/setRequestHeaders',
'collections/addFormUrlEncodedParam',
'collections/updateFormUrlEncodedParam',
'collections/deleteFormUrlEncodedParam',
'collections/moveFormUrlEncodedParam',
'collections/setFormUrlEncodedParams',
'collections/addMultipartFormParam',
'collections/updateMultipartFormParam',
'collections/deleteMultipartFormParam',
'collections/moveMultipartFormParam',
'collections/setMultipartFormParams',
'collections/updateRequestAuthMode',
'collections/updateRequestBodyMode',
'collections/updateRequestBody',
@@ -47,9 +51,11 @@ const actionsToIntercept = [
'collections/addFolderHeader',
'collections/updateFolderHeader',
'collections/deleteFolderHeader',
'collections/setFolderHeaders',
'collections/addFolderVar',
'collections/updateFolderVar',
'collections/deleteFolderVar',
'collections/setFolderVars',
'collections/updateFolderRequestScript',
'collections/updateFolderResponseScript',
'collections/updateFolderTests',
@@ -61,9 +67,11 @@ const actionsToIntercept = [
'collections/addCollectionHeader',
'collections/updateCollectionHeader',
'collections/deleteCollectionHeader',
'collections/setCollectionHeaders',
'collections/addCollectionVar',
'collections/updateCollectionVar',
'collections/deleteCollectionVar',
'collections/setCollectionVars',
'collections/updateCollectionAuth',
'collections/updateCollectionAuthMode',
'collections/updateCollectionRequestScript',

View File

@@ -8,19 +8,23 @@ const actionsToIntercept = [
'collections/moveQueryParam',
'collections/updateQueryParam',
'collections/deleteQueryParam',
'collections/setQueryParams',
'collections/updatePathParam',
'collections/addRequestHeader',
'collections/updateRequestHeader',
'collections/deleteRequestHeader',
'collections/moveRequestHeader',
'collections/setRequestHeaders',
'collections/addFormUrlEncodedParam',
'collections/updateFormUrlEncodedParam',
'collections/deleteFormUrlEncodedParam',
'collections/moveFormUrlEncodedParam',
'collections/setFormUrlEncodedParams',
'collections/addMultipartFormParam',
'collections/updateMultipartFormParam',
'collections/deleteMultipartFormParam',
'collections/moveMultipartFormParam',
'collections/setMultipartFormParams',
'collections/updateRequestAuthMode',
'collections/updateRequestBodyMode',
'collections/updateRequestBody',
@@ -45,9 +49,11 @@ const actionsToIntercept = [
'collections/addFolderHeader',
'collections/updateFolderHeader',
'collections/deleteFolderHeader',
'collections/setFolderHeaders',
'collections/addFolderVar',
'collections/updateFolderVar',
'collections/deleteFolderVar',
'collections/setFolderVars',
'collections/updateFolderRequestScript',
'collections/updateFolderResponseScript',
'collections/updateFolderTests',
@@ -59,9 +65,11 @@ const actionsToIntercept = [
'collections/addCollectionHeader',
'collections/updateCollectionHeader',
'collections/deleteCollectionHeader',
'collections/setCollectionHeaders',
'collections/addCollectionVar',
'collections/updateCollectionVar',
'collections/deleteCollectionVar',
'collections/setCollectionVars',
'collections/updateCollectionAuth',
'collections/updateCollectionAuthMode',
'collections/updateCollectionRequestScript',

View File

@@ -2,7 +2,7 @@ import get from 'lodash/get';
import each from 'lodash/each';
import filter from 'lodash/filter';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { removeTaskFromQueue, hideHomePage } from 'providers/ReduxStore/slices/app';
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
@@ -37,7 +37,6 @@ taskMiddleware.startListening({
requestPaneTab: getDefaultRequestPaneTab(item)
})
);
listenerApi.dispatch(hideHomePage());
}
}
@@ -80,7 +79,6 @@ taskMiddleware.startListening({
type: 'response-example',
itemUid: item.uid
}));
listenerApi.dispatch(hideHomePage());
}
}
}

View File

@@ -1,6 +1,7 @@
import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import brunoClipboard from 'utils/bruno-clipboard';
import { addTab, focusTab } from './tabs';
const initialState = {
isDragging: false,
@@ -127,6 +128,20 @@ export const appSlice = createSlice({
// Update clipboard UI state
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
}
},
extraReducers: (builder) => {
// Automatically hide special pages when any tab is added or focused
builder
.addCase(addTab, (state) => {
state.showHomePage = false;
state.showApiSpecPage = false;
state.showManageWorkspacePage = false;
})
.addCase(focusTab, (state) => {
state.showHomePage = false;
state.showApiSpecPage = false;
state.showManageWorkspacePage = false;
});
}
});

View File

@@ -2683,7 +2683,12 @@ export const collectionsSlice = createSlice({
item.examples = file.data.examples;
item.filename = file.meta.name;
item.pathname = file.meta.pathname;
item.draft = null;
// Only clear draft if it matches the file content
// This preserves characters typed during autosave
if (item.draft && areItemsTheSameExceptSeqUpdate(item.draft, file.data)) {
item.draft = null;
}
}
}
}

View File

@@ -36,6 +36,10 @@ const transformCollection = async (collection, type) => {
const { convertOpenapiToBruno } = await import('utils/importers/openapi-collection');
return convertOpenapiToBruno(collection);
}
case 'opencollection': {
const { processOpenCollection } = await import('utils/importers/opencollection');
return processOpenCollection(collection);
}
case 'wsdl': {
const { wsdlToBruno } = await import('@usebruno/converters');
return wsdlToBruno(collection);

View File

@@ -2,11 +2,13 @@ const colors = {
BRAND: '#d9a342',
TEXT: '#d4d4d4',
TEXT_MUTED: '#858585',
TEXT_LINK: '#569cd6',
TEXT_LINK: '#8BB7E0',
BG: '#1e1e1e',
GREEN: '#4ec9b0',
YELLOW: '#d9a342',
WHITE: '#fff',
BLACK: '#000',
GRAY_1: '#252526',
GRAY_2: '#3D3D3D',
@@ -313,6 +315,9 @@ const darkTheme = {
},
backdrop: {
opacity: 0.2
},
closeButton: {
hoverBg: 'rgba(255, 255, 255, 0.1)'
}
},
@@ -340,6 +345,30 @@ const darkTheme = {
border: '#dc3545'
}
},
button2: {
color: {
primary: {
bg: colors.BRAND,
text: colors.BLACK
},
secondary: {
bg: colors.GRAY_4,
text: '#fff'
},
success: {
bg: '#059669',
text: '#fff'
},
warning: {
bg: '#f59e0b',
text: '#1e1e1e'
},
danger: {
bg: '#f43f5e',
text: '#fff'
}
}
},
tabs: {
marginRight: '1.2rem',

View File

@@ -1,5 +1,5 @@
const colors = {
BRAND: '#cf8730',
BRAND: '#c7822e',
TEXT: 'rgb(52, 52, 52)',
TEXT_MUTED: '#838383',
TEXT_LINK: '#1663bb',
@@ -9,7 +9,7 @@ const colors = {
BLACK: '#000',
SLATE_BLACK: '#343434',
GREEN: '#047857',
YELLOW: '#cf8730',
YELLOW: '#c7822e',
GRAY_1: '#f8f8f8',
GRAY_2: '#f3f3f3',
@@ -318,6 +318,9 @@ const lightTheme = {
},
backdrop: {
opacity: 0.4
},
closeButton: {
hoverBg: 'rgba(0, 0, 0, 0.08)'
}
},
@@ -345,13 +348,36 @@ const lightTheme = {
border: '#dc3545'
}
},
button2: {
color: {
primary: {
bg: colors.BRAND,
text: '#fff'
},
secondary: {
bg: '#e5e7eb',
text: colors.TEXT
},
success: {
bg: '#4f9a7d',
text: '#fff'
},
warning: {
bg: '#c98b2b',
text: '#fff'
},
danger: {
bg: '#d14f5b',
text: '#fff'
}
}
},
tabs: {
marginRight: '1.2rem',
active: {
fontWeight: 400,
color: colors.SLATE_BLACK,
border: '#cf8730'
border: '#c7822e'
},
secondary: {
active: {

View File

@@ -0,0 +1,421 @@
import React from 'react';
import Button from './index';
export default {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered'
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'select',
options: ['xs', 'sm', 'base', 'md', 'lg'],
description: 'The size of the button'
},
variant: {
control: 'select',
options: ['filled', 'outline', 'ghost'],
description: 'The visual style variant of the button'
},
color: {
control: 'select',
options: ['primary', 'secondary', 'success', 'warning', 'danger'],
description: 'The color of the button'
},
fontWeight: {
control: 'select',
options: ['regular', 'medium'],
description: 'Font weight (default: regular for filled/ghost, medium for outline)'
},
rounded: {
control: 'select',
options: ['sm', 'base', 'md', 'lg', 'full'],
description: 'Border radius style'
},
disabled: {
control: 'boolean',
description: 'Whether the button is disabled'
},
loading: {
control: 'boolean',
description: 'Whether the button is in loading state'
},
fullWidth: {
control: 'boolean',
description: 'Whether the button takes full width'
},
iconPosition: {
control: 'select',
options: ['left', 'right'],
description: 'Position of the icon relative to text'
},
children: {
control: 'text',
description: 'Button text content'
},
onClick: { action: 'clicked' },
onDoubleClick: { action: 'double-clicked' },
onMouseEnter: { action: 'mouse-entered' },
onMouseLeave: { action: 'mouse-left' }
}
};
// Sample icon component for stories
const PlusIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
);
const SendIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
);
const TrashIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
);
// Default story
export const Default = {
args: {
children: 'Button'
}
};
// Variants
export const Filled = {
render: () => (
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<Button variant="filled" color="primary">Primary</Button>
<Button variant="filled" color="secondary">Secondary</Button>
<Button variant="filled" color="success">Success</Button>
<Button variant="filled" color="warning">Warning</Button>
<Button variant="filled" color="danger">Danger</Button>
</div>
)
};
export const Outline = {
render: () => (
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<Button variant="outline" color="primary">Primary</Button>
<Button variant="outline" color="secondary">Secondary</Button>
<Button variant="outline" color="success">Success</Button>
<Button variant="outline" color="warning">Warning</Button>
<Button variant="outline" color="danger">Danger</Button>
</div>
)
};
export const Ghost = {
render: () => (
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<Button variant="ghost" color="primary">Primary</Button>
<Button variant="ghost" color="secondary">Secondary</Button>
<Button variant="ghost" color="success">Success</Button>
<Button variant="ghost" color="warning">Warning</Button>
<Button variant="ghost" color="danger">Danger</Button>
</div>
)
};
// With Icons
export const WithIconLeft = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Filled</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="filled" size="xs" icon={<PlusIcon />} iconPosition="left">Add</Button>
<Button variant="filled" size="sm" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="filled" size="base" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="filled" size="md" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="filled" size="lg" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Outline</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="outline" size="xs" icon={<PlusIcon />} iconPosition="left">Add</Button>
<Button variant="outline" size="sm" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="outline" size="base" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="outline" size="md" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="outline" size="lg" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Ghost</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="ghost" size="xs" icon={<PlusIcon />} iconPosition="left">Add</Button>
<Button variant="ghost" size="sm" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="ghost" size="base" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="ghost" size="md" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
<Button variant="ghost" size="lg" icon={<PlusIcon />} iconPosition="left">Add Item</Button>
</div>
</div>
</div>
)
};
export const WithIconRight = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Filled</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="filled" size="xs" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="filled" size="sm" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="filled" size="base" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="filled" size="md" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="filled" size="lg" icon={<SendIcon />} iconPosition="right">Send</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Outline</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="outline" size="xs" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="outline" size="sm" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="outline" size="base" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="outline" size="md" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="outline" size="lg" icon={<SendIcon />} iconPosition="right">Send</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Ghost</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="ghost" size="xs" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="ghost" size="sm" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="ghost" size="base" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="ghost" size="md" icon={<SendIcon />} iconPosition="right">Send</Button>
<Button variant="ghost" size="lg" icon={<SendIcon />} iconPosition="right">Send</Button>
</div>
</div>
</div>
)
};
export const IconOnly = {
args: {
icon: <PlusIcon />,
rounded: 'full',
size: 'base'
}
};
// States
export const Disabled = {
render: () => (
<div style={{ display: 'flex', gap: '48px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<h3 style={{ marginBottom: '4px', fontSize: '14px', fontWeight: 600 }}>Enabled</h3>
<Button variant="filled" color="primary">Primary</Button>
<Button variant="filled" color="secondary">Secondary</Button>
<Button variant="filled" color="success">Success</Button>
<Button variant="filled" color="warning">Warning</Button>
<Button variant="filled" color="danger">Danger</Button>
<Button variant="outline" color="primary">Outline</Button>
<Button variant="ghost" color="primary">Ghost</Button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<h3 style={{ marginBottom: '4px', fontSize: '14px', fontWeight: 600 }}>Disabled</h3>
<Button variant="filled" color="primary" disabled>Primary</Button>
<Button variant="filled" color="secondary" disabled>Secondary</Button>
<Button variant="filled" color="success" disabled>Success</Button>
<Button variant="filled" color="warning" disabled>Warning</Button>
<Button variant="filled" color="danger" disabled>Danger</Button>
<Button variant="outline" color="primary" disabled>Outline</Button>
<Button variant="ghost" color="primary" disabled>Ghost</Button>
</div>
</div>
)
};
export const Loading = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Filled</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="filled" size="xs" loading>Loading</Button>
<Button variant="filled" size="sm" loading>Loading</Button>
<Button variant="filled" size="base" loading>Loading</Button>
<Button variant="filled" size="md" loading>Loading</Button>
<Button variant="filled" size="lg" loading>Loading</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Outline</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="outline" size="xs" loading>Loading</Button>
<Button variant="outline" size="sm" loading>Loading</Button>
<Button variant="outline" size="base" loading>Loading</Button>
<Button variant="outline" size="md" loading>Loading</Button>
<Button variant="outline" size="lg" loading>Loading</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Ghost</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="ghost" size="xs" loading>Loading</Button>
<Button variant="ghost" size="sm" loading>Loading</Button>
<Button variant="ghost" size="base" loading>Loading</Button>
<Button variant="ghost" size="md" loading>Loading</Button>
<Button variant="ghost" size="lg" loading>Loading</Button>
</div>
</div>
</div>
)
};
// Rounded variants
export const Rounded = {
render: () => (
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<Button rounded="sm">Small</Button>
<Button rounded="base">Base</Button>
<Button rounded="md">Medium</Button>
<Button rounded="lg">Large</Button>
<Button rounded="full">Full</Button>
</div>
)
};
// Full Width
export const FullWidth = {
args: {
children: 'Full Width Button',
fullWidth: true
},
decorators: [
(Story) => (
<div style={{ width: '300px' }}>
<Story />
</div>
)
]
};
// Combined Examples
export const DangerWithIcon = {
args: {
children: 'Delete',
variant: 'filled',
color: 'danger',
icon: <TrashIcon />
}
};
// All Colors Showcase
export const AllColors = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Filled Variant</h3>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<Button variant="filled" color="primary">Primary</Button>
<Button variant="filled" color="secondary">Secondary</Button>
<Button variant="filled" color="success">Success</Button>
<Button variant="filled" color="warning">Warning</Button>
<Button variant="filled" color="danger">Danger</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Outline Variant</h3>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<Button variant="outline" color="primary">Primary</Button>
<Button variant="outline" color="secondary">Secondary</Button>
<Button variant="outline" color="success">Success</Button>
<Button variant="outline" color="warning">Warning</Button>
<Button variant="outline" color="danger">Danger</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Ghost Variant</h3>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<Button variant="ghost" color="primary">Primary</Button>
<Button variant="ghost" color="secondary">Secondary</Button>
<Button variant="ghost" color="success">Success</Button>
<Button variant="ghost" color="warning">Warning</Button>
<Button variant="ghost" color="danger">Danger</Button>
</div>
</div>
</div>
)
};
// All Sizes Showcase
export const AllSizes = {
render: () => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Filled</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="filled" size="xs">Extra Small</Button>
<Button variant="filled" size="sm">Small</Button>
<Button variant="filled" size="base">Base</Button>
<Button variant="filled" size="md">Medium</Button>
<Button variant="filled" size="lg">Large</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Outline</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="outline" size="xs">Extra Small</Button>
<Button variant="outline" size="sm">Small</Button>
<Button variant="outline" size="base">Base</Button>
<Button variant="outline" size="md">Medium</Button>
<Button variant="outline" size="lg">Large</Button>
</div>
</div>
<div>
<h3 style={{ marginBottom: '12px', fontSize: '14px', fontWeight: 600 }}>Ghost</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Button variant="ghost" size="xs">Extra Small</Button>
<Button variant="ghost" size="sm">Small</Button>
<Button variant="ghost" size="base">Base</Button>
<Button variant="ghost" size="md">Medium</Button>
<Button variant="ghost" size="lg">Large</Button>
</div>
</div>
</div>
)
};

View File

@@ -0,0 +1,274 @@
import styled, { css, keyframes } from 'styled-components';
import { darken, rgba } from 'polished';
const spin = keyframes`
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
`;
const sizeStyles = {
xs: css`
padding: 0.25rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
gap: 0.25rem;
.button-icon {
width: 0.75rem;
height: 0.75rem;
}
.spinner-icon {
width: 0.75rem;
height: 0.75rem;
}
`,
sm: css`
padding: 0.375rem 0.75rem;
font-size: ${(props) => props.theme.font.size.sm};
gap: 0.375rem;
.button-icon {
width: 0.875rem;
height: 0.875rem;
}
.spinner-icon {
width: 0.875rem;
height: 0.875rem;
}
`,
base: css`
padding: 0.5rem 1rem;
font-size: ${(props) => props.theme.font.size.base};
gap: 0.5rem;
.button-icon {
width: 1rem;
height: 1rem;
}
.spinner-icon {
width: 1rem;
height: 1rem;
}
`,
md: css`
padding: 0.625rem 1.125rem;
font-size: ${(props) => props.theme.font.size.md};
gap: 0.5rem;
.button-icon {
width: 1rem;
height: 1rem;
}
.spinner-icon {
width: 1rem;
height: 1rem;
}
`,
lg: css`
padding: 0.75rem 1.5rem;
font-size: ${(props) => props.theme.font.size.md};
gap: 0.75rem;
.button-icon {
width: 1.125rem;
height: 1.125rem;
}
.spinner-icon {
width: 1.125rem;
height: 1.125rem;
}
`
};
const roundedStyles = {
sm: css`
border-radius: ${(props) => props.theme.border.radius.sm};
`,
base: css`
border-radius: ${(props) => props.theme.border.radius.base};
`,
md: css`
border-radius: ${(props) => props.theme.border.radius.md};
`,
lg: css`
border-radius: ${(props) => props.theme.border.radius.lg};
`,
full: css`
border-radius: 9999px;
`
};
const fontWeightStyles = {
regular: 400,
medium: 500
};
// For secondary, use text color for outline/ghost; for others, use bg
const getDisplayColor = (colorConfig, colorName) => {
return colorName === 'secondary' ? colorConfig.text : colorConfig.bg;
};
const getVariantStyles = (variant, color) => {
if (variant === 'filled') {
return css`
background-color: ${(props) => props.theme.button2.color[color].bg};
color: ${(props) => props.theme.button2.color[color].text};
border: 1px solid ${(props) => props.theme.button2.color[color].bg};
&:disabled {
color: ${(props) => props.theme.button2.color[color].text} !important;
}
&:hover:not(:disabled) {
${(props) => {
const bg = props.theme.button2.color[color].bg;
return css`
background-color: ${darken(0.03, bg)};
border-color: ${darken(0.03, bg)};
`;
}}
}
&:active:not(:disabled) {
${(props) => {
const bg = props.theme.button2.color[color].bg;
return css`
background-color: ${darken(0.07, bg)};
`;
}}
}
`;
}
if (variant === 'outline') {
return css`
background-color: transparent;
color: ${(props) => getDisplayColor(props.theme.button2.color[color], color)};
border: 1px solid ${(props) => getDisplayColor(props.theme.button2.color[color], color)};
&:hover:not(:disabled) {
${(props) => {
const displayColor = getDisplayColor(props.theme.button2.color[color], color);
return css`
background-color: ${rgba(displayColor, 0.05)};
`;
}}
}
&:active:not(:disabled) {
${(props) => {
const displayColor = getDisplayColor(props.theme.button2.color[color], color);
return css`
background-color: ${rgba(displayColor, 0.1)};
`;
}}
}
`;
}
if (variant === 'ghost') {
return css`
background-color: transparent;
color: ${(props) => getDisplayColor(props.theme.button2.color[color], color)};
border: 1px solid transparent;
&:hover:not(:disabled) {
${(props) => {
const displayColor = getDisplayColor(props.theme.button2.color[color], color);
return css`
background-color: ${rgba(displayColor, 0.1)};
`;
}}
}
&:active:not(:disabled) {
${(props) => {
const displayColor = getDisplayColor(props.theme.button2.color[color], color);
return css`
background-color: ${rgba(displayColor, 0.15)};
`;
}}
}
`;
}
return css``;
};
const StyledWrapper = styled.div`
display: ${(props) => (props.$fullWidth ? 'block' : 'inline-block')};
width: ${(props) => (props.$fullWidth ? '100%' : 'auto')};
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: ${(props) => (props.$fullWidth ? '100%' : 'auto')};
font-family: inherit;
font-weight: ${(props) => fontWeightStyles[props.$fontWeight] || 400};
line-height: 1;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
white-space: nowrap;
user-select: none;
${(props) => sizeStyles[props.$size]}
${(props) => roundedStyles[props.$rounded]}
${(props) => getVariantStyles(props.$variant, props.$color)}
&:focus-visible {
${(props) => {
const colorConfig = props.theme.button2.color[props.$color];
const focusColor = props.$color === 'secondary' ? colorConfig.text : colorConfig.bg;
return css`
box-shadow: 0 0 0 2px ${rgba(focusColor, 0.4)};
`;
}}
}
&:disabled {
cursor: not-allowed;
pointer-events: none;
opacity: 0.7;
}
.button-content {
display: inline-flex;
align-items: center;
}
.button-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
.button-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
.spinner-icon {
animation: ${spin} 0.75s linear infinite;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,81 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const Button = ({
children,
size = 'base',
variant = 'filled',
color = 'primary',
disabled = false,
loading = false,
icon,
iconPosition = 'left',
fullWidth = false,
type = 'button',
rounded = 'base',
fontWeight,
onClick,
onDoubleClick,
className = '',
...rest
}) => {
const handleClick = (e) => {
if (disabled || loading) return;
onClick?.(e);
};
const handleDoubleClick = (e) => {
if (disabled || loading) return;
onDoubleClick?.(e);
};
return (
<StyledWrapper
$size={size}
$variant={variant}
$color={color}
$disabled={disabled}
$loading={loading}
$fullWidth={fullWidth}
$rounded={rounded}
$fontWeight={fontWeight}
$hasIcon={!!icon}
$iconPosition={iconPosition}
className={className}
>
<button
type={type}
disabled={disabled || loading}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
{...rest}
>
{loading && (
<span className="button-spinner">
<svg className="spinner-icon" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeDasharray="31.4 31.4"
/>
</svg>
</span>
)}
{icon && iconPosition === 'left' && !loading && (
<span className="button-icon button-icon-left">{icon}</span>
)}
{children && <span className="button-content">{children}</span>}
{icon && iconPosition === 'right' && !loading && (
<span className="button-icon button-icon-right">{icon}</span>
)}
</button>
</StyledWrapper>
);
};
export default Button;

View File

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

View File

@@ -1,6 +1,5 @@
import React, { forwardRef, useRef, useCallback, useState, useImperativeHandle, useEffect, useMemo } from 'react';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
// Constants
const NAVIGATION_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'];
@@ -432,37 +431,35 @@ const MenuDropdown = forwardRef(({
: <div onClick={handleTriggerClick} data-testid={testId}>{children}</div>;
return (
<StyledWrapper>
<Dropdown
onCreate={onDropdownCreate}
icon={triggerElement}
placement={placement}
className={className}
visible={isOpen}
onClickOutside={handleClickOutside}
{...dropdownProps}
>
<div {...(testId && { 'data-testid': testId + '-dropdown' })}>
{header && (
<div className="dropdown-header-container" onClick={handleClickOutside}>
{header}
<div className="dropdown-divider"></div>
</div>
)}
<div role="menu" tabIndex={-1} onKeyDown={handleMenuKeyDown}>
{renderMenuContent()}
<Dropdown
onCreate={onDropdownCreate}
icon={triggerElement}
placement={placement}
className={className}
visible={isOpen}
onClickOutside={handleClickOutside}
{...dropdownProps}
>
<div {...(testId && { 'data-testid': testId + '-dropdown' })}>
{header && (
<div className="dropdown-header-container" onClick={handleClickOutside}>
{header}
<div className="dropdown-divider"></div>
</div>
{footer && (
<>
<div className="dropdown-divider"></div>
<div className="dropdown-footer-container">
{footer}
</div>
</>
)}
)}
<div role="menu" tabIndex={-1} onKeyDown={handleMenuKeyDown}>
{renderMenuContent()}
</div>
</Dropdown>
</StyledWrapper>
{footer && (
<>
<div className="dropdown-divider"></div>
<div className="dropdown-footer-container">
{footer}
</div>
</>
)}
</div>
</Dropdown>
);
});

View File

@@ -8,6 +8,7 @@ const DROPDOWN_WIDTH = 60;
const CALCULATION_DELAY_DEFAULT = 20;
const CALCULATION_DELAY_EXTENDED = 150;
const GAP_BETWEEN_LEFT_AND_RIGHT_CONTENT = 80;
const EXPANDABLE_HYSTERESIS = 20; // Buffer to prevent flickering at boundary
// Compare two tab arrays by their keys
const areTabArraysEqual = (a, b) => {
@@ -22,7 +23,8 @@ const ResponsiveTabs = ({
rightContent,
rightContentRef,
delayedTabs = [],
rightContentExpandedWidth // Optional: width of the right content when expanded(used when right content's elements are collapsible)
rightContentExpandedWidth, // Optional: width of the expandable element when expanded
expandableElementIndex = -1 // Optional: index of the expandable child element (-1 means last child)
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
@@ -82,12 +84,47 @@ const ResponsiveTabs = ({
setOverflowTabs((prev) => (areTabArraysEqual(prev, overflow) ? prev : overflow));
// Only calculate expandibility if rightContentExpandedWidth is provided
if (rightContentExpandedWidth) {
if (rightContentExpandedWidth && rightContentRef?.current) {
const leftContentWidth = currentWidth + (overflow.length ? DROPDOWN_WIDTH : 0);
const expandable = containerWidth - leftContentWidth - GAP_BETWEEN_LEFT_AND_RIGHT_CONTENT > rightContentExpandedWidth;
setRightSideExpandable((prev) => (prev === expandable ? prev : expandable));
// Calculate total expanded width by summing children widths
// and replacing the expandable element's current width with its expanded width
const children = rightContentRef.current.children;
const childrenCount = children.length;
if (childrenCount > 0) {
// Resolve the expandable element index (-1 means last child)
const targetIndex = expandableElementIndex < 0 ? childrenCount + expandableElementIndex : expandableElementIndex;
const validTargetIndex = Math.max(0, Math.min(targetIndex, childrenCount - 1));
let totalExpandedWidth = 0;
for (let i = 0; i < childrenCount; i++) {
if (i === validTargetIndex) {
// Use the expanded width for the expandable element
totalExpandedWidth += rightContentExpandedWidth;
} else {
// Use the current width for other elements
totalExpandedWidth += children[i].offsetWidth;
}
}
const availableSpace = containerWidth - leftContentWidth - GAP_BETWEEN_LEFT_AND_RIGHT_CONTENT;
// Use hysteresis to prevent flickering at boundary
// When expanded: only collapse if significantly less space available
// When collapsed: expand when there's enough space
setRightSideExpandable((prev) => {
if (prev) {
// Currently expanded - only collapse if space drops below threshold minus hysteresis
return availableSpace > totalExpandedWidth - EXPANDABLE_HYSTERESIS;
} else {
// Currently collapsed - expand if there's enough space
return availableSpace > totalExpandedWidth;
}
});
}
}
}, [tabs, activeTab, rightContentRef, rightContentExpandedWidth]);
}, [tabs, activeTab, rightContentRef, rightContentExpandedWidth, expandableElementIndex]);
// Recalculate on tab/activeTab changes
useEffect(() => {

View File

@@ -33,6 +33,13 @@ export const isMacOS = () => {
return osFamily.includes('os x');
};
export const isLinuxOS = () => {
const os = platform.os;
const osFamily = os.family.toLowerCase();
return osFamily.includes('linux') || osFamily.includes('ubuntu') || osFamily.includes('debian') || osFamily.includes('fedora') || osFamily.includes('centos') || osFamily.includes('arch');
};
export const getAppInstallDate = () => {
let dateString = localStorage.getItem('bruno.installedOn');

View File

@@ -0,0 +1,23 @@
import * as FileSaver from 'file-saver';
import jsyaml from 'js-yaml';
import { brunoToOpenCollection } from '@usebruno/converters';
import { sanitizeName } from 'utils/common/regex';
export const exportCollection = (collection) => {
const openCollection = brunoToOpenCollection(collection);
const yamlContent = jsyaml.dump(openCollection, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: false
});
const sanitizedName = sanitizeName(collection.name);
const fileName = `${sanitizedName}.yml`;
const fileBlob = new Blob([yamlContent], { type: 'application/x-yaml' });
FileSaver.saveAs(fileBlob, fileName);
};
export default exportCollection;

View File

@@ -0,0 +1,81 @@
import each from 'lodash/each';
import { uuid } from 'utils/common';
import { BrunoError } from 'utils/common/error';
import { validateSchema, updateUidsInCollection, hydrateSeqInCollection } from './common';
import { openCollectionToBruno } from '@usebruno/converters';
const addUidsToRoot = (collection) => {
if (collection.root?.request?.headers) {
each(collection.root.request.headers, (header) => {
header.uid = uuid();
});
}
if (collection.root?.request?.vars?.req) {
each(collection.root.request.vars.req, (v) => {
v.uid = uuid();
});
}
if (collection.root?.request?.vars?.res) {
each(collection.root.request.vars.res, (v) => {
v.uid = uuid();
});
}
const addUidsToFolderRoot = (items) => {
each(items, (item) => {
if (item.type === 'folder') {
if (item.root?.request?.headers) {
each(item.root.request.headers, (header) => {
header.uid = uuid();
});
}
if (item.root?.request?.vars?.req) {
each(item.root.request.vars.req, (v) => {
v.uid = uuid();
});
}
if (item.root?.request?.vars?.res) {
each(item.root.request.vars.res, (v) => {
v.uid = uuid();
});
}
if (item.items?.length) {
addUidsToFolderRoot(item.items);
}
}
});
};
addUidsToFolderRoot(collection.items);
return collection;
};
export const processOpenCollection = async (jsonData) => {
try {
let collection = openCollectionToBruno(jsonData);
collection = hydrateSeqInCollection(collection);
collection = updateUidsInCollection(collection);
collection = addUidsToRoot(collection);
await validateSchema(collection);
return collection;
} catch (err) {
console.error('Error processing OpenCollection:', err);
throw new BrunoError('Import OpenCollection failed');
}
};
export const isOpenCollection = (data) => {
if (typeof data !== 'object' || data === null) {
return false;
}
if (typeof data.opencollection !== 'string' || !data.opencollection.trim()) {
return false;
}
if (typeof data.info !== 'object' || data.info === null) {
return false;
}
return true;
};

View File

@@ -0,0 +1,35 @@
const path = require('path');
/** @type { import('@storybook/react-webpack5').StorybookConfig } */
const config = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-webpack5-compiler-babel'
],
framework: {
name: '@storybook/react-webpack5',
options: {}
},
docs: {
autodocs: true
},
webpackFinal: async (config) => {
// Add path aliases to match jsconfig.json
config.resolve.alias = {
...config.resolve.alias,
assets: path.resolve(__dirname, '../src/assets'),
ui: path.resolve(__dirname, '../src/ui'),
components: path.resolve(__dirname, '../src/components'),
hooks: path.resolve(__dirname, '../src/hooks'),
themes: path.resolve(__dirname, '../src/themes'),
api: path.resolve(__dirname, '../src/api'),
pageComponents: path.resolve(__dirname, '../src/pageComponents'),
providers: path.resolve(__dirname, '../src/providers'),
utils: path.resolve(__dirname, '../src/utils')
};
return config;
}
};
module.exports = config;

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { ThemeProvider as SCThemeProvider, createGlobalStyle } from 'styled-components';
import themes from 'themes/index';
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
const GlobalStyle = createGlobalStyle`
* {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
`;
/** @type { import('@storybook/react').Preview } */
const preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#1e1e1e' }
]
}
},
globalTypes: {
theme: {
description: 'Global theme for components',
toolbar: {
title: 'Theme',
icon: 'paintbrush',
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' }
],
dynamicTitle: true
}
}
},
initialGlobals: {
theme: 'light'
},
decorators: [
(Story, context) => {
const themeName = context.globals.theme || 'light';
const theme = themes[themeName];
// Update background and text color based on theme
const isDark = themeName === 'dark';
const backgroundColor = isDark ? '#1e1e1e' : '#ffffff';
const textColor = isDark ? '#d4d4d4' : '#333333';
document.body.style.backgroundColor = backgroundColor;
document.body.style.color = textColor;
return (
<SCThemeProvider theme={theme}>
<GlobalStyle />
<div style={{ padding: '1rem', color: textColor }}>
<Story />
</div>
</SCThemeProvider>
);
}
]
};
export default preview;

View File

@@ -4,7 +4,6 @@
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
@@ -29,6 +28,8 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@opencollection/types": "0.3.0",
"@usebruno/schema-types": "0.0.1",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",

View File

@@ -1,43 +1,89 @@
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const typescript = require('@rollup/plugin-typescript');
const { terser } = require('rollup-plugin-terser');
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const { copy } = require('@web/rollup-plugin-copy');
const path = require('path');
const packageJson = require('./package.json');
const alias = require('@rollup/plugin-alias');
const path = require('path');
const externalDeps = [
'@usebruno/schema',
'@usebruno/schema-types',
/@usebruno\/schema-types\/.*/,
'@opencollection/types',
/@opencollection\/types\/.*/,
// Runtime dependencies
'lodash',
'lodash/each',
'lodash/get',
'lodash/cloneDeep',
'lodash/map',
'js-yaml',
'jscodeshift',
'nanoid',
'xml2js',
// Node built-ins
'path',
'fs'
];
module.exports = [
{
input: 'src/index.js',
output: [
{
dir: path.dirname(packageJson.main),
format: 'cjs',
sourcemap: true
},
{
dir: path.dirname(packageJson.module),
format: 'esm',
sourcemap: true
}
],
output: {
dir: path.dirname(packageJson.main),
format: 'cjs',
sourcemap: true,
exports: 'named',
entryFileNames: 'index.js'
},
plugins: [
peerDepsExternal(),
nodeResolve({
preferBuiltins: true,
extensions: ['.js', '.css'] // Resolve .js files
extensions: ['.js', '.ts', '.tsx', '.json']
}),
commonjs(),
terser(),
alias({
entries: [{ find: 'src', replacement: path.resolve(__dirname, 'src') }]
typescript({
tsconfig: './tsconfig.json',
sourceMap: true,
outDir: path.dirname(packageJson.main)
}),
terser(),
copy({
patterns: 'src/workers/scripts/**/*',
rootDir: '.'
})
]
],
external: externalDeps
},
{
input: 'src/index.js',
output: {
dir: path.dirname(packageJson.module),
format: 'esm',
sourcemap: true,
exports: 'named',
entryFileNames: 'index.js'
},
plugins: [
peerDepsExternal(),
nodeResolve({
extensions: ['.js', '.ts', '.tsx', '.json']
}),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
sourceMap: true,
outDir: path.dirname(packageJson.module)
}),
terser(),
copy({
patterns: 'src/workers/scripts/**/*',
rootDir: '.'
})
],
external: externalDeps
}
];

View File

@@ -5,3 +5,5 @@ export { default as openApiToBruno } from './openapi/openapi-to-bruno.js';
export { default as insomniaToBruno } from './insomnia/insomnia-to-bruno.js';
export { default as wsdlToBruno } from './wsdl/wsdl-to-bruno.js';
export { default as postmanTranslation } from './postman/postman-translations.js';
export { openCollectionToBruno } from './opencollection/opencollection-to-bruno.js';
export { brunoToOpenCollection } from './opencollection/bruno-to-opencollection.js';

View File

@@ -0,0 +1,172 @@
import { toOpenCollectionAuth, toOpenCollectionHeaders, toOpenCollectionScripts, toOpenCollectionVariables } from "./common";
import { toOpenCollectionEnvironments } from "./environment";
import { toOpenCollectionFolder } from "./folder";
import { toOpenCollectionItems } from "./items";
import { BrunoCollection, BrunoCollectionRoot, BrunoConfig, ClientCertificate, CollectionConfig, OpenCollection, PemCertificate, Pkcs12Certificate, Protobuf } from "./types";
const toOpenCollectionConfig = (brunoConfig: BrunoConfig | undefined): CollectionConfig | undefined => {
if (!brunoConfig) {
return undefined;
}
const config: CollectionConfig = {};
if (brunoConfig.protobuf?.protoFiles?.length || brunoConfig.protobuf?.importPaths?.length) {
config.protobuf = {} as Protobuf;
if (brunoConfig.protobuf.protoFiles?.length) {
config.protobuf.protoFiles = brunoConfig.protobuf.protoFiles.map((f) => ({
type: 'file' as const,
path: f.path
}));
}
if (brunoConfig.protobuf.importPaths?.length) {
config.protobuf.importPaths = brunoConfig.protobuf.importPaths.map((p) => {
const importPath: { path: string; disabled?: boolean } = { path: p.path };
if (p.disabled) {
importPath.disabled = true;
}
return importPath;
});
}
}
if (brunoConfig.proxy?.enabled) {
if (brunoConfig.proxy.enabled === 'global') {
config.proxy = 'inherit';
} else {
config.proxy = {
protocol: brunoConfig.proxy.protocol || 'http',
hostname: brunoConfig.proxy.hostname || '',
port: brunoConfig.proxy.port || 0
};
if (brunoConfig.proxy.auth?.enabled) {
config.proxy.auth = {
username: brunoConfig.proxy.auth.username || '',
password: brunoConfig.proxy.auth.password || ''
};
}
if (brunoConfig.proxy.bypassProxy) {
config.proxy.bypassProxy = brunoConfig.proxy.bypassProxy;
}
}
}
if (brunoConfig.clientCertificates?.certs?.length) {
config.clientCertificates = brunoConfig.clientCertificates.certs
.map((cert): ClientCertificate | null => {
if (cert.type === 'pem') {
const pemCert: PemCertificate = {
domain: cert.domain || '',
type: 'pem',
certificateFilePath: cert.certFilePath || '',
privateKeyFilePath: cert.keyFilePath || ''
};
if (cert.passphrase) {
pemCert.passphrase = cert.passphrase;
}
return pemCert;
} else if (cert.type === 'pkcs12') {
const pkcs12Cert: Pkcs12Certificate = {
domain: cert.domain || '',
type: 'pkcs12',
pkcs12FilePath: cert.pfxFilePath || ''
};
if (cert.passphrase) {
pkcs12Cert.passphrase = cert.passphrase;
}
return pkcs12Cert;
}
return null;
})
.filter((cert): cert is ClientCertificate => cert !== null);
}
return Object.keys(config).length > 0 ? config : undefined;
};
const hasRequestDefaults = (root: BrunoCollectionRoot | undefined): boolean => {
const request = root?.request;
return Boolean(
request?.headers?.length ||
request?.vars?.req?.length ||
request?.script?.req ||
request?.script?.res ||
request?.tests ||
(request?.auth && request.auth.mode !== 'none')
);
};
export const brunoToOpenCollection = (collection: BrunoCollection): OpenCollection => {
const openCollection: OpenCollection = {
opencollection: '1.0.0',
info: {
name: collection.name || 'Untitled Collection'
}
};
const config = toOpenCollectionConfig(collection.brunoConfig as BrunoConfig);
if (config) {
openCollection.config = config;
}
const environments = toOpenCollectionEnvironments(collection.environments ?? undefined);
if (environments?.length) {
if (!openCollection.config) {
openCollection.config = {};
}
openCollection.config.environments = environments;
}
const items = toOpenCollectionItems(collection.items, toOpenCollectionFolder);
if (items.length) {
openCollection.items = items as OpenCollection['items'];
}
if (hasRequestDefaults(collection.root as BrunoCollectionRoot)) {
const request = (collection.root as BrunoCollectionRoot)?.request;
openCollection.request = {};
const headers = toOpenCollectionHeaders(request?.headers);
if (headers) {
openCollection.request.headers = headers;
}
const auth = toOpenCollectionAuth(request?.auth);
if (auth) {
openCollection.request.auth = auth;
}
const variables = toOpenCollectionVariables(request?.vars);
if (variables) {
openCollection.request.variables = variables;
}
const scripts = toOpenCollectionScripts(request as any);
if (scripts) {
openCollection.request.scripts = scripts;
}
}
if ((collection.root as BrunoCollectionRoot)?.docs) {
openCollection.docs = {
content: (collection.root as BrunoCollectionRoot).docs!,
type: 'text/markdown'
};
}
openCollection.bundled = true;
const extensions: { ignore?: string[] } = {};
if ((collection.brunoConfig as BrunoConfig)?.ignore?.length) {
extensions.ignore = (collection.brunoConfig as BrunoConfig).ignore;
}
if (Object.keys(extensions).length > 0) {
openCollection.extensions = extensions;
}
return openCollection;
};

View File

@@ -0,0 +1,83 @@
import { uuid } from '../../common/index.js';
import type {
Action,
ActionSetVariable,
ActionVariableScope,
BrunoVariable,
BrunoVariables
} from '../types';
/**
* Convert Bruno post-response variables to OpenCollection actions.
* Post-response variables in Bruno are converted to 'set-variable' actions
* with phase 'after-response'.
*/
export const toOpenCollectionActions = (resVariables: BrunoVariables | null | undefined): Action[] | undefined => {
if (!resVariables?.length) {
return undefined;
}
const actions: Action[] = resVariables.map((v: BrunoVariable): ActionSetVariable => {
const action: ActionSetVariable = {
type: 'set-variable',
phase: 'after-response',
selector: {
expression: v.value || '',
method: 'jsonq'
},
variable: {
name: v.name || '',
scope: v.local ? 'request' : 'runtime' as ActionVariableScope
}
};
if (v.description && typeof v.description === 'string' && v.description.trim().length) {
action.description = v.description;
}
if (v.enabled === false) {
action.disabled = true;
}
return action;
});
return actions.length > 0 ? actions : undefined;
};
/**
* Convert OpenCollection actions to Bruno post-response variables.
* Only 'set-variable' actions with phase 'after-response' are converted.
*/
export const fromOpenCollectionActions = (actions: Action[] | null | undefined): BrunoVariables => {
if (!actions?.length) {
return [];
}
const resVars: BrunoVariables = [];
actions.forEach((action: Action) => {
// Only process 'set-variable' actions with 'after-response' phase
if (action.type === 'set-variable' && action.phase === 'after-response') {
const setVarAction = action as ActionSetVariable;
const variable: BrunoVariable = {
uid: uuid(),
name: setVarAction.variable?.name || '',
value: setVarAction.selector?.expression || '',
enabled: setVarAction.disabled !== true,
local: setVarAction.variable?.scope === 'request'
};
if (setVarAction.description) {
variable.description = typeof setVarAction.description === 'string'
? setVarAction.description
: (setVarAction.description as { content?: string })?.content || '';
}
resVars.push(variable);
}
});
return resVars;
};

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