From cf31c50f46d81690b3a8dca6c69e8cf1a728b4d7 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 20 Feb 2025 21:45:32 +0000 Subject: [PATCH] feat: delete comments --- apps/web/src/providers/workspace.tsx | 11 ++- .../views/card/components/ActivityList.tsx | 5 ++ .../web/src/views/card/components/Comment.tsx | 49 ++++++++--- .../components/DeleteCommentConfirmation.tsx | 85 +++++++++++++++++++ .../views/card/components/NewCommentForm.tsx | 17 ++-- apps/web/src/views/card/index.tsx | 7 ++ packages/api/src/routers/card.ts | 65 ++++++++++++++ packages/db/src/repository/card.repo.ts | 1 + .../db/src/repository/cardComment.repo.ts | 20 +++++ 9 files changed, 239 insertions(+), 21 deletions(-) create mode 100644 apps/web/src/views/card/components/DeleteCommentConfirmation.tsx diff --git a/apps/web/src/providers/workspace.tsx b/apps/web/src/providers/workspace.tsx index 58a5b46a..87fdd24b 100644 --- a/apps/web/src/providers/workspace.tsx +++ b/apps/web/src/providers/workspace.tsx @@ -17,6 +17,7 @@ interface Workspace { publicId: string; slug: string | undefined; plan: "free" | "pro" | "enterprise" | undefined; + role: "admin" | "member" | "guest"; } const initialWorkspace: Workspace = { @@ -25,6 +26,7 @@ const initialWorkspace: Workspace = { publicId: "", slug: "", plan: "free", + role: "member", }; const initialAvailableWorkspaces: Workspace[] = []; @@ -60,10 +62,11 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ if (data.length) { const workspaces = data - .map(({ workspace }) => { + .map(({ workspace, role }) => { if (!workspace) return; return { + role, publicId: workspace.publicId, name: workspace.name, slug: workspace.slug, @@ -90,10 +93,13 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ slug: selectedWorkspace.workspace.slug, plan: selectedWorkspace.workspace.plan, description: selectedWorkspace.workspace.description, + role: selectedWorkspace.role, }); } else { const primaryWorkspace = data[0]?.workspace; - if (!primaryWorkspace) return; + const primaryWorkspaceRole = data[0]?.role; + + if (!primaryWorkspace || !primaryWorkspaceRole) return; localStorage.setItem("workspacePublicId", primaryWorkspace.publicId); setWorkspace({ publicId: primaryWorkspace.publicId, @@ -101,6 +107,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({ slug: primaryWorkspace.slug, plan: primaryWorkspace.plan, description: primaryWorkspace.description, + role: primaryWorkspaceRole, }); } }, [data]); diff --git a/apps/web/src/views/card/components/ActivityList.tsx b/apps/web/src/views/card/components/ActivityList.tsx index 407fd46a..0c84755e 100644 --- a/apps/web/src/views/card/components/ActivityList.tsx +++ b/apps/web/src/views/card/components/ActivityList.tsx @@ -12,6 +12,7 @@ import { import type { GetCardByIdOutput } from "@kan/api/types"; import Avatar from "~/components/Avatar"; +import { useWorkspace } from "~/providers/workspace"; import Comment from "./Comment"; type ActivityType = @@ -145,6 +146,8 @@ const ActivityList = ({ cardPublicId: string; isLoading: boolean; }) => { + const { workspace } = useWorkspace(); + return (
{activities.map((activity, index) => { @@ -170,6 +173,8 @@ const ActivityList = ({ createdAt={activity.createdAt} comment={activity.comment?.comment} isEdited={!!activity.comment?.updatedAt} + isAuthor={activity.comment?.createdBy === activity.user?.id} + isAdmin={workspace.role === "admin"} /> ); diff --git a/apps/web/src/views/card/components/Comment.tsx b/apps/web/src/views/card/components/Comment.tsx index 64dc61e1..707df1c0 100644 --- a/apps/web/src/views/card/components/Comment.tsx +++ b/apps/web/src/views/card/components/Comment.tsx @@ -2,11 +2,12 @@ import { formatDistanceToNow } from "date-fns"; import { useState } from "react"; import ContentEditable from "react-contenteditable"; import { useForm } from "react-hook-form"; -import { HiEllipsisHorizontal, HiPencil } from "react-icons/hi2"; +import { HiEllipsisHorizontal, HiPencil, HiTrash } from "react-icons/hi2"; import Avatar from "~/components/Avatar"; import Button from "~/components/Button"; import Dropdown from "~/components/Dropdown"; +import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -22,6 +23,8 @@ const Comment = ({ isLoading, createdAt, comment, + isAuthor, + isAdmin, isEdited = false, }: { publicId: string | undefined; @@ -31,11 +34,14 @@ const Comment = ({ isLoading: boolean; createdAt: string; comment: string | undefined; + isAuthor: boolean; + isAdmin: boolean; isEdited: boolean; }) => { const [isEditing, setIsEditing] = useState(false); const utils = api.useUtils(); const { showPopup } = usePopup(); + const { openModal } = useModal(); const { handleSubmit, setValue, watch } = useForm({ defaultValues: { comment, @@ -66,6 +72,27 @@ const Comment = ({ }); }; + const dropdownItems = [ + ...(isAuthor + ? [ + { + label: "Edit comment", + action: () => setIsEditing(true), + icon: , + }, + ] + : []), + ...(isAuthor || isAdmin + ? [ + { + label: "Delete comment", + action: () => openModal("DELETE_COMMENT", publicId), + icon: , + }, + ] + : []), + ]; + return (
-
- setIsEditing(true), - icon: , - }, - ]} - > - - -
+ {dropdownItems.length > 0 && ( +
+ + + +
+ )}
{!isEditing ? (

{comment}

diff --git a/apps/web/src/views/card/components/DeleteCommentConfirmation.tsx b/apps/web/src/views/card/components/DeleteCommentConfirmation.tsx new file mode 100644 index 00000000..ee34fe28 --- /dev/null +++ b/apps/web/src/views/card/components/DeleteCommentConfirmation.tsx @@ -0,0 +1,85 @@ +import Button from "~/components/Button"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface DeleteCommentConfirmationProps { + cardPublicId: string; + commentPublicId: string; +} + +export function DeleteCommentConfirmation({ + cardPublicId, + commentPublicId, +}: DeleteCommentConfirmationProps) { + const { closeModal } = useModal(); + const utils = api.useUtils(); + const { showPopup } = usePopup(); + + const queryParams = { + cardPublicId, + }; + + const deleteCommentMutation = api.card.deleteComment.useMutation({ + onMutate: async (args) => { + closeModal(); + await utils.card.byId.cancel(); + const currentState = utils.card.byId.getData(queryParams); + + utils.card.byId.setData(queryParams, (oldCard) => { + if (!oldCard) return oldCard; + const updatedActivities = oldCard.activities.filter( + (activity) => activity.comment?.publicId !== args.commentPublicId, + ); + return { ...oldCard, activities: updatedActivities }; + }); + + return { previousState: currentState }; + }, + onError: (_error, _newList, context) => { + utils.card.byId.setData(queryParams, context?.previousState); + showPopup({ + header: "Unable to delete comment", + message: "Please try again later, or contact customer support.", + icon: "error", + }); + }, + onSettled: async () => { + await utils.card.byId.invalidate(queryParams); + }, + }); + + const handleDeleteComment = () => { + deleteCommentMutation.mutate({ + cardPublicId, + commentPublicId, + }); + }; + + return ( +
+
+

+ Are you sure you want to delete this comment? +

+

+ {"This action can't be undone."} +

+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/views/card/components/NewCommentForm.tsx b/apps/web/src/views/card/components/NewCommentForm.tsx index 6dcd77a2..b90894c7 100644 --- a/apps/web/src/views/card/components/NewCommentForm.tsx +++ b/apps/web/src/views/card/components/NewCommentForm.tsx @@ -19,18 +19,25 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => { }, }); + const queryParams = { + cardPublicId, + }; + const addCommentMutation = api.card.addComment.useMutation({ - onSuccess: async () => { - await utils.card.byId.refetch(); - reset(); - }, - onError: () => { + onError: (_error, _newList) => { showPopup({ header: "Unable to add comment", message: "Please try again later, or contact customer support.", icon: "error", }); }, + onSettled: async () => { + reset(); + await utils.card.byId.invalidate(queryParams); + }, + onSuccess: async () => { + await utils.card.byId.refetch(); + }, }); const onSubmit = (data: FormValues) => { diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index f30e3ba2..36505705 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -16,6 +16,7 @@ import { formatMemberDisplayName } from "~/utils/helpers"; import { getPublicUrl } from "~/utils/supabase/getPublicUrl"; import ActivityList from "./components/ActivityList"; import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation"; +import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation"; import { DeleteLabelConfirmation } from "./components/DeleteLabelConfirmation"; import Dropdown from "./components/Dropdown"; import { LabelForm } from "./components/LabelForm"; @@ -259,6 +260,12 @@ export default function CardPage() { cardPublicId={cardId} /> )} + {modalContentType === "DELETE_COMMENT" && ( + + )} {modalContentType === "NEW_WORKSPACE" && } diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index f76a09c3..d24b2c4a 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -300,6 +300,71 @@ export const cardRouter = createTRPCRouter({ return updatedComment; }), + deleteComment: protectedProcedure + .meta({ + openapi: { + summary: "Delete a comment", + method: "DELETE", + path: "/cards/{cardPublicId}/comments/{commentPublicId}", + description: "Deletes a comment", + tags: ["Cards"], + }, + }) + .input( + z.object({ + cardPublicId: z.string().min(12), + commentPublicId: z.string().min(12), + }), + ) + .output(z.custom>>()) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const card = await cardRepo.getByPublicId(ctx.db, input.cardPublicId); + const existingComment = await cardCommentRepo.getByPublicId( + ctx.db, + input.commentPublicId, + ); + + if (!card) + throw new TRPCError({ + message: `Card with public ID ${input.cardPublicId} not found`, + code: "NOT_FOUND", + }); + + if (!existingComment) + throw new TRPCError({ + message: `Comment with public ID ${input.commentPublicId} not found`, + code: "NOT_FOUND", + }); + + const deletedComment = await cardCommentRepo.softDelete(ctx.db, { + commentId: existingComment.id, + deletedAt: new Date().toISOString(), + deletedBy: userId, + }); + + if (!deletedComment) + throw new TRPCError({ + message: `Failed to delete comment`, + code: "INTERNAL_SERVER_ERROR", + }); + + await cardActivityRepo.create(ctx.db, { + type: "card.updated.comment.deleted" as const, + cardId: card.id, + commentId: existingComment.id, + createdBy: userId, + }); + + return deletedComment; + }), addOrRemoveLabel: protectedProcedure .meta({ openapi: { diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index ef3ccece..3413713f 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -302,6 +302,7 @@ export const getWithListAndMembersByPublicId = async ( .is("deletedAt", null) .is("list.board.lists.deletedAt", null) .is("list.board.workspace.members.deletedAt", null) + .is("activities.comment.deletedAt", null) .order("index", { referencedTable: "list.board.lists", ascending: true }) .is("members.deletedAt", null) .limit(1) diff --git a/packages/db/src/repository/cardComment.repo.ts b/packages/db/src/repository/cardComment.repo.ts index 8df47a2b..e96ddc13 100644 --- a/packages/db/src/repository/cardComment.repo.ts +++ b/packages/db/src/repository/cardComment.repo.ts @@ -61,3 +61,23 @@ export const update = async ( return data; }; + +export const softDelete = async ( + db: SupabaseClient, + args: { + commentId: number; + deletedAt: string; + deletedBy: string; + }, +) => { + const { data } = await db + .from("card_comments") + .update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy }) + .eq("id", args.commentId) + .select(`id`) + .order("id", { ascending: true }) + .limit(1) + .single(); + + return data; +};