feat: add frontend folder creation functionality.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user