feat: update checklist name

This commit is contained in:
Henry
2025-08-10 23:08:52 +01:00
parent a3f0809507
commit a89bd835d0
7 changed files with 180 additions and 75 deletions

View File

@@ -15,10 +15,15 @@ import {
ReactRenderer,
useEditor,
} from "@tiptap/react";
import { useEffect } from "react";
import StarterKit from "@tiptap/starter-kit";
import Suggestion from "@tiptap/suggestion";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import {
HiH1,
HiH2,
@@ -373,7 +378,7 @@ export default function Editor({
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
<EditorContent
editor={editor}
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-dark-950 [&_ul]:!text-xs"
/>
</div>
);

View File

@@ -91,10 +91,20 @@ 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);
setCompleted(item.completed);
}, [item.publicId, item.title, item.completed]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [item.publicId]);
const sanitizeHtmlToPlainText = (html: string): string =>
html
.replace(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/g, " ")
.trim();
const handleToggleCompleted = () => {
setCompleted((prev) => !prev);
@@ -104,12 +114,13 @@ export default function ChecklistItemRow({
});
};
const commitTitle = async () => {
const trimmed = title.trim();
if (!trimmed || trimmed === item.title) return;
const commitTitle = (rawHtml: string) => {
const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) return;
setTitle(plain);
updateItem.mutate({
checklistItemPublicId: item.publicId,
title: trimmed,
title: plain,
});
};
@@ -118,40 +129,26 @@ export default function ChecklistItemRow({
};
return (
<div className="group relative flex h-9 items-center gap-3 rounded-md pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
<label className="relative inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
<input
type="checkbox"
checked={completed}
onChange={handleToggleCompleted}
className="peer h-[16px] w-[16px] appearance-none rounded-full border border-light-500 bg-transparent outline-none ring-0 checked:bg-indigo-600 hover:border-light-500 hover:bg-transparent focus:outline-none focus:ring-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500"
className="h-[16px] w-[16px] cursor-pointer appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500"
/>
<svg
viewBox="0 0 20 20"
className="pointer-events-none absolute left-1/2 top-1/2 hidden h-[12px] w-[12px] -translate-x-1/2 -translate-y-1/2 text-white peer-checked:block"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 10.5l3 3 7-7"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</label>
<div className="flex-1 pr-7">
<ContentEditable
html={title}
onChange={(e) => 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"
onBlur={() => commitTitle(title)}
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={async (e) => {
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
await commitTitle();
commitTitle(title);
}
if (e.key === "Escape") {
e.preventDefault();

View File

@@ -0,0 +1,81 @@
import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function ChecklistNameInput({
checklistPublicId,
initialName,
cardPublicId,
}: {
checklistPublicId: string;
initialName: string;
cardPublicId: string;
}) {
const utils = api.useUtils();
const { showPopup } = usePopup();
const [name, setName] = useState(initialName);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
setName(initialName);
}, [initialName, checklistPublicId]);
const update = api.checklist.update.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 updated = old.checklists.map((cl) =>
cl.publicId === checklistPublicId ? { ...cl, name: vars.name } : cl,
);
return { ...old, checklists: updated } 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`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});
const commit = () => {
const trimmed = name.trim();
if (!trimmed || trimmed === initialName) return;
update.mutate({ checklistPublicId, name: trimmed });
};
return (
<input
ref={inputRef}
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
inputRef.current?.blur();
}
if (e.key === "Escape") {
e.preventDefault();
setName(initialName);
inputRef.current?.blur();
}
}}
title={name}
className="text-md block w-full truncate border-0 bg-transparent p-0 py-0 font-medium text-light-900 outline-none focus:ring-0 dark:text-dark-1000"
/>
);
}

View File

@@ -23,6 +23,7 @@ import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
import ActivityList from "./components/ActivityList";
import ChecklistItemRow from "./components/ChecklistItemRow";
import ChecklistNameInput from "./components/ChecklistNameInput";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
import Dropdown from "./components/Dropdown";
@@ -276,11 +277,15 @@ export default function CardPage() {
return (
<div key={checklist.publicId} className="mb-4">
<div className="text-md mb-2 flex items-center justify-between font-medium text-light-900 dark:text-dark-1000">
<div className="flex items-center gap-2">
<span>{checklist.name}</span>
<div className="mb-2 flex items-center font-medium text-light-900 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardId}
/>
</div>
<div className="flex items-center gap-2">
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}

View File

@@ -73,6 +73,51 @@ export const checklistRouter = createTRPCRouter({
return newChecklist;
}),
update: protectedProcedure
.input(
z.object({
checklistPublicId: z.string().length(12),
name: z.string().min(1).max(255),
}),
)
.output(z.object({ publicId: z.string().length(12), name: z.string() }))
.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 updated = await checklistRepo.updateChecklistById(ctx.db, {
id: checklist.id,
name: input.name,
});
if (!updated)
throw new TRPCError({
message: `Failed to update checklist`,
code: "INTERNAL_SERVER_ERROR",
});
return updated;
}),
createItem: protectedProcedure
.meta({
openapi: {
@@ -241,46 +286,4 @@ export const checklistRouter = createTRPCRouter({
return { success: true };
}),
// update: protectedProcedure
// .meta({
// openapi: {
// summary: "Update a checklist",
// method: "PUT",
// path: "/cards/{cardPublicId}/checklists/{checklistPublicId}",
// description: "Updates a checklist",
// tags: ["Cards"],
// protect: true,
// },
// })
// .input(
// z.object({
// cardPublicId: z.string().min(12),
// checklistPublicId: z.string().min(12),
// title: z.string().min(1),
// }),
// )
// .output(checklistSchema)
// .mutation(async ({ ctx, input }) => {
// }),
// delete: protectedProcedure
// .meta({
// openapi: {
// summary: "Delete a checklist",
// method: "DELETE",
// path: "/cards/{cardPublicId}/checklists/{checklistPublicId}",
// description: "Deletes a checklist",
// tags: ["Cards"],
// },
// })
// .input(
// z.object({
// cardPublicId: z.string().min(12),
// checklistPublicId: z.string().min(12),
// }),
// )
// .output(checklistSchema)
// .mutation(async ({ ctx, input }) => {
// }),
});

View File

@@ -309,6 +309,7 @@ export const getWithListAndMembersByPublicId = async (
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
@@ -318,6 +319,7 @@ export const getWithListAndMembersByPublicId = async (
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},

View File

@@ -171,3 +171,15 @@ export const softDeleteItemById = async (
return result;
};
export const updateChecklistById = async (
db: dbClient,
args: { id: number; name: string },
) => {
const [result] = await db
.update(checklists)
.set({ name: args.name, updatedAt: new Date() })
.where(eq(checklists.id, args.id))
.returning({ publicId: checklists.publicId, name: checklists.name });
return result;
};