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;
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]);

View File

@@ -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 (
<div className="flex flex-col space-y-4 pt-4">
{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"}
/>
);

View File

@@ -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<FormValues>({
defaultValues: {
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 (
<div
key={publicId}
@@ -96,19 +123,13 @@ const Comment = ({
</p>
</div>
<div className="absolute right-4 top-4">
<Dropdown
items={[
{
label: "Edit comment",
action: () => setIsEditing(true),
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>
{dropdownItems.length > 0 && (
<div className="absolute right-4 top-4">
<Dropdown items={dropdownItems}>
<HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />
</Dropdown>
</div>
)}
</div>
{!isEditing ? (
<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({
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) => {

View File

@@ -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" && (
<DeleteCommentConfirmation
cardPublicId={cardId}
commentPublicId={entityId}
/>
)}
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
</Modal>
</div>

View File

@@ -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<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
.meta({
openapi: {

View File

@@ -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)

View File

@@ -61,3 +61,23 @@ export const update = async (
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;
};