- {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;
+ });
+};