From d2b4a4dea8e74bd8446b95f4f3b86aad70e6f07f Mon Sep 17 00:00:00 2001 From: Ricardo Costa Date: Sun, 19 May 2024 17:24:46 +0100 Subject: [PATCH] Implemented Workspace Tree Collapsing & Expanding --- .../editor/operations/input/operations.ts | 8 +- .../handlers/history/toHistoryOperations.ts | 14 ++-- .../workspaces/tree/useWorkspaceTree.ts | 5 ++ .../src/ui/components/sidebar/Sidebar.scss | 83 +++++++++++++++++-- .../src/ui/components/sidebar/Sidebar.tsx | 31 +++++-- .../sidebar/components/ResourceView.tsx | 46 +++++++--- .../sidebar/components/WorkspaceTree.tsx | 19 ++++- .../contexts/workspace/WorkspaceContext.tsx | 2 +- .../src/ui/contexts/workspace/useResources.ts | 8 +- code/client/src/ui/pages/home/Home.tsx | 2 +- .../src/ui/pages/home/hooks/useWorkspaces.ts | 6 +- .../http/handlers/workspacesHandlers.ts | 2 - code/server/src/ts/databases/memory/Memory.ts | 8 +- 13 files changed, 186 insertions(+), 48 deletions(-) diff --git a/code/client/src/domain/editor/operations/input/operations.ts b/code/client/src/domain/editor/operations/input/operations.ts index 4f0e5b3e..3e3020ac 100644 --- a/code/client/src/domain/editor/operations/input/operations.ts +++ b/code/client/src/domain/editor/operations/input/operations.ts @@ -6,7 +6,7 @@ import { nodeInsert } from '@domain/editor/crdt/utils'; import { InlineStyle } from '@notespace/shared/src/document/types/styles'; import { Operation } from '@notespace/shared/src/document/types/operations'; import { Communication } from '@services/communication/communication'; -import {isEqual} from "lodash"; +import { isEqual } from 'lodash'; export default (fugue: Fugue, { socket }: Communication): InputDomainOperations => { function insertCharacter(char: string, cursor: Cursor, styles: InlineStyle[] = []) { @@ -29,10 +29,10 @@ export default (fugue: Fugue, { socket }: Communication): InputDomainOperations } function deleteSelection(selection: Selection) { - if(isEqual(selection.start, selection.end)) return; + if (isEqual(selection.start, selection.end)) return; - if(selection.start.column === 0) selection.start.column += 1; - if(selection.end.column === 0) selection.end.column += 1; + if (selection.start.column === 0) selection.start.column += 1; + if (selection.end.column === 0) selection.end.column += 1; const operations = fugue.deleteLocal(selection); socket.emit('operations', operations); diff --git a/code/client/src/domain/editor/slate/handlers/history/toHistoryOperations.ts b/code/client/src/domain/editor/slate/handlers/history/toHistoryOperations.ts index e45d893e..1276c24e 100644 --- a/code/client/src/domain/editor/slate/handlers/history/toHistoryOperations.ts +++ b/code/client/src/domain/editor/slate/handlers/history/toHistoryOperations.ts @@ -100,14 +100,16 @@ function toHistoryOperations(editor: Editor, operations: Batch | undefined, reve * @param operation * @param selectionBefore */ - function removeTextOperation(operation: BaseRemoveTextOperation, selectionBefore : BaseRange | null): RemoveTextOperation | undefined { + function removeTextOperation( + operation: BaseRemoveTextOperation, + selectionBefore: BaseRange | null + ): RemoveTextOperation | undefined { const offset = (line: number) => (line === 0 ? 0 : 1); if (operation.text === '') return undefined; - if(!selectionBefore) return undefined; - - const cursor = pointToCursor(editor, {...selectionBefore?.anchor}); + if (!selectionBefore) return undefined; + const cursor = pointToCursor(editor, { ...selectionBefore?.anchor }); const start = { line: operation.path[0], @@ -137,8 +139,8 @@ function toHistoryOperations(editor: Editor, operations: Batch | undefined, reve // Remove whole line if (operation.path.length === 1) { - const start = { line: operation.path[0], column: 0} - const end = { line: operation.path[0], column: Infinity} + const start = { line: operation.path[0], column: 0 }; + const end = { line: operation.path[0], column: Infinity }; const selection = { start, end }; return { diff --git a/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts b/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts index a2dcede3..511e2012 100644 --- a/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts +++ b/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts @@ -16,6 +16,10 @@ function useWorkspaceTree() { setNodes(nodesMap); } + function getNode(id: string) { + return nodes.get(id); + } + function addNode(node: WorkspaceTreeNode) { const newNodes = new Map(nodes); const parentNode = newNodes.get(node.parent); @@ -71,6 +75,7 @@ function useWorkspaceTree() { nodes, setNodes, setTree, + getNode, addNode, updateNode, removeNode, diff --git a/code/client/src/ui/components/sidebar/Sidebar.scss b/code/client/src/ui/components/sidebar/Sidebar.scss index 4558763a..3264d906 100644 --- a/code/client/src/ui/components/sidebar/Sidebar.scss +++ b/code/client/src/ui/components/sidebar/Sidebar.scss @@ -3,10 +3,11 @@ top: 0; left: 0; height: 100vh; - width: 400px; + width: 0; display: flex; flex-direction: column; align-items: center; + justify-content: center; overflow-x: hidden; overflow-y: scroll; transition: 0.5s; @@ -18,19 +19,25 @@ ul { margin: 0; padding: 0; + width: 90%; } li { list-style-type: none; padding: 8px; text-decoration: none; - font-size: 14px; + font-size: 16px; font-weight: 500; - display: block; transition: 0.3s; + + display: flex; + flex-direction: row; + justify-content: start; + align-items: center; + gap: 10px; } - button { + > button { position: fixed; top: 1vh; left: 1vh; @@ -41,7 +48,7 @@ padding: 0; margin: 0; transition: transform 1s ease-in-out; - z-index: 5 !important; + z-index: 1; .icon { transition: opacity 1s ease-in-out; @@ -55,4 +62,70 @@ button:hover { color: dimgray; } + + .workspace-tree { + li { + padding: 0; + } + } + + .resource { + display: flex; + flex-direction: column; + width: 100%; + + .resource-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + transition: 0.1s; + + div { + display: flex; + flex-direction: row; + justify-content: start; + border-radius: 5px; + user-select: none; + } + } + + .resource-header:hover { + background-color: rgba(0, 0, 0, 0.1); + cursor: pointer; + } + + .resource-header > button { + display: none; + } + + .resource-header:hover > button { + display: flex; + align-items: center; + padding: 0; + margin: 1vh; + } + + .resource-header > button:hover { + color: gray; + } + + .resource-children { + padding-left: 10px; + } + + a { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + } + + button { + background-color: transparent; + color: black; + padding: 5px; + margin: 0; + } + } } diff --git a/code/client/src/ui/components/sidebar/Sidebar.tsx b/code/client/src/ui/components/sidebar/Sidebar.tsx index 34fa89b6..0b0160c6 100644 --- a/code/client/src/ui/components/sidebar/Sidebar.tsx +++ b/code/client/src/ui/components/sidebar/Sidebar.tsx @@ -1,19 +1,21 @@ -import { IoMenu } from 'react-icons/io5'; +import { IoMenu, IoTime } from 'react-icons/io5'; import { Link } from 'react-router-dom'; -import { RiMenuFold2Line, RiMenuFoldLine } from 'react-icons/ri'; +import { RiMenuFold2Line, RiMenuFoldLine, RiTeamFill } from 'react-icons/ri'; import useWorkspace from '@ui/contexts/workspace/useWorkspace'; import useSidebarState from '@ui/components/sidebar/hooks/useSidebarState'; import './Sidebar.scss'; import WorkspaceTree from '@ui/components/sidebar/components/WorkspaceTree'; +import { FaHome } from 'react-icons/fa'; +import { IoMdSettings } from 'react-icons/io'; function Sidebar() { const { isOpen, isLocked, handleClick, handleMouseEnter, handleMouseLeave } = useSidebarState(); - const { workspace, nodes } = useWorkspace(); + const { workspace, nodes, operations } = useWorkspace(); return (
diff --git a/code/client/src/ui/components/sidebar/components/ResourceView.tsx b/code/client/src/ui/components/sidebar/components/ResourceView.tsx index a1a4b158..6823f179 100644 --- a/code/client/src/ui/components/sidebar/components/ResourceView.tsx +++ b/code/client/src/ui/components/sidebar/components/ResourceView.tsx @@ -2,12 +2,15 @@ import { Link } from 'react-router-dom'; import { ResourceType } from '@notespace/shared/src/workspace/types/resource'; import { IoDocumentText } from 'react-icons/io5'; import { FaFolder } from 'react-icons/fa6'; -import { WorkspaceTreeNode } from '@domain/workspaces/tree/WorkspaceTree'; -import { TreeNode } from '@domain/workspaces/tree/utils'; +import { TreeNode, WorkspaceTreeNode } from '@domain/workspaces/tree/types'; +import { useState } from 'react'; +import { RiArrowDownSFill, RiArrowRightSFill } from 'react-icons/ri'; +import { FaPlusSquare } from 'react-icons/fa'; type ResourceViewProps = { workspace: string; resource: WorkspaceTreeNode; + onCreateResource: (parent?: string) => void; children?: TreeNode[]; }; @@ -26,18 +29,37 @@ const ResourceComponents = { ), }; -function ResourceView({ resource, workspace, children }: ResourceViewProps) { +function ResourceView({ resource, workspace, children, onCreateResource }: ResourceViewProps) { + const [isOpen, setIsOpen] = useState(true); const ResourceComponent = ResourceComponents[resource.type]; + + const handleToggle = () => { + setIsOpen(!isOpen); + }; + return ( -
- - {children?.map(child => ( - - ))} +
+
+
+ + +
+ +
+
+ {isOpen && + children?.map(child => ( + + ))} +
); } diff --git a/code/client/src/ui/components/sidebar/components/WorkspaceTree.tsx b/code/client/src/ui/components/sidebar/components/WorkspaceTree.tsx index 46445096..fb59b984 100644 --- a/code/client/src/ui/components/sidebar/components/WorkspaceTree.tsx +++ b/code/client/src/ui/components/sidebar/components/WorkspaceTree.tsx @@ -2,19 +2,32 @@ import ResourceView from '@ui/components/sidebar/components/ResourceView'; import { WorkspaceMetaData } from '@notespace/shared/src/workspace/types/workspace'; import { getTree } from '@domain/workspaces/tree/utils'; import { WorkspaceTreeNodes } from '@domain/workspaces/tree/types'; +import { ResourceOperationsType } from '@ui/contexts/workspace/WorkspaceContext'; +import { ResourceType } from '@notespace/shared/src/workspace/types/resource'; type WorkspaceTreeProps = { workspace: WorkspaceMetaData; + operations: ResourceOperationsType; nodes?: WorkspaceTreeNodes; }; -function WorkspaceTree({ workspace, nodes }: WorkspaceTreeProps) { +function WorkspaceTree({ workspace, nodes, operations }: WorkspaceTreeProps) { if (!nodes) return null; + + async function onCreateResource(parent?: string) { + await operations.createResource('Untitled', ResourceType.DOCUMENT, parent); + } + return ( -
    +
      {getTree(nodes).children.map(node => (
    • - +
    • ))}
    diff --git a/code/client/src/ui/contexts/workspace/WorkspaceContext.tsx b/code/client/src/ui/contexts/workspace/WorkspaceContext.tsx index 29c31e82..77e5a7aa 100644 --- a/code/client/src/ui/contexts/workspace/WorkspaceContext.tsx +++ b/code/client/src/ui/contexts/workspace/WorkspaceContext.tsx @@ -10,7 +10,7 @@ import { ResourceType, WorkspaceResource } from '@notespace/shared/src/workspace import { WorkspaceTreeNodes } from '@domain/workspaces/tree/types'; export type ResourceOperationsType = { - createResource: (name: string, type: ResourceType) => Promise; + createResource: (name: string, type: ResourceType, parent?: string) => Promise; deleteResource: (id: string) => Promise; updateResource: (id: string, newProps: Partial) => Promise; }; diff --git a/code/client/src/ui/contexts/workspace/useResources.ts b/code/client/src/ui/contexts/workspace/useResources.ts index 5793afb2..1719e509 100644 --- a/code/client/src/ui/contexts/workspace/useResources.ts +++ b/code/client/src/ui/contexts/workspace/useResources.ts @@ -25,7 +25,13 @@ function useResources() { } function onDeleteResource(id: string) { - setResources(resources.filter(resource => resource.id !== id)); + const getChildren = (id: string): string[] => { + const resource = tree.getNode(id); + if (!resource) return []; + return [id, ...resource.children.flatMap(childId => getChildren(childId))]; + }; + const idsToRemove = getChildren(id); + setResources(resources.filter(resource => !idsToRemove.includes(resource.id))); tree.removeNode(id); } diff --git a/code/client/src/ui/pages/home/Home.tsx b/code/client/src/ui/pages/home/Home.tsx index 9b11ed31..7c121db9 100644 --- a/code/client/src/ui/pages/home/Home.tsx +++ b/code/client/src/ui/pages/home/Home.tsx @@ -22,7 +22,7 @@ function Home() { key={workspace.id} workspace={workspace} onDelete={() => deleteWorkspace(workspace.id).catch(publishError)} - onRename={name => updateWorkspace(workspace.id, { ...workspace, name: name + '-copy' }).catch(publishError)} + onRename={name => updateWorkspace(workspace.id, { ...workspace, name }).catch(publishError)} onInvite={() => {}} /> ))} diff --git a/code/client/src/ui/pages/home/hooks/useWorkspaces.ts b/code/client/src/ui/pages/home/hooks/useWorkspaces.ts index a005369a..15770716 100644 --- a/code/client/src/ui/pages/home/hooks/useWorkspaces.ts +++ b/code/client/src/ui/pages/home/hooks/useWorkspaces.ts @@ -3,11 +3,13 @@ import { WorkspaceInputModel, WorkspaceMetaData } from '@notespace/shared/src/wo import useSocketListeners from '@services/communication/socket/useSocketListeners'; import { useCommunication } from '@ui/contexts/communication/useCommunication'; import useWorkspaceService from '@services/workspace/useWorkspaceService'; +import useError from '@ui/contexts/error/useError'; function useWorkspaces() { const { socket } = useCommunication(); const service = useWorkspaceService(); const [workspaces, setWorkspaces] = useState([]); + const { publishError } = useError(); function onCreateWorkspace(workspace: WorkspaceMetaData) { setWorkspaces([...workspaces, workspace]); @@ -44,8 +46,8 @@ function useWorkspaces() { const workspaces = await service.getWorkspaces(); setWorkspaces(workspaces); } - fetchWorkspaces(); - }, [service]); + fetchWorkspaces().catch(publishError); + }, [service, publishError]); return { workspaces, diff --git a/code/server/src/ts/controllers/http/handlers/workspacesHandlers.ts b/code/server/src/ts/controllers/http/handlers/workspacesHandlers.ts index 89f75884..c2d8c729 100644 --- a/code/server/src/ts/controllers/http/handlers/workspacesHandlers.ts +++ b/code/server/src/ts/controllers/http/handlers/workspacesHandlers.ts @@ -13,7 +13,6 @@ function workspacesHandlers(services: Services, io: Server) { * Create a new workspace */ const createWorkspace = async (req: Request, res: Response) => { - // TODO. use and validate other fields const { name } = req.body as WorkspaceInputModel; if (!name) throw new InvalidParameterError('Workspace name is required'); const id = await services.workspace.createWorkspace(name); @@ -49,7 +48,6 @@ function workspacesHandlers(services: Services, io: Server) { if (!wid) throw new InvalidParameterError('Workspace id is required'); if (!isValidUUID(wid)) throw new InvalidParameterError('Invalid workspace id'); const { name } = req.body as WorkspaceMetaData; - if (!wid) throw new InvalidParameterError('Workspace id is required'); if (!name) throw new InvalidParameterError('Workspace name is required'); await services.workspace.updateWorkspace(wid, name); diff --git a/code/server/src/ts/databases/memory/Memory.ts b/code/server/src/ts/databases/memory/Memory.ts index e92bc5c5..1935591b 100644 --- a/code/server/src/ts/databases/memory/Memory.ts +++ b/code/server/src/ts/databases/memory/Memory.ts @@ -41,8 +41,9 @@ export function getWorkspace(id: string): Workspace { } export function updateWorkspace(id: string, name: string) { - const workspace = getWorkspace(id); - workspace.name = name; + const workspace = workspaces[id]; + if (!workspace) throw new NotFoundError(`Workspace not found`); + workspaces[id] = { ...workspace, name }; } export function deleteWorkspace(id: string) { @@ -87,6 +88,9 @@ export function deleteResource(id: string) { const parentResource = getResource(resource.parent); parentResource.children = parentResource.children.filter(childId => childId !== id); + for (const childId of resource.children) { + deleteResource(childId); + } delete workspaces[resource.workspace].resources[id]; }