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,
HiLink,
HiOutlineCheckCircle,
HiOutlineDocumentDuplicate,
HiOutlineTrash,
} from "react-icons/hi2";
@@ -13,6 +14,7 @@ import Dropdown from "~/components/Dropdown";
import { usePermissions } from "~/hooks/usePermissions";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
export default function CardDropdown({
cardPublicId,
@@ -20,19 +22,44 @@ export default function CardDropdown({
boardPublicId,
cardCreatedBy,
ticketNumber,
listPublicId,
cardIndex,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
listPublicId?: string;
cardIndex?: number;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
const { canEditCard, canDeleteCard } = usePermissions();
const { data: session } = authClient.useSession();
const utils = api.useUtils();
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 path =
isTemplate && boardPublicId
@@ -99,6 +126,24 @@ export default function CardDropdown({
<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

View File

@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
? router.query.cardId[0]
: router.query.cardId;
const { data: card, isLoading, error } = api.card.byId.useQuery(
const {
data: card,
isLoading,
error,
} = api.card.byId.useQuery(
{ cardPublicId: cardId ?? "" },
{ enabled: !!cardId && cardId.length >= 12 },
);
@@ -340,14 +344,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{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">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
{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">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Dropdown
@@ -356,10 +361,13 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
card.cardNumber != null &&
card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null
}
listPublicId={card?.list.publicId}
cardIndex={card?.index}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

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

View File

@@ -107,7 +107,12 @@ export const create = async (
cardNumber,
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");
@@ -321,8 +326,7 @@ export const bulkCreate = async (
.where(eq(workspaces.id, workspaceId))
.returning({ cardCounter: workspaces.cardCounter });
if (!counterResult)
throw new Error(`Workspace ${workspaceId} not found`);
if (!counterResult) throw new Error(`Workspace ${workspaceId} not found`);
const last = counterResult.cardCounter;
const start = last - count + 1;
@@ -486,6 +490,7 @@ export const getWithListAndMembersByPublicId = async (
dueDate: true,
createdBy: true,
cardNumber: true,
index: true,
},
with: {
labels: {