From abc9731e4a1ca226ef892550cadb73483172da91 Mon Sep 17 00:00:00 2001 From: michael Date: Wed, 10 Sep 2025 19:53:06 -0500 Subject: [PATCH] added hierarchical folders (just two levels) with drag and drop management --- .../models/__pycache__/folder.cpython-313.pyc | Bin 17322 -> 28004 bytes backend/app/models/folder.py | 251 +++++++++- .../__pycache__/folders.cpython-313.pyc | Bin 13519 -> 15563 bytes backend/app/routes/folders.py | 69 ++- dist/index.html | 4 +- src/components/FolderTree.tsx | 454 ++++++++++++++++++ src/components/FolderTreeItem.tsx | 273 +++++++++++ src/lib/api.ts | 7 + src/pages/SyntheticUsers.tsx | 401 +++++++--------- 9 files changed, 1202 insertions(+), 257 deletions(-) create mode 100644 src/components/FolderTree.tsx create mode 100644 src/components/FolderTreeItem.tsx diff --git a/backend/app/models/__pycache__/folder.cpython-313.pyc b/backend/app/models/__pycache__/folder.cpython-313.pyc index 2ff16e172d8e67dcadd6525f2489df3f1905812c..2a902be1c8cc1ef2e75316df79eff37f988337c7 100644 GIT binary patch delta 10711 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 N<0P%WIuI!;{R^W?zr6qe diff --git a/backend/app/models/folder.py b/backend/app/models/folder.py index fb8e9e9a..c2c1fe0b 100644 --- a/backend/app/models/folder.py +++ b/backend/app/models/folder.py @@ -6,13 +6,31 @@ from datetime import datetime class Folder: @staticmethod async def create(folder_data, user_id): - """Create a new folder.""" + """Create a new folder with hierarchical support.""" db = await get_db() # Add metadata folder_data["created_at"] = datetime.utcnow() folder_data["created_by"] = user_id + # Handle hierarchy + parent_folder_id = folder_data.get("parent_folder_id") + if parent_folder_id: + # Validate parent folder exists + parent_folder = await db.folders.find_one({"_id": ObjectId(parent_folder_id)}) + if not parent_folder: + raise ValueError("Parent folder not found") + + # Validate hierarchy depth (max 2 levels: parent -> child) + parent_level = parent_folder.get("level", 0) + if parent_level >= 1: + raise ValueError("Maximum folder depth exceeded (max 2 levels)") + + folder_data["level"] = parent_level + 1 + else: + folder_data["parent_folder_id"] = None + folder_data["level"] = 0 + # Note: No longer storing persona_ids in folders - using persona-centric storage result = await db.folders.insert_one(folder_data) @@ -320,4 +338,233 @@ class Folder: return result except Exception as e: print(f"Error getting folders for persona {persona_id}: {e}") - return [] \ No newline at end of file + return [] + + @staticmethod + async def get_folder_tree(user_id=None, limit=100): + """Get all folders organized in a hierarchical tree structure.""" + db = await get_db() + try: + # Get all folders + query = {} + if user_id: + query["created_by"] = user_id + + cursor = db.folders.find(query).sort("created_at", -1).limit(limit) + folders = await cursor.to_list(length=limit) + + # Convert ObjectIds to strings + processed_folders = [] + for folder in folders: + folder["_id"] = str(folder["_id"]) + if folder.get("parent_folder_id"): + folder["parent_folder_id"] = str(folder["parent_folder_id"]) + processed_folders.append(folder) + + return processed_folders + except Exception as e: + print(f"Error in Folder.get_folder_tree: {e}") + return [] + + @staticmethod + async def get_descendants(folder_id): + """Get all descendant folders of a given folder.""" + db = await get_db() + try: + descendants = [] + + # Get immediate children + children_cursor = db.folders.find({"parent_folder_id": folder_id}) + children = await children_cursor.to_list(length=None) + + for child in children: + child["_id"] = str(child["_id"]) + if child.get("parent_folder_id"): + child["parent_folder_id"] = str(child["parent_folder_id"]) + descendants.append(child) + + # Since we only support 2 levels, children can't have children + # But we'll keep this structure for potential future expansion + + return descendants + except Exception as e: + print(f"Error getting descendants for folder {folder_id}: {e}") + return [] + + @staticmethod + async def get_sibling_names(parent_id): + """Get names of all folders that would be siblings in the target location.""" + db = await get_db() + try: + if parent_id: + # Get folders at the same level under the parent + siblings_cursor = db.folders.find({"parent_folder_id": parent_id}) + else: + # Get root level folders + siblings_cursor = db.folders.find({"$or": [{"parent_folder_id": None}, {"level": 0}]}) + + siblings = await siblings_cursor.to_list(length=None) + return [folder.get("name", "") for folder in siblings] + except Exception as e: + print(f"Error getting sibling names: {e}") + return [] + + @staticmethod + def generate_unique_name(desired_name, existing_names): + """Generate a unique name by adding suffix if conflicts exist.""" + if desired_name not in existing_names: + return desired_name + + counter = 1 + while f"{desired_name}_{counter}" in existing_names: + counter += 1 + + return f"{desired_name}_{counter}" + + @staticmethod + async def move_folder(folder_id, new_parent_id, user_id): + """Move a folder to a new parent with automatic hierarchy flattening.""" + db = await get_db() + try: + # Get the folder to move + folder = await db.folders.find_one({"_id": ObjectId(folder_id)}) + if not folder: + return False, "Folder not found" + + # Folder operations are shared across all users in this system + # No ownership check needed + + # Check if trying to move into current parent (redundant operation) + if new_parent_id and folder.get("parent_folder_id") == new_parent_id: + return False, "Folder is already in this location" + + # If moving to root and folder has children, do nothing (it's already at root) + if not new_parent_id: + descendants = await Folder.get_descendants(folder_id) + if len(descendants) > 0: + return True, "Folder with children is already at root level" + + # Validate new parent + new_level = 0 + if new_parent_id: + parent_folder = await db.folders.find_one({"_id": ObjectId(new_parent_id)}) + if not parent_folder: + return False, "Parent folder not found" + + # Check if moving into descendant (prevent circular reference) + if parent_folder.get("parent_folder_id") == folder_id: + return False, "Cannot move folder into its own descendant" + + parent_level = parent_folder.get("level", 0) + if parent_level >= 1: + return False, "Maximum folder depth exceeded" + + new_level = parent_level + 1 + + # Get children of the folder being moved + descendants = await Folder.get_descendants(folder_id) + + # Get existing sibling names at the destination to handle conflicts + existing_names = await Folder.get_sibling_names(new_parent_id) + + moved_folders = [] + + # First, move the main folder + original_name = folder.get("name", "") + unique_name = Folder.generate_unique_name(original_name, existing_names) + + if unique_name != original_name: + # Update the folder name if there was a conflict + await db.folders.update_one( + {"_id": ObjectId(folder_id)}, + {"$set": {"name": unique_name, "updated_at": datetime.utcnow()}} + ) + + # Move the main folder + update_data = { + "parent_folder_id": new_parent_id, + "level": new_level, + "updated_at": datetime.utcnow() + } + + result = await db.folders.update_one( + {"_id": ObjectId(folder_id)}, + {"$set": update_data} + ) + + if result.modified_count > 0: + moved_folders.append({"name": unique_name, "original_name": original_name}) + existing_names.append(unique_name) # Add to existing names for subsequent conflicts + + # If there are children, flatten them to the same level + if len(descendants) > 0: + for child in descendants: + child_name = child.get("name", "") + unique_child_name = Folder.generate_unique_name(child_name, existing_names) + + # Update child folder name if there was a conflict + if unique_child_name != child_name: + await db.folders.update_one( + {"_id": ObjectId(child["_id"])}, + {"$set": {"name": unique_child_name, "updated_at": datetime.utcnow()}} + ) + + # Move child to the same parent as the moved folder (flattening) + child_result = await db.folders.update_one( + {"_id": ObjectId(child["_id"])}, + {"$set": { + "parent_folder_id": new_parent_id, + "level": new_level, + "updated_at": datetime.utcnow() + }} + ) + + if child_result.modified_count > 0: + moved_folders.append({"name": unique_child_name, "original_name": child_name}) + existing_names.append(unique_child_name) # Add to existing names + + # Build success message + if len(moved_folders) == 1: + message = f"Folder moved successfully" + else: + subfolder_names = [f["name"] for f in moved_folders[1:]] # Exclude main folder + message = f"Folder and {len(subfolder_names)} subfolders moved successfully (flattened): {', '.join(subfolder_names)}" + + return len(moved_folders) > 0, message + + except Exception as e: + print(f"Error moving folder {folder_id}: {e}") + return False, f"Error moving folder: {str(e)}" + + @staticmethod + async def delete_hierarchy(folder_id, user_id): + """Delete a folder and all its descendants.""" + db = await get_db() + try: + # Get the folder to delete + folder = await db.folders.find_one({"_id": ObjectId(folder_id)}) + if not folder: + return False, "Folder not found" + + # Folder operations are shared across all users in this system + # No ownership check needed + + # Get all descendants + descendants = await Folder.get_descendants(folder_id) + all_folder_ids = [folder_id] + [desc["_id"] for desc in descendants] + + # Remove folder references from all personas + for fid in all_folder_ids: + await db.personas.update_many( + {"folder_ids": fid}, + {"$pull": {"folder_ids": fid}, "$set": {"updated_at": datetime.utcnow()}} + ) + + # Delete all folders in the hierarchy + object_ids = [ObjectId(fid) for fid in all_folder_ids] + result = await db.folders.delete_many({"_id": {"$in": object_ids}}) + + return result.deleted_count > 0, f"Deleted {result.deleted_count} folders" + except Exception as e: + print(f"Error deleting folder hierarchy {folder_id}: {e}") + return False, f"Error deleting folder: {str(e)}" \ No newline at end of file diff --git a/backend/app/routes/__pycache__/folders.cpython-313.pyc b/backend/app/routes/__pycache__/folders.cpython-313.pyc index 149d9bb0db970823836c0ecf4e091471a8ce5fe3..c1714c38cd217c22e8f62145c8a2654488515d29 100644 GIT binary patch delta 2455 zcmbVNZERCj7(VBI^|o6-*3z}x>U!_Gx^=LXeRLlarjHASV9ngb)%n`kvc%tBQX-N#8#2 z$9>N^_rA~jUX6c$+;q!mG$2^g?cAYDU1v>B<^+4G^9L-1>%+zooE4c?e75B9xx+R; z4jV?8XZbc%_JyTp6rj(-s{X4@5ZoC~=-P|S=_R98_@n2E4kaR8U~Fgw7s|F@T;Uh=Bj!}1Z1e6DL^nXo{vx;s;w@K-yM`Q6hWm-zjm&24w&^AexA%(}MQ|ZN25yXl`%cM-LCs27XlQG#* zw~YpcsPK{fvXfkKm=_+SD*Te~fuOJYwusE`f=N2Lvn1;IJ$O~_D9Xdj^6-_Wf;?Vq z8NVhv7j-|^Syye%Z|=HqEZ^cU*tTXod0QY~AGmoh<60eZP2jt?>ruV^7Gh{p(U`aN zX5@^{mNx~ii-Y9-Oh3xpXrl1{tfP*&fVhf-gi@})VOcYx+Y{L}W4IwL= zb9aV}?2--xUJ`kTTr%<$TUo$qJDK;{vtKwrV)5f7*wIQ}aXI*L*y*M0Mc0QM7$@a1 zeh(NfWZ#lM;PI0XHOfB&hRfMg9VY}Z{MkKhe+mrR7gM_^+Dt{5jQ0%Tr^)G_%a%!M z@K9l)VlUa{AL91`EM#Bu?`CZ22LEWKyyMej{^#b8D&(xM5uYJvS~rsiJ%o-iaAF7=O(60pq>ZbSsDkw`OQdq+PDf7$Tq94!?0(STgP3GmV$OqC@DQ-#x{V0f5imn@y3 zF?MMfQ#``cm@yvkS1b!S?fe@?0~Z)ENua<+Ac2S;f?(^h1?fhU-a z)*fNV%Q~-_jn-A;fEz5dZ7;cO*o1lVox!spq`Rv#YGa@EaWy{!)j1nKsw65VXxp@7 zr`yUk2&1aDS8rrULQ9q)Q0vQ3Ra!^ZCRfvxp+Eu zU{0B;@sw+kgY9T3YK6GcSTjXyLuVkW9;RKgJW(fMq>2LXUv;$=U46^0zRXC$wY}K5 zeX(xUGmwcEJi)vuL51WU{(_lycXJ>w2G)4w7`uC0sPu8aR#JVLUhta+uZu(EA${lQ z2tM63iq7F}-1(MK^f3;O;7{-<`pggUph)ydf*{Cc5cMHCchl;?{k`*k=lsq&oO@rNd7GA2 ztX2yGliOxSt(&h)mE3P`*1LekHpYUfMy?w3BZAMRJ~DjcZT^^&btf|!_waZorH~2w zO6(I&BT6;_B&|0q&QqZJj3&{t#ViDuDy#2HcO<>iJ74Cd56vFzQ{R{qrDZ&1U4*l8 z1S3?3+~_8qZQn`fY>m52Ko@LnhB0mc?>Dcp|Gsgd4aCd_xX5XQk}^Vv1-eyom`vL^ z4?2u!(zBg@ac$}roAFw1++2tWMI}OUE^hfRTEe0t>c0Y~A!HcFzS5knN69EOUuF;c zK}8w>7A?n#lFTZp#IZArr2}KhliAU6lFliqG=-sZ8+!};ai>5DS+r}D{hXsWna(IF zcXoW0UR@%ofr36G5n1s712!CCdmv$eMPspwk0*_cYhn;#5CxcEt8{}A2RMt?Vs4^% zKV%O}V_DPaT=8DMi>TZ=Z}+^jd!~kTdvM+wob3HrvhIVt{Xff?pi=Mxu!z8$zKpmb!v_lU2c6G*W|D!hL_5nxB71MP5Jcl zz`PWgjD0dmtCfd#YmP2mjy~_!(yv>pX{5>_^n>=2 zI#@N$1IgER3j2U~pvGz+3OEi~9l{|Xo~q5>vqm7Mo5B?bfFRo#{Jmx}Kz}ub@E~mo zKCvBSp*jXO21E2mFeD_veXq7Q_j8?@aW)3+%RKvE_Ry<4=1TIs3m?T~6cm2|dVcB= diff --git a/backend/app/routes/folders.py b/backend/app/routes/folders.py index b7f41f18..6551894c 100644 --- a/backend/app/routes/folders.py +++ b/backend/app/routes/folders.py @@ -23,10 +23,10 @@ folders_bp = Blueprint('folders', __name__) @folders_bp.route('/', methods=['GET']) @jwt_required(optional=True) # Make JWT optional for development async def get_folders(): - """Get all folders - shared across all users.""" + """Get all folders in hierarchical tree structure - shared across all users.""" try: - # Always return all folders - this is a shared persona system - folders = await Folder.get_all() + # Return folders in hierarchical tree structure + folders = await Folder.get_folder_tree() # Make folders serializable serializable_folders = make_serializable(folders) @@ -112,24 +112,23 @@ async def update_folder(folder_id): return jsonify({"message": f"Failed to update folder: {str(e)}"}), 500 @folders_bp.route('/', methods=['DELETE']) -@jwt_required() +@jwt_required(optional=True) # Make JWT optional for development async def delete_folder(folder_id): - """Delete a folder.""" - folder = await Folder.find_by_id(folder_id) - if not folder: - return jsonify({"message": "Folder not found"}), 404 - - # Ensure user owns the folder + """Delete a folder and its entire hierarchy.""" user_id = get_jwt_identity() - if folder.get('created_by') != user_id: - return jsonify({"message": "Unauthorized"}), 403 - success = await Folder.delete(folder_id) + # Folder operations are shared across all users in this system - if success: - return jsonify({"message": "Folder deleted successfully"}), 200 - else: - return jsonify({"message": "Failed to delete folder"}), 500 + try: + success, message = await Folder.delete_hierarchy(folder_id, user_id) + + if success: + return jsonify({"message": message}), 200 + else: + return jsonify({"message": message}), 400 + except Exception as e: + print(f"Error deleting folder hierarchy: {e}") + return jsonify({"message": f"Failed to delete folder: {str(e)}"}), 500 @folders_bp.route('//personas', methods=['POST']) @jwt_required() @@ -242,4 +241,38 @@ async def remove_personas_from_folder_batch(folder_id): return jsonify({"message": "Update failed or no changes made"}), 200 except Exception as e: print(f"Error removing personas from folder: {e}") - return jsonify({"message": f"Failed to remove personas from folder: {str(e)}"}), 500 \ No newline at end of file + return jsonify({"message": f"Failed to remove personas from folder: {str(e)}"}), 500 + +@folders_bp.route('//move', methods=['PUT']) +@jwt_required(optional=True) # Make JWT optional for development +async def move_folder(folder_id): + """Move a folder to a new parent.""" + try: + data = await request.get_json() + user_id = get_jwt_identity() + + new_parent_id = data.get('parent_folder_id') # None for root level + + # Folder operations are shared across all users in this system + + success, message = await Folder.move_folder(folder_id, new_parent_id, user_id) + + if success: + return jsonify({"message": message}), 200 + else: + return jsonify({"message": message}), 400 + except Exception as e: + print(f"Error moving folder: {e}") + return jsonify({"message": f"Failed to move folder: {str(e)}"}), 500 + +@folders_bp.route('//descendants', methods=['GET']) +@jwt_required(optional=True) +async def get_folder_descendants(folder_id): + """Get all descendant folders of a given folder.""" + try: + descendants = await Folder.get_descendants(folder_id) + serializable_descendants = make_serializable(descendants) + return jsonify(serializable_descendants), 200 + except Exception as e: + print(f"Error getting folder descendants: {e}") + return jsonify({"error": str(e)}), 500 \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index d233cfce..f1a5c595 100644 --- a/dist/index.html +++ b/dist/index.html @@ -7,8 +7,8 @@ - - + + diff --git a/src/components/FolderTree.tsx b/src/components/FolderTree.tsx new file mode 100644 index 00000000..2e99812a --- /dev/null +++ b/src/components/FolderTree.tsx @@ -0,0 +1,454 @@ +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { FolderPlus, Folder, Check, X } from 'lucide-react'; +import FolderTreeItem from './FolderTreeItem'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + useDroppable, + rectIntersection, +} from '@dnd-kit/core'; + +interface Folder { + _id: string; + id?: string; + name: string; + parent_folder_id?: string | null; + level: number; + created_by?: string; + created_at?: string; + updated_at?: string; +} + +interface FolderTreeProps { + folders: Folder[]; + allPersonas: any[]; + selectedFolder: string; + onFolderSelect: (folderId: string) => void; + onCreateFolder: (name: string, parentId?: string) => Promise; + onRenameFolder: (folderId: string, newName: string) => Promise; + onDeleteFolder: (folder: Folder) => void; + onMoveFolder: (folderId: string, newParentId: string | null) => Promise; + defaultFolderId: string; +} + +const FolderTree = ({ + folders, + allPersonas, + selectedFolder, + onFolderSelect, + onCreateFolder, + onRenameFolder, + onDeleteFolder, + onMoveFolder, + defaultFolderId, +}: FolderTreeProps) => { + // Local state for folder management + const [isCreatingFolder, setIsCreatingFolder] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [creatingParentId, setCreatingParentId] = useState(null); + + const [folderToRename, setFolderToRename] = useState(null); + const [renameFolderName, setRenameFolderName] = useState(''); + + const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [activeId, setActiveId] = useState(null); + const [dragConfirmOpen, setDragConfirmOpen] = useState(false); + const [pendingMove, setPendingMove] = useState<{ + folderId: string; + newParentId: string | null; + folderName: string; + parentName: string | null; + subfolders: string[]; + } | null>(null); + + // Drag and drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + // Load expansion state from localStorage + useEffect(() => { + const savedExpansion = localStorage.getItem('folderTreeExpansion'); + if (savedExpansion) { + try { + const parsed = JSON.parse(savedExpansion); + setExpandedFolders(new Set(parsed)); + } catch (e) { + console.warn('Failed to parse folder expansion state:', e); + } + } + }, []); + + // Save expansion state to localStorage + const saveExpansionState = (newExpandedFolders: Set) => { + localStorage.setItem('folderTreeExpansion', JSON.stringify(Array.from(newExpandedFolders))); + }; + + // Organize folders into hierarchy + const organizeHierarchy = () => { + const rootFolders: Folder[] = []; + const childMap: Record = {}; + + folders.forEach(folder => { + if (!folder.parent_folder_id || folder.level === 0) { + rootFolders.push(folder); + } else { + if (!childMap[folder.parent_folder_id]) { + childMap[folder.parent_folder_id] = []; + } + childMap[folder.parent_folder_id].push(folder); + } + }); + + return { rootFolders, childMap }; + }; + + const { rootFolders, childMap } = organizeHierarchy(); + + const handleToggleExpansion = (folderId: string) => { + const newExpandedFolders = new Set(expandedFolders); + if (newExpandedFolders.has(folderId)) { + newExpandedFolders.delete(folderId); + } else { + newExpandedFolders.add(folderId); + } + setExpandedFolders(newExpandedFolders); + saveExpansionState(newExpandedFolders); + }; + + const handleCreateFolder = async () => { + if (!newFolderName.trim()) return; + + try { + await onCreateFolder(newFolderName.trim(), creatingParentId || undefined); + setNewFolderName(''); + setIsCreatingFolder(false); + setCreatingParentId(null); + } catch (error) { + console.error('Failed to create folder:', error); + } + }; + + const handleStartRename = (folder: Folder) => { + setFolderToRename(folder); + setRenameFolderName(folder.name); + }; + + const handleCompleteRename = async () => { + if (!folderToRename || !renameFolderName.trim()) { + setFolderToRename(null); + return; + } + + try { + await onRenameFolder(folderToRename._id, renameFolderName.trim()); + setFolderToRename(null); + } catch (error) { + console.error('Failed to rename folder:', error); + setFolderToRename(null); + } + }; + + const handleStartCreateSubfolder = (parentId: string) => { + setCreatingParentId(parentId); + setIsCreatingFolder(true); + setNewFolderName(''); + }; + + const handleDragStart = (event: DragStartEvent) => { + const draggedFolderId = event.active.id as string; + setActiveId(draggedFolderId); + console.log('🖱️ Drag started:', draggedFolderId); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + console.log('🖱️ Drag ended:', { + activeId: active.id, + overId: over?.id, + overType: over?.data.current?.type, + }); + + if (!over || active.id === over.id) return; + + const draggedFolder = folders.find(f => f._id === active.id); + if (!draggedFolder) return; + + let newParentId: string | null = null; + let parentName: string | null = null; + + if (over.data.current?.type === 'folder') { + const targetFolder = over.data.current.folder as Folder; + + // Special case: All Personas folder (move to root level) + if (targetFolder._id === 'all-personas-root') { + console.log('🖱️ Dropping to root level via All Personas'); + newParentId = null; + parentName = null; + } else { + // Regular folder drop + + // Validate: can't move into a child folder or same level child + if (targetFolder.level >= 1) return; + + // Validate: can't move into self + if (targetFolder._id === draggedFolder._id) return; + + // Validate: can't move into current parent (redundant operation) + if (draggedFolder.parent_folder_id === targetFolder._id) return; + + newParentId = targetFolder._id; + parentName = targetFolder.name; + } + } else { + // If no valid drop zone, don't proceed + return; + } + + // Get subfolders of the dragged folder + const { childMap } = organizeHierarchy(); + const subfolders = childMap[draggedFolder._id] || []; + const subfolderNames = subfolders.map(subfolder => subfolder.name); + + // Show confirmation dialog + setPendingMove({ + folderId: draggedFolder._id, + newParentId, + folderName: draggedFolder.name, + parentName, + subfolders: subfolderNames, + }); + setDragConfirmOpen(true); + }; + + const handleConfirmMove = async () => { + if (!pendingMove) return; + + try { + await onMoveFolder(pendingMove.folderId, pendingMove.newParentId); + setDragConfirmOpen(false); + setPendingMove(null); + } catch (error) { + console.error('Failed to move folder:', error); + } + }; + + const activeDragFolder = activeId ? folders.find(f => f._id === activeId) : null; + + + return ( + <> +
+
+

Folders

+ +
+ + {/* Hierarchical folder tree with drag-and-drop */} + { + console.log('🖱️ Drag over:', { + activeId: event.active.id, + overId: event.over?.id, + overData: event.over?.data.current, + }); + }} + onDragEnd={handleDragEnd} + > +
+ {/* All Personas as a special FolderTreeItem */} + onFolderSelect(defaultFolderId)} + onToggleExpansion={() => {}} // No expansion for All Personas + onStartRename={() => {}} // No rename for All Personas + onCompleteRename={() => {}} + onCancelRename={() => {}} + onStartDelete={() => {}} // No delete for All Personas + onStartCreateSubfolder={() => {}} // No subfolder creation + onRenameFolderNameChange={() => {}} + currentlyDraggedFolderId={activeId} + /> + + {/* Regular folder list */} + {rootFolders.map(folder => ( + setFolderToRename(null)} + onStartDelete={onDeleteFolder} + onStartCreateSubfolder={handleStartCreateSubfolder} + onRenameFolderNameChange={setRenameFolderName} + currentlyDraggedFolderId={activeId} + /> + ))} + + {/* Create folder input */} + {isCreatingFolder && ( +
+
+
{/* Spacer */} + + setNewFolderName(e.target.value)} + placeholder={creatingParentId ? "Sub-folder name" : "Folder name"} + className="h-7 text-sm" + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') { + handleCreateFolder(); + } else if (e.key === 'Escape') { + setIsCreatingFolder(false); + setNewFolderName(''); + setCreatingParentId(null); + } + }} + /> +
+ + +
+ )} +
+ + + {activeDragFolder && ( + {}} + onToggleExpansion={() => {}} + onStartRename={() => {}} + onCompleteRename={() => {}} + onCancelRename={() => {}} + onStartDelete={() => {}} + onStartCreateSubfolder={() => {}} + onRenameFolderNameChange={() => {}} + currentlyDraggedFolderId={activeId} + isDragOverlay + /> + )} + + +
+ + {/* Drag confirmation dialog */} + + + + Move Folder + + {pendingMove && ( + <> + {pendingMove.subfolders.length > 0 ? ( + <> + Are you sure you want to move "{pendingMove.folderName}" and its subfolders ({pendingMove.subfolders.join(', ')}) + {!pendingMove.parentName ? ' to the root level' : ` into "${pendingMove.parentName}"`}? + {pendingMove.parentName && ( + <> +

+ Note: The subfolders will be moved to the same level as "{pendingMove.folderName}" (hierarchy will be flattened). + + )} + + ) : ( + <> + Are you sure you want to move "{pendingMove.folderName}" + {!pendingMove.parentName ? ' to the root level' : ` into "${pendingMove.parentName}"`}? + + )} + + )} +
+
+ + + + +
+
+ + ); +}; + +export default FolderTree; \ No newline at end of file diff --git a/src/components/FolderTreeItem.tsx b/src/components/FolderTreeItem.tsx new file mode 100644 index 00000000..924cf6da --- /dev/null +++ b/src/components/FolderTreeItem.tsx @@ -0,0 +1,273 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Folder, FolderPlus, MoreHorizontal, Check, X, ChevronRight, ChevronDown } from 'lucide-react'; +import { useDraggable, useDroppable } from '@dnd-kit/core'; + +interface Folder { + _id: string; + id?: string; + name: string; + parent_folder_id?: string | null; + level: number; + created_by?: string; + created_at?: string; + updated_at?: string; +} + +interface FolderTreeItemProps { + folder: Folder; + children?: Folder[]; + allPersonas: any[]; + selectedFolder: string; + isExpanded: boolean; + folderToRename: Folder | null; + renameFolderName: string; + onFolderSelect: (folderId: string) => void; + onToggleExpansion: (folderId: string) => void; + onStartRename: (folder: Folder) => void; + onCompleteRename: () => void; + onCancelRename: () => void; + onStartDelete: (folder: Folder) => void; + onStartCreateSubfolder: (parentId: string) => void; + onRenameFolderNameChange: (name: string) => void; + isDragOverlay?: boolean; + currentlyDraggedFolderId?: string | null; +} + +const FolderTreeItem = ({ + folder, + children = [], + allPersonas, + selectedFolder, + isExpanded, + folderToRename, + renameFolderName, + onFolderSelect, + onToggleExpansion, + onStartRename, + onCompleteRename, + onCancelRename, + onStartDelete, + onStartCreateSubfolder, + onRenameFolderNameChange, + isDragOverlay = false, + currentlyDraggedFolderId = null, +}: FolderTreeItemProps) => { + const [isHovered, setIsHovered] = useState(false); + + // Calculate persona count for this folder (explicit membership only) + const personaCount = allPersonas.filter(persona => + persona.folder_ids && persona.folder_ids.includes(folder._id) + ).length; + + const hasChildren = children.length > 0; + const canHaveChildren = folder.level === 0; // Only root folders can have children + const isAllPersonasFolder = folder._id === 'all-personas-root'; + + // Check if this folder is the current parent of the dragged folder + const isCurrentParentOfDraggedFolder = currentlyDraggedFolderId && + children.some(child => child._id === currentlyDraggedFolderId); + + // Don't allow dropping into current parent or invalid targets + // All Personas folder can always receive drops (special case for root moves) + const canReceiveDrop = isAllPersonasFolder || (canHaveChildren && !isCurrentParentOfDraggedFolder); + + const { + attributes, + listeners, + setNodeRef: setDragRef, + isDragging, + } = useDraggable({ + id: folder._id, + data: { + type: 'folder', + folder, + }, + }); + + const { + setNodeRef: setDropRef, + isOver, + } = useDroppable({ + id: `drop-${folder._id}`, + data: { + type: 'folder', + folder, + }, + disabled: !canReceiveDrop, // Only enable drop zone for valid targets + }); + + const setNodeRef = (node: HTMLElement | null) => { + setDragRef(node); + setDropRef(node); + }; + + return ( +
+
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {folderToRename && folderToRename._id === folder._id ? ( + // Rename mode +
+
{/* Spacer for alignment */} + + onRenameFolderNameChange(e.target.value)} + placeholder="Folder name" + className="h-7 text-sm" + autoFocus + onKeyDown={e => { + if (e.key === 'Enter') { + onCompleteRename(); + } else if (e.key === 'Escape') { + onCancelRename(); + } + }} + /> + + +
+ ) : ( + // Normal display mode + <> +
+ {/* Expansion chevron */} + {hasChildren && ( + + )} + {!hasChildren &&
} {/* Spacer for alignment */} + + + + + {isAllPersonasFolder ? allPersonas.length : personaCount} + +
+ + {/* Actions dropdown - or invisible spacer for All Personas */} + {!isAllPersonasFolder ? ( + + + + + + onStartRename(folder)}> + Rename + + {canHaveChildren && ( + onStartCreateSubfolder(folder._id)}> + + Create Sub-folder + + )} + onStartDelete(folder)} + > + Delete + + + + ) : ( + // Invisible spacer to match dropdown menu width +
+ )} + + )} +
+ + {/* Child folders */} + {hasChildren && isExpanded && ( +
+ {children.map(childFolder => ( + + ))} +
+ )} +
+ ); +}; + +export default FolderTreeItem; \ No newline at end of file diff --git a/src/lib/api.ts b/src/lib/api.ts index 3c622c08..350f66e2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -714,6 +714,13 @@ export const foldersApi = { persona_ids: personaIds }); }, + + // Hierarchical operations + moveFolder: (folderId: string, parentFolderId: string | null) => + api.put(`/folders/${folderId}/move`, { parent_folder_id: parentFolderId }), + + getDescendants: (folderId: string) => + api.get(`/folders/${folderId}/descendants`), // New endpoints for multiple folder management addPersonaToMultipleFolders: (personaId: string, folderIds: string[]) => { diff --git a/src/pages/SyntheticUsers.tsx b/src/pages/SyntheticUsers.tsx index 54a2fbf5..42ba7941 100644 --- a/src/pages/SyntheticUsers.tsx +++ b/src/pages/SyntheticUsers.tsx @@ -44,12 +44,15 @@ import { getSocket } from '@/services/websocketServiceNew'; import { personasApi, aiPersonasApi, foldersApi } from '@/lib/api'; import { toastService } from '@/lib/toast'; import GenerationProgressBar from '@/components/ui/GenerationProgressBar'; +import FolderTree from '@/components/FolderTree'; interface Folder { _id: string; id?: string; // Legacy field for compatibility name: string; + parent_folder_id?: string | null; + level: number; created_by?: string; created_at?: string; updated_at?: string; @@ -91,8 +94,6 @@ const SyntheticUsers = () => { const [searchTerm, setSearchTerm] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [selectedFolder, setSelectedFolder] = useState(DEFAULT_FOLDER_ID); - const [isCreatingFolder, setIsCreatingFolder] = useState(false); - const [newFolderName, setNewFolderName] = useState(''); // Handle URL parameters to set mode and folder useEffect(() => { @@ -113,12 +114,10 @@ const SyntheticUsers = () => { const [error, setError] = useState(null); const [selectedPersonas, setSelectedPersonas] = useState>(new Set()); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [folderToRename, setFolderToRename] = useState(null); - const [renameFolderName, setRenameFolderName] = useState(''); const [deleteFolderConfirmOpen, setDeleteFolderConfirmOpen] = useState(false); const [folderToDelete, setFolderToDelete] = useState(null); const [moveToFolderOpen, setMoveToFolderOpen] = useState(false); - const [targetFolder, setTargetFolder] = useState(null); + const [targetFolders, setTargetFolders] = useState>(new Set()); // Filter state const [isFilterOpen, setIsFilterOpen] = useState(false); const [activeFilters, setActiveFilters] = useState({ @@ -450,67 +449,70 @@ const SyntheticUsers = () => { // Note: Server-side folder management eliminates need for manual synchronization - const createNewFolder = async () => { - if (!newFolderName.trim()) { + const createNewFolder = async (name: string, parentId?: string) => { + if (!name.trim()) { toastService.error("Please enter a folder name"); return; } try { - const response = await foldersApi.create({ - name: newFolderName.trim(), + const folderData: any = { + name: name.trim(), persona_ids: [] - }); + }; + + if (parentId) { + folderData.parent_folder_id = parentId; + } + + const response = await foldersApi.create(folderData); // Refresh folders from server await fetchFolders(); - setNewFolderName(''); - setIsCreatingFolder(false); - - toastService.success(`Folder "${newFolderName}" created`); + const folderType = parentId ? 'Sub-folder' : 'Folder'; + toastService.success(`${folderType} "${name}" created`); } catch (error) { console.error("Error creating folder:", error); - toastService.error("Failed to create folder"); + const errorMessage = error.response?.data?.message || "Failed to create folder"; + toastService.error(errorMessage); } }; - const cancelFolderCreation = () => { - setNewFolderName(''); - setIsCreatingFolder(false); - }; - - const startRenameFolder = (folder: Folder) => { - setFolderToRename(folder); - setRenameFolderName(folder.name); - }; - - const completeRenameFolder = async () => { - if (!folderToRename || !renameFolderName.trim()) { - setFolderToRename(null); - return; - } - + const renameFolder = async (folderId: string, newName: string) => { try { - await foldersApi.update(folderToRename._id, { - name: renameFolderName.trim() + await foldersApi.update(folderId, { + name: newName }); // Refresh folders from server await fetchFolders(); - setFolderToRename(null); - toastService.success(`Folder renamed to "${renameFolderName}"`); + toastService.success(`Folder renamed to "${newName}"`); } catch (error) { console.error("Error renaming folder:", error); - toastService.error("Failed to rename folder"); - setFolderToRename(null); + const errorMessage = error.response?.data?.message || "Failed to rename folder"; + toastService.error(errorMessage); } }; - const cancelRenameFolder = () => { - setFolderToRename(null); - setRenameFolderName(''); + const moveFolder = async (folderId: string, newParentId: string | null) => { + try { + await foldersApi.moveFolder(folderId, newParentId); + + // Refresh folders from server + await fetchFolders(); + + const targetName = newParentId + ? folders.find(f => f._id === newParentId)?.name || 'folder' + : 'root level'; + + toastService.success(`Folder moved to ${targetName}`); + } catch (error) { + console.error("Error moving folder:", error); + const errorMessage = error.response?.data?.message || "Failed to move folder"; + toastService.error(errorMessage); + } }; const startDeleteFolder = (folder: Folder) => { @@ -541,12 +543,12 @@ const SyntheticUsers = () => { } }; - const movePersonasToFolder = async (personasToMove?: Set, targetFolderId?: string) => { + const movePersonasToFolder = async (personasToMove?: Set, targetFolderIds?: Set) => { // Support both direct calls and calls from the dialog button const personas = personasToMove || selectedPersonas; - const folderId = targetFolderId || targetFolder; + const folderIds = targetFolderIds || targetFolders; - if (!folderId || personas.size === 0) return; + if (folderIds.size === 0 || personas.size === 0) return; const personaIds = Array.from(personas); @@ -559,36 +561,56 @@ const SyntheticUsers = () => { try { const successfulUpdates: string[] = []; const failedUpdates: string[] = []; + const folderNames: string[] = []; - // Add personas to the target folder (supports multiple folders per persona) - if (folderId !== DEFAULT_FOLDER_ID) { - try { - await foldersApi.addPersonasBatch(folderId, mongoIds); - successfulUpdates.push(...personaIds); - } catch (error) { - console.error("Error adding personas to folder:", error); - failedUpdates.push(...personaIds); + // Handle "All Personas" selection (remove from all folders) + if (folderIds.has(DEFAULT_FOLDER_ID)) { + // Remove personas from all current folders + const currentFolderIds = new Set(); + personaIds.forEach(personaId => { + const persona = allPersonas.find(p => p.id === personaId); + if (persona?.folder_ids) { + persona.folder_ids.forEach(fid => currentFolderIds.add(fid)); + } + }); + + for (const folderId of currentFolderIds) { + try { + await foldersApi.removePersonasBatch(folderId, mongoIds); + } catch (error) { + console.error("Error removing personas from folder:", error); + } } - } else { - // If moving to "All Personas", this is essentially removing from current folder - // For now, we'll just mark as successful since "All Personas" shows everything successfulUpdates.push(...personaIds); + folderNames.push("All Personas (removed from folders)"); + } else { + // Add personas to multiple selected folders + for (const folderId of folderIds) { + try { + await foldersApi.addPersonasBatch(folderId, mongoIds); + successfulUpdates.push(...personaIds); + const folderName = folders.find(f => f._id === folderId || f.id === folderId)?.name || "folder"; + folderNames.push(folderName); + } catch (error) { + console.error(`Error adding personas to folder ${folderId}:`, error); + failedUpdates.push(...personaIds); + } + } } // Refresh data from server await Promise.all([fetchFolders(), fetchPersonas()]); // Show toast messages - const folderName = folderId === DEFAULT_FOLDER_ID - ? "All Personas" - : folders.find(f => f._id === folderId || f.id === folderId)?.name || "folder"; - if (successfulUpdates.length > 0) { - toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderName}`); + const folderList = folderNames.length > 1 + ? folderNames.slice(0, -1).join(', ') + ' and ' + folderNames.slice(-1) + : folderNames[0]; + toastService.success(`Added ${successfulUpdates.length} persona${successfulUpdates.length !== 1 ? 's' : ''} to ${folderList}`); } if (failedUpdates.length > 0) { - toastService.error(`Failed to add ${failedUpdates.length} persona${failedUpdates.length !== 1 ? 's' : ''} to ${folderName}.`); + toastService.error(`Failed to add some personas to selected folders.`); } // Clear selection - caller can also handle this if needed @@ -1138,156 +1160,17 @@ const SyntheticUsers = () => { {mode === 'view' ? ( <>
-
-
-

