From e43fa0c7b3f786a586e70937bf912d7607d8134f Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 6 Aug 2025 22:22:28 +0100 Subject: [PATCH] feat: add create new checklist modal --- apps/web/src/components/CircularProgress.tsx | 58 ++++++++ .../src/views/card/components/Dropdown.tsx | 13 +- .../card/components/NewChecklistForm.tsx | 126 ++++++++++++++++++ apps/web/src/views/card/index.tsx | 38 ++++++ packages/api/src/root.ts | 2 + .../routers/{checklists.ts => checklist.ts} | 10 +- ...sql => 20250806202106_glossy_luminals.sql} | 2 +- ...shot.json => 20250806202106_snapshot.json} | 6 +- packages/db/migrations/meta/_journal.json | 6 +- packages/db/src/repository/card.repo.ts | 19 +++ packages/db/src/repository/checklist.repo.ts | 41 ++++++ packages/db/src/schema/checklists.ts | 2 +- 12 files changed, 309 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/components/CircularProgress.tsx create mode 100644 apps/web/src/views/card/components/NewChecklistForm.tsx rename packages/api/src/routers/{checklists.ts => checklist.ts} (94%) rename packages/db/migrations/{20250715123447_AddCardChecklists.sql => 20250806202106_glossy_luminals.sql} (99%) rename packages/db/migrations/meta/{20250715123447_snapshot.json => 20250806202106_snapshot.json} (99%) create mode 100644 packages/db/src/repository/checklist.repo.ts diff --git a/apps/web/src/components/CircularProgress.tsx b/apps/web/src/components/CircularProgress.tsx new file mode 100644 index 00000000..b2690746 --- /dev/null +++ b/apps/web/src/components/CircularProgress.tsx @@ -0,0 +1,58 @@ +import { twMerge } from "tailwind-merge"; + +interface CircularProgressProps { + progress: number; // 0-100 + size?: "sm" | "md" | "lg"; + className?: string; +} + +const CircularProgress = ({ + progress, + size = "md", + className, +}: CircularProgressProps) => { + const radius = 40; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference - (progress / 100) * circumference; + + return ( +
+ + + + +
+ ); +}; + +export default CircularProgress; diff --git a/apps/web/src/views/card/components/Dropdown.tsx b/apps/web/src/views/card/components/Dropdown.tsx index 6aa19239..938c2f36 100644 --- a/apps/web/src/views/card/components/Dropdown.tsx +++ b/apps/web/src/views/card/components/Dropdown.tsx @@ -1,5 +1,9 @@ import { t } from "@lingui/core/macro"; -import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2"; +import { + HiEllipsisHorizontal, + HiOutlineCheckCircle, + HiOutlineTrash, +} from "react-icons/hi2"; import Dropdown from "~/components/Dropdown"; import { useModal } from "~/providers/modal"; @@ -10,6 +14,13 @@ export default function BoardDropdown() { return ( openModal("ADD_CHECKLIST"), + icon: ( + + ), + }, { label: t`Delete card`, action: () => openModal("DELETE_CARD"), diff --git a/apps/web/src/views/card/components/NewChecklistForm.tsx b/apps/web/src/views/card/components/NewChecklistForm.tsx new file mode 100644 index 00000000..e76ed4b5 --- /dev/null +++ b/apps/web/src/views/card/components/NewChecklistForm.tsx @@ -0,0 +1,126 @@ +import { t } from "@lingui/core/macro"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { HiXMark } from "react-icons/hi2"; + +import { generateUID } from "@kan/shared/utils"; + +import Button from "~/components/Button"; +import Input from "~/components/Input"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface NewChecklistFormInput { + name: string; + cardPublicId: string; +} + +export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) { + const { closeModal } = useModal(); + const { showPopup } = usePopup(); + + const utils = api.useUtils(); + + const { register, handleSubmit, reset, setValue, watch } = + useForm({ + defaultValues: { + name: "Checklist", + cardPublicId, + }, + }); + + const createChecklist = api.checklist.create.useMutation({ + onMutate: async (args) => { + // await utils.board.byId.cancel(); + // const currentState = utils.board.byId.getData(queryParams); + // utils.board.byId.setData(queryParams, (oldBoard) => { + // if (!oldBoard) return oldBoard; + // const newList = { + // publicId: generateUID(), + // name: args.name, + // boardId: 1, + // boardPublicId, + // cards: [], + // index: oldBoard.lists.length, + // }; + // const updatedLists = [...oldBoard.lists, newList]; + // return { ...oldBoard, lists: updatedLists }; + // }); + // return { previousState: currentState }; + }, + onError: (_error, _newList, context) => { + // utils.board.byId.setData(queryParams, context?.previousState); + showPopup({ + header: t`Unable to create checklist`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + // onSettled: async () => { + // await utils.board.byId.invalidate(queryParams); + // }, + }); + + useEffect(() => { + const nameElement: HTMLElement | null = + document.querySelector("#checklist-name"); + if (nameElement) nameElement.focus(); + }, []); + + const onSubmit = (data: NewChecklistFormInput) => { + closeModal(); + reset({ + name: "", + }); + + createChecklist.mutate({ + name: data.name, + cardPublicId: data.cardPublicId, + }); + }; + + return ( +
+
+
+

