Files
zeekay ee2c84a559 feat(all-documents): Add hide from All Documents feature
This feature allows users to hide folders and documents from the All Documents
view without deleting them:

- Add hiddenInAllDocuments field to Document and Folder models
- Create API endpoints for hiding documents (/documents/hide) and folders
  (/folders/hide)
- Update documents and folders APIs to exclude hidden items from All Documents
  view
- Add "Hide from All Documents" action to document and folder dropdown menus
- Add bulk hide action to multi-select action panel
- Cascade hiding: when a folder is hidden, all subfolders and documents within
  are also hidden

This is a non-destructive feature that only affects visibility in the All
Documents section. Hidden items remain accessible via Data Rooms and are not
deleted.

Resolves PM-465
2026-01-13 19:59:33 +00:00

859 lines
29 KiB
TypeScript

import { memo, useCallback, useMemo, useState } from "react";
import React from "react";
import { TeamContextType } from "@/context/team-context";
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragOverlay,
DragStartEvent,
MeasuringStrategy,
MouseSensor,
PointerSensor,
TouchSensor,
UniqueIdentifier,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
EyeOffIcon,
FileIcon,
FolderIcon,
FolderInputIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { motion } from "motion/react";
import { toast } from "sonner";
import { mutate } from "swr";
import { moveDocumentToFolder } from "@/lib/documents/move-documents";
import { moveFolderToFolder } from "@/lib/documents/move-folder";
import { DataroomFolderWithCount } from "@/lib/swr/use-dataroom";
import { FolderWithCount } from "@/lib/swr/use-documents";
import { DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
import { useMediaQuery } from "@/lib/utils/use-media-query";
import { Skeleton } from "@/components/ui/skeleton";
import { UploadNotificationDrawer } from "@/components/upload-notification";
import UploadZone, {
RejectedFile,
UploadState,
} from "@/components/upload-zone";
import { itemsMessage } from "../datarooms/folders/utils";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import { Portal } from "../ui/portal";
import { ButtonTooltip } from "../ui/tooltip";
import { useDeleteDocumentsAndFoldersModal } from "./actions/delete-documents-modal";
import { useDeleteFolderModal } from "./actions/delete-folder-modal";
import DocumentCard from "./document-card";
import { DraggableItem } from "./drag-and-drop/draggable-item";
import { DroppableFolder } from "./drag-and-drop/droppable-folder";
import { EmptyDocuments } from "./empty-document";
import FolderCard from "./folder-card";
import { MoveToFolderModal, TSelectedFolder } from "./move-folder-modal";
export type Upload = {
fileName: string;
progress: number;
documentId?: string;
};
export type File = {
fileName: string;
message: string;
};
export function DocumentsList({
folders,
documents,
teamInfo,
folderPathName,
loading,
foldersLoading,
}: {
folders: FolderWithCount[] | undefined;
documents: DocumentWithLinksAndLinkCountAndViewCount[] | undefined;
teamInfo: TeamContextType | null;
folderPathName?: string[];
loading: boolean;
foldersLoading: boolean;
}) {
const { isMobile } = useMediaQuery();
const [uploads, setUploads] = useState<UploadState[]>([]);
const [rejectedFiles, setRejectedFiles] = useState<RejectedFile[]>([]);
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
const [showDrawer, setShowDrawer] = useState<boolean>(false);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [draggedDocument, setDraggedDocument] =
useState<DocumentWithLinksAndLinkCountAndViewCount | null>(null);
//forFolder
const [draggedFolder, setDraggedFolder] = useState<
FolderWithCount | DataroomFolderWithCount | null
>(null);
const [isDragging, setIsDragging] = useState<boolean>(false);
const [isOverFolder, setIsOverFolder] = useState<boolean>(false);
const [parentFolderId, setParentFolderId] = useState<string>("");
const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);
const { setDeleteModalOpen, setFolderToDelete, DeleteFolderModal } =
useDeleteFolderModal(teamInfo);
const handleDeleteFolder = useCallback(
(folderId: string) => {
const folderToDelete = folders?.find((f) => f.id === folderId);
if (folderToDelete) {
setFolderToDelete(folderToDelete);
setDeleteModalOpen(true);
setSelectedFolders((prev) => prev.filter((id) => id !== folderId));
}
},
[folders, setFolderToDelete, setDeleteModalOpen, setSelectedFolders],
);
const { setShowDeleteItemsModal, DeleteItemsModal } =
useDeleteDocumentsAndFoldersModal({
documentIds: selectedDocuments,
setSelectedDocuments,
folderIds: selectedFolders,
setSelectedFolder: setSelectedFolders,
});
const totalSelectedItem = [...selectedDocuments, ...selectedFolders].length;
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(PointerSensor, {
activationConstraint: {
distance: 10,
},
}),
);
const selectedDocumentsLength = useMemo(
() => selectedDocuments && selectedDocuments.length,
[selectedDocuments],
);
const selectedFoldersLength = useMemo(
() => selectedFolders && selectedFolders.length,
[selectedFolders],
);
const handleSelect = useCallback(
(id: string, type: "document" | "folder") => {
if (type === "folder") {
setSelectedFolders((prev) =>
prev.includes(id)
? prev.filter((docId) => docId !== id)
: [...prev, id],
);
} else {
setSelectedDocuments((prev) =>
prev.includes(id)
? prev.filter((docId) => docId !== id)
: [...prev, id],
);
}
},
[],
);
const handleDragForType = useCallback(
(
itemId: string,
items: { id: string }[] | undefined,
setDraggedItem: (item: any) => void,
selectedItems: string[],
setSelectedItems: (items: string[]) => void,
) => {
if (!items) return;
const draggedItem = items.find((item) => item.id === itemId) ?? null;
setDraggedItem(draggedItem);
const itemIndex = items.findIndex((item) => item.id === itemId);
const isSelected = selectedItems.includes(itemId);
let yOffset = 0;
if (isSelected) {
const firstSelectedIndex = items.findIndex((item) =>
selectedItems.includes(item.id),
);
yOffset = (itemIndex - firstSelectedIndex) * 80; // Adjust based on actual height
}
setDragOffset({ x: 0, y: yOffset });
if (!isSelected) {
setSelectedItems([...selectedItems, itemId]);
}
},
[],
);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setIsDragging(true);
setParentFolderId(event.active.data.current?.parentFolderId);
const { type } = event.active.data.current ?? {};
const itemId = event.active.id as string;
if (type === "document") {
handleDragForType(
itemId,
documents,
setDraggedDocument,
selectedDocuments,
setSelectedDocuments,
);
}
if (type === "folder") {
handleDragForType(
itemId,
folders,
setDraggedFolder,
selectedFolders,
setSelectedFolders,
);
}
},
[handleDragForType, documents, folders, selectedDocuments, selectedFolders],
);
const handleDragOver = (event: DragOverEvent) => {
const { over } = event;
if (!over) return;
const overType = over.data.current?.type;
if (overType === "folder") {
setIsOverFolder(true);
} else {
setIsOverFolder(false);
}
};
const moveDocumentsAndFolders = async ({
documentsToMove,
foldersToMove,
overId,
folderPathName,
teamId,
selectedFolderPath,
}: {
documentsToMove: string[];
foldersToMove: string[];
overId: UniqueIdentifier;
folderPathName: string[] | undefined;
teamId: string;
selectedFolderPath: string;
}) => {
return new Promise(async (resolve, reject) => {
try {
if (documentsToMove && documentsToMove.length > 0) {
await moveDocumentToFolder({
documentIds: documentsToMove,
folderId: overId.toString(),
folderPathName,
teamId: teamId,
folderIds: foldersToMove,
});
}
if (foldersToMove && foldersToMove.length > 0) {
await moveFolderToFolder({
folderIds: foldersToMove,
folderPathName: folderPathName ? folderPathName : undefined,
teamId: teamId,
selectedFolder: overId.toString(),
selectedFolderPath: selectedFolderPath,
});
}
resolve("Successfully moved documents and folders.");
} catch (error) {
reject(
error instanceof Error
? error.message
: "Failed to move documents and folders.",
);
}
});
};
const handleDragEnd = async (event: DragEndEvent) => {
setIsDragging(false);
const { active, over } = event;
setDraggedDocument(null);
setDraggedFolder(null);
if (!over) return;
const activeId = active.id;
const overId = over.id;
if (selectedFolders.includes(overId.toString())) {
return toast.error(
"Can not move folder and documents into selected folders",
);
}
const isActiveADocument = active.data.current?.type === "document";
const isActiveAFolder = active.data.current?.type === "folder";
const isOverAFolder = over.data.current?.type === "folder";
if (activeId === overId) return;
if (isActiveADocument && !isOverAFolder) return;
if (isActiveAFolder && !isOverAFolder) return;
// Move the document(s) to the new folder
const documentsToMove =
selectedDocumentsLength > 0 ? selectedDocuments : [];
// Move the folder(s) to the new folder
const foldersToMove = selectedFoldersLength > 0 ? selectedFolders : [];
toast.promise(
moveDocumentsAndFolders({
documentsToMove: documentsToMove,
foldersToMove: foldersToMove,
overId: overId,
folderPathName: folderPathName,
teamId: teamInfo?.currentTeam?.id!,
selectedFolderPath: over.data.current?.path,
}),
{
loading: itemsMessage(documentsToMove, foldersToMove, "Moving"),
success: () =>
itemsMessage(documentsToMove, foldersToMove, "Successfully moved"),
error: (err) => err,
},
);
setSelectedDocuments([]);
setSelectedFolders([]);
setIsOverFolder(false);
};
const handleCloseDrawer = () => {
setShowDrawer(false);
};
const resetSelection = () => {
setSelectedDocuments([]);
setSelectedFolders([]);
};
const [isHiding, setIsHiding] = useState(false);
const handleBulkHide = useCallback(async () => {
if (selectedDocuments.length === 0 && selectedFolders.length === 0) return;
setIsHiding(true);
try {
const promises: Promise<Response>[] = [];
// Hide documents
if (selectedDocuments.length > 0) {
promises.push(
fetch(`/api/teams/${teamInfo?.currentTeam?.id}/documents/hide`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
documentIds: selectedDocuments,
hidden: true,
}),
}),
);
}
// Hide folders (cascades to children)
if (selectedFolders.length > 0) {
promises.push(
fetch(`/api/teams/${teamInfo?.currentTeam?.id}/folders/hide`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
folderIds: selectedFolders,
hidden: true,
}),
}),
);
}
const results = await Promise.all(promises);
// Check for errors
for (const res of results) {
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || "Failed to hide items");
}
}
// Revalidate data
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders?root=true`);
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/folders`);
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/documents`);
if (folderPathName && folderPathName.length > 0) {
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/folders/${folderPathName.join("/")}`,
);
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/folders/documents/${folderPathName.join("/")}`,
);
}
// Reset selection
setSelectedDocuments([]);
setSelectedFolders([]);
toast.success(
`Successfully hidden ${selectedDocuments.length > 0 ? `${selectedDocuments.length} document${selectedDocuments.length > 1 ? "s" : ""}` : ""}${selectedDocuments.length > 0 && selectedFolders.length > 0 ? " and " : ""}${selectedFolders.length > 0 ? `${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}` : ""} from All Documents`,
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to hide items",
);
} finally {
setIsHiding(false);
}
}, [selectedDocuments, selectedFolders, teamInfo?.currentTeam?.id, folderPathName]);
const HeaderContent = memo(() => {
if (selectedDocumentsLength > 0 || selectedFoldersLength > 0) {
const totalItems = (documents?.length || 0) + (folders?.length || 0);
const isAllSelected = totalItems === totalSelectedItem;
const handleSelectAll = () => {
if (isAllSelected) {
setSelectedDocuments([]);
setSelectedFolders([]);
} else {
const allDocumentIds = documents?.map((doc) => doc.id) || [];
const allFolderIds = folders?.map((folder) => folder.id) || [];
setSelectedDocuments(allDocumentIds);
setSelectedFolders(allFolderIds);
}
};
return (
<div className="mb-2 flex items-center gap-x-1 rounded-3xl bg-gray-100 text-sm text-foreground dark:bg-gray-800">
<div className="ml-5 flex h-8 w-8 items-center justify-center rounded-full hover:bg-gray-200 hover:dark:bg-gray-700">
<ButtonTooltip
content={isAllSelected ? "Deselect all" : "Select all"}
>
<Checkbox
id="select-all"
checked={isAllSelected}
onCheckedChange={handleSelectAll}
className="h-5 w-5"
aria-label={isAllSelected ? "Deselect all" : "Select all"}
/>
</ButtonTooltip>
</div>
<ButtonTooltip content="Clear selection">
<Button
onClick={resetSelection}
className="mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700"
variant="ghost"
size="icon"
>
<XIcon className="h-5 w-5" />
</Button>
</ButtonTooltip>
{selectedDocumentsLength ? (
<div className="mr-2 tabular-nums">
{selectedDocumentsLength} document
{selectedDocumentsLength > 1 ? "s" : ""} selected
</div>
) : null}
{selectedFoldersLength ? (
<div className="mr-2 tabular-nums">
{selectedFoldersLength} folder
{selectedFoldersLength > 1 ? "s" : ""} selected
</div>
) : null}
<ButtonTooltip content="Move">
<Button
onClick={() => setMoveFolderOpen(true)}
className="mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700"
variant="ghost"
size="icon"
>
<FolderInputIcon className="h-5 w-5" />
</Button>
</ButtonTooltip>
<ButtonTooltip content="Hide from All Documents">
<Button
onClick={handleBulkHide}
disabled={isHiding}
className="mx-1.5 my-1 size-8 rounded-full hover:bg-gray-200 hover:dark:bg-gray-700"
variant="ghost"
size="icon"
>
<EyeOffIcon className="h-5 w-5" />
</Button>
</ButtonTooltip>
<ButtonTooltip content="Delete">
<Button
onClick={() => setShowDeleteItemsModal(true)}
className="mx-1.5 my-1 size-8 rounded-full hover:bg-destructive hover:text-destructive-foreground"
variant="ghost"
size="icon"
>
<Trash2Icon className="h-5 w-5" />
</Button>
</ButtonTooltip>
</div>
);
} else {
return (
<div className="mb-2 flex items-center gap-x-2 pt-5">
{folders && folders.length > 0 && (
<p className="flex items-center gap-x-1 text-sm text-gray-400">
<FolderIcon className="h-5 w-5" />
<span>
{folders.length} folder{folders.length > 1 ? "s" : ""}
</span>
</p>
)}
{documents && documents.length > 0 && (
<p className="flex items-center gap-x-1 text-sm text-gray-400">
<FileIcon className="h-5 w-5" />
<span>
{documents.length} document{documents.length > 1 ? "s" : ""}
</span>
</p>
)}
</div>
);
}
});
HeaderContent.displayName = "HeaderContent";
return (
<>
<UploadZone
folderPathName={folderPathName?.join("/")}
onUploadStart={(newUploads) => {
setUploads((prevUploads) => [...prevUploads, ...newUploads]);
setShowDrawer(true);
}}
onUploadProgress={(index, progress, documentId) => {
setUploads((prevUploads) => {
const recentBatchStartIndex = prevUploads.length - index - 1;
if (
recentBatchStartIndex < 0 ||
recentBatchStartIndex >= prevUploads.length
) {
return prevUploads;
}
return prevUploads.map((upload, i) =>
i === recentBatchStartIndex
? { ...upload, progress, documentId }
: upload,
);
});
}}
onUploadRejected={(rejected) => {
setRejectedFiles((prevRejected) => [...prevRejected, ...rejected]);
setShowDrawer(true);
}}
setUploads={setUploads}
setRejectedFiles={setRejectedFiles}
>
{isMobile ? (
<div className="space-y-4">
{/* Folders list */}
<ul role="list" className="space-y-4">
{folders && !foldersLoading
? folders.map((folder) => {
return (
<li key={folder.id}>
<FolderCard
key={folder.id}
folder={folder}
teamInfo={teamInfo}
isSelected={selectedFolders.includes(folder.id)}
isDragging={
isDragging && selectedFolders.includes(folder.id)
}
onDelete={handleDeleteFolder}
/>
</li>
);
})
: Array.from({ length: 3 }).map((_, i) => (
<li
key={i}
className="relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6"
>
<Skeleton key={i} className="h-9 w-9" />
<div>
<Skeleton key={i} className="h-4 w-32" />
<Skeleton key={i + 1} className="mt-2 h-3 w-12" />
</div>
<Skeleton
key={i + 1}
className="absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform"
/>
</li>
))}
</ul>
{/* Documents list */}
<ul role="list" className="space-y-4">
{documents && !loading
? documents.map((document) => {
return (
<li key={document.id}>
<DocumentCard
key={document.id}
document={document}
teamInfo={teamInfo}
isDragging={
isDragging &&
selectedDocuments.includes(document.id)
}
/>
</li>
);
})
: Array.from({ length: 3 }).map((_, i) => (
<li
key={i}
className="relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6"
>
<Skeleton key={i} className="h-9 w-9" />
<div>
<Skeleton key={i} className="h-4 w-32" />
<Skeleton key={i + 1} className="mt-2 h-3 w-12" />
</div>
<Skeleton
key={i + 1}
className="absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform"
/>
</li>
))}
</ul>
<Portal containerId={"documents-header-count"}>
<HeaderContent />
</Portal>
{documents && documents.length === 0 && !loading && (
<div className="flex items-center justify-center">
<EmptyDocuments />
</div>
)}
</div>
) : (
<>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={() => setIsOverFolder(false)}
measuring={{
droppable: {
strategy: MeasuringStrategy.Always,
},
}}
>
<div className="space-y-4">
{/* Folders list */}
<ul role="list" className="space-y-4">
{folders && !foldersLoading
? folders.map((folder) => {
return (
<li key={folder.id}>
<DroppableFolder
key={folder.id}
id={folder.id}
disabledFolder={selectedFolders}
path={folder.path}
>
<DraggableItem
key={folder.id}
id={folder.id}
isSelected={selectedFolders.includes(folder.id)}
onSelect={(id, type) => {
handleSelect(id, type);
}}
isDraggingSelected={isDragging}
type="folder"
>
<FolderCard
key={folder.id}
folder={folder}
teamInfo={teamInfo}
isSelected={selectedFolders.includes(
folder.id,
)}
isDragging={
isDragging &&
selectedFolders.includes(folder.id)
}
onDelete={handleDeleteFolder}
/>
</DraggableItem>
</DroppableFolder>
</li>
);
})
: Array.from({ length: 3 }).map((_, i) => (
<li
key={i}
className="relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6"
>
<Skeleton key={i} className="h-9 w-9" />
<div>
<Skeleton key={i} className="h-4 w-32" />
<Skeleton key={i + 1} className="mt-2 h-3 w-12" />
</div>
<Skeleton
key={i + 1}
className="absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform"
/>
</li>
))}
</ul>
{/* Documents list */}
<ul role="list" className="space-y-4">
{documents && !loading
? documents.map((document) => {
return (
<li key={document.id}>
<DraggableItem
key={document.id}
id={document.id}
isSelected={selectedDocuments.includes(
document.id,
)}
isDraggingSelected={isDragging}
type="document"
onSelect={(id, type) => {
handleSelect(id, type);
}}
>
<DocumentCard
key={document.id}
document={document}
teamInfo={teamInfo}
isDragging={
isDragging &&
selectedDocuments.includes(document.id)
}
/>
</DraggableItem>
</li>
);
})
: Array.from({ length: 3 }).map((_, i) => (
<li
key={i}
className="relative flex w-full items-center space-x-3 rounded-lg border px-4 py-5 sm:px-6 lg:px-6"
>
<Skeleton key={i} className="h-9 w-9" />
<div>
<Skeleton key={i} className="h-4 w-32" />
<Skeleton key={i + 1} className="mt-2 h-3 w-12" />
</div>
<Skeleton
key={i + 1}
className="absolute right-5 top-[50%] h-5 w-20 -translate-y-[50%] transform"
/>
</li>
))}
</ul>
<Portal>
<DragOverlay className="cursor-default">
<motion.div
initial={{ scale: 1, opacity: 1 }}
animate={{ scale: 0.9, opacity: 0.95 }}
exit={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.2 }}
className="relative"
style={{ transform: `translateY(${dragOffset.y}px)` }}
>
{draggedDocument ? (
<DocumentCard
document={draggedDocument}
teamInfo={teamInfo}
/>
) : null}
{draggedFolder ? (
<FolderCard
folder={draggedFolder}
teamInfo={teamInfo}
onDelete={handleDeleteFolder}
/>
) : null}
{totalSelectedItem > 1 ? (
<div className="absolute -right-4 -top-4 rounded-full border border-border bg-foreground px-4 py-2">
<span className="text-sm font-semibold text-background">
{totalSelectedItem}
</span>
</div>
) : null}
</motion.div>
</DragOverlay>
</Portal>
<Portal containerId={"documents-header-count"}>
<HeaderContent />
</Portal>
{documents && documents.length === 0 && !loading && (
<div className="flex items-center justify-center">
<EmptyDocuments />
</div>
)}
</div>
</DndContext>
{moveFolderOpen ? (
<MoveToFolderModal
open={moveFolderOpen}
setOpen={setMoveFolderOpen}
setSelectedDocuments={setSelectedDocuments}
documentIds={selectedDocuments}
folderIds={selectedFolders}
folderParentId={parentFolderId}
setSelectedFoldersId={setSelectedFolders}
/>
) : null}
</>
)}
</UploadZone>
{showDrawer ? (
<UploadNotificationDrawer
open={showDrawer}
onOpenChange={setShowDrawer}
uploads={uploads}
handleCloseDrawer={handleCloseDrawer}
setUploads={setUploads}
rejectedFiles={rejectedFiles}
setRejectedFiles={setRejectedFiles}
/>
) : null}
<DeleteFolderModal />
<DeleteItemsModal />
</>
);
}