feat: display checklist items on public card modal

This commit is contained in:
Henry
2025-08-14 14:41:21 +01:00
parent cb80c664d9
commit f19f7e1483
6 changed files with 209 additions and 107 deletions

View File

@@ -2,6 +2,7 @@ import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -13,11 +14,13 @@ interface ChecklistItemRowProps {
completed: boolean;
};
cardPublicId: string;
viewOnly?: boolean;
}
export default function ChecklistItemRow({
item,
cardPublicId,
viewOnly = false,
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -107,6 +110,7 @@ export default function ChecklistItemRow({
.trim();
const handleToggleCompleted = () => {
if (viewOnly) return;
setCompleted((prev) => !prev);
updateItem.mutate({
checklistItemPublicId: item.publicId,
@@ -115,6 +119,7 @@ export default function ChecklistItemRow({
};
const commitTitle = (rawHtml: string) => {
if (viewOnly) return;
const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) {
setTitle(item.title);
@@ -128,28 +133,45 @@ export default function ChecklistItemRow({
};
const handleDelete = () => {
if (viewOnly) return;
deleteItem.mutate({ checklistItemPublicId: item.publicId });
};
return (
<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">
<label
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
>
<input
type="checkbox"
checked={completed}
onChange={handleToggleCompleted}
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"
onChange={(e) => {
if (viewOnly) {
e.preventDefault();
return;
}
handleToggleCompleted();
}}
className={twMerge(
"h-[16px] w-[16px] 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",
viewOnly ? "cursor-default" : "cursor-pointer",
)}
/>
</label>
<div className="flex-1 pr-7">
<ContentEditable
html={title}
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)}
// @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-950 outline-none focus-visible:outline-none dark:text-dark-950"
className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
viewOnly && "cursor-default",
)}
placeholder={t`Add details...`}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
commitTitle(title);
@@ -161,13 +183,15 @@ export default function ChecklistItemRow({
}}
/>
</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>
{!viewOnly && (
<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,5 +1,6 @@
import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
@@ -8,10 +9,12 @@ export default function ChecklistNameInput({
checklistPublicId,
initialName,
cardPublicId,
viewOnly = false,
}: {
checklistPublicId: string;
initialName: string;
cardPublicId: string;
viewOnly?: boolean;
}) {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -50,6 +53,7 @@ export default function ChecklistNameInput({
});
const commit = () => {
if (viewOnly) return;
const trimmed = name.trim();
if (!trimmed || trimmed === initialName) return;
update.mutate({ checklistPublicId, name: trimmed });
@@ -60,9 +64,11 @@ export default function ChecklistNameInput({
ref={inputRef}
type="text"
value={name}
readOnly={viewOnly}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
commit();
@@ -75,7 +81,10 @@ export default function ChecklistNameInput({
}
}}
title={name}
className="text-md block w-full truncate border-0 bg-transparent p-0 py-0 font-medium text-light-1000 outline-none focus:ring-0 dark:text-dark-1000"
className={twMerge(
"text-md block w-full truncate border-0 bg-transparent p-0 py-0 font-medium text-light-1000 outline-none focus:ring-0 dark:text-dark-1000",
viewOnly && "cursor-default",
)}
/>
);
}

View File

@@ -0,0 +1,142 @@
import { HiPlus, HiXMark } from "react-icons/hi2";
import CircularProgress from "~/components/CircularProgress";
import { useModal } from "~/providers/modal";
import ChecklistItemRow from "./ChecklistItemRow";
import ChecklistNameInput from "./ChecklistNameInput";
import NewChecklistItemForm from "./NewChecklistItemForm";
interface ChecklistItem {
publicId: string;
title: string;
completed: boolean;
}
interface Checklist {
publicId: string;
name: string;
items: ChecklistItem[];
}
interface ChecklistsProps {
checklists: Checklist[];
cardPublicId: string;
activeChecklistForm?: string | null;
setActiveChecklistForm?: (id: string | null) => void;
viewOnly?: boolean;
}
export default function Checklists({
checklists,
cardPublicId,
activeChecklistForm,
setActiveChecklistForm,
viewOnly = false,
}: ChecklistsProps) {
const { openModal } = useModal();
if (!checklists || checklists.length === 0) return null;
return (
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
{!viewOnly && (
<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}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</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"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</div>
</div>
)}
{viewOnly && (
<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}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</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={cardPublicId}
viewOnly={viewOnly}
/>
))}
</div>
{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -16,12 +16,14 @@ interface NewChecklistItemFormProps {
checklistPublicId: string;
cardPublicId: string;
onCancel: () => void;
readOnly?: boolean;
}
const NewChecklistItemForm = ({
checklistPublicId,
cardPublicId,
onCancel,
readOnly = false,
}: NewChecklistItemFormProps) => {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -40,6 +42,7 @@ const NewChecklistItemForm = ({
const refocusEditable = () => {
const el = editableRef.current;
if (!el) return;
if (readOnly) return;
el.focus();
// hack to ensure the input is focused after creating new checklist item
@@ -102,6 +105,7 @@ const NewChecklistItemForm = ({
};
const submitIfNotEmpty = (keepOpen: boolean) => {
if (readOnly) return;
keepOpenRef.current = keepOpen;
const currentHtml = getValues("title") ?? "";
const plain = sanitizeHtmlToPlainText(currentHtml);
@@ -132,14 +136,15 @@ const NewChecklistItemForm = ({
<div className="flex-1 pr-7">
<ContentEditable
id={`checklist-item-input-${checklistPublicId}`}
tabIndex={0}
tabIndex={readOnly ? -1 : 0}
placeholder={t`Add an item...`}
html={title}
disabled={false}
disabled={readOnly}
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-950"
onBlur={() => submitIfNotEmpty(false)}
onKeyDown={async (e) => {
if (readOnly) return;
if (e.key === "Enter") {
e.preventDefault();
submitIfNotEmpty(true);

View File

@@ -3,11 +3,9 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { HiPlus, HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5";
import Avatar from "~/components/Avatar";
import CircularProgress from "~/components/CircularProgress";
import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal";
import { LabelForm } from "~/components/LabelForm";
@@ -22,8 +20,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 ChecklistNameInput from "./components/ChecklistNameInput";
import Checklists from "./components/Checklists";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
@@ -279,95 +276,12 @@ export default function CardPage() {
</div>
</form>
</div>
{card.checklists.length > 0 && (
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{card.checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 &&
completedItems.length > 0
? (completedItems.length / checklist.items.length) *
100
: 2;
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardId}
/>
</div>
<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}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/
{checklist.items.length}
</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"
onClick={() =>
openModal(
"DELETE_CHECKLIST",
checklist.publicId,
)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</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 && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardId}
onCancel={() => setActiveChecklistForm(null)}
/>
</div>
)}
</div>
);
})}
</div>
</div>
)}
<Checklists
checklists={card.checklists}
cardPublicId={cardId}
activeChecklistForm={activeChecklistForm}
setActiveChecklistForm={setActiveChecklistForm}
/>
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
{t`Activity`}

View File

@@ -9,6 +9,7 @@ import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList";
import Checklists from "~/views/card/components/Checklists";
export function CardModal({
cardPublicId,
@@ -123,6 +124,13 @@ export function CardModal({
</div>
</div>
)}
{data?.checklists && data.checklists.length > 0 && (
<Checklists
checklists={data.checklists}
cardPublicId={cardPublicId ?? ""}
viewOnly
/>
)}
<div className="border-t-[1px] border-light-600 pb-4 pt-12 dark:border-dark-400">
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
{t`Activity`}