added hierarchical folders (just two levels) with drag and drop management

This commit is contained in:
michael 2025-09-10 19:53:06 -05:00
parent e29d2a0bb9
commit abc9731e4a
9 changed files with 1202 additions and 257 deletions

View file

@ -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 []
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)}"

View file

@ -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('/<folder_id>', 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('/<folder_id>/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
return jsonify({"message": f"Failed to remove personas from folder: {str(e)}"}), 500
@folders_bp.route('/<folder_id>/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('/<folder_id>/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

4
dist/index.html vendored
View file

@ -7,8 +7,8 @@
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<meta property="og:image" content="/og-image.png" />
<script type="module" crossorigin src="/semblance/assets/index-BwNvaEWm.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-CvzgaSoN.css">
<script type="module" crossorigin src="/semblance/assets/index-CBPpFZFw.js"></script>
<link rel="stylesheet" crossorigin href="/semblance/assets/index-BAs_xR_U.css">
</head>
<body>

View file

@ -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<void>;
onRenameFolder: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder: (folder: Folder) => void;
onMoveFolder: (folderId: string, newParentId: string | null) => Promise<void>;
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<string | null>(null);
const [folderToRename, setFolderToRename] = useState<Folder | null>(null);
const [renameFolderName, setRenameFolderName] = useState('');
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [activeId, setActiveId] = useState<string | null>(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<string>) => {
localStorage.setItem('folderTreeExpansion', JSON.stringify(Array.from(newExpandedFolders)));
};
// Organize folders into hierarchy
const organizeHierarchy = () => {
const rootFolders: Folder[] = [];
const childMap: Record<string, Folder[]> = {};
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 (
<>
<div className="w-full md:w-64 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Folders</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setIsCreatingFolder(true)}
className="h-7 w-7 p-0"
>
<FolderPlus className="h-4 w-4" />
</Button>
</div>
{/* Hierarchical folder tree with drag-and-drop */}
<DndContext
sensors={sensors}
collisionDetection={rectIntersection}
onDragStart={handleDragStart}
onDragOver={(event) => {
console.log('🖱️ Drag over:', {
activeId: event.active.id,
overId: event.over?.id,
overData: event.over?.data.current,
});
}}
onDragEnd={handleDragEnd}
>
<div className="space-y-1">
{/* All Personas as a special FolderTreeItem */}
<FolderTreeItem
folder={{
_id: 'all-personas-root',
name: 'All Personas',
level: 0, // Same level as root folders for proper spacing
parent_folder_id: null,
}}
children={[]}
allPersonas={allPersonas}
selectedFolder={selectedFolder === defaultFolderId ? 'all-personas-root' : selectedFolder}
isExpanded={false}
folderToRename={null}
renameFolderName=""
onFolderSelect={() => 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 => (
<FolderTreeItem
key={folder._id}
folder={folder}
children={childMap[folder._id] || []}
allPersonas={allPersonas}
selectedFolder={selectedFolder}
isExpanded={expandedFolders.has(folder._id)}
folderToRename={folderToRename}
renameFolderName={renameFolderName}
onFolderSelect={onFolderSelect}
onToggleExpansion={handleToggleExpansion}
onStartRename={handleStartRename}
onCompleteRename={handleCompleteRename}
onCancelRename={() => setFolderToRename(null)}
onStartDelete={onDeleteFolder}
onStartCreateSubfolder={handleStartCreateSubfolder}
onRenameFolderNameChange={setRenameFolderName}
currentlyDraggedFolderId={activeId}
/>
))}
{/* Create folder input */}
{isCreatingFolder && (
<div className="flex items-center px-3 py-2 space-x-2">
<div className="flex-1 flex items-center space-x-2">
<div className="w-4 h-4" /> {/* Spacer */}
<Folder className="h-4 w-4" />
<Input
value={newFolderName}
onChange={e => 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);
}
}}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={handleCreateFolder}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsCreatingFolder(false);
setNewFolderName('');
setCreatingParentId(null);
}}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
<DragOverlay>
{activeDragFolder && (
<FolderTreeItem
folder={activeDragFolder}
children={[]}
allPersonas={allPersonas}
selectedFolder=""
isExpanded={false}
folderToRename={null}
renameFolderName=""
onFolderSelect={() => {}}
onToggleExpansion={() => {}}
onStartRename={() => {}}
onCompleteRename={() => {}}
onCancelRename={() => {}}
onStartDelete={() => {}}
onStartCreateSubfolder={() => {}}
onRenameFolderNameChange={() => {}}
currentlyDraggedFolderId={activeId}
isDragOverlay
/>
)}
</DragOverlay>
</DndContext>
</div>
{/* Drag confirmation dialog */}
<Dialog open={dragConfirmOpen} onOpenChange={setDragConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Move Folder</DialogTitle>
<DialogDescription>
{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 && (
<>
<br /><br />
<strong>Note:</strong> 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}"`}?
</>
)}
</>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDragConfirmOpen(false)}>
Cancel
</Button>
<Button onClick={handleConfirmMove}>
{pendingMove?.subfolders.length && pendingMove.subfolders.length > 0 ? 'Move & Flatten' : 'Move'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
export default FolderTree;

View file

@ -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 (
<div ref={setNodeRef} className={isDragOverlay ? 'z-50' : ''}>
<div
className={`flex items-center justify-between group ${
isOver && canReceiveDrop
? 'bg-blue-50 border-2 border-blue-200 border-dashed rounded-md'
: ''
} ${
isDragging ? 'opacity-50' : ''
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{folderToRename && folderToRename._id === folder._id ? (
// Rename mode
<div className="flex-1 flex items-center px-3 py-2 space-x-2">
<div className="w-4 h-4" /> {/* Spacer for alignment */}
<Folder className="h-4 w-4" />
<Input
value={renameFolderName}
onChange={e => 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();
}
}}
/>
<Button
size="sm"
variant="ghost"
onClick={onCompleteRename}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={onCancelRename}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
// Normal display mode
<>
<div
className={`flex-1 flex items-center space-x-2 px-3 py-2 text-sm rounded-md transition-colors ${
selectedFolder === folder._id
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-slate-100'
}`}
{...attributes}
{...listeners}
>
{/* Expansion chevron */}
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleExpansion(folder._id);
}}
className="h-4 w-4 hover:bg-slate-200 rounded flex-shrink-0"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
)}
{!hasChildren && <div className="w-4 h-4 flex-shrink-0" />} {/* Spacer for alignment */}
<Folder className="h-4 w-4 flex-shrink-0" />
<button
onClick={() => onFolderSelect(folder._id)}
className="truncate text-left flex-1 hover:underline"
>
{folder.name}
</button>
<span className="text-muted-foreground text-xs ml-auto flex-shrink-0">
{isAllPersonasFolder ? allPersonas.length : personaCount}
</span>
</div>
{/* Actions dropdown - or invisible spacer for All Personas */}
{!isAllPersonasFolder ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={`h-7 w-7 p-0 transition-opacity ${
isHovered ? 'opacity-100' : 'opacity-0'
} group-hover:opacity-100`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onStartRename(folder)}>
Rename
</DropdownMenuItem>
{canHaveChildren && (
<DropdownMenuItem onClick={() => onStartCreateSubfolder(folder._id)}>
<FolderPlus className="h-4 w-4 mr-2" />
Create Sub-folder
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-red-600"
onClick={() => onStartDelete(folder)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
// Invisible spacer to match dropdown menu width
<div className="h-7 w-7 flex-shrink-0" />
)}
</>
)}
</div>
{/* Child folders */}
{hasChildren && isExpanded && (
<div className="ml-4 border-l border-slate-200">
{children.map(childFolder => (
<FolderTreeItem
key={childFolder._id}
folder={childFolder}
children={[]} // Children can't have children in 2-level system
allPersonas={allPersonas}
selectedFolder={selectedFolder}
isExpanded={false} // Children can't be expanded
folderToRename={folderToRename}
renameFolderName={renameFolderName}
onFolderSelect={onFolderSelect}
onToggleExpansion={onToggleExpansion}
onStartRename={onStartRename}
onCompleteRename={onCompleteRename}
onCancelRename={onCancelRename}
onStartDelete={onStartDelete}
onStartCreateSubfolder={onStartCreateSubfolder}
onRenameFolderNameChange={onRenameFolderNameChange}
currentlyDraggedFolderId={currentlyDraggedFolderId}
/>
))}
</div>
)}
</div>
);
};
export default FolderTreeItem;

View file

@ -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[]) => {

View file

@ -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<Persona | null>(null);
const [selectedFolder, setSelectedFolder] = useState<string>(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<string | null>(null);
const [selectedPersonas, setSelectedPersonas] = useState<Set<string>>(new Set());
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [folderToRename, setFolderToRename] = useState<Folder | null>(null);
const [renameFolderName, setRenameFolderName] = useState('');
const [deleteFolderConfirmOpen, setDeleteFolderConfirmOpen] = useState(false);
const [folderToDelete, setFolderToDelete] = useState<Folder | null>(null);
const [moveToFolderOpen, setMoveToFolderOpen] = useState(false);
const [targetFolder, setTargetFolder] = useState<string | null>(null);
const [targetFolders, setTargetFolders] = useState<Set<string>>(new Set());
// Filter state
const [isFilterOpen, setIsFilterOpen] = useState(false);
const [activeFilters, setActiveFilters] = useState<FilterState>({
@ -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<string>, targetFolderId?: string) => {
const movePersonasToFolder = async (personasToMove?: Set<string>, targetFolderIds?: Set<string>) => {
// 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<string>();
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' ? (
<>
<div className="flex flex-col md:flex-row gap-6 mb-6">
<div className="w-full md:w-64 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Folders</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setIsCreatingFolder(true)}
className="h-7 w-7 p-0"
>
<FolderPlus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-1">
<button
onClick={() => setSelectedFolder(DEFAULT_FOLDER_ID)}
className={`w-full flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
selectedFolder === DEFAULT_FOLDER_ID
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-slate-100'
}`}
>
<Folder className="h-4 w-4" />
<span>All Personas</span>
</button>
{folders.map(folder => (
<div
key={folder._id}
className="flex items-center justify-between group"
>
{folderToRename && folderToRename._id === folder._id ? (
<div className="flex-1 flex items-center px-3 py-2 space-x-2">
<Folder className="h-4 w-4" />
<Input
value={renameFolderName}
onChange={e => 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();
}
}}
/>
<Button
size="sm"
variant="ghost"
onClick={completeRenameFolder}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelRenameFolder}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<>
<button
onClick={() => setSelectedFolder(folder._id)}
className={`flex-1 flex items-center space-x-2 px-3 py-2 text-sm rounded-md text-left transition-colors ${
selectedFolder === folder._id
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-slate-100'
}`}
>
<Folder className="h-4 w-4" />
<span>{folder.name}</span>
<span className="text-muted-foreground text-xs ml-auto">
{allPersonas.filter(persona =>
persona.folder_ids && persona.folder_ids.includes(folder._id)
).length}
</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => startRenameFolder(folder)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600"
onClick={() => startDeleteFolder(folder)}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
))}
{isCreatingFolder && (
<div className="flex items-center px-3 py-2 space-x-2">
<div className="flex-1 flex items-center space-x-2">
<Folder className="h-4 w-4" />
<Input
value={newFolderName}
onChange={e => 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();
}
}}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={createNewFolder}
className="h-7 w-7 p-0"
>
<Check className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={cancelFolderCreation}
className="h-7 w-7 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
</div>
<FolderTree
folders={folders}
allPersonas={allPersonas}
selectedFolder={selectedFolder}
onFolderSelect={setSelectedFolder}
onCreateFolder={createNewFolder}
onRenameFolder={renameFolder}
onDeleteFolder={startDeleteFolder}
onMoveFolder={moveFolder}
defaultFolderId={DEFAULT_FOLDER_ID}
/>
<div className="flex-1">
<div className="flex flex-col sm:flex-row gap-4 mb-6">
@ -1538,34 +1421,82 @@ const SyntheticUsers = () => {
<DialogHeader>
<DialogTitle>Move to Folder</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<RadioGroup
value={targetFolder || ''}
onValueChange={setTargetFolder}
className="space-y-2"
>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value={DEFAULT_FOLDER_ID} id="folder-all" />
<Label htmlFor="folder-all" className="flex items-center gap-2">
<Checkbox
id="folder-all"
checked={targetFolders.has(DEFAULT_FOLDER_ID)}
onCheckedChange={(checked) => {
const newTargetFolders = new Set(targetFolders);
if (checked) {
// If "All Personas" is selected, clear all other selections
setTargetFolders(new Set([DEFAULT_FOLDER_ID]));
} else {
newTargetFolders.delete(DEFAULT_FOLDER_ID);
setTargetFolders(newTargetFolders);
}
}}
/>
<Label htmlFor="folder-all" className="flex items-center gap-2 cursor-pointer">
<Folder className="h-4 w-4" />
<span>All Personas (Remove from folders)</span>
</Label>
</div>
{folders.map(folder => (
<div key={folder._id} className="flex items-center space-x-2">
<RadioGroupItem value={folder._id} id={`folder-${folder._id}`} />
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2">
<Folder className="h-4 w-4" />
<span>{folder.name}</span>
</Label>
</div>
))}
</RadioGroup>
{(() => {
// Organize folders into hierarchy for the dialog
const rootFolders = folders.filter(folder => !folder.parent_folder_id || folder.level === 0);
const childMap: Record<string, Folder[]> = {};
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) => (
<div key={folder._id}>
<div className={`flex items-center space-x-2 ${isChild ? 'ml-6 border-l border-slate-200 pl-4' : ''}`}>
<Checkbox
id={`folder-${folder._id}`}
checked={targetFolders.has(folder._id)}
onCheckedChange={(checked) => handleFolderToggle(folder._id, !!checked)}
/>
<Label htmlFor={`folder-${folder._id}`} className="flex items-center gap-2 cursor-pointer">
<Folder className={`h-4 w-4 ${isChild ? 'opacity-75' : ''}`} />
<span className={isChild ? 'text-slate-600' : ''}>{folder.name}</span>
</Label>
</div>
{/* Render children if any */}
{childMap[folder._id] && childMap[folder._id].map(childFolder =>
renderFolderOption(childFolder, true)
)}
</div>
);
return rootFolders.map(folder => renderFolderOption(folder));
})()}
</div>
</div>
<DialogFooter>
@ -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
</Button>