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,10 +45,12 @@ export const formatMemberDisplayName = (
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://")) {
return imageOrKey;
}
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
};

View File

@@ -1,5 +1,6 @@
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
import CircularProgress from "~/components/CircularProgress";
import LabelIcon from "~/components/LabelIcon";
import { getAvatarUrl } from "~/utils/helpers";
@@ -7,6 +8,7 @@ const Card = ({
title,
labels,
members,
checklists,
}: {
title: string;
labels: { name: string; colourCode: string | null }[];
@@ -15,7 +17,28 @@ const Card = ({
email: string;
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 (
<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>
@@ -29,21 +52,37 @@ const Card = ({
/>
))}
</div>
<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}
<div className="flex items-center justify-end gap-1">
{checklists.length > 0 && (
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-600">
<CircularProgress
progress={progress || 2}
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>
) : null}

View File

@@ -238,15 +238,15 @@ export default function BoardPage() {
const renderModalContent = () => {
return (
<>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
>
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
</Modal>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LIST"}
>
<DeleteListConfirmation
@@ -255,8 +255,8 @@ export default function BoardPage() {
/>
</Modal>
<Modal
modalSize="md"
<Modal
modalSize="md"
isVisible={isOpen && modalContentType === "NEW_CARD"}
>
<NewCardForm
@@ -266,8 +266,8 @@ export default function BoardPage() {
/>
</Modal>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LIST"}
>
<NewListForm
@@ -276,22 +276,22 @@ export default function BoardPage() {
/>
</Modal>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
>
<NewWorkspaceForm />
</Modal>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_LABEL"}
>
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
</Modal>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
>
<LabelForm
@@ -301,8 +301,8 @@ export default function BoardPage() {
/>
</Modal>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
>
<DeleteLabelConfirmation
@@ -311,8 +311,8 @@ export default function BoardPage() {
/>
</Modal>
<Modal
modalSize="sm"
<Modal
modalSize="sm"
isVisible={isOpen && modalContentType === "UPDATE_BOARD_SLUG"}
>
<UpdateBoardSlugForm
@@ -492,6 +492,7 @@ export default function BoardPage() {
title={card.title}
labels={card.labels}
members={card.members}
checklists={card.checklists}
/>
</Link>
)}

View File

@@ -22,7 +22,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
const utils = api.useUtils();
const { register, handleSubmit, reset, setValue, watch } =
const { register, handleSubmit, reset, watch } =
useForm<NewChecklistFormInput>({
defaultValues: {
name: "Checklist",
@@ -32,34 +32,45 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
const createChecklist = api.checklist.create.useMutation({
onMutate: async (args) => {
// await utils.board.byId.cancel();
// const currentState = utils.board.byId.getData(queryParams);
// utils.board.byId.setData(queryParams, (oldBoard) => {
// if (!oldBoard) return oldBoard;
// const newList = {
// publicId: generateUID(),
// name: args.name,
// boardId: 1,
// boardPublicId,
// cards: [],
// index: oldBoard.lists.length,
// };
// const updatedLists = [...oldBoard.lists, newList];
// return { ...oldBoard, lists: updatedLists };
// });
// return { previousState: currentState };
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 };
},
onError: (_error, _newList, context) => {
// utils.board.byId.setData(queryParams, context?.previousState);
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 () => {
// await utils.board.byId.invalidate(queryParams);
// },
onSettled: async (_data, _error, vars) => {
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
},
});
useEffect(() => {

View File

@@ -7,6 +7,8 @@ import {
cards,
cardsToLabels,
cardToWorkspaceMembers,
checklistItems,
checklists,
labels,
lists,
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(
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,