feat: delete comments

This commit is contained in:
Henry
2025-02-20 21:45:32 +00:00
parent 95aa23056b
commit cf31c50f46
9 changed files with 239 additions and 21 deletions

View File

@@ -17,6 +17,7 @@ interface Workspace {
publicId: string; publicId: string;
slug: string | undefined; slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined; plan: "free" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest";
} }
const initialWorkspace: Workspace = { const initialWorkspace: Workspace = {
@@ -25,6 +26,7 @@ const initialWorkspace: Workspace = {
publicId: "", publicId: "",
slug: "", slug: "",
plan: "free", plan: "free",
role: "member",
}; };
const initialAvailableWorkspaces: Workspace[] = []; const initialAvailableWorkspaces: Workspace[] = [];
@@ -60,10 +62,11 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
if (data.length) { if (data.length) {
const workspaces = data const workspaces = data
.map(({ workspace }) => { .map(({ workspace, role }) => {
if (!workspace) return; if (!workspace) return;
return { return {
role,
publicId: workspace.publicId, publicId: workspace.publicId,
name: workspace.name, name: workspace.name,
slug: workspace.slug, slug: workspace.slug,
@@ -90,10 +93,13 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: selectedWorkspace.workspace.slug, slug: selectedWorkspace.workspace.slug,
plan: selectedWorkspace.workspace.plan, plan: selectedWorkspace.workspace.plan,
description: selectedWorkspace.workspace.description, description: selectedWorkspace.workspace.description,
role: selectedWorkspace.role,
}); });
} else { } else {
const primaryWorkspace = data[0]?.workspace; const primaryWorkspace = data[0]?.workspace;
if (!primaryWorkspace) return; const primaryWorkspaceRole = data[0]?.role;
if (!primaryWorkspace || !primaryWorkspaceRole) return;
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId); localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
setWorkspace({ setWorkspace({
publicId: primaryWorkspace.publicId, publicId: primaryWorkspace.publicId,
@@ -101,6 +107,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
slug: primaryWorkspace.slug, slug: primaryWorkspace.slug,
plan: primaryWorkspace.plan, plan: primaryWorkspace.plan,
description: primaryWorkspace.description, description: primaryWorkspace.description,
role: primaryWorkspaceRole,
}); });
} }
}, [data]); }, [data]);

View File

