feat: add template pages

This commit is contained in:
Henry
2025-10-08 21:29:24 +01:00
parent 734e0bbfc5
commit 18e85ef6be
12 changed files with 472 additions and 226 deletions

View File

@@ -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 (
<>
<BoardView isTemplate />
<Popup />
</>
);
};
TemplatePage.getLayout = (page) => getDashboardLayout(page);
export default TemplatePage;

View File

@@ -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 (
<>
<TemplatesView />
<BoardsView isTemplate />
<Popup />
</>
);

View File

@@ -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 (
<Dropdown
disabled={isLoading}
items={[
...(isTemplate
? []
: [
{
label: t`Make template`,
action: () => {
makeTemplate.mutate({
boardPublicId,
workspacePublicId,
});
},
icon: (
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
),
},
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
]),
{
label: t`Edit board URL`,
action: () => openModal("UPDATE_BOARD_SLUG"),
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
{
label: t`Delete board`,
label: isTemplate ? t`Delete template` : t`Delete board`,
action: () => openModal("DELETE_BOARD"),
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
},

View File

@@ -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({
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
Are you sure you want to delete this board?
{t`Are you sure you want to delete this ${isTemplate ? "template" : "board"}?`}
</h2>
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
{"This action can't be undone."}
{t`This action can't be undone.`}
</p>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button onClick={() => closeModal()} variant="secondary">
Cancel
{t`Cancel`}
</Button>
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
Delete
{t`Delete`}
</Button>
</div>
</div>

View File

@@ -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,
}),
) ?? []
}
/>
</div>
@@ -347,42 +353,46 @@ export function NewCardForm({
</div>
</CheckboxDropdown>
</div>
<div className="w-fit">
<CheckboxDropdown
items={formattedMembers}
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!memberPublicIds.length ? (
t`Members`
) : (
<div className="flex -space-x-1 overflow-hidden">
{memberPublicIds.map((memberPublicId) => {
const member = formattedMembers.find(
(member) => member.key === memberPublicId,
);
{!isTemplate && (
<div className="w-fit">
<CheckboxDropdown
items={formattedMembers}
handleSelect={(_groupKey, item) =>
handleSelectMembers(item.key)
}
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
{!memberPublicIds.length ? (
t`Members`
) : (
<div className="flex -space-x-1 overflow-hidden">
{memberPublicIds.map((memberPublicId) => {
const member = formattedMembers.find(
(member) => member.key === memberPublicId,
);
return (
<span
key={member?.key}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
>
<span className="text-[8px] font-medium leading-none text-white">
{member?.value
.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
return (
<span
key={member?.key}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
>
<span className="text-[8px] font-medium leading-none text-white">
{member?.value
.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
</span>
</span>
</span>
);
})}
</div>
)}
</div>
</CheckboxDropdown>
</div>
);
})}
</div>
)}
</div>
</CheckboxDropdown>
</div>
)}
<div className="w-fit">
<CheckboxDropdown
items={formattedLabels}

View File

@@ -38,7 +38,7 @@ import VisibilityButton from "./components/VisibilityButton";
type PublicListId = string;
export default function BoardPage() {
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
const params = useParams() as { boardId: string[] } | null;
const router = useRouter();
const utils = api.useUtils();
@@ -67,10 +67,16 @@ export default function BoardPage() {
});
};
const queryParams = {
const queryParams: {
boardPublicId: string;
members: string[];
labels: string[];
type: "regular" | "template";
} = {
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
type: isTemplate ? "template" : "regular",
};
const {
@@ -208,7 +214,7 @@ export default function BoardPage() {
};
const onDragEnd = ({
source,
source: _source,
destination,
draggableId,
type,
@@ -241,7 +247,10 @@ export default function BoardPage() {
modalSize="sm"
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
>
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
<DeleteBoardConfirmation
isTemplate={!!isTemplate}
boardPublicId={boardId ?? ""}
/>
</Modal>
<Modal
@@ -259,6 +268,7 @@ export default function BoardPage() {
isVisible={isOpen && modalContentType === "NEW_CARD"}
>
<NewCardForm
isTemplate={!!isTemplate}
boardPublicId={boardId ?? ""}
listPublicId={selectedPublicListId}
queryParams={queryParams}
@@ -328,7 +338,7 @@ export default function BoardPage() {
return (
<>
<PageHead
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
title={`${(boardData?.name ?? isTemplate) ? t`Board` : t`Template`} | ${workspace.name}`}
/>
<div className="relative flex h-full flex-col">
<PatternedBackground />
@@ -354,31 +364,38 @@ export default function BoardPage() {
)}
{!boardData && !isLoading && (
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
{t`Board not found`}
{t`${isTemplate ? "Template" : "Board"} not found`}
</p>
)}
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
<UpdateBoardSlugButton
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
/>
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
isLoading={!boardData}
isAdmin={workspace.role === "admin"}
/>
<Filters
labels={boardData?.labels ?? []}
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
position="left"
isLoading={!boardData}
/>
{!isTemplate && (
<>
<UpdateBoardSlugButton
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
isLoading={isLoading}
workspaceSlug={workspace.slug ?? ""}
boardSlug={boardData?.slug ?? ""}
/>
<VisibilityButton
visibility={boardData?.visibility ?? "private"}
boardPublicId={boardId ?? ""}
boardSlug={boardData?.slug ?? ""}
queryParams={queryParams}
isLoading={!boardData}
isAdmin={workspace.role === "admin"}
/>
{boardData && (
<Filters
labels={boardData.labels}
members={boardData.workspace.members.filter(
(member) => member.user !== null,
)}
position="left"
isLoading={!boardData}
/>
)}
</>
)}
<Button
iconLeft={
<HiOutlinePlusSmall
@@ -393,7 +410,12 @@ export default function BoardPage() {
>
{t`New list`}
</Button>
<BoardDropdown isLoading={!boardData} />
<BoardDropdown
isTemplate={!!isTemplate}
isLoading={!boardData}
boardPublicId={boardId ?? ""}
workspacePublicId={workspace.publicId}
/>
</div>
</div>
@@ -491,7 +513,7 @@ export default function BoardPage() {
title={card.title}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}
checklists={card.checklists}
/>
</Link>
)}

View File

@@ -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() {
<div className="flex flex-col items-center">
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
{t`No boards`}
{t`No ${isTemplate ? "templates" : "boards"}`}
</p>
<p className="text-[14px] text-light-900 dark:text-dark-900">
{t`Get started by creating a new board`}
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
</p>
</div>
<Button onClick={() => openModal("NEW_BOARD")}>
{t`Create new board`}
{t`Create new ${isTemplate ? "template" : "board"}`}
</Button>
</div>
);
@@ -47,7 +50,10 @@ export function BoardsList() {
return (
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
{data?.map((board) => (
<Link key={board.publicId} href={`boards/${board.publicId}`}>
<Link
key={board.publicId}
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
>
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
<PatternedBackground />
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">

View File

@@ -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() {
<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"
@@ -113,19 +116,21 @@ export function NewBoardForm() {
showTemplates={showTemplates}
/>
<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>

View File

@@ -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 (
<>
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
<PageHead
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? "Workspace"}`}
/>
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Boards`}
{t`${isTemplate ? "Templates" : "Boards"}`}
</h1>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
{t`Import`}
</Button>
{!isTemplate && (
<Button
type="button"
variant="secondary"
onClick={() => openModal("IMPORT_BOARDS")}
iconLeft={
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
}
>
{t`Import`}
</Button>
)}
<Button
type="button"
variant="primary"
@@ -67,7 +71,7 @@ export default function BoardsPage() {
modalSize="sm"
isVisible={isOpen && modalContentType === "NEW_BOARD"}
>
<NewBoardForm />
<NewBoardForm isTemplate={!!isTemplate} />
</Modal>
<Modal
@@ -86,7 +90,7 @@ export default function BoardsPage() {
</>
<div className="flex h-full flex-row">
<BoardsList />
<BoardsList isTemplate={!!isTemplate} />
</div>
</div>
</>

View File

@@ -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 (
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
<PageHead title={t`Templates | ${workspace.name ?? "Workspace"}`} />
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Templates`}
</h1>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
{isLoading && <div>{t`Loading templates...`}</div>}
{!isLoading && (templates?.length ?? 0) === 0 && (
<div className="text-sm text-light-900 dark:text-dark-900">{t`No templates yet`}</div>
)}
{templates?.map((tpl) => (
<Link
key={tpl.publicId}
href={`/boards/${workspace.slug}/${tpl.publicId}`}
className="rounded border border-light-300 p-3 text-sm hover:bg-light-100 dark:border-dark-300 dark:hover:bg-dark-100"
>
<div className="font-medium">{tpl.name}</div>
</Link>
))}
</div>
</div>
);
}

View File

@@ -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<ReturnType<typeof boardRepo.getTemplatesByWorkspaceId>>
>(),
)
.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<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
)
@@ -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<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
@@ -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<Awaited<ReturnType<typeof boardRepo.create>>>())
@@ -262,6 +227,7 @@ export const boardRouter = createTRPCRouter({
name: input.name,
createdBy: userId,
workspaceId: workspace.id,
type: input.type,
});
if (!result)

View File

@@ -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<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");
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;
});
};