From a4cc922d054aeea20338e36aab8dbd2362c77f7c Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 8 Aug 2025 22:02:01 +0100 Subject: [PATCH] feat: create new checklist item --- apps/web/src/components/Button.tsx | 3 +- apps/web/src/components/CircularProgress.tsx | 6 +- .../card/components/NewChecklistItemForm.tsx | 103 ++++++++++++++++++ apps/web/src/views/card/index.tsx | 63 ++++++++--- packages/api/src/routers/checklist.ts | 64 +++++++++++ packages/db/src/repository/checklist.repo.ts | 69 +++++++++++- 6 files changed, 289 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/views/card/components/NewChecklistItemForm.tsx diff --git a/apps/web/src/components/Button.tsx b/apps/web/src/components/Button.tsx index 8034fe9f..d2331525 100644 --- a/apps/web/src/components/Button.tsx +++ b/apps/web/src/components/Button.tsx @@ -5,7 +5,7 @@ import LoadingSpinner from "./LoadingSpinner"; interface ButtonProps extends React.ButtonHTMLAttributes { variant?: "primary" | "secondary" | "danger" | "ghost"; - size?: "sm" | "md" | "lg"; + size?: "xs" | "sm" | "md" | "lg"; isLoading?: boolean; iconLeft?: React.ReactNode; iconRight?: React.ReactNode; @@ -28,6 +28,7 @@ const Button = ({ }: ButtonProps) => { const classes = twMerge( "inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none", + size === "xs" && "text-xs px-2 py-1", size === "sm" && "text-xs", size === "lg" && "py-[0.65rem]", fullWidth && "w-full", diff --git a/apps/web/src/components/CircularProgress.tsx b/apps/web/src/components/CircularProgress.tsx index b2690746..a25fc8c0 100644 --- a/apps/web/src/components/CircularProgress.tsx +++ b/apps/web/src/components/CircularProgress.tsx @@ -32,8 +32,8 @@ const CircularProgress = ({ r={radius} fill="none" stroke="currentColor" - strokeWidth="8" - className="text-light-300 dark:text-dark-300" + strokeWidth="14" + className="text-light-300 dark:text-dark-400" /> void; +} + +const NewChecklistItemForm = ({ + checklistPublicId, + cardPublicId, + onCancel, +}: NewChecklistItemFormProps) => { + const utils = api.useUtils(); + const { showPopup } = usePopup(); + + const { handleSubmit, setValue, watch, reset } = useForm({ + defaultValues: { + title: "", + }, + }); + + const title = watch("title"); + + const addChecklistItemMutation = api.checklist.createItem.useMutation({ + onError: (_error, _newItem) => { + showPopup({ + header: t`Unable to add checklist item`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + onSettled: async () => { + await utils.card.byId.invalidate({ cardPublicId }); + }, + onSuccess: async () => { + reset(); + await utils.card.byId.refetch({ cardPublicId }); + }, + }); + + const onSubmit = (data: FormValues) => { + addChecklistItemMutation.mutate({ + checklistPublicId, + title: data.title, + }); + }; + + return ( +
+
+ setValue("title", 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" + onKeyDown={async (e) => { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + /> +
+ +
+
+ + +
+
+
+ ); +}; + +export default NewChecklistItemForm; diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index ff31460d..cfbb09d8 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -1,7 +1,9 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { t } from "@lingui/core/macro"; +import { useState } from "react"; import { useForm } from "react-hook-form"; +import { HiPlus, HiXMark } from "react-icons/hi2"; import { IoChevronForwardSharp } from "react-icons/io5"; import Avatar from "~/components/Avatar"; @@ -27,6 +29,7 @@ import LabelSelector from "./components/LabelSelector"; import ListSelector from "./components/ListSelector"; import MemberSelector from "./components/MemberSelector"; import { NewChecklistForm } from "./components/NewChecklistForm"; +import NewChecklistItemForm from "./components/NewChecklistItemForm"; import NewCommentForm from "./components/NewCommentForm"; interface FormValues { @@ -137,6 +140,9 @@ export default function CardPage() { const { modalContentType, entityId } = useModal(); const { showPopup } = usePopup(); const { workspace } = useWorkspace(); + const [activeChecklistForm, setActiveChecklistForm] = useState( + null, + ); const cardId = Array.isArray(router.query.cardId) ? router.query.cardId[0] @@ -261,25 +267,54 @@ export default function CardPage() { (item) => item.completed, ); const progress = - checklist.items.length > 0 + checklist.items.length > 0 && + completedItems.length > 0 ? (completedItems.length / checklist.items.length) * 100 : 2; + console.log({ checklist }); + return ( -
- {checklist.name} - - - {completedItems.length}/{checklist.items.length} - +
+
+
+ {checklist.name} +
+
+
+ + + {completedItems.length}/ + {checklist.items.length} + +
+
+ + +
+
+
+ {activeChecklistForm === checklist.publicId && ( + setActiveChecklistForm(null)} + /> + )}
); })} diff --git a/packages/api/src/routers/checklist.ts b/packages/api/src/routers/checklist.ts index cece89a4..cf91744a 100644 --- a/packages/api/src/routers/checklist.ts +++ b/packages/api/src/routers/checklist.ts @@ -12,6 +12,12 @@ const checklistSchema = z.object({ name: z.string().min(1).max(255), }); +const checklistItemSchema = z.object({ + publicId: z.string().length(12), + title: z.string().min(1).max(500), + completed: z.boolean(), +}); + export const checklistRouter = createTRPCRouter({ create: protectedProcedure .meta({ @@ -75,6 +81,64 @@ export const checklistRouter = createTRPCRouter({ return newChecklist; }), + createItem: protectedProcedure + .meta({ + openapi: { + summary: "Add an item to a checklist", + method: "POST", + path: "/checklists/{checklistPublicId}/items", + description: "Adds an item to a checklist", + tags: ["Cards"], + protect: true, + }, + }) + .input( + z.object({ + checklistPublicId: z.string().length(12), + title: z.string().min(1).max(500), + }), + ) + .output(checklistItemSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const checklist = await checklistRepo.getChecklistByPublicId( + ctx.db, + input.checklistPublicId, + ); + + if (!checklist) + throw new TRPCError({ + message: `Checklist with public ID ${input.checklistPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace( + ctx.db, + userId, + checklist.card.list.board.workspace.id, + ); + + const newChecklistItem = await checklistRepo.createItem(ctx.db, { + title: input.title, + createdBy: userId, + checklistId: checklist.id, + }); + + if (!newChecklistItem?.id) + throw new TRPCError({ + message: `Failed to create checklist item`, + code: "INTERNAL_SERVER_ERROR", + }); + + return newChecklistItem; + }), // update: protectedProcedure // .meta({ // openapi: { diff --git a/packages/db/src/repository/checklist.repo.ts b/packages/db/src/repository/checklist.repo.ts index 1677e095..a5f398a9 100644 --- a/packages/db/src/repository/checklist.repo.ts +++ b/packages/db/src/repository/checklist.repo.ts @@ -1,7 +1,7 @@ import { and, desc, eq, isNull } from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; -import { checklists } from "@kan/db/schema"; +import { checklistItems, checklists } from "@kan/db/schema"; import { generateUID } from "@kan/shared/utils"; export const create = async ( @@ -39,3 +39,70 @@ export const create = async ( return result; }); }; + +export const createItem = async ( + db: dbClient, + checklistItemInput: { + checklistId: number; + title: string; + createdBy: string; + }, +) => { + return db.transaction(async (tx) => { + const lastItem = await tx.query.checklistItems.findFirst({ + where: and( + eq(checklistItems.checklistId, checklistItemInput.checklistId), + isNull(checklistItems.deletedAt), + ), + orderBy: desc(checklistItems.index), + }); + + const [result] = await tx + .insert(checklistItems) + .values({ + publicId: generateUID(), + title: checklistItemInput.title, + createdBy: checklistItemInput.createdBy, + checklistId: checklistItemInput.checklistId, + index: lastItem ? lastItem.index + 1 : 0, + completed: false, + }) + .returning({ + id: checklistItems.id, + publicId: checklistItems.publicId, + title: checklistItems.title, + completed: checklistItems.completed, + }); + + return result; + }); +}; + +export const getChecklistByPublicId = async ( + db: dbClient, + checklistPublicId: string, +) => { + const checklist = await db.query.checklists.findFirst({ + where: and( + eq(checklists.publicId, checklistPublicId), + isNull(checklists.deletedAt), + ), + with: { + card: { + with: { + list: { + with: { + board: { + with: { + workspace: true, + }, + }, + }, + }, + }, + }, + }, + }); + + return checklist; +};