fix: refactor response examples to use MenuDropdown and Editable components (#6382)

* feat: use common dropdown component

* fix: update example ui to match v3

* fix: test cases, bugs

* fix: review comments

* fix: review comments

* fix: review

* fix: file body/binary table within response examples

* fix: file name, close btn not visible issue

* fix: unnessary transition for three  dots

* fix: install missing deps in bruno-app

* update example url when param is updated

* empty commit

* chore: update package-lock.json

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
This commit is contained in:
sanish chirayath
2025-12-14 16:21:06 +05:30
committed by GitHub
parent 2f5537c8db
commit 8cbda5f5cc
30 changed files with 1211 additions and 1349 deletions

View File

@@ -5,13 +5,14 @@ const StyledWrapper = styled.div`
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
visibility: hidden;
.dropdown {
div[aria-expanded='true'] {
visibility: visible;
}
div[aria-expanded='false'] {
visibility: hidden;
visibility: visible;
}
}
}
@@ -36,11 +37,7 @@ const StyledWrapper = styled.div`
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.menu-icon {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
visibility: visible;
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import {
@@ -8,13 +8,16 @@ import {
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid } from 'utils/common';
import { IconDots } from '@tabler/icons';
import { IconDots, IconEdit, IconCopy, IconTrash, IconCode } from '@tabler/icons';
import ExampleIcon from 'components/Icons/ExampleIcon';
import range from 'lodash/range';
import classnames from 'classnames';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import Modal from 'components/Modal';
import DeleteResponseExampleModal from './DeleteResponseExampleModal';
import GenerateCodeItem from '../GenerateCodeItem';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
const ExampleItem = ({ example, item, collection }) => {
@@ -25,8 +28,9 @@ const ExampleItem = ({ example, item, collection }) => {
const [editName, setEditName] = useState(example.name);
const [showRenameModal, setShowRenameModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const dropdownTippyRef = useRef(null);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const exampleRef = useRef(null);
const menuDropdownRef = useRef(null);
// Calculate indentation: item depth + 1 for examples
const indents = range((item.depth || 0) + 1);
@@ -48,9 +52,6 @@ const ExampleItem = ({ example, item, collection }) => {
const handleRename = () => {
setEditName(example.name); // Set current name when opening modal
setShowRenameModal(true);
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
// Update editName when example changes
@@ -93,26 +94,22 @@ const ExampleItem = ({ example, item, collection }) => {
itemUid: item.uid,
exampleIndex: clonedExampleIndex
}));
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
const handleDelete = () => {
setShowDeleteModal(true);
if (dropdownTippyRef.current) {
dropdownTippyRef.current.hide();
}
};
const handleRightClick = (e) => {
e.preventDefault();
e.stopPropagation();
// Show the dropdown menu programmatically
if (dropdownTippyRef.current) {
dropdownTippyRef.current.show();
const handleGenerateCode = () => {
// Check if example has a request URL
if (
(example?.request?.url !== '' && example?.request?.url !== undefined)
|| (item?.request?.url !== '' && item?.request?.url !== undefined)
|| (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
@@ -130,17 +127,48 @@ const ExampleItem = ({ example, item, collection }) => {
setShowRenameModal(false);
};
const onDropdownCreate = (instance) => {
dropdownTippyRef.current = instance;
// Build menu items for MenuDropdown
const buildMenuItems = () => {
return [
{
id: 'rename',
leftSection: IconEdit,
label: 'Rename',
onClick: handleRename,
testId: 'response-example-rename-option'
},
{
id: 'clone',
leftSection: IconCopy,
label: 'Clone',
onClick: handleClone,
testId: 'response-example-clone-option'
},
{
id: 'generate-code',
leftSection: IconCode,
label: 'Generate Code',
onClick: handleGenerateCode,
testId: 'response-example-generate-code-option'
},
{ id: 'separator-1', type: 'divider' },
{
id: 'delete',
leftSection: IconTrash,
label: 'Delete',
className: 'delete-item',
onClick: handleDelete,
testId: 'response-example-delete-option'
}
];
};
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref} data-testid="response-example-menu-icon">
<IconDots size={22} />
</div>
);
});
// Handle right-click context menu
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
menuDropdownRef.current?.open();
};
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
'item-focused-in-tab': isExampleActive
@@ -152,7 +180,7 @@ const ExampleItem = ({ example, item, collection }) => {
className={itemRowClassName}
onClick={handleExampleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleRightClick}
onContextMenu={handleContextMenu}
>
{indents && indents.length
? indents.map((i) => (
@@ -160,7 +188,6 @@ const ExampleItem = ({ example, item, collection }) => {
className="indent-block"
key={i}
style={{ width: 16, minWidth: 16, height: '100%' }}
onContextMenu={handleRightClick}
>
&nbsp;{/* Indent */}
</div>
@@ -169,45 +196,20 @@ const ExampleItem = ({ example, item, collection }) => {
<div
className="flex flex-grow items-center h-full overflow-hidden"
style={{ paddingLeft: 8 }}
onContextMenu={handleRightClick}
>
<div style={{ width: 16, minWidth: 16 }}></div>
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-400 flex-shrink-0" />
<span className="item-name truncate text-gray-700 dark:text-gray-300 ">{example.name}</span>
</div>
<div className="menu-icon pr-2">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleRename();
}}
data-testid="response-example-rename-option"
>
Rename
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleClone();
}}
data-testid="response-example-clone-option"
>
Clone
</div>
<div
className="dropdown-item text-red-600"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleDelete();
}}
data-testid="response-example-delete-option"
>
Delete
</div>
</Dropdown>
<MenuDropdown
ref={menuDropdownRef}
items={buildMenuItems()}
placement="bottom-start"
data-testid="response-example-menu"
>
<IconDots size={22} data-testid="response-example-menu-icon" />
</MenuDropdown>
</div>
{showRenameModal && (
@@ -250,6 +252,16 @@ const ExampleItem = ({ example, item, collection }) => {
collection={collection}
/>
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem
collectionUid={collection.uid}
item={item}
onClose={() => setGenerateCodeItemModalOpen(false)}
isExample={true}
exampleUid={example.uid}
/>
)}
</StyledWrapper>
);
};

View File

@@ -4,13 +4,14 @@ const Wrapper = styled.div`
position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
visibility: hidden;
.dropdown {
div[aria-expanded='true'] {
visibility: visible;
}
div[aria-expanded='false'] {
visibility: hidden;
visibility: visible;
}
}
}
@@ -97,11 +98,7 @@ const Wrapper = styled.div`
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.menu-icon {
.dropdown {
div[aria-expanded='false'] {
visibility: visible;
}
}
visibility: visible;
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import React, { useState, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
@@ -14,7 +14,6 @@ import {
IconCopy,
IconClipboard,
IconCode,
IconPhoto,
IconFolder,
IconTrash,
IconSettings,
@@ -28,7 +27,7 @@ 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 Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import RenameCollectionItem from './RenameCollectionItem';
@@ -46,6 +45,7 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
import ExampleItem from './ExampleItem';
import ExampleIcon from 'components/Icons/ExampleIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
@@ -68,6 +68,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
// We use a single ref for drag and drop.
const ref = useRef(null);
const menuDropdownRef = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -182,15 +183,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
})
});
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
<div ref={ref}>
<IconDots size={22} />
</div>
);
});
const iconClassName = classnames({
'rotate-90': !itemIsCollapsed
});
@@ -289,19 +281,138 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
e.preventDefault();
};
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
if (_menuDropdown) {
let menuDropdownBehavior = 'show';
if (_menuDropdown.state.isShown) {
menuDropdownBehavior = 'hide';
}
_menuDropdown[menuDropdownBehavior]();
}
// Handle right-click context menu
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
menuDropdownRef.current?.open();
};
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
// Build menu items for MenuDropdown
const buildMenuItems = () => {
const items = [
{
id: 'info',
leftSection: IconInfoCircle,
label: 'Info',
onClick: () => setItemInfoModalOpen(true)
}
];
if (isFolder) {
items.push(
{
id: 'new-request',
leftSection: IconFilePlus,
label: 'New Request',
onClick: () => setNewRequestModalOpen(true)
},
{
id: 'new-folder',
leftSection: IconFolderPlus,
label: 'New Folder',
onClick: () => setNewFolderModalOpen(true)
},
{
id: 'run',
leftSection: IconPlayerPlay,
label: 'Run',
onClick: () => setRunCollectionModalOpen(true)
}
);
}
items.push(
{
id: 'clone',
leftSection: IconCopy,
label: 'Clone',
onClick: () => setCloneItemModalOpen(true)
},
{
id: 'copy',
leftSection: IconCopy,
label: 'Copy',
onClick: handleCopyItem
}
);
if (isFolder && hasCopiedItems) {
items.push({
id: 'paste',
leftSection: IconClipboard,
label: 'Paste',
onClick: handlePasteItem
});
}
items.push(
{
id: 'rename',
leftSection: IconEdit,
label: 'Rename',
onClick: () => setRenameItemModalOpen(true)
},
{
id: 'show-in-folder',
leftSection: IconFolder,
label: 'Show in Folder',
onClick: handleShowInFolder
}
);
if (!isFolder && (item.type === 'http-request' || item.type === 'graphql-request')) {
items.push({
id: 'generate-code',
leftSection: IconCode,
label: 'Generate Code',
onClick: handleGenerateCode
});
}
if (!isFolder && isItemARequest(item) && item.type === 'http-request') {
items.push({
id: 'create-example',
leftSection: ExampleIcon,
label: 'Create Example',
onClick: () => setCreateExampleModalOpen(true)
});
}
items.push({ id: 'separator-1', type: 'divider' });
if (isFolder) {
items.push(
{
id: 'settings',
leftSection: IconSettings,
label: 'Settings',
onClick: viewFolderSettings
},
{
id: 'open-terminal',
leftSection: IconTerminal2,
label: 'Open in Terminal',
onClick: async () => {
const folderCwd = item.pathname || collectionPathname;
await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd);
}
}
);
}
items.push({
id: 'delete',
leftSection: IconTrash,
label: 'Delete',
className: 'delete-item',
onClick: () => setDeleteItemModalOpen(true)
});
return items;
};
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -382,9 +493,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
const handleGenerateCode = () => {
if (
(item?.request?.url !== '')
|| (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
@@ -412,15 +521,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
};
const handleCopyItem = () => {
dropdownTippyRef.current.hide();
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied to clipboard`);
};
const handlePasteItem = () => {
dropdownTippyRef.current.hide();
// Only allow paste into folders
if (!isFolder) {
toast.error('Paste is only available for folders');
@@ -504,6 +610,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onContextMenu={handleContextMenu}
data-testid="sidebar-collection-item-row"
>
<div className="flex items-center h-full w-full">
@@ -511,7 +618,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
? indents.map((i) => (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
@@ -525,7 +631,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
className="flex flex-grow items-center h-full overflow-hidden"
style={{ paddingLeft: 8 }}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
>
<div style={{ width: 16, minWidth: 16 }}>
@@ -559,186 +664,14 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
</div>
</div>
<div className="menu-icon pr-2">
<Dropdown onCreate={onDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setItemInfoModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconInfoCircle size={16} strokeWidth={2} />
</span>
Info
</div>
{isFolder && (
<>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setNewRequestModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconFilePlus size={16} strokeWidth={2} />
</span>
New Request
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setNewFolderModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconFolderPlus size={16} strokeWidth={2} />
</span>
New Folder
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setRunCollectionModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconPlayerPlay size={16} strokeWidth={2} />
</span>
Run
</div>
</>
)}
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setCloneItemModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={2} />
</span>
Clone
</div>
<div
className="dropdown-item"
onClick={handleCopyItem}
>
<span className="dropdown-icon">
<IconCopy size={16} strokeWidth={2} />
</span>
Copy
</div>
{isFolder && hasCopiedItems && (
<div
className="dropdown-item"
onClick={handlePasteItem}
>
<span className="dropdown-icon">
<IconClipboard size={16} strokeWidth={2} />
</span>
Paste
</div>
)}
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setRenameItemModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconEdit size={16} strokeWidth={2} />
</span>
Rename
</div>
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
handleShowInFolder();
}}
>
<span className="dropdown-icon">
<IconFolder size={16} strokeWidth={2} />
</span>
Show in Folder
</div>
{!isFolder && (item.type === 'http-request' || item.type === 'graphql-request') && (
<div
className="dropdown-item"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<span className="dropdown-icon">
<IconCode size={16} strokeWidth={2} />
</span>
Generate Code
</div>
)}
{!isFolder && isItemARequest(item) && item.type === 'http-request' && (
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setCreateExampleModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconPhoto size={16} strokeWidth={2} />
</span>
Create Example
</div>
)}
<div className="dropdown-separator"></div>
{isFolder && (
<div
className="dropdown-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
viewFolderSettings();
}}
>
<span className="dropdown-icon">
<IconSettings size={16} strokeWidth={2} />
</span>
Settings
</div>
)}
{isFolder && (
<div
className="dropdown-item"
onClick={async (e) => {
dropdownTippyRef.current.hide();
// Get folder pathname
const folderCwd = item.pathname || collectionPathname;
await openDevtoolsAndSwitchToTerminal(dispatch, folderCwd);
}}
>
<span className="dropdown-icon">
<IconTerminal2 size={16} strokeWidth={2} />
</span>
Open in Terminal
</div>
)}
<div
className="dropdown-item delete-item"
onClick={(e) => {
dropdownTippyRef.current.hide();
setDeleteItemModalOpen(true);
}}
>
<span className="dropdown-icon">
<IconTrash size={16} strokeWidth={2} />
</span>
Delete
</div>
</Dropdown>
<MenuDropdown
ref={menuDropdownRef}
items={buildMenuItems()}
placement="bottom-start"
data-testid="collection-item-menu"
>
<IconDots size={22} />
</MenuDropdown>
</div>
</div>
</div>