diff --git a/apps/web/src/views/board/components/BoardDropdown.tsx b/apps/web/src/views/board/components/BoardDropdown.tsx index 3e651e89..e46bdd51 100644 --- a/apps/web/src/views/board/components/BoardDropdown.tsx +++ b/apps/web/src/views/board/components/BoardDropdown.tsx @@ -23,26 +23,6 @@ export default function BoardDropdown({ 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, - }); - }, + action: () => openModal("CREATE_TEMPLATE"), icon: ( ), diff --git a/apps/web/src/views/board/components/NewTemplateForm.tsx b/apps/web/src/views/board/components/NewTemplateForm.tsx new file mode 100644 index 00000000..308664ee --- /dev/null +++ b/apps/web/src/views/board/components/NewTemplateForm.tsx @@ -0,0 +1,138 @@ +import { useRouter } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { t } from "@lingui/core/macro"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { HiXMark } from "react-icons/hi2"; +import { z } from "zod"; + +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"; + +const schema = z.object({ + name: z + .string() + .min(1, { message: t`Template name is required` }) + .max(100, { message: t`Template name cannot exceed 100 characters` }), + workspacePublicId: z.string(), + sourceBoardPublicId: z.string(), +}); + +interface NewBoardInputWithTemplate { + name: string; + workspacePublicId: string; + sourceBoardPublicId: string; +} + +export function NewTemplateForm({ + sourceBoardPublicId, + workspacePublicId, + sourceBoardName, +}: { + sourceBoardPublicId: string; + workspacePublicId: string; + sourceBoardName: string; +}) { + const router = useRouter(); + const { closeModal } = useModal(); + const { showPopup } = usePopup(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: sourceBoardName, + workspacePublicId, + sourceBoardPublicId, + }, + }); + + const createBoard = api.board.create.useMutation({ + onSuccess: (newTemplate) => { + if (!newTemplate) { + showPopup({ + header: t`Unable to create template`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + } else { + router.push(`/templates/${newTemplate.publicId}`); + showPopup({ + header: t`Template created`, + message: t`Template created successfully`, + icon: "success", + }); + } + closeModal(); + }, + onError: () => { + showPopup({ + header: t`Unable to create template`, + message: t`Please try again later, or contact customer support.`, + icon: "error", + }); + }, + }); + + const onSubmit = (data: NewBoardInputWithTemplate) => { + createBoard.mutate({ + name: data.name, + workspacePublicId: data.workspacePublicId, + sourceBoardPublicId: data.sourceBoardPublicId, + lists: [], + labels: [], + type: "template", + }); + }; + + useEffect(() => { + const titleElement: HTMLElement | null = + document.querySelector("#name"); + if (titleElement) titleElement.focus(); + }, []); + + return ( +
+
+
+

{t`New template`}

+ +
+ { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + /> +
+
+
+ +
+
+
+ ); +} diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 880aa593..20a9d1f2 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -36,6 +36,7 @@ import Filters from "./components/Filters"; import List from "./components/List"; import { NewCardForm } from "./components/NewCardForm"; import { NewListForm } from "./components/NewListForm"; +import { NewTemplateForm } from "./components/NewTemplateForm"; import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton"; import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm"; import VisibilityButton from "./components/VisibilityButton"; @@ -59,8 +60,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { : params.boardId : null; - console.log("params", params); - const updateBoard = api.board.update.useMutation(); const { register, handleSubmit, setValue } = useForm({ @@ -341,6 +340,17 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { queryParams={queryParams} /> + + + + ); }; diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 2dc4cd89..091bc7ac 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -187,6 +187,7 @@ export const boardRouter = createTRPCRouter({ lists: z.array(z.string().min(1)), labels: z.array(z.string().min(1)), type: z.enum(["regular", "template"]).optional(), + sourceBoardPublicId: z.string().min(12).optional(), }), ) .output(z.custom>>()) @@ -212,6 +213,65 @@ export const boardRouter = createTRPCRouter({ await assertUserInWorkspace(ctx.db, userId, workspace.id); + // If sourceBoardPublicId is provided, clone the source board + if (input.sourceBoardPublicId) { + const sourceBoard = await boardRepo.getByPublicId( + ctx.db, + input.sourceBoardPublicId, + { + members: [], + labels: [], + type: undefined, + }, + ); + + if (!sourceBoard) + throw new TRPCError({ + message: `Source board with public ID ${input.sourceBoardPublicId} not found`, + code: "NOT_FOUND", + }); + + // Verify the source board belongs to the same workspace + const sourceWorkspace = await workspaceRepo.getByPublicId( + ctx.db, + sourceBoard.workspace.publicId, + ); + + if (!sourceWorkspace || sourceWorkspace.id !== workspace.id) + throw new TRPCError({ + message: `Source board does not belong to this workspace`, + code: "FORBIDDEN", + }); + + let slug = generateSlug(input.name); + + const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, { + slug, + workspaceId: workspace.id, + }); + + if (!isSlugUnique || input.type === "template") + slug = `${slug}-${generateUID()}`; + + const result = await boardRepo.createFromSnapshot(ctx.db, { + source: sourceBoard, + workspaceId: workspace.id, + createdBy: userId, + slug, + name: input.name, + type: input.type ?? "regular", + }); + + if (!result) + throw new TRPCError({ + message: `Failed to create board from source`, + code: "INTERNAL_SERVER_ERROR", + }); + + return result; + } + + // Otherwise, create a new board with provided lists and labels let slug = generateSlug(input.name); const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, { @@ -219,7 +279,8 @@ export const boardRouter = createTRPCRouter({ workspaceId: workspace.id, }); - if (!isSlugUnique) slug = `${slug}-${generateUID()}`; + if (!isSlugUnique || input.type === "template") + slug = `${slug}-${generateUID()}`; const result = await boardRepo.create(ctx.db, { publicId: generateUID(), @@ -236,7 +297,7 @@ export const boardRouter = createTRPCRouter({ code: "INTERNAL_SERVER_ERROR", }); - if (input.lists?.length) { + if (input.lists.length) { const listInputs = input.lists.map((list, index) => ({ publicId: generateUID(), name: list, @@ -248,7 +309,7 @@ export const boardRouter = createTRPCRouter({ await listRepo.bulkCreate(ctx.db, listInputs); } - if (input.labels?.length) { + if (input.labels.length) { const labelInputs = input.labels.map((label, index) => ({ publicId: generateUID(), name: label,