feat: toggle checklist item completed state

This commit is contained in:
Henry
2025-08-09 22:14:56 +01:00
parent a4cc922d05
commit a3f0809507
6 changed files with 495 additions and 62 deletions

View File

@@ -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 (
<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">
<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"
/>
<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"
placeholder={t`Add details...`}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await commitTitle();
}
if (e.key === "Escape") {
e.preventDefault();
setTitle(item.title);
}
}}
/>
</div>
<button
type="button"
onClick={handleDelete}
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded-md p-1 text-light-900 group-hover:block hover:bg-light-200 dark:text-dark-700 dark:hover:bg-dark-200"
>
<HiXMark size={16} />
</button>
</div>
);
}

View File

@@ -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<FormValues>({
const { setValue, watch, reset, getValues } = useForm<FormValues>({
defaultValues: {
title: "",
},
@@ -32,8 +34,58 @@ const NewChecklistItemForm = ({
const title = watch("title");
const editableRef = useRef<HTMLElement | null>(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(/<br\s*\/?>(\n)?/gi, "\n")
.replace(/<div><br\s*\/?><\/div>/gi, "")
.replace(/<[^>]*>/g, "")
.replace(/&nbsp;/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 (
<form
onSubmit={handleSubmit(onSubmit)}
className="mt-2 w-full rounded-xl border border-light-600 bg-light-100 p-4 text-light-900 focus-visible:outline-none dark:border-dark-400 dark:bg-dark-100 dark:text-dark-1000"
>
<div className="mb-3">
<ContentEditable
placeholder={t`Add an item...`}
html={title}
disabled={false}
onChange={(e) => 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();
}
}}
/>
</div>
useEffect(() => {
refocusEditable();
}, []);
<div className="flex items-center justify-end">
<div className="flex space-x-2">
<Button size="xs" type="button" variant="ghost" onClick={onCancel}>
{t`Cancel`}
</Button>
<Button
size="xs"
type="submit"
variant="secondary"
disabled={title.length === 0 || addChecklistItemMutation.isPending}
return (
<form onSubmit={(e) => e.preventDefault()} className="mt-1">
<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-default items-center justify-center">
<input
type="checkbox"
disabled
className="peer h-[16px] w-[16px] appearance-none rounded-full border border-light-500 bg-transparent outline-none ring-0 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"
/>
<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"
>
{t`Add`}
</Button>
<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
placeholder={t`Add an item...`}
html={title}
disabled={false}
onChange={(e) => 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;
}}
/>
</div>
</div>
</form>

View File

@@ -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 (
<div key={checklist.publicId}>
<div className="text-md mb-4 flex items-center justify-between font-medium text-light-900 dark:text-dark-1000">
<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>
@@ -308,12 +307,29 @@ export default function CardPage() {
</div>
</div>
</div>
<div className="ml-1">
{checklist.items.map((item) => (
<ChecklistItemRow
key={item.publicId}
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardId}
/>
))}
</div>
{activeChecklistForm === checklist.publicId && (
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardId}
onCancel={() => setActiveChecklistForm(null)}
/>
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardId}
onCancel={() => setActiveChecklistForm(null)}
/>
</div>
)}
</div>
);