From 3a44957662c288cc9916fc11a930c554e6fe3b42 Mon Sep 17 00:00:00 2001 From: Charity <81490612+charitea@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:32:56 -0400 Subject: [PATCH] feat: add card context menu and duplication functionality (#381) * feat: add card context menu and duplication functionality * Implemented a context menu for cards allowing actions such as copying links, duplicating cards, and managing members, labels, and due dates * Added modals for card duplication and context actions. * Updated API with a new endpoint for duplicating cards, including options for copying labels, members, and checklists * refactor: remove cardPublicId from context menu and related components * Removed cardPublicId prop from CardContextMenu and CardContextMembersModal for cleaner context handling * Updated scrollbar styling * feat: add delete card functionality to context menu --- .../components/CardContextDueDateModal.tsx | 41 +++ .../components/CardContextDuplicateModal.tsx | 284 ++++++++++++++++++ .../components/CardContextLabelsModal.tsx | 53 ++++ .../components/CardContextMembersModal.tsx | 73 +++++ .../board/components/CardContextMenu.tsx | 130 ++++++++ .../components/CardContextMoveListModal.tsx | 101 +++++++ apps/web/src/views/board/index.tsx | 160 +++++++++- .../views/card/components/LabelSelector.tsx | 1 + .../views/card/components/ListSelector.tsx | 1 + .../views/card/components/MemberSelector.tsx | 1 + packages/api/src/routers/card.ts | 173 +++++++++++ 11 files changed, 1008 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/views/board/components/CardContextDueDateModal.tsx create mode 100644 apps/web/src/views/board/components/CardContextDuplicateModal.tsx create mode 100644 apps/web/src/views/board/components/CardContextLabelsModal.tsx create mode 100644 apps/web/src/views/board/components/CardContextMembersModal.tsx create mode 100644 apps/web/src/views/board/components/CardContextMenu.tsx create mode 100644 apps/web/src/views/board/components/CardContextMoveListModal.tsx diff --git a/apps/web/src/views/board/components/CardContextDueDateModal.tsx b/apps/web/src/views/board/components/CardContextDueDateModal.tsx new file mode 100644 index 00000000..ad7a9b3b --- /dev/null +++ b/apps/web/src/views/board/components/CardContextDueDateModal.tsx @@ -0,0 +1,41 @@ +import { t } from "@lingui/core/macro"; + +import { useModal } from "~/providers/modal"; +import { api } from "~/utils/api"; +import { DueDateSelector } from "~/views/card/components/DueDateSelector"; + +export function CardContextDueDateModal() { + const { entityId: cardPublicId, closeModal } = useModal(); + + const { data: card, isLoading } = api.card.byId.useQuery( + { cardPublicId: cardPublicId ?? "" }, + { enabled: !!cardPublicId && cardPublicId.length >= 12 }, + ); + + if (!cardPublicId) return null; + + return ( +
+

+ {t`Set due date`} +

+ {isLoading ? ( +
+ ) : ( + + )} +
+ +
+
+ ); +} diff --git a/apps/web/src/views/board/components/CardContextDuplicateModal.tsx b/apps/web/src/views/board/components/CardContextDuplicateModal.tsx new file mode 100644 index 00000000..3186c9f6 --- /dev/null +++ b/apps/web/src/views/board/components/CardContextDuplicateModal.tsx @@ -0,0 +1,284 @@ +import { + Listbox, + ListboxButton, + ListboxOption, + ListboxOptions, + Transition, +} from "@headlessui/react"; +import { t } from "@lingui/core/macro"; +import { useState } from "react"; +import { HiChevronDown } from "react-icons/hi2"; +import { twMerge } from "tailwind-merge"; + +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 CardContextDuplicateModalProps { + boardPublicId?: string; + isTemplate?: boolean; +} + +export function CardContextDuplicateModal({ + boardPublicId: boardPublicIdProp, + isTemplate: isTemplateProp, +}: CardContextDuplicateModalProps = {}) { + const { entityId: cardPublicId, closeModal, getModalState } = useModal(); + const { showPopup } = usePopup(); + const utils = api.useUtils(); + + const modalState = getModalState("CARD_CONTEXT_DUPLICATE") as + | { boardPublicId: string; isTemplate?: boolean } + | undefined; + const boardPublicId = + boardPublicIdProp ?? modalState?.boardPublicId ?? ""; + const isTemplate = isTemplateProp ?? modalState?.isTemplate ?? false; + + const [listPublicId, setListPublicId] = useState(""); + const [copyLabels, setCopyLabels] = useState(true); + const [copyMembers, setCopyMembers] = useState(true); + const [copyChecklists, setCopyChecklists] = useState(true); + const [position, setPosition] = useState(""); + const [title, setTitle] = useState(""); + + const { data: card, isLoading: isCardLoading } = api.card.byId.useQuery( + { cardPublicId: cardPublicId ?? "" }, + { enabled: !!cardPublicId && cardPublicId.length >= 12 }, + ); + + const boardType = isTemplate ? "template" : "regular"; + const { data: board } = api.board.byId.useQuery( + { boardPublicId, type: boardType }, + { enabled: !!boardPublicId }, + ); + const lists = board?.lists ?? []; + const listOptions = lists.map((l) => ({ publicId: l.publicId, name: l.name })); + const currentListPublicId = card?.list?.publicId; + const hasLabels = (card?.labels?.length ?? 0) > 0; + const hasMembers = (card?.members?.length ?? 0) > 0; + const hasChecklists = (card?.checklists?.length ?? 0) > 0; + const hasAnyCopyOption = hasLabels || hasMembers || hasChecklists; + + const duplicateCard = api.card.duplicate.useMutation({ + onSuccess: () => { + showPopup({ + header: t`Card duplicated`, + icon: "success", + message: t`The card has been duplicated.`, + }); + closeModal(); + }, + onError: () => { + showPopup({ + header: t`Unable to duplicate card`, + message: t`Please try again.`, + icon: "error", + }); + }, + onSettled: async () => { + await utils.board.byId.invalidate(); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!cardPublicId || !listPublicId) return; + const indexNum = + position === "" ? undefined : parseInt(position, 10); + if ( + position !== "" && + (indexNum === undefined || isNaN(indexNum) || indexNum < 0) + ) + return; + duplicateCard.mutate({ + cardPublicId, + listPublicId, + copyLabels, + copyMembers, + copyChecklists, + ...(typeof indexNum === "number" && { index: indexNum }), + title: title.trim() || undefined, + }); + }; + + if (!cardPublicId) return null; + + return ( +
+

+ {t`Duplicate card`} +

+ +
+
+ + +
+ + + {listPublicId + ? listOptions.find((o) => o.publicId === listPublicId) + ?.name + : t`Select a list`} + + + + + + + +
+ {listOptions.map((option) => { + const isCurrentList = + option.publicId === currentListPublicId; + return ( + + twMerge( + "relative select-none py-2 pl-3 pr-9 text-sm", + isCurrentList + ? "cursor-default opacity-50" + : "cursor-pointer", + !isCurrentList && focus + ? "bg-light-200 text-light-1000 dark:bg-dark-400 dark:text-dark-1000" + : "text-light-900 dark:text-dark-900", + ) + } + > + {option.name} + + ); + })} +
+
+
+
+
+
+ +
+ + setTitle(e.target.value)} + placeholder={card?.title ?? ""} + className="w-full" + /> +
+ +
+ + setPosition(e.target.value)} + placeholder={t`End of list`} + className="w-full" + /> +
+ + {hasAnyCopyOption && ( +
+ {hasLabels && ( +
+ + + {t`Copy labels`} + +
+ )} + {hasMembers && ( +
+ + + {t`Copy members`} + +
+ )} + {hasChecklists && ( +
+ + + {t`Copy checklists`} + +
+ )} +
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/views/board/components/CardContextLabelsModal.tsx b/apps/web/src/views/board/components/CardContextLabelsModal.tsx new file mode 100644 index 00000000..e1cfb3f0 --- /dev/null +++ b/apps/web/src/views/board/components/CardContextLabelsModal.tsx @@ -0,0 +1,53 @@ +import { t } from "@lingui/core/macro"; + +import { useModal } from "~/providers/modal"; +import { api } from "~/utils/api"; +import LabelIcon from "~/components/LabelIcon"; +import LabelSelector from "~/views/card/components/LabelSelector"; + +export function CardContextLabelsModal() { + const { entityId: cardPublicId, closeModal } = useModal(); + + const { data: card, isLoading } = api.card.byId.useQuery( + { cardPublicId: cardPublicId ?? "" }, + { enabled: !!cardPublicId && cardPublicId.length >= 12 }, + ); + + const boardLabels = card?.list?.board?.labels ?? []; + const selectedLabels = card?.labels ?? []; + + const formattedLabels = boardLabels.map((label) => ({ + key: label.publicId, + value: label.name, + selected: selectedLabels.some((l) => l.publicId === label.publicId), + leftIcon: , + })); + + if (!cardPublicId) return null; + + return ( +
+

+ {t`Labels`} +

+ {isLoading ? ( +
+ ) : ( + + )} +
+ +
+
+ ); +} diff --git a/apps/web/src/views/board/components/CardContextMembersModal.tsx b/apps/web/src/views/board/components/CardContextMembersModal.tsx new file mode 100644 index 00000000..b67c1414 --- /dev/null +++ b/apps/web/src/views/board/components/CardContextMembersModal.tsx @@ -0,0 +1,73 @@ +import { t } from "@lingui/core/macro"; + +import { useModal } from "~/providers/modal"; +import { api } from "~/utils/api"; +import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers"; +import Avatar from "~/components/Avatar"; +import MemberSelector from "~/views/card/components/MemberSelector"; + +export function CardContextMembersModal() { + const { entityId: cardPublicId, closeModal } = useModal(); + + const { data: card, isLoading } = api.card.byId.useQuery( + { cardPublicId: cardPublicId ?? "" }, + { enabled: !!cardPublicId && cardPublicId.length >= 12 }, + ); + + const board = card?.list?.board; + const workspaceMembers = board?.workspace?.members ?? []; + const selectedMembers = card?.members ?? []; + + const formattedMembers = workspaceMembers.map((member) => { + const isSelected = selectedMembers.some( + (m) => m.publicId === member.publicId, + ); + return { + key: member.publicId, + value: formatMemberDisplayName( + member.user?.name ?? null, + member.user?.email ?? member.email, + ), + imageUrl: member.user?.image ? getAvatarUrl(member.user.image) : undefined, + selected: isSelected, + leftIcon: ( + + ), + }; + }); + + if (!cardPublicId) return null; + + return ( +
+

+ {t`Manage members`} +

+ {isLoading ? ( +
+ ) : ( + + )} +
+ +
+
+ ); +} diff --git a/apps/web/src/views/board/components/CardContextMenu.tsx b/apps/web/src/views/board/components/CardContextMenu.tsx new file mode 100644 index 00000000..3b84e3cd --- /dev/null +++ b/apps/web/src/views/board/components/CardContextMenu.tsx @@ -0,0 +1,130 @@ +import { t } from "@lingui/core/macro"; +import { useEffect, useRef } from "react"; +import { + HiLink, + HiOutlineCalendar, + HiOutlineDocumentDuplicate, + HiOutlineTag, + HiOutlineTrash, + HiOutlineUserGroup, + HiOutlineArrowRightCircle, +} from "react-icons/hi2"; + +export type CardContextMenuAction = + | "members" + | "move" + | "labels" + | "dueDate" + | "copyLink" + | "duplicate" + | "delete"; + +interface CardContextMenuProps { + x: number; + y: number; + onClose: () => void; + onAction: (action: CardContextMenuAction) => void; + canEdit: boolean; +} + +const MENU_ITEMS: { + action: CardContextMenuAction; + label: string; + icon: React.ReactNode; + requiresEdit: boolean; +}[] = [ + { + action: "members", + label: t`Manage members`, + icon: , + requiresEdit: true, + }, + { + action: "move", + label: t`Move to another list`, + icon: , + requiresEdit: true, + }, + { + action: "labels", + label: t`Add / edit label`, + icon: , + requiresEdit: true, + }, + { + action: "dueDate", + label: t`Set due date`, + icon: , + requiresEdit: true, + }, + { + action: "copyLink", + label: t`Copy link to card`, + icon: , + requiresEdit: false, + }, + { + action: "duplicate", + label: t`Duplicate card`, + icon: , + requiresEdit: true, + }, + { + action: "delete", + label: t`Delete card`, + icon: , + requiresEdit: true, + }, +]; + +export function CardContextMenu({ + x, + y, + onClose, + onAction, + canEdit, +}: CardContextMenuProps) { + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [onClose]); + + const items = MENU_ITEMS.filter((item) => !item.requiresEdit || canEdit); + + return ( +
+ {items.map(({ action, label, icon }) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/views/board/components/CardContextMoveListModal.tsx b/apps/web/src/views/board/components/CardContextMoveListModal.tsx new file mode 100644 index 00000000..3b66380e --- /dev/null +++ b/apps/web/src/views/board/components/CardContextMoveListModal.tsx @@ -0,0 +1,101 @@ +import { t } from "@lingui/core/macro"; + +import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; +import { invalidateCard } from "~/utils/cardInvalidation"; + +export function CardContextMoveListModal() { + const { entityId: cardPublicId, closeModal } = useModal(); + const { showPopup } = usePopup(); + const utils = api.useUtils(); + + const { data: card, isLoading } = api.card.byId.useQuery( + { cardPublicId: cardPublicId ?? "" }, + { enabled: !!cardPublicId && cardPublicId.length >= 12 }, + ); + + const updateCardList = api.card.update.useMutation({ + onMutate: async (vars) => { + if (!cardPublicId) return undefined; + await utils.card.byId.cancel(); + const previous = utils.card.byId.getData({ cardPublicId }); + utils.card.byId.setData({ cardPublicId }, (old) => { + if (!old) return old; + const list = old.list.board?.lists?.find( + (l) => l.publicId === vars.listPublicId, + ); + if (!list) return old; + return { + ...old, + list: { ...old.list, publicId: list.publicId, name: list.name }, + }; + }); + return { previous }; + }, + onError: (_err, _vars, ctx) => { + if (cardPublicId && ctx?.previous) { + utils.card.byId.setData({ cardPublicId }, ctx.previous); + } + showPopup({ + header: t`Unable to move card`, + message: t`Please try again.`, + icon: "error", + }); + }, + onSettled: async () => { + if (cardPublicId) { + await invalidateCard(utils, cardPublicId); + await utils.board.byId.invalidate(); + } + }, + }); + + const lists = card?.list?.board?.lists ?? []; + const currentListPublicId = card?.list?.publicId; + + const handleSelectList = (listPublicId: string) => { + if (listPublicId === currentListPublicId || !cardPublicId) return; + updateCardList.mutate( + { cardPublicId, listPublicId, index: 0 }, + { onSuccess: closeModal }, + ); + }; + + if (!cardPublicId) return null; + + return ( +
+

+ {t`Move to list`} +

+ {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : ( +
+
    + {lists.map((list) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index d0d400a6..de0841e3 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -15,6 +15,7 @@ import { import type { UpdateBoardInput } from "@kan/api/types"; +import type { CardContextMenuAction } from "./components/CardContextMenu"; import Button from "~/components/Button"; import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation"; import { LabelForm } from "~/components/LabelForm"; @@ -26,16 +27,23 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab import { Tooltip } from "~/components/Tooltip"; import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal"; import { useDragToScroll } from "~/hooks/useDragToScroll"; -import { useScrollRestore } from "~/hooks/useScrollRestore"; import { usePermissions } from "~/hooks/usePermissions"; +import { useScrollRestore } from "~/hooks/useScrollRestore"; import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; import { formatToArray } from "~/utils/helpers"; +import { DeleteCardConfirmation } from "~/views/card/components/DeleteCardConfirmation"; import BoardDropdown from "./components/BoardDropdown"; import Card from "./components/Card"; +import { CardContextDueDateModal } from "./components/CardContextDueDateModal"; +import { CardContextDuplicateModal } from "./components/CardContextDuplicateModal"; +import { CardContextLabelsModal } from "./components/CardContextLabelsModal"; +import { CardContextMembersModal } from "./components/CardContextMembersModal"; +import { CardContextMenu } from "./components/CardContextMenu"; +import { CardContextMoveListModal } from "./components/CardContextMoveListModal"; import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation"; import { DeleteListConfirmation } from "./components/DeleteListConfirmation"; import Filters from "./components/Filters"; @@ -55,11 +63,18 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { const utils = api.useUtils(); const { showPopup } = usePopup(); const { workspace } = useWorkspace(); - const { openModal, modalContentType, entityId, isOpen } = useModal(); + const { openModal, modalContentType, entityId, isOpen, setModalState } = + useModal(); const [selectedPublicListId, setSelectedPublicListId] = useState(""); const [isInitialLoading, setIsInitialLoading] = useState(true); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + cardPublicId: string; + } | null>(null); + const { ref: scrollRef, onMouseDown } = useDragToScroll({ enabled: true, direction: "horizontal", @@ -134,7 +149,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { // Redirect to 404 if board doesn't exist useEffect(() => { if (router.isReady && boardId && !isQueryLoading) { - if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) { + if ( + error?.data?.code === "NOT_FOUND" || + (!boardData && !isQueryLoading) + ) { router.replace("/404"); } } @@ -152,7 +170,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { const isLoading = isInitialLoading || isQueryLoading; - useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0); + useScrollRestore( + boardId, + scrollRef, + router, + !isLoading && (boardData?.lists.length ?? 0) > 0, + ); const updateListMutation = api.list.update.useMutation({ onMutate: async (args) => { @@ -267,6 +290,56 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { setSelectedPublicListId(publicBoardId); }; + const handleCardContextMenuAction = (action: CardContextMenuAction) => { + const cardPublicId = contextMenu?.cardPublicId; + if (!cardPublicId) return; + setContextMenu(null); + if (action === "copyLink") { + const path = isTemplate + ? `/templates/${boardId}/cards/${cardPublicId}` + : `/cards/${cardPublicId}`; + const url = `${typeof window !== "undefined" ? window.location.origin : ""}${path}`; + void navigator.clipboard.writeText(url).then( + () => { + showPopup({ + header: t`Link copied`, + icon: "success", + message: t`Card URL copied to clipboard`, + }); + }, + () => { + showPopup({ + header: t`Unable to copy link`, + icon: "error", + message: t`Please try again.`, + }); + }, + ); + return; + } + if (action === "duplicate") { + setModalState("CARD_CONTEXT_DUPLICATE", { + boardPublicId: boardId ?? "", + isTemplate: !!isTemplate, + }); + openModal("CARD_CONTEXT_DUPLICATE", cardPublicId); + return; + } + if (action === "delete") { + openModal("DELETE_CARD", cardPublicId); + return; + } + const modalType = + action === "members" + ? "CARD_CONTEXT_MEMBERS" + : action === "move" + ? "CARD_CONTEXT_MOVE_LIST" + : action === "labels" + ? "CARD_CONTEXT_LABELS" + : "CARD_CONTEXT_DUE_DATE"; + openModal(modalType, cardPublicId); + }; + const onDragEnd = ({ source: _source, destination, @@ -403,6 +476,49 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { > + + + + + + + + + + + + + + + + + + + ); }; @@ -605,18 +721,33 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { ) e.preventDefault(); }} + onContextMenu={(e) => { + if ( + card.publicId.startsWith( + "PLACEHOLDER", + ) + ) + return; + e.preventDefault(); + setContextMenu({ + x: e.clientX, + y: e.clientY, + cardPublicId: card.publicId, + }); + }} key={card.publicId} href={ isTemplate ? `/templates/${boardId}/cards/${card.publicId}` : `/cards/${card.publicId}` } - className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith( - "PLACEHOLDER", - ) - ? "pointer-events-none" - : "" - }`} + className={`mb-2 flex !cursor-pointer flex-col ${ + card.publicId.startsWith( + "PLACEHOLDER", + ) + ? "pointer-events-none" + : "" + }`} ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} @@ -653,6 +784,15 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { ) : null}
+ {contextMenu && ( + setContextMenu(null)} + onAction={handleCardContextMenuAction} + canEdit={!!canEditCard} + /> + )} {renderModalContent()}
diff --git a/apps/web/src/views/card/components/LabelSelector.tsx b/apps/web/src/views/card/components/LabelSelector.tsx index 55fed87b..0bc7347c 100644 --- a/apps/web/src/views/card/components/LabelSelector.tsx +++ b/apps/web/src/views/card/components/LabelSelector.tsx @@ -78,6 +78,7 @@ export default function LabelSelector({ }, onSettled: async () => { await invalidateCard(utils, cardPublicId); + await utils.board.byId.invalidate(); }, }); diff --git a/apps/web/src/views/card/components/ListSelector.tsx b/apps/web/src/views/card/components/ListSelector.tsx index bed786b4..0ac51b14 100644 --- a/apps/web/src/views/card/components/ListSelector.tsx +++ b/apps/web/src/views/card/components/ListSelector.tsx @@ -58,6 +58,7 @@ export default function ListSelector({ }, onSettled: async () => { await invalidateCard(utils, cardPublicId); + await utils.board.byId.invalidate(); }, }); diff --git a/apps/web/src/views/card/components/MemberSelector.tsx b/apps/web/src/views/card/components/MemberSelector.tsx index a2dcf00b..48a3c1b1 100644 --- a/apps/web/src/views/card/components/MemberSelector.tsx +++ b/apps/web/src/views/card/components/MemberSelector.tsx @@ -85,6 +85,7 @@ export default function MemberSelector({ }, onSettled: async () => { await invalidateCard(utils, cardPublicId); + await utils.board.byId.invalidate(); }, }); diff --git a/packages/api/src/routers/card.ts b/packages/api/src/routers/card.ts index e9253bd8..66550496 100644 --- a/packages/api/src/routers/card.ts +++ b/packages/api/src/routers/card.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import * as cardRepo from "@kan/db/repository/card.repo"; import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo"; import * as cardCommentRepo from "@kan/db/repository/cardComment.repo"; +import * as checklistRepo from "@kan/db/repository/checklist.repo"; import * as labelRepo from "@kan/db/repository/label.repo"; import * as listRepo from "@kan/db/repository/list.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; @@ -1184,4 +1185,176 @@ export const cardRouter = createTRPCRouter({ return { success: true }; }), + duplicate: protectedProcedure + .meta({ + openapi: { + summary: "Duplicate a card", + method: "POST", + path: "/cards/{cardPublicId}/duplicate", + description: "Duplicates a card to a target list with optional options", + tags: ["Cards"], + protect: true, + }, + }) + .input( + z.object({ + cardPublicId: z.string().min(12), + listPublicId: z.string().min(12), + index: z.number().int().min(0).optional(), + title: z.string().min(1).max(2000).optional(), + copyLabels: z.boolean(), + copyMembers: z.boolean(), + copyChecklists: z.boolean(), + }), + ) + .output( + z.object({ + publicId: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const sourceCardMeta = await cardRepo.getWorkspaceAndCardIdByCardPublicId( + ctx.db, + input.cardPublicId, + ); + + if (!sourceCardMeta) + throw new TRPCError({ + message: `Card with public ID ${input.cardPublicId} not found`, + code: "NOT_FOUND", + }); + + await assertPermission( + ctx.db, + userId, + sourceCardMeta.workspaceId, + "card:create", + ); + + const targetList = await listRepo.getWorkspaceAndListIdByListPublicId( + ctx.db, + input.listPublicId, + ); + + if (!targetList) + throw new TRPCError({ + message: `List with public ID ${input.listPublicId} not found`, + code: "NOT_FOUND", + }); + + if (targetList.workspaceId !== sourceCardMeta.workspaceId) + throw new TRPCError({ + message: `Target list must be in the same workspace`, + code: "BAD_REQUEST", + }); + + const sourceCard = await cardRepo.getWithListAndMembersByPublicId( + ctx.db, + input.cardPublicId, + ); + + if (!sourceCard) + throw new TRPCError({ + message: `Card with public ID ${input.cardPublicId} not found`, + code: "NOT_FOUND", + }); + + const newCard = await cardRepo.create(ctx.db, { + title: input.title ?? sourceCard.title, + description: sourceCard.description ?? "", + createdBy: userId, + listId: targetList.id, + position: "end", + dueDate: sourceCard.dueDate ?? null, + }); + + if (input.index !== undefined && input.index >= 0) { + await cardRepo.reorder(ctx.db, { + cardId: newCard.id, + newIndex: input.index, + newListId: targetList.id, + }); + } + + if (input.copyLabels && sourceCard.labels?.length) { + const labelPublicIds = sourceCard.labels.map((l) => l.publicId); + const labels = await labelRepo.getAllByPublicIds(ctx.db, labelPublicIds); + if (labels.length) { + const labelsInsert = labels.map((label) => ({ + cardId: newCard.id, + labelId: label.id, + })); + await cardRepo.bulkCreateCardLabelRelationships(ctx.db, labelsInsert); + const cardActivitesInsert = labels.map((cardLabel) => ({ + type: "card.updated.label.added" as const, + cardId: newCard.id, + labelId: cardLabel.id, + createdBy: userId, + })); + await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert); + } + } + + if (input.copyMembers && sourceCard.members?.length) { + const memberPublicIds = sourceCard.members.map((m) => m.publicId); + const members = await workspaceRepo.getAllMembersByPublicIds( + ctx.db, + memberPublicIds, + ); + if (members.length) { + const membersInsert = members.map((member) => ({ + cardId: newCard.id, + workspaceMemberId: member.id, + })); + await cardRepo.bulkCreateCardWorkspaceMemberRelationships( + ctx.db, + membersInsert, + ); + const cardActivitesInsert = members.map((member) => ({ + type: "card.updated.member.added" as const, + cardId: newCard.id, + workspaceMemberId: member.id, + createdBy: userId, + })); + await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert); + } + } + + if (input.copyChecklists && sourceCard.checklists?.length) { + for (const checklist of sourceCard.checklists) { + const newChecklist = await checklistRepo.create(ctx.db, { + cardId: newCard.id, + name: checklist.name, + createdBy: userId, + }); + if (!newChecklist?.id) continue; + if (checklist.items?.length) { + for (const item of checklist.items) { + await checklistRepo.createItem(ctx.db, { + checklistId: newChecklist.id, + title: item.title, + createdBy: userId, + completed: item.completed ?? false, + }); + } + } + await cardActivityRepo.create(ctx.db, { + type: "card.updated.checklist.added", + cardId: newCard.id, + toTitle: newChecklist.name, + createdBy: userId, + }); + } + } + + return { publicId: newCard.publicId }; + }), });