feat: show checklists progress on cards

This commit is contained in:
Henry
2025-08-12 22:21:11 +01:00
parent 64d9d51e59
commit a488968778
5 changed files with 131 additions and 55 deletions

View File

@@ -45,7 +45,9 @@ export const formatMemberDisplayName = (
return localPart.replace(/[_-]/g, "."); return localPart.replace(/[_-]/g, ".");
}; };
export const getAvatarUrl = (imageOrKey: string) => { export const getAvatarUrl = (imageOrKey: string | null) => {
if (!imageOrKey) return "";
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) { if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
return imageOrKey; return imageOrKey;
} }

View File

@@ -1,5 +1,6 @@
import Avatar from "~/components/Avatar"; import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge"; import Badge from "~/components/Badge";
import CircularProgress from "~/components/CircularProgress";
import LabelIcon from "~/components/LabelIcon"; import LabelIcon from "~/components/LabelIcon";
import { getAvatarUrl } from "~/utils/helpers"; import { getAvatarUrl } from "~/utils/helpers";
@@ -7,6 +8,7 @@ const Card = ({
title, title,
labels, labels,
members, members,
checklists,
}: { }: {
title: string; title: string;
labels: { name: string; colourCode: string | null }[]; labels: { name: string; colourCode: string | null }[];
@@ -15,7 +17,28 @@ const Card = ({
email: string; email: string;
user: { name: string | null; email: string; image: string | null } | null; user: { name: string | null; email: string; image: string | null } | null;
}[]; }[];
checklists: {
publicId: string;
name: string;
items: {
publicId: string;
title: string;
completed: boolean;
index: number;
}[];
}[];
}) => { }) => {
const completedItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.filter((item) => item.completed).length;
}, 0);
const totalItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.length;
}, 0);
const progress =
totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
return ( return (
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"> <div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
<span>{title}</span> <span>{title}</span>
@@ -29,21 +52,37 @@ const Card = ({
/> />
))} ))}
</div> </div>
<div className="isolate flex justify-end -space-x-1 overflow-hidden"> <div className="flex items-center justify-end gap-1">
{members.map(({ user, email }) => { {checklists.length > 0 && (
const avatarUrl = user?.image <div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-600">
? getAvatarUrl(user.image) <CircularProgress
: undefined; progress={progress || 2}
return (
<Avatar
name={user?.name ?? ""}
email={user?.email ?? email}
imageUrl={avatarUrl}
size="sm" size="sm"
className="flex-shrink-0"
/> />
); <span className="text-[10px] text-light-900 dark:text-dark-950">
})} {completedItems}/{totalItems}
</span>
</div>
)}
{members.length > 0 && (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{members.map(({ user, email }) => {
const avatarUrl = user?.image
? getAvatarUrl(user.image)
: undefined;
return (
<Avatar
name={user?.name ?? ""}
email={user?.email ?? email}
imageUrl={avatarUrl}
size="sm"
/>
);
})}
</div>
)}
</div> </div>
</div> </div>
) : null} ) : null}

View File

@@ -492,6 +492,7 @@ export default function BoardPage() {
title={card.title} title={card.title}
labels={card.labels} labels={card.labels}
members={card.members} members={card.members}
checklists={card.checklists}
/> />
</Link> </Link>
)} )}

View File

@@ -22,7 +22,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
const utils = api.useUtils(); const utils = api.useUtils();
const { register, handleSubmit, reset, setValue, watch } = const { register, handleSubmit, reset, watch } =
useForm<NewChecklistFormInput>({ useForm<NewChecklistFormInput>({
defaultValues: { defaultValues: {
name: "Checklist", name: "Checklist",
@@ -32,34 +32,45 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
const createChecklist = api.checklist.create.useMutation({ const createChecklist = api.checklist.create.useMutation({
onMutate: async (args) => { onMutate: async (args) => {
// await utils.board.byId.cancel(); await utils.card.byId.cancel({ cardPublicId: args.cardPublicId });
// const currentState = utils.board.byId.getData(queryParams); const previous = utils.card.byId.getData({
// utils.board.byId.setData(queryParams, (oldBoard) => { cardPublicId: args.cardPublicId,
// if (!oldBoard) return oldBoard; });
// const newList = { utils.card.byId.setData({ cardPublicId: args.cardPublicId }, (old) => {
// publicId: generateUID(), if (!old) return old as any;
// name: args.name, const placeholderChecklist = {
// boardId: 1, publicId: `PLACEHOLDER_${generateUID()}`,
// boardPublicId, name: args.name,
// cards: [], index: old.checklists.length,
// index: oldBoard.lists.length, items: [] as {
// }; publicId: string;
// const updatedLists = [...oldBoard.lists, newList]; title: string;
// return { ...oldBoard, lists: updatedLists }; completed: boolean;
// }); index: number;
// return { previousState: currentState }; }[],
};
return {
...old,
checklists: [...old.checklists, placeholderChecklist],
} as typeof old;
});
return { previous };
}, },
onError: (_error, _newList, context) => { onError: (_error, vars, ctx) => {
// utils.board.byId.setData(queryParams, context?.previousState); if (ctx?.previous)
utils.card.byId.setData(
{ cardPublicId: vars.cardPublicId },
ctx.previous,
);
showPopup({ showPopup({
header: t`Unable to create checklist`, header: t`Unable to create checklist`,
message: t`Please try again later, or contact customer support.`, message: t`Please try again later, or contact customer support.`,
icon: "error", icon: "error",
}); });
}, },
// onSettled: async () => { onSettled: async (_data, _error, vars) => {
// await utils.board.byId.invalidate(queryParams); await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
// }, },
}); });
useEffect(() => { useEffect(() => {

View File

@@ -7,6 +7,8 @@ import {
cards, cards,
cardsToLabels, cardsToLabels,
cardToWorkspaceMembers, cardToWorkspaceMembers,
checklistItems,
checklists,
labels, labels,
lists, lists,
workspaceMembers, workspaceMembers,
@@ -164,6 +166,27 @@ export const getByPublicId = async (
}, },
}, },
}, },
checklists: {
columns: {
publicId: true,
name: true,
index: true,
},
where: isNull(checklists.deletedAt),
orderBy: asc(checklists.index),
with: {
items: {
columns: {
publicId: true,
title: true,
completed: true,
index: true,
},
where: isNull(checklistItems.deletedAt),
orderBy: asc(checklistItems.index),
},
},
},
}, },
where: and( where: and(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined, cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,