From 074088d3cbd2e987be3827ddaa6c12f95ec5c116 Mon Sep 17 00:00:00 2001
From: Sean Hatfield <seanhatfield5@gmail.com>
Date: Wed, 25 Sep 2024 13:04:42 -0700
Subject: [PATCH] Bulk document removal from workspace

* wip improve remove document ux

* fix border ui bugs when adding files to workspace

* sort workspacedirectory put adding files at top

* fix workspace file row ui shifting

* fix selected items bug when adding another item with items already selected on workspace

* fix tooltip

* lint

* refactor

* fix bug where unadding single item while selected would stay selected

---------

Co-authored-by: timothycarambat <rambat1010@gmail.com>
---
 .../WorkspaceFileRow/index.jsx                |  47 ++++-
 .../Documents/WorkspaceDirectory/index.jsx    | 196 +++++++++++++++---
 2 files changed, 206 insertions(+), 37 deletions(-)

diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx
index 177d9b823..cc267170b 100644
--- a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx
+++ b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/WorkspaceFileRow/index.jsx
@@ -19,8 +19,13 @@ export default function WorkspaceFileRow({
   fetchKeys,
   hasChanges,
   movedItems,
+  selected,
+  toggleSelection,
+  disableSelection,
+  setSelectedItems,
 }) {
-  const onRemoveClick = async () => {
+  const onRemoveClick = async (e) => {
+    e.stopPropagation();
     setLoading(true);
 
     try {
@@ -33,24 +38,49 @@ export default function WorkspaceFileRow({
     } catch (error) {
       console.error("Failed to remove document:", error);
     }
-
+    setSelectedItems({});
     setLoadingMessage("");
     setLoading(false);
   };
 
+  function toggleRowSelection(e) {
+    if (disableSelection) return;
+    e.stopPropagation();
+    toggleSelection();
+  }
+
+  function handleRowSelection(e) {
+    e.stopPropagation();
+    toggleSelection();
+  }
+
   const isMovedItem = movedItems?.some((movedItem) => movedItem.id === item.id);
   return (
     <div
-      className={`items-center text-white/80 text-xs grid grid-cols-12 py-2 pl-3.5 pr-8 hover:bg-sky-500/20 cursor-pointer ${
-        isMovedItem ? "bg-green-800/40" : "file-row"
-      }`}
+      className={`items-center h-[34px] text-white/80 text-xs grid grid-cols-12 py-2 pl-3.5 pr-8 ${
+        !disableSelection ? "hover:bg-sky-500/20 cursor-pointer" : ""
+      } ${isMovedItem ? "bg-green-800/40" : "file-row"} ${selected ? "selected" : ""}`}
+      onClick={toggleRowSelection}
     >
       <div
+        className="col-span-10 w-fit flex gap-x-[2px] items-center relative"
         data-tooltip-id={`ws-directory-item-${item.url}`}
-        className="col-span-10 w-fit flex gap-x-[4px] items-center relative"
       >
+        <div className="shrink-0 w-3 h-3">
+          {!disableSelection ? (
+            <div
+              className="w-full h-full rounded border-[1px] border-white flex justify-center items-center cursor-pointer"
+              role="checkbox"
+              aria-checked={selected}
+              tabIndex={0}
+              onClick={handleRowSelection}
+            >
+              {selected && <div className="w-2 h-2 bg-white rounded-[2px]" />}
+            </div>
+          ) : null}
+        </div>
         <File
-          className="shrink-0 text-base font-bold w-4 h-4 mr-[3px] ml-3"
+          className="shrink-0 text-base font-bold w-4 h-4 mr-[3px] ml-1"
           weight="fill"
         />
         <p className="whitespace-nowrap overflow-hidden text-ellipsis">
@@ -105,8 +135,9 @@ const PinItemToWorkspace = memo(({ workspace, docPath, item }) => {
   const [hover, setHover] = useState(false);
   const pinEvent = new CustomEvent("pinned_document");
 
-  const updatePinStatus = async () => {
+  const updatePinStatus = async (e) => {
     try {
+      e.stopPropagation();
       if (!pinned) window.dispatchEvent(pinEvent);
       const success = await Workspace.setPinForDocument(
         workspace.slug,
diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx
index 4ec3a86ed..e9864f12c 100644
--- a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx
+++ b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx
@@ -7,6 +7,7 @@ import { Eye, PushPin } from "@phosphor-icons/react";
 import { SEEN_DOC_PIN_ALERT, SEEN_WATCH_ALERT } from "@/utils/constants";
 import paths from "@/utils/paths";
 import { Link } from "react-router-dom";
+import Workspace from "@/models/workspace";
 
 function WorkspaceDirectory({
   workspace,
@@ -22,6 +23,66 @@ function WorkspaceDirectory({
   embeddingCosts,
   movedItems,
 }) {
+  const [selectedItems, setSelectedItems] = useState({});
+
+  const toggleSelection = (item) => {
+    setSelectedItems((prevSelectedItems) => {
+      const newSelectedItems = { ...prevSelectedItems };
+      if (newSelectedItems[item.id]) {
+        delete newSelectedItems[item.id];
+      } else {
+        newSelectedItems[item.id] = true;
+      }
+      return newSelectedItems;
+    });
+  };
+
+  const toggleSelectAll = () => {
+    const allItems = files.items.flatMap((folder) => folder.items);
+    const allSelected = allItems.every((item) => selectedItems[item.id]);
+    if (allSelected) {
+      setSelectedItems({});
+    } else {
+      const newSelectedItems = {};
+      allItems.forEach((item) => {
+        newSelectedItems[item.id] = true;
+      });
+      setSelectedItems(newSelectedItems);
+    }
+  };
+
+  const removeSelectedItems = async () => {
+    setLoading(true);
+    setLoadingMessage("Removing selected files from workspace");
+
+    const itemsToRemove = Object.keys(selectedItems).map((itemId) => {
+      const folder = files.items.find((f) =>
+        f.items.some((i) => i.id === itemId)
+      );
+      const item = folder.items.find((i) => i.id === itemId);
+      return `${folder.name}/${item.name}`;
+    });
+
+    try {
+      await Workspace.modifyEmbeddings(workspace.slug, {
+        adds: [],
+        deletes: itemsToRemove,
+      });
+      await fetchKeys(true);
+      setSelectedItems({});
+    } catch (error) {
+      console.error("Failed to remove documents:", error);
+    }
+
+    setLoadingMessage("");
+    setLoading(false);
+  };
+
+  const handleSaveChanges = (e) => {
+    setSelectedItems({});
+    saveChanges(e);
+  };
+
   if (loading) {
     return (
       <div className="px-8">
@@ -31,11 +92,14 @@ function WorkspaceDirectory({
           </h3>
         </div>
         <div className="relative w-[560px] h-[445px] bg-zinc-900 rounded-2xl mt-5">
-          <div className="text-white/80 text-xs grid grid-cols-12 py-2 px-8">
-            <p className="col-span-5">Name</p>
+          <div className="text-white/80 text-xs grid grid-cols-12 py-2 px-3.5 border-b border-white/20 bg-zinc-900 sticky top-0 z-10 rounded-t-2xl">
+            <div className="col-span-10 flex items-center gap-x-[4px]">
+              <div className="shrink-0 w-3 h-3" />
+              <p className="ml-[7px]">Name</p>
+            </div>
             <p className="col-span-2" />
           </div>
-          <div className="w-full h-full flex items-center justify-center flex-col gap-y-5">
+          <div className="w-full h-[calc(100%-40px)] flex items-center justify-center flex-col gap-y-5">
             <PreLoader />
             <p className="text-white/80 text-sm font-semibold animate-pulse text-center w-1/3">
               {loadingMessage}
@@ -54,24 +118,50 @@ function WorkspaceDirectory({
             {workspace.name}
           </h3>
         </div>
-        <div
-          className={`relative w-[560px] h-[445px] bg-zinc-900 rounded-2xl mt-5 overflow-y-auto border-4 ${
-            highlightWorkspace ? "border-cyan-300/80" : "border-transparent"
-          }`}
-        >
-          <div className="text-white/80 text-xs grid grid-cols-12 py-2 px-8 border-b border-white/20 bg-zinc-900 sticky top-0 z-10">
-            <p className="col-span-5">Name</p>
-            <p className="col-span-2" />
-          </div>
-          <div className="w-full h-full flex flex-col z-0">
-            {Object.values(files.items).some(
-              (folder) => folder.items.length > 0
-            ) || movedItems.length > 0 ? (
-              <>
-                {files.items.map((folder) =>
-                  folder.items.map((item, index) => (
+        <div className="relative w-[560px] h-[445px] mt-5">
+          <div
+            className={`absolute inset-0 rounded-2xl  ${
+              highlightWorkspace ? "border-4 border-cyan-300/80 z-[999]" : ""
+            }`}
+          />
+          <div className="relative w-full h-full bg-zinc-900 rounded-2xl overflow-hidden">
+            <div className="text-white/80 text-xs grid grid-cols-12 py-2 px-3.5 border-b border-white/20 bg-zinc-900 sticky top-0 z-10">
+              <div className="col-span-10 flex items-center gap-x-[4px]">
+                {!hasChanges &&
+                files.items.some((folder) => folder.items.length > 0) ? (
+                  <div
+                    className="shrink-0 w-3 h-3 rounded border-[1px] border-white flex justify-center items-center cursor-pointer"
+                    role="checkbox"
+                    aria-checked={
+                      Object.keys(selectedItems).length ===
+                      files.items.reduce(
+                        (sum, folder) => sum + folder.items.length,
+                        0
+                      )
+                    }
+                    tabIndex={0}
+                    onClick={toggleSelectAll}
+                  >
+                    {Object.keys(selectedItems).length ===
+                      files.items.reduce(
+                        (sum, folder) => sum + folder.items.length,
+                        0
+                      ) && <div className="w-2 h-2 bg-white rounded-[2px]" />}
+                  </div>
+                ) : (
+                  <div className="shrink-0 w-3 h-3" />
+                )}
+                <p className="ml-[7px]">Name</p>
+              </div>
+              <p className="col-span-2" />
+            </div>
+            <div className="overflow-y-auto h-[calc(100%-40px)]">
+              {files.items.some((folder) => folder.items.length > 0) ||
+              movedItems.length > 0 ? (
+                <RenderFileRows files={files} movedItems={movedItems}>
+                  {({ item, folder }) => (
                     <WorkspaceFileRow
-                      key={index}
+                      key={item.id}
                       item={item}
                       folderName={folder.name}
                       workspace={workspace}
@@ -80,15 +170,45 @@ function WorkspaceDirectory({
                       fetchKeys={fetchKeys}
                       hasChanges={hasChanges}
                       movedItems={movedItems}
+                      selected={selectedItems[item.id]}
+                      toggleSelection={() => toggleSelection(item)}
+                      disableSelection={hasChanges}
+                      setSelectedItems={setSelectedItems}
                     />
-                  ))
-                )}
-              </>
-            ) : (
-              <div className="w-full h-full flex items-center justify-center">
-                <p className="text-white text-opacity-40 text-sm font-medium">
-                  No Documents
-                </p>
+                  )}
+                </RenderFileRows>
+              ) : (
+                <div className="w-full h-full flex items-center justify-center">
+                  <p className="text-white text-opacity-40 text-sm font-medium">
+                    No Documents
+                  </p>
+                </div>
+              )}
+            </div>
+            {Object.keys(selectedItems).length > 0 && !hasChanges && (
+              <div className="absolute bottom-[12px] left-0 right-0 flex justify-center pointer-events-none">
+                <div className="mx-auto bg-white/40 rounded-lg py-1 px-2 pointer-events-auto">
+                  <div className="flex flex-row items-center gap-x-2">
+                    <button
+                      onClick={toggleSelectAll}
+                      className="border-none text-sm font-semibold bg-white h-[30px] px-2.5 rounded-lg hover:text-white hover:bg-neutral-800/80"
+                    >
+                      {Object.keys(selectedItems).length ===
+                      files.items.reduce(
+                        (sum, folder) => sum + folder.items.length,
+                        0
+                      )
+                        ? "Deselect All"
+                        : "Select All"}
+                    </button>
+                    <button
+                      onClick={removeSelectedItems}
+                      className="border-none text-sm font-semibold bg-white h-[30px] px-2.5 rounded-lg hover:text-white hover:bg-neutral-800/80"
+                    >
+                      Remove Selected
+                    </button>
+                  </div>
+                </div>
               </div>
             )}
           </div>
@@ -111,7 +231,7 @@ function WorkspaceDirectory({
             </div>
 
             <button
-              onClick={saveChanges}
+              onClick={(e) => handleSaveChanges(e)}
               className="border border-slate-200 px-5 py-2.5 rounded-lg text-white text-sm items-center flex gap-x-2 hover:bg-slate-200 hover:text-slate-800 focus:ring-gray-800"
             >
               Save and Embed
@@ -258,4 +378,22 @@ const DocumentWatchAlert = memo(() => {
   );
 });
 
+function RenderFileRows({ files, movedItems, children }) {
+  function sortMovedItemsAndFiles(a, b) {
+    const aIsMovedItem = movedItems.some((movedItem) => movedItem.id === a.id);
+    const bIsMovedItem = movedItems.some((movedItem) => movedItem.id === b.id);
+    if (aIsMovedItem && !bIsMovedItem) return -1;
+    if (!aIsMovedItem && bIsMovedItem) return 1;
+    return 0;
+  }
+
+  return files.items
+    .flatMap((folder) => folder.items)
+    .sort(sortMovedItemsAndFiles)
+    .map((item) => {
+      const folder = files.items.find((f) => f.items.includes(item));
+      return children({ item, folder });
+    });
+}
+
 export default memo(WorkspaceDirectory);