feat: custom board templates (#98) (#209)

* feat: add type and sourceId columns to board schema

* chore: update _journal and add snapshot

* feat: scaffold templates page

* feat: add template pages

* feat: add template card page

* feat: add template view indicator

* fix: ensure checklists default to empty array in board view

* feat: create template from board

* feat: show custom templates in new board form

* feat: create card activity from snapshot creation

* chore: update readme and features

* chore: add translations
This commit is contained in:
Henry
2025-10-10 20:19:42 +01:00
committed by GitHub
parent 362381f516
commit a7a27e7054
41 changed files with 8296 additions and 1357 deletions

View File

@@ -4,6 +4,7 @@ import type { dbClient } from "@kan/db/client";
import type { BoardVisibilityStatus } from "@kan/db/schema";
import {
boards,
cardActivities,
cards,
cardsToLabels,
cardToWorkspaceMembers,
@@ -15,13 +16,38 @@ 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)),
with: {
lists: {
columns: {
publicId: true,
name: true,
index: true,
},
orderBy: [asc(lists.index)],
},
labels: {
columns: {
publicId: true,
name: true,
colourCode: true,
},
},
},
where: and(
eq(boards.workspaceId, workspaceId),
isNull(boards.deletedAt),
opts?.type ? eq(boards.type, opts.type) : undefined,
),
});
};
@@ -29,6 +55,7 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
const board = await db.query.boards.findFirst({
columns: {
id: true,
type: true,
},
where: eq(boards.publicId, boardPublicId),
});
@@ -42,6 +69,7 @@ export const getByPublicId = async (
filters: {
members: string[];
labels: string[];
type: "regular" | "template" | undefined;
},
) => {
let cardIds: string[] = [];
@@ -199,7 +227,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 +444,8 @@ export const create = async (
workspaceId: number;
importId?: number;
slug: string;
type?: "regular" | "template";
sourceBoardId?: number;
},
) => {
const [result] = await db
@@ -423,6 +457,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 +578,229 @@ 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<string, number>();
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<number, number>();
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");
// Create card.created activity
await tx.insert(cardActivities).values({
publicId: generateUID(),
type: "card.created",
cardId: createdCard.id,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
});
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);
// Create card.updated.label.added activities for each label
const labelActivities = cardLabels.map((cardLabel) => ({
publicId: generateUID(),
type: "card.updated.label.added" as const,
cardId: cardLabel.cardId,
labelId: cardLabel.labelId,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
}));
await tx.insert(cardActivities).values(labelActivities);
}
}
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;
// Create card.updated.checklist.added activity
await tx.insert(cardActivities).values({
publicId: generateUID(),
type: "card.updated.checklist.added",
cardId: createdCard.id,
toTitle: checklist.name,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
});
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);
// Create card.updated.checklist.item.added activities for each item
const itemActivities = itemValues.map((item) => ({
publicId: generateUID(),
type: "card.updated.checklist.item.added" as const,
cardId: createdCard.id,
toTitle: item.title,
createdBy: args.createdBy,
sourceBoardId: args.sourceBoardId,
}));
await tx.insert(cardActivities).values(itemActivities);
}
}
}
}
}
}
return newBoard;
});
};

View File

@@ -22,6 +22,7 @@ export const create = async (
commentId?: number;
fromComment?: string;
toComment?: string;
sourceBoardId?: number;
},
) => {
const [result] = await db
@@ -44,6 +45,7 @@ export const create = async (
commentId: activityInput.commentId,
fromComment: activityInput.fromComment,
toComment: activityInput.toComment,
sourceBoardId: activityInput.sourceBoardId,
})
.returning({ id: cardActivities.id });
@@ -66,6 +68,7 @@ export const bulkCreate = async (
fromDescription?: string;
toDescription?: string;
createdBy: string;
sourceBoardId?: number;
}[],
) => {
const activitiesWithPublicIds = activityInputs.map((activity) => ({