diff --git a/apps/web/src/views/card/components/ChecklistItemRow.tsx b/apps/web/src/views/card/components/ChecklistItemRow.tsx new file mode 100644 index 00000000..e29132a0 --- /dev/null +++ b/apps/web/src/views/card/components/ChecklistItemRow.tsx @@ -0,0 +1,172 @@ +import { t } from "@lingui/core/macro"; +import { useEffect, useState } from "react"; +import ContentEditable from "react-contenteditable"; +import { HiXMark } from "react-icons/hi2"; + +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; + +interface ChecklistItemRowProps { + item: { + publicId: string; + title: string; + completed: boolean; + }; + cardPublicId: string; +} + +export default function ChecklistItemRow({ + item, + cardPublicId, +}: ChecklistItemRowProps) { + const utils = api.useUtils(); + const { showPopup } = usePopup(); + + const updateItem = api.checklist.updateItem.useMutation({ + onMutate: async (vars) => { + 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.map((cl) => ({ + ...cl, + items: cl.items.map((ci) => + ci.publicId === item.publicId + ? { + ...ci, + ...(vars.title !== undefined ? { title: vars.title } : {}), + ...(vars.completed !== undefined + ? { completed: vars.completed } + : {}), + } + : ci, + ), + })); + 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 update checklist item`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + onSettled: async () => { + await utils.card.byId.invalidate({ cardPublicId }); + }, + }); + + const deleteItem = api.checklist.deleteItem.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.map((cl) => ({ + ...cl, + items: cl.items.filter((ci) => ci.publicId !== item.publicId), + })); + 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 item`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + onSettled: async () => { + await utils.card.byId.invalidate({ cardPublicId }); + }, + }); + + const [title, setTitle] = useState(item.title); + const [completed, setCompleted] = useState(item.completed); + + useEffect(() => { + setTitle(item.title); + setCompleted(item.completed); + }, [item.publicId, item.title, item.completed]); + + const handleToggleCompleted = () => { + setCompleted((prev) => !prev); + updateItem.mutate({ + checklistItemPublicId: item.publicId, + completed: !completed, + }); + }; + + const commitTitle = async () => { + const trimmed = title.trim(); + if (!trimmed || trimmed === item.title) return; + updateItem.mutate({ + checklistItemPublicId: item.publicId, + title: trimmed, + }); + }; + + const handleDelete = () => { + deleteItem.mutate({ checklistItemPublicId: item.publicId }); + }; + + return ( +
+ +
+ setTitle(e.target.value)} + onBlur={commitTitle} + className="m-0 min-h-[20px] w-full p-0 text-[14px] leading-[20px] text-light-900 outline-none focus-visible:outline-none dark:text-dark-1000" + placeholder={t`Add details...`} + onKeyDown={async (e) => { + if (e.key === "Enter") { + e.preventDefault(); + await commitTitle(); + } + if (e.key === "Escape") { + e.preventDefault(); + setTitle(item.title); + } + }} + /> +
+ +
+ ); +} diff --git a/apps/web/src/views/card/components/NewChecklistItemForm.tsx b/apps/web/src/views/card/components/NewChecklistItemForm.tsx index 539544c9..04511fa5 100644 --- a/apps/web/src/views/card/components/NewChecklistItemForm.tsx +++ b/apps/web/src/views/card/components/NewChecklistItemForm.tsx @@ -1,8 +1,10 @@ import { t } from "@lingui/core/macro"; +import { useEffect, useRef } from "react"; import ContentEditable from "react-contenteditable"; import { useForm } from "react-hook-form"; -import Button from "~/components/Button"; +import { generateUID } from "@kan/shared/utils"; + import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; @@ -24,7 +26,7 @@ const NewChecklistItemForm = ({ const utils = api.useUtils(); const { showPopup } = usePopup(); - const { handleSubmit, setValue, watch, reset } = useForm({ + const { setValue, watch, reset, getValues } = useForm({ defaultValues: { title: "", }, @@ -32,8 +34,58 @@ const NewChecklistItemForm = ({ const title = watch("title"); + const editableRef = useRef(null); + const keepOpenRef = useRef(false); + + const refocusEditable = () => { + const el = editableRef.current; + if (!el) return; + setTimeout(() => { + el.focus(); + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + sel.addRange(range); + } + }, 0); + }; + const addChecklistItemMutation = api.checklist.createItem.useMutation({ - onError: (_error, _newItem) => { + onMutate: async (vars) => { + 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 placeholder = { + publicId: `PLACEHOLDER_${generateUID()}`, + title: vars.title, + completed: false, + }; + const updatedChecklists = old.checklists.map((cl) => + cl.publicId === checklistPublicId + ? { ...cl, items: [...cl.items, placeholder] } + : cl, + ); + return { ...old, checklists: updatedChecklists } as typeof old; + }); + + if (keepOpenRef.current) { + reset({ title: "" }); + if (editableRef.current) editableRef.current.innerHTML = ""; + refocusEditable(); + } else { + onCancel(); + } + + return { previous }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.previous) + utils.card.byId.setData({ cardPublicId }, ctx.previous); showPopup({ header: t`Unable to add checklist item`, message: t`Please try again later, or contact customer support.`, @@ -43,57 +95,81 @@ const NewChecklistItemForm = ({ onSettled: async () => { await utils.card.byId.invalidate({ cardPublicId }); }, - onSuccess: async () => { - reset(); - await utils.card.byId.refetch({ cardPublicId }); - }, }); - const onSubmit = (data: FormValues) => { + const sanitizeHtmlToPlainText = (html: string): string => { + return html + .replace(/(\n)?/gi, "\n") + .replace(/
<\/div>/gi, "") + .replace(/<[^>]*>/g, "") + .replace(/ /g, " ") + .trim(); + }; + + const submitIfNotEmpty = (keepOpen: boolean) => { + keepOpenRef.current = keepOpen; + const currentHtml = getValues("title") ?? ""; + const plain = sanitizeHtmlToPlainText(currentHtml); + if (!plain) { + onCancel(); + return; + } addChecklistItemMutation.mutate({ checklistPublicId, - title: data.title, + title: plain, }); }; - 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(); - } - }} - /> -
+ useEffect(() => { + refocusEditable(); + }, []); -
-
- - + + + +
+ setValue("title", e.target.value)} + className="m-0 min-h-[20px] w-full p-0 text-sm leading-5 text-light-900 outline-none focus-visible:outline-none dark:text-dark-1000" + onBlur={() => submitIfNotEmpty(false)} + onKeyDown={async (e) => { + if (e.key === "Enter") { + e.preventDefault(); + submitIfNotEmpty(true); + } + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + } + }} + innerRef={(el) => { + editableRef.current = (el as unknown as HTMLElement) ?? null; + }} + />
diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index cfbb09d8..55fbdeb4 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -22,6 +22,7 @@ import { api } from "~/utils/api"; import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers"; import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation"; import ActivityList from "./components/ActivityList"; +import ChecklistItemRow from "./components/ChecklistItemRow"; import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation"; import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation"; import Dropdown from "./components/Dropdown"; @@ -273,11 +274,9 @@ export default function CardPage() { 100 : 2; - console.log({ checklist }); - return ( -
-
+
+
{checklist.name}
@@ -308,12 +307,29 @@ export default function CardPage() {
+ +
+ {checklist.items.map((item) => ( + + ))} +
+ {activeChecklistForm === checklist.publicId && ( - setActiveChecklistForm(null)} - /> +
+ setActiveChecklistForm(null)} + /> +
)}
); diff --git a/packages/api/src/routers/checklist.ts b/packages/api/src/routers/checklist.ts index cf91744a..a3e41261 100644 --- a/packages/api/src/routers/checklist.ts +++ b/packages/api/src/routers/checklist.ts @@ -71,14 +71,6 @@ export const checklistRouter = createTRPCRouter({ code: "INTERNAL_SERVER_ERROR", }); - // await cardActivityRepo.create(ctx.db, { - // type: "card.updated.checklist.added" as const, - // cardId: card.id, - // checklistId: newChecklist.id, - // toChecklist: newChecklist.title, - // createdBy: userId, - // }); - return newChecklist; }), createItem: protectedProcedure @@ -139,6 +131,116 @@ export const checklistRouter = createTRPCRouter({ return newChecklistItem; }), + updateItem: protectedProcedure + .meta({ + openapi: { + summary: "Update a checklist item", + method: "PUT", + path: "/checklists/items/{checklistItemPublicId}", + description: "Updates a checklist item (title/completed)", + tags: ["Cards"], + protect: true, + }, + }) + .input( + z.object({ + checklistItemPublicId: z.string().length(12), + title: z.string().min(1).max(500).optional(), + completed: z.boolean().optional(), + }), + ) + .output(checklistItemSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist( + ctx.db, + input.checklistItemPublicId, + ); + + if (!item) + throw new TRPCError({ + message: `Checklist item with public ID ${input.checklistItemPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace( + ctx.db, + userId, + item.checklist.card.list.board.workspace.id, + ); + + const updated = await checklistRepo.updateItemById(ctx.db, { + id: item.id, + title: input.title, + completed: input.completed, + }); + + if (!updated) + throw new TRPCError({ + message: `Failed to update checklist item`, + code: "INTERNAL_SERVER_ERROR", + }); + + return updated; + }), + deleteItem: protectedProcedure + .meta({ + openapi: { + summary: "Delete a checklist item", + method: "DELETE", + path: "/checklists/items/{checklistItemPublicId}", + description: "Deletes a checklist item", + tags: ["Cards"], + protect: true, + }, + }) + .input(z.object({ checklistItemPublicId: 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 item = await checklistRepo.getChecklistItemByPublicIdWithChecklist( + ctx.db, + input.checklistItemPublicId, + ); + if (!item) + throw new TRPCError({ + message: `Checklist item with public ID ${input.checklistItemPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertUserInWorkspace( + ctx.db, + userId, + item.checklist.card.list.board.workspace.id, + ); + + const deleted = await checklistRepo.softDeleteItemById(ctx.db, { + id: item.id, + deletedAt: new Date(), + deletedBy: userId, + }); + + if (!deleted) + throw new TRPCError({ + message: `Failed to delete item`, + code: "INTERNAL_SERVER_ERROR", + }); + + return { success: true }; + }), // update: protectedProcedure // .meta({ // openapi: { diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts index 34bbaa1b..9a84d74e 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, + checklistItems, checklists, labels, lists, @@ -316,6 +317,7 @@ export const getWithListAndMembersByPublicId = async ( completed: true, index: true, }, + where: isNull(checklistItems.deletedAt), }, }, }, diff --git a/packages/db/src/repository/checklist.repo.ts b/packages/db/src/repository/checklist.repo.ts index a5f398a9..593a8031 100644 --- a/packages/db/src/repository/checklist.repo.ts +++ b/packages/db/src/repository/checklist.repo.ts @@ -106,3 +106,68 @@ export const getChecklistByPublicId = async ( return checklist; }; + +export const getChecklistItemByPublicIdWithChecklist = async ( + db: dbClient, + checklistItemPublicId: string, +) => { + const item = await db.query.checklistItems.findFirst({ + where: and( + eq(checklistItems.publicId, checklistItemPublicId), + isNull(checklistItems.deletedAt), + ), + with: { + checklist: { + with: { + card: { + with: { + list: { + with: { + board: { + with: { workspace: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return item; +}; + +export const updateItemById = async ( + db: dbClient, + args: { id: number; title?: string; completed?: boolean }, +) => { + const [result] = await db + .update(checklistItems) + .set({ + ...(args.title !== undefined ? { title: args.title } : {}), + ...(args.completed !== undefined ? { completed: args.completed } : {}), + updatedAt: new Date(), + }) + .where(eq(checklistItems.id, args.id)) + .returning({ + publicId: checklistItems.publicId, + title: checklistItems.title, + completed: checklistItems.completed, + }); + + return result; +}; + +export const softDeleteItemById = async ( + db: dbClient, + args: { id: number; deletedAt: Date; deletedBy: string }, +) => { + const [result] = await db + .update(checklistItems) + .set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy }) + .where(eq(checklistItems.id, args.id)) + .returning({ id: checklistItems.id }); + + return result; +};