feat: folder multi-select mode with checkboxes, shows contacts from multiple folders

This commit is contained in:
Agent Zero
2026-07-28 12:22:30 +02:00
parent 77284cbf10
commit 78738f5aa9
2 changed files with 121 additions and 24 deletions
@@ -34,6 +34,8 @@ export interface ContactFolderTreeProps {
tags: string[]; tags: string[];
loading?: boolean; loading?: boolean;
contacts?: { id: string; displayname: string; type: string; folder_id?: string | null }[]; contacts?: { id: string; displayname: string; type: string; folder_id?: string | null }[];
multiSelectFolders?: string[];
onMultiSelectChange?: (folderIds: string[]) => void;
} }
// Icons // Icons
@@ -137,6 +139,9 @@ function FolderTreeItem({
onDragOver, onDragOver,
onDragLeave, onDragLeave,
onDrop, onDrop,
multiSelectMode,
multiSelectedFolders,
onToggleMultiSelect,
}: { }: {
node: ContactFolderTreeNode; node: ContactFolderTreeNode;
depth: number; depth: number;
@@ -147,10 +152,14 @@ function FolderTreeItem({
onDragOver: (e: React.DragEvent, folderId: string) => void; onDragOver: (e: React.DragEvent, folderId: string) => void;
onDragLeave: (folderId: string) => void; onDragLeave: (folderId: string) => void;
onDrop: (e: React.DragEvent, folderId: string) => void; onDrop: (e: React.DragEvent, folderId: string) => void;
multiSelectMode?: boolean;
multiSelectedFolders?: string[];
onToggleMultiSelect?: (folderId: string) => void;
}) { }) {
const [expanded, setExpanded] = useState(true); const [expanded, setExpanded] = useState(true);
const folderKey = `folder:${node.id}` as ContactFilter; const folderKey = `folder:${node.id}` as ContactFilter;
const isActive = selectedFilter === folderKey; const isActive = selectedFilter === folderKey;
const isChecked = multiSelectedFolders?.includes(node.id) ?? false;
return ( return (
<div> <div>
@@ -174,29 +183,45 @@ function FolderTreeItem({
: 'text-secondary-700 hover:bg-secondary-100', : 'text-secondary-700 hover:bg-secondary-100',
)} )}
onClick={() => { onClick={() => {
setExpanded(!expanded); if (multiSelectMode && onToggleMultiSelect) {
onSelectFolder(node.id); onToggleMultiSelect(node.id);
} else {
setExpanded(!expanded);
onSelectFolder(node.id);
}
}} }}
> >
{chevron(expanded)} {multiSelectMode ? (
<input
type="checkbox"
checked={isChecked}
onChange={() => onToggleMultiSelect?.(node.id)}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500 flex-shrink-0 cursor-pointer"
/>
) : (
chevron(expanded)
)}
{icon(ICONS.folder, 'w-4 h-4 text-secondary-400')} {icon(ICONS.folder, 'w-4 h-4 text-secondary-400')}
<span className="flex-1 truncate">{node.name}</span> <span className="flex-1 truncate">{node.name}</span>
{node.contact_count > 0 && ( {node.contact_count > 0 && (
<span className="text-xs text-secondary-400 tabular-nums">{node.contact_count}</span> <span className="text-xs text-secondary-400 tabular-nums">{node.contact_count}</span>
)} )}
</div> </div>
<button {!multiSelectMode && (
type="button" <button
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onMoreClick(e, node.id); }} type="button"
className="flex-shrink-0 text-secondary-400 hover:text-primary-600 p-1.5 rounded hover:bg-secondary-100 transition-colors touch-manipulation" onClick={(e) => { e.stopPropagation(); e.preventDefault(); onMoreClick(e, node.id); }}
title="Optionen" className="flex-shrink-0 text-secondary-400 hover:text-primary-600 p-1.5 rounded hover:bg-secondary-100 transition-colors touch-manipulation"
aria-label="Optionen" title="Optionen"
> aria-label="Optionen"
{icon(ICONS.more, 'w-4 h-4')} >
</button> {icon(ICONS.more, 'w-4 h-4')}
</button>
)}
</div> </div>
{expanded && node.children.map((child) => ( {expanded && !multiSelectMode && node.children.map((child) => (
<FolderTreeItem <FolderTreeItem
key={child.id} key={child.id}
node={child} node={child}
@@ -208,6 +233,26 @@ function FolderTreeItem({
onDragOver={onDragOver} onDragOver={onDragOver}
onDragLeave={onDragLeave} onDragLeave={onDragLeave}
onDrop={onDrop} onDrop={onDrop}
multiSelectMode={multiSelectMode}
multiSelectedFolders={multiSelectedFolders}
onToggleMultiSelect={onToggleMultiSelect}
/>
))}
{multiSelectMode && node.children.map((child) => (
<FolderTreeItem
key={child.id}
node={child}
depth={depth + 1}
selectedFilter={selectedFilter}
onSelectFolder={onSelectFolder}
onMoreClick={onMoreClick}
isDragOver={false}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
multiSelectMode={multiSelectMode}
multiSelectedFolders={multiSelectedFolders}
onToggleMultiSelect={onToggleMultiSelect}
/> />
))} ))}
</div> </div>
@@ -222,12 +267,15 @@ export function ContactFolderTree({
tags, tags,
loading, loading,
contacts = [], contacts = [],
multiSelectFolders = [],
onMultiSelectChange,
}: ContactFolderTreeProps) { }: ContactFolderTreeProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [tagsOpen, setTagsOpen] = useState(true); const [tagsOpen, setTagsOpen] = useState(true);
const [dropdown, setDropdown] = useState<DropdownState | null>(null); const [dropdown, setDropdown] = useState<DropdownState | null>(null);
const [colorPicker, setColorPicker] = useState<{ folderId: string; color: string } | null>(null); const [colorPicker, setColorPicker] = useState<{ folderId: string; color: string } | null>(null);
const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null); const [dragOverFolderId, setDragOverFolderId] = useState<string | null>(null);
const [multiSelectMode, setMultiSelectMode] = useState(false);
const { data: folders, isLoading: foldersLoading } = useContactFolders(); const { data: folders, isLoading: foldersLoading } = useContactFolders();
const createFolderMut = useCreateContactFolder(); const createFolderMut = useCreateContactFolder();
@@ -344,6 +392,22 @@ export function ContactFolderTree({
onSelect(`folder:${folderId}` as ContactFilter); onSelect(`folder:${folderId}` as ContactFilter);
}, [onSelect]); }, [onSelect]);
const handleToggleMultiSelect = useCallback((folderId: string) => {
if (!onMultiSelectChange) return;
if (multiSelectFolders.includes(folderId)) {
onMultiSelectChange(multiSelectFolders.filter((id) => id !== folderId));
} else {
onMultiSelectChange([...multiSelectFolders, folderId]);
}
}, [multiSelectFolders, onMultiSelectChange]);
const handleToggleMultiSelectMode = () => {
setMultiSelectMode(!multiSelectMode);
if (multiSelectMode && onMultiSelectChange) {
onMultiSelectChange([]);
}
};
return ( return (
<nav className="space-y-0.5" aria-label={t('contacts.title')} data-testid="contact-folder-tree"> <nav className="space-y-0.5" aria-label={t('contacts.title')} data-testid="contact-folder-tree">
{/* Alle Kontakte */} {/* Alle Kontakte */}
@@ -359,16 +423,40 @@ export function ContactFolderTree({
{/* Folder tree header with add button */} {/* Folder tree header with add button */}
<div className="flex items-center justify-between px-2 pt-2 pb-1"> <div className="flex items-center justify-between px-2 pt-2 pb-1">
<span className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">Ordner</span> <span className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">Ordner</span>
<button <div className="flex items-center gap-1">
onClick={handleNewFolder} <button
className="text-secondary-400 hover:text-primary-600 p-0.5" onClick={handleToggleMultiSelectMode}
title="Neuer Ordner" className={`text-secondary-400 hover:text-primary-600 p-0.5 ${multiSelectMode ? 'text-primary-600 bg-primary-50 rounded' : ''}`}
aria-label="Neuer Ordner" title="Mehrere Ordner auswählen"
> aria-label="Mehrere Ordner auswählen"
{icon(ICONS.plus, 'w-3.5 h-3.5')} >
</button> <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</button>
<button
onClick={handleNewFolder}
className="text-secondary-400 hover:text-primary-600 p-0.5"
title="Neuer Ordner"
aria-label="Neuer Ordner"
>
{icon(ICONS.plus, 'w-3.5 h-3.5')}
</button>
</div>
</div> </div>
{/* Multi-select info */}
{multiSelectMode && (
<div className="px-2 pb-1 text-[11px] text-primary-600">
{multiSelectFolders.length > 0
? `${multiSelectFolders.length} Ordner ausgewählt`
: 'Ordner durch Anklicken auswählen'}
</div>
)}
{/* Folder tree with drag-drop */} {/* Folder tree with drag-drop */}
<div <div
onDragOver={handleRootDragOver} onDragOver={handleRootDragOver}
@@ -386,6 +474,9 @@ export function ContactFolderTree({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
multiSelectMode={multiSelectMode}
multiSelectedFolders={multiSelectFolders}
onToggleMultiSelect={handleToggleMultiSelect}
/> />
))} ))}
+9 -3
View File
@@ -50,6 +50,7 @@ export function ContactsListPage() {
const [groupState, setGroupState] = useState<GroupState>(emptyGroupState); const [groupState, setGroupState] = useState<GroupState>(emptyGroupState);
const [viewsOpen, setViewsOpen] = useState(true); const [viewsOpen, setViewsOpen] = useState(true);
const [foldersOpen, setFoldersOpen] = useState(true); const [foldersOpen, setFoldersOpen] = useState(true);
const [multiSelectFolders, setMultiSelectFolders] = useState<string[]>([]);
const [savedViews, setSavedViews] = useState<{ id: string; name: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode }[]>([]); const [savedViews, setSavedViews] = useState<{ id: string; name: string; filterState: FilterState; sortState: SortState; groupState: GroupState; selectedFilter: ContactFilter; viewMode: ContactViewMode }[]>([]);
const [activeViewId, setActiveViewId] = useState<string | null>(null); const [activeViewId, setActiveViewId] = useState<string | null>(null);
const openWindow = useWindowStore((s) => s.openWindow); const openWindow = useWindowStore((s) => s.openWindow);
@@ -113,7 +114,10 @@ export function ContactsListPage() {
// Filter by tag client-side if tag filter is active, then apply FilterPanel conditions // Filter by tag client-side if tag filter is active, then apply FilterPanel conditions
const filteredContacts = useMemo(() => { const filteredContacts = useMemo(() => {
let result = contacts; let result = contacts;
if (tagFilter) { // Multi-select folder filter — show contacts from multiple folders
if (multiSelectFolders.length > 0) {
result = result.filter((c) => c.folder_id != null && multiSelectFolders.includes(c.folder_id));
} else if (tagFilter) {
result = result.filter((c) => { result = result.filter((c) => {
if (!c.tags) return false; if (!c.tags) return false;
return c.tags.split(',').map((t) => t.trim()).includes(tagFilter); return c.tags.split(',').map((t) => t.trim()).includes(tagFilter);
@@ -124,7 +128,7 @@ export function ContactsListPage() {
// Apply SortPanel sorting // Apply SortPanel sorting
result = applySorting(result, sortState); result = applySorting(result, sortState);
return result; return result;
}, [contacts, tagFilter, filterState, sortState]); }, [contacts, tagFilter, filterState, sortState, multiSelectFolders]);
// Apply GroupPanel grouping // Apply GroupPanel grouping
const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState), [filteredContacts, groupState]); const groupedContacts = useMemo(() => applyGrouping(filteredContacts, groupState), [filteredContacts, groupState]);
@@ -370,7 +374,7 @@ export function ContactsListPage() {
]; ];
registerItems('contacts', items); registerItems('contacts', items);
return () => unregisterPlugin('contacts'); return () => unregisterPlugin('contacts');
}, [handleSearch, handleCreate, handleSelectFilter, handleSaveView, applySavedView, deleteSavedView, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState, groupState, savedViews, activeViewId]); }, [handleSearch, handleCreate, handleSelectFilter, handleSaveView, applySavedView, deleteSavedView, t, registerItems, unregisterPlugin, selectedFilter, viewMode, sortOptions, viewModeOptions, filterState, contactType, sortState, groupState, savedViews, activeViewId, multiSelectFolders]);
return ( return (
<div className="flex flex-col h-full" data-testid="contacts-list-page"> <div className="flex flex-col h-full" data-testid="contacts-list-page">
@@ -486,6 +490,8 @@ export function ContactsListPage() {
onSelect={handleSelectFilter} onSelect={handleSelectFilter}
tags={allTags} tags={allTags}
contacts={contacts} contacts={contacts}
multiSelectFolders={multiSelectFolders}
onMultiSelectChange={setMultiSelectFolders}
/> />
</div> </div>
)} )}