mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-28 15:14:06 +00:00
430 lines
15 KiB
JavaScript
430 lines
15 KiB
JavaScript
import React, { useState, forwardRef, useRef, useEffect } from 'react';
|
|
import { getEmptyImage } from 'react-dnd-html5-backend';
|
|
import classnames from 'classnames';
|
|
import { uuid } from 'utils/common';
|
|
import filter from 'lodash/filter';
|
|
import { useDrop, useDrag } from 'react-dnd';
|
|
import {
|
|
IconChevronRight,
|
|
IconDots,
|
|
IconLoader2,
|
|
IconFilePlus,
|
|
IconFolderPlus,
|
|
IconCopy,
|
|
IconClipboard,
|
|
IconPlayerPlay,
|
|
IconEdit,
|
|
IconShare,
|
|
IconFoldDown,
|
|
IconX,
|
|
IconSettings
|
|
} 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 { useDispatch, useSelector } from 'react-redux';
|
|
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
|
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
|
import toast from 'react-hot-toast';
|
|
import NewRequest from 'components/Sidebar/NewRequest';
|
|
import NewFolder from 'components/Sidebar/NewFolder';
|
|
import CollectionItem from './CollectionItem';
|
|
import RemoveCollection from './RemoveCollection';
|
|
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
|
|
import { isItemAFolder, isItemARequest } from 'utils/collections';
|
|
import { isTabForItemActive } from 'src/selectors/tab';
|
|
|
|
import RenameCollection from './RenameCollection';
|
|
import StyledWrapper from './StyledWrapper';
|
|
import CloneCollection from './CloneCollection';
|
|
import { areItemsLoading } from 'utils/collections';
|
|
import { scrollToTheActiveTab } from 'utils/tabs';
|
|
import ShareCollection from 'components/ShareCollection/index';
|
|
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
|
|
import { sortByNameThenSequence } from 'utils/common/index';
|
|
|
|
const Collection = ({ collection, searchText }) => {
|
|
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
|
|
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
|
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
|
|
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
|
|
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
|
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
|
|
const [dropType, setDropType] = useState(null);
|
|
const dispatch = useDispatch();
|
|
const isLoading = areItemsLoading(collection);
|
|
const collectionRef = useRef(null);
|
|
|
|
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
|
|
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
|
const menuDropdownTippyRef = useRef();
|
|
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
|
const MenuIcon = forwardRef((_props, ref) => {
|
|
return (
|
|
<div ref={ref} className="pr-2">
|
|
<IconDots size={22} />
|
|
</div>
|
|
);
|
|
});
|
|
|
|
const handleRun = () => {
|
|
dispatch(
|
|
addTab({
|
|
uid: uuid(),
|
|
collectionUid: collection.uid,
|
|
type: 'collection-runner'
|
|
})
|
|
);
|
|
};
|
|
|
|
const ensureCollectionIsMounted = () => {
|
|
if(collection.mountStatus === 'mounted'){
|
|
return;
|
|
}
|
|
dispatch(mountCollection({
|
|
collectionUid: collection.uid,
|
|
collectionPathname: collection.pathname,
|
|
brunoConfig: collection.brunoConfig
|
|
}));
|
|
}
|
|
|
|
const hasSearchText = searchText && searchText?.trim()?.length;
|
|
const collectionIsCollapsed = hasSearchText ? false : collection.collapsed;
|
|
|
|
const iconClassName = classnames({
|
|
'rotate-90': !collectionIsCollapsed
|
|
});
|
|
|
|
const handleClick = (event) => {
|
|
if (event.detail != 1) return;
|
|
// Check if the click came from the chevron icon
|
|
const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
|
|
setTimeout(scrollToTheActiveTab, 50);
|
|
|
|
ensureCollectionIsMounted();
|
|
|
|
if(collection.collapsed) {
|
|
dispatch(toggleCollection(collection.uid));
|
|
}
|
|
|
|
if(!isChevronClick) {
|
|
dispatch(hideHomePage()); // @TODO Playwright tests are often stuck on home page, rather than collection settings tab. Revisit for a proper fix.
|
|
dispatch(
|
|
addTab({
|
|
uid: collection.uid,
|
|
collectionUid: collection.uid,
|
|
type: 'collection-settings',
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleDoubleClick = (_event) => {
|
|
dispatch(makeTabPermanent({ uid: collection.uid }))
|
|
};
|
|
|
|
const handleCollectionCollapse = (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
ensureCollectionIsMounted();
|
|
dispatch(toggleCollection(collection.uid));
|
|
}
|
|
|
|
// prevent the parent's double-click handler from firing
|
|
const handleCollectionDoubleClick = (e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
};
|
|
|
|
const handleRightClick = (_event) => {
|
|
const _menuDropdown = menuDropdownTippyRef.current;
|
|
if (_menuDropdown) {
|
|
let menuDropdownBehavior = 'show';
|
|
if (_menuDropdown.state.isShown) {
|
|
menuDropdownBehavior = 'hide';
|
|
}
|
|
_menuDropdown[menuDropdownBehavior]();
|
|
}
|
|
};
|
|
|
|
const handleCollapseFullCollection = () => {
|
|
dispatch(collapseFullCollection({ collectionUid: collection.uid }));
|
|
};
|
|
|
|
const viewCollectionSettings = () => {
|
|
dispatch(
|
|
addTab({
|
|
uid: collection.uid,
|
|
collectionUid: collection.uid,
|
|
type: 'collection-settings'
|
|
})
|
|
);
|
|
};
|
|
|
|
const handlePasteRequest = () => {
|
|
menuDropdownTippyRef.current.hide();
|
|
dispatch(pasteItem(collection.uid, null))
|
|
.then(() => {
|
|
toast.success('Request pasted successfully');
|
|
})
|
|
.catch((err) => {
|
|
toast.error(err ? err.message : 'An error occurred while pasting the request');
|
|
});
|
|
};
|
|
|
|
const isCollectionItem = (itemType) => {
|
|
return itemType === 'collection-item';
|
|
};
|
|
|
|
const [{ isDragging }, drag, dragPreview] = useDrag({
|
|
type: "collection",
|
|
item: collection,
|
|
collect: (monitor) => ({
|
|
isDragging: monitor.isDragging(),
|
|
}),
|
|
options: {
|
|
dropEffect: "move"
|
|
}
|
|
});
|
|
|
|
const [{ isOver }, drop] = useDrop({
|
|
accept: ["collection", "collection-item"],
|
|
hover: (_draggedItem, monitor) => {
|
|
const itemType = monitor.getItemType();
|
|
if (isCollectionItem(itemType)) {
|
|
// For collection items, always show full highlight (inside drop)
|
|
setDropType('inside');
|
|
} else {
|
|
// For collections, show line indicator (adjacent drop)
|
|
setDropType('adjacent');
|
|
}
|
|
},
|
|
drop: (draggedItem, monitor) => {
|
|
const itemType = monitor.getItemType();
|
|
if (isCollectionItem(itemType)) {
|
|
dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
|
|
} else {
|
|
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
|
|
}
|
|
setDropType(null);
|
|
},
|
|
canDrop: (draggedItem) => {
|
|
return draggedItem.uid !== collection.uid;
|
|
},
|
|
collect: (monitor) => ({
|
|
isOver: monitor.isOver(),
|
|
}),
|
|
});
|
|
|
|
useEffect(() => {
|
|
dragPreview(getEmptyImage(), { captureDraggingState: true });
|
|
}, []);
|
|
|
|
if (searchText && searchText.length) {
|
|
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
|
|
'item-hovered': isOver && dropType === 'adjacent', // For collection-to-collection moves (show line)
|
|
'drop-target': isOver && dropType === 'inside', // For collection-item drops (highlight full area)
|
|
'collection-focused-in-tab': isCollectionFocused
|
|
});
|
|
|
|
// we need to sort request items by seq property
|
|
const sortItemsBySequence = (items = []) => {
|
|
return items.sort((a, b) => a.seq - b.seq);
|
|
};
|
|
|
|
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
|
|
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
|
|
|
|
return (
|
|
<StyledWrapper className="flex flex-col" id={`collection-${collection.name.replace(/\s+/g, '-').toLowerCase()}`}>
|
|
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
|
|
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
|
|
{showRenameCollectionModal && (
|
|
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
|
|
)}
|
|
{showRemoveCollectionModal && (
|
|
<RemoveCollection collectionUid={collection.uid} onClose={() => setShowRemoveCollectionModal(false)} />
|
|
)}
|
|
{showShareCollectionModal && (
|
|
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
|
|
)}
|
|
{showCloneCollectionModalOpen && (
|
|
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
|
|
)}
|
|
<CollectionItemDragPreview />
|
|
<div className={collectionRowClassName}
|
|
ref={(node) => {
|
|
collectionRef.current = node;
|
|
drag(drop(node));
|
|
}}
|
|
>
|
|
<div
|
|
className="flex flex-grow items-center overflow-hidden"
|
|
onClick={handleClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
onContextMenu={handleRightClick}
|
|
>
|
|
<IconChevronRight
|
|
size={16}
|
|
strokeWidth={2}
|
|
className={`chevron-icon ${iconClassName}`}
|
|
style={{ width: 16, minWidth: 16, color: 'rgb(160 160 160)' }}
|
|
onClick={handleCollectionCollapse}
|
|
onDoubleClick={handleCollectionDoubleClick}
|
|
/>
|
|
<div className="ml-1 w-full" id="sidebar-collection-name" title={collection.name}>
|
|
{collection.name}
|
|
</div>
|
|
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
|
|
</div>
|
|
<div className="collection-actions" data-testid="collection-actions">
|
|
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
setShowNewRequestModal(true);
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconFilePlus size={16} strokeWidth={2} />
|
|
</span>
|
|
New Request
|
|
</div>
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
setShowNewFolderModal(true);
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconFolderPlus size={16} strokeWidth={2} />
|
|
</span>
|
|
New Folder
|
|
</div>
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
ensureCollectionIsMounted();
|
|
handleRun();
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconPlayerPlay size={16} strokeWidth={2} />
|
|
</span>
|
|
Run
|
|
</div>
|
|
<div
|
|
className="dropdown-item"
|
|
data-testid="clone-collection"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
setShowCloneCollectionModalOpen(true);
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconCopy size={16} strokeWidth={2} />
|
|
</span>
|
|
Clone
|
|
</div>
|
|
{hasCopiedItems && (
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={handlePasteRequest}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconClipboard size={16} strokeWidth={2} />
|
|
</span>
|
|
Paste
|
|
</div>
|
|
)}
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
setShowRenameCollectionModal(true);
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconEdit size={16} strokeWidth={2} />
|
|
</span>
|
|
Rename
|
|
</div>
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
ensureCollectionIsMounted();
|
|
setShowShareCollectionModal(true);
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconShare size={16} strokeWidth={2} />
|
|
</span>
|
|
Share
|
|
</div>
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
handleCollapseFullCollection();
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconFoldDown size={16} strokeWidth={2} />
|
|
</span>
|
|
Collapse
|
|
</div>
|
|
<div className="dropdown-separator"></div>
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
viewCollectionSettings();
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconSettings size={16} strokeWidth={2} />
|
|
</span>
|
|
Settings
|
|
</div>
|
|
<div
|
|
className="dropdown-item"
|
|
onClick={(_e) => {
|
|
menuDropdownTippyRef.current.hide();
|
|
setShowRemoveCollectionModal(true);
|
|
}}
|
|
>
|
|
<span className="dropdown-icon">
|
|
<IconX size={16} strokeWidth={2} />
|
|
</span>
|
|
Remove
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
{!collectionIsCollapsed ? (
|
|
<div>
|
|
{folderItems?.map?.((i) => {
|
|
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
|
|
})}
|
|
{requestItems?.map?.((i) => {
|
|
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</StyledWrapper>
|
|
);
|
|
};
|
|
|
|
export default Collection;
|