From 867f3908ed687dfd2ccdec9c8c85ed462d532bb8 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Fri, 3 Nov 2023 14:08:46 -0700 Subject: [PATCH] Nested sidebar ordering almost working --- src-web/components/Sidebar.tsx | 288 +++++++++++------- src-web/components/core/Editor/Editor.css | 2 +- .../core/Editor/twig/placeholder.ts | 1 + src-web/hooks/useUpdateAnyFolder.ts | 28 ++ src-web/lib/store.ts | 11 +- 5 files changed, 220 insertions(+), 110 deletions(-) create mode 100644 src-web/hooks/useUpdateAnyFolder.ts diff --git a/src-web/components/Sidebar.tsx b/src-web/components/Sidebar.tsx index 83dcdbcc..bde18fe4 100644 --- a/src-web/components/Sidebar.tsx +++ b/src-web/components/Sidebar.tsx @@ -13,7 +13,7 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden'; import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent'; import { useUpdateAnyRequest } from '../hooks/useUpdateAnyRequest'; import { useUpdateRequest } from '../hooks/useUpdateRequest'; -import type { HttpRequest } from '../lib/models'; +import type { Folder, HttpRequest, Workspace } from '../lib/models'; import { isResponseLoading } from '../lib/models'; import { Icon } from './core/Icon'; import { StatusTag } from './core/StatusTag'; @@ -21,6 +21,9 @@ import { DropMarker } from './DropMarker'; import { useActiveEnvironmentId } from '../hooks/useActiveEnvironmentId'; import { useCreateRequest } from '../hooks/useCreateRequest'; import { VStack } from './core/Stacks'; +import { useFolders } from '../hooks/useFolders'; +import { useActiveWorkspace } from '../hooks/useActiveWorkspace'; +import { useUpdateAnyFolder } from '../hooks/useUpdateAnyFolder'; interface Props { className?: string; @@ -30,27 +33,60 @@ enum ItemTypes { REQUEST = 'request', } +interface TreeNode { + item: Workspace | Folder | HttpRequest; + children: TreeNode[]; + depth: number; +} + export const Sidebar = memo(function Sidebar({ className }: Props) { const { hidden } = useSidebarHidden(); const createRequest = useCreateRequest(); - const sidebarRef = useRef(null); + const sidebarRef = useRef(null); const activeRequestId = useActiveRequestId(); const activeEnvironmentId = useActiveEnvironmentId(); - const unorderedRequests = useRequests(); + const requests = useRequests(); + const folders = useFolders(); const deleteAnyRequest = useDeleteAnyRequest(); + const activeWorkspace = useActiveWorkspace(); const routes = useAppRoutes(); - const requests = useMemo( - () => [...unorderedRequests].sort((a, b) => a.sortPriority - b.sortPriority), - [unorderedRequests], - ); const [hasFocus, setHasFocus] = useState(false); const [selectedIndex, setSelectedIndex] = useState(); + const { tree, treeParentMap } = useMemo<{ + tree: TreeNode | null; + treeParentMap: Record; + }>(() => { + const treeParentMap: Record = {}; + if (activeWorkspace == null) { + return { tree: null, treeParentMap }; + } + + // Put requests and folders into a tree structure + const next = (node: TreeNode): TreeNode => { + const childItems = [...requests, ...folders].filter((f) => + node.item.model === 'workspace' ? f.folderId == null : f.folderId === node.item.id, + ); + + childItems.sort((a, b) => a.sortPriority - b.sortPriority); + const depth = node.depth + 1; + for (const item of childItems) { + treeParentMap[item.id] = node; + node.children.push(next({ item, children: [], depth })); + } + return node; + }; + + const tree = next({ item: activeWorkspace, children: [], depth: 0 }); + return { tree, treeParentMap }; + }, [activeWorkspace, requests, folders]); + // TODO: Move these listeners to a central place useListenToTauriEvent('new_request', async () => createRequest.mutate({})); const focusActiveRequest = useCallback( (forcedIndex?: number) => { + // TODO: Use tree to find index const index = forcedIndex ?? requests.findIndex((r) => r.id === activeRequestId); if (index < 0) return; setSelectedIndex(index >= 0 ? index : undefined); @@ -62,6 +98,7 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { const handleSelect = useCallback( (requestId: string) => { + // TODO: Use tree to find index const index = requests.findIndex((r) => r.id === requestId); const request = requests[index]; if (!request) return; @@ -70,10 +107,10 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { workspaceId: request.workspaceId, environmentId: activeEnvironmentId ?? undefined, }); - setSelectedIndex(index); - focusActiveRequest(index); + // setSelectedIndex(index); + // focusActiveRequest(index); }, - [focusActiveRequest, requests, routes, activeEnvironmentId], + [requests, routes, activeEnvironmentId], ); const handleClearSelected = useCallback(() => { @@ -151,6 +188,80 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { undefined, [hasFocus, requests, selectedIndex], ); + const updateAnyRequest = useUpdateAnyRequest(); + const updateAnyFolder = useUpdateAnyFolder(); + + const [hoveredId, setHoveredId] = useState(null); + const handleMove = useCallback( + (id, side) => { + const hoveredTree = treeParentMap[id]; + const dragIndex = hoveredTree?.children.findIndex((n) => n.item.id === id) ?? -99; + const target = hoveredTree?.children[dragIndex + (side === 'above' ? 0 : 1)]?.item; + console.log('SET HOVERED ID', target?.id); + setHoveredId(target?.id ?? null); + }, + [treeParentMap, setHoveredId], + ); + + const handleEnd = useCallback( + (itemId) => { + if (hoveredId === null) return; + setHoveredId(null); + handleClearSelected(); + + const targetTree = treeParentMap[hoveredId] ?? null; + if (targetTree == null) { + return; + } + + const parentTree = treeParentMap[itemId] ?? null; + const index = parentTree?.children.findIndex((n) => n.item.id === itemId) ?? -1; + const child = parentTree?.children[index ?? -1]; + if (child === undefined || parentTree == null) return; + + const newChildren = targetTree.children.filter((c) => c.item.id !== itemId); + const hoveredIndex = newChildren.findIndex((c) => c.item.id === hoveredId) ?? null; + if (hoveredIndex > index) newChildren.splice(hoveredIndex - 1, 0, child); + else newChildren.splice(hoveredIndex, 0, child); + + // Do a simple find because the math is too hard + const newIndex = newChildren.findIndex((r) => r.item.id === itemId) ?? 0; + const prev = newChildren[newIndex - 1]?.item; + const next = newChildren[newIndex + 1]?.item; + const beforePriority = prev == null || prev.model === 'workspace' ? 0 : prev.sortPriority; + const afterPriority = next == null || next.model === 'workspace' ? 0 : next.sortPriority; + + const folderId = targetTree.item.model === 'folder' ? targetTree.item.id : null; + const shouldUpdateAll = afterPriority - beforePriority < 1; + + if (shouldUpdateAll) { + newChildren.forEach((child, i) => { + const sortPriority = i * 1000; + if (child.item.model === 'folder') { + const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); + updateAnyFolder.mutate({ id: child.item.id, update: updateFolder }); + } else if (child.item.model === 'http_request') { + const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); + updateAnyRequest.mutate({ id: child.item.id, update: updateRequest }); + } + }); + } else { + const sortPriority = afterPriority - (afterPriority - beforePriority) / 2; + if (child.item.model === 'folder') { + const updateFolder = (f: Folder) => ({ ...f, sortPriority, folderId }); + updateAnyFolder.mutate({ id: child.item.id, update: updateFolder }); + } else if (child.item.model === 'http_request') { + const updateRequest = (r: HttpRequest) => ({ ...r, sortPriority, folderId }); + updateAnyRequest.mutate({ id: child.item.id, update: updateRequest }); + } + } + }, + [hoveredId, handleClearSelected, treeParentMap, updateAnyFolder, updateAnyRequest], + ); + + if (tree == null) { + return null; + } return (
@@ -165,11 +276,14 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { )} >
@@ -177,110 +291,66 @@ export const Sidebar = memo(function Sidebar({ className }: Props) { }); interface SidebarItemsProps { - requests: HttpRequest[]; + tree: TreeNode; focused: boolean; selectedIndex?: number; + treeParentMap: Record; + hoveredId: string | null; + handleMove: (id: string, side: 'above' | 'below') => void; + handleEnd: (id: string) => void; onSelect: (requestId: string) => void; - onClearSelected: () => void; } function SidebarItems({ - requests, + tree, focused, selectedIndex, onSelect, - onClearSelected, + treeParentMap, + hoveredId, + handleEnd, + handleMove, }: SidebarItemsProps) { - const [hoveredIndex, setHoveredIndex] = useState(null); - const updateRequest = useUpdateAnyRequest(); - - const handleMove = useCallback( - (id, side) => { - const dragIndex = requests.findIndex((r) => r.id === id); - setHoveredIndex(side === 'above' ? dragIndex : dragIndex + 1); - }, - [requests], - ); - - const handleEnd = useCallback( - (requestId) => { - if (hoveredIndex === null) return; - setHoveredIndex(null); - onClearSelected(); - - const index = requests.findIndex((r) => r.id === requestId); - const request = requests[index]; - if (request === undefined) return; - - const newRequests = requests.filter((r) => r.id !== requestId); - if (hoveredIndex > index) newRequests.splice(hoveredIndex - 1, 0, request); - else newRequests.splice(hoveredIndex, 0, request); - - // Do a simple find because the math is too hard - const newIndex = newRequests.findIndex((r) => r.id === requestId) ?? 0; - const beforePriority = newRequests[newIndex - 1]?.sortPriority ?? 0; - const afterPriority = newRequests[newIndex + 1]?.sortPriority ?? 0; - - const shouldUpdateAll = afterPriority - beforePriority < 1; - if (shouldUpdateAll) { - newRequests.forEach(({ id }, i) => { - const sortPriority = i * 1000; - const update = (r: HttpRequest) => ({ ...r, sortPriority }); - updateRequest.mutate({ id, update }); - }); - } else { - const sortPriority = afterPriority - (afterPriority - beforePriority) / 2; - const update = (r: HttpRequest) => ({ ...r, sortPriority }); - updateRequest.mutate({ id: requestId, update }); - } - }, - [hoveredIndex, requests, updateRequest, onClearSelected], - ); - return ( - {requests.map((r, i) => ( - - {hoveredIndex === i && } + {tree.children.map((child, i) => ( + + {hoveredId === child.item.id && } 0 && 'border-l border-highlight ml-5')} /> + {child.item.model === 'folder' && ( + + )} ))} - {hoveredIndex === requests.length && } - - {requests.slice(0, 1).map((r, i) => ( - - {hoveredIndex === i && } - - - ))} - {hoveredIndex === requests.length && } - + {hoveredId === tree.children[tree.children.length - 1]?.item.id && } ); } type SidebarItemProps = { className?: string; - requestId: string; - requestName: string; + itemId: string; + itemName: string; + itemModel: string; useProminentStyles?: boolean; selected?: boolean; onSelect: (requestId: string) => void; @@ -288,14 +358,14 @@ type SidebarItemProps = { }; const _SidebarItem = forwardRef(function SidebarItem( - { className, requestName, requestId, useProminentStyles, selected, onSelect }: SidebarItemProps, + { className, itemName, itemId, useProminentStyles, selected, onSelect }: SidebarItemProps, ref: ForwardedRef, ) { - const latestResponse = useLatestResponse(requestId); - const updateRequest = useUpdateRequest(requestId); + const latestResponse = useLatestResponse(itemId); + const updateRequest = useUpdateRequest(itemId); const [editing, setEditing] = useState(false); const activeRequestId = useActiveRequestId(); - const isActive = activeRequestId === requestId; + const isActive = activeRequestId === itemId; const handleSubmitNameEdit = useCallback( (el: HTMLInputElement) => { @@ -337,8 +407,8 @@ const _SidebarItem = forwardRef(function SidebarItem( ); const handleSelect = useCallback(() => { - onSelect(requestId); - }, [onSelect, requestId]); + onSelect(itemId); + }, [onSelect, itemId]); return (
  • @@ -360,14 +430,14 @@ const _SidebarItem = forwardRef(function SidebarItem( {editing ? ( ) : ( - - {requestName || 'New Request'} + + {itemName || 'New Request'} )} {latestResponse && ( @@ -393,12 +463,13 @@ type DraggableSidebarItemProps = SidebarItemProps & { type DragItem = { id: string; - requestName: string; + itemName: string; }; const DraggableSidebarItem = memo(function DraggableSidebarItem({ - requestName, - requestId, + itemName, + itemId, + itemModel, onMove, onEnd, ...props @@ -414,7 +485,8 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({ const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const clientOffset = monitor.getClientOffset(); const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top; - onMove(requestId, hoverClientY < hoverMiddleY ? 'above' : 'below'); + if (!monitor.isOver()) return; + onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below'); }, }, [onMove], @@ -423,24 +495,24 @@ const DraggableSidebarItem = memo(function DraggableSidebarItem({ const [{ isDragging }, connectDrag] = useDrag( () => ({ type: ItemTypes.REQUEST, - item: () => ({ id: requestId, requestName }), + item: () => ({ id: itemId, itemName }), collect: (m) => ({ isDragging: m.isDragging() }), options: { dropEffect: 'move' }, - end: () => onEnd(requestId), + end: () => onEnd(itemId), }), [onEnd], ); - connectDrag(ref); - connectDrop(ref); + connectDrag(connectDrop(ref)); return ( ); diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index 06a30651..b4873b53 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -58,7 +58,7 @@ -webkit-text-security: none; &.placeholder-widget-error { - @apply bg-red-300/40 border-red-300 border-opacity-40; + @apply bg-red-300/40 border-red-300/80 border-opacity-40 hover:border-red-300 hover:bg-red-300/50; } } } diff --git a/src-web/components/core/Editor/twig/placeholder.ts b/src-web/components/core/Editor/twig/placeholder.ts index ee1db575..511c9a5e 100644 --- a/src-web/components/core/Editor/twig/placeholder.ts +++ b/src-web/components/core/Editor/twig/placeholder.ts @@ -16,6 +16,7 @@ class PlaceholderWidget extends WidgetType { elt.className = `placeholder-widget ${ !this.isExistingVariable ? 'placeholder-widget-error' : '' }`; + elt.title = !this.isExistingVariable ? 'Variable not found in active environment' : ''; elt.textContent = this.name; return elt; } diff --git a/src-web/hooks/useUpdateAnyFolder.ts b/src-web/hooks/useUpdateAnyFolder.ts new file mode 100644 index 00000000..d2c4c09a --- /dev/null +++ b/src-web/hooks/useUpdateAnyFolder.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { invoke } from '@tauri-apps/api'; +import type { Folder, HttpRequest } from '../lib/models'; +import { getFolder, getRequest } from '../lib/store'; +import { requestsQueryKey } from './useRequests'; +import { foldersQueryKey } from './useFolders'; + +export function useUpdateAnyFolder() { + const queryClient = useQueryClient(); + + return useMutation Folder }>({ + mutationFn: async ({ id, update }) => { + const folder = await getFolder(id); + if (folder === null) { + throw new Error("Can't update a null folder"); + } + + await invoke('update_folder', { request: update(folder) }); + }, + onMutate: async ({ id, update }) => { + const folder = await getFolder(id); + if (folder === null) return; + queryClient.setQueryData(foldersQueryKey(folder), (folders) => + (folders ?? []).map((f) => (f.id === folder.id ? update(f) : f)), + ); + }, + }); +} diff --git a/src-web/lib/store.ts b/src-web/lib/store.ts index b948d52f..b1be0e6a 100644 --- a/src-web/lib/store.ts +++ b/src-web/lib/store.ts @@ -1,5 +1,5 @@ import { invoke } from '@tauri-apps/api'; -import type { Environment, HttpRequest, Workspace } from './models'; +import type { Environment, Folder, HttpRequest, Workspace } from './models'; export async function getRequest(id: string | null): Promise { if (id === null) return null; @@ -19,6 +19,15 @@ export async function getEnvironment(id: string | null): Promise { + if (id === null) return null; + const folder: Folder = (await invoke('get_folder', { id })) ?? null; + if (folder == null) { + return null; + } + return folder; +} + export async function getWorkspace(id: string | null): Promise { if (id === null) return null; const workspace: Workspace = (await invoke('get_workspace', { id })) ?? null;