feat: folder multi-select mode with checkboxes, shows contacts from multiple folders
This commit is contained in:
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user