import type { AnyModel, GrpcConnection, HttpResponse } from '@yaakapp-internal/models'; import classNames from 'classnames'; import { atom, useAtomValue } from 'jotai'; import type { ReactElement } from 'react'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { XYCoord } from 'react-dnd'; import { useDrag, useDrop } from 'react-dnd'; import { upsertWebsocketRequest } from '../../commands/upsertWebsocketRequest'; import { activeRequestAtom } from '../../hooks/useActiveRequest'; import { foldersAtom } from '../../hooks/useFolders'; import { requestsAtom } from '../../hooks/useRequests'; import { useScrollIntoView } from '../../hooks/useScrollIntoView'; import { useSidebarItemCollapsed } from '../../hooks/useSidebarItemCollapsed'; import { useUpdateAnyGrpcRequest } from '../../hooks/useUpdateAnyGrpcRequest'; import { useUpdateAnyHttpRequest } from '../../hooks/useUpdateAnyHttpRequest'; import { getWebsocketRequest } from '../../hooks/useWebsocketRequests'; import { jotaiStore } from '../../lib/jotai'; import { HttpMethodTag } from '../core/HttpMethodTag'; import { Icon } from '../core/Icon'; import { StatusTag } from '../core/StatusTag'; import type { SidebarTreeNode } from './Sidebar'; import { sidebarSelectedIdAtom } from './SidebarAtoms'; import { SidebarItemContextMenu } from './SidebarItemContextMenu'; import type { SidebarItemsProps } from './SidebarItems'; enum ItemTypes { REQUEST = 'request', } export type SidebarItemProps = { className?: string; itemId: string; itemName: string; itemModel: AnyModel['model']; onMove: (id: string, side: 'above' | 'below') => void; onEnd: (id: string) => void; onDragStart: (id: string) => void; children: ReactElement | null; child: SidebarTreeNode; latestHttpResponse: HttpResponse | null; latestGrpcConnection: GrpcConnection | null; } & Pick; type DragItem = { id: string; itemName: string; }; export const SidebarItem = memo(function SidebarItem({ itemName, itemId, itemModel, child, onMove, onEnd, onDragStart, onSelect, className, latestHttpResponse, latestGrpcConnection, children, }: SidebarItemProps) { const ref = useRef(null); const [collapsed, toggleCollapsed] = useSidebarItemCollapsed(itemId); 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: () => { // Cancel drag when editing if (editing) return null; onDragStart(itemId); return { id: itemId, itemName }; }, collect: (m) => ({ isDragging: m.isDragging() }), options: { dropEffect: 'move' }, end: () => onEnd(itemId), }), [onEnd], ); connectDrag(connectDrop(ref)); const updateHttpRequest = useUpdateAnyHttpRequest(); const updateGrpcRequest = useUpdateAnyGrpcRequest(); const [editing, setEditing] = useState(false); const [selected, setSelected] = useState( jotaiStore.get(sidebarSelectedIdAtom) == itemId, ); useEffect(() => { return jotaiStore.sub(sidebarSelectedIdAtom, () => { const value = jotaiStore.get(sidebarSelectedIdAtom); setSelected(value === itemId); }); }, [itemId]); const [active, setActive] = useState(jotaiStore.get(activeRequestAtom)?.id === itemId); useEffect( () => jotaiStore.sub(activeRequestAtom, () => setActive(jotaiStore.get(activeRequestAtom)?.id === itemId), ), [itemId], ); useScrollIntoView(ref.current, active); const handleSubmitNameEdit = useCallback( async (el: HTMLInputElement) => { if (itemModel === 'http_request') { await updateHttpRequest.mutateAsync({ id: itemId, update: (r) => ({ ...r, name: el.value }), }); } else if (itemModel === 'grpc_request') { await updateGrpcRequest.mutateAsync({ id: itemId, update: (r) => ({ ...r, name: el.value }), }); } else if (itemModel === 'websocket_request') { const request = getWebsocketRequest(itemId); if (request == null) return; await upsertWebsocketRequest.mutateAsync({ ...request, name: el.value }); } setEditing(false); }, [itemId, itemModel, updateGrpcRequest, updateHttpRequest], ); const handleFocus = useCallback((el: HTMLInputElement | null) => { el?.focus(); el?.select(); }, []); const handleInputKeyDown = useCallback( async (e: React.KeyboardEvent) => { e.stopPropagation(); switch (e.key) { case 'Enter': e.preventDefault(); await handleSubmitNameEdit(e.currentTarget); break; case 'Escape': e.preventDefault(); setEditing(false); break; } }, [handleSubmitNameEdit], ); const handleStartEditing = useCallback(() => { if ( itemModel !== 'http_request' && itemModel !== 'grpc_request' && itemModel !== 'websocket_request' ) return; setEditing(true); }, [setEditing, itemModel]); const handleBlur = useCallback( async (e: React.FocusEvent) => { await handleSubmitNameEdit(e.currentTarget); }, [handleSubmitNameEdit], ); const handleSelect = useCallback(async () => { if (itemModel === 'folder') toggleCollapsed(); else onSelect(itemId); }, [itemModel, toggleCollapsed, onSelect, itemId]); const [showContextMenu, setShowContextMenu] = useState<{ x: number; y: number; } | null>(null); const handleContextMenu = useCallback((e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setShowContextMenu({ x: e.clientX, y: e.clientY }); }, []); const handleCloseContextMenu = useCallback(() => setShowContextMenu(null), []); const itemAtom = useMemo(() => { return atom((get) => { if (itemModel === 'folder') { return get(foldersAtom).find((v) => v.id === itemId); } else { return get(requestsAtom).find((v) => v.id === itemId); } }); }, [itemId, itemModel]); const item = useAtomValue(itemAtom); if (item == null) { return null; } const itemPrefix = item.model !== 'folder' && ( ); return (
  • {showContextMenu && ( )}
    {collapsed ? null : children}
  • ); });