feat: card comments

This commit is contained in:
Henry
2024-11-18 21:34:07 +00:00
parent e5927cd205
commit a5734cd86c
16 changed files with 3143 additions and 7 deletions

View File

@@ -11,7 +11,7 @@ const Avatar = ({
size?: "sm" | "md" | "lg";
name: string;
email: string;
icon: React.ReactNode;
icon?: React.ReactNode;
isLoading: boolean;
}) => {
const initials = name

View File

@@ -1,6 +1,16 @@
const LoadingSpinner = () => {
import { twMerge } from "tailwind-merge";
const LoadingSpinner = ({ size = "md" }: { size?: "sm" | "md" | "lg" }) => {
return (
<svg className="h-6 w-6 animate-spin" viewBox="0 0 100 100">
<svg
className={twMerge(
"animate-spin",
size === "sm" && "h-4 w-4",
size === "md" && "h-5 w-5",
size === "lg" && "h-6 w-6",
)}
viewBox="0 0 100 100"
>
<circle
fill="none"
stroke-width="10"

View File

@@ -5,6 +5,7 @@ import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import * as cardRepo from "~/server/db/repository/card.repo";
import * as cardActivityRepo from "~/server/db/repository/cardActivity.repo";
import * as cardCommentRepo from "~/server/db/repository/cardComment.repo";
import * as labelRepo from "~/server/db/repository/label.repo";
import * as listRepo from "~/server/db/repository/list.repo";
import * as workspaceRepo from "~/server/db/repository/workspace.repo";
@@ -168,6 +169,63 @@ export const cardRouter = createTRPCRouter({
return newCard;
}),
addComment: protectedProcedure
.meta({
openapi: {
summary: "Add a comment to a card",
method: "POST",
path: "/cards/{cardPublicId}/comments",
description: "Adds a comment to a card",
tags: ["Cards"],
protect: true,
},
})
.input(
z.object({
cardPublicId: z.string().min(12),
comment: z.string().min(1),
}),
)
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
.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);
if (!card)
throw new TRPCError({
message: `Card with public ID ${input.cardPublicId} not found`,
code: "NOT_FOUND",
});
const newComment = await cardCommentRepo.create(ctx.db, {
comment: input.comment,
createdBy: userId,
cardId: card.id,
});
if (!newComment?.id)
throw new TRPCError({
message: `Failed to create comment`,
code: "INTERNAL_SERVER_ERROR",
});
await cardActivityRepo.create(ctx.db, {
type: "card.updated.comment.added" as const,
cardId: card.id,
commentId: newComment.id,
toComment: newComment.comment,
createdBy: userId,
});
return newComment;
}),
addOrRemoveLabel: protectedProcedure
.meta({
openapi: {

View File

@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS "card_comments" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
"comment" text NOT NULL,
"cardId" bigint NOT NULL,
"createdBy" uuid NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp,
"deletedAt" timestamp,
"deletedBy" uuid,
CONSTRAINT "card_comments_publicId_unique" UNIQUE("publicId")
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_comments" ADD CONSTRAINT "card_comments_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "user"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,11 @@
ALTER TYPE "card_activity_type" ADD VALUE 'card.updated.comment.added';--> statement-breakpoint
ALTER TYPE "card_activity_type" ADD VALUE 'card.updated.comment.updated';--> statement-breakpoint
ALTER TYPE "card_activity_type" ADD VALUE 'card.updated.comment.deleted';--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "commentId" bigint;--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "fromComment" text;--> statement-breakpoint
ALTER TABLE "card_activity" ADD COLUMN "toComment" text;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_commentId_card_comments_id_fk" FOREIGN KEY ("commentId") REFERENCES "card_comments"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,20 @@
"when": 1730967400524,
"tag": "0006_neat_korg",
"breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1731934769875,
"tag": "0007_adorable_crystal",
"breakpoints": true
},
{
"idx": 8,
"version": "5",
"when": 1731958265600,
"tag": "0008_nasty_bloodstorm",
"breakpoints": true
}
]
}

View File

@@ -271,6 +271,11 @@ export const getWithListAndMembersByPublicId = async (
id,
name,
email
),
comment:card_comments!card_activity_commentId_card_comments_id_fk (
publicId,
comment,
createdBy
)
)
`,

View File

@@ -18,6 +18,9 @@ export const create = async (
fromDescription?: string;
toDescription?: string;
createdBy: string;
commentId?: number;
fromComment?: string;
toComment?: string;
},
) => {
const { data } = await db
@@ -37,6 +40,9 @@ export const create = async (
fromDescription: activityInput.fromDescription,
toDescription: activityInput.toDescription,
createdBy: activityInput.createdBy,
commentId: activityInput.commentId,
fromComment: activityInput.fromComment,
toComment: activityInput.toComment,
})
.select(`id`)
.limit(1)

View File

@@ -0,0 +1,26 @@
import { generateUID } from "~/utils/generateUID";
import { type Database } from "~/types/database.types";
import { type SupabaseClient } from "@supabase/supabase-js";
export const create = async (
db: SupabaseClient<Database>,
commentInput: {
cardId: number;
comment: string;
createdBy: string;
},
) => {
const { data } = await db
.from("card_comments")
.insert({
publicId: generateUID(),
comment: commentInput.comment,
createdBy: commentInput.createdBy,
cardId: commentInput.cardId,
})
.select(`id, publicId, comment`)
.limit(1)
.single();
return data;
};

View File

@@ -34,6 +34,9 @@ export const activityTypeEnum = pgEnum("card_activity_type", [
"card.updated.label.removed",
"card.updated.member.added",
"card.updated.member.removed",
"card.updated.comment.added",
"card.updated.comment.updated",
"card.updated.comment.deleted",
"card.archived",
]);
@@ -260,6 +263,7 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
fields: [cards.importId],
references: [imports.id],
}),
comments: many(comments),
}));
export const users = pgTable("user", {
@@ -362,6 +366,11 @@ export const cardActivities = pgTable("card_activity", {
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
commentId: bigint("commentId", { mode: "number" }).references(
() => comments.id,
),
fromComment: text("fromComment"),
toComment: text("toComment"),
});
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
@@ -390,3 +399,34 @@ export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
references: [users.id],
}),
}));
export const comments = pgTable("card_comments", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
comment: text("comment").notNull(),
cardId: bigint("cardId", { mode: "number" })
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
});
export const commentsRelations = relations(comments, ({ one }) => ({
card: one(cards, {
fields: [comments.cardId],
references: [cards.id],
}),
createdBy: one(users, {
fields: [comments.createdBy],
references: [users.id],
}),
deletedBy: one(users, {
fields: [comments.deletedBy],
references: [users.id],
}),
}));

View File

@@ -216,8 +216,10 @@ export type Database = {
card_activity: {
Row: {
cardId: number;
commentId: number | null;
createdAt: string;
createdBy: string;
fromComment: string | null;
fromDescription: string | null;
fromIndex: number | null;
fromListId: number | null;
@@ -225,6 +227,7 @@ export type Database = {
id: number;
labelId: number | null;
publicId: string;
toComment: string | null;
toDescription: string | null;
toIndex: number | null;
toListId: number | null;
@@ -234,8 +237,10 @@ export type Database = {
};
Insert: {
cardId: number;
commentId?: number | null;
createdAt?: string;
createdBy: string;
fromComment?: string | null;
fromDescription?: string | null;
fromIndex?: number | null;
fromListId?: number | null;
@@ -243,6 +248,7 @@ export type Database = {
id?: number;
labelId?: number | null;
publicId: string;
toComment?: string | null;
toDescription?: string | null;
toIndex?: number | null;
toListId?: number | null;
@@ -252,8 +258,10 @@ export type Database = {
};
Update: {
cardId?: number;
commentId?: number | null;
createdAt?: string;
createdBy?: string;
fromComment?: string | null;
fromDescription?: string | null;
fromIndex?: number | null;
fromListId?: number | null;
@@ -261,6 +269,7 @@ export type Database = {
id?: number;
labelId?: number | null;
publicId?: string;
toComment?: string | null;
toDescription?: string | null;
toIndex?: number | null;
toListId?: number | null;
@@ -276,6 +285,13 @@ export type Database = {
referencedRelation: "card";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_commentId_card_comments_id_fk";
columns: ["commentId"];
isOneToOne: false;
referencedRelation: "card_comments";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_activity_createdBy_user_id_fk";
columns: ["createdBy"];
@@ -313,6 +329,64 @@ export type Database = {
},
];
};
card_comments: {
Row: {
cardId: number;
comment: string;
createdAt: string;
createdBy: string;
deletedAt: string | null;
deletedBy: string | null;
id: number;
publicId: string;
updatedAt: string | null;
};
Insert: {
cardId: number;
comment: string;
createdAt?: string;
createdBy: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
publicId: string;
updatedAt?: string | null;
};
Update: {
cardId?: number;
comment?: string;
createdAt?: string;
createdBy?: string;
deletedAt?: string | null;
deletedBy?: string | null;
id?: number;
publicId?: string;
updatedAt?: string | null;
};
Relationships: [
{
foreignKeyName: "card_comments_cardId_card_id_fk";
columns: ["cardId"];
isOneToOne: false;
referencedRelation: "card";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_comments_createdBy_user_id_fk";
columns: ["createdBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
{
foreignKeyName: "card_comments_deletedBy_user_id_fk";
columns: ["deletedBy"];
isOneToOne: false;
referencedRelation: "user";
referencedColumns: ["id"];
},
];
};
import: {
Row: {
createdAt: string;
@@ -680,7 +754,10 @@ export type Database = {
| "card.updated.label.removed"
| "card.updated.member.added"
| "card.updated.member.removed"
| "card.archived";
| "card.archived"
| "card.updated.comment.added"
| "card.updated.comment.updated"
| "card.updated.comment.deleted";
member_status: "invited" | "active" | "removed";
role: "admin" | "member" | "guest";
source: "trello";

View File

@@ -144,6 +144,35 @@ const ActivityList = ({
label: activity.label?.name ?? null,
});
if (activity.type === "card.updated.comment.added")
return (
<div
key={activity.publicId}
className="relative flex flex w-full flex-col rounded-xl border border-light-600 bg-light-200 p-5 text-light-900 focus-visible:outline-none dark:border-dark-600 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<div className="flex items-center space-x-2">
<Avatar
size="sm"
name={activity.user?.name ?? ""}
email={activity.user?.email ?? ""}
isLoading={isLoading}
/>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
<span className="mx-1 text-light-900 dark:text-dark-800">
·
</span>
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
})}
</span>
</p>
</div>
<p className="mt-2 text-sm">{activity.comment?.comment}</p>
</div>
);
if (!activityText) return null;
return (
@@ -159,9 +188,11 @@ const ActivityList = ({
icon={getActivityIcon(activity.type)}
isLoading={isLoading}
/>
{index !== activities.length - 1 && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
{index !== activities.length - 1 &&
activities[index + 1]?.type !==
"card.updated.comment.added" && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>

View File

@@ -0,0 +1,72 @@
import { useForm } from "react-hook-form";
import ContentEditable from "react-contenteditable";
import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { api } from "~/utils/api";
import { usePopup } from "~/providers/popup";
interface FormValues {
comment: string;
}
const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
const { handleSubmit, setValue, watch, reset } = useForm<FormValues>({
values: {
comment: "",
},
});
const addCommentMutation = api.card.addComment.useMutation({
onSuccess: async () => {
await utils.card.byId.refetch();
reset();
},
onError: () => {
showPopup({
header: "Unable to add comment",
message: "Please try again later, or contact customer support.",
});
},
});
const onSubmit = (data: FormValues) => {
addCommentMutation.mutate({
cardPublicId,
comment: data.comment,
});
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="flex w-full flex-col rounded-xl border border-light-600 bg-light-200 p-5 text-light-900 focus-visible:outline-none dark:border-dark-600 dark:bg-dark-100 dark:text-dark-1000 sm:text-sm sm:leading-6"
>
<ContentEditable
placeholder="Add a comment..."
html={watch("comment")}
disabled={false}
onChange={(e) => setValue("comment", e.target.value)}
className="block w-full border-0 bg-transparent py-1.5 text-light-900 focus-visible:outline-none dark:text-dark-1000 sm:text-sm sm:leading-6"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={addCommentMutation.isPending}
className="flex h-8 w-8 items-center justify-center rounded-full border border-light-600 bg-light-300 hover:bg-light-400 disabled:opacity-50 dark:border-dark-600 dark:bg-dark-300 dark:hover:bg-dark-400"
>
{addCommentMutation.isPending ? (
<LoadingSpinner size="sm" />
) : (
<HiOutlineArrowUp />
)}
</button>
</div>
</form>
);
};
export default NewCommentForm;

View File

@@ -13,6 +13,7 @@ import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
import { LabelForm } from "./components/LabelForm";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import NewCommentForm from "./components/NewCommentForm";
import Modal from "~/components/modal";
import { useModal } from "~/providers/modal";
@@ -179,6 +180,9 @@ export default function CardPage() {
isLoading={isLoading}
/>
</div>
<div className="mt-6">
<NewCommentForm cardPublicId={cardId} />
</div>
</div>
</div>
</div>