Refactor dropdown components to use MenuDropdown for improved functionality and keyboard accessibility (#6404)

* Refactor dropdown components to use MenuDropdown for improved functionality and keyboard accessibility

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

* fix: Update HttpMethodSelector styles and tests for improved accessibility

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

* refactor: Improve component accessibility and consistency

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

* refactor: streamline hover interactions for collection actions across tests

* refactor: enhance component structure and accessibility across response actions

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

* refactor: simplify isDisabled logic in response components for clarity

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

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

* fix: enable action buttons in ResponsePaneActions for improved usability

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

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

* refactor: remove SidebarHeader component and related styles for improved structure
This commit is contained in:
Abhishek S Lal
2025-12-16 18:26:38 +05:30
committed by GitHub
parent 231776ca4b
commit 30d2a6d141
58 changed files with 1542 additions and 1944 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,6 @@ const Wrapper = styled.div`
padding: 0.375rem 0.625rem 0.25rem 0.625rem;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: ${(props) => props.theme.dropdown.color};
opacity: 0.6;
@@ -137,6 +136,10 @@ const Wrapper = styled.div`
margin-top: 0.25rem;
padding-top: 0.375rem;
}
&.dropdown-item-select {
padding-left: 1.5rem;
}
}
.dropdown-separator {

View File

@@ -206,7 +206,7 @@ const Auth = ({ collection, folder }) => {
Configures authentication for the entire folder. This applies to all requests using the{' '}
<span className="font-medium">Inherit</span> option in the <span className="font-medium">Auth</span> tab.
</div>
<div className="flex flex-grow justify-start items-center mb-4">
<div className="flex flex-grow justify-start items-center">
<AuthMode collection={collection} folder={folder} />
</div>
{getAuthView()}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import Documentation from 'components/Documentation/index';
import StatusDot from 'components/StatusDot';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import AuthMode from '../Auth/AuthMode/index';
const MULTIPLE_CONTENT_TABS = new Set(['params', 'script', 'vars', 'auth', 'docs']);
@@ -51,7 +52,7 @@ const HttpRequestPane = ({ item, collection }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const bodyModeRef = useRef(null);
const rightContentRef = useRef(null);
const initialAutoSelectDone = useRef(false);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
@@ -130,9 +131,13 @@ const HttpRequestPane = ({ item, collection }) => {
const isMultipleContentTab = MULTIPLE_CONTENT_TABS.has(requestPaneTab);
const rightContent = requestPaneTab === 'body' ? (
<div ref={bodyModeRef}>
<div ref={rightContentRef}>
<RequestBodyMode item={item} collection={collection} />
</div>
) : requestPaneTab === 'auth' ? (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<AuthMode item={item} collection={collection} />
</div>
) : null;
return (
@@ -142,7 +147,7 @@ const HttpRequestPane = ({ item, collection }) => {
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={bodyModeRef}
rightContentRef={rightContent ? rightContentRef : null}
delayedTabs={['body']}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,32 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
.caret {
fill: currentColor;
}
.preview-response-tab-label {
color: ${(props) => props.theme.colors.text.muted};
.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 {
background-color: ${(props) => props.theme.dropdown.separator};
height: 1px;
margin: 4px 0;
}
.active {
color: ${(props) => props.theme.colors.text.yellow};
}
.preview-response-tab-label {
color: ${(props) => props.theme.colors.text.muted};
}
`;
export default StyledWrapper;

View File

@@ -1,9 +1,34 @@
import React from 'react';
import { IconEye } from '@tabler/icons';
import ButtonDropdown from 'ui/ButtonDropdown';
import React, { forwardRef } from 'react';
import { IconEye, IconCaretDown } 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) => {
return (
<button
ref={ref}
className={classnames('button-dropdown-button flex items-center gap-1.5 text-xs',
'cursor-pointer select-none',
'h-7 rounded-[6px] border px-2 transition-colors',
{ 'opacity-50 cursor-not-allowed': disabled },
className)}
disabled={disabled}
data-testid={props['data-testid']}
style={style}
role="button"
{...props}
>
{prefix && <span>{prefix}</span>}
<span className="active">{selectedLabel}</span>
{suffix && <span>{suffix}</span>}
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</button>
);
});
ButtonIcon.displayName = 'ButtonIcon';
const QueryResultTypeSelector = ({
formatOptions,
formatValue,
@@ -11,6 +36,29 @@ const QueryResultTypeSelector = ({
onPreviewTabSelect,
selectedTab
}) => {
// Find the selected item's label
const findSelectedLabel = () => {
if (formatValue != null) {
const selectedItem = formatOptions.find((item) => item.id === formatValue && (item.type === 'item' || !item.type));
if (selectedItem) return selectedItem.label;
}
return formatValue;
};
const selectedLabel = findSelectedLabel();
// Enhance items with onChange handler
const enhancedItems = formatOptions.map((item) => {
return {
...item,
onClick: () => {
if (onFormatChange) {
onFormatChange(item.id);
}
}
};
});
const header = (
<div className="flex items-center justify-between gap-3 py-[0.35rem] px-[0.6rem]">
<span className="text-[0.8125rem] preview-response-tab-label">Preview</span>
@@ -27,18 +75,25 @@ const QueryResultTypeSelector = ({
/>
</div>
);
return (
<StyledWrapper>
<ButtonDropdown
label={formatValue}
options={formatOptions}
value={formatValue}
onChange={onFormatChange}
<MenuDropdown
items={enhancedItems}
header={header}
className="h-[20px] text-[11px]"
selectedItemId={formatValue}
showTickMark={true}
placement="bottom-end"
data-testid="format-response-tab"
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
/>
>
<ButtonIcon
selectedLabel={selectedLabel}
suffix={selectedTab === 'preview' ? <IconEye size={14} strokeWidth={2} className="active mr-[2px]" /> : null}
disabled={false}
className="h-[20px] text-[11px]"
data-testid="format-response-tab"
/>
</MenuDropdown>
</StyledWrapper>
);
};

View File

@@ -8,24 +8,24 @@ import QueryResultFilter from './QueryResultFilter';
import QueryResultPreview from './QueryResultPreview';
import StyledWrapper from './StyledWrapper';
// Raw format options (for byte format types)
const RAW_FORMAT_OPTIONS = [
{ id: 'raw', label: 'Raw', type: 'item', codeMirrorMode: 'text/plain' },
{ id: 'hex', label: 'Hex', type: 'item', codeMirrorMode: 'text/plain' },
{ id: 'base64', label: 'Base64', type: 'item', codeMirrorMode: 'text/plain' }
];
// Preview format options
const PREVIEW_FORMAT_OPTIONS = [
{
// name: 'Structured',
options: [
{ label: 'JSON', value: 'json', codeMirrorMode: 'application/ld+json' },
{ label: 'HTML', value: 'html', codeMirrorMode: 'xml' },
{ label: 'XML', value: 'xml', codeMirrorMode: 'xml' },
{ label: 'JavaScript', value: 'javascript', codeMirrorMode: 'javascript' }
]
},
{
// name: 'Raw',
options: [
{ label: 'Raw', value: 'raw', codeMirrorMode: 'text/plain' },
{ label: 'Hex', value: 'hex', codeMirrorMode: 'text/plain' },
{ label: 'Base64', value: 'base64', codeMirrorMode: 'text/plain' }
]
}
// Structured formats
{ id: 'json', label: 'JSON', type: 'item', codeMirrorMode: 'application/ld+json' },
{ id: 'html', label: 'HTML', type: 'item', codeMirrorMode: 'xml' },
{ id: 'xml', label: 'XML', type: 'item', codeMirrorMode: 'xml' },
{ id: 'javascript', label: 'JavaScript', type: 'item', codeMirrorMode: 'javascript' },
// Divider
{ type: 'divider', id: 'divider-structured-raw' },
// Raw formats
...RAW_FORMAT_OPTIONS
];
const formatErrorMessage = (error) => {
@@ -79,9 +79,11 @@ export const useResponsePreviewFormatOptions = (dataBuffer, headers) => {
const contentTypeToCheck = getContentTypeToCheck();
if (contentTypeToCheck && isByteFormatType(contentTypeToCheck)) {
return PREVIEW_FORMAT_OPTIONS.slice(1, 2); // Remove structured format options
// Return only raw format options (no structured formats)
return RAW_FORMAT_OPTIONS;
}
// Return all format options
return PREVIEW_FORMAT_OPTIONS;
}, [dataBuffer, headers]);
};
@@ -158,9 +160,10 @@ const QueryResult = ({
}, [selectedFormat, detectedContentType]);
const codeMirrorMode = useMemo(() => {
// Find the codeMirrorMode from PREVIEW_FORMAT_OPTIONS (contains all format options)
return PREVIEW_FORMAT_OPTIONS
.flatMap((option) => option.options)
.find((option) => option.value === selectedFormat)?.codeMirrorMode || 'text/plain';
.filter((option) => option.type === 'item' || !option.type)
.find((option) => option.id === selectedFormat)?.codeMirrorMode || 'text/plain';
}, [selectedFormat]);
const queryFilterEnabled = useMemo(() => codeMirrorMode.includes('json') && selectedFormat === 'json' && selectedTab === 'editor', [codeMirrorMode, selectedFormat, selectedTab]);

View File

@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, forwardRef, useImperativeHandle, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { IconBookmark } from '@tabler/icons';
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
@@ -11,6 +11,7 @@ import { getBodyType } from 'utils/responseBodyProcessor';
import { getInitialExampleName } from 'utils/collections/index';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import ActionIcon from 'ui/ActionIcon/index';
const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
if (isStreamingResponse) {
@@ -24,33 +25,26 @@ const getTitleText = ({ isResponseTooLarge, isStreamingResponse }) => {
return 'Save current response as example';
};
const ResponseBookmark = ({ item, collection, responseSize, children }) => {
const ResponseBookmark = forwardRef(({ item, collection, responseSize, children }, ref) => {
const dispatch = useDispatch();
const [showSaveResponseExampleModal, setShowSaveResponseExampleModal] = useState(false);
const response = item.response || {};
const elementRef = useRef(null);
const isResponseTooLarge = responseSize >= 5 * 1024 * 1024; // 5 MB
const isStreamingResponse = response.stream;
const isDisabled = isResponseTooLarge || isStreamingResponse;
const isDisabled = isResponseTooLarge || isStreamingResponse ? true : false;
useImperativeHandle(ref, () => ({
click: () => elementRef.current?.click(),
isDisabled
}), [isDisabled]);
// Only show for HTTP requests
if (item.type !== 'http-request') {
return null;
}
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSaveClick(e);
}
};
const handleSaveClick = (e) => {
if (!response || response.error) {
toast.error('No valid response to save as example');
@@ -141,24 +135,21 @@ const ResponseBookmark = ({ item, collection, responseSize, children }) => {
return (
<>
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
aria-disabled={isDisabled}
onKeyDown={handleKeyDown}
ref={elementRef}
onClick={handleSaveClick}
title={
!children ? disabledMessage : (isDisabled ? disabledMessage : null)
}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
'opacity-50 cursor-not-allowed': isDisabled && !children
})}
data-testid="response-bookmark-btn"
>
{children ?? (
<StyledWrapper className="flex items-center">
<button className="p-1">
<ActionIcon className="p-1" disabled={isDisabled}>
<IconBookmark size={16} strokeWidth={2} />
</button>
</ActionIcon>
</StyledWrapper>
)}
</div>
@@ -172,6 +163,8 @@ const ResponseBookmark = ({ item, collection, responseSize, children }) => {
/>
</>
);
};
});
ResponseBookmark.displayName = 'ResponseBookmark';
export default ResponseBookmark;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import { IconEraser } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { responseCleared } from 'providers/ReduxStore/slices/collections/index';
import ActionIcon from 'ui/ActionIcon/index';
// Hook to get clear response function
export const useResponseClear = (item, collection) => {
@@ -21,26 +22,28 @@ export const useResponseClear = (item, collection) => {
return { clearResponse };
};
const ResponseClear = ({ collection, item, children }) => {
const ResponseClear = forwardRef(({ collection, item, children }, ref) => {
const { clearResponse } = useResponseClear(item, collection);
const elementRef = useRef(null);
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearResponse();
}
};
useImperativeHandle(ref, () => ({
click: () => elementRef.current?.click(),
isDisabled: false
}), []);
return (
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={clearResponse} title={!children ? 'Clear response' : null} onKeyDown={handleKeyDown} data-testid="response-clear-button">
<div ref={elementRef} onClick={clearResponse} title={!children ? 'Clear response' : null} data-testid="response-clear-btn">
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<ActionIcon className="p-1">
<IconEraser size={16} strokeWidth={2} />
</button>
</ActionIcon>
</StyledWrapper>
)}
</div>
);
};
});
ResponseClear.displayName = 'ResponseClear';
export default ResponseClear;

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, forwardRef, useImperativeHandle, useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { IconCopy, IconCheck } from '@tabler/icons';
import classnames from 'classnames';
import ActionIcon from 'ui/ActionIcon/index';
// Hook to get copy response function
export const useResponseCopy = (item) => {
@@ -34,8 +36,16 @@ export const useResponseCopy = (item) => {
return { copyResponse, copied, hasData: !!response.data };
};
const ResponseCopy = ({ item, children }) => {
const ResponseCopy = forwardRef(({ item, children }, ref) => {
const { copyResponse, copied, hasData } = useResponseCopy(item);
const elementRef = useRef(null);
const isDisabled = !hasData ? true : false;
useImperativeHandle(ref, () => ({
click: () => elementRef.current?.click(),
isDisabled
}), [isDisabled]);
const handleKeyDown = (e) => {
if ((e.key === 'Enter' || e.key === ' ') && hasData) {
@@ -51,20 +61,32 @@ const ResponseCopy = ({ item, children }) => {
};
return (
<div role={!!children ? 'button' : undefined} tabIndex={0} onClick={handleClick} title={!children ? 'Copy response to clipboard' : null} onKeyDown={handleKeyDown} data-testid="response-copy-btn">
<div
ref={elementRef}
onClick={handleClick}
title={!children ? 'Copy response to clipboard' : null}
onKeyDown={handleKeyDown}
aria-disabled={isDisabled}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled && !children
})}
data-testid="response-copy-btn"
>
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1" disabled={!hasData}>
<ActionIcon className="p-1" disabled={isDisabled}>
{copied ? (
<IconCheck size={16} strokeWidth={2} />
) : (
<IconCopy size={16} strokeWidth={2} />
)}
</button>
</ActionIcon>
</StyledWrapper>
)}
</div>
);
};
});
ResponseCopy.displayName = 'ResponseCopy';
export default ResponseCopy;

View File

@@ -1,14 +1,21 @@
import React from 'react';
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconDownload } from '@tabler/icons';
import classnames from 'classnames';
import ActionIcon from 'ui/ActionIcon/index';
const ResponseDownload = ({ item, children }) => {
const ResponseDownload = forwardRef(({ item, children }, ref) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer;
const isDisabled = !response.dataBuffer ? true : false;
const elementRef = useRef(null);
useImperativeHandle(ref, () => ({
click: () => elementRef.current?.click(),
isDisabled
}), [isDisabled]);
const saveResponseToFile = () => {
if (isDisabled) {
@@ -25,39 +32,28 @@ const ResponseDownload = ({ item, children }) => {
});
};
const handleKeyDown = (e) => {
if (isDisabled) {
e.preventDefault();
e.stopPropagation();
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
saveResponseToFile();
}
};
return (
<div
role={!!children ? 'button' : undefined}
tabIndex={isDisabled ? -1 : 0}
ref={elementRef}
aria-disabled={isDisabled}
onClick={saveResponseToFile}
onKeyDown={handleKeyDown}
title={!children ? 'Save response to file' : null}
className={classnames({
'opacity-50 cursor-not-allowed': isDisabled
'opacity-50 cursor-not-allowed': isDisabled && !children
})}
data-testid="response-download-btn"
>
{children ? children : (
<StyledWrapper className="flex items-center">
<button className="p-1">
<ActionIcon className="p-1" disabled={isDisabled}>
<IconDownload size={16} strokeWidth={2} />
</button>
</ActionIcon>
</StyledWrapper>
)}
</div>
);
};
});
ResponseDownload.displayName = 'ResponseDownload';
export default ResponseDownload;

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { IconLayoutColumns, IconLayoutRows } from '@tabler/icons';
import ActionIcon from 'ui/ActionIcon/index';
export const IconDockToBottom = () => {
return (
@@ -70,40 +71,39 @@ export const useResponseLayoutToggle = () => {
return { orientation, toggleOrientation };
};
const ResponseLayoutToggle = ({ children }) => {
const ResponseLayoutToggle = forwardRef(({ children }, ref) => {
const { orientation, toggleOrientation } = useResponseLayoutToggle();
const elementRef = useRef(null);
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleOrientation();
}
};
useImperativeHandle(ref, () => ({
click: () => elementRef.current?.click(),
isDisabled: false
}), []);
const title = !children ? (orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout') : null;
return (
<div
role={children ? 'button' : undefined}
tabIndex={0}
ref={elementRef}
onClick={toggleOrientation}
title={title}
onKeyDown={handleKeyDown}
data-testid="response-layout-toggle-button"
data-testid="response-layout-toggle-btn"
>
{children ? children : (
<StyledWrapper className="flex items-center w-full">
<button className="p-1">
<ActionIcon className="p-1">
{orientation === 'vertical' ? (
<IconLayoutColumns size={16} strokeWidth={1.5} />
<IconLayoutColumns size={16} strokeWidth={2} />
) : (
<IconLayoutRows size={16} strokeWidth={1.5} />
<IconLayoutRows size={16} strokeWidth={2} />
)}
</button>
</ActionIcon>
</StyledWrapper>
)}
</div>
);
};
});
ResponseLayoutToggle.displayName = 'ResponseLayoutToggle';
export default ResponseLayoutToggle;

View File

@@ -84,7 +84,7 @@ describe('ResponseLayoutToggle', () => {
describe('Initial Render', () => {
it('should render with horizontal orientation by default', () => {
renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByTestId('response-layout-toggle-btn');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
});
@@ -100,7 +100,7 @@ describe('ResponseLayoutToggle', () => {
}
};
renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByTestId('response-layout-toggle-btn');
expect(button).toBeInTheDocument();
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
});
@@ -109,7 +109,7 @@ describe('ResponseLayoutToggle', () => {
describe('Interaction', () => {
it('should switch to vertical layout when clicked in horizontal mode', () => {
const { store } = renderWithProviders(<ResponseLayoutToggle />);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByTestId('response-layout-toggle-btn');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to vertical layout');
@@ -145,7 +145,7 @@ describe('ResponseLayoutToggle', () => {
}
};
const { store } = renderWithProviders(<ResponseLayoutToggle />, customState);
const button = screen.getByTestId('response-layout-toggle-button');
const button = screen.getByTestId('response-layout-toggle-btn');
// Initial state check
expect(button).toHaveAttribute('title', 'Switch to horizontal layout');

View File

@@ -1,7 +1,7 @@
import React, { useRef, forwardRef } from 'react';
import React, { forwardRef, useRef } from 'react';
import styled from 'styled-components';
import { IconDots, IconDownload, IconEraser, IconBookmark, IconCopy, IconLayoutColumns, IconLayoutRows } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import ResponseDownload from '../ResponseDownload';
import ResponseBookmark from '../ResponseBookmark';
import ResponseClear from '../ResponseClear';
@@ -39,17 +39,61 @@ MenuIcon.displayName = 'MenuIcon';
const ResponsePaneActions = ({ item, collection, responseSize }) => {
const { orientation } = useResponseLayoutToggle();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
// Refs to access child component imperative handles (click, isDisabled)
const bookmarkButtonRef = useRef(null);
const downloadButtonRef = useRef(null);
const clearButtonRef = useRef(null);
const copyButtonRef = useRef(null);
const layoutToggleButtonRef = useRef(null);
const closeDropdown = () => {
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
const menuItems = [
{
id: 'copy-response',
label: 'Copy response',
leftSection: IconCopy,
get disabled() {
return copyButtonRef.current?.isDisabled ?? false;
},
onClick: () => copyButtonRef.current?.click()
},
{
id: 'save-response',
label: 'Save response',
leftSection: IconBookmark,
get disabled() {
return bookmarkButtonRef.current?.isDisabled ?? false;
},
onClick: () => bookmarkButtonRef.current?.click()
},
{
id: 'download-response',
label: 'Download response',
leftSection: IconDownload,
get disabled() {
return downloadButtonRef.current?.isDisabled ?? false;
},
onClick: () => downloadButtonRef.current?.click()
},
{
id: 'clear-response',
label: 'Clear response',
leftSection: IconEraser,
get disabled() {
return clearButtonRef.current?.isDisabled ?? false;
},
onClick: () => clearButtonRef.current?.click()
},
{
id: 'change-layout',
label: 'Change layout',
leftSection: orientation === 'vertical' ? IconLayoutColumns : IconLayoutRows,
get disabled() {
return layoutToggleButtonRef.current?.isDisabled ?? false;
},
onClick: () => layoutToggleButtonRef.current?.click()
}
};
];
if (item.type !== 'http-request') {
return null;
@@ -58,65 +102,20 @@ const ResponsePaneActions = ({ item, collection, responseSize }) => {
return (
<StyledWrapper className="response-pane-actions-wrapper">
<div className="actions-dropdown">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon data-testid="response-actions-menu" />} placement="bottom-end">
{/* Response Copy */}
<ResponseCopy item={item}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={1.5} />
</span>
<span>Copy response</span>
</div>
</ResponseCopy>
{/* Response Save as Example */}
<ResponseBookmark item={item} collection={collection} responseSize={responseSize}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconBookmark size={16} strokeWidth={1.5} />
</span>
<span>Save response</span>
</div>
</ResponseBookmark>
{/* Response Download */}
<ResponseDownload item={item}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconDownload size={16} strokeWidth={1.5} />
</span>
Download response
</div>
</ResponseDownload>
{/* Response Clear */}
<ResponseClear item={item} collection={collection}>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
<IconEraser size={16} strokeWidth={1.5} />
</span>
Clear response
</div>
</ResponseClear>
{/* Response Layout Toggle */}
<ResponseLayoutToggle>
<div className="dropdown-item" onClick={closeDropdown}>
<span className="dropdown-icon">
{orientation === 'vertical' ? <IconLayoutColumns size={16} strokeWidth={1.5} /> : <IconLayoutRows size={16} strokeWidth={1.5} />}
</span>
<span>Change layout</span>
</div>
</ResponseLayoutToggle>
</Dropdown>
<MenuDropdown
items={menuItems}
placement="bottom-end"
data-testid="response-actions-menu"
>
<MenuIcon />
</MenuDropdown>
</div>
<div className="actions-buttons flex items-center gap-[2px]">
<ResponseCopy item={item} />
<ResponseBookmark item={item} collection={collection} responseSize={responseSize} />
<ResponseDownload item={item} />
<ResponseClear item={item} collection={collection} />
<ResponseLayoutToggle />
<ResponseCopy ref={copyButtonRef} item={item} />
<ResponseBookmark ref={bookmarkButtonRef} item={item} collection={collection} responseSize={responseSize} />
<ResponseDownload ref={downloadButtonRef} item={item} />
<ResponseClear ref={clearButtonRef} item={item} collection={collection} />
<ResponseLayoutToggle ref={layoutToggleButtonRef} />
</div>
</StyledWrapper>

View File

@@ -167,7 +167,7 @@ const ExampleItem = ({ example, item, collection }) => {
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
menuDropdownRef.current?.open();
menuDropdownRef.current?.show();
};
const itemRowClassName = classnames('flex collection-item-name relative items-center', {

View File

@@ -26,6 +26,11 @@ const Wrapper = styled.div`
user-select: none;
position: relative;
/* Default: menu icon hidden, shown on hover/focus states (see consolidated rule below) */
.collection-item-menu-icon {
visibility: hidden;
}
/* Common styles for drop indicators */
&::before,
&::after {
@@ -50,7 +55,7 @@ const Wrapper = styled.div`
/* Drop target styles */
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
&::before,
&::after {
opacity: 0;
@@ -94,10 +99,13 @@ const Wrapper = styled.div`
overflow: hidden;
}
/* Single source of truth for hover/focus states: background and menu icon visibility */
&:hover,
&.item-hovered {
&.item-hovered,
&.item-keyboard-focused {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.menu-icon {
.menu-icon,
.collection-item-menu-icon {
visibility: visible;
}
}

View File

@@ -27,7 +27,6 @@ import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/s
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid } from 'utils/common';
import { copyRequest } from 'providers/ReduxStore/slices/app';
import MenuDropdown from 'ui/MenuDropdown';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import RenameCollectionItem from './RenameCollectionItem';
@@ -53,6 +52,8 @@ import { calculateDraggedItemNewPathname, getInitialExampleName } from 'utils/co
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';
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
@@ -285,7 +286,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
menuDropdownRef.current?.open();
menuDropdownRef.current?.show();
};
let indents = range(item.depth);
@@ -362,6 +363,16 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onClick: handleShowInFolder
}
);
if (!isFolder && isItemARequest(item) && !(item.type === 'http-request' || item.type === 'graphql-request')) {
items.push({
id: 'run',
leftSection: IconPlayerPlay,
label: 'Run',
onClick: () => {
handleRun();
}
});
}
if (!isFolder && (item.type === 'http-request' || item.type === 'graphql-request')) {
items.push({
@@ -633,7 +644,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
<div style={{ width: 16, minWidth: 16 }}>
<ActionIcon style={{ width: 16, minWidth: 16 }}>
{isFolder ? (
<IconChevronRight
size={16}
@@ -655,7 +666,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
data-testid="request-item-chevron"
/>
) : null}
</div>
</ActionIcon>
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
@@ -663,14 +674,16 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
</span>
</div>
</div>
<div className="menu-icon pr-2">
<div className="pr-2">
<MenuDropdown
ref={menuDropdownRef}
items={buildMenuItems()}
placement="bottom-start"
data-testid="collection-item-menu"
>
<IconDots size={22} />
<ActionIcon className="menu-icon">
<IconDots size={18} className="collection-item-menu-icon" />
</ActionIcon>
</MenuDropdown>
</div>
</div>

View File

@@ -11,44 +11,25 @@ const Wrapper = styled.div`
.rotate-90 {
transform: rotateZ(90deg);
}
.collection-actions {
visibility: hidden;
}
&:hover,
&:focus-within,
&.collection-keyboard-focused {
.collection-actions {
visibility: visible;
}
}
&.item-hovered {
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-bottom: 2px solid transparent;
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
}
}
.collection-actions {
.dropdown {
div[aria-expanded='true'] {
visibility: visible;
}
div[aria-expanded='false'] {
visibility: hidden;
}
}
svg {
height: 22px;
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
}
}
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
}
}
div.tippy-box {

View File

@@ -1,4 +1,4 @@
import React, { useState, forwardRef, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import classnames from 'classnames';
import { uuid } from 'utils/common';
@@ -18,11 +18,11 @@ import {
IconFoldDown,
IconX,
IconSettings,
IconTerminal2
IconTerminal2,
IconFolder
} from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem } from 'providers/ReduxStore/slices/collections/actions';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop, pasteItem, showInFolder } 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';
@@ -44,6 +44,8 @@ import ShareCollection from 'components/ShareCollection/index';
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
import { sortByNameThenSequence } from 'utils/common/index';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
import ActionIcon from 'ui/ActionIcon';
import MenuDropdown from 'ui/MenuDropdown';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -60,15 +62,7 @@ const Collection = ({ collection, searchText }) => {
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((_props, ref) => {
return (
<div ref={ref} className="pr-2">
<IconDots size={22} />
</div>
);
});
const menuDropdownRef = useRef(null);
const handleRun = () => {
dispatch(
@@ -140,15 +134,9 @@ const Collection = ({ collection, searchText }) => {
e.preventDefault();
};
const handleRightClick = (_event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
let menuDropdownBehavior = 'show';
if (_menuDropdown.state.isShown) {
menuDropdownBehavior = 'hide';
}
_menuDropdown[menuDropdownBehavior]();
}
const handleRightClick = (event) => {
event.preventDefault();
menuDropdownRef.current?.show();
};
const handleCollapseFullCollection = () => {
@@ -165,8 +153,14 @@ const Collection = ({ collection, searchText }) => {
);
};
const handleShowInFolder = () => {
dispatch(showInFolder(collection.pathname)).catch((error) => {
console.error('Error opening the folder', error);
toast.error('Error opening the folder');
});
};
const handlePasteItem = () => {
menuDropdownTippyRef.current.hide();
dispatch(pasteItem(collection.uid, null))
.then(() => {
toast.success('Item pasted successfully');
@@ -276,6 +270,111 @@ const Collection = ({ collection, searchText }) => {
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
const menuItems = [
{
id: 'new-request',
leftSection: IconFilePlus,
label: 'New Request',
onClick: () => {
ensureCollectionIsMounted();
setShowNewRequestModal(true);
}
},
{
id: 'new-folder',
leftSection: IconFolderPlus,
label: 'New Folder',
onClick: () => {
ensureCollectionIsMounted();
setShowNewFolderModal(true);
}
},
{
id: 'run',
leftSection: IconPlayerPlay,
label: 'Run',
onClick: () => {
ensureCollectionIsMounted();
handleRun();
}
},
{
id: 'clone',
leftSection: IconCopy,
label: 'Clone',
testId: 'clone-collection',
onClick: () => {
setShowCloneCollectionModalOpen(true);
}
},
...(hasCopiedItems
? [
{
id: 'paste',
leftSection: IconClipboard,
label: 'Paste',
onClick: handlePasteItem
}
]
: []),
{
id: 'rename',
leftSection: IconEdit,
label: 'Rename',
onClick: () => {
setShowRenameCollectionModal(true);
}
},
{
id: 'share',
leftSection: IconShare,
label: 'Share',
onClick: () => {
ensureCollectionIsMounted();
setShowShareCollectionModal(true);
}
},
{
id: 'collapse',
leftSection: IconFoldDown,
label: 'Collapse',
onClick: handleCollapseFullCollection
},
{
id: 'show-in-folder',
leftSection: IconFolder,
label: 'Show in File Explorer',
onClick: handleShowInFolder
},
{
id: 'divider-1',
type: 'divider'
},
{
id: 'settings',
leftSection: IconSettings,
label: 'Settings',
onClick: viewCollectionSettings
},
{
id: 'terminal',
leftSection: IconTerminal2,
label: 'Open in Terminal',
onClick: async () => {
const collectionCwd = collection.pathname;
await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd);
}
},
{
id: 'remove',
leftSection: IconX,
label: 'Remove',
onClick: () => {
setShowRemoveCollectionModal(true);
}
}
];
return (
<StyledWrapper className="flex flex-col" id={`collection-${collection.name.replace(/\s+/g, '-').toLowerCase()}`}>
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
@@ -311,160 +410,34 @@ const Collection = ({ collection, searchText }) => {
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick}
>
<IconChevronRight
size={16}
strokeWidth={2}
className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleCollectionCollapse}
onDoubleClick={handleCollectionDoubleClick}
/>
<ActionIcon style={{ width: 16, minWidth: 16 }}>
<IconChevronRight
size={16}
strokeWidth={2}
className={`chevron-icon ${iconClassName}`}
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
onClick={handleCollectionCollapse}
onDoubleClick={handleCollectionDoubleClick}
/>
</ActionIcon>
<div className="ml-1 w-full" id="sidebar-collection-name" title={collection.name}>
{collection.name}
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
</div>
<div className="collection-actions" data-testid="collection-actions">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
setShowNewRequestModal(true);
}}
<div>
<div className="pr-2">
<MenuDropdown
ref={menuDropdownRef}
items={menuItems}
placement="bottom-start"
data-testid="collection-actions"
>
<span className="dropdown-icon">
<IconFilePlus size={16} strokeWidth={2} />
</span>
New Request
</div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
setShowNewFolderModal(true);
}}
>
<span className="dropdown-icon">
<IconFolderPlus size={16} strokeWidth={2} />
</span>
New Folder
</div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
handleRun();
}}
>
<span className="dropdown-icon">
<IconPlayerPlay size={16} strokeWidth={2} />
</span>
Run
</div>
<div
className="dropdown-item"
data-testid="clone-collection"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowCloneCollectionModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={2} />
</span>
Clone
</div>
{hasCopiedItems && (
<div
className="dropdown-item"
onClick={handlePasteItem}
>
<span className="dropdown-icon">
<IconClipboard size={16} strokeWidth={2} />
</span>
Paste
</div>
)}
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowRenameCollectionModal(true);
}}
>
<span className="dropdown-icon">
<IconEdit size={16} strokeWidth={2} />
</span>
Rename
</div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
ensureCollectionIsMounted();
setShowShareCollectionModal(true);
}}
>
<span className="dropdown-icon">
<IconShare size={16} strokeWidth={2} />
</span>
Share
</div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
handleCollapseFullCollection();
}}
>
<span className="dropdown-icon">
<IconFoldDown size={16} strokeWidth={2} />
</span>
Collapse
</div>
<div className="dropdown-separator"></div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
viewCollectionSettings();
}}
>
<span className="dropdown-icon">
<IconSettings size={16} strokeWidth={2} />
</span>
Settings
</div>
<div
className="dropdown-item"
onClick={async (_e) => {
menuDropdownTippyRef.current.hide();
const collectionCwd = collection.pathname;
await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd);
}}
>
<span className="dropdown-icon">
<IconTerminal2 size={16} strokeWidth={2} />
</span>
Open in Terminal
</div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowRemoveCollectionModal(true);
}}
>
<span className="dropdown-icon">
<IconX size={16} strokeWidth={2} />
</span>
Remove
</div>
</Dropdown>
<ActionIcon className="collection-actions">
<IconDots size={18} />
</ActionIcon>
</MenuDropdown>
</div>
</div>
</div>
<div>

View File

@@ -1,107 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
padding: 6px 4px 6px 10px;
}
/* Section Title (single view mode) */
.section-title {
display: flex;
align-items: center;
gap: 6px;
color: ${(props) => props.theme.sidebar.color};
font-size: 12px;
font-weight: 600;
padding: 2px 0;
svg {
color: ${(props) => props.theme.sidebar.muted};
}
}
/* View Tabs (multi-view mode) */
.view-tabs {
display: flex;
align-items: center;
gap: 2px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-radius: 6px;
padding: 2px;
}
.view-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.muted};
font-size: 11px;
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
color: ${(props) => props.theme.sidebar.color};
}
&.active {
background: ${(props) => props.theme.sidebar.bg};
color: ${(props) => props.theme.sidebar.color};
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
svg {
flex-shrink: 0;
}
span {
display: none;
}
@media (min-width: 280px) {
span {
display: inline;
}
}
}
/* Header Actions */
.header-actions {
display: flex;
align-items: center;
gap: 1px;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.sidebar.muted};
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.dropdown?.hoverBg || props.theme.sidebar?.collection?.item?.hoverBg};
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
}
}
`;
export default StyledWrapper;

View File

@@ -1,303 +0,0 @@
import {
IconArrowsSort,
IconBox,
IconDeviceDesktop,
IconDotsVertical,
IconDownload,
IconFileCode,
IconFolder,
IconPlus,
IconSearch,
IconSortAscendingLetters,
IconSortDescendingLetters,
IconSquareX,
IconTrash
} from '@tabler/icons';
import { useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import RemoveCollectionsModal from '../Collections/RemoveCollectionsModal/index';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
const SidebarHeader = ({ setShowSearch }) => {
const dispatch = useDispatch();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
// Get collection sort order
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const [collectionsToClose, setCollectionsToClose] = useState([]);
const [importData, setImportData] = useState(null);
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [createApiSpecModalOpen, setCreateApiSpecModalOpen] = useState(false);
const handleImportCollection = ({ rawData, type }) => {
setImportCollectionModalOpen(false);
setImportData({ rawData, type });
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
dispatch(importCollection(convertedCollection, collectionLocation))
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
console.error(err);
toast.error('An error occurred while importing the collection');
});
};
const handleToggleSearch = () => {
if (setShowSearch) {
setShowSearch((prev) => !prev);
}
};
const handleSortCollections = () => {
let order;
switch (collectionSortOrder) {
case 'default':
order = 'alphabetical';
break;
case 'alphabetical':
order = 'reverseAlphabetical';
break;
case 'reverseAlphabetical':
order = 'default';
break;
default:
order = 'default';
break;
}
dispatch(sortCollections({ order }));
};
const getSortIcon = () => {
switch (collectionSortOrder) {
case 'alphabetical':
return IconSortDescendingLetters;
case 'reverseAlphabetical':
return IconArrowsSort;
default:
return IconSortAscendingLetters;
}
};
const getSortLabel = () => {
switch (collectionSortOrder) {
case 'alphabetical':
return 'Sort Z-A';
case 'reverseAlphabetical':
return 'Clear sort';
default:
return 'Sort A-Z';
}
};
const selectAllCollectionsToClose = () => {
setCollectionsToClose(collections.map((c) => c.uid));
};
const clearCollectionsToClose = () => {
setCollectionsToClose([]);
};
const handleOpenCollection = () => {
const options = {};
if (activeWorkspace?.pathname) {
options.workspaceId = activeWorkspace.pathname;
}
dispatch(openCollection(options)).catch((err) => {
toast.error('An error occurred while opening the collection');
});
};
const handleOpenApiSpec = () => {
dispatch(openApiSpec()).catch((err) => {
console.error(err);
toast.error('An error occurred while opening the API spec');
});
};
const renderModals = () => (
<>
{createCollectionModalOpen && (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}
/>
)}
{importCollectionModalOpen && (
<ImportCollection
onClose={() => setImportCollectionModalOpen(false)}
handleSubmit={handleImportCollection}
/>
)}
{importCollectionLocationModalOpen && importData && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
)}
{createApiSpecModalOpen && (
<CreateApiSpec
onClose={() => setCreateApiSpecModalOpen(false)}
/>
)}
</>
);
// Configuration for Add/Create dropdown items
const addDropdownItems = [
{
id: 'create',
leftSection: IconPlus,
label: 'Create collection',
onClick: () => {
setCreateCollectionModalOpen(true);
}
},
{
id: 'import',
leftSection: IconDownload,
label: 'Import collection',
onClick: () => {
setImportCollectionModalOpen(true);
}
},
{
id: 'open',
leftSection: IconFolder,
label: 'Open collection',
onClick: () => {
handleOpenCollection();
}
},
{
type: 'label',
label: 'API Specs'
},
{
id: 'create-api-spec',
leftSection: IconPlus,
label: 'Create API Spec',
onClick: () => {
setCreateApiSpecModalOpen(true);
}
},
{
id: 'open-api-spec',
leftSection: IconFileCode,
label: 'Open API Spec',
onClick: () => {
handleOpenApiSpec();
}
}
];
// Configuration for Actions dropdown items
const actionsDropdownItems = [
{
id: 'sort',
leftSection: getSortIcon(),
label: getSortLabel(),
onClick: () => {
handleSortCollections();
}
},
{
id: 'close-all',
leftSection: IconSquareX,
label: 'Close all',
onClick: () => {
selectAllCollectionsToClose();
}
}
];
// Render Collections-specific actions
const renderCollectionsActions = () => (
<>
<ActionIcon
onClick={handleToggleSearch}
label="Search requests"
>
<IconSearch size={14} stroke={1.5} aria-hidden="true" />
</ActionIcon>
{/* Add Collection dropdown */}
<MenuDropdown
data-testid="collections-header-add-menu"
items={[
{ type: 'label', label: 'Collections' },
...addDropdownItems
]}
placement="bottom-end"
>
<ActionIcon
label="Add new collection"
>
<IconPlus size={14} stroke={1.5} aria-hidden="true" />
</ActionIcon>
</MenuDropdown>
{/* More Actions dropdown (sort, close all, etc.) */}
<MenuDropdown
data-testid="collections-header-actions-menu"
items={actionsDropdownItems}
placement="bottom-end"
>
<ActionIcon
label="More actions"
>
<IconDotsVertical size={14} stroke={1.5} aria-hidden="true" />
</ActionIcon>
</MenuDropdown>
{collectionsToClose.length > 0 && (
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
)}
</>
);
return (
<StyledWrapper>
{renderModals()}
<div className="sidebar-header">
<div className="section-title">
<IconBox size={14} stroke={1.5} />
<span>Collections</span>
</div>
{/* Action Buttons - Context Sensitive */}
<div className="header-actions">
{renderCollectionsActions()}
</div>
</div>
</StyledWrapper>
);
};
export default SidebarHeader;

View File

@@ -4,7 +4,7 @@ import { IconCategory, IconDots, IconEdit, IconX, IconCheck, IconFolder } from '
import { renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import CloseWorkspace from 'components/Sidebar/SidebarHeader/CloseWorkspace';
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
import WorkspaceOverview from './WorkspaceOverview';
import WorkspaceEnvironments from './WorkspaceEnvironments';
import StyledWrapper from './StyledWrapper';

View File

@@ -34,6 +34,10 @@ const StyledWrapper = styled.button`
${(props) => variants[props.$variant] || variants.subtle}
${(props) => props.$color && css`
color: ${props.$color};
`}
svg {
stroke: currentColor;
}

View File

@@ -15,6 +15,8 @@ import StyledWrapper from './StyledWrapper';
* @param {string} props.title - Title attribute (falls back to label or aria-label)
* @param {string} [props.ariaLabel] - Accessibility label (falls back to label or title)
* @param {string} props.colorOnHover - Color to apply to icon on hover/focus (e.g., 'red', '#ef4444', 'var(--color-danger)')
* @param {string} props.color - Color to override the default variant color (e.g., 'red', '#ef4444', 'var(--color-text)')
* @param {Object} props.style - Style object to override the default variant style (e.g., 'width: 16px; min-width: 16px;')
* @param {Object} props...rest - Other props passed to the underlying element
*/
const ActionIcon = ({
@@ -27,6 +29,8 @@ const ActionIcon = ({
label,
'aria-label': ariaLabel,
colorOnHover,
color,
style,
...rest
}) => {
// Build className array and filter out empty strings
@@ -38,10 +42,12 @@ const ActionIcon = ({
$variant={variant}
$size={size}
$colorOnHover={colorOnHover}
$color={color}
disabled={disabled}
className={classNames}
title={label}
aria-label={ariaLabel}
style={style}
{...rest}
>
{children}

View File

@@ -1,24 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.caret {
fill: currentColor;
}
.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 {
background-color: ${(props) => props.theme.dropdown.separator};
height: 1px;
margin: 4px 0;
}
`;
export default StyledWrapper;

View File

@@ -1,146 +0,0 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
const ButtonIcon = forwardRef(({ disabled, className, style, prefix, selectedLabel, suffix, ...props }, ref) => {
return (
<button
ref={ref}
className={classnames('button-dropdown-button flex items-center gap-1.5 text-xs',
'cursor-pointer select-none',
'h-7 rounded-[6px] border px-2 transition-colors',
{ 'opacity-50 cursor-not-allowed': disabled },
className)}
disabled={disabled}
data-testid={props['data-testid']}
style={style}
role="button"
{...props}
>
{prefix && <span>{prefix}</span>}
<span className="active">{selectedLabel}</span>
{suffix && <span>{suffix}</span>}
<IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</button>
);
});
ButtonIcon.displayName = 'ButtonIcon';
const ButtonDropdown = ({
label,
options,
onChange,
value,
disabled,
className,
style,
header,
prefix,
suffix,
...props
}) => {
const dropdownTippyRef = useRef(null);
// Check if options is a group array
const isGrouped = Array.isArray(options) && options.length > 0 && 'options' in options[0];
// Find the selected option's label
const findSelectedLabel = () => {
if (isGrouped) {
const groups = options;
for (const group of groups) {
const option = group.options.find((opt) => opt.value === value);
if (option) return option.label;
}
} else {
const flatOptions = options;
const option = flatOptions.find((opt) => opt.value === value);
if (option) return option.label;
}
return label;
};
const selectedLabel = findSelectedLabel();
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const handleOptionSelect = (optionValue) => {
onChange(optionValue);
dropdownTippyRef.current?.hide();
};
// Flatten options for rendering
const renderOptions = () => {
if (isGrouped) {
const groups = options;
return groups.map((group, groupIndex) => (
<React.Fragment key={groupIndex}>
{group.options.map((option, optionIndex) => {
const isFirstInGroup = optionIndex === 0;
const isFirstGroup = groupIndex === 0;
const showSeparator = !isFirstGroup && isFirstInGroup;
return (
<div
key={option.value}
className={classnames('dropdown-item flex items-center gap-2',
{
'active': option.value === value,
'border-top': showSeparator
})}
onClick={() => handleOptionSelect(option.value)}
>
<span>{option.label}</span>
{option.value === value && (
<span className="ml-auto"></span>
)}
</div>
);
})}
</React.Fragment>
));
} else {
const flatOptions = options;
return flatOptions.map((option) => (
<div
key={option.value}
className={classnames('dropdown-item flex items-center gap-2', {
active: option.value === value
})}
onClick={() => handleOptionSelect(option.value)}
>
<span>{option.label}</span>
{option.value === value && (
<span className="ml-auto"></span>
)}
</div>
));
}
};
return (
<StyledWrapper>
<Dropdown
onCreate={onDropdownCreate}
icon={<ButtonIcon selectedLabel={selectedLabel} prefix={prefix} suffix={suffix} disabled={disabled} className={className} style={style} {...props} />}
placement="bottom-end"
disabled={disabled}
>
<div {...(props['data-testid'] && { 'data-testid': props['data-testid'] + '-dropdown' })}>
{header && (
<div className="dropdown-header-container" onClick={() => dropdownTippyRef.current?.hide()}>
{header}
<div className="dropdown-divider"></div>
</div>
)}
{renderOptions()}
</div>
</Dropdown>
</StyledWrapper>
);
};
export default ButtonDropdown;

View File

@@ -0,0 +1,150 @@
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,6 @@
import React, { forwardRef, useImperativeHandle } from 'react';
import { useRef, useCallback, useState } from 'react';
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'];
@@ -19,7 +19,8 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
* MenuDropdown - A reusable dropdown menu component with keyboard navigation
*
* @param {Object} props
* @param {Array} props.items - Array of menu items with structure:
* @param {Array} props.items - Array of menu items. Supports multiple formats:
* Standard format (MenuDropdown items):
* - id: string (unique identifier)
* - type: 'item' | 'label' | 'divider' (default: 'item')
* - leftSection: React component or React element (rendered on the left side, for items only)
@@ -31,10 +32,21 @@ const getNextIndex = (currentIndex, total, key, noFocus) => {
* - testId: string (optional, for testing, for items only)
* - disabled: boolean (optional, for items only)
* - className: string (optional, additional CSS classes for the item)
*
* Grouped format: [{name: string, options: [{id, label, ...}]}, ...]
* Flat format: [{id, label, ...}, ...]
* @param {ReactNode} props.children - The trigger element (button, etc.)
* @param {string} props.placement - Tippy placement (default: 'bottom-end')
* @param {string} props.className - Optional className for the dropdown
* @param {string} props.selectedItemId - Optional ID of the selected/active item to focus on open
* @param {boolean} props.opened - Controlled open state (when provided, component is controlled)
* @param {function} props.onChange - Callback when dropdown state changes: (opened: boolean) => void
* @param {ReactNode} props.header - Optional header content to render above menu items
* @param {ReactNode} props.footer - Optional footer content to render below menu items
* @param {boolean} props.showTickMark - Optional flag to show checkmark (✓) on selected items (default: true)
* @param {boolean} props.showGroupDividers - Optional flag to show dividers between groups in grouped format (default: true)
* @param {string} props.groupStyle - Style for grouped items: 'action' (default, normal case) or 'select' (uppercase labels, indented items)
* @param {boolean} props.autoFocusFirstOption - Optional flag to auto-focus first option when dropdown opens (default: false)
* @param {Object} props.dropdownProps - Other props passed to underlying Dropdown component
* @param {React.Ref} ref - Optional ref to expose open/close methods
*/
@@ -44,18 +56,36 @@ const MenuDropdown = forwardRef(({
placement = 'bottom-end',
className,
selectedItemId,
opened,
onChange,
header,
footer,
showTickMark = true,
showGroupDividers = true,
groupStyle = 'action',
autoFocusFirstOption = false,
'data-testid': testId = 'menu-dropdown',
...dropdownProps
}, ref) => {
const tippyRef = useRef();
const [isOpen, setIsOpen] = useState(false);
const selectedItemIdRef = useRef(selectedItemId);
const autoFocusFirstOptionRef = useRef(autoFocusFirstOption);
const [internalIsOpen, setInternalIsOpen] = useState(false);
// Expose open/close methods via ref
useImperativeHandle(ref, () => ({
open: () => setIsOpen(true),
close: () => setIsOpen(false),
toggle: () => setIsOpen((prev) => !prev)
}), []);
// Keep refs in sync
useEffect(() => {
selectedItemIdRef.current = selectedItemId;
}, [selectedItemId]);
useEffect(() => {
autoFocusFirstOptionRef.current = autoFocusFirstOption;
}, [autoFocusFirstOption]);
// Determine if component is controlled
const isControlled = opened !== undefined;
// Use controlled state if provided, otherwise use internal state
const isOpen = isControlled ? opened : internalIsOpen;
// Get all focusable menu items from the menu dropdown
const getMenuItems = useCallback(() => {
@@ -70,29 +100,123 @@ const MenuDropdown = forwardRef(({
);
}, []);
// Update state (respects controlled vs uncontrolled mode)
const updateOpenState = useCallback((newState) => {
if (isControlled) {
onChange?.(newState);
} else {
setInternalIsOpen(newState);
}
}, [isControlled, onChange]);
// Handle item click and close dropdown
const handleItemClick = useCallback((item) => {
if (item.disabled) return;
item.onClick?.();
setIsOpen(false);
}, []);
updateOpenState(false);
}, [updateOpenState]);
// Convert legacy formats (grouped or flat) to standard MenuDropdown items format
const normalizeItems = useCallback((itemsToNormalize) => {
if (!Array.isArray(itemsToNormalize) || itemsToNormalize.length === 0) {
return [];
}
// Check if it's a grouped format: [{options: [{value, label, ...}]}, ...]
const firstItem = itemsToNormalize[0];
const isGrouped = firstItem != null && typeof firstItem === 'object' && 'options' in firstItem;
if (isGrouped) {
const result = [];
itemsToNormalize.forEach((group, groupIndex) => {
// Add divider before each group except the first (if showGroupDividers is true)
if (groupIndex > 0 && showGroupDividers) {
result.push({ type: 'divider', id: `divider-${groupIndex}` });
}
// Add group name as label
if (group.name) {
const normalizeGroupNameForId = (group.name || '').toLowerCase().replace(/ /g, '-');
result.push({ type: 'label', id: `label-${normalizeGroupNameForId}-${groupIndex}`, label: group.name, groupStyle });
}
// Convert group options to menu items
group.options.forEach((option) => {
result.push({
id: option.id,
label: option.label,
type: 'item',
onClick: option.onClick,
disabled: option.disabled,
className: option.className,
leftSection: option.leftSection,
rightSection: option.rightSection,
ariaLabel: option.ariaLabel,
title: option.title,
groupStyle: groupStyle
});
});
});
return result;
}
// Already in standard format, return as-is
return itemsToNormalize;
}, [showGroupDividers, groupStyle]);
// Normalize items to standard format
const normalizedItems = useMemo(() => normalizeItems(items), [items, normalizeItems]);
// Enhance items with tick mark for selected item if showTickMark is enabled
const enhancedItems = useMemo(() => {
if (!showTickMark || selectedItemId == null) {
return normalizedItems;
}
return normalizedItems.map((item) => {
// Skip non-item types (dividers, labels)
if (item.type && item.type !== 'item') {
return item;
}
const isSelected = item.id === selectedItemId;
// Only add tick mark if item is selected and doesn't already have a rightSection
if (isSelected && !item.rightSection) {
return {
...item,
rightSection: <span className="ml-auto"></span>
};
}
return item;
});
}, [normalizedItems, showTickMark, selectedItemId]);
// Clear focused class from all items
const clearFocusedClass = (menuContainer) => {
if (menuContainer) {
menuContainer.querySelectorAll('.dropdown-item-focused').forEach((el) => {
el.classList.remove('dropdown-item-focused');
});
}
};
// Focus a menu item
const focusMenuItem = (item, addSelectedClass = false) => {
const focusMenuItem = (item, addFocusedClass = true) => {
if (item) {
// Remove selected class from all items first
// Remove focused class from all items first
const menuContainer = item.closest('[role="menu"]');
if (menuContainer) {
menuContainer.querySelectorAll('.selected-focused').forEach((el) => {
el.classList.remove('selected-focused');
});
}
clearFocusedClass(menuContainer);
if (addSelectedClass) {
item.classList.add('selected-focused');
if (addFocusedClass) {
item.classList.add('dropdown-item-focused');
}
item.focus();
item.scrollIntoView({ block: 'nearest' });
// scrollIntoView may not be available in test environments (jsdom)
if (typeof item.scrollIntoView === 'function') {
item.scrollIntoView({ block: 'nearest' });
}
}
};
@@ -108,7 +232,7 @@ const MenuDropdown = forwardRef(({
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
setIsOpen(false);
updateOpenState(false);
return;
}
@@ -118,7 +242,8 @@ const MenuDropdown = forwardRef(({
e.stopPropagation();
const currentItem = itemsToNavigate[currentIndex];
const itemId = currentItem?.getAttribute('data-item-id');
const item = items.find((i) => i.id === itemId);
// Use enhancedItems for finding the item
const item = enhancedItems.find((i) => i.id === itemId);
if (item && !item.disabled) {
handleItemClick(item);
}
@@ -130,19 +255,32 @@ const MenuDropdown = forwardRef(({
e.preventDefault();
e.stopPropagation();
const nextIndex = getNextIndex(currentIndex, itemsToNavigate.length, e.key, isNoMenuItemFocused);
focusMenuItem(itemsToNavigate[nextIndex], false);
focusMenuItem(itemsToNavigate[nextIndex], true);
}
}, [getMenuItems, items, handleItemClick]);
}, [getMenuItems, enhancedItems, handleItemClick, updateOpenState]);
// Toggle dropdown visibility
const handleTriggerClick = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
updateOpenState(!isOpen);
}, [isOpen, updateOpenState]);
// Close dropdown when clicking outside
const handleClickOutside = useCallback(() => {
setIsOpen(false);
}, []);
updateOpenState(false);
}, [updateOpenState]);
// Expose imperative methods via ref
useImperativeHandle(ref, () => ({
show: () => {
updateOpenState(true);
},
hide: () => {
updateOpenState(false);
},
toggle: () => {
updateOpenState(!isOpen);
}
}), [updateOpenState, isOpen]);
// Setup Tippy instance
const onDropdownCreate = useCallback((ref) => {
@@ -151,18 +289,23 @@ const MenuDropdown = forwardRef(({
ref.setProps({
onShow: () => {
// Focus selected item if available, otherwise focus menu container
setTimeout(() => {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
const menuContainer = ref.popper?.querySelector('[role="menu"]');
if (!menuContainer) return;
// If selectedItemId is provided, find and focus that item
if (selectedItemId) {
const menuItems = Array.from(
menuContainer.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')
);
const menuItems = Array.from(
menuContainer.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"])')
);
// If selectedItemId is provided, find and focus that item
// Use ref to get the latest value
const currentSelectedItemId = selectedItemIdRef.current;
if (currentSelectedItemId != null) {
// Convert to string for comparison since data attributes are always strings
const selectedItemIdStr = String(currentSelectedItemId);
const selectedItem = menuItems.find(
(item) => item.getAttribute('data-item-id') === selectedItemId
(item) => item.getAttribute('data-item-id') === selectedItemIdStr
);
if (selectedItem) {
@@ -171,13 +314,24 @@ const MenuDropdown = forwardRef(({
}
}
// If autoFocusFirstOption is true, focus the first item
if (autoFocusFirstOptionRef.current && menuItems.length > 0) {
focusMenuItem(menuItems[0], true);
return;
}
// Fallback: focus menu container
menuContainer.focus();
}, 0);
});
},
onHide: () => {
// Clear focused class when dropdown closes
const menuContainer = ref.popper?.querySelector('[role="menu"]');
clearFocusedClass(menuContainer);
}
});
}
}, [selectedItemId]);
}, []);
// Render section (left or right)
const renderSection = (section) => {
@@ -195,18 +349,23 @@ const MenuDropdown = forwardRef(({
// Render menu item
const renderMenuItem = (item) => {
const selectIndentClass = item.groupStyle === 'select' ? 'dropdown-item-select' : '';
const isActive = item.id === selectedItemId;
const activeClass = isActive ? 'dropdown-item-active' : '';
return (
<div
key={item.id}
className={`dropdown-item ${item.disabled ? 'disabled' : ''} ${item.className || ''}`.trim()}
className={`dropdown-item ${item.disabled ? 'disabled' : ''} ${selectIndentClass} ${activeClass} ${item.className || ''}`.trim()}
role="menuitem"
data-item-id={item.id}
onClick={() => !item.disabled && handleItemClick(item)}
tabIndex={item.disabled ? -1 : 0}
aria-label={item.ariaLabel}
aria-disabled={item.disabled}
aria-current={isActive ? 'true' : undefined}
title={item.title}
data-testid={`${testId}-${item.id.toLowerCase()}`}
data-testid={`${testId}-${String(item.id).toLowerCase()}`}
>
{renderSection(item.leftSection)}
<span className="dropdown-label">{item.label}</span>
@@ -227,8 +386,13 @@ const MenuDropdown = forwardRef(({
// Render label item
const renderLabel = (item) => (
<div key={item.id || `label-${item.label}`} className="label-item" role="presentation" data-testid={`${testId}-label-${item.label.toLowerCase().replace(/ /g, '-')}`}>
{item.label}
<div
key={item.id || `label-${item.label}`}
className={`label-item ${item.groupStyle === 'select' ? 'label-select' : ''}`}
role="presentation"
data-testid={`${testId}-label-${(item.label || '').toLowerCase().replace(/ /g, '-')}`}
>
{item.groupStyle === 'select' ? (item.label || '').toUpperCase() : item.label || ''}
</div>
);
@@ -241,7 +405,7 @@ const MenuDropdown = forwardRef(({
const renderMenuContent = () => {
let dividerIndex = 0;
return items.map((item) => {
return enhancedItems.map((item) => {
const itemType = item.type || 'item';
if (itemType === 'label') {
@@ -268,19 +432,37 @@ const MenuDropdown = forwardRef(({
: <div onClick={handleTriggerClick} data-testid={testId}>{children}</div>;
return (
<Dropdown
onCreate={onDropdownCreate}
icon={triggerElement}
placement={placement}
className={className}
visible={isOpen}
onClickOutside={handleClickOutside}
{...dropdownProps}
>
<div role="menu" tabIndex={-1} onKeyDown={handleMenuKeyDown}>
{renderMenuContent()}
</div>
</Dropdown>
<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()}
</div>
{footer && (
<>
<div className="dropdown-divider"></div>
<div className="dropdown-footer-container">
{footer}
</div>
</>
)}
</div>
</Dropdown>
</StyledWrapper>
);
});

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { IconChevronDown } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
@@ -30,12 +30,12 @@ const ResponsiveTabs = ({
const tabsContainerRef = useRef(null);
const tabRefsMap = useRef({});
const dropdownTippyRef = useRef(null);
const menuDropdownRef = useRef(null);
const handleTabSelect = useCallback(
(tabKey) => {
onTabSelect(tabKey);
dropdownTippyRef.current?.hide();
menuDropdownRef.current?.hide();
},
[onTabSelect]
);
@@ -148,26 +148,9 @@ const ResponsiveTabs = ({
}
}, []);
const renderTab = (tab, isInDropdown = false) => {
const renderTab = (tab) => {
const isActive = tab.key === activeTab;
if (isInDropdown) {
return (
<div
key={tab.key}
role="tab"
aria-selected={isActive}
className={classnames('dropdown-item', { active: isActive })}
onClick={() => handleTabSelect(tab.key)}
>
<span className="flex items-center gap-1">
{tab.label}
{tab.indicator}
</span>
</div>
);
}
return (
<div
key={tab.key}
@@ -186,6 +169,22 @@ const ResponsiveTabs = ({
expandable: rightSideExpandable
});
// Convert overflow tabs to MenuDropdown items format
const overflowMenuItems = useMemo(() => {
return overflowTabs.map((tab) => ({
id: tab.key,
label: (
<span className="flex items-center gap-1">
{tab.label}
{tab.indicator}
</span>
),
ariaLabel: typeof tab.label === 'string' ? tab.label : tab.key,
onClick: () => handleTabSelect(tab.key),
className: classnames({ active: tab.key === activeTab })
}));
}, [overflowTabs, activeTab, handleTabSelect]);
return (
<StyledWrapper ref={tabsContainerRef} role="tablist" className="tabs flex items-center justify-between gap-6">
<div className="flex items-center">
@@ -208,20 +207,17 @@ const ResponsiveTabs = ({
{/* Overflow dropdown */}
{overflowTabs.length > 0 && (
<Dropdown
<MenuDropdown
ref={menuDropdownRef}
items={overflowMenuItems}
placement="bottom-start"
onCreate={(instance) => (dropdownTippyRef.current = instance)}
icon={(
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
<span>More</span>
<IconChevronDown size={14} strokeWidth={2} />
</div>
)}
selectedItemId={activeTab}
>
<div style={{ minWidth: '150px' }}>
{overflowTabs.map((tab) => renderTab(tab, true))}
<div className="more-tabs select-none flex items-center cursor-pointer gap-1">
<span>More</span>
<IconChevronDown size={14} strokeWidth={2} />
</div>
</Dropdown>
</MenuDropdown>
)}
</div>

View File

@@ -218,7 +218,7 @@ test.describe('Draft indicator in collection and folder settings', () => {
// Create a folder in the collection
const collection = page.locator('.collection-name').filter({ hasText: collectionName });
await collection.locator('.collection-actions').hover();
await collection.hover(); // Hover on collection to reveal action buttons
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();

View File

@@ -37,7 +37,7 @@ test.describe('Draft values are used in requests', () => {
// Create a folder in the collection
const collection = page.locator('.collection-name').filter({ hasText: collectionName });
await collection.locator('.collection-actions').hover();
await collection.hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
await page.locator('#folder-name').fill('Test Folder');
@@ -123,8 +123,8 @@ test.describe('Draft values are used in requests', () => {
// Create a new request from collection menu
const collection = page.locator('.collection-name').filter({ hasText: collectionName });
await collection.locator('.collection-actions').hover();
await collection.locator('.collection-actions').click();
await collection.hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByTestId('request-name').fill('Test Request');
await page.getByTestId('new-request-url').locator('.CodeMirror').click();

View File

@@ -14,7 +14,7 @@ test.describe('Cross-Collection Drag and Drop for folder', () => {
// Create a folder in the first collection
// Look for the collection menu button for the source collection specifically
const sourceCollectionContainer1 = page.locator('.collection-name').filter({ hasText: 'source-collection' });
await sourceCollectionContainer1.locator('.collection-actions').hover();
await sourceCollectionContainer1.hover();
await sourceCollectionContainer1.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();

View File

@@ -26,7 +26,7 @@ test.describe('Move tabs', () => {
// Create a folder in the collection
const sourceCollection = page.locator('.collection-name').filter({ hasText: 'source-collection-drag-drop' });
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
@@ -45,7 +45,7 @@ test.describe('Move tabs', () => {
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' })).toBeVisible();
// Add a request to the collection
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('test-request');
@@ -117,7 +117,7 @@ test.describe('Move tabs', () => {
// Create a folder in the collection
const sourceCollection = page.locator('.collection-name').filter({ hasText: 'source-collection-keyboard-shortcut' });
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
@@ -136,7 +136,7 @@ test.describe('Move tabs', () => {
await expect(page.locator('.request-tab .tab-label').filter({ hasText: 'test-folder' })).toBeVisible();
// Add a request to the collection
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('test-request');

View File

@@ -17,7 +17,7 @@ test('should persist request with newlines across app restarts', async ({ create
await page.locator('.bruno-modal').getByRole('button', { name: 'Create' }).click();
const collection = page.getByTestId('collections').locator('.collection-name').filter({ hasText: 'newlines-persistence' });
await collection.locator('.collection-actions').hover();
await collection.hover();
await collection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('persistence-test');

View File

@@ -22,7 +22,7 @@ const setup = async (page: Page, createTmpDir: (tag?: string | undefined) => Pro
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
const sourceCollection = page.locator('.collection-name').filter({ hasText: 'source-collection' });
await sourceCollection.locator('.collection-actions').hover();
await sourceCollection.hover();
await sourceCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Request' }).click();
await page.getByPlaceholder('Request Name').fill('test-request');

View File

@@ -30,7 +30,7 @@ test.describe.parallel('Collection Run', () => {
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: 'bruno-testbench' });
await collectionContainer.locator('.collection-actions').hover();
await collectionContainer.hover();
await collectionContainer.locator('.collection-actions .icon').waitFor({ state: 'visible' });
await collectionContainer.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();

View File

@@ -13,7 +13,9 @@ const closeAllCollections = async (page) => {
const numberOfCollections = await page.locator('[data-testid="collections"] .collection-name').count();
for (let i = 0; i < numberOfCollections; i++) {
await page.locator('[data-testid="collections"] .collection-name').first().locator('.collection-actions').click();
const firstCollection = page.locator('[data-testid="collections"] .collection-name').first();
await firstCollection.hover();
await firstCollection.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').getByText('Remove').click();
// Wait for the remove collection modal to be visible
await page.locator('.bruno-modal-header-title', { hasText: 'Remove Collection' }).waitFor({ state: 'visible' });
@@ -653,10 +655,10 @@ const selectRequestPaneTab = async (page: Page, tabName: string) => {
if (await overflowButton.isVisible()) {
await overflowButton.click();
// Wait for dropdown to appear and click the tab
const dropdownTab = page.locator('.tippy-content').getByRole('tab', { name: tabName });
await expect(dropdownTab).toBeVisible();
await dropdownTab.click();
// Wait for dropdown to appear and click the menu item (overflow tabs are rendered as menuitems)
const dropdownItem = page.locator('.tippy-content').getByRole('menuitem', { name: tabName });
await expect(dropdownItem).toBeVisible();
await dropdownItem.click();
return;
}
@@ -680,15 +682,32 @@ const expectResponseContains = async (page: Page, texts: string[]) => {
});
};
// Create a action to click a response action
// Map button testIds to menu item IDs
const buttonToMenuItemMap: Record<string, string> = {
'response-copy-btn': 'copy-response',
'response-bookmark-btn': 'save-response',
'response-download-btn': 'download-response',
'response-clear-btn': 'clear-response',
'response-layout-toggle-btn': 'change-layout'
};
// Click a response action - handles both visible buttons and menu items
const clickResponseAction = async (page: Page, actionTestId: string) => {
const actionButton = await page.getByTestId(actionTestId).first();
const actionButton = page.getByTestId(actionTestId).first();
if (await actionButton.isVisible()) {
await actionButton.click();
} else {
const menu = await page.getByTestId('response-actions-menu');
// Open the menu dropdown
const menu = page.getByTestId('response-actions-menu');
await menu.click();
await actionButton.click();
// Click the corresponding menu item
const menuItemId = buttonToMenuItemMap[actionTestId];
if (menuItemId) {
await page.locator(`[role="menuitem"][data-item-id="${menuItemId}"]`).click();
} else {
throw new Error(`Unknown action testId: ${actionTestId}. Add mapping to buttonToMenuItemMap.`);
}
}
};

View File

@@ -123,7 +123,7 @@ export const buildWebsocketCommonLocators = (page: Page) => ({
toolbar: {
latestFirst: () => page.getByRole('button', { name: 'Latest First' }),
latestLast: () => page.getByRole('button', { name: 'Latest Last' }),
clearResponse: () => page.getByTestId('response-clear-button')
clearResponse: () => page.getByTestId('response-clear-btn')
}
});

View File

@@ -44,10 +44,10 @@ export const runCollection = async (page: Page, collectionName: string) => {
const collectionContainer = page.getByTestId('collections').locator('.collection-name').filter({ hasText: collectionName });
await collectionContainer.waitFor({ state: 'visible' });
// Open collection actions menu - wait for the actions container to be actionable
// Open collection actions menu - hover first to reveal the hidden actions button
const actionsContainer = collectionContainer.locator('.collection-actions');
await collectionContainer.hover();
await actionsContainer.waitFor({ state: 'visible' });
await actionsContainer.hover();
const icon = actionsContainer.locator('.icon');
await icon.waitFor({ state: 'visible', timeout: 5000 });