+ {t`New checklist`} +

+ +
+ + { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + /> +
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index 06024832..ff31460d 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -5,6 +5,7 @@ import { useForm } from "react-hook-form"; import { IoChevronForwardSharp } from "react-icons/io5"; import Avatar from "~/components/Avatar"; +import CircularProgress from "~/components/CircularProgress"; import Editor from "~/components/Editor"; import FeedbackModal from "~/components/FeedbackModal"; import { LabelForm } from "~/components/LabelForm"; @@ -25,6 +26,7 @@ import Dropdown from "./components/Dropdown"; import LabelSelector from "./components/LabelSelector"; import ListSelector from "./components/ListSelector"; import MemberSelector from "./components/MemberSelector"; +import { NewChecklistForm } from "./components/NewChecklistForm"; import NewCommentForm from "./components/NewCommentForm"; interface FormValues { @@ -251,6 +253,39 @@ export default function CardPage() { + {card.checklists.length > 0 && ( +
+
+ {card.checklists.map((checklist) => { + const completedItems = checklist.items.filter( + (item) => item.completed, + ); + const progress = + checklist.items.length > 0 + ? (completedItems.length / checklist.items.length) * + 100 + : 2; + + return ( +
+ {checklist.name} + + + {completedItems.length}/{checklist.items.length} + +
+ ); + })} +
+
+ )}

{t`Activity`} @@ -303,6 +338,9 @@ export default function CardPage() { /> )} {modalContentType === "NEW_WORKSPACE" && } + {modalContentType === "ADD_CHECKLIST" && ( + + )}

diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 8005995d..f106930b 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,5 +1,6 @@ import { boardRouter } from "./routers/board"; import { cardRouter } from "./routers/card"; +import { checklistRouter } from "./routers/checklist"; import { feedbackRouter } from "./routers/feedback"; import { importRouter } from "./routers/import"; import { integrationRouter } from "./routers/integration"; @@ -13,6 +14,7 @@ import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ board: boardRouter, card: cardRouter, + checklist: checklistRouter, feedback: feedbackRouter, label: labelRouter, list: listRouter, diff --git a/packages/api/src/routers/checklists.ts b/packages/api/src/routers/checklist.ts similarity index 94% rename from packages/api/src/routers/checklists.ts rename to packages/api/src/routers/checklist.ts index a3aac701..cece89a4 100644 --- a/packages/api/src/routers/checklists.ts +++ b/packages/api/src/routers/checklist.ts @@ -8,8 +8,8 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; import { assertUserInWorkspace } from "../utils/auth"; const checklistSchema = z.object({ - publicId: z.string(), - title: z.string(), + publicId: z.string().length(12), + name: z.string().min(1).max(255), }); export const checklistRouter = createTRPCRouter({ @@ -26,8 +26,8 @@ export const checklistRouter = createTRPCRouter({ }) .input( z.object({ - cardPublicId: z.string().min(12), - title: z.string().min(1), + cardPublicId: z.string().length(12), + name: z.string().min(1).max(255), }), ) .output(checklistSchema) @@ -54,7 +54,7 @@ export const checklistRouter = createTRPCRouter({ await assertUserInWorkspace(ctx.db, userId, card.workspaceId); const newChecklist = await checklistRepo.create(ctx.db, { - title: input.title, + name: input.name, createdBy: userId, cardId: card.id, }); diff --git a/packages/db/migrations/20250715123447_AddCardChecklists.sql b/packages/db/migrations/20250806202106_glossy_luminals.sql similarity index 99% rename from packages/db/migrations/20250715123447_AddCardChecklists.sql rename to packages/db/migrations/20250806202106_glossy_luminals.sql index 25fcea2e..f4d0e38a 100644 --- a/packages/db/migrations/20250715123447_AddCardChecklists.sql +++ b/packages/db/migrations/20250806202106_glossy_luminals.sql @@ -17,7 +17,7 @@ ALTER TABLE "card_checklist_item" ENABLE ROW LEVEL SECURITY;--> statement-breakp CREATE TABLE IF NOT EXISTS "card_checklist" ( "id" bigserial PRIMARY KEY NOT NULL, "publicId" varchar(12) NOT NULL, - "title" varchar(255) NOT NULL, + "name" varchar(255) NOT NULL, "index" integer NOT NULL, "cardId" bigint NOT NULL, "createdBy" uuid, diff --git a/packages/db/migrations/meta/20250715123447_snapshot.json b/packages/db/migrations/meta/20250806202106_snapshot.json similarity index 99% rename from packages/db/migrations/meta/20250715123447_snapshot.json rename to packages/db/migrations/meta/20250806202106_snapshot.json index 535c305b..f4257c76 100644 --- a/packages/db/migrations/meta/20250715123447_snapshot.json +++ b/packages/db/migrations/meta/20250806202106_snapshot.json @@ -1,5 +1,5 @@ { - "id": "0b7ca39b-1114-4c0d-8e55-d932f40706ba", + "id": "957fcb16-856c-4756-ac3b-13f2a0814959", "prevId": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501", "version": "7", "dialect": "postgresql", @@ -1344,8 +1344,8 @@ "primaryKey": false, "notNull": true }, - "title": { - "name": "title", + "name": { + "name": "name", "type": "varchar(255)", "primaryKey": false, "notNull": true diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 1b68777e..5dd0d430 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -54,9 +54,9 @@ { "idx": 7, "version": "7", - "when": 1752582887553, - "tag": "20250715123447_AddCardChecklists", + "when": 1754511666265, + "tag": "20250806202106_glossy_luminals", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index 32948552..34bbaa1b 100644 --- a/packages/db/src/repository/card.repo.ts +++ b/packages/db/src/repository/card.repo.ts @@ -6,6 +6,7 @@ import { cards, cardsToLabels, cardToWorkspaceMembers, + checklists, labels, lists, workspaceMembers, @@ -300,6 +301,24 @@ export const getWithListAndMembersByPublicId = async ( }, }, }, + checklists: { + columns: { + publicId: true, + name: true, + index: true, + }, + where: isNull(checklists.deletedAt), + with: { + items: { + columns: { + publicId: true, + title: true, + completed: true, + index: true, + }, + }, + }, + }, list: { columns: { publicId: true, diff --git a/packages/db/src/repository/checklist.repo.ts b/packages/db/src/repository/checklist.repo.ts new file mode 100644 index 00000000..1677e095 --- /dev/null +++ b/packages/db/src/repository/checklist.repo.ts @@ -0,0 +1,41 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; + +import type { dbClient } from "@kan/db/client"; +import { checklists } from "@kan/db/schema"; +import { generateUID } from "@kan/shared/utils"; + +export const create = async ( + db: dbClient, + checklistInput: { + cardId: number; + name: string; + createdBy: string; + }, +) => { + return db.transaction(async (tx) => { + const card = await tx.query.checklists.findFirst({ + where: and( + eq(checklists.cardId, checklistInput.cardId), + isNull(checklists.deletedAt), + ), + orderBy: desc(checklists.index), + }); + + const [result] = await tx + .insert(checklists) + .values({ + publicId: generateUID(), + name: checklistInput.name, + createdBy: checklistInput.createdBy, + cardId: checklistInput.cardId, + index: card ? card.index + 1 : 0, + }) + .returning({ + id: checklists.id, + publicId: checklists.publicId, + name: checklists.name, + }); + + return result; + }); +}; diff --git a/packages/db/src/schema/checklists.ts b/packages/db/src/schema/checklists.ts index dbcd0d8e..b31d730c 100644 --- a/packages/db/src/schema/checklists.ts +++ b/packages/db/src/schema/checklists.ts @@ -16,7 +16,7 @@ import { users } from "./users"; export const checklists = pgTable("card_checklist", { id: bigserial("id", { mode: "number" }).primaryKey(), publicId: varchar("publicId", { length: 12 }).notNull().unique(), - title: varchar("title", { length: 255 }).notNull(), + name: varchar("name", { length: 255 }).notNull(), index: integer("index").notNull(), cardId: bigint("cardId", { mode: "number" }) .notNull()