SurfSense/surfsense_web/components/documents/DocumentNode.tsx
DESKTOP-RTLN3BA\$punk 656e061f84
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions
feat: add processing mode support for document uploads and ETL pipeline, improded error handling ux
- Introduced a `ProcessingMode` enum to differentiate between basic and premium processing modes.
- Updated `EtlRequest` to include a `processing_mode` field, defaulting to basic.
- Enhanced ETL pipeline services to utilize the selected processing mode for Azure Document Intelligence and LlamaCloud parsing.
- Modified various routes and services to handle processing mode, affecting document upload and indexing tasks.
- Improved error handling and logging to include processing mode details.
- Added tests to validate processing mode functionality and its impact on ETL operations.
2026-04-14 21:26:00 -07:00

345 lines
10 KiB
TypeScript

"use client";
import {
AlertCircle,
Clock,
Download,
Eye,
History,
MoreHorizontal,
Move,
PenLine,
Trash2,
} from "lucide-react";
import React, { useCallback, useRef, useState } from "react";
import { useDrag } from "react-dnd";
import { getDocumentTypeIcon } from "@/components/documents/DocumentTypeIcon";
import { ExportContextItems, ExportDropdownItems } from "@/components/shared/ExportMenuItems";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { cn } from "@/lib/utils";
import { DND_TYPES } from "./FolderNode";
import { isVersionableType } from "./version-history";
const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]);
export interface DocumentNodeDoc {
id: number;
title: string;
document_type: string;
folderId: number | null;
status?: { state: string; reason?: string | null };
}
interface DocumentNodeProps {
doc: DocumentNodeDoc;
depth: number;
isMentioned: boolean;
onToggleChatMention: (doc: DocumentNodeDoc, isMentioned: boolean) => void;
onPreview: (doc: DocumentNodeDoc) => void;
onEdit: (doc: DocumentNodeDoc) => void;
onDelete: (doc: DocumentNodeDoc) => void;
onMove: (doc: DocumentNodeDoc) => void;
onExport?: (doc: DocumentNodeDoc, format: string) => void;
onVersionHistory?: (doc: DocumentNodeDoc) => void;
contextMenuOpen?: boolean;
onContextMenuOpenChange?: (open: boolean) => void;
}
export const DocumentNode = React.memo(function DocumentNode({
doc,
depth,
isMentioned,
onToggleChatMention,
onPreview,
onEdit,
onDelete,
onMove,
onExport,
onVersionHistory,
contextMenuOpen,
onContextMenuOpenChange,
}: DocumentNodeProps) {
const statusState = doc.status?.state ?? "ready";
const isFailed = statusState === "failed";
const isProcessing = statusState === "pending" || statusState === "processing";
const isUnavailable = isProcessing || isFailed;
const isSelectable = !isUnavailable;
const isEditable = EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
const handleCheckChange = useCallback(() => {
if (isSelectable) {
onToggleChatMention(doc, isMentioned);
}
}, [doc, isMentioned, isSelectable, onToggleChatMention]);
const [{ isDragging }, drag] = useDrag(
() => ({
type: DND_TYPES.DOCUMENT,
item: { id: doc.id },
collect: (monitor) => ({ isDragging: monitor.isDragging() }),
}),
[doc.id]
);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
const [titleTooltipOpen, setTitleTooltipOpen] = useState(false);
const rowRef = useRef<HTMLDivElement>(null);
const titleRef = useRef<HTMLSpanElement>(null);
const handleExport = useCallback(
(format: string) => {
if (!onExport) return;
setExporting(format);
onExport(doc, format);
setTimeout(() => setExporting(null), 2000);
},
[doc, onExport]
);
const handleTitleTooltipOpenChange = useCallback((open: boolean) => {
if (open && titleRef.current) {
setTitleTooltipOpen(titleRef.current.scrollWidth > titleRef.current.clientWidth);
} else {
setTitleTooltipOpen(false);
}
}, []);
const attachRef = useCallback(
(node: HTMLDivElement | null) => {
(rowRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
drag(node);
},
[drag]
);
return (
<ContextMenu onOpenChange={onContextMenuOpenChange}>
<ContextMenuTrigger asChild>
{/* biome-ignore lint/a11y/useSemanticElements: contains nested interactive children (Checkbox) that render as <button>, making a semantic <button> wrapper invalid */}
<div
role="button"
tabIndex={0}
ref={attachRef}
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",
isMentioned && "bg-accent/30",
isDragging && "opacity-40"
)}
style={{ paddingLeft: `${depth * 16 + 4}px` }}
onClick={handleCheckChange}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleCheckChange();
}
}}
>
{(() => {
if (statusState === "pending") {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Clock className="h-3.5 w-3.5 text-muted-foreground/60" />
</span>
</TooltipTrigger>
<TooltipContent side="top">Pending: waiting to be synced</TooltipContent>
</Tooltip>
);
}
if (statusState === "processing") {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Spinner size="xs" className="text-primary" />
</span>
</TooltipTrigger>
<TooltipContent side="top">Syncing</TooltipContent>
</Tooltip>
);
}
if (statusState === "failed") {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
</span>
</TooltipTrigger>
<TooltipContent side="top">
{doc.status?.reason || "Processing failed"}
</TooltipContent>
</Tooltip>
);
}
return (
<Checkbox
checked={isMentioned}
onCheckedChange={handleCheckChange}
onClick={(e) => e.stopPropagation()}
className="h-3.5 w-3.5 shrink-0"
/>
);
})()}
<Tooltip
delayDuration={600}
open={titleTooltipOpen}
onOpenChange={handleTitleTooltipOpenChange}
>
<TooltipTrigger asChild>
<span ref={titleRef} className="flex-1 min-w-0 truncate">
{doc.title}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs break-words">
{doc.title}
</TooltipContent>
</Tooltip>
<span className="relative shrink-0 flex items-center justify-center h-6 w-6">
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
) && (
<span
className={cn(
"absolute inset-0 flex items-center justify-center transition-opacity pointer-events-none",
dropdownOpen ? "opacity-0" : "group-hover:opacity-0"
)}
>
{getDocumentTypeIcon(
doc.document_type as DocumentTypeEnum,
"h-3.5 w-3.5 text-muted-foreground"
)}
</span>
)}
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
"hidden sm:inline-flex h-6 w-6 shrink-0 hover:bg-transparent",
dropdownOpen
? "opacity-100 bg-accent hover:bg-accent"
: "opacity-0 group-hover:opacity-100"
)}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" />
Open
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
{onExport && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</div>
</ContextMenuTrigger>
{contextMenuOpen && (
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
{onExport && (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem>
)}
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
);
});