mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 05:35:41 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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']}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -76,7 +76,7 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
&:nth-last-child(1) {
|
||||
margin-right: 10px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.has-overflow:not(:hover) .tab-name {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -34,6 +34,10 @@ const StyledWrapper = styled.button`
|
||||
|
||||
${(props) => variants[props.$variant] || variants.subtle}
|
||||
|
||||
${(props) => props.$color && css`
|
||||
color: ${props.$color};
|
||||
`}
|
||||
|
||||
svg {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
150
packages/bruno-app/src/ui/MenuDropdown/StyledWrapper.js
Normal file
150
packages/bruno-app/src/ui/MenuDropdown/StyledWrapper.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user