feat: duplicate card via dropdown

This commit is contained in:
Henry
2026-05-29 23:27:17 +01:00
parent 22899cd14c
commit 6b8ada8d90
4 changed files with 72 additions and 13 deletions

View File

@@ -4,6 +4,7 @@ import {
HiHashtag, HiHashtag,
HiLink, HiLink,
HiOutlineCheckCircle, HiOutlineCheckCircle,
HiOutlineDocumentDuplicate,
HiOutlineTrash, HiOutlineTrash,
} from "react-icons/hi2"; } from "react-icons/hi2";
@@ -13,6 +14,7 @@ import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions"; import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup"; import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function CardDropdown({ export default function CardDropdown({
cardPublicId, cardPublicId,
@@ -20,19 +22,44 @@ export default function CardDropdown({
boardPublicId, boardPublicId,
cardCreatedBy, cardCreatedBy,
ticketNumber, ticketNumber,
listPublicId,
cardIndex,
}: { }: {
cardPublicId: string; cardPublicId: string;
isTemplate?: boolean; isTemplate?: boolean;
boardPublicId?: string; boardPublicId?: string;
cardCreatedBy?: string | null; cardCreatedBy?: string | null;
ticketNumber?: string | null; ticketNumber?: string | null;
listPublicId?: string;
cardIndex?: number;
}) { }) {
const { openModal } = useModal(); const { openModal } = useModal();
const { showPopup } = usePopup(); const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions(); const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession(); const { data: session } = authClient.useSession();
const utils = api.useUtils();
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy; const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
const duplicateCard = api.card.duplicate.useMutation({
onSuccess: () => {
showPopup({
header: t`Card duplicated`,
icon: "success",
message: t`Card duplicated successfully.`,
});
},
onError: () => {
showPopup({
header: t`Unable to duplicate card`,
icon: "error",
message: t`Please try again.`,
});
},
onSettled: async () => {
await utils.board.byId.invalidate();
},
});
const handleCopyCardLink = async () => { const handleCopyCardLink = async () => {
const path = const path =
isTemplate && boardPublicId isTemplate && boardPublicId
@@ -99,6 +126,24 @@ export default function CardDropdown({
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" /> <HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
), ),
}, },
{
label: t`Duplicate card`,
action: () => {
if (!listPublicId || cardIndex === undefined) return;
duplicateCard.mutate({
cardPublicId,
listPublicId,
index: cardIndex + 1,
copyLabels: true,
copyMembers: true,
copyChecklists: true,
});
},
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
disabled: duplicateCard.isPending || !listPublicId,
},
] ]
: []), : []),
...(canDeleteCard || isCreator ...(canDeleteCard || isCreator

View File

@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0] ? router.query.cardId[0]
: router.query.cardId; : router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery( const {
data: card,
isLoading,
error,
} = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" }, { cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 }, { enabled: !!cardId && cardId.length >= 12 },
); );
@@ -340,14 +344,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
> >
{board?.name} {board?.name}
</Link> </Link>
{card.cardNumber != null && card.list.board.workspace.cardPrefix && ( {card.cardNumber != null &&
<> card.list.board.workspace.cardPrefix && (
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" /> <>
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800"> <IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
{card.list.board.workspace.cardPrefix}-{card.cardNumber} <span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
</span> {card.list.board.workspace.cardPrefix}-{card.cardNumber}
</> </span>
)} </>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Dropdown <Dropdown
@@ -356,10 +361,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
boardPublicId={boardId} boardPublicId={boardId}
cardCreatedBy={card?.createdBy} cardCreatedBy={card?.createdBy}
ticketNumber={ ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix card.cardNumber != null &&
card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}` ? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null : null
} }
listPublicId={card?.list.publicId}
cardIndex={card?.index}
/> />
<Link <Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`} href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -47,6 +47,7 @@ export const cardDetailSchema = z.object({
title: z.string(), title: z.string(),
description: z.string().nullable(), description: z.string().nullable(),
cardNumber: z.number().nullable(), cardNumber: z.number().nullable(),
index: z.number(),
dueDate: z.date().nullable(), dueDate: z.date().nullable(),
createdBy: z.string().nullable(), createdBy: z.string().nullable(),
labels: z.array(labelSchema), labels: z.array(labelSchema),

View File

@@ -107,7 +107,12 @@ export const create = async (
cardNumber, cardNumber,
dueDate: cardInput.dueDate ?? null, dueDate: cardInput.dueDate ?? null,
}) })
.returning({ id: cards.id, listId: cards.listId, publicId: cards.publicId, cardNumber: cards.cardNumber }); .returning({
id: cards.id,
listId: cards.listId,
publicId: cards.publicId,
cardNumber: cards.cardNumber,
});
if (!result[0]) throw new Error("Unable to create card"); if (!result[0]) throw new Error("Unable to create card");
@@ -321,8 +326,7 @@ export const bulkCreate = async (
.where(eq(workspaces.id, workspaceId)) .where(eq(workspaces.id, workspaceId))
.returning({ cardCounter: workspaces.cardCounter }); .returning({ cardCounter: workspaces.cardCounter });
if (!counterResult) if (!counterResult) throw new Error(`Workspace ${workspaceId} not found`);
throw new Error(`Workspace ${workspaceId} not found`);
const last = counterResult.cardCounter; const last = counterResult.cardCounter;
const start = last - count + 1; const start = last - count + 1;
@@ -486,6 +490,7 @@ export const getWithListAndMembersByPublicId = async (
dueDate: true, dueDate: true,
createdBy: true, createdBy: true,
cardNumber: true, cardNumber: true,
index: true,
}, },
with: { with: {
labels: { labels: {