feat: delete checklist

This commit is contained in:
Henry
2025-08-11 22:09:44 +01:00
parent a89bd835d0
commit 64d9d51e59
5 changed files with 185 additions and 7 deletions

View File

@@ -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({
<ContentEditable
html={title}
onChange={(e) => 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) => {

View File

@@ -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 (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{t`Are you sure you want to delete this checklist?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
{t`Cancel`}
</Button>
<Button onClick={handleDelete} isLoading={deleteChecklist.isPending}>
{t`Delete`}
</Button>
</div>
</div>
);
}

View File

@@ -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<string | null>(
@@ -298,7 +299,15 @@ export default function CardPage() {
</span>
</div>
<div>
<button className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100">
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal(
"DELETE_CHECKLIST",
checklist.publicId,
)
}
>
<HiXMark size={16} />
</button>
<button
@@ -397,6 +406,12 @@ export default function CardPage() {
{modalContentType === "ADD_CHECKLIST" && (
<NewChecklistForm cardPublicId={cardId} />
)}
{modalContentType === "DELETE_CHECKLIST" && (
<DeleteChecklistConfirmation
cardPublicId={cardId}
checklistPublicId={entityId}
/>
)}
</Modal>
</div>
</>

View File

@@ -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: {

View File

@@ -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 },