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

@@ -1,3 +1,4 @@
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
@@ -10,9 +11,10 @@ import Button from "~/components/Button";
import Input from "~/components/Input";
import Toggle from "~/components/Toggle";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
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 +31,25 @@ interface NewBoardInputWithTemplate {
template: Template | null;
}
export function NewBoardForm() {
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
const utils = api.useUtils();
const { closeModal } = useModal();
const router = useRouter();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
const [showTemplates, setShowTemplates] = useState(false);
const { data: templates } = api.board.all.useQuery(
{ workspacePublicId: workspace.publicId ?? "", type: "template" },
{ enabled: !!workspace.publicId },
);
const templates = getTemplates();
const formattedTemplates = templates?.map((template) => ({
id: template.publicId,
sourceBoardPublicId: template.publicId,
name: template.name,
lists: template.lists.map((list) => list.name),
labels: template.labels.map((label) => label.name),
}));
const {
register,
@@ -57,18 +71,39 @@ export function NewBoardForm() {
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.board.create.useMutation({
onSuccess: async () => {
onSuccess: async (board) => {
if (!board) {
showPopup({
header: t`Error`,
message: t`Failed to create board`,
icon: "error",
});
} else {
router.push(
`${isTemplate ? "/templates" : "/boards"}/${board.publicId}`,
);
}
closeModal();
await refetchBoards();
},
onError: () => {
showPopup({
header: t`Error`,
message: t`Failed to create board`,
icon: "error",
});
},
});
const onSubmit = (data: NewBoardInputWithTemplate) => {
createBoard.mutate({
name: data.name,
workspacePublicId: data.workspacePublicId,
sourceBoardPublicId: data.template?.sourceBoardPublicId ?? undefined,
lists: data.template?.lists ?? [],
labels: data.template?.labels ?? [],
type: isTemplate ? "template" : "regular",
});
};
@@ -82,7 +117,7 @@ export function NewBoardForm() {
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">{t`New board`}</h2>
<h2 className="text-sm font-bold">{t`New ${isTemplate ? "template" : "board"}`}</h2>
<button
type="button"
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
@@ -111,21 +146,24 @@ export function NewBoardForm() {
currentBoard={currentTemplate}
setCurrentBoard={(t) => setValue("template", t)}
showTemplates={showTemplates}
customTemplates={formattedTemplates ?? []}
/>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<Toggle
label={t`Use template`}
isChecked={showTemplates}
onChange={() => {
setShowTemplates(!showTemplates);
if (!showTemplates && !currentTemplate) {
setValue("template", templates[0] ?? null);
}
}}
/>
{!isTemplate && (
<Toggle
label={t`Use template`}
isChecked={showTemplates}
onChange={() => {
setShowTemplates(!showTemplates);
if (!showTemplates && !currentTemplate) {
setValue("template", (templates?.[0] as any) ?? null);
}
}}
/>
)}
<div>
<Button type="submit" isLoading={createBoard.isPending}>
{t`Create board`}
{t`Create ${isTemplate ? "template" : "board"}`}
</Button>
</div>
</div>