feat: add frontend folder creation functionality.

This commit is contained in:
2025-12-12 01:51:33 +01:00
parent d281364aef
commit b10404f0f3
5 changed files with 191 additions and 83 deletions
+168 -24
View File
@@ -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<unknown>;
class FoldersManager<T extends ManagedFolder = ManagedFolder> {
private byId: Map<FolderNodeId, T>;
class FoldersManager {
private byId: Map<FolderNodeId, Folder>;
private fetcher?: FetchFolder;
private inflight: Map<FolderNodeId, Promise<T | null>>;
private inflight: Map<FolderNodeId, Promise<Folder | null>>;
private treePromise: Promise<FolderTreeNode[]> | null = null;
private treeSnapshot: FolderTreeNode[] = [];
private listeners: Set<() => void>;
@@ -52,7 +55,7 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
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<T extends ManagedFolder = ManagedFolder> {
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<T extends ManagedFolder = ManagedFolder> {
}
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<T extends ManagedFolder = ManagedFolder> {
return { canonical, changed };
}
async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise<T | null> {
async ensure(id: FolderNodeId, fetcherOverride?: FetchFolder): Promise<Folder | null> {
if (id == null) {
return null;
}
@@ -129,13 +132,13 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
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<FolderNodeId, T>();
const next = new Map<FolderNodeId, Folder>();
this.byId.forEach((folder, key) => {
const updated = mapper(folder);
const nextFolder = updated === undefined ? folder : updated;
@@ -175,17 +178,112 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
return changed;
}
getById(id: FolderNodeId): T | null {
async create(name: string, parentId: FolderNodeId | null): Promise<Folder> {
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<void> {
await apiDeleteFolder(id);
this.removeNode(id);
}
async rename(id: FolderNodeId, name: string): Promise<void> {
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<void> {
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<FolderNodeId> = []): T[] {
getMany(ids: Array<FolderNodeId> = []): 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<FolderNodeId, T> {
getSnapshot(): Map<FolderNodeId, Folder> {
return this.byId;
}
@@ -235,11 +333,57 @@ class FoldersManager<T extends ManagedFolder = ManagedFolder> {
}
}
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;
@@ -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<Array<{ id?: FolderNodeId; name?: string | null;[key: string]: unknown }>>([]);
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,
@@ -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<FolderState, 'folderNodes' | 'selectedFolder' | 'setSelectedFolder' | 'setCreatingFolder'> & { foldersManager?: FoldersManager };
folderState: Pick<FolderState, 'folderNodes' | 'selectedFolder' | 'setSelectedFolder' | 'setCreatingFolder'> & { foldersManager: FoldersManager };
dragState: DragState;
actions: {
handleFileDrop: (dataTransfer: DataTransfer, folderId: FolderKey) => Promise<void> | 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
+3 -2
View File
@@ -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<unknown> => {
const { data } = await api.post('/folders', payload);
export const createFolder = async (payload: { name: string; parent_id?: Identifier | null }): Promise<CreateFolderResponse> => {
const { data } = await api.post<CreateFolderResponse>('/folders', payload);
return data;
};
+5 -1
View File
@@ -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;