From 64d9d51e59a7b13b6d163ac81bb70efa844d2dd5 Mon Sep 17 00:00:00 2001 From: Henry Date: Mon, 11 Aug 2025 22:09:44 +0100 Subject: [PATCH] feat: delete checklist --- .../card/components/ChecklistItemRow.tsx | 14 ++-- .../DeleteChecklistConfirmation.tsx | 71 +++++++++++++++++++ apps/web/src/views/card/index.tsx | 19 ++++- packages/api/src/routers/checklist.ts | 57 +++++++++++++++ packages/db/src/repository/checklist.repo.ts | 31 ++++++++ 5 files changed, 185 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/views/card/components/DeleteChecklistConfirmation.tsx diff --git a/apps/web/src/views/card/components/ChecklistItemRow.tsx b/apps/web/src/views/card/components/ChecklistItemRow.tsx index 0ee7da32..210a2766 100644 --- a/apps/web/src/views/card/components/ChecklistItemRow.tsx +++ b/apps/web/src/views/card/components/ChecklistItemRow.tsx @@ -22,6 +22,9 @@ export default function ChecklistItemRow({ const utils = api.useUtils(); const { showPopup } = usePopup(); + const [title, setTitle] = useState(""); + const [completed, setCompleted] = useState(false); + const updateItem = api.checklist.updateItem.useMutation({ onMutate: async (vars) => { await utils.card.byId.cancel({ cardPublicId }); @@ -88,9 +91,6 @@ export default function ChecklistItemRow({ }, }); - const [title, setTitle] = useState(item.title); - const [completed, setCompleted] = useState(item.completed); - // Only resync from props when switching items to avoid clobbering edits useEffect(() => { setTitle(item.title); @@ -116,7 +116,10 @@ export default function ChecklistItemRow({ const commitTitle = (rawHtml: string) => { const plain = sanitizeHtmlToPlainText(rawHtml); - if (!plain || plain === item.title) return; + if (!plain || plain === item.title) { + setTitle(item.title); + return; + } setTitle(plain); updateItem.mutate({ checklistItemPublicId: item.publicId, @@ -142,7 +145,8 @@ export default function ChecklistItemRow({ setTitle(e.target.value)} - onBlur={() => commitTitle(title)} + // @ts-expect-error - valid event + onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)} className="m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-900 outline-none focus-visible:outline-none dark:text-dark-950" placeholder={t`Add details...`} onKeyDown={(e) => { diff --git a/apps/web/src/views/card/components/DeleteChecklistConfirmation.tsx b/apps/web/src/views/card/components/DeleteChecklistConfirmation.tsx new file mode 100644 index 00000000..9b5ba864 --- /dev/null +++ b/apps/web/src/views/card/components/DeleteChecklistConfirmation.tsx @@ -0,0 +1,71 @@ +import { t } from "@lingui/core/macro"; + +import Button from "~/components/Button"; +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +export function DeleteChecklistConfirmation({ + cardPublicId, + checklistPublicId, +}: { + cardPublicId: string; + checklistPublicId: string; +}) { + const { closeModal } = useModal(); + const { showPopup } = usePopup(); + const utils = api.useUtils(); + + const deleteChecklist = api.checklist.delete.useMutation({ + onMutate: async () => { + await utils.card.byId.cancel({ cardPublicId }); + const previous = utils.card.byId.getData({ cardPublicId }); + utils.card.byId.setData({ cardPublicId }, (old) => { + if (!old) return old as any; + const updatedChecklists = old.checklists.filter( + (cl) => cl.publicId !== checklistPublicId, + ); + return { ...old, checklists: updatedChecklists } as typeof old; + }); + return { previous }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.previous) + utils.card.byId.setData({ cardPublicId }, ctx.previous); + showPopup({ + header: t`Unable to delete checklist`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + onSettled: async () => { + closeModal(); + await utils.card.byId.invalidate({ cardPublicId }); + }, + }); + + const handleDelete = () => { + deleteChecklist.mutate({ checklistPublicId }); + }; + + return ( +
+
+

+ {t`Are you sure you want to delete this checklist?`} +

+

+ {t`This action can't be undone.`} +

+
+
+ + +
+
+ ); +} diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index f69e83fd..6cd3dd6f 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -25,6 +25,7 @@ import ActivityList from "./components/ActivityList"; import ChecklistItemRow from "./components/ChecklistItemRow"; import ChecklistNameInput from "./components/ChecklistNameInput"; import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation"; +import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation"; import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation"; import Dropdown from "./components/Dropdown"; import LabelSelector from "./components/LabelSelector"; @@ -139,7 +140,7 @@ export function CardRightPanel() { export default function CardPage() { const router = useRouter(); const utils = api.useUtils(); - const { modalContentType, entityId } = useModal(); + const { modalContentType, entityId, openModal } = useModal(); const { showPopup } = usePopup(); const { workspace } = useWorkspace(); const [activeChecklistForm, setActiveChecklistForm] = useState( @@ -298,7 +299,15 @@ export default function CardPage() {
-
diff --git a/packages/api/src/routers/checklist.ts b/packages/api/src/routers/checklist.ts index 3de4f28c..8574f209 100644 --- a/packages/api/src/routers/checklist.ts +++ b/packages/api/src/routers/checklist.ts @@ -118,6 +118,63 @@ export const checklistRouter = createTRPCRouter({ return updated; }), + delete: protectedProcedure + .meta({ + openapi: { + summary: "Delete a checklist", + method: "DELETE", + path: "/checklists/{checklistPublicId}", + description: "Deletes a checklist by its public ID", + tags: ["Cards"], + protect: true, + }, + }) + .input(z.object({ checklistPublicId: z.string().length(12) })) + .output(z.object({ success: z.boolean() })) + .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, + ); + + await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, { + checklistId: checklist.id, + deletedAt: new Date(), + deletedBy: userId, + }); + + const deleted = await checklistRepo.softDeleteById(ctx.db, { + id: checklist.id, + deletedAt: new Date(), + deletedBy: userId, + }); + + if (!deleted) + throw new TRPCError({ + message: `Failed to delete checklist`, + code: "INTERNAL_SERVER_ERROR", + }); + + return { success: true }; + }), createItem: protectedProcedure .meta({ openapi: { diff --git a/packages/db/src/repository/checklist.repo.ts b/packages/db/src/repository/checklist.repo.ts index b3fd9ac3..18e481de 100644 --- a/packages/db/src/repository/checklist.repo.ts +++ b/packages/db/src/repository/checklist.repo.ts @@ -172,6 +172,37 @@ export const softDeleteItemById = async ( return result; }; +export const softDeleteAllItemsByChecklistId = async ( + db: dbClient, + args: { checklistId: number; deletedAt: Date; deletedBy: string }, +) => { + const result = await db + .update(checklistItems) + .set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy }) + .where( + and( + eq(checklistItems.checklistId, args.checklistId), + isNull(checklistItems.deletedAt), + ), + ) + .returning({ id: checklistItems.id }); + + return result; +}; + +export const softDeleteById = async ( + db: dbClient, + args: { id: number; deletedAt: Date; deletedBy: string }, +) => { + const [result] = await db + .update(checklists) + .set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy }) + .where(eq(checklists.id, args.id)) + .returning({ id: checklists.id }); + + return result; +}; + export const updateChecklistById = async ( db: dbClient, args: { id: number; name: string },