import React, { useState, useRef, forwardRef, useEffect } from 'react'; import { getEmptyImage } from 'react-dnd-html5-backend'; import range from 'lodash/range'; import filter from 'lodash/filter'; import classnames from 'classnames'; import { useDrag, useDrop } from 'react-dnd'; import { IconChevronRight, IconDots } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; import { handleCollectionItemDrop, sendRequest, showInFolder, pasteItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { toggleCollectionItem, addResponseExample } from 'providers/ReduxStore/slices/collections'; 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 NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; import RenameCollectionItem from './RenameCollectionItem'; import CloneCollectionItem from './CloneCollectionItem'; import DeleteCollectionItem from './DeleteCollectionItem'; import RunCollectionItem from './RunCollectionItem'; import GenerateCodeItem from './GenerateCodeItem'; import { isItemARequest, isItemAFolder } from 'utils/tabs'; import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search'; import { getDefaultRequestPaneTab } from 'utils/collections'; import { hideHomePage } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; import NetworkError from 'components/ResponsePane/NetworkError/index'; import CollectionItemInfo from './CollectionItemInfo/index'; import CollectionItemIcon from './CollectionItemIcon'; import ExampleItem from './ExampleItem'; import { scrollToTheActiveTab } from 'utils/tabs'; import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab'; import { isEqual } from 'lodash'; import { calculateDraggedItemNewPathname, getInitialExampleName } from 'utils/collections/index'; import { sortByNameThenSequence } from 'utils/common/index'; import CreateExampleModal from 'components/ResponseExample/CreateExampleModal'; const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => { const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid }); const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual); const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid }); const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual); const isSidebarDragging = useSelector((state) => state.app.isDragging); const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid)); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); const dispatch = useDispatch(); // We use a single ref for drag and drop. const ref = useRef(null); const [renameItemModalOpen, setRenameItemModalOpen] = useState(false); const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false); const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false); const [createExampleModalOpen, setCreateExampleModalOpen] = useState(false); const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); const [examplesExpanded, setExamplesExpanded] = useState(false); const hasSearchText = searchText && searchText?.trim()?.length; const itemIsCollapsed = hasSearchText ? false : item.collapsed; const isFolder = isItemAFolder(item); // Check if request has examples (only for HTTP requests) const hasExamples = isItemARequest(item) && item.type === 'http-request' && item.examples && item.examples.length > 0; const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside' const [{ isDragging }, drag, dragPreview] = useDrag({ type: 'collection-item', item: { ...item, sourceCollectionUid: collectionUid }, collect: (monitor) => ({ isDragging: monitor.isDragging() }), options: { dropEffect: "move" } }); useEffect(() => { dragPreview(getEmptyImage(), { captureDraggingState: true }); }, []); // Auto-scroll to show this item when its tab becomes active useEffect(() => { if (isTabForItemActive && ref.current) { try { ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } catch (err) { // ignore scroll errors (some environments may not support smooth scrolling) } } }, [isTabForItemActive]); const determineDropType = (monitor) => { const hoverBoundingRect = ref.current?.getBoundingClientRect(); const clientOffset = monitor.getClientOffset(); if (!hoverBoundingRect || !clientOffset) return null; const clientY = clientOffset.y - hoverBoundingRect.top; const folderUpperThreshold = hoverBoundingRect.height * 0.35; const fileUpperThreshold = hoverBoundingRect.height * 0.5; if (isItemAFolder(item)) { return clientY < folderUpperThreshold ? 'adjacent' : 'inside'; } else { return clientY < fileUpperThreshold ? 'adjacent' : null; } }; const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => { const { uid: targetItemUid, pathname: targetItemPathname } = targetItem; const { uid: draggedItemUid, pathname: draggedItemPathname, sourceCollectionUid } = draggedItem; if (draggedItemUid === targetItemUid) return false; // For cross-collection moves, we allow the drop if (sourceCollectionUid !== collectionUid) { return true; } const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname }); if (!newPathname) return false; if (targetItemPathname?.startsWith(draggedItemPathname)) return false; return true; }; const [{ isOver, canDrop }, drop] = useDrop({ accept: 'collection-item', hover: (draggedItem, monitor) => { const { uid: targetItemUid } = item; const { uid: draggedItemUid } = draggedItem; if (draggedItemUid === targetItemUid) return; const dropType = determineDropType(monitor); const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType }); setDropType(_canItemBeDropped ? dropType : null); }, drop: async (draggedItem, monitor) => { const { uid: targetItemUid } = item; const { uid: draggedItemUid } = draggedItem; if (draggedItemUid === targetItemUid) return; const dropType = determineDropType(monitor); if (!dropType) return; await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid })) setDropType(null); }, canDrop: (draggedItem) => draggedItem.uid !== item.uid, collect: (monitor) => ({ isOver: monitor.isOver() }), }); const dropdownTippyRef = useRef(); const MenuIcon = forwardRef((props, ref) => { return (
); }); const iconClassName = classnames({ 'rotate-90': !itemIsCollapsed }); const examplesIconClassName = classnames({ 'rotate-90': examplesExpanded }); const itemRowClassName = classnames('flex collection-item-name relative items-center', { 'item-focused-in-tab': isTabForItemActive, 'item-hovered': isOver && canDrop, 'drop-target': isOver && dropType === 'inside', 'drop-target-above': isOver && dropType === 'adjacent' }); const handleRun = async () => { dispatch(sendRequest(item, collectionUid)).catch((err) => toast.custom((t) => toast.dismiss(t.id)} />, { duration: 5000 }) ); }; const handleClick = (event) => { if (event && event.detail != 1) return; //scroll to the active tab setTimeout(scrollToTheActiveTab, 50); const isRequest = isItemARequest(item); if (isRequest) { dispatch(hideHomePage()); if (isTabForItemPresent) { dispatch( focusTab({ uid: item.uid }) ); return; } dispatch( addTab({ uid: item.uid, collectionUid: collectionUid, requestPaneTab: getDefaultRequestPaneTab(item), type: 'request', }) ); } else { dispatch( addTab({ uid: item.uid, collectionUid: collectionUid, type: 'folder-settings', }) ); if(item.collapsed) { dispatch( toggleCollectionItem({ itemUid: item.uid, collectionUid: collectionUid }) ); } } }; const handleFolderCollapse = (e) => { e.stopPropagation(); e.preventDefault(); dispatch( toggleCollectionItem({ itemUid: item.uid, collectionUid: collectionUid }) ); }; // prevent the parent's double-click handler from firing const handleFolderDoubleClick = (e) => { e.stopPropagation(); e.preventDefault(); }; const handleExamplesCollapse = (e) => { e.stopPropagation(); e.preventDefault(); setExamplesExpanded(!examplesExpanded); }; // prevent the parent's double-click handler from firing const handleExamplesDoubleClick = (e) => { e.stopPropagation(); e.preventDefault(); }; const handleRightClick = (event) => { const _menuDropdown = dropdownTippyRef.current; if (_menuDropdown) { let menuDropdownBehavior = 'show'; if (_menuDropdown.state.isShown) { menuDropdownBehavior = 'hide'; } _menuDropdown[menuDropdownBehavior](); } }; let indents = range(item.depth); const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); const className = classnames('flex flex-col w-full', { 'is-sidebar-dragging': isSidebarDragging }); if (searchText && searchText.length) { if (isItemARequest(item)) { if (!doesRequestMatchSearchText(item, searchText)) { return null; } } else { if (!doesFolderHaveItemsMatchSearchText(item, searchText)) { return null; } } } const handleDoubleClick = (event) => { dispatch(makeTabPermanent({ uid: item.uid })); }; // Sort items by their "seq" property. const sortItemsBySequence = (items = []) => { return items.sort((a, b) => a.seq - b.seq); }; const handleShowInFolder = () => { dispatch(showInFolder(item.pathname)).catch((error) => { console.error('Error opening the folder', error); toast.error('Error opening the folder'); }); }; const handleCreateExample = async (name, description = '') => { // Create example with default values const exampleData = { name: name, description: description, status: '200', statusText: 'OK', headers: [], body: { type: 'text', content: '' } }; // Calculate the index where the example will be saved const existingExamples = item.draft?.examples || item.examples || []; const exampleIndex = existingExamples.length; const exampleUid = uuid(); dispatch(addResponseExample({ itemUid: item.uid, collectionUid: collectionUid, example: { ...exampleData, uid: exampleUid } })); // Save the request await dispatch(saveRequest(item.uid, collectionUid)); // Task middleware will track this and open the example in a new tab once the file is reloaded dispatch(insertTaskIntoQueue({ uid: exampleUid, type: 'OPEN_EXAMPLE', collectionUid: collectionUid, itemUid: item.uid, exampleIndex: exampleIndex })); toast.success(`Example "${name}" created successfully`); setCreateExampleModalOpen(false); }; 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(); if ( (item?.request?.url !== '') || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '') ) { setGenerateCodeItemModalOpen(true); } else { toast.error('URL is required'); } }; const viewFolderSettings = () => { if (isItemAFolder(item)) { if (isTabForItemPresent) { dispatch(focusTab({ uid: item.uid })); return; } dispatch( addTab({ uid: item.uid, collectionUid, type: 'folder-settings' }) ); } }; const handleCopyRequest = () => { dropdownTippyRef.current.hide(); dispatch(copyRequest(item)); toast.success('Request copied to clipboard'); }; const handlePasteRequest = () => { dropdownTippyRef.current.hide(); dispatch(pasteItem(collectionUid, item.uid)) .then(() => { toast.success('Request pasted successfully'); }) .catch((err) => { toast.error(err ? err.message : 'An error occurred while pasting the request'); }); }; return ( {renameItemModalOpen && ( setRenameItemModalOpen(false)} /> )} {cloneItemModalOpen && ( setCloneItemModalOpen(false)} /> )} {deleteItemModalOpen && ( setDeleteItemModalOpen(false)} /> )} {newRequestModalOpen && ( setNewRequestModalOpen(false)} /> )} {newFolderModalOpen && ( setNewFolderModalOpen(false)} /> )} {runCollectionModalOpen && ( setRunCollectionModalOpen(false)} /> )} {generateCodeItemModalOpen && ( setGenerateCodeItemModalOpen(false)} /> )} {itemInfoModalOpen && ( setItemInfoModalOpen(false)} /> )} setCreateExampleModalOpen(false)} onSave={handleCreateExample} title="Create Response Example" initialName={getInitialExampleName(item)} />
{ ref.current = node; drag(drop(node)); }} >
{indents && indents.length ? indents.map((i) => (
 {/* Indent */}
)) : null}
{isFolder ? ( ) : hasExamples ? ( ) : null}
{item.name}
} placement="bottom-start"> {isFolder && ( <>
{ dropdownTippyRef.current.hide(); setNewRequestModalOpen(true); }} > New Request
{ dropdownTippyRef.current.hide(); setNewFolderModalOpen(true); }} > New Folder
{ dropdownTippyRef.current.hide(); setRunCollectionModalOpen(true); }} > Run
)}
{ dropdownTippyRef.current.hide(); setRenameItemModalOpen(true); }} > Rename
{ dropdownTippyRef.current.hide(); setCloneItemModalOpen(true); }} > Clone
{!isFolder && (
Copy
)} {isFolder && hasCopiedItems && (
Paste
)} {!isFolder && (
{ dropdownTippyRef.current.hide(); handleClick(null); handleRun(); }} > Run
)} {!isFolder && (item.type === 'http-request' || item.type === 'graphql-request') && (
{ handleGenerateCode(e); }} > Generate Code
)} {!isFolder && isItemARequest(item) && item.type === 'http-request' && (
{ dropdownTippyRef.current.hide(); setCreateExampleModalOpen(true); }} > Create Example
)}
{ dropdownTippyRef.current.hide(); handleShowInFolder(); }} > Show in Folder
{ dropdownTippyRef.current.hide(); setDeleteItemModalOpen(true); }} > Delete
{isFolder && (
{ dropdownTippyRef.current.hide(); viewFolderSettings(); }} > Settings
)}
{ dropdownTippyRef.current.hide(); setItemInfoModalOpen(true); }} > Info
{!itemIsCollapsed ? (
{folderItems && folderItems.length ? folderItems.map((i) => { return ; }) : null} {requestItems && requestItems.length ? requestItems.map((i) => { return ; }) : null}
) : null} {/* Show examples when expanded (only for HTTP requests) */} {isItemARequest(item) && item.type === 'http-request' && examplesExpanded && hasExamples && (
{(item.examples || []).map((example, index) => { return ( ); })}
)}
); }; export default React.memo(CollectionItem);