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 { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable"; import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -13,11 +14,13 @@ interface ChecklistItemRowProps {
completed: boolean; completed: boolean;
}; };
cardPublicId: string; cardPublicId: string;
viewOnly?: boolean;
} }
export default function ChecklistItemRow({ export default function ChecklistItemRow({
item, item,
cardPublicId, cardPublicId,
viewOnly = false,
}: ChecklistItemRowProps) { }: ChecklistItemRowProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
@@ -107,6 +110,7 @@ export default function ChecklistItemRow({
.trim(); .trim();
const handleToggleCompleted = () => { const handleToggleCompleted = () => {
if (viewOnly) return;
setCompleted((prev) => !prev); setCompleted((prev) => !prev);
updateItem.mutate({ updateItem.mutate({
checklistItemPublicId: item.publicId, checklistItemPublicId: item.publicId,
@@ -115,6 +119,7 @@ export default function ChecklistItemRow({
}; };
const commitTitle = (rawHtml: string) => { const commitTitle = (rawHtml: string) => {
if (viewOnly) return;
const plain = sanitizeHtmlToPlainText(rawHtml); const plain = sanitizeHtmlToPlainText(rawHtml);
if (!plain || plain === item.title) { if (!plain || plain === item.title) {
setTitle(item.title); setTitle(item.title);
@@ -128,28 +133,45 @@ export default function ChecklistItemRow({
}; };
const handleDelete = () => { const handleDelete = () => {
if (viewOnly) return;
deleteItem.mutate({ checklistItemPublicId: item.publicId }); deleteItem.mutate({ checklistItemPublicId: item.publicId });
}; };
return ( 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"> <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 <input
type="checkbox" type="checkbox"
checked={completed} checked={completed}
onChange={handleToggleCompleted} onChange={(e) => {
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" 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> </label>
<div className="flex-1 pr-7"> <div className="flex-1 pr-7">
<ContentEditable <ContentEditable
html={title} html={title}
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
// @ts-expect-error - valid event // @ts-expect-error - valid event
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)} 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...`} placeholder={t`Add details...`}
onKeyDown={(e) => { onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
commitTitle(title); commitTitle(title);
@@ -161,13 +183,15 @@ export default function ChecklistItemRow({
}} }}
/> />
</div> </div>
<button {!viewOnly && (
type="button" <button
onClick={handleDelete} type="button"
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" 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> <HiXMark size={16} />
</button>
)}
</div> </div>
); );
} }

View File

@@ -1,5 +1,6 @@
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -8,10 +9,12 @@ export default function ChecklistNameInput({
checklistPublicId, checklistPublicId,
initialName, initialName,
cardPublicId, cardPublicId,
viewOnly = false,
}: { }: {
checklistPublicId: string; checklistPublicId: string;
initialName: string; initialName: string;
cardPublicId: string; cardPublicId: string;
viewOnly?: boolean;
}) { }) {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
@@ -50,6 +53,7 @@ export default function ChecklistNameInput({
}); });
const commit = () => { const commit = () => {
if (viewOnly) return;
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed || trimmed === initialName) return; if (!trimmed || trimmed === initialName) return;
update.mutate({ checklistPublicId, name: trimmed }); update.mutate({ checklistPublicId, name: trimmed });
@@ -60,9 +64,11 @@ export default function ChecklistNameInput({
ref={inputRef} ref={inputRef}
type="text" type="text"
value={name} value={name}
readOnly={viewOnly}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => { onKeyDown={(e) => {
if (viewOnly) return;
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
commit(); commit();
@@ -75,7 +81,10 @@ export default function ChecklistNameInput({
} }
}} }}
title={name} 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; checklistPublicId: string;
cardPublicId: string; cardPublicId: string;
onCancel: () => void; onCancel: () => void;
readOnly?: boolean;
} }
const NewChecklistItemForm = ({ const NewChecklistItemForm = ({
checklistPublicId, checklistPublicId,
cardPublicId, cardPublicId,
onCancel, onCancel,
readOnly = false,
}: NewChecklistItemFormProps) => { }: NewChecklistItemFormProps) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
@@ -40,6 +42,7 @@ const NewChecklistItemForm = ({
const refocusEditable = () => { const refocusEditable = () => {
const el = editableRef.current; const el = editableRef.current;
if (!el) return; if (!el) return;
if (readOnly) return;
el.focus(); el.focus();
// hack to ensure the input is focused after creating new checklist item // hack to ensure the input is focused after creating new checklist item
@@ -102,6 +105,7 @@ const NewChecklistItemForm = ({
}; };
const submitIfNotEmpty = (keepOpen: boolean) => { const submitIfNotEmpty = (keepOpen: boolean) => {
if (readOnly) return;
keepOpenRef.current = keepOpen; keepOpenRef.current = keepOpen;
const currentHtml = getValues("title") ?? ""; const currentHtml = getValues("title") ?? "";
const plain = sanitizeHtmlToPlainText(currentHtml); const plain = sanitizeHtmlToPlainText(currentHtml);
@@ -132,14 +136,15 @@ const NewChecklistItemForm = ({
<div className="flex-1 pr-7"> <div className="flex-1 pr-7">
<ContentEditable <ContentEditable
id={`checklist-item-input-${checklistPublicId}`} id={`checklist-item-input-${checklistPublicId}`}
tabIndex={0} tabIndex={readOnly ? -1 : 0}
placeholder={t`Add an item...`} placeholder={t`Add an item...`}
html={title} html={title}
disabled={false} disabled={readOnly}
onChange={(e) => setValue("title", e.target.value)} 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" 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)} onBlur={() => submitIfNotEmpty(false)}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (readOnly) return;
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
submitIfNotEmpty(true); submitIfNotEmpty(true);

View File

@@ -3,11 +3,9 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiPlus, HiXMark } from "react-icons/hi2";
import { IoChevronForwardSharp } from "react-icons/io5"; import { IoChevronForwardSharp } from "react-icons/io5";
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import CircularProgress from "~/components/CircularProgress";
import Editor from "~/components/Editor"; import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal"; import FeedbackModal from "~/components/FeedbackModal";
import { LabelForm } from "~/components/LabelForm"; import { LabelForm } from "~/components/LabelForm";
@@ -22,8 +20,7 @@ import { api } from "~/utils/api";
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers"; import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation"; import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
import ActivityList from "./components/ActivityList"; import ActivityList from "./components/ActivityList";
import ChecklistItemRow from "./components/ChecklistItemRow"; import Checklists from "./components/Checklists";
import ChecklistNameInput from "./components/ChecklistNameInput";
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation"; import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation"; import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation"; import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
@@ -279,95 +276,12 @@ export default function CardPage() {
</div> </div>
</form> </form>
</div> </div>
{card.checklists.length > 0 && ( <Checklists
<div className="border-light-300 pb-4 dark:border-dark-300"> checklists={card.checklists}
<div> cardPublicId={cardId}
{card.checklists.map((checklist) => { activeChecklistForm={activeChecklistForm}
const completedItems = checklist.items.filter( setActiveChecklistForm={setActiveChecklistForm}
(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>
)}
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300"> <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"> <h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
{t`Activity`} {t`Activity`}

View File

@@ -9,6 +9,7 @@ import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList"; import ActivityList from "~/views/card/components/ActivityList";
import Checklists from "~/views/card/components/Checklists";
export function CardModal({ export function CardModal({
cardPublicId, cardPublicId,
@@ -123,6 +124,13 @@ export function CardModal({
</div> </div>
</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"> <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"> <h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
{t`Activity`} {t`Activity`}