feat: card checklists (#141)
* feat: setup checklist schema * feat: scaffold checklist router * feat: add create new checklist modal * feat: create new checklist item * feat: toggle checklist item completed state * feat: update checklist name * feat: delete checklist * feat: show checklists progress on cards * feat: tweak light mode styling * feat: focus item form on creation of new checklist * feat: tweak new checklist item form styling * feat: add checklist activity * feat: show checklist progress on public board page * feat: display checklist items on public card modal * chore: add translations
This commit is contained in:
@@ -5,9 +5,11 @@ import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
HiOutlineTrash,
|
||||
HiOutlineUserMinus,
|
||||
HiOutlineUserPlus,
|
||||
} from "react-icons/hi2";
|
||||
@@ -31,6 +33,11 @@ const dateLocaleMap = {
|
||||
nl: nl,
|
||||
} as const;
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
};
|
||||
|
||||
const getActivityText = ({
|
||||
type,
|
||||
toTitle,
|
||||
@@ -39,6 +46,7 @@ const getActivityText = ({
|
||||
memberName,
|
||||
isSelf,
|
||||
label,
|
||||
fromTitle,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -47,6 +55,7 @@ const getActivityText = ({
|
||||
memberName: string | null;
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
fromTitle?: string | null;
|
||||
}) => {
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": t`created the card`,
|
||||
@@ -57,6 +66,14 @@ const getActivityText = ({
|
||||
"card.updated.label.removed": t`removed a label from the card`,
|
||||
"card.updated.member.added": t`added a member to the card`,
|
||||
"card.updated.member.removed": t`removed a member from the card`,
|
||||
"card.updated.checklist.added": t`added a checklist`,
|
||||
"card.updated.checklist.renamed": t`renamed a checklist`,
|
||||
"card.updated.checklist.deleted": t`deleted a checklist`,
|
||||
"card.updated.checklist.item.added": t`added a checklist item`,
|
||||
"card.updated.checklist.item.updated": t`updated a checklist item`,
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
} as const;
|
||||
|
||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||
@@ -71,7 +88,7 @@ const getActivityText = ({
|
||||
if (type === "card.updated.title" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
updated the title to <TextHighlight>{toTitle}</TextHighlight>
|
||||
updated the title to <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -79,8 +96,9 @@ const getActivityText = ({
|
||||
if (type === "card.updated.list" && fromList && toList) {
|
||||
return (
|
||||
<Trans>
|
||||
moved the card from <TextHighlight>{fromList}</TextHighlight> to
|
||||
<TextHighlight>{toList}</TextHighlight>
|
||||
moved the card from <TextHighlight>{truncate(fromList)}</TextHighlight>{" "}
|
||||
to
|
||||
<TextHighlight>{truncate(toList)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +108,8 @@ const getActivityText = ({
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
assigned <TextHighlight>{memberName}</TextHighlight> to the card
|
||||
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
|
||||
card
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +119,8 @@ const getActivityText = ({
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
|
||||
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
|
||||
the card
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +128,7 @@ const getActivityText = ({
|
||||
if (type === "card.updated.label.added" && label) {
|
||||
return (
|
||||
<Trans>
|
||||
added label <TextHighlight>{label}</TextHighlight>
|
||||
added label <TextHighlight>{truncate(label)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -116,7 +136,75 @@ const getActivityText = ({
|
||||
if (type === "card.updated.label.removed" && label) {
|
||||
return (
|
||||
<Trans>
|
||||
removed label <TextHighlight>{label}</TextHighlight>
|
||||
removed label <TextHighlight>{truncate(label)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.added" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
added checklist <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.renamed" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
renamed checklist <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.deleted" && fromTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
deleted checklist <TextHighlight>{truncate(fromTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.added" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
added checklist item <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.updated" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
renamed checklist item to{" "}
|
||||
<TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.completed" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
completed checklist item{" "}
|
||||
<TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.uncompleted" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
marked checklist item <TextHighlight>{truncate(toTitle)}</TextHighlight>{" "}
|
||||
as incomplete
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.deleted" && fromTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
deleted checklist item{" "}
|
||||
<TextHighlight>{truncate(fromTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -133,6 +221,14 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.label.removed": <HiOutlineTag />,
|
||||
"card.updated.member.added": <HiOutlineUserPlus />,
|
||||
"card.updated.member.removed": <HiOutlineUserMinus />,
|
||||
"card.updated.checklist.added": <HiOutlinePlus />,
|
||||
"card.updated.checklist.renamed": <HiOutlinePencil />,
|
||||
"card.updated.checklist.deleted": <HiOutlineTrash />,
|
||||
"card.updated.checklist.item.added": <HiOutlinePlus />,
|
||||
"card.updated.checklist.item.updated": <HiOutlinePencil />,
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
} as const;
|
||||
|
||||
const getActivityIcon = (
|
||||
@@ -179,6 +275,7 @@ const ActivityList = ({
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
isSelf: activity.member?.user?.id === data?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
fromTitle: activity.fromTitle ?? null,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
|
||||
197
apps/web/src/views/card/components/ChecklistItemRow.tsx
Normal file
197
apps/web/src/views/card/components/ChecklistItemRow.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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";
|
||||
|
||||
interface ChecklistItemRowProps {
|
||||
item: {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
};
|
||||
cardPublicId: string;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function ChecklistItemRow({
|
||||
item,
|
||||
cardPublicId,
|
||||
viewOnly = false,
|
||||
}: ChecklistItemRowProps) {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
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 });
|
||||
},
|
||||
});
|
||||
|
||||
// Only resync from props when switching items to avoid clobbering edits
|
||||
useEffect(() => {
|
||||
setTitle(item.title);
|
||||
setCompleted(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 = () => {
|
||||
if (viewOnly) return;
|
||||
setCompleted((prev) => !prev);
|
||||
updateItem.mutate({
|
||||
checklistItemPublicId: item.publicId,
|
||||
completed: !completed,
|
||||
});
|
||||
};
|
||||
|
||||
const commitTitle = (rawHtml: string) => {
|
||||
if (viewOnly) return;
|
||||
const plain = sanitizeHtmlToPlainText(rawHtml);
|
||||
if (!plain || plain === item.title) {
|
||||
setTitle(item.title);
|
||||
return;
|
||||
}
|
||||
setTitle(plain);
|
||||
updateItem.mutate({
|
||||
checklistItemPublicId: item.publicId,
|
||||
title: plain,
|
||||
});
|
||||
};
|
||||
|
||||
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 items-center justify-center`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completed}
|
||||
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={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);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setTitle(item.title);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
90
apps/web/src/views/card/components/ChecklistNameInput.tsx
Normal file
90
apps/web/src/views/card/components/ChecklistNameInput.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
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";
|
||||
|
||||
export default function ChecklistNameInput({
|
||||
checklistPublicId,
|
||||
initialName,
|
||||
cardPublicId,
|
||||
viewOnly = false,
|
||||
}: {
|
||||
checklistPublicId: string;
|
||||
initialName: string;
|
||||
cardPublicId: string;
|
||||
viewOnly?: boolean;
|
||||
}) {
|
||||
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 = () => {
|
||||
if (viewOnly) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed === initialName) return;
|
||||
update.mutate({ checklistPublicId, name: trimmed });
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
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();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setName(initialName);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
title={name}
|
||||
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",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/views/card/components/Checklists.tsx
Normal file
142
apps/web/src/views/card/components/Checklists.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteChecklistConfirmation({
|
||||
cardPublicId,
|
||||
checklistPublicId,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
checklistPublicId: string;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteChecklist = api.checklist.delete.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.filter(
|
||||
(cl) => cl.publicId !== checklistPublicId,
|
||||
);
|
||||
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`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
closeModal();
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteChecklist.mutate({ checklistPublicId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Are you sure you want to delete this checklist?`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`This action can't be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleDelete} isLoading={deleteChecklist.isPending}>
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -10,6 +14,13 @@ export default function BoardDropdown() {
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
|
||||
140
apps/web/src/views/card/components/NewChecklistForm.tsx
Normal file
140
apps/web/src/views/card/components/NewChecklistForm.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface NewChecklistFormInput {
|
||||
name: string;
|
||||
cardPublicId: string;
|
||||
}
|
||||
|
||||
export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
|
||||
const { closeModal, setModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { register, handleSubmit, reset, watch } =
|
||||
useForm<NewChecklistFormInput>({
|
||||
defaultValues: {
|
||||
name: "Checklist",
|
||||
cardPublicId,
|
||||
},
|
||||
});
|
||||
|
||||
const createChecklist = api.checklist.create.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.card.byId.cancel({ cardPublicId: args.cardPublicId });
|
||||
const previous = utils.card.byId.getData({
|
||||
cardPublicId: args.cardPublicId,
|
||||
});
|
||||
utils.card.byId.setData({ cardPublicId: args.cardPublicId }, (old) => {
|
||||
if (!old) return old as any;
|
||||
const placeholderChecklist = {
|
||||
publicId: `PLACEHOLDER_${generateUID()}`,
|
||||
name: args.name,
|
||||
index: old.checklists.length,
|
||||
items: [] as {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
index: number;
|
||||
}[],
|
||||
};
|
||||
return {
|
||||
...old,
|
||||
checklists: [...old.checklists, placeholderChecklist],
|
||||
} as typeof old;
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setModalState("ADD_CHECKLIST", { createdChecklistId: data.publicId });
|
||||
},
|
||||
onError: (_error, vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData(
|
||||
{ cardPublicId: vars.cardPublicId },
|
||||
ctx.previous,
|
||||
);
|
||||
showPopup({
|
||||
header: t`Unable to create checklist`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async (_data, _error, vars) => {
|
||||
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#checklist-name");
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: NewChecklistFormInput) => {
|
||||
closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
});
|
||||
|
||||
createChecklist.mutate({
|
||||
name: data.name,
|
||||
cardPublicId: data.cardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`New checklist`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="checklist-name"
|
||||
placeholder={t`Checklist name`}
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createChecklist.isPending || !watch("name")}
|
||||
>
|
||||
{t`Create checklist`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
167
apps/web/src/views/card/components/NewChecklistItemForm.tsx
Normal file
167
apps/web/src/views/card/components/NewChecklistItemForm.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface FormValues {
|
||||
title: string;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const { setValue, watch, reset, getValues } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
|
||||
const title = watch("title");
|
||||
|
||||
const editableRef = useRef<HTMLElement | null>(null);
|
||||
const keepOpenRef = useRef(false);
|
||||
|
||||
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
|
||||
setTimeout(() => {
|
||||
el.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const addChecklistItemMutation = api.checklist.createItem.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 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.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
const sanitizeHtmlToPlainText = (html: string): string => {
|
||||
return html
|
||||
.replace(/<br\s*\/?>(\n)?/gi, "\n")
|
||||
.replace(/<div><br\s*\/?><\/div>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const submitIfNotEmpty = (keepOpen: boolean) => {
|
||||
if (readOnly) return;
|
||||
keepOpenRef.current = keepOpen;
|
||||
const currentHtml = getValues("title") ?? "";
|
||||
const plain = sanitizeHtmlToPlainText(currentHtml);
|
||||
if (!plain) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
addChecklistItemMutation.mutate({
|
||||
checklistPublicId,
|
||||
title: plain,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refocusEditable();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<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-md 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"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex-1 pr-7">
|
||||
<ContentEditable
|
||||
id={`checklist-item-input-${checklistPublicId}`}
|
||||
tabIndex={readOnly ? -1 : 0}
|
||||
placeholder={t`Add an item...`}
|
||||
html={title}
|
||||
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);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
innerRef={(el) => {
|
||||
editableRef.current = (el as unknown as HTMLElement) ?? null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewChecklistItemForm;
|
||||
Reference in New Issue
Block a user