feat: enhance folder and document selection functionality

- Updated DocumentNode to use a div instead of a button for better accessibility and added keyboard interaction for selection.
- Introduced Checkbox component in FolderNode for selecting folders, with state management for selection (all, some, none).
- Implemented folder selection state logic in FolderTreeView to manage document selection across nested folders.
- Added handleToggleFolderSelect function in DocumentsSidebar to manage selection of documents based on folder selection.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-03-27 17:58:04 -07:00
parent 947def5c4a
commit ee0b59c0fa
4 changed files with 104 additions and 6 deletions

View file

@ -89,7 +89,7 @@ export const DocumentNode = React.memo(function DocumentNode({
const isProcessing = statusState === "pending" || statusState === "processing"; const isProcessing = statusState === "pending" || statusState === "processing";
const [dropdownOpen, setDropdownOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null); const [exporting, setExporting] = useState<string | null>(null);
const rowRef = useRef<HTMLButtonElement>(null); const rowRef = useRef<HTMLDivElement>(null);
const handleExport = useCallback( const handleExport = useCallback(
(format: string) => { (format: string) => {
@ -102,8 +102,8 @@ export const DocumentNode = React.memo(function DocumentNode({
); );
const attachRef = useCallback( const attachRef = useCallback(
(node: HTMLButtonElement | null) => { (node: HTMLDivElement | null) => {
(rowRef as React.MutableRefObject<HTMLButtonElement | null>).current = node; (rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
drag(node); drag(node);
}, },
[drag] [drag]
@ -112,8 +112,9 @@ export const DocumentNode = React.memo(function DocumentNode({
return ( return (
<ContextMenu onOpenChange={onContextMenuOpenChange}> <ContextMenu onOpenChange={onContextMenuOpenChange}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<button <div
type="button" role="button"
tabIndex={0}
ref={attachRef} ref={attachRef}
className={cn( className={cn(
"group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none text-left", "group flex h-8 w-full items-center gap-2.5 rounded-md px-1 text-sm hover:bg-accent/50 cursor-pointer select-none text-left",
@ -122,6 +123,12 @@ export const DocumentNode = React.memo(function DocumentNode({
)} )}
style={{ paddingLeft: `${depth * 16 + 4}px` }} style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={handleCheckChange} onClick={handleCheckChange}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCheckChange();
}
}}
> >
{(() => { {(() => {
if (statusState === "pending") { if (statusState === "pending") {
@ -231,7 +238,7 @@ export const DocumentNode = React.memo(function DocumentNode({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</button> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
{contextMenuOpen && ( {contextMenuOpen && (

View file

@ -14,6 +14,8 @@ import {
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { useDrag, useDrop } from "react-dnd"; import { useDrag, useDrop } from "react-dnd";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import type { FolderSelectionState } from "./FolderTreeView";
import { import {
ContextMenu, ContextMenu,
ContextMenuContent, ContextMenuContent,
@ -49,6 +51,8 @@ interface FolderNodeProps {
isExpanded: boolean; isExpanded: boolean;
isRenaming: boolean; isRenaming: boolean;
childCount: number; childCount: number;
selectionState: FolderSelectionState;
onToggleSelect: (folderId: number, selectAll: boolean) => void;
onToggleExpand: (folderId: number) => void; onToggleExpand: (folderId: number) => void;
onRename: (folder: FolderDisplay, newName: string) => void; onRename: (folder: FolderDisplay, newName: string) => void;
onStartRename: (folderId: number) => void; onStartRename: (folderId: number) => void;
@ -88,6 +92,8 @@ export const FolderNode = React.memo(function FolderNode({
isExpanded, isExpanded,
isRenaming, isRenaming,
childCount, childCount,
selectionState,
onToggleSelect,
onToggleExpand, onToggleExpand,
onRename, onRename,
onStartRename, onStartRename,
@ -212,6 +218,10 @@ export const FolderNode = React.memo(function FolderNode({
onStartRename(folder.id); onStartRename(folder.id);
}, [folder, onStartRename]); }, [folder, onStartRename]);
const handleCheckChange = useCallback(() => {
onToggleSelect(folder.id, selectionState !== "all");
}, [folder.id, selectionState, onToggleSelect]);
const FolderIcon = isExpanded ? FolderOpen : Folder; const FolderIcon = isExpanded ? FolderOpen : Folder;
return ( return (
@ -252,6 +262,13 @@ export const FolderNode = React.memo(function FolderNode({
)} )}
</span> </span>
<Checkbox
checked={selectionState === "all" ? true : selectionState === "some" ? "indeterminate" : false}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
<FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <FolderIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
{isRenaming ? ( {isRenaming ? (

View file

@ -10,6 +10,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode"; import { DocumentNode, type DocumentNodeDoc } from "./DocumentNode";
import { type FolderDisplay, FolderNode } from "./FolderNode"; import { type FolderDisplay, FolderNode } from "./FolderNode";
export type FolderSelectionState = "all" | "some" | "none";
interface FolderTreeViewProps { interface FolderTreeViewProps {
folders: FolderDisplay[]; folders: FolderDisplay[];
documents: DocumentNodeDoc[]; documents: DocumentNodeDoc[];
@ -20,6 +22,7 @@ interface FolderTreeViewProps {
doc: { id: number; title: string; document_type: string }, doc: { id: number; title: string; document_type: string },
isMentioned: boolean isMentioned: boolean
) => void; ) => void;
onToggleFolderSelect: (folderId: number, selectAll: boolean) => void;
onRenameFolder: (folder: FolderDisplay, newName: string) => void; onRenameFolder: (folder: FolderDisplay, newName: string) => void;
onDeleteFolder: (folder: FolderDisplay) => void; onDeleteFolder: (folder: FolderDisplay) => void;
onMoveFolder: (folder: FolderDisplay) => void; onMoveFolder: (folder: FolderDisplay) => void;
@ -55,6 +58,7 @@ export function FolderTreeView({
onToggleExpand, onToggleExpand,
mentionedDocIds, mentionedDocIds,
onToggleChatMention, onToggleChatMention,
onToggleFolderSelect,
onRenameFolder, onRenameFolder,
onDeleteFolder, onDeleteFolder,
onMoveFolder, onMoveFolder,
@ -122,6 +126,36 @@ export function FolderTreeView({
return match; return match;
}, [folders, docsByFolder, foldersByParent, activeTypes]); }, [folders, docsByFolder, foldersByParent, activeTypes]);
const folderSelectionStates = useMemo(() => {
const states: Record<number, FolderSelectionState> = {};
const isSelectable = (d: DocumentNodeDoc) =>
d.status?.state !== "pending" && d.status?.state !== "processing";
function compute(folderId: number): { selected: number; total: number } {
const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable);
let selected = directDocs.filter((d) => mentionedDocIds.has(d.id)).length;
let total = directDocs.length;
for (const child of foldersByParent[folderId] ?? []) {
const sub = compute(child.id);
selected += sub.selected;
total += sub.total;
}
if (total === 0) states[folderId] = "none";
else if (selected === total) states[folderId] = "all";
else if (selected > 0) states[folderId] = "some";
else states[folderId] = "none";
return { selected, total };
}
for (const f of folders) {
if (states[f.id] === undefined) compute(f.id);
}
return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocIds]);
function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { function renderLevel(parentId: number | null, depth: number): React.ReactNode[] {
const key = parentId ?? "root"; const key = parentId ?? "root";
const childFolders = (foldersByParent[key] ?? []) const childFolders = (foldersByParent[key] ?? [])
@ -151,6 +185,8 @@ export function FolderTreeView({
isExpanded={expandedIds.has(f.id)} isExpanded={expandedIds.has(f.id)}
isRenaming={renamingFolderId === f.id} isRenaming={renamingFolderId === f.id}
childCount={folderChildCounts[f.id] ?? 0} childCount={folderChildCounts[f.id] ?? 0}
selectionState={folderSelectionStates[f.id] ?? "none"}
onToggleSelect={onToggleFolderSelect}
onToggleExpand={onToggleExpand} onToggleExpand={onToggleExpand}
onRename={onRenameFolder} onRename={onRenameFolder}
onStartRename={handleStartRename} onStartRename={handleStartRename}

View file

@ -348,6 +348,43 @@ export function DocumentsSidebar({
[setSidebarDocs] [setSidebarDocs]
); );
const handleToggleFolderSelect = useCallback(
(folderId: number, selectAll: boolean) => {
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
const directDocs = (treeDocuments ?? []).filter(
(d) =>
d.folderId === parentId &&
d.status?.state !== "pending" &&
d.status?.state !== "processing",
);
const childFolders = foldersByParent[String(parentId)] ?? [];
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
return [...directDocs, ...descendantDocs];
}
const subtreeDocs = collectSubtreeDocs(folderId);
if (subtreeDocs.length === 0) return;
if (selectAll) {
setSidebarDocs((prev) => {
const existingIds = new Set(prev.map((d) => d.id));
const newDocs = subtreeDocs
.filter((d) => !existingIds.has(d.id))
.map((d) => ({
id: d.id,
title: d.title,
document_type: d.document_type as DocumentTypeEnum,
}));
return newDocs.length > 0 ? [...prev, ...newDocs] : prev;
});
} else {
const idsToRemove = new Set(subtreeDocs.map((d) => d.id));
setSidebarDocs((prev) => prev.filter((d) => !idsToRemove.has(d.id)));
}
},
[treeDocuments, foldersByParent, setSidebarDocs],
);
const isSearchMode = !!debouncedSearch.trim(); const isSearchMode = !!debouncedSearch.trim();
const { const {
@ -625,6 +662,7 @@ export function DocumentsSidebar({
onToggleExpand={toggleFolderExpand} onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds} mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention} onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder} onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder} onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder} onMoveFolder={handleMoveFolder}