diff --git a/packages/bruno-app/src/components/BodyModeSelector/index.js b/packages/bruno-app/src/components/BodyModeSelector/index.js index 781bfbffc..ac5e76b72 100644 --- a/packages/bruno-app/src/components/BodyModeSelector/index.js +++ b/packages/bruno-app/src/components/BodyModeSelector/index.js @@ -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 ( -
- {humanizeRequestBodyMode(currentMode)} - {' '} - -
- ); - }); - - 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 (
- } + - {Object.entries(groupedModes).map(([category, categoryModes]) => ( - - {showCategories &&
{category}
} - {categoryModes.map((mode) => { - const ModeIcon = mode.icon; - return ( -
onModeSelect(mode.key)} - > - {ModeIcon && ( - - - - )} - {mode.label} -
- ); - })} -
- ))} -
+
+ {humanizeRequestBodyMode(currentMode)} + {' '} + +
+
); diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/StyledWrapper.js b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/StyledWrapper.js index 9cf2c5fa4..f8443e1cc 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/StyledWrapper.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/StyledWrapper.js @@ -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); } `; diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js index 781e9f477..1abcb4ae9 100644 --- a/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js +++ b/packages/bruno-app/src/components/CollectionSettings/Auth/AuthMode/index.js @@ -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 ( -
- {humanizeRequestAuthMode(authMode)} -
- ); - }); - - 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 (
- } placement="bottom-end"> -
{ - dropdownTippyRef.current.hide(); - onModeChange('awsv4'); - }} - > - AWS Sig v4 + +
+ {humanizeRequestAuthMode(authMode)}
-
{ - dropdownTippyRef.current.hide(); - onModeChange('basic'); - }} - > - Basic Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('wsse'); - }} - > - WSSE Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('bearer'); - }} - > - Bearer Token -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('digest'); - }} - > - Digest Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('ntlm'); - }} - > - NTLM Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('oauth2'); - }} - > - OAuth 2.0 -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('apikey'); - }} - > - API Key -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('none'); - }} - > - No Auth -
- +
); diff --git a/packages/bruno-app/src/components/CreateUntitledRequest/index.js b/packages/bruno-app/src/components/CreateUntitledRequest/index.js index 42e82e7f9..03740fe7f 100644 --- a/packages/bruno-app/src/components/CreateUntitledRequest/index.js +++ b/packages/bruno-app/src/components/CreateUntitledRequest/index.js @@ -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: , + onClick: handleCreateHttpRequest + }, + { + id: 'graphql', + label: 'GraphQL', + leftSection: , + onClick: handleCreateGraphQLRequest + }, + { + id: 'websocket', + label: 'WebSocket', + leftSection: , + onClick: handleCreateWebSocketRequest + }, + { + id: 'grpc', + label: 'gRPC', + leftSection: , + onClick: handleCreateGrpcRequest + } + ], [handleCreateHttpRequest, handleCreateGraphQLRequest, handleCreateWebSocketRequest, handleCreateGrpcRequest]); + + if (!collection) { + return null; + } return ( - } placement={placement}> -
{ - dropdownTippyRef.current.hide(); - handleCreateHttpRequest(); - }} - > - - - - HTTP -
-
{ - dropdownTippyRef.current.hide(); - handleCreateGraphQLRequest(); - }} - > - - - - GraphQL -
-
{ - dropdownTippyRef.current.hide(); - handleCreateWebSocketRequest(); - }} - > - - - - WebSocket -
-
{ - dropdownTippyRef.current.hide(); - handleCreateGrpcRequest(); - }} - > - - - - gRPC -
-
+ + + + + ); }; diff --git a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js index 7452a4f75..d4068b42f 100644 --- a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js +++ b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js @@ -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 { diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js index 91151ac4b..ffbebfd3b 100644 --- a/packages/bruno-app/src/components/FolderSettings/Auth/index.js +++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js @@ -206,7 +206,7 @@ const Auth = ({ collection, folder }) => { Configures authentication for the entire folder. This applies to all requests using the{' '} Inherit option in the Auth tab.
-
+
{getAuthView()} diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js index ccd21ccd5..3bb25a05c 100644 --- a/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js +++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js index f4a3c3966..ab1a4e531 100644 --- a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js +++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js @@ -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 ( -
- {humanizeRequestAuthMode(authMode)} -
- ); - }); - - 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 ( -
- } placement="bottom-end"> -
{ - dropdownTippyRef.current.hide(); - onModeChange('awsv4'); - }} - > - AWS Sig v4 +
+ +
+ {humanizeRequestAuthMode(authMode)}
-
{ - dropdownTippyRef.current.hide(); - onModeChange('basic'); - }} - > - Basic Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('bearer'); - }} - > - Bearer Token -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('digest'); - }} - > - Digest Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('ntlm'); - }} - > - NTLM Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('oauth2'); - }} - > - OAuth 2.0 -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('wsse'); - }} - > - WSSE Auth -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('apikey'); - }} - > - API Key -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('inherit'); - }} - > - Inherit -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('none'); - }} - > - No Auth -
- +
); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/StyledWrapper.js index 9cf2c5fa4..f8443e1cc 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/StyledWrapper.js @@ -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); } `; diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js index 1e3bedc2f..2cd19f9c4 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/AuthMode/index.js @@ -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 ( -
- {humanizeRequestAuthMode(authMode)} -
- ); - }); - - 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 (
- } placement="bottom-end"> -
{ - dropdownTippyRef?.current?.hide(); - onModeChange('awsv4'); - }} - > - AWS Sig v4 + +
+ {humanizeRequestAuthMode(authMode)}
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('basic'); - }} - > - Basic Auth -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('bearer'); - }} - > - Bearer Token -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('digest'); - }} - > - Digest Auth -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('ntlm'); - }} - > - NTLM Auth -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('oauth2'); - }} - > - OAuth 2.0 -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('wsse'); - }} - > - WSSE Auth -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('apikey'); - }} - > - API Key -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('inherit'); - }} - > - Inherit -
-
{ - dropdownTippyRef?.current?.hide(); - onModeChange('none'); - }} - > - No Auth -
- +
); diff --git a/packages/bruno-app/src/components/RequestPane/Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/index.js index be3bb8ed5..0dd9e0b5f 100644 --- a/packages/bruno-app/src/components/RequestPane/Auth/index.js +++ b/packages/bruno-app/src/components/RequestPane/Auth/index.js @@ -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
No Auth
; + } case 'awsv4': { return ; } @@ -113,9 +115,6 @@ const Auth = ({ item, collection }) => { return ( -
- -
{getAuthView()}
); diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js index 088920f6d..091bc1f18 100644 --- a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js +++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js @@ -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 ( -
- {humanizeRequestAuthMode(authMode)} -
- ); - }); - - 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 (
- } placement="bottom-end"> - {authModes.map((authMode) => ( -
onClickHandler(authMode.mode)} - > - {authMode.name} -
- ))} -
+ +
+ {humanizeRequestAuthMode(authMode)} +
+
); diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index ac5d9b205..aaf7dcd1b 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -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' ? ( -
+
+ ) : requestPaneTab === 'auth' ? ( +
+ +
) : null; return ( @@ -142,7 +147,7 @@ const HttpRequestPane = ({ item, collection }) => { activeTab={requestPaneTab} onTabSelect={selectTab} rightContent={rightContent} - rightContentRef={bodyModeRef} + rightContentRef={rightContent ? rightContentRef : null} delayedTabs={['body']} /> diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js index f96403125..2afd0a5da 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/StyledWrapper.js @@ -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 { diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js index 19ffc9339..6624b261c 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.js @@ -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 ( -
onSelect(verb)}> - {verb} -
- ); -} - -const Icon = forwardRef(function IconComponent( - { isCustomMode, inputValue, handleInputChange, handleBlur, handleKeyDown, inputRef }, - ref -) { - if (isCustomMode) { - return ( -
- -
- ); - } - - return ( -
- -
+ {method} + + + ); -}); +}; 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 ( + +
+
+ +
+
+
+ ); + } return (
- - )} + -
- {STANDARD_METHODS.map((verb) => ( - - ))} -
- + Add Custom -
-
-
+ +
); diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.spec.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.spec.js index 5431dce23..c8caede6f 100644 --- a/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.spec.js +++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/HttpMethodSelector/index.spec.js @@ -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'); }); }); diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js index 211864aeb..d81dd2f4c 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/StyledWrapper.js @@ -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); } `; diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js index 907ab7b03..d5d832708 100644 --- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js +++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js @@ -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 ( -
- {humanizeRequestBodyMode(bodyMode)} -
- ); - }); - - 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 (
- } placement="bottom-end"> -
Form
-
{ - dropdownTippyRef.current.hide(); - onModeChange('multipartForm'); - }} - > - - - - Multipart Form + +
+ {humanizeRequestBodyMode(bodyMode)}
-
{ - dropdownTippyRef.current.hide(); - onModeChange('formUrlEncoded'); - }} - > - - - - Form URL Encoded -
-
Raw
-
{ - dropdownTippyRef.current.hide(); - onModeChange('json'); - }} - > - - - - JSON -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('xml'); - }} - > - - - - XML -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('text'); - }} - > - - - - TEXT -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('sparql'); - }} - > - - - - SPARQL -
-
Other
-
{ - dropdownTippyRef.current.hide(); - onModeChange('file'); - }} - > - - - - File / Binary -
-
{ - dropdownTippyRef.current.hide(); - onModeChange('none'); - }} - > - - - - No Body -
- +
{(bodyMode === 'json' || bodyMode === 'xml') && (
-
- - - - - - - - - - - - - - - - - - - - - + +
+ + + + + + + + + + + + + + + + + + +
diff --git a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js index a4203b8b3..741d1142f 100644 --- a/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabs/StyledWrapper.js @@ -76,7 +76,7 @@ const Wrapper = styled.div` } &:nth-last-child(1) { - margin-right: 10px; + margin-right: 4px; } &.has-overflow:not(:hover) .tab-name { diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js index c8f42bf0a..1270b5486 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx index 88b9eba5e..7fff2c88c 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultTypeSelector/index.jsx @@ -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 ( + + ); +}); +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 = (
Preview @@ -27,18 +75,25 @@ const QueryResultTypeSelector = ({ />
); + return ( - : null} - /> + > + : null} + disabled={false} + className="h-[20px] text-[11px]" + data-testid="format-response-tab" + /> + ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index f6160d380..0a1b0546a 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -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]); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js index 0397c43e2..909a129cb 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseBookmark/index.js @@ -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 ( <>
{children ?? ( - + )}
@@ -172,6 +163,8 @@ const ResponseBookmark = ({ item, collection, responseSize, children }) => { /> ); -}; +}); + +ResponseBookmark.displayName = 'ResponseBookmark'; export default ResponseBookmark; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js index 944de0a98..d57a72bb6 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseClear/index.js @@ -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 ( -
+
{children ? children : ( - + )}
); -}; +}); + +ResponseClear.displayName = 'ResponseClear'; + export default ResponseClear; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js index 3b40b2ff8..9a279e5e5 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseCopy/index.js @@ -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 ( -
+
{children ? children : ( - + )}
); -}; +}); + +ResponseCopy.displayName = 'ResponseCopy'; export default ResponseCopy; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js index 8efbd58af..2fa5153ed 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseDownload/index.js @@ -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 (
{children ? children : ( - + )}
); -}; +}); + +ResponseDownload.displayName = 'ResponseDownload'; + export default ResponseDownload; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js index 5242d4679..310a49296 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js @@ -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 (
{children ? children : ( - + )}
); -}; +}); + +ResponseLayoutToggle.displayName = 'ResponseLayoutToggle'; export default ResponseLayoutToggle; diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js index 3e49d7618..503076df6 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js @@ -84,7 +84,7 @@ describe('ResponseLayoutToggle', () => { describe('Initial Render', () => { it('should render with horizontal orientation by default', () => { renderWithProviders(); - 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(, 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(); - 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(, 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'); diff --git a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js index 19c773c95..ed905ede0 100644 --- a/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js +++ b/packages/bruno-app/src/components/ResponsePane/ResponsePaneActions/index.js @@ -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 (
- } placement="bottom-end"> - - {/* Response Copy */} - -
- - - - Copy response -
-
- - {/* Response Save as Example */} - -
- - - - Save response -
-
- - {/* Response Download */} - -
- - - - Download response -
-
- - {/* Response Clear */} - -
- - - - Clear response -
-
- - {/* Response Layout Toggle */} - -
- - {orientation === 'vertical' ? : } - - Change layout -
-
-
+ + +
- - - - - + + + + +
diff --git a/packages/bruno-app/src/components/Sidebar/SidebarHeader/CloseWorkspace/index.js b/packages/bruno-app/src/components/Sidebar/CloseWorkspace/index.js similarity index 100% rename from packages/bruno-app/src/components/Sidebar/SidebarHeader/CloseWorkspace/index.js rename to packages/bruno-app/src/components/Sidebar/CloseWorkspace/index.js diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js index bc4e983c5..2f0fb1dfe 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/ExampleItem/index.js @@ -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', { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js index a8901c4a1..c4ed228d0 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js @@ -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; } } diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 2b56afdbb..eb0879b5b 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -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} > -
+ {isFolder ? ( ) : null} -
+
@@ -663,14 +674,16 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
-
+
- + + +
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js index b7341d1a6..a845064a0 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js @@ -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 { diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index 51831c268..cd147e21c 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -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 ( -
- -
- ); - }); + 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 ( {showNewRequestModal && setShowNewRequestModal(false)} />} @@ -311,160 +410,34 @@ const Collection = ({ collection, searchText }) => { onDoubleClick={handleDoubleClick} onContextMenu={handleRightClick} > - + + + {isLoading ? : null}
-
- } placement="bottom-start"> -
{ - menuDropdownTippyRef.current.hide(); - ensureCollectionIsMounted(); - setShowNewRequestModal(true); - }} +
+
+ - - - - New Request -
-
{ - menuDropdownTippyRef.current.hide(); - ensureCollectionIsMounted(); - setShowNewFolderModal(true); - }} - > - - - - New Folder -
-
{ - menuDropdownTippyRef.current.hide(); - ensureCollectionIsMounted(); - handleRun(); - }} - > - - - - Run -
-
{ - menuDropdownTippyRef.current.hide(); - setShowCloneCollectionModalOpen(true); - }} - > - - - - Clone -
- {hasCopiedItems && ( -
- - - - Paste -
- )} -
{ - menuDropdownTippyRef.current.hide(); - setShowRenameCollectionModal(true); - }} - > - - - - Rename -
-
{ - menuDropdownTippyRef.current.hide(); - ensureCollectionIsMounted(); - setShowShareCollectionModal(true); - }} - > - - - - Share -
-
{ - menuDropdownTippyRef.current.hide(); - handleCollapseFullCollection(); - }} - > - - - - Collapse -
-
-
{ - menuDropdownTippyRef.current.hide(); - viewCollectionSettings(); - }} - > - - - - Settings -
-
{ - menuDropdownTippyRef.current.hide(); - const collectionCwd = collection.pathname; - await openDevtoolsAndSwitchToTerminal(dispatch, collectionCwd); - }} - > - - - - Open in Terminal -
-
{ - menuDropdownTippyRef.current.hide(); - setShowRemoveCollectionModal(true); - }} - > - - - - Remove -
- + + + + +
diff --git a/packages/bruno-app/src/components/Sidebar/SidebarHeader/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/SidebarHeader/StyledWrapper.js deleted file mode 100644 index 0285c8b0b..000000000 --- a/packages/bruno-app/src/components/Sidebar/SidebarHeader/StyledWrapper.js +++ /dev/null @@ -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; diff --git a/packages/bruno-app/src/components/Sidebar/SidebarHeader/index.js b/packages/bruno-app/src/components/Sidebar/SidebarHeader/index.js deleted file mode 100644 index e0eeeb3e0..000000000 --- a/packages/bruno-app/src/components/Sidebar/SidebarHeader/index.js +++ /dev/null @@ -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 && ( - setCreateCollectionModalOpen(false)} - /> - )} - {importCollectionModalOpen && ( - setImportCollectionModalOpen(false)} - handleSubmit={handleImportCollection} - /> - )} - {importCollectionLocationModalOpen && importData && ( - setImportCollectionLocationModalOpen(false)} - handleSubmit={handleImportCollectionLocation} - /> - )} - {createApiSpecModalOpen && ( - 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 = () => ( - <> - - - - {/* Add Collection dropdown */} - - - - - - {/* More Actions dropdown (sort, close all, etc.) */} - - - - - - {collectionsToClose.length > 0 && ( - - )} - - ); - - return ( - - {renderModals()} -
-
- - Collections -
- - {/* Action Buttons - Context Sensitive */} -
- {renderCollectionsActions()} -
-
-
- ); -}; - -export default SidebarHeader; diff --git a/packages/bruno-app/src/components/Sidebar/SidebarHeader/WorkspaceSelector/index.js b/packages/bruno-app/src/components/Sidebar/WorkspaceSelector/index.js similarity index 100% rename from packages/bruno-app/src/components/Sidebar/SidebarHeader/WorkspaceSelector/index.js rename to packages/bruno-app/src/components/Sidebar/WorkspaceSelector/index.js diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js index ed7340901..c43d19d0b 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/index.js @@ -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'; diff --git a/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js b/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js index 55c6e9044..701d797f7 100644 --- a/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js +++ b/packages/bruno-app/src/ui/ActionIcon/StyledWrapper.js @@ -34,6 +34,10 @@ const StyledWrapper = styled.button` ${(props) => variants[props.$variant] || variants.subtle} + ${(props) => props.$color && css` + color: ${props.$color}; + `} + svg { stroke: currentColor; } diff --git a/packages/bruno-app/src/ui/ActionIcon/index.js b/packages/bruno-app/src/ui/ActionIcon/index.js index fa4ec9761..e02597802 100644 --- a/packages/bruno-app/src/ui/ActionIcon/index.js +++ b/packages/bruno-app/src/ui/ActionIcon/index.js @@ -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} diff --git a/packages/bruno-app/src/ui/ButtonDropdown/StyledWrapper.js b/packages/bruno-app/src/ui/ButtonDropdown/StyledWrapper.js deleted file mode 100644 index 1a2b7177a..000000000 --- a/packages/bruno-app/src/ui/ButtonDropdown/StyledWrapper.js +++ /dev/null @@ -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; diff --git a/packages/bruno-app/src/ui/ButtonDropdown/index.jsx b/packages/bruno-app/src/ui/ButtonDropdown/index.jsx deleted file mode 100644 index 0b373670a..000000000 --- a/packages/bruno-app/src/ui/ButtonDropdown/index.jsx +++ /dev/null @@ -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 ( - - ); -}); -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) => ( - - {group.options.map((option, optionIndex) => { - const isFirstInGroup = optionIndex === 0; - const isFirstGroup = groupIndex === 0; - const showSeparator = !isFirstGroup && isFirstInGroup; - - return ( -
handleOptionSelect(option.value)} - > - {option.label} - {option.value === value && ( - - )} -
- ); - })} -
- )); - } else { - const flatOptions = options; - return flatOptions.map((option) => ( -
handleOptionSelect(option.value)} - > - {option.label} - {option.value === value && ( - - )} -
- )); - } - }; - - return ( - - } - placement="bottom-end" - disabled={disabled} - > -
- {header && ( -
dropdownTippyRef.current?.hide()}> - {header} -
-
- )} - {renderOptions()} -
-
-
- ); -}; - -export default ButtonDropdown; diff --git a/packages/bruno-app/src/ui/MenuDropdown/StyledWrapper.js b/packages/bruno-app/src/ui/MenuDropdown/StyledWrapper.js new file mode 100644 index 000000000..fbe183bbb --- /dev/null +++ b/packages/bruno-app/src/ui/MenuDropdown/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/MenuDropdown/index.js b/packages/bruno-app/src/ui/MenuDropdown/index.js index f8152b989..8fc171b15 100644 --- a/packages/bruno-app/src/ui/MenuDropdown/index.js +++ b/packages/bruno-app/src/ui/MenuDropdown/index.js @@ -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: + }; + } + + 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 (
!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)} {item.label} @@ -227,8 +386,13 @@ const MenuDropdown = forwardRef(({ // Render label item const renderLabel = (item) => ( -
- {item.label} +
+ {item.groupStyle === 'select' ? (item.label || '').toUpperCase() : item.label || ''}
); @@ -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(({ :
{children}
; return ( - -
- {renderMenuContent()} -
-
+ + +
+ {header && ( +
+ {header} +
+
+ )} +
+ {renderMenuContent()} +
+ {footer && ( + <> +
+
+ {footer} +
+ + )} +
+
+
); }); diff --git a/packages/bruno-app/src/ui/ResponsiveTabs/index.js b/packages/bruno-app/src/ui/ResponsiveTabs/index.js index 472acba84..b6b98d528 100644 --- a/packages/bruno-app/src/ui/ResponsiveTabs/index.js +++ b/packages/bruno-app/src/ui/ResponsiveTabs/index.js @@ -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 ( -
handleTabSelect(tab.key)} - > - - {tab.label} - {tab.indicator} - -
- ); - } - return (
{ + return overflowTabs.map((tab) => ({ + id: tab.key, + label: ( + + {tab.label} + {tab.indicator} + + ), + ariaLabel: typeof tab.label === 'string' ? tab.label : tab.key, + onClick: () => handleTabSelect(tab.key), + className: classnames({ active: tab.key === activeTab }) + })); + }, [overflowTabs, activeTab, handleTabSelect]); + return (
@@ -208,20 +207,17 @@ const ResponsiveTabs = ({ {/* Overflow dropdown */} {overflowTabs.length > 0 && ( - (dropdownTippyRef.current = instance)} - icon={( -
- More - -
- )} + selectedItemId={activeTab} > -
- {overflowTabs.map((tab) => renderTab(tab, true))} +
+ More +
- + )}
diff --git a/tests/collection/draft/draft-indicator.spec.ts b/tests/collection/draft/draft-indicator.spec.ts index be293f154..1ff1decec 100644 --- a/tests/collection/draft/draft-indicator.spec.ts +++ b/tests/collection/draft/draft-indicator.spec.ts @@ -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(); diff --git a/tests/collection/draft/draft-values-in-requests.spec.ts b/tests/collection/draft/draft-values-in-requests.spec.ts index 11fb9f509..b90a2cfe7 100644 --- a/tests/collection/draft/draft-values-in-requests.spec.ts +++ b/tests/collection/draft/draft-values-in-requests.spec.ts @@ -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(); diff --git a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts index fdee42d29..e74e36cae 100644 --- a/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts +++ b/tests/collection/moving-requests/cross-collection-drag-drop-folder.spec.ts @@ -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(); diff --git a/tests/collection/moving-tabs/move-tabs.spec.ts b/tests/collection/moving-tabs/move-tabs.spec.ts index aa3938584..16f888b41 100644 --- a/tests/collection/moving-tabs/move-tabs.spec.ts +++ b/tests/collection/moving-tabs/move-tabs.spec.ts @@ -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'); diff --git a/tests/request/newlines/newlines-persistence.spec.ts b/tests/request/newlines/newlines-persistence.spec.ts index e36e5dfbc..775871840 100644 --- a/tests/request/newlines/newlines-persistence.spec.ts +++ b/tests/request/newlines/newlines-persistence.spec.ts @@ -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'); diff --git a/tests/request/save/save.spec.ts b/tests/request/save/save.spec.ts index aee7aa9c9..1992bafb3 100644 --- a/tests/request/save/save.spec.ts +++ b/tests/request/save/save.spec.ts @@ -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'); diff --git a/tests/runner/collection-run.ts b/tests/runner/collection-run.ts index 8d89b8618..6d8f7bacd 100644 --- a/tests/runner/collection-run.ts +++ b/tests/runner/collection-run.ts @@ -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(); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 0590cc074..a4da13a8c 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -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 = { + '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.`); + } } }; diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index b071dbcef..67efa05ee 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -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') } }); diff --git a/tests/utils/page/runner.ts b/tests/utils/page/runner.ts index 88695f162..41a0e592c 100644 --- a/tests/utils/page/runner.ts +++ b/tests/utils/page/runner.ts @@ -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 });