diff --git a/code/client/src/domain/workspaces/tree/WorkspaceTree.ts b/code/client/src/domain/workspaces/tree/WorkspaceTree.ts deleted file mode 100644 index 37d83a58..00000000 --- a/code/client/src/domain/workspaces/tree/WorkspaceTree.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { ResourceType, WorkspaceResourceMetadata } from '@notespace/shared/src/workspace/types/resource'; - -export type WorkspaceTreeNode = WorkspaceResourceMetadata; - -export class WorkspaceTree { - private root: WorkspaceTreeNode; - private nodes: Map = new Map(); - - constructor() { - this.root = this.rootNode(); - } - - setNodes(nodes: WorkspaceTreeNode[]) { - this.nodes = new Map(nodes.map(node => [node.id, node])); - this.nodes.set('root', this.root); - this.root = this.rootNode(); - } - - addNode(node: WorkspaceTreeNode) { - const { parent } = node; - const parentNode = this.nodes.get(parent); - if (parentNode) parentNode.children.push(node.id); - this.nodes.set(node.id, node); - } - - updateNode(id: string, props: Partial) { - const node = this.nodes.get(id); - if (!node) throw new Error('Invalid id: ' + id); - Object.assign(node, props); - } - - removeNode(id: string) { - const node = this.nodes.get(id); - if (!node) throw new Error('Invalid id: ' + id); - const { parent } = node; - const parentNode = this.nodes.get(parent); - if (parentNode) { - const index = parentNode.children.indexOf(node.id); - if (index !== -1) parentNode.children.splice(index, 1); - this.nodes.delete(node.id); - } - } - - moveNode(id: string, newParent: string) { - const node = this.nodes.get(id); - if (!node) throw new Error('Invalid id: ' + id); - const { parent } = node; - const parentNode = this.nodes.get(parent); - - if (parentNode) { - const index = parentNode.children.indexOf(node.id); - if (index !== -1) parentNode.children.splice(index, 1); - } - const newParentNode = this.nodes.get(newParent); - if (!newParentNode) throw new Error('Invalid parent id: ' + newParent); - newParentNode.children.push(node.id); - node.parent = newParent; - } - - *traverse(node = this.root, set = new Set()): IterableIterator<[WorkspaceTreeNode, WorkspaceTreeNode[]]> { - const children = (node.children?.map(id => this.nodes.get(id)) as WorkspaceTreeNode[]) || []; - if (node.id !== 'root' && !set.has(node.id)) { - children.forEach(child => set.add(child.id)); - yield [node, children]; - } - for (const child of children) { - yield* this.traverse(child, set); - } - } - - clone() { - const newTree = new WorkspaceTree(); - newTree.root = { ...this.root }; // shallow copy guarantees re-render - newTree.nodes = this.nodes; - return newTree; - } - - get resources() { - return this.nodes; - } - - rootNode() { - const children = Array.from(this.nodes.values()) - .filter(node => node.parent === 'root') - .map(node => node.id); - return { - id: 'root', - name: 'root', - parent: '', - children: children, - type: ResourceType.FOLDER, - }; - } -} diff --git a/code/client/src/domain/workspaces/tree/types.ts b/code/client/src/domain/workspaces/tree/types.ts new file mode 100644 index 00000000..84c7c008 --- /dev/null +++ b/code/client/src/domain/workspaces/tree/types.ts @@ -0,0 +1,10 @@ +import { WorkspaceResourceMetadata } from '@notespace/shared/src/workspace/types/resource'; + +export type TreeNode = { + node: WorkspaceTreeNode; + children: TreeNode[]; +}; + +export type WorkspaceTreeNode = WorkspaceResourceMetadata; + +export type WorkspaceTreeNodes = Map; diff --git a/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts b/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts index da221ec9..a2dcede3 100644 --- a/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts +++ b/code/client/src/domain/workspaces/tree/useWorkspaceTree.ts @@ -1,8 +1,81 @@ -import { useMemo } from 'react'; -import { WorkspaceTree } from '@domain/workspaces/tree/WorkspaceTree'; +import { useState } from 'react'; +import { rootNode } from '@domain/workspaces/tree/utils'; +import { WorkspaceTreeNode } from '@domain/workspaces/tree/types'; function useWorkspaceTree() { - return useMemo(() => new WorkspaceTree(), []); + const [nodes, setNodes] = useState>(new Map()); + + function setTree(nodes: WorkspaceTreeNode[]) { + const nodesMap = new Map(nodes.map(node => [node.id, node])); + const root = rootNode( + Array.from(nodes?.values() || []) + .filter(node => node.parent === 'root') + .map(node => node.id) + ); + nodesMap.set('root', root); + setNodes(nodesMap); + } + + function addNode(node: WorkspaceTreeNode) { + const newNodes = new Map(nodes); + const parentNode = newNodes.get(node.parent); + if (!parentNode) throw new Error('Invalid parent id: ' + node.parent); + newNodes.set(node.id, node); + newNodes.set(node.parent, { ...parentNode, children: [...parentNode.children, node.id] }); + setNodes(newNodes); + } + + function removeNode(id: string) { + const node = nodes.get(id); + if (!node) throw new Error('Invalid id: ' + id); + const { parent } = node; + const parentNode = nodes.get(parent); + if (!parentNode) throw new Error('Invalid parent id: ' + parent); + const newNodes = new Map(nodes); + const index = parentNode.children.indexOf(id); + if (index !== -1) parentNode.children.splice(index, 1); + newNodes.delete(id); + newNodes.set(parent, parentNode); + setNodes(newNodes); + } + + function updateNode(id: string, name: string) { + const node = nodes.get(id); + if (!node) throw new Error('Invalid id: ' + id); + const newNode = { ...node, name }; + nodes.set(id, newNode); + setNodes(new Map(nodes)); + } + + function moveNode(id: string, newParent: string) { + const node = nodes.get(id); + if (!node) throw new Error('Invalid id: ' + id); + const { parent } = node; + const parentNode = nodes.get(parent); + + if (parentNode) { + const index = parentNode.children.indexOf(node.id); + if (index !== -1) parentNode.children.splice(index, 1); + nodes.set(parent, parentNode); + } + const newParentNode = nodes.get(newParent); + if (!newParentNode) throw new Error('Invalid parent id: ' + newParent); + newParentNode.children.push(node.id); + node.parent = newParent; + nodes.set(id, node); + nodes.set(newParent, newParentNode); + setNodes(new Map(nodes)); + } + + return { + nodes, + setNodes, + setTree, + addNode, + updateNode, + removeNode, + moveNode, + }; } export default useWorkspaceTree; diff --git a/code/client/src/domain/workspaces/tree/utils.ts b/code/client/src/domain/workspaces/tree/utils.ts new file mode 100644 index 00000000..b74627b2 --- /dev/null +++ b/code/client/src/domain/workspaces/tree/utils.ts @@ -0,0 +1,20 @@ +import { ResourceType } from '@notespace/shared/src/workspace/types/resource'; +import { TreeNode, WorkspaceTreeNode } from '@domain/workspaces/tree/types'; + +export function getTree(nodes: Map, id: string = 'root'): TreeNode { + const root = nodes.get(id)!; + return { + node: root, + children: root.children.map(id => getTree(nodes, id)), + }; +} + +export function rootNode(children?: string[]): WorkspaceTreeNode { + return { + id: 'root', + name: 'root', + parent: '', + children: children || [], + type: ResourceType.FOLDER, + }; +} diff --git a/code/client/src/ui/components/error/Error.tsx b/code/client/src/ui/components/error/Error.tsx index 3c72903e..d2da41e4 100644 --- a/code/client/src/ui/components/error/Error.tsx +++ b/code/client/src/ui/components/error/Error.tsx @@ -1,20 +1,38 @@ import './Error.scss'; import { MdError } from 'react-icons/md'; +import { useEffect, useRef, useState } from 'react'; type ErrorProps = { error?: Error; }; function Error({ error }: ErrorProps) { - if (!error) { + const [err, setErr] = useState(error); + const errorRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (errorRef.current && !errorRef.current.contains(event.target as Node)) { + setErr(undefined); + } + } + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + if (!err) { return null; } + return ( -
+
-

{error.message}

+

{err.message}

); } diff --git a/code/client/src/ui/components/sidebar/Sidebar.scss b/code/client/src/ui/components/sidebar/Sidebar.scss index 94219729..4558763a 100644 --- a/code/client/src/ui/components/sidebar/Sidebar.scss +++ b/code/client/src/ui/components/sidebar/Sidebar.scss @@ -3,7 +3,7 @@ top: 0; left: 0; height: 100vh; - width: 320px; + width: 400px; display: flex; flex-direction: column; align-items: center; @@ -22,7 +22,7 @@ li { list-style-type: none; - padding: 8px 8px 8px 16px; + padding: 8px; text-decoration: none; font-size: 14px; font-weight: 500; diff --git a/code/client/src/ui/components/sidebar/Sidebar.tsx b/code/client/src/ui/components/sidebar/Sidebar.tsx index f1257526..34fa89b6 100644 --- a/code/client/src/ui/components/sidebar/Sidebar.tsx +++ b/code/client/src/ui/components/sidebar/Sidebar.tsx @@ -5,11 +5,10 @@ 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 { useEffect } from 'react'; function Sidebar() { const { isOpen, isLocked, handleClick, handleMouseEnter, handleMouseLeave } = useSidebarState(); - const { workspace, tree } = useWorkspace(); + const { workspace, nodes } = useWorkspace(); return (
{workspace.name} - + )} diff --git a/code/client/src/ui/components/sidebar/components/ResourceView.tsx b/code/client/src/ui/components/sidebar/components/ResourceView.tsx index 5edb79ab..a1a4b158 100644 --- a/code/client/src/ui/components/sidebar/components/ResourceView.tsx +++ b/code/client/src/ui/components/sidebar/components/ResourceView.tsx @@ -3,11 +3,12 @@ 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'; type ResourceViewProps = { workspace: string; resource: WorkspaceTreeNode; - children?: WorkspaceTreeNode[]; + children?: TreeNode[]; }; const ResourceComponents = { @@ -28,9 +29,15 @@ const ResourceComponents = { function ResourceView({ resource, workspace, children }: ResourceViewProps) { const ResourceComponent = ResourceComponents[resource.type]; return ( -
+
- {children?.map(child => )} + {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 75011c88..46445096 100644 --- a/code/client/src/ui/components/sidebar/components/WorkspaceTree.tsx +++ b/code/client/src/ui/components/sidebar/components/WorkspaceTree.tsx @@ -1,19 +1,20 @@ -import { WorkspaceTree as Tree } from '@domain/workspaces/tree/WorkspaceTree'; 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'; type WorkspaceTreeProps = { workspace: WorkspaceMetaData; - tree?: Tree; + nodes?: WorkspaceTreeNodes; }; -function WorkspaceTree({ workspace, tree }: WorkspaceTreeProps) { - if (!tree) return null; +function WorkspaceTree({ workspace, nodes }: WorkspaceTreeProps) { + if (!nodes) return null; return (
    - {Array.from(tree.traverse()).map(([node, children]) => ( -
  • - + {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 81350bfd..29c31e82 100644 --- a/code/client/src/ui/contexts/workspace/WorkspaceContext.tsx +++ b/code/client/src/ui/contexts/workspace/WorkspaceContext.tsx @@ -7,7 +7,7 @@ import { useParams } from 'react-router-dom'; import useWorkspaceService from '@services/workspace/useWorkspaceService'; import useResources from '@ui/contexts/workspace/useResources'; import { ResourceType, WorkspaceResource } from '@notespace/shared/src/workspace/types/resource'; -import { WorkspaceTree } from '@domain/workspaces/tree/WorkspaceTree'; +import { WorkspaceTreeNodes } from '@domain/workspaces/tree/types'; export type ResourceOperationsType = { createResource: (name: string, type: ResourceType) => Promise; @@ -19,7 +19,7 @@ export type WorkspaceContextType = { workspace?: WorkspaceMetaData; resources?: WorkspaceResource[]; operations?: ResourceOperationsType; - tree?: WorkspaceTree; + nodes?: WorkspaceTreeNodes; }; export const WorkspaceContext = createContext({}); @@ -27,7 +27,7 @@ export const WorkspaceContext = createContext({}); export function WorkspaceProvider({ children }: { children: React.ReactNode }) { const services = useWorkspaceService(); const [workspace, setWorkspace] = useState(undefined); - const { resources, setResources, tree, setTree, operations } = useResources(); + const { resources, setResources, tree, operations } = useResources(); const { socket } = useCommunication(); const { publishError } = useError(); const { wid } = useParams(); @@ -39,10 +39,7 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { const { id, name, resources } = await services.getWorkspace(wid!); setWorkspace({ id, name }); setResources(resources); - setTree(prev => { - prev.setNodes(resources); - return prev.clone(); - }); + tree.setTree(resources); } socket.emit('joinWorkspace', wid); fetchWorkspace().catch(publishError); @@ -53,6 +50,8 @@ export function WorkspaceProvider({ children }: { children: React.ReactNode }) { }, [wid, services, socket, publishError]); return ( - {children} + + {children} + ); } diff --git a/code/client/src/ui/contexts/workspace/useResources.ts b/code/client/src/ui/contexts/workspace/useResources.ts index cb4c957b..5793afb2 100644 --- a/code/client/src/ui/contexts/workspace/useResources.ts +++ b/code/client/src/ui/contexts/workspace/useResources.ts @@ -7,20 +7,17 @@ import useResourceService from '@services/resource/useResourceService'; import useSocketListeners from '@services/communication/socket/useSocketListeners'; import { useCommunication } from '@ui/contexts/communication/useCommunication'; import { useState } from 'react'; -import { WorkspaceTree } from '@domain/workspaces/tree/WorkspaceTree'; +import useWorkspaceTree from '@domain/workspaces/tree/useWorkspaceTree'; function useResources() { const service = useResourceService(); const { socket } = useCommunication(); const [resources, setResources] = useState([]); - const [tree, setTree] = useState(new WorkspaceTree()); + const tree = useWorkspaceTree(); function onCreateResource(resource: WorkspaceResource) { setResources([...resources, resource]); - setTree(prev => { - prev.addNode(resource); - return prev.clone(); - }); + tree.addNode(resource); } async function createResource(name: string, type: ResourceType, parent?: string) { @@ -29,22 +26,17 @@ function useResources() { function onDeleteResource(id: string) { setResources(resources.filter(resource => resource.id !== id)); - setTree(prev => { - prev.removeNode(id); - return prev.clone(); - }); + tree.removeNode(id); } async function deleteResource(id: string) { await service.deleteResource(id); } - function onUpdateResource(id: string, resource: Partial) { + function onUpdateResource(resource: Partial) { + if (!resource.id) throw new Error('Resource id is required'); setResources(resources.map(res => (res.id === resource.id ? { ...res, ...resource } : res))); - setTree(prev => { - prev.updateNode(id, resource); - return prev.clone(); - }); + if (resource.name) tree.updateNode(resource.id, resource.name); } async function updateResource(id: string, newProps: Partial) { @@ -61,7 +53,6 @@ function useResources() { resources, setResources, tree, - setTree, operations: { createResource, deleteResource, diff --git a/code/server/src/ts/controllers/http/handlers/resourcesHandlers.ts b/code/server/src/ts/controllers/http/handlers/resourcesHandlers.ts index 049dba6c..b68fd16f 100644 --- a/code/server/src/ts/controllers/http/handlers/resourcesHandlers.ts +++ b/code/server/src/ts/controllers/http/handlers/resourcesHandlers.ts @@ -1,5 +1,9 @@ import PromiseRouter from 'express-promise-router'; -import { ResourceInputModel, WorkspaceResource } from '@notespace/shared/src/workspace/types/resource'; +import { + ResourceInputModel, + WorkspaceResource, + WorkspaceResourceMetadata, +} from '@notespace/shared/src/workspace/types/resource'; import { httpResponse } from '@controllers/http/utils/httpResponse'; import { Request, Response } from 'express'; import { ResourcesService } from '@services/ResourcesService'; @@ -23,7 +27,8 @@ function resourcesHandlers(service: ResourcesService, io: Server) { if (!type) throw new InvalidParameterError('Resource type is required'); const id = await service.createResource(wid, name, type, parent); - io.in(wid).emit('createdResource', { id, ...resource }); + const createdResource: WorkspaceResourceMetadata = { id, ...resource, children: [] }; + io.in(wid).emit('createdResource', createdResource); httpResponse.created(res).json({ id }); }; @@ -58,7 +63,7 @@ function resourcesHandlers(service: ResourcesService, io: Server) { const resource = req.body as Partial; if (!resource) throw new InvalidParameterError('Body is required'); await service.updateResource(id, resource); - io.in(wid).emit('updatedResource', { ...resource, id }); + io.in(wid).emit('updatedResource', { id, ...resource }); httpResponse.noContent(res).send(); }; diff --git a/code/shared/src/utils/logging.ts b/code/shared/src/utils/logging.ts index 4ad7ec5b..7401730f 100644 --- a/code/shared/src/utils/logging.ts +++ b/code/shared/src/utils/logging.ts @@ -1,37 +1,39 @@ export enum LogColor { - Red = '\x1b[31m', - Green = '\x1b[32m', - Yellow = '\x1b[33m', - Blue = '\x1b[34m', - Reset = '\x1b[0m', + Red = "\x1b[31m", + Green = "\x1b[32m", + Yellow = "\x1b[33m", + Blue = "\x1b[34m", + Reset = "\x1b[0m", } -export const ColorWrap = (color : LogColor, message: string) => color + message + LogColor.Reset; +export const ColorWrap = (color: LogColor, message: string) => + color + message + LogColor.Reset; -const colorLog = (caller : string, message : string, color : LogColor) => - log(caller, color + message + LogColor.Reset); +const colorLog = (caller: string, message: string, color: LogColor) => + log(caller, color + message + LogColor.Reset); -const log = (caller : string, message : string) => console.log(`[${caller}] ${message}`); +const log = (caller: string, message: string) => + console.log(`[${caller}] ${message}`); -const logWarning = (caller : string, message : string, ) => - colorLog(caller, "⚠ " + message, LogColor.Yellow); +const logWarning = (caller: string, message: string) => + colorLog(caller, "⚠ " + message, LogColor.Yellow); -const logError = (caller : string, message : string) => - colorLog(caller, "✖ " + message, LogColor.Red); +const logError = (caller: string, message: string) => + colorLog(caller, "✖ " + message, LogColor.Red); -const logSuccess = (caller : string, message : string) => - colorLog(caller, "✔ " + message, LogColor.Green); +const logSuccess = (caller: string, message: string) => + colorLog(caller, "✔ " + message, LogColor.Green); -const logInfo = (caller : string, message : string) => - colorLog(caller,"🛈" + message, LogColor.Blue); +const logInfo = (caller: string, message: string) => + colorLog(caller, "🛈" + message, LogColor.Blue); -const logLine = () => console.log('----------------------------------------'); +const logLine = () => console.log("----------------------------------------"); -export default (caller : string) => ({ - log: (message : string) => log(caller, message), - logWarning: (message : string) => logWarning(caller, message), - logError: (message : string) => logError(caller, message), - logSuccess: (message : string) => logSuccess(caller, message), - logInfo: (message : string) => logInfo(caller, message), - logLine : () => logLine(), -}); \ No newline at end of file +export default (caller: string) => ({ + log: (message: string) => log(caller, message), + logWarning: (message: string) => logWarning(caller, message), + logError: (message: string) => logError(caller, message), + logSuccess: (message: string) => logSuccess(caller, message), + logInfo: (message: string) => logInfo(caller, message), + logLine: () => logLine(), +}); diff --git a/code/shared/src/workspace/types/resource.ts b/code/shared/src/workspace/types/resource.ts index 76465d31..3f014b8a 100644 --- a/code/shared/src/workspace/types/resource.ts +++ b/code/shared/src/workspace/types/resource.ts @@ -13,7 +13,7 @@ export interface WorkspaceResource { export interface ResourceInputModel { name: string; type: ResourceType; - parent?: string; + parent: string; } export enum ResourceType {