Folders

- -
- -
- - - {folders.map(folder => ( -
- {folderToRename && folderToRename._id === folder._id ? ( -
- - setRenameFolderName(e.target.value)} - placeholder="Folder name" - className="h-7 text-sm" - autoFocus - onKeyDown={e => { - if (e.key === 'Enter') { - completeRenameFolder(); - } else if (e.key === 'Escape') { - cancelRenameFolder(); - } - }} - /> - - -
- ) : ( - <> - - - - - - - - startRenameFolder(folder)}> - Rename - - startDeleteFolder(folder)} - > - Delete - - - - - )} -
- ))} - - {isCreatingFolder && ( -
-
- - setNewFolderName(e.target.value)} - placeholder="Folder name" - className="h-7 text-sm" - autoFocus - onKeyDown={e => { - if (e.key === 'Enter') { - createNewFolder(); - } else if (e.key === 'Escape') { - cancelFolderCreation(); - } - }} - /> -
- - -
- )} -
-
+
@@ -1538,34 +1421,82 @@ const SyntheticUsers = () => { Move to Folder - Choose a folder to move {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''} to. + Choose one or more folders to add {selectedPersonas.size} selected persona{selectedPersonas.size !== 1 ? 's' : ''} to. Personas can belong to multiple folders.
- +
- -
- {folders.map(folder => ( -
- - -
- ))} - + {(() => { + // Organize folders into hierarchy for the dialog + const rootFolders = folders.filter(folder => !folder.parent_folder_id || folder.level === 0); + const childMap: Record = {}; + + folders.forEach(folder => { + if (folder.parent_folder_id && folder.level > 0) { + if (!childMap[folder.parent_folder_id]) { + childMap[folder.parent_folder_id] = []; + } + childMap[folder.parent_folder_id].push(folder); + } + }); + + const handleFolderToggle = (folderId: string, checked: boolean) => { + const newTargetFolders = new Set(targetFolders); + if (checked) { + // If selecting a regular folder, remove "All Personas" if it was selected + newTargetFolders.delete(DEFAULT_FOLDER_ID); + newTargetFolders.add(folderId); + } else { + newTargetFolders.delete(folderId); + } + setTargetFolders(newTargetFolders); + }; + + const renderFolderOption = (folder: Folder, isChild = false) => ( +
+
+ handleFolderToggle(folder._id, !!checked)} + /> + +
+ {/* Render children if any */} + {childMap[folder._id] && childMap[folder._id].map(childFolder => + renderFolderOption(childFolder, true) + )} +
+ ); + + return rootFolders.map(folder => renderFolderOption(folder)); + })()} +
@@ -1578,7 +1509,7 @@ const SyntheticUsers = () => { // Close dialog, don't touch selections setMoveToFolderOpen(false); - setTargetFolder(null); + setTargetFolders(new Set()); }} > Cancel @@ -1589,23 +1520,23 @@ const SyntheticUsers = () => { e.preventDefault(); e.stopPropagation(); - if (!targetFolder) return; // Ensure target folder is selected + if (targetFolders.size === 0) return; // Ensure target folders are selected // Capture values before closing dialog or clearing state const currentSelectedPersonas = new Set(selectedPersonas); - const currentTargetFolder = targetFolder; + const currentTargetFolders = new Set(targetFolders); // Close dialog immediately for better UX setMoveToFolderOpen(false); - setTargetFolder(null); // Reset target folder state + setTargetFolders(new Set()); // Reset target folders state // Perform the move operation - if (currentTargetFolder && currentSelectedPersonas.size > 0) { + if (currentTargetFolders.size > 0 && currentSelectedPersonas.size > 0) { setIsLoading(true); // Show loading indicator try { // Call the move function with captured values - await movePersonasToFolder(currentSelectedPersonas, currentTargetFolder); + await movePersonasToFolder(currentSelectedPersonas, currentTargetFolders); } finally { setIsLoading(false); // Hide loading indicator first @@ -1614,7 +1545,7 @@ const SyntheticUsers = () => { } } }} - disabled={!targetFolder} + disabled={targetFolders.size === 0} > Move