feat: update checklist name
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(/ /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();
|
||||
|
||||
81
apps/web/src/views/card/components/ChecklistNameInput.tsx
Normal file
81
apps/web/src/views/card/components/ChecklistNameInput.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
// }),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user