diff --git a/src-web/components/EnvironmentEditDialog.tsx b/src-web/components/EnvironmentEditDialog.tsx index c8a9a5c2..de80a51d 100644 --- a/src-web/components/EnvironmentEditDialog.tsx +++ b/src-web/components/EnvironmentEditDialog.tsx @@ -258,7 +258,7 @@ function SidebarButton({ {environment != null && ( setShowContextMenu(null)} items={[ { diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index 513c5b9b..5642c3ba 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import type { ForwardedRef, ReactNode } from 'react'; -import React, { forwardRef, Fragment, useCallback, useMemo, useRef, useState } from 'react'; +import type { ReactNode } from 'react'; +import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { useKey, useKeyPressEvent } from 'react-use'; @@ -299,7 +299,7 @@ export function Sidebar({ className }: Props) { [hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree], ); - const handleMove = useCallback( + const handleMove = useCallback( (id, side) => { let hoveredTree = treeParentMap[id] ?? null; const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99; @@ -318,11 +318,11 @@ export function Sidebar({ className }: Props) { [isCollapsed, treeParentMap], ); - const handleDragStart = useCallback((id: string) => { + const handleDragStart = useCallback((id: string) => { setDraggingId(id); }, []); - const handleEnd = useCallback( + const handleEnd = useCallback( async (itemId) => { setHoveredTree(null); handleClearSelected(); @@ -436,7 +436,7 @@ export function Sidebar({ className }: Props) { )} > setShowMainContextMenu(null)} /> @@ -511,8 +511,7 @@ function SidebarItems({ return ( {hoveredIndex === i && hoveredTree?.item.id === tree.item.id && } - )} - + ); })} @@ -579,28 +578,74 @@ type SidebarItemProps = { useProminentStyles?: boolean; selected?: boolean; draggable?: boolean; + onMove: (id: string, side: 'above' | 'below') => void; + onEnd: (id: string) => void; + onDragStart: (id: string) => void; children?: ReactNode; child: TreeNode; } & Pick; -const SidebarItem = forwardRef(function SidebarItem( - { - children, - className, - itemName, - itemFallbackName, - itemId, - itemModel, - itemPrefix, - useProminentStyles, - selected, - onSelect, - isCollapsed, - child, - draggable, - }: SidebarItemProps, - ref: ForwardedRef, -) { +type DragItem = { + id: string; + itemName: string; +}; + +function SidebarItem({ + itemName, + itemId, + itemModel, + child, + onMove, + onEnd, + onDragStart, + onSelect, + isCollapsed, + itemPrefix, + className, + selected, + itemFallbackName, + useProminentStyles, + children, +}: SidebarItemProps) { + const ref = useRef(null); + + const [, connectDrop] = useDrop( + { + accept: ItemTypes.REQUEST, + hover: (_, monitor) => { + if (!ref.current) return; + const hoverBoundingRect = ref.current?.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; + onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below'); + }, + }, + [onMove], + ); + + const [, connectDrag] = useDrag< + DragItem, + unknown, + { + isDragging: boolean; + } + >( + () => ({ + type: ItemTypes.REQUEST, + item: () => { + onDragStart(itemId); + return { id: itemId, itemName }; + }, + collect: (m) => ({ isDragging: m.isDragging() }), + options: { dropEffect: 'move' }, + end: () => onEnd(itemId), + }), + [onEnd], + ); + + connectDrag(connectDrop(ref)); + const activeRequest = useActiveRequest(); const deleteFolder = useDeleteFolder(itemId); const deleteRequest = useDeleteRequest(itemId); @@ -673,134 +718,163 @@ const SidebarItem = forwardRef(function SidebarItem( y: number; } | null>(null); + const handleCloseContextMenu = useCallback(() => { + setShowContextMenu(null); + }, []); + const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowContextMenu({ x: e.clientX, y: e.clientY }); }, []); + const items = useMemo(() => { + if (itemModel === 'folder') { + return [ + { + key: 'sendAll', + label: 'Send All', + leftSlot: , + onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)), + }, + { + key: 'rename', + label: 'Rename', + leftSlot: , + onSelect: async () => { + const name = await prompt({ + id: 'rename-folder', + title: 'Rename Folder', + description: ( + <> + Enter a new name for {itemName} + + ), + name: 'name', + label: 'Name', + placeholder: 'New Name', + defaultValue: itemName, + }); + updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) }); + }, + }, + { + key: 'deleteFolder', + label: 'Delete', + variant: 'danger', + leftSlot: , + onSelect: () => deleteFolder.mutate(), + }, + { type: 'separator' }, + ...createDropdownItems, + ]; + } else { + const requestItems: DropdownItem[] = + itemModel === 'http_request' + ? [ + { + key: 'sendRequest', + label: 'Send', + hotKeyAction: 'http_request.send', + hotKeyLabelOnly: true, // Already bound in URL bar + leftSlot: , + onSelect: () => sendRequest.mutate(itemId), + }, + { + key: 'copyCurl', + label: 'Copy as Curl', + leftSlot: , + onSelect: copyAsCurl, + }, + { type: 'separator' }, + ] + : []; + return [ + ...requestItems, + { + key: 'renameRequest', + label: 'Rename', + leftSlot: , + onSelect: async () => { + const name = await prompt({ + id: 'rename-request', + title: 'Rename Request', + description: + itemName === '' ? ( + 'Enter a new name' + ) : ( + <> + Enter a new name for {itemName} + + ), + name: 'name', + label: 'Name', + placeholder: 'New Name', + defaultValue: itemName, + }); + if (itemModel === 'http_request') { + updateHttpRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) }); + } else { + updateGrpcRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) }); + } + }, + }, + { + key: 'duplicateRequest', + label: 'Duplicate', + hotKeyAction: 'http_request.duplicate', + hotKeyLabelOnly: true, // Would trigger for every request (bad) + leftSlot: , + onSelect: () => { + itemModel === 'http_request' + ? duplicateHttpRequest.mutate() + : duplicateGrpcRequest.mutate(); + }, + }, + { + key: 'moveWorkspace', + label: 'Change Workspace', + leftSlot: , + hidden: workspaces.length <= 1, + onSelect: moveToWorkspace.mutate, + }, + { + key: 'deleteRequest', + variant: 'danger', + label: 'Delete', + leftSlot: , + onSelect: () => deleteRequest.mutate(), + }, + ]; + } + }, [ + child.children, + copyAsCurl, + createDropdownItems, + deleteFolder, + deleteRequest, + duplicateGrpcRequest, + duplicateHttpRequest, + itemId, + itemModel, + itemName, + moveToWorkspace.mutate, + prompt, + sendManyRequests, + sendRequest, + updateAnyFolder, + updateGrpcRequest, + updateHttpRequest, + workspaces.length, + ]); + return ( -
  • +
  • , - onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.item.id)), - }, - { - key: 'rename', - label: 'Rename', - leftSlot: , - onSelect: async () => { - const name = await prompt({ - id: 'rename-folder', - title: 'Rename Folder', - description: ( - <> - Enter a new name for {itemName} - - ), - name: 'name', - label: 'Name', - placeholder: 'New Name', - defaultValue: itemName, - }); - updateAnyFolder.mutate({ id: itemId, update: (f) => ({ ...f, name }) }); - }, - }, - { - key: 'deleteFolder', - label: 'Delete', - variant: 'danger', - leftSlot: , - onSelect: () => deleteFolder.mutate(), - }, - { type: 'separator' }, - ...createDropdownItems, - ] - : [ - ...((itemModel === 'http_request' - ? [ - { - key: 'sendRequest', - label: 'Send', - hotKeyAction: 'http_request.send', - hotKeyLabelOnly: true, // Already bound in URL bar - leftSlot: , - onSelect: () => sendRequest.mutate(itemId), - }, - { - key: 'copyCurl', - label: 'Copy as Curl', - leftSlot: , - onSelect: copyAsCurl, - }, - { type: 'separator' }, - ] - : []) as DropdownItem[]), - { - key: 'renameRequest', - label: 'Rename', - leftSlot: , - onSelect: async () => { - const name = await prompt({ - id: 'rename-request', - title: 'Rename Request', - description: - itemName === '' ? ( - 'Enter a new name' - ) : ( - <> - Enter a new name for {itemName} - - ), - name: 'name', - label: 'Name', - placeholder: 'New Name', - defaultValue: itemName, - }); - if (itemModel === 'http_request') { - updateHttpRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) }); - } else { - updateGrpcRequest.mutate({ id: itemId, update: (r) => ({ ...r, name }) }); - } - }, - }, - { - key: 'duplicateRequest', - label: 'Duplicate', - hotKeyAction: 'http_request.duplicate', - hotKeyLabelOnly: true, // Would trigger for every request (bad) - leftSlot: , - onSelect: () => { - itemModel === 'http_request' - ? duplicateHttpRequest.mutate() - : duplicateGrpcRequest.mutate(); - }, - }, - { - key: 'moveWorkspace', - label: 'Change Workspace', - leftSlot: , - hidden: workspaces.length <= 1, - onSelect: moveToWorkspace.mutate, - }, - { - key: 'deleteRequest', - variant: 'danger', - label: 'Delete', - leftSlot: , - onSelect: () => deleteRequest.mutate(), - }, - ] - } - onClose={() => setShowContextMenu(null)} + triggerPosition={showContextMenu} + items={items} + onClose={handleCloseContextMenu} />