@@ -12,6 +12,7 @@ import {
import type { GetCardByIdOutput } from "@kan/api/types"; import type { GetCardByIdOutput } from "@kan/api/types";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import { useWorkspace } from "~/providers/workspace";
import Comment from "./Comment"; import Comment from "./Comment";
type ActivityType = type ActivityType =
@@ -145,6 +146,8 @@ const ActivityList = ({
cardPublicId: string; cardPublicId: string;
isLoading: boolean; isLoading: boolean;
}) => { }) => {
const { workspace } = useWorkspace();
return ( return (
<div className="flex flex-col space-y-4 pt-4"> <div className="flex flex-col space-y-4 pt-4">
{activities.map((activity, index) => { {activities.map((activity, index) => {
@@ -170,6 +173,8 @@ const ActivityList = ({
createdAt={activity.createdAt} createdAt={activity.createdAt}
comment={activity.comment?.comment} comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt} isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === activity.user?.id}
isAdmin={workspace.role === "admin"}
/> />
); );

View File

@@ -2,11 +2,12 @@ import { formatDistanceToNow } from "date-fns";
import { useState } from "react"; import { useState } from "react";
import ContentEditable from "react-contenteditable"; import ContentEditable from "react-contenteditable";
import { useForm } from "react-hook-form"; 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 Avatar from "~/components/Avatar";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Dropdown from "~/components/Dropdown"; import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -22,6 +23,8 @@ const Comment = ({
isLoading, isLoading,
createdAt, createdAt,
comment, comment,
isAuthor,
isAdmin,
isEdited = false, isEdited = false,
}: { }: {
publicId: string | undefined; publicId: string | undefined;
@@ -31,11 +34,14 @@ const Comment = ({
isLoading: boolean; isLoading: boolean;
createdAt: string; createdAt: string;
comment: string | undefined; comment: string | undefined;
isAuthor: boolean;
isAdmin: boolean;
isEdited: boolean; isEdited: boolean;
}) => { }) => {
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { openModal } = useModal();
const { handleSubmit, setValue, watch } = useForm<FormValues>({ const { handleSubmit, setValue, watch } = useForm<FormValues>({
defaultValues: { defaultValues: {
comment, comment,
@@ -66,6 +72,27 @@ const Comment = ({
}); });
}; };
const dropdownItems = [
...(isAuthor
? [
{
label: "Edit comment",
action: () => setIsEditing(true),
icon: <HiPencil className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
...(isAuthor || isAdmin
? [
{
label: "Delete comment",
action: () => openModal("DELETE_COMMENT", publicId),
icon: <HiTrash className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
];
return ( return (
<div <div
key={publicId} key={publicId}
@@ -96,19 +123,13 @@ const Comment = ({
</p> </p>
</div> </div>
<div className="absolute right-4 top-4"> {dropdownItems.length > 0 && (
<Dropdown <div className="absolute right-4 top-4">
items={[ <Dropdown items={dropdownItems}>
{ <HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />
label: "Edit comment", </Dropdown>
action: () => setIsEditing(true), </div>
icon: <HiPencil className="h-[18px] w-[18px] text-dark-900" />, )}
},
]}
>
<HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />
</Dropdown>
</div>
</div> </div>
{!isEditing ? ( {!isEditing ? (
<p className="mt-2 text-sm">{comment}</p> <p className="mt-2 text-sm">{comment}</p>

View File

@@ -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 (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this comment?
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
</p>
</div>
<div className="mt-5 flex justify-end sm:mt-6">
<button
className="mr-4 inline-flex justify-center rounded-md border-[1px] border-light-600 bg-light-50 px-3 py-2 text-sm font-semibold text-neutral-900 shadow-sm focus-visible:outline-none dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000"
onClick={() => closeModal()}
>
Cancel
</button>
<Button
onClick={handleDeleteComment}
isLoading={deleteCommentMutation.isPending}
>
Delete
</Button>
</div>
</div>
);
}

View File

@@ -19,18 +19,25 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
}, },
}); });
const queryParams = {
cardPublicId,
};
const addCommentMutation = api.card.addComment.useMutation({ const addCommentMutation = api.card.addComment.useMutation({
onSuccess: async () => { onError: (_error, _newList) => {
await utils.card.byId.refetch();
reset();
},
onError: () => {
showPopup({ showPopup({
header: "Unable to add comment", header: "Unable to add comment",
message: "Please try again later, or contact customer support.", message: "Please try again later, or contact customer support.",
icon: "error", icon: "error",
}); });
}, },
onSettled: async () => {
reset();
await utils.card.byId.invalidate(queryParams);
},
onSuccess: async () => {
await utils.card.byId.refetch();
},
}); });
const onSubmit = (data: FormValues) => { const onSubmit = (data: FormValues) => {

View File

@@ -16,6 +16,7 @@ import { formatMemberDisplayName } from "~/utils/helpers";
import { getPublicUrl } from "~/utils/supabase/getPublicUrl"; import { getPublicUrl } from "~/utils/supabase/getPublicUrl";
import ActivityList from "./components/ActivityList"; import ActivityList from "./components/ActivityList";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation"; import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
import { DeleteLabelConfirmation } from "./components/DeleteLabelConfirmation"; import { DeleteLabelConfirmation } from "./components/DeleteLabelConfirmation";
import Dropdown from "./components/Dropdown"; import Dropdown from "./components/Dropdown";
import { LabelForm } from "./components/LabelForm"; import { LabelForm } from "./components/LabelForm";
@@ -259,6 +260,12 @@ export default function CardPage() {
cardPublicId={cardId} cardPublicId={cardId}
/> />
)} )}
{modalContentType === "DELETE_COMMENT" && (
<DeleteCommentConfirmation
cardPublicId={cardId}
commentPublicId={entityId}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />} {modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal> </Modal>
</div> </div>

View File

@@ -300,6 +300,71 @@ export const cardRouter = createTRPCRouter({
return updatedComment; 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<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
.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 addOrRemoveLabel: protectedProcedure
.meta({ .meta({
openapi: { openapi: {

View File

@@ -302,6 +302,7 @@ export const getWithListAndMembersByPublicId = async (
.is("deletedAt", null) .is("deletedAt", null)
.is("list.board.lists.deletedAt", null) .is("list.board.lists.deletedAt", null)
.is("list.board.workspace.members.deletedAt", null) .is("list.board.workspace.members.deletedAt", null)
.is("activities.comment.deletedAt", null)
.order("index", { referencedTable: "list.board.lists", ascending: true }) .order("index", { referencedTable: "list.board.lists", ascending: true })
.is("members.deletedAt", null) .is("members.deletedAt", null)
.limit(1) .limit(1)

View File

@@ -61,3 +61,23 @@ export const update = async (
return data; return data;
}; };
export const softDelete = async (
db: SupabaseClient<Database>,
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;
};