mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
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:
parent
947def5c4a
commit
ee0b59c0fa
4 changed files with 104 additions and 6 deletions
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue