From b10404f0f386c3d0efa580f4870334dc082852f4 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 12 Dec 2025 01:51:33 +0100 Subject: [PATCH] feat: add frontend folder creation functionality. --- frontend/src/documents/FoldersManager.ts | 192 +++++++++++++++--- .../documents/data/useDocumentsWorkspace.ts | 24 +-- .../features/folders/useFolderTreeActions.ts | 47 +---- frontend/src/lib/api/apiClient.ts | 5 +- frontend/src/lib/api/apiTypes.ts | 6 +- 5 files changed, 191 insertions(+), 83 deletions(-) diff --git a/frontend/src/documents/FoldersManager.ts b/frontend/src/documents/FoldersManager.ts index 2331561..6901286 100644 --- a/frontend/src/documents/FoldersManager.ts +++ b/frontend/src/documents/FoldersManager.ts @@ -3,19 +3,22 @@ import type { FolderNodeId } from '../types/identifiers'; import type { Folder } from '../types/documents'; import { createRootNode } from '../app/workspaceUtils'; -import type { FolderTreeNode } from '../lib/api/apiTypes'; -import { getFolderTree } from '../lib/api/apiClient'; +import type { FolderTreeNode, FolderInfo } from '../lib/api/apiTypes'; +import { + createFolder as apiCreateFolder, + deleteFolder as apiDeleteFolder, + moveFolder as apiMoveFolder, + renameFolder as apiRenameFolder, + getFolderTree +} from '../lib/api/apiClient'; import { flattenFolderTree } from '../app/workspaceUtils'; -type ManagedFolder = Folder; - type FetchFolder = (id: FolderNodeId) => Promise; - -class FoldersManager { - private byId: Map; +class FoldersManager { + private byId: Map; private fetcher?: FetchFolder; - private inflight: Map>; + private inflight: Map>; private treePromise: Promise | null = null; private treeSnapshot: FolderTreeNode[] = []; private listeners: Set<() => void>; @@ -52,7 +55,7 @@ class FoldersManager { this.fetcher = fetchFolder; } - ingest(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } { + ingest(rawFolders: unknown[] = []): { canonical: Folder[]; changed: boolean } { const result = this.ingestInternal(rawFolders); if (result.changed) { this.emit(); @@ -60,11 +63,11 @@ class FoldersManager { return result; } - private ingestInternal(rawFolders: unknown[] = []): { canonical: T[]; changed: boolean } { - const folders = rawFolders.map((f) => f as T).filter(Boolean); + private ingestInternal(rawFolders: unknown[] = []): { canonical: Folder[]; changed: boolean } { + const folders = rawFolders.map((f) => f as Folder).filter(Boolean); let changed = false; let nextById = this.byId; - const canonical: T[] = []; + const canonical: Folder[] = []; folders.forEach((folder) => { const id = folder?.id; @@ -74,9 +77,9 @@ class FoldersManager { } const existing = nextById.get(id as FolderNodeId); - const merged = existing ? ({ ...existing, ...folder } as T) : ({ ...(folder as T) } as T); + const merged = existing ? ({ ...existing, ...folder } as Folder) : ({ ...(folder as Folder) } as Folder); const useExisting = existing && shallowEqual(existing, merged); - const nextFolder = useExisting ? (existing as T) : merged; + const nextFolder = useExisting ? (existing as Folder) : merged; if (!useExisting) { if (!changed) { @@ -95,7 +98,7 @@ class FoldersManager { return { canonical, changed }; } - async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise { + async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise { if (id == null) { return null; } @@ -129,13 +132,13 @@ class FoldersManager { return request; } - map(mapper: (folder: T) => T | undefined): boolean { + map(mapper: (folder: Folder) => Folder | undefined): boolean { if (!this.byId.size) { return false; } let changed = false; - const next = new Map(); + const next = new Map(); this.byId.forEach((folder, key) => { const updated = mapper(folder); const nextFolder = updated === undefined ? folder : updated; @@ -175,17 +178,112 @@ class FoldersManager { return changed; } - getById(id: FolderNodeId): T | null { + async create(name: string, parentId: FolderNodeId | null): Promise { + const payload = { + name, + parent_id: parentId === 'root' ? null : parentId + }; + const response = await apiCreateFolder(payload); + const folderData = response.folder as unknown as Folder; + + if (!folderData?.id) { + throw new Error('Folder creation failed: No ID returned'); + } + + this.addNode(folderData); + return folderData; + } + + async delete(id: FolderNodeId): Promise { + await apiDeleteFolder(id); + this.removeNode(id); + } + + async rename(id: FolderNodeId, name: string): Promise { + await apiRenameFolder(id, name); + + // Update local state + const existing = this.byId.get(id); + if (existing) { + this.ingest([{ ...existing, name }]); + } + + // Update tree node + const node = this.findNode(this.treeSnapshot, id); + if (node) { + node.name = name; + this.emit(); + } + } + + async move(id: FolderNodeId, parentId: FolderNodeId | null): Promise { + const targetParentId = parentId === 'root' ? null : parentId; + await apiMoveFolder(id, targetParentId); + + // Update local state 'parent_id' + const existing = this.byId.get(id); + if (existing) { + this.ingest([{ ...existing, parent_id: targetParentId }]); + } + + this.moveNode(id, parentId); + } + + private moveNode(id: FolderNodeId, parentId: FolderNodeId | null) { + const node = this.findNode(this.treeSnapshot, id); + if (!node) return; + + this.removeNodeFromParent(this.treeSnapshot, id); + + node.parent_id = (parentId as string) || null; + + const attachToRoot = !parentId || parentId === 'root'; + if (attachToRoot) { + const root = this.treeSnapshot[0]; + if (root) { + root.children = [...(root.children || []), node]; + root.hasChildren = true; + } + } else { + const newParent = this.findNode(this.treeSnapshot, parentId); + if (newParent) { + newParent.children = [...(newParent.children || []), node]; + newParent.hasChildren = true; + } + } + this.emit(); + } + + private removeNodeFromParent(nodes: FolderTreeNode[], id: FolderNodeId): boolean { + for (const node of nodes) { + if (node.children) { + const idx = node.children.findIndex(c => c.id === id); + if (idx !== -1) { + node.children.splice(idx, 1); + if (node.children.length === 0) { + node.hasChildren = false; + } + return true; + } + if (this.removeNodeFromParent(node.children, id)) { + return true; + } + } + } + return false; + } + + getById(id: FolderNodeId): Folder | null { return this.byId.get(id) ?? null; } - getMany(ids: Array = []): T[] { + getMany(ids: Array = []): Folder[] { return ids .map((id) => this.byId.get(id) || null) - .filter((folder): folder is T => Boolean(folder)); + .filter((folder): folder is Folder => Boolean(folder)); } - getSnapshot(): Map { + getSnapshot(): Map { return this.byId; } @@ -235,11 +333,57 @@ class FoldersManager { } } - invalidateTree() { - this.treeSnapshot = []; - this.treePromise = null; + addNode(folder: Folder) { + this.ingest([folder]); + + const newNode: FolderTreeNode = { + ...(folder as unknown as FolderInfo), + children: [], + hasChildren: false, + loaded: true, + }; + + const parentId = folder.parent_id; + if (!parentId || parentId === 'root') { + const root = this.treeSnapshot[0]; + if (root) { + root.children = [...(root.children || []), newNode]; + root.hasChildren = true; + } + } else { + const parent = this.findNode(this.treeSnapshot, parentId); + if (parent) { + parent.children = [...(parent.children || []), newNode]; + parent.hasChildren = true; + } + } this.emit(); } + + removeNode(id: FolderNodeId) { + this.remove([id]); + + // The treeSnapshot usually contains one root node which holds the tree + const changed = this.removeNodeFromParent(this.treeSnapshot, id); + if (changed) { + this.emit(); + } + } + + private findNode(nodes: FolderTreeNode[], id: FolderNodeId): FolderTreeNode | null { + for (const node of nodes) { + if (node.id === id) { + return node; + } + if (node.children) { + const found = this.findNode(node.children, id); + if (found) return found; + } + } + return null; + } + + } export default FoldersManager; diff --git a/frontend/src/documents/data/useDocumentsWorkspace.ts b/frontend/src/documents/data/useDocumentsWorkspace.ts index 1724dc6..a14c104 100644 --- a/frontend/src/documents/data/useDocumentsWorkspace.ts +++ b/frontend/src/documents/data/useDocumentsWorkspace.ts @@ -57,7 +57,7 @@ import { useApi } from '../../lib/context/ApiContext'; import { useWorkspaceSelection } from '../../app/useWorkspaceSelection'; import useDocumentViewer from '../../app/useDocumentViewer'; import type { DocumentId, FolderNodeId, Identifier } from '../../types/identifiers'; -import type { Document } from '../../types/documents'; +import type { Document, Folder } from '../../types/documents'; const EntryType = Object.freeze({ document: 'document', @@ -279,8 +279,6 @@ const useDocumentsWorkspace = ({ [folderNodes], ); - const [currentSubfolders, setCurrentSubfolders] = useState>([]); - const foldersSnapshot = useSyncExternalStore( useCallback((cb) => foldersManager.subscribe(cb), [foldersManager]), () => foldersManager.getSnapshot(), @@ -288,15 +286,15 @@ const useDocumentsWorkspace = ({ ); const visibleSubfolders = useMemo(() => { - return currentSubfolders.map((folder) => { - if (!folder?.id) return folder; - const live = foldersSnapshot.get(folder.id); - if (live) { - return { ...folder, ...live }; - } - return folder; + // Derive subfolders directly from the source of truth (FoldersManager) + const currentId = selectedFolder || 'root'; + const allFolders = Array.from(foldersSnapshot.values()); + + return allFolders.filter((folder: Folder) => { + const parentId = folder.parent_id || 'root'; + return parentId === currentId; }); - }, [currentSubfolders, foldersSnapshot]); + }, [foldersSnapshot, selectedFolder]); const reconcileSelectionWithFolderData = useCallback( (currentSelection: string[], docs: Document[], subfolders: any[]) => { @@ -334,7 +332,6 @@ const useDocumentsWorkspace = ({ } const subfolders = (data.subfolders || []) as any[]; - setCurrentSubfolders(subfolders); foldersManager.ingest(subfolders); if (includeDocuments) { @@ -349,7 +346,6 @@ const useDocumentsWorkspace = ({ [ setDocuments, setSelectedEntries, - setCurrentSubfolders, reconcileSelectionWithFolderData, selectedFolderRef, foldersManager, @@ -545,7 +541,6 @@ const useDocumentsWorkspace = ({ const resetWorkspaceState = useCallback(() => { setSelectedFolder('root'); - setCurrentSubfolders([]); setDocuments([]); setSelectedEntries([]); setSelectionOrder([]); @@ -578,7 +573,6 @@ const useDocumentsWorkspace = ({ setSelectedEntries, setSelectionOrder, setSelectedFolder, - setCurrentSubfolders, setDocuments, setDraggedDocumentIds, setDraggedFolderId, diff --git a/frontend/src/documents/features/folders/useFolderTreeActions.ts b/frontend/src/documents/features/folders/useFolderTreeActions.ts index 7c65f8f..da0d422 100644 --- a/frontend/src/documents/features/folders/useFolderTreeActions.ts +++ b/frontend/src/documents/features/folders/useFolderTreeActions.ts @@ -3,12 +3,6 @@ import { useNavigate } from 'react-router-dom'; import type { DragEvent } from 'react'; import { useStatusToast } from '../../../lib/context/StatusToastContext'; import { hasFiles } from '../../../app/workspaceUtils'; -import { - createFolder, - deleteFolder, - moveFolder as moveFolderRequest, - renameFolder as renameFolderRequest, -} from '../../../lib/api/apiClient'; import type { FolderId } from '../../../types/identifiers'; import type { MessageOptions } from '../../../types/documents'; @@ -40,7 +34,7 @@ import type { import type FoldersManager from '../../FoldersManager'; interface UseFolderTreeActionsOptions { - folderState: Pick & { foldersManager?: FoldersManager }; + folderState: Pick & { foldersManager: FoldersManager }; dragState: DragState; actions: { handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise | void; @@ -97,14 +91,8 @@ const useFolderTreeActions = ({ return; } - const parent_id = targetKey === 'root' ? null : targetKey; - try { - await moveFolderRequest(folderId, parent_id); - - if (foldersManager) { - foldersManager.refreshTree(); - } + await foldersManager.move(folderId, targetKey); if (selectedFolder === folderId) { setSelectedFolder(folderId); @@ -160,11 +148,7 @@ const useFolderTreeActions = ({ return false; } try { - await renameFolderRequest(folderId, trimmed); - - if (foldersManager) { - foldersManager.ingest([{ id: folderId, name: trimmed }]); - } + await foldersManager.rename(folderId, trimmed); showToast('Folder renamed.', 'success'); return true; @@ -192,26 +176,12 @@ const useFolderTreeActions = ({ ? (parentId === 'root' ? null : parentId) : (selectedFolder === 'root' ? null : selectedFolder); - const payload = { - name: name.trim(), - parent_id: targetParentId, - }; setCreatingFolder(true); let succeeded = false; try { - const data = await createFolder(payload); - const folderData = (data as { folder?: { id?: FolderKey; name?: string; parent_id?: FolderKey | null; children?: FolderKey[] } }).folder; - if (!folderData?.id) { - throw new Error('Folder creation failed.'); - } - showToast('Folder created.', 'success'); + const folderData = await foldersManager.create(name.trim(), targetParentId); - if (foldersManager) { - // Ingest the new folder data immediately so it's available - foldersManager.ingest([folderData]); - // Force tree refresh to update structure - foldersManager.refreshTree(); - } + showToast('Folder created.', 'success'); await selectFolder(folderData.id, { immediate: true }); succeeded = true; @@ -247,12 +217,7 @@ const useFolderTreeActions = ({ } try { - await deleteFolder(folderId); - - if (foldersManager) { - foldersManager.remove([folderId]); - foldersManager.refreshTree(); - } + await foldersManager.delete(folderId); if (selectedFolder === folderId) { // Fallback selection logic diff --git a/frontend/src/lib/api/apiClient.ts b/frontend/src/lib/api/apiClient.ts index 7e922c0..adf448f 100644 --- a/frontend/src/lib/api/apiClient.ts +++ b/frontend/src/lib/api/apiClient.ts @@ -13,6 +13,7 @@ import type { TagResponse, CorrespondentResponse, FolderTreeNode, + CreateFolderResponse, } from './apiTypes'; import type { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosRequestConfig } from 'axios'; @@ -132,8 +133,8 @@ export const createTag = async (payload: { label: string; color?: string | null return data; }; -export const createFolder = async (payload: { name: string; parent_id?: Identifier | null }): Promise => { - const { data } = await api.post('/folders', payload); +export const createFolder = async (payload: { name: string; parent_id?: Identifier | null }): Promise => { + const { data } = await api.post('/folders', payload); return data; }; diff --git a/frontend/src/lib/api/apiTypes.ts b/frontend/src/lib/api/apiTypes.ts index bc2c6ae..fb3ab62 100644 --- a/frontend/src/lib/api/apiTypes.ts +++ b/frontend/src/lib/api/apiTypes.ts @@ -57,7 +57,7 @@ export interface DocumentResponse { current_version?: DocumentVersionResponse | null; } -interface FolderInfo { +export interface FolderInfo { id: string; name: string; parent_id?: string | null; @@ -65,6 +65,10 @@ interface FolderInfo { updated_at?: string; } +export interface CreateFolderResponse { + folder: FolderInfo; +} + export interface FolderTreeNode extends FolderInfo { children?: FolderTreeNode[]; hasChildren?: boolean;