From ad778dd36d48843f80f2654a0e24bf12ef7cb4e4 Mon Sep 17 00:00:00 2001 From: Imtiaz Mehmood <116969564+imtiazmehmood@users.noreply.github.com> Date: Wed, 8 May 2024 23:10:00 +0500 Subject: [PATCH] Feat/quick delete chat (#1302) * feat:quick delete chat thread * update:pull request template * refactor bulk-deletion implementation * unset pull_request_changes * add border none for desktop support * unset marks when toggling bulk mode --------- Co-authored-by: timothycarambat <rambat1010@gmail.com> --- .../ThreadContainer/ThreadItem/index.jsx | 63 ++++++++++++--- .../ThreadContainer/index.jsx | 78 ++++++++++++++++++- frontend/src/models/workspaceThread.js | 12 +++ server/endpoints/workspaceThreads.js | 23 ++++++ server/models/workspaceThread.js | 2 +- 5 files changed, 164 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx index e31f4793b..87fd55587 100644 --- a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx +++ b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx @@ -1,7 +1,13 @@ import Workspace from "@/models/workspace"; import paths from "@/utils/paths"; import showToast from "@/utils/toast"; -import { DotsThree, PencilSimple, Trash } from "@phosphor-icons/react"; +import { + ArrowCounterClockwise, + DotsThree, + PencilSimple, + Trash, + X, +} from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import truncate from "truncate"; @@ -14,7 +20,9 @@ export default function ThreadItem({ workspace, thread, onRemove, + toggleMarkForDeletion, hasNext, + ctrlPressed = false, }) { const { slug } = useParams(); const optionsContainer = useRef(null); @@ -57,14 +65,30 @@ export default function ThreadItem({ /> <div className="flex w-full items-center justify-between pr-2 group relative"> {thread.deleted ? ( - <a className="w-full"> - <p className={`text-left text-sm text-slate-400/50 italic`}> - deleted thread - </p> - </a> + <div className="w-full flex justify-between"> + <div className="w-full "> + <p className={`text-left text-sm text-slate-400/50 italic`}> + deleted thread + </p> + </div> + {ctrlPressed && ( + <button + type="button" + className="border-none" + onClick={() => toggleMarkForDeletion(thread.id)} + > + <ArrowCounterClockwise + className="text-zinc-300 hover:text-white" + size={18} + /> + </button> + )} + </div> ) : ( <a - href={window.location.pathname === linkTo ? "#" : linkTo} + href={ + window.location.pathname === linkTo || ctrlPressed ? "#" : linkTo + } className="w-full" aria-current={isActive ? "page" : ""} > @@ -79,15 +103,30 @@ export default function ThreadItem({ )} {!!thread.slug && !thread.deleted && ( <div ref={optionsContainer}> - <div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1"> + {ctrlPressed ? ( <button type="button" - onClick={() => setShowOptions(!showOptions)} - aria-label="Thread options" + className="border-none" + onClick={() => toggleMarkForDeletion(thread.id)} > - <DotsThree className="text-slate-300" size={25} /> + <X + className="text-zinc-300 hover:text-white" + weight="bold" + size={18} + /> </button> - </div> + ) : ( + <div className="flex items-center w-fit group-hover:visible md:invisible gap-x-1"> + <button + type="button" + className="border-none" + onClick={() => setShowOptions(!showOptions)} + aria-label="Thread options" + > + <DotsThree className="text-slate-300" size={25} /> + </button> + </div> + )} {showOptions && ( <OptionsMenu containerRef={optionsContainer} diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx index d3659e3f0..f3c0ac2a1 100644 --- a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx +++ b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx @@ -1,7 +1,7 @@ import Workspace from "@/models/workspace"; import paths from "@/utils/paths"; import showToast from "@/utils/toast"; -import { Plus, CircleNotch } from "@phosphor-icons/react"; +import { Plus, CircleNotch, Trash } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; import ThreadItem from "./ThreadItem"; import { useParams } from "react-router-dom"; @@ -10,6 +10,7 @@ export default function ThreadContainer({ workspace }) { const { threadSlug = null } = useParams(); const [threads, setThreads] = useState([]); const [loading, setLoading] = useState(true); + const [ctrlPressed, setCtrlPressed] = useState(false); useEffect(() => { async function fetchThreads() { @@ -21,6 +22,43 @@ export default function ThreadContainer({ workspace }) { fetchThreads(); }, [workspace.slug]); + // Enable toggling of meta-key (ctrl on win and cmd/fn on others) + useEffect(() => { + const handleKeyDown = (event) => { + if (["Control", "Meta"].includes(event.key)) { + setCtrlPressed((prev) => !prev); + // when toggling, unset bulk progress so + // previously marked threads that were never deleted + // come back to life. + setThreads((prev) => + prev.map((t) => { + return { ...t, deleted: false }; + }) + ); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + const toggleForDeletion = (id) => { + setThreads((prev) => + prev.map((t) => { + if (t.id !== id) return t; + return { ...t, deleted: !t.deleted }; + }) + ); + }; + + const handleDeleteAll = async () => { + const slugs = threads.filter((t) => t.deleted === true).map((t) => t.slug); + await Workspace.threads.deleteBulk(workspace.slug, slugs); + setThreads((prev) => prev.filter((t) => !t.deleted)); + setCtrlPressed(false); + }; + function removeThread(threadId) { setThreads((prev) => prev.map((_t) => { @@ -28,6 +66,12 @@ export default function ThreadContainer({ workspace }) { return { ..._t, deleted: true }; }) ); + + // Show thread was deleted, but then remove from threads entirely so it will + // not appear in bulk-selection. + setTimeout(() => { + setThreads((prev) => prev.filter((t) => !t.deleted)); + }, 500); } if (loading) { @@ -58,6 +102,8 @@ export default function ThreadContainer({ workspace }) { <ThreadItem key={thread.slug} idx={i + 1} + ctrlPressed={ctrlPressed} + toggleMarkForDeletion={toggleForDeletion} activeIdx={activeThreadIdx} isActive={activeThreadIdx === i + 1} workspace={workspace} @@ -66,6 +112,11 @@ export default function ThreadContainer({ workspace }) { hasNext={i !== threads.length - 1} /> ))} + <DeleteAllThreadButton + ctrlPressed={ctrlPressed} + threads={threads} + onDelete={handleDeleteAll} + /> <NewThreadButton workspace={workspace} /> </div> ); @@ -113,3 +164,28 @@ function NewThreadButton({ workspace }) { </button> ); } + +function DeleteAllThreadButton({ ctrlPressed, threads, onDelete }) { + if (!ctrlPressed || threads.filter((t) => t.deleted).length === 0) + return null; + return ( + <button + type="button" + onClick={onDelete} + className="w-full relative flex h-[40px] items-center border-none hover:bg-red-400/20 rounded-lg group" + > + <div className="flex w-full gap-x-2 items-center pl-4"> + <div className="bg-zinc-600 p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center"> + <Trash + weight="bold" + size={14} + className="shrink-0 text-slate-100 group-hover:text-red-400" + /> + </div> + <p className="text-white text-left text-sm group-hover:text-red-400"> + Delete Selected + </p> + </div> + </button> + ); +} diff --git a/frontend/src/models/workspaceThread.js b/frontend/src/models/workspaceThread.js index b1bcaf644..039ee1868 100644 --- a/frontend/src/models/workspaceThread.js +++ b/frontend/src/models/workspaceThread.js @@ -62,6 +62,18 @@ const WorkspaceThread = { .then((res) => res.ok) .catch(() => false); }, + deleteBulk: async function (workspaceSlug, threadSlugs = []) { + return await fetch( + `${API_BASE}/workspace/${workspaceSlug}/thread-bulk-delete`, + { + method: "DELETE", + body: JSON.stringify({ slugs: threadSlugs }), + headers: baseHeaders(), + } + ) + .then((res) => res.ok) + .catch(() => false); + }, chatHistory: async function (workspaceSlug, threadSlug) { const history = await fetch( `${API_BASE}/workspace/${workspaceSlug}/thread/${threadSlug}/chats`, diff --git a/server/endpoints/workspaceThreads.js b/server/endpoints/workspaceThreads.js index 6ebf2ef4a..05b584af5 100644 --- a/server/endpoints/workspaceThreads.js +++ b/server/endpoints/workspaceThreads.js @@ -92,6 +92,29 @@ function workspaceThreadEndpoints(app) { } ); + app.delete( + "/workspace/:slug/thread-bulk-delete", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async (request, response) => { + try { + const { slugs = [] } = reqBody(request); + if (slugs.length === 0) return response.sendStatus(200).end(); + + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + await WorkspaceThread.delete({ + slug: { in: slugs }, + user_id: user?.id ?? null, + workspace_id: workspace.id, + }); + response.sendStatus(200).end(); + } catch (e) { + console.log(e.message, e); + response.sendStatus(500).end(); + } + } + ); + app.get( "/workspace/:slug/thread/:threadSlug/chats", [ diff --git a/server/models/workspaceThread.js b/server/models/workspaceThread.js index 0f99082b4..a2a96f310 100644 --- a/server/models/workspaceThread.js +++ b/server/models/workspaceThread.js @@ -61,7 +61,7 @@ const WorkspaceThread = { delete: async function (clause = {}) { try { - await prisma.workspace_threads.delete({ + await prisma.workspace_threads.deleteMany({ where: clause, }); return true; -- GitLab