From 18e85ef6be76e45272ac5631a7c2d3c0ea1106fa Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 8 Oct 2025 21:29:24 +0100 Subject: [PATCH] feat: add template pages --- .../pages/templates/[...boardId]/index.tsx | 17 ++ apps/web/src/pages/templates/index.tsx | 4 +- .../views/board/components/BoardDropdown.tsx | 69 +++++- .../components/DeleteBoardConfirmation.tsx | 13 +- .../views/board/components/NewCardForm.tsx | 120 +++++----- apps/web/src/views/board/index.tsx | 80 ++++--- .../views/boards/components/BoardsList.tsx | 18 +- .../views/boards/components/NewBoardForm.tsx | 37 ++-- apps/web/src/views/boards/index.tsx | 34 +-- apps/web/src/views/templates/index.tsx | 40 ---- packages/api/src/routers/board.ts | 60 ++--- packages/db/src/repository/board.repo.ts | 206 +++++++++++++++++- 12 files changed, 472 insertions(+), 226 deletions(-) create mode 100644 apps/web/src/pages/templates/[...boardId]/index.tsx delete mode 100644 apps/web/src/views/templates/index.tsx diff --git a/apps/web/src/pages/templates/[...boardId]/index.tsx b/apps/web/src/pages/templates/[...boardId]/index.tsx new file mode 100644 index 00000000..bf7fe1c0 --- /dev/null +++ b/apps/web/src/pages/templates/[...boardId]/index.tsx @@ -0,0 +1,17 @@ +import type { NextPageWithLayout } from "~/pages/_app"; +import { getDashboardLayout } from "~/components/Dashboard"; +import Popup from "~/components/Popup"; +import BoardView from "~/views/board"; + +const TemplatePage: NextPageWithLayout = () => { + return ( + <> + + + + ); +}; + +TemplatePage.getLayout = (page) => getDashboardLayout(page); + +export default TemplatePage; diff --git a/apps/web/src/pages/templates/index.tsx b/apps/web/src/pages/templates/index.tsx index 66218e5c..11250526 100644 --- a/apps/web/src/pages/templates/index.tsx +++ b/apps/web/src/pages/templates/index.tsx @@ -1,12 +1,12 @@ import type { NextPageWithLayout } from "~/pages/_app"; import { getDashboardLayout } from "~/components/Dashboard"; import Popup from "~/components/Popup"; -import TemplatesView from "~/views/templates"; +import BoardsView from "~/views/boards"; const TemplatesPage: NextPageWithLayout = () => { return ( <> - + ); diff --git a/apps/web/src/views/board/components/BoardDropdown.tsx b/apps/web/src/views/board/components/BoardDropdown.tsx index 0e896b93..3e651e89 100644 --- a/apps/web/src/views/board/components/BoardDropdown.tsx +++ b/apps/web/src/views/board/components/BoardDropdown.tsx @@ -1,23 +1,76 @@ import { t } from "@lingui/core/macro"; -import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2"; +import { + HiEllipsisHorizontal, + HiLink, + HiOutlineDocumentDuplicate, + HiOutlineTrash, +} from "react-icons/hi2"; import Dropdown from "~/components/Dropdown"; import { useModal } from "~/providers/modal"; +import { usePopup } from "~/providers/popup"; +import { api } from "~/utils/api"; -export default function BoardDropdown({ isLoading }: { isLoading: boolean }) { +export default function BoardDropdown({ + isTemplate, + isLoading, + boardPublicId, + workspacePublicId, +}: { + isTemplate: boolean; + isLoading: boolean; + boardPublicId: string; + workspacePublicId: string; +}) { const { openModal } = useModal(); + const { showPopup } = usePopup(); + const utils = api.useUtils(); + + // const makeTemplate = api.template.create.useMutation({ + // onSuccess: async () => { + // showPopup({ + // header: t`Success`, + // message: t`Template created`, + // icon: "success", + // }); + // await utils.template.getAll.invalidate(); + // }, + // onError: () => + // showPopup({ + // header: t`Error`, + // message: t`Failed to create template`, + // icon: "error", + // }), + // }); return ( { + makeTemplate.mutate({ + boardPublicId, + workspacePublicId, + }); + }, + icon: ( + + ), + }, + { + label: t`Edit board URL`, + action: () => openModal("UPDATE_BOARD_SLUG"), + icon: , + }, + ]), + { - label: t`Edit board URL`, - action: () => openModal("UPDATE_BOARD_SLUG"), - icon: , - }, - { - label: t`Delete board`, + label: isTemplate ? t`Delete template` : t`Delete board`, action: () => openModal("DELETE_BOARD"), icon: , }, diff --git a/apps/web/src/views/board/components/DeleteBoardConfirmation.tsx b/apps/web/src/views/board/components/DeleteBoardConfirmation.tsx index 207f84ef..8f8f78fe 100644 --- a/apps/web/src/views/board/components/DeleteBoardConfirmation.tsx +++ b/apps/web/src/views/board/components/DeleteBoardConfirmation.tsx @@ -1,4 +1,5 @@ import { useRouter } from "next/navigation"; +import { t } from "@lingui/core/macro"; import Button from "~/components/Button"; import { useModal } from "~/providers/modal"; @@ -6,8 +7,10 @@ import { api } from "~/utils/api"; export function DeleteBoardConfirmation({ boardPublicId, + isTemplate, }: { boardPublicId: string; + isTemplate: boolean; }) { const router = useRouter(); const { closeModal } = useModal(); @@ -15,7 +18,7 @@ export function DeleteBoardConfirmation({ const deleteBoard = api.board.delete.useMutation({ onSuccess: () => { closeModal(); - router.push(`/boards`); + router.push(isTemplate ? `/templates` : `/boards`); }, }); @@ -30,18 +33,18 @@ export function DeleteBoardConfirmation({

- Are you sure you want to delete this board? + {t`Are you sure you want to delete this ${isTemplate ? "template" : "board"}?`}

- {"This action can't be undone."} + {t`This action can't be undone.`}

diff --git a/apps/web/src/views/board/components/NewCardForm.tsx b/apps/web/src/views/board/components/NewCardForm.tsx index 05f245fe..eacc9ed4 100644 --- a/apps/web/src/views/board/components/NewCardForm.tsx +++ b/apps/web/src/views/board/components/NewCardForm.tsx @@ -11,10 +11,11 @@ import { import type { NewCardInput } from "@kan/api/types"; import { generateUID } from "@kan/shared/utils"; +import type { WorkspaceMember } from "~/components/Editor"; import Avatar from "~/components/Avatar"; import Button from "~/components/Button"; import CheckboxDropdown from "~/components/CheckboxDropdown"; -import Editor, { WorkspaceMember } from "~/components/Editor"; +import Editor from "~/components/Editor"; import Input from "~/components/Input"; import LabelIcon from "~/components/LabelIcon"; import Toggle from "~/components/Toggle"; @@ -35,12 +36,14 @@ interface QueryParams { } interface NewCardFormProps { + isTemplate: boolean; boardPublicId: string; listPublicId: string; queryParams: QueryParams; } export function NewCardForm({ + isTemplate, boardPublicId, listPublicId, queryParams, @@ -85,14 +88,13 @@ export function NewCardForm({ return () => subscription.unsubscribe(); }, [watch, saveFormState]); - const { data: boardData } = api.board.byId.useQuery(queryParams, { enabled: !!boardPublicId, }); // this adds the new created label to selected labels useEffect(() => { - const newLabelId = modalStates["NEW_LABEL_CREATED"]; + const newLabelId = modalStates.NEW_LABEL_CREATED; if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) { setValue("labelPublicIds", [...labelPublicIds, newLabelId]); } @@ -101,23 +103,23 @@ export function NewCardForm({ // this removes the deleted label from selected labels if it is selected useEffect(() => { if (boardData?.labels) { - const availableLabelIds = boardData.labels.map(label => label.publicId); - const newLabelId = modalStates["NEW_LABEL_CREATED"]; - + const availableLabelIds = boardData.labels.map((label) => label.publicId); + const newLabelId = modalStates.NEW_LABEL_CREATED; + if (newLabelId && availableLabelIds.includes(newLabelId)) { clearModalState("NEW_LABEL_CREATED"); } - - const validLabelIds = labelPublicIds.filter(id => - availableLabelIds.includes(id) || id === newLabelId + + const validLabelIds = labelPublicIds.filter( + (id) => availableLabelIds.includes(id) || id === newLabelId, ); - + if (validLabelIds.length !== labelPublicIds.length) { setValue("labelPublicIds", validLabelIds); } } - }, [boardData?.labels, labelPublicIds, modalStates["NEW_LABEL_CREATED"]]); - + }, [boardData?.labels, labelPublicIds, modalStates.NEW_LABEL_CREATED]); + const createCard = api.card.create.useMutation({ onMutate: async (args) => { await utils.board.byId.cancel(); @@ -323,15 +325,19 @@ export function NewCardForm({ saveFormState({ ...formState, description: value }); }} workspaceMembers={ - boardData?.workspace.members?.map((member): WorkspaceMember => ({ - publicId: member.publicId, - email: member.email, - user: member.user ? { - id: member.publicId, - name: member.user.name, - image: member.user.image ?? null, - } : null, - })) ?? [] + boardData?.workspace.members?.map( + (member): WorkspaceMember => ({ + publicId: member.publicId, + email: member.email, + user: member.user + ? { + id: member.publicId, + name: member.user.name, + image: member.user.image ?? null, + } + : null, + }), + ) ?? [] } /> @@ -347,42 +353,46 @@ export function NewCardForm({ -
- handleSelectMembers(item.key)} - > -
- {!memberPublicIds.length ? ( - t`Members` - ) : ( -
- {memberPublicIds.map((memberPublicId) => { - const member = formattedMembers.find( - (member) => member.key === memberPublicId, - ); + {!isTemplate && ( +
+ + handleSelectMembers(item.key) + } + > +
+ {!memberPublicIds.length ? ( + t`Members` + ) : ( +
+ {memberPublicIds.map((memberPublicId) => { + const member = formattedMembers.find( + (member) => member.key === memberPublicId, + ); - return ( - - - {member?.value - .split(" ") - .map((namePart) => - namePart.charAt(0).toUpperCase(), - ) - .join("")} + return ( + + + {member?.value + .split(" ") + .map((namePart) => + namePart.charAt(0).toUpperCase(), + ) + .join("")} + - - ); - })} -
- )} -
-
-
+ ); + })} +
+ )} +
+
+
+ )}
- +
@@ -354,31 +364,38 @@ export default function BoardPage() { )} {!boardData && !isLoading && (

- {t`Board not found`} + {t`${isTemplate ? "Template" : "Board"} not found`}

)} -
- openModal("UPDATE_BOARD_SLUG")} - isLoading={isLoading} - workspaceSlug={workspace.slug ?? ""} - boardSlug={boardData?.slug ?? ""} - /> - - member.user !== null) ?? []} - position="left" - isLoading={!boardData} - /> + {!isTemplate && ( + <> + openModal("UPDATE_BOARD_SLUG")} + isLoading={isLoading} + workspaceSlug={workspace.slug ?? ""} + boardSlug={boardData?.slug ?? ""} + /> + + {boardData && ( + member.user !== null, + )} + position="left" + isLoading={!boardData} + /> + )} + + )} - +
@@ -491,7 +513,7 @@ export default function BoardPage() { title={card.title} labels={card.labels} members={card.members} - checklists={card.checklists ?? []} + checklists={card.checklists} /> )} diff --git a/apps/web/src/views/boards/components/BoardsList.tsx b/apps/web/src/views/boards/components/BoardsList.tsx index 5f5af3a0..596e15ee 100644 --- a/apps/web/src/views/boards/components/BoardsList.tsx +++ b/apps/web/src/views/boards/components/BoardsList.tsx @@ -8,12 +8,15 @@ import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; -export function BoardsList() { +export function BoardsList({ isTemplate }: { isTemplate?: boolean }) { const { workspace } = useWorkspace(); const { openModal } = useModal(); const { data, isLoading } = api.board.all.useQuery( - { workspacePublicId: workspace.publicId }, + { + workspacePublicId: workspace.publicId, + type: isTemplate ? "template" : "regular", + }, { enabled: workspace.publicId ? true : false }, ); @@ -32,14 +35,14 @@ export function BoardsList() {

- {t`No boards`} + {t`No ${isTemplate ? "templates" : "boards"}`}

- {t`Get started by creating a new board`} + {t`Get started by creating a new ${isTemplate ? "template" : "board"}`}

); @@ -47,7 +50,10 @@ export function BoardsList() { return (
{data?.map((board) => ( - +

diff --git a/apps/web/src/views/boards/components/NewBoardForm.tsx b/apps/web/src/views/boards/components/NewBoardForm.tsx index 80383641..16af6a22 100644 --- a/apps/web/src/views/boards/components/NewBoardForm.tsx +++ b/apps/web/src/views/boards/components/NewBoardForm.tsx @@ -12,7 +12,7 @@ import Toggle from "~/components/Toggle"; import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; -import TemplateBoards, { getTemplates } from "./TemplateBoards"; +import TemplateBoards from "./TemplateBoards"; const schema = z.object({ name: z @@ -29,13 +29,15 @@ interface NewBoardInputWithTemplate { template: Template | null; } -export function NewBoardForm() { +export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) { const utils = api.useUtils(); const { closeModal } = useModal(); const { workspace } = useWorkspace(); const [showTemplates, setShowTemplates] = useState(false); - - const templates = getTemplates(); + const { data: templates } = api.board.all.useQuery( + { workspacePublicId: workspace.publicId ?? "", type: "template" }, + { enabled: !!workspace.publicId }, + ); const { register, @@ -69,6 +71,7 @@ export function NewBoardForm() { workspacePublicId: data.workspacePublicId, lists: data.template?.lists ?? [], labels: data.template?.labels ?? [], + type: isTemplate ? "template" : "regular", }); }; @@ -82,7 +85,7 @@ export function NewBoardForm() {

-

{t`New board`}

+

{t`New ${isTemplate ? "template" : "board"}`}

diff --git a/apps/web/src/views/boards/index.tsx b/apps/web/src/views/boards/index.tsx index fd796b6d..f719a5a9 100644 --- a/apps/web/src/views/boards/index.tsx +++ b/apps/web/src/views/boards/index.tsx @@ -13,7 +13,7 @@ import { BoardsList } from "./components/BoardsList"; import { ImportBoardsForm } from "./components/ImportBoardsForm"; import { NewBoardForm } from "./components/NewBoardForm"; -export default function BoardsPage() { +export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) { const { openModal, modalContentType, isOpen } = useModal(); const { availableWorkspaces, workspace, hasLoaded } = useWorkspace(); @@ -25,23 +25,27 @@ export default function BoardsPage() { return ( <> - +

- {t`Boards`} + {t`${isTemplate ? "Templates" : "Boards"}`}

- + {!isTemplate && ( + + )}
diff --git a/apps/web/src/views/templates/index.tsx b/apps/web/src/views/templates/index.tsx deleted file mode 100644 index fb681e3c..00000000 --- a/apps/web/src/views/templates/index.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import Link from "next/link"; -import { t } from "@lingui/core/macro"; - -import { PageHead } from "~/components/PageHead"; -import { useWorkspace } from "~/providers/workspace"; -import { api } from "~/utils/api"; - -export default function TemplatesView() { - const { workspace } = useWorkspace(); - const { data: templates, isLoading } = api.board.templates.useQuery( - { workspacePublicId: workspace.publicId ?? "" }, - { enabled: !!workspace.publicId }, - ); - - return ( -
- -
-

- {t`Templates`} -

-
-
- {isLoading &&
{t`Loading templates...`}
} - {!isLoading && (templates?.length ?? 0) === 0 && ( -
{t`No templates yet`}
- )} - {templates?.map((tpl) => ( - -
{tpl.name}
- - ))} -
-
- ); -} diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index f30c8a60..2dc4cd89 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -14,51 +14,6 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { assertUserInWorkspace } from "../utils/auth"; export const boardRouter = createTRPCRouter({ - templates: protectedProcedure - .meta({ - openapi: { - method: "GET", - path: "/workspaces/{workspacePublicId}/templates", - summary: "Get templates", - description: "Retrieves all templates for a given workspace", - tags: ["Boards"], - protect: true, - }, - }) - .input(z.object({ workspacePublicId: z.string().min(12) })) - .output( - z.custom< - Awaited> - >(), - ) - .query(async ({ ctx, input }) => { - const userId = ctx.user?.id; - - if (!userId) - throw new TRPCError({ - message: `User not authenticated`, - code: "UNAUTHORIZED", - }); - - const workspace = await workspaceRepo.getByPublicId( - ctx.db, - input.workspacePublicId, - ); - - if (!workspace) - throw new TRPCError({ - message: `Workspace with public ID ${input.workspacePublicId} not found`, - code: "NOT_FOUND", - }); - - await assertUserInWorkspace(ctx.db, userId, workspace.id); - - const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, { - type: "template", - }); - - return result; - }), all: protectedProcedure .meta({ openapi: { @@ -70,7 +25,12 @@ export const boardRouter = createTRPCRouter({ protect: true, }, }) - .input(z.object({ workspacePublicId: z.string().min(12) })) + .input( + z.object({ + workspacePublicId: z.string().min(12), + type: z.enum(["regular", "template"]).optional(), + }), + ) .output( z.custom>>(), ) @@ -96,7 +56,9 @@ export const boardRouter = createTRPCRouter({ await assertUserInWorkspace(ctx.db, userId, workspace.id); - const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id); + const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, { + type: input.type, + }); return result; }), @@ -116,6 +78,7 @@ export const boardRouter = createTRPCRouter({ boardPublicId: z.string().min(12), members: z.array(z.string().min(12)).optional(), labels: z.array(z.string().min(12)).optional(), + type: z.enum(["regular", "template"]).optional(), }), ) .output(z.custom>>()) @@ -147,6 +110,7 @@ export const boardRouter = createTRPCRouter({ { members: input.members ?? [], labels: input.labels ?? [], + type: input.type, }, ); @@ -222,6 +186,7 @@ export const boardRouter = createTRPCRouter({ workspacePublicId: z.string().min(12), lists: z.array(z.string().min(1)), labels: z.array(z.string().min(1)), + type: z.enum(["regular", "template"]).optional(), }), ) .output(z.custom>>()) @@ -262,6 +227,7 @@ export const boardRouter = createTRPCRouter({ name: input.name, createdBy: userId, workspaceId: workspace.id, + type: input.type, }); if (!result) diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index 2dd6dbe2..0481c7e1 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -15,13 +15,21 @@ import { } from "@kan/db/schema"; import { generateUID } from "@kan/shared/utils"; -export const getAllByWorkspaceId = (db: dbClient, workspaceId: number) => { +export const getAllByWorkspaceId = ( + db: dbClient, + workspaceId: number, + opts?: { type?: "regular" | "template" }, +) => { return db.query.boards.findMany({ columns: { publicId: true, name: true, }, - where: and(eq(boards.workspaceId, workspaceId), isNull(boards.deletedAt)), + where: and( + eq(boards.workspaceId, workspaceId), + isNull(boards.deletedAt), + opts?.type ? eq(boards.type, opts.type) : undefined, + ), }); }; @@ -42,6 +50,7 @@ export const getByPublicId = async ( filters: { members: string[]; labels: string[]; + type: "regular" | "template" | undefined; }, ) => { let cardIds: string[] = []; @@ -199,7 +208,11 @@ export const getByPublicId = async ( orderBy: [asc(lists.index)], }, }, - where: and(eq(boards.publicId, boardPublicId), isNull(boards.deletedAt)), + where: and( + eq(boards.publicId, boardPublicId), + isNull(boards.deletedAt), + eq(boards.type, filters.type ?? "regular"), + ), }); if (!board) return null; @@ -412,6 +425,8 @@ export const create = async ( workspaceId: number; importId?: number; slug: string; + type?: "regular" | "template"; + sourceBoardId?: number; }, ) => { const [result] = await db @@ -423,6 +438,8 @@ export const create = async ( workspaceId: boardInput.workspaceId, importId: boardInput.importId, slug: boardInput.slug, + type: boardInput.type ?? "regular", + sourceBoardId: boardInput.sourceBoardId, }) .returning({ id: boards.id, @@ -542,3 +559,186 @@ export const isBoardSlugAvailable = async ( return result === undefined; }; + +// Create a new board (regular/template) from a full board snapshot +export const createFromSnapshot = async ( + db: dbClient, + args: { + source: { + name: string; + labels: { publicId: string; name: string; colourCode: string | null }[]; + lists: { + name: string; + index: number; + cards: { + title: string; + description: string | null; + index: number; + labels: { + publicId: string; + name: string; + colourCode: string | null; + }[]; + checklists?: { + publicId: string; + name: string; + index: number; + items: { + publicId: string; + title: string; + completed: boolean; + index: number; + }[]; + }[]; + }[]; + }[]; + }; + workspaceId: number; + createdBy: string; + slug: string; + name?: string; + type: "regular" | "template"; + sourceBoardId?: number; + }, +) => { + return db.transaction(async (tx) => { + const [newBoard] = await tx + .insert(boards) + .values({ + publicId: generateUID(), + name: args.name ?? args.source.name, + slug: args.slug, + createdBy: args.createdBy, + workspaceId: args.workspaceId, + type: args.type, + sourceBoardId: args.sourceBoardId, + }) + .returning({ + id: boards.id, + publicId: boards.publicId, + name: boards.name, + }); + + if (!newBoard) throw new Error("Failed to create board"); + + // Labels + const srcLabels = args.source.labels; + const labelMap = new Map(); + + if (srcLabels.length) { + const inserted = await tx + .insert(labels) + .values( + srcLabels.map((l) => ({ + publicId: generateUID(), + name: l.name, + colourCode: l.colourCode ?? null, + createdBy: args.createdBy, + boardId: newBoard.id, + })), + ) + .returning({ id: labels.id }); + + for (let i = 0; i < srcLabels.length; i++) { + const src = srcLabels[i]; + + if (!src) throw new Error("Source label not found"); + + const created = inserted[i]; + if (created) labelMap.set(src.publicId, created.id); + } + } + + // Lists + const listIndexToId = new Map(); + const srcLists = [...args.source.lists].sort((a, b) => a.index - b.index); + if (srcLists.length) { + const insertedLists = await tx + .insert(lists) + .values( + srcLists.map((list) => ({ + publicId: generateUID(), + name: list.name, + createdBy: args.createdBy, + boardId: newBoard.id, + index: list.index, + })), + ) + .returning({ id: lists.id, index: lists.index }); + + for (const list of insertedLists) listIndexToId.set(list.index, list.id); + } + + // Cards, card-labels, checklists + for (const list of srcLists) { + const newListId = listIndexToId.get(list.index); + if (!newListId) continue; + const sortedCards = [...list.cards].sort((a, b) => a.index - b.index); + + for (const card of sortedCards) { + const [createdCard] = await tx + .insert(cards) + .values({ + publicId: generateUID(), + title: card.title, + description: card.description ?? "", + createdBy: args.createdBy, + listId: newListId, + index: card.index, + }) + .returning({ id: cards.id }); + + if (!createdCard) throw new Error("Failed to create card"); + + if (card.labels.length) { + const cardLabels: { cardId: number; labelId: number }[] = []; + for (const label of card.labels) { + const newLabelId = labelMap.get(label.publicId); + if (newLabelId) + cardLabels.push({ cardId: createdCard.id, labelId: newLabelId }); + } + if (cardLabels.length) + await tx.insert(cardsToLabels).values(cardLabels); + } + + if (card.checklists?.length) { + const sortedChecklists = [...card.checklists].sort( + (a, b) => a.index - b.index, + ); + for (const checklist of sortedChecklists) { + const [createdChecklist] = await tx + .insert(checklists) + .values({ + publicId: generateUID(), + name: checklist.name, + createdBy: args.createdBy, + cardId: createdCard.id, + index: checklist.index, + }) + .returning({ id: checklists.id }); + + if (!createdChecklist) continue; + + if (checklist.items.length) { + const itemValues = [...checklist.items] + .sort((a, b) => a.index - b.index) + .map((checklistItem) => ({ + publicId: generateUID(), + title: checklistItem.title, + createdBy: args.createdBy, + checklistId: createdChecklist.id, + index: checklistItem.index, + completed: !!checklistItem.completed, + })); + + if (itemValues.length) + await tx.insert(checklistItems).values(itemValues); + } + } + } + } + } + + return newBoard; + }